Merge pull request #3733 from storytold/land/3361

G4: paragraph Auto Leading drives the Leading field, leading nudge and placeholder fill
This commit is contained in:
BFlat
2026-10-23 07:25:29 +07:00
committed by GitHub
9 changed files with 201 additions and 25 deletions
+29
View File
@@ -717,6 +717,19 @@ impl ParaStyle {
t.position = within(t.position, 0.0, 100_000.0);
}
}
/// The leading of characters styled `st` in this paragraph, as the layout sets it: their own
/// leading, or for Auto leading the paragraph's Justification › Auto Leading share of their
/// size (exactly [`CharStyle::effective_leading`] at the default 120%, or when the share is
/// not a finite number).
pub fn effective_leading(&self, st: &CharStyle) -> f64 {
let auto = self.justification.auto_leading;
if st.leading.is_some() || auto == JustificationOptions::default().auto_leading {
return st.effective_leading();
}
let l = self.justification.auto_leading_for(st.size);
if l.is_finite() { l } else { st.effective_leading() }
}
}
/// [`ParaStyle`] as saved. [`Justify::Auto`] is written as the alignment it has in the paragraph
@@ -1989,6 +2002,22 @@ mod tests {
assert_eq!(serde_json::from_value::<CharStyle>(serde_json::to_value(&farsi).unwrap()).unwrap(), farsi);
}
/// Auto leading follows the paragraph's Auto Leading; a set leading doesn't.
#[test]
fn paragraph_effective_leading_follows_auto_leading() {
let st = CharStyle { size: 10.0, leading: None, ..CharStyle::default() };
let mut para = ParaStyle::default();
assert_eq!(para.effective_leading(&st), st.effective_leading(), "default 120%");
para.justification.auto_leading = 200.0;
assert_eq!(para.effective_leading(&st), 20.0);
para.justification.auto_leading = 0.0;
assert_eq!(para.effective_leading(&st), 0.0);
let set = CharStyle { leading: Some(14.0), ..st.clone() };
assert_eq!(para.effective_leading(&set), 14.0);
para.justification.auto_leading = f64::NAN;
assert_eq!(para.effective_leading(&st), st.effective_leading(), "not a number");
}
/// A file can't feed layout justification values `text.setFormat` would refuse: each is
/// clamped to its range, one that isn't finite takes its default, and a Minimum, Desired and
/// Maximum out of order go back to their defaults.
+25 -2
View File
@@ -1029,12 +1029,35 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> {
1.0
};
let mut runs = edit::slice_runs(&t.runs, a, b);
for run in &mut runs {
// Auto leading steps from the leading its paragraph's Justification › Auto Leading
// sets, so each run covers one paragraph and knows which.
let mut paras = vec![];
if matches!(step, Step::Leading) {
let plain = t.plain_text();
let mut para = plain.get(..a).map_or(0, |s| s.bytes().filter(|&c| c == b'\n').count());
let mut split = Vec::with_capacity(runs.len());
for run in runs {
if run.inline.is_some() || !run.text.contains('\n') {
paras.push(para);
para += run.text.bytes().filter(|&c| c == b'\n').count();
split.push(run);
continue;
}
for piece in run.text.split_inclusive('\n') {
paras.push(para);
para += usize::from(piece.ends_with('\n'));
split.push(TextRun::new(piece, run.style.clone()));
}
}
runs = split;
}
for (i, run) in runs.iter_mut().enumerate() {
let st = &mut run.style;
match step {
Step::Size => st.size = super::typecmd::local_type_value(t, st.size * points + inc, (0.1, 1296.0), false, C)?,
Step::Leading => {
st.leading = Some(super::typecmd::local_type_value(t, st.effective_leading() * points + inc, (0.1, 5000.0), false, C)?);
let leading = t.para_at(paras.get(i).copied().unwrap_or(0)).effective_leading(st);
st.leading = Some(super::typecmd::local_type_value(t, leading * points + inc, (0.1, 5000.0), false, C)?);
}
Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0),
Step::Kerning => st.kerning = Some((st.kerning.unwrap_or(0.0) + inc).clamp(-1000.0, 10000.0)),
+3 -1
View File
@@ -520,7 +520,9 @@ pub(crate) fn fill_with_placeholder(t: &mut TextObject) {
TextKind::Area { frame } => {
let b = frame.bounds().unwrap_or_default();
let chars_per_line = (b.width() / (style.size * 0.5)).max(1.0);
let lines = (b.height() / style.effective_leading()).max(1.0);
// The first paragraph's Auto Leading sets the leading of Auto type (a leading of 0
// counts as a tenth of a point a line).
let lines = (b.height() / t.para_at(0).effective_leading(&style).max(0.1)).max(1.0);
let want = (chars_per_line * lines) as usize;
let mut out = String::new();
for (i, w) in PLACEHOLDER.split(' ').cycle().enumerate() {
+32
View File
@@ -1060,6 +1060,38 @@ fn type_increments_step_the_type() {
assert_eq!((style_at(&s, id, 0).size, style_at(&s, id, 6).size), (22.0, 25.0));
}
/// Auto leading steps from the leading its paragraph's Justification › Auto Leading sets, which
/// is what the layout shows; a run spanning paragraphs with different Auto Leading is split.
/// Fill with Placeholder Text counts lines at that leading too.
#[test]
fn auto_leading_steps_and_fills_from_the_paragraph_auto_leading() {
let mut s = new_doc();
let id = NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "One\nTwo", "size": 20})).unwrap()["id"].as_u64().unwrap());
s.execute("text.setFormat", &json!({"ids": [id.0], "autoLeading": 200, "start": 5, "end": 5})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("type.step", &json!({"attribute": "leading"})).unwrap();
let lead = |s: &Session, byte| style_at(s, id, byte).leading.unwrap();
assert!((lead(&s, 0) - (20.0 * 1.2 + 2.0)).abs() < 1e-9, "default Auto Leading 120%");
assert!((lead(&s, 3) - (20.0 * 1.2 + 2.0)).abs() < 1e-9, "the line break is the first paragraph's");
assert!((lead(&s, 5) - (20.0 * 2.0 + 2.0)).abs() < 1e-9, "Auto Leading 200%");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((style_at(&s, id, 0).leading, style_at(&s, id, 5).leading), (None, None), "one undo step");
let area = |s: &mut Session, x: f64| {
let r = s.execute("text.create", &json!({"x": x, "y": 10, "text": "x", "size": 10, "area": {"width": 100, "height": 200}})).unwrap();
NodeId(r["id"].as_u64().unwrap())
};
let (normal, airy) = (area(&mut s, 10.0), area(&mut s, 200.0));
s.execute("text.setFormat", &json!({"ids": [airy.0], "autoLeading": 240})).unwrap();
s.execute("type.fillPlaceholder", &json!({"ids": [normal.0, airy.0]})).unwrap();
let len = |s: &Session, id: NodeId| match s.doc().unwrap().doc.node(id).map(|n| &n.kind) {
Some(vectorcraft_doc::NodeKind::Text(t)) => t.plain_text().len(),
_ => panic!("not text"),
};
let (n, a) = (len(&s, normal), len(&s, airy));
assert!(a * 3 < n * 2 && a * 3 > n, "half as many lines at twice the leading: {a} vs {n}");
}
/// Cmd/Ctrl+Alt+Shift+. and , step the font size by five times the Size/Leading increment, on
/// the selected type objects and on the Type tool's selected text.
#[test]
+86 -14
View File
@@ -58,14 +58,20 @@ pub(crate) fn first_selected_text(app: &VectorcraftApp) -> Option<&vectorcraft_d
/// caret's style at a caret), else every run of the selected type, groups' included. `None` (a
/// blank field) where they differ, or with no type.
pub(crate) fn shared<T: PartialEq>(app: &VectorcraftApp, f: impl Fn(&CharStyle) -> T) -> Option<T> {
shared_styles(app, |_, style| Some(f(style)))
shared_styles(app, |_, _, style| Some(f(style)))
}
/// Dimensional controls use document units and tolerate arithmetic roundoff when comparing
/// compensated runs. Keep raw styles available to font/paragraph controls and style definitions,
/// even if a collapsed affine prevents editing dimensions.
fn shared_effective(app: &VectorcraftApp, f: impl Fn(&CharStyle) -> f64) -> Option<f64> {
shared_styles(app, |t, style| t.effective_char_style(style).map(|s| Metric(f(&s)))).map(|v| v.0)
shared_styles(app, |t, _, style| t.effective_char_style(style).map(|s| Metric(f(&s)))).map(|v| v.0)
}
/// The Leading shown: each character's own, or for Auto leading the share of its size its
/// paragraph's Justification › Auto Leading sets, as the layout uses.
fn shared_leading(app: &VectorcraftApp) -> Option<f64> {
shared_styles(app, |t, para, style| t.effective_char_style(style).map(|s| Metric(para.effective_leading(&s)))).map(|v| v.0)
}
pub(crate) struct Metric(pub(crate) f64);
@@ -76,7 +82,12 @@ impl PartialEq for Metric {
}
}
fn shared_styles<T: PartialEq>(app: &VectorcraftApp, f: impl Fn(&vectorcraft_doc::TextObject, &CharStyle) -> Option<T>) -> Option<T> {
/// `f` of each character style in the selection, with its text object and the paragraph the
/// characters are in (a run that spans paragraphs counts once for each).
fn shared_styles<T: PartialEq>(
app: &VectorcraftApp,
f: impl Fn(&vectorcraft_doc::TextObject, &vectorcraft_doc::ParaStyle, &CharStyle) -> Option<T>,
) -> Option<T> {
let st = app.session.active()?;
let text = |id: NodeId| match st.doc.node(id).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => Some(t),
@@ -86,21 +97,15 @@ fn shared_styles<T: PartialEq>(app: &VectorcraftApp, f: impl Fn(&vectorcraft_doc
if let Some((id, a, b)) = text_editing(app) {
let t = text(id)?;
if b <= a {
return f(t, &vectorcraft_text::edit::insertion_style(&t.runs, a, b));
}
let mut end = 0;
for r in &t.runs {
let start = end;
end += r.text.len();
if start < b && end > a {
acc.add(f(t, &r.style));
}
let para = t.plain_text().get(..a).map_or(0, |s| s.bytes().filter(|&c| c == b'\n').count());
return f(t, t.para_at(para), &vectorcraft_text::edit::insertion_style(&t.runs, a, b));
}
each_paragraph_run(t, Some((a, b)), |para, style| acc.add(f(t, para, style)));
} else {
for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
n.walk(&mut |c| {
if let NodeKind::Text(t) = &c.kind {
t.runs.iter().for_each(|r| acc.add(f(t, &r.style)));
each_paragraph_run(t, None, |para, style| acc.add(f(t, para, style)));
}
});
}
@@ -108,6 +113,43 @@ fn shared_styles<T: PartialEq>(app: &VectorcraftApp, f: impl Fn(&vectorcraft_doc
acc.value().flatten()
}
/// `f` of the paragraph and style of each piece of `t`'s runs that lies in one paragraph (a
/// paragraph's closing line break belongs to it): the pieces within bytes `a..b`, or with no
/// range every run's, empty ones included. With no range, a final empty paragraph (the text ends
/// in a line break) counts too, with the style of the run before it, as the caret there types.
fn each_paragraph_run<'a>(
t: &'a vectorcraft_doc::TextObject,
range: Option<(usize, usize)>,
mut f: impl FnMut(&'a vectorcraft_doc::ParaStyle, &'a CharStyle),
) {
let (mut end, mut para) = (0, 0);
// The style of the last character and whether it is a line break: the paragraph after it has
// no characters of its own.
let mut last: Option<(&'a CharStyle, bool)> = None;
for r in &t.runs {
if r.text.is_empty() {
if range.is_none_or(|(a, b)| a < end && end < b) {
f(t.para_at(para), &r.style);
}
continue;
}
for piece in r.text.split_inclusive('\n') {
let start = end;
end += piece.len();
if range.is_none_or(|(a, b)| start < b && end > a) {
f(t.para_at(para), &r.style);
}
para += usize::from(piece.ends_with('\n'));
last = Some((&r.style, piece.ends_with('\n')));
}
}
if range.is_none()
&& let Some((style, true)) = last
{
f(t.para_at(para), style);
}
}
/// The value a run of styles share so far ([`shared`]).
pub(crate) enum Shared<T> {
None,
@@ -461,7 +503,7 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: &
egui::Grid::new(id).num_columns(2).spacing([6.0, 4.0]).show(ui, |ui| {
cell(ui, "T", tl!("Font Size"), |ui| size_field(app, ui, "ch-size", fw));
cell(ui, "A↕", tl!("Leading"), |ui| {
let leading = shared_effective(app, CharStyle::effective_leading);
let leading = shared_leading(app);
let auto = shared(app, |s| s.leading.is_none()) == Some(true);
match widgets::spin_field_auto(ui, "ch-lead", leading, Some(auto), unit, fw, 1.0, 0.1, &SIZE_PRESETS) {
Some(SpinPick::Value(v)) => style(app, json!({"leading": v})),
@@ -910,6 +952,36 @@ mod tests {
}
}
/// A final empty paragraph counts: with the object selected, its Auto Leading differing from
/// the paragraph before it shows Leading as mixed.
#[test]
fn leading_field_counts_the_final_empty_paragraph() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "One\n", "size": 10})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [id]})).unwrap();
assert!((shared_leading(&app).unwrap() - 12.0).abs() < 1e-9);
app.run("text.setFormat", json!({"autoLeading": 200, "start": 4, "end": 4})).unwrap();
assert_eq!(shared_leading(&app), None, "the empty last paragraph differs");
}
/// Auto leading shows the leading the paragraph's Justification › Auto Leading sets.
#[test]
fn leading_field_shows_the_paragraph_auto_leading() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "One\nTwo", "size": 10})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [id]})).unwrap();
assert!((shared_leading(&app).unwrap() - 12.0).abs() < 1e-9);
app.run("text.setFormat", json!({"autoLeading": 200, "start": 5, "end": 5})).unwrap();
assert_eq!(shared_leading(&app), None, "the paragraphs differ");
assert_eq!(shared(&app, |s| s.leading.is_none()), Some(true), "still Auto");
app.run("text.setFormat", json!({"autoLeading": 200})).unwrap();
assert!((shared_leading(&app).unwrap() - 20.0).abs() < 1e-9);
app.run("text.setStyle", json!({"leading": 15})).unwrap();
assert!((shared_leading(&app).unwrap() - 15.0).abs() < 1e-9, "a set leading is its own");
}
#[test]
fn scaled_collapsed_text_keeps_nondimensional_controls_available() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
+6 -1
View File
File diff suppressed because one or more lines are too long
+3 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3 -2
View File
File diff suppressed because one or more lines are too long