mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
@@ -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.
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
+3
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3
-2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user