From 81dca05eb1dbd5087133843afb02782c88d52680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ju=CC=88rg=20Lehni?= Date: Thu, 22 Oct 2026 10:26:50 +0200 Subject: [PATCH 1/2] Render: moved stretched type redraws where its strokes reach Follows #3484: characters' strokes stretch with the type, past what its scale factor covers. --- crates/doc/src/text.rs | 15 +++++++-------- crates/render/src/tests_changed.rs | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 62bca0ba1..5add7b34c 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -1713,18 +1713,17 @@ impl TextObject { /// How far the characters' strokes paint past their glyphs, in document space: they scale /// with the type. pub fn char_stroke_outset(&self) -> f64 { - self.char_strokes(CharStyle::stroke_outset) + let k = crate::node::Scaling::factor(self.xf).unwrap_or(1.0); + self.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k } /// How far the characters' strokes can paint past their glyphs, their miter spikes and square /// caps included ([`StrokeLayer::reach`]), in document space: what a change of the type must - /// redraw. The visual bounds take [`Self::char_stroke_outset`]. + /// redraw. They stretch with the type, so this is as far as its transform takes them on either + /// axis. The visual bounds take [`Self::char_stroke_outset`]. pub fn char_stroke_reach(&self) -> f64 { - self.char_strokes(|style| if style.has_stroke() { style.stroke_layer().reach() } else { 0.0 }) - } - /// The farthest `outset` of the characters' styles, scaled with the type. - fn char_strokes(&self, outset: impl Fn(&CharStyle) -> f64) -> f64 { - let k = crate::node::Scaling::factor(self.xf).unwrap_or(1.0); - self.runs.iter().map(|r| outset(&r.style)).fold(0.0, f64::max) * k + let reach = self.runs.iter().filter(|r| r.style.has_stroke()).map(|r| r.style.stroke_layer().reach()).fold(0.0, f64::max); + let [a, b, c, d, ..] = self.xf.as_coeffs(); + reach * (a.abs() + c.abs()).max(b.abs() + d.abs()) } pub fn transform(&mut self, a: Affine) { self.xf = a * self.xf; diff --git a/crates/render/src/tests_changed.rs b/crates/render/src/tests_changed.rs index 21b75c158..108e7604e 100644 --- a/crates/render/src/tests_changed.rs +++ b/crates/render/src/tests_changed.rs @@ -235,3 +235,26 @@ fn an_edit_while_a_pattern_is_edited_changes_everything() { new.node_mut(loose).unwrap().transform(Affine::translate((7.0, 3.0)), false); assert_eq!(changed(&old, &new), Changed::Everything); } + +/// Type stretched more on one axis than its area grows: its characters' strokes stretch with it. +#[test] +fn moving_stretched_type_changes_where_its_stretched_strokes_reach() { + let mut old = Document::new(110.0, 80.0); + let l = old.layers[0].id; + let style = vectorcraft_doc::text::CharStyle { + size: 4.0, + stroke: Paint::solid(Color::BLACK), + stroke_width: 1.0, + stroke_join: vectorcraft_doc::LineJoin::Miter, + stroke_miter_limit: 10.0, + ..Default::default() + }; + let mut t = vectorcraft_doc::text::TextObject::point(Point::ZERO, "VAWV", style); + t.cached_bounds = Some(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds); + // Its area stays, so its scale factor is 1, while it grows ten times as wide. + t.xf = Affine::translate((10.0, 40.0)) * Affine::scale_non_uniform(10.0, 0.1); + let text = old.insert(Some(l), 0, Node::new(NodeId(0), NodeKind::Text(Box::new(t)))).unwrap(); + let mut new = old.clone(); + new.node_mut(text).unwrap().transform(Affine::translate((9.0, 7.0)), false); + area(redraw(&old, &new)); +} From 5498b62a3de6487af78675eb8183ebfb25cf19f1 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 22 Oct 2026 15:35:44 +0700 Subject: [PATCH 2/2] G4: Mixed Justification dialog fields Squashed from #3542 onto main. --- crates/ui-egui/src/dialogs/justification.rs | 84 +++++++++++++++------ crates/ui-egui/src/panels/paragraph.rs | 23 +++++- 2 files changed, 84 insertions(+), 23 deletions(-) diff --git a/crates/ui-egui/src/dialogs/justification.rs b/crates/ui-egui/src/dialogs/justification.rs index 758523ce2..d00d12277 100644 --- a/crates/ui-egui/src/dialogs/justification.rs +++ b/crates/ui-egui/src/dialogs/justification.rs @@ -1,9 +1,10 @@ //! Paragraph panel menu › Justification… (or `ui.justification`): Word Spacing, Letter Spacing and //! Glyph Scaling (each Minimum, Desired and Maximum, in percent), Auto Leading and Single Word //! Justification of the paragraphs the Paragraph panel acts on. The dialog opens on the values the -//! Paragraph panel shows and previews them on the canvas. OK runs `text.setFormat` with the values -//! that changed, as one undo step; paragraphs that differ keep the values left alone. OK is -//! disabled while a row's Minimum exceeds its Desired value or Desired its Maximum. +//! Paragraph panel shows (blank fields where paragraphs in scope differ) and previews them on the +//! canvas. OK runs `text.setFormat` with the values that changed, as one undo step; paragraphs +//! that differ keep the values left alone. OK is disabled while a row's Minimum exceeds its Desired +//! value or Desired its Maximum. //! //! Fields: `wordMin`, `wordDesired`, `wordMax` (0..1000), `letterMin`, `letterDesired`, //! `letterMax` (-100..500), `glyphMin`, `glyphDesired`, `glyphMax` (50..200), `autoLeading` @@ -65,22 +66,32 @@ fn single_word_name(s: SingleWordJustify) -> &'static str { /// Open Justification on the paragraph attributes the Paragraph panel shows. pub fn open(app: &mut VectorcraftApp) -> Result { - let (_, para) = crate::panels::character::text_style(app).ok_or("select some type first")?; + crate::panels::character::text_style(app).ok_or("select some type first")?; // While typing, the dialog acts on the paragraphs of the text being edited. let mut targets = json!({}); if let Some((id, a, b)) = crate::panels::character::text_editing(app) { crate::panels::character::end_typing(app); targets = json!({"ids": [id.0], "start": a, "end": b}); } - let j = ¶.justification; let mut values = Map::new(); - for ((_, prefix, _), v) in ROWS.iter().zip([j.word_spacing, j.letter_spacing, j.glyph_scaling]) { - for ((suffix, _), x) in COLUMNS.iter().zip(v) { - values.insert(format!("{prefix}{suffix}"), json!(x)); + for (_, prefix, _) in ROWS { + for (i, (suffix, _)) in COLUMNS.iter().enumerate() { + let key = format!("{prefix}{suffix}"); + if let Some(v) = crate::panels::paragraph::shared_justification_f64(app, |j| match prefix { + "word" => j.word_spacing[i], + "letter" => j.letter_spacing[i], + _ => j.glyph_scaling[i], + }) { + values.insert(key, json!(v)); + } } } - values.insert("autoLeading".into(), json!(j.auto_leading)); - values.insert("singleWord".into(), json!(single_word_name(j.single_word))); + if let Some(v) = crate::panels::paragraph::shared_justification_f64(app, |j| j.auto_leading) { + values.insert("autoLeading".into(), json!(v)); + } + if let Some(s) = crate::panels::paragraph::shared_single_word_justify(app) { + values.insert("singleWord".into(), json!(single_word_name(s))); + } let mut fields = Value::Object(values.clone()); fields[INITIAL] = Value::Object(values); fields[TARGETS] = targets; @@ -92,7 +103,11 @@ pub fn open(app: &mut VectorcraftApp) -> Result { /// Why OK can't apply the dialog: a row whose values are out of order. fn problem(d: &Dialog) -> Option<&'static str> { let out_of_order = ROWS.iter().any(|(_, prefix, _)| { - let [min, desired, max] = COLUMNS.map(|(suffix, _)| d.f64(&format!("{prefix}{suffix}"), 0.0)); + let keys = COLUMNS.map(|(suffix, _)| format!("{prefix}{suffix}")); + if !keys.iter().all(|k| d.fields.contains_key(k)) { + return false; + } + let [min, desired, max] = keys.map(|k| d.f64(&k, 0.0)); !(min <= desired && desired <= max) }); out_of_order.then(|| tl!("Minimum must not exceed Desired, nor Desired Maximum.")) @@ -110,8 +125,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { label(ui, text); for (suffix, _) in COLUMNS { let key = format!("{prefix}{suffix}"); - if let Some(v) = widgets::range_field(ui, ("justification", key.as_str()), d.f64(&key, 0.0), *lo..=*hi, "%", 0, 70.0) { - d.fields.insert(key, json!(v)); + let value = d.fields.get(&key).and_then(|v| v.as_f64()); + if let Some(v) = widgets::mixed_field(ui, ("justification", key.as_str()), value, "%", 0, 70.0) { + d.fields.insert(key, json!(v.clamp(*lo, *hi))); } } ui.end_row(); @@ -121,14 +137,15 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { egui::Grid::new("justification-more").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { label(ui, tl!("Auto Leading:")); let (lo, hi) = JustificationOptions::AUTO_LEADING; - if let Some(v) = widgets::range_field(ui, ("justification", "autoLeading"), d.f64("autoLeading", 120.0), lo..=hi, "%", 0, 64.0) { - d.fields.insert("autoLeading".into(), json!(v)); + let leading = d.fields.get("autoLeading").and_then(|v| v.as_f64()); + if let Some(v) = widgets::mixed_field(ui, ("justification", "autoLeading"), leading, "%", 0, 64.0) { + d.fields.insert("autoLeading".into(), json!(v.clamp(lo, hi))); } ui.end_row(); label(ui, tl!("Single Word Justification:")); let choices = single_word_choices(); - let current = d.str("singleWord"); - let shown = choices.iter().find(|(v, _)| *v == current).map_or(choices[0].1, |(_, l)| *l); + let current = d.fields.get("singleWord").and_then(|v| v.as_str()); + let shown = current.and_then(|current| choices.iter().find(|(v, _)| *v == current).map(|(_, l)| *l)).unwrap_or(""); let labels = choices.map(|(_, l)| l); if let Some(i) = widgets::dropdown(ui, ("justification", "singleWord"), shown, &labels, 140.0) && let Some((v, _)) = choices.get(i) @@ -157,6 +174,9 @@ fn params(d: &Dialog) -> Map { let mut changed = Map::new(); for (suffix, name) in COLUMNS { let key = format!("{prefix}{suffix}"); + if !d.fields.contains_key(&key) { + continue; + } let v = d.f64(&key, 0.0); if was(&key) != Some(v) { changed.insert(name.into(), json!(v)); @@ -166,12 +186,15 @@ fn params(d: &Dialog) -> Map { p.insert(param.into(), Value::Object(changed)); } } - let leading = d.f64("autoLeading", 120.0); - if was("autoLeading") != Some(leading) { - p.insert("autoLeading".into(), json!(leading)); + if d.fields.contains_key("autoLeading") { + let leading = d.f64("autoLeading", 120.0); + if was("autoLeading") != Some(leading) { + p.insert("autoLeading".into(), json!(leading)); + } } - let single = d.str("singleWord"); - if initial.and_then(|m| m.get("singleWord")).and_then(Value::as_str) != Some(single.as_str()) { + if let Some(single) = d.fields.get("singleWord").and_then(|v| v.as_str()) + && initial.and_then(|m| m.get("singleWord")).and_then(Value::as_str) != Some(single) + { p.insert("singleWordJustification".into(), json!(single)); } p @@ -308,4 +331,21 @@ mod tests { assert!(app.ui.dialog.is_none()); assert_eq!(paras(&app, id).0.justification.auto_leading, 120.0); } + + /// When paragraphs in scope disagree, only shared justification values are filled in. + #[test] + fn justification_dialog_mixed_paragraph_fields_show_blank() { + let (mut app, id) = app(); + app.run("text.setFormat", json!({"ids": [id], "wordSpacing": [70, 100, 200], "start": 5, "end": 5})).unwrap(); + app.run("ui.justification", json!({})).unwrap(); + let d = app.ui.dialog.clone().unwrap(); + assert!(!d.fields.contains_key("wordMin")); + assert!(!d.fields.contains_key("wordMax")); + assert_eq!(d.f64("wordDesired", 0.0), 100.0); + set(&mut app, "wordMin", json!(75.0)); + super::super::confirm(&mut app).unwrap(); + let (first, second) = paras(&app, id); + assert_eq!(first.justification.word_spacing, [75.0, 100.0, 133.0]); + assert_eq!(second.justification.word_spacing, [75.0, 100.0, 200.0]); + } } diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index d804cca1e..efea3765a 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -5,7 +5,9 @@ use egui::{Ui, vec2}; use serde_json::{Value, json}; -use vectorcraft_doc::{Composer, HyphenationOptions, Justify, Kinsoku, Mojikumi, NodeKind, ParaDirection, ParaStyle}; +use vectorcraft_doc::{ + Composer, HyphenationOptions, JustificationOptions, Justify, Kinsoku, Mojikumi, NodeKind, ParaDirection, ParaStyle, SingleWordJustify, +}; use super::character::{Metric, Shared, text_style}; use super::{pstate, set_pstate}; @@ -179,6 +181,25 @@ fn shared_para_field(app: &VectorcraftApp, field: impl Fn(&ParaStyle) -> f64) -> acc.value().map(|m| m.0) } +/// A justification option shared by every paragraph in scope, or `None` when they differ. +pub(crate) fn shared_justification_f64(app: &VectorcraftApp, f: impl Fn(&JustificationOptions) -> f64) -> Option { + let mut acc = Shared::None; + let mut any = false; + visit_paragraphs(app, |p| { + any = true; + acc.add(Metric(f(&p.justification))); + }); + if !any { + return text_style(app).map(|(_, p)| f(&p.justification)); + } + acc.value().map(|m| m.0) +} + +/// Single Word Justification shared by every paragraph in scope, or `None` when they differ. +pub(crate) fn shared_single_word_justify(app: &VectorcraftApp) -> Option { + shared_para(app, |p| p.justification.single_word) +} + /// A paragraph attribute shared by every paragraph in scope, or `None` when they differ. fn shared_para(app: &VectorcraftApp, field: impl Fn(&ParaStyle) -> T) -> Option { let mut acc = Shared::None;