From 45cabc1c3c6d44c6019604d8a082e6c6bc202689 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Mon, 19 Oct 2026 19:28:16 +0300 Subject: [PATCH 1/3] G4: blank Paragraph panel indents when paragraphs differ Mixed left/right/first-line indents and space before/after now follow the same shared-value rules as alignment buttons. Co-authored-by: Cursor --- crates/ui-egui/src/panels/paragraph.rs | 106 +++++++++++++++++++++++-- 1 file changed, 101 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index fec1f19ff..85e81bd16 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -142,6 +142,85 @@ fn collect_text_alignment(text: &vectorcraft_doc::TextObject, range: Option<(usi record(index, &plain, start, end) // Includes empty text and the paragraph after a final newline. } +/// Compare paragraph distances with the same tolerance as Character dimensional fields. +struct ParaMetric(f64); + +impl PartialEq for ParaMetric { + fn eq(&self, other: &Self) -> bool { + (self.0 - other.0).abs() <= 1e-9 * self.0.abs().max(other.0.abs()).max(1.0) + } +} + +enum ParaShared { + None, + Same(T), + Differ, +} + +impl ParaShared { + fn add(&mut self, v: T) { + match self { + Self::None => *self = Self::Same(v), + Self::Same(old) if *old == v => {} + _ => *self = Self::Differ, + } + } + fn value(self) -> Option { + match self { + Self::Same(v) => Some(v), + _ => None, + } + } +} + +/// Visit every paragraph the Paragraph panel acts on; stop early when `f` returns false. +fn visit_paragraphs(app: &VectorcraftApp, mut f: impl FnMut(&ParaStyle) -> bool) -> bool { + let Some(st) = app.session.active() else { return false }; + if let Some((id, a, b)) = super::character::text_editing(app) + && let Some(NodeKind::Text(text)) = st.doc.node(id).map(|n| &n.kind) + { + let range = text.paragraphs_in(a, b); + for i in range { + if !f(text.para_at(i)) { + return false; + } + } + return true; + } + let mut ok = true; + for node in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) { + node.walk(&mut |c| { + if let NodeKind::Text(t) = &c.kind { + for i in 0..t.paragraph_count() { + if !f(t.para_at(i)) { + ok = false; + } + } + } + }); + if !ok { + return false; + } + } + ok +} + +/// A distance shared by every paragraph in scope, or `None` when they differ (blank spin field). +fn shared_para_field(app: &VectorcraftApp, field: impl Fn(&ParaStyle) -> f64) -> Option { + let mut acc = ParaShared::None; + let mut any = false; + let record = |p: &ParaStyle| { + any = true; + acc.add(ParaMetric(field(p))); + true + }; + visit_paragraphs(app, record); + if !any { + return text_style(app).map(|(_, p)| field(&p)); + } + acc.value().map(|m| m.0) +} + /// None means differing displayed alignments, following the other mixed-value controls. fn selected_alignment(app: &VectorcraftApp, fallback: &ParaStyle) -> Option { let default = Some(shown_alignment(fallback.justify, fallback.direction == Some(ParaDirection::RightToLeft))); @@ -217,27 +296,27 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }; egui::Grid::new("para-grid").num_columns(4).spacing([4.0, 4.0]).show(ui, |ui| { label(ui, "→|", tl!("Left Indent")); - if let Some(v) = widgets::spin_field(ui, "pa-li", Some(para.left_indent), unit, fw, 1.0, -1296.0, &[]) { + if let Some(v) = widgets::spin_field(ui, "pa-li", shared_para_field(app, |p| p.left_indent), unit, fw, 1.0, -1296.0, &[]) { format(app, json!({"leftIndent": v})); } label(ui, "|←", tl!("Right Indent")); - if let Some(v) = widgets::spin_field(ui, "pa-ri", Some(para.right_indent), unit, fw, 1.0, -1296.0, &[]) { + if let Some(v) = widgets::spin_field(ui, "pa-ri", shared_para_field(app, |p| p.right_indent), unit, fw, 1.0, -1296.0, &[]) { format(app, json!({"rightIndent": v})); } ui.end_row(); label(ui, "1→", tl!("First-line Left Indent")); - if let Some(v) = widgets::spin_field(ui, "pa-fi", Some(para.first_line_indent), unit, fw, 1.0, -1296.0, &[]) { + if let Some(v) = widgets::spin_field(ui, "pa-fi", shared_para_field(app, |p| p.first_line_indent), unit, fw, 1.0, -1296.0, &[]) { format(app, json!({"firstLineIndent": v})); } ui.label(""); ui.label(""); ui.end_row(); label(ui, "↑¶", tl!("Space Before Paragraph")); - if let Some(v) = widgets::spin_field(ui, "pa-sb", Some(para.space_before), unit, fw, 1.0, 0.0, &[]) { + if let Some(v) = widgets::spin_field(ui, "pa-sb", shared_para_field(app, |p| p.space_before), unit, fw, 1.0, 0.0, &[]) { format(app, json!({"spaceBefore": v})); } label(ui, "↓¶", tl!("Space After Paragraph")); - if let Some(v) = widgets::spin_field(ui, "pa-sa", Some(para.space_after), unit, fw, 1.0, 0.0, &[]) { + if let Some(v) = widgets::spin_field(ui, "pa-sa", shared_para_field(app, |p| p.space_after), unit, fw, 1.0, 0.0, &[]) { format(app, json!({"spaceAfter": v})); } ui.end_row(); @@ -442,6 +521,23 @@ mod tests { app.session.set_tool_option("select", &json!({"start": start, "end": end})); } + #[test] + fn paragraph_mixed_indent_fields_show_blank() { + let (mut app, id) = editing_text("A\nB\n"); + app.run("text.setFormat", json!({"id": id, "leftIndent": 10})).unwrap(); + app.run("text.setFormat", json!({"id": id, "start": 2, "end": 3, "leftIndent": 20})).unwrap(); + app.session.set_tool_option("selectAll", &json!(true)); + assert_eq!(shared_para_field(&app, |p| p.left_indent), None); + assert_eq!(shared_para_field(&app, |p| p.space_before), Some(0.0)); + app.run("text.setFormat", json!({"id": id, "leftIndent": 12})).unwrap(); + assert_eq!(shared_para_field(&app, |p| p.left_indent), Some(12.0)); + app.session.set_tool_option("stopEditing", &json!(true)); + let other = app.run("text.create", json!({"x": 40, "y": 80, "text": "C"})).unwrap()["id"].clone(); + app.run("text.setFormat", json!({"id": other, "leftIndent": 5})).unwrap(); + app.run("select.set", json!({"ids": [id, other]})).unwrap(); + assert_eq!(shared_para_field(&app, |p| p.left_indent), None); + } + #[test] fn paragraph_mixed_alignment_click_center_and_undo() { let (mut app, id) = editing_text("é\nאב\n終"); From c7df7a56e87e474c783896642a602e59df0091cc Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Mon, 19 Oct 2026 19:28:25 +0300 Subject: [PATCH 2/3] docs: note G4 Paragraph panel mixed indent fields Co-authored-by: Cursor --- docs/gaps.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/gaps.md b/docs/gaps.md index 10204c2f4..264304942 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -201,6 +201,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Done:** character-style Auto-leading resets propagate to style users while retaining local leading overrides. Sparse definitions remain sparse, native serialization retains explicit Auto, and the Character Styles override marker stays consistent. - **Improved:** mixed paragraph alignments leave the existing Paragraph/Properties alignment buttons unselected; choosing an alignment still uses the range-aware command. Headless regressions, full integration CI and visual review pass. Other mixed paragraph fields remain; estimate unchanged. +- **Improved:** Paragraph panel left/right/first-line indents and space before/after show blank when paragraphs in scope differ; uniform values still display and edits apply through `text.setFormat`. Headless regression in `panels/paragraph.rs`; estimate unchanged. - **Improved (#1258):** full font names (name ID 4) resolve the face instead of falling back, and Missing Fonts searches recognize the same aliases. Synthetic regressions cover loaded/cataloged fonts, localized names, real-family precedence and variable default faces. Windows font-service activation still needs reporter QA; estimate unchanged. - **Done:** Paragraph panel menu › Roman Hanging Punctuation (#2756), per paragraph; estimate unchanged. - **Done:** Paragraph panel menu › Justification… (#2755): word and letter spacing, glyph scaling (Minimum, Desired, From cc4836abb754485c99a3a2ee2b40404c9d47e7c1 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Tue, 20 Oct 2026 07:01:46 +0300 Subject: [PATCH 3/3] G4: reuse Character panel Shared/Metric for mixed paragraph fields. Co-authored-by: Cursor --- crates/ui-egui/src/panels/character.rs | 8 ++-- crates/ui-egui/src/panels/paragraph.rs | 64 +++++--------------------- 2 files changed, 15 insertions(+), 57 deletions(-) diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 65cf0c039..e7f653a14 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -68,7 +68,7 @@ fn shared_effective(app: &VectorcraftApp, f: impl Fn(&CharStyle) -> f64) -> Opti shared_styles(app, |t, style| t.effective_char_style(style).map(|s| Metric(f(&s)))).map(|v| v.0) } -struct Metric(f64); +pub(crate) struct Metric(pub(crate) f64); impl PartialEq for Metric { fn eq(&self, other: &Self) -> bool { @@ -109,21 +109,21 @@ fn shared_styles(app: &VectorcraftApp, f: impl Fn(&vectorcraft_doc } /// The value a run of styles share so far ([`shared`]). -enum Shared { +pub(crate) enum Shared { None, Same(T), Differ, } impl Shared { - fn add(&mut self, v: T) { + pub(crate) fn add(&mut self, v: T) { match self { Shared::None => *self = Shared::Same(v), Shared::Same(x) if *x != v => *self = Shared::Differ, _ => {} } } - fn value(self) -> Option { + pub(crate) fn value(self) -> Option { match self { Shared::Same(v) => Some(v), _ => None, diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index 85e81bd16..bf16cb906 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -7,7 +7,7 @@ use egui::{Ui, vec2}; use serde_json::{Value, json}; use vectorcraft_doc::{Composer, HyphenationOptions, Justify, NodeKind, ParaDirection, ParaStyle}; -use super::character::text_style; +use super::character::{Metric, Shared, text_style}; use super::{pstate, set_pstate}; use crate::VectorcraftApp; use crate::theme::Tokens; @@ -142,79 +142,37 @@ fn collect_text_alignment(text: &vectorcraft_doc::TextObject, range: Option<(usi record(index, &plain, start, end) // Includes empty text and the paragraph after a final newline. } -/// Compare paragraph distances with the same tolerance as Character dimensional fields. -struct ParaMetric(f64); - -impl PartialEq for ParaMetric { - fn eq(&self, other: &Self) -> bool { - (self.0 - other.0).abs() <= 1e-9 * self.0.abs().max(other.0.abs()).max(1.0) - } -} - -enum ParaShared { - None, - Same(T), - Differ, -} - -impl ParaShared { - fn add(&mut self, v: T) { - match self { - Self::None => *self = Self::Same(v), - Self::Same(old) if *old == v => {} - _ => *self = Self::Differ, - } - } - fn value(self) -> Option { - match self { - Self::Same(v) => Some(v), - _ => None, - } - } -} - -/// Visit every paragraph the Paragraph panel acts on; stop early when `f` returns false. -fn visit_paragraphs(app: &VectorcraftApp, mut f: impl FnMut(&ParaStyle) -> bool) -> bool { - let Some(st) = app.session.active() else { return false }; +/// Visit every paragraph the Paragraph panel acts on. +fn visit_paragraphs(app: &VectorcraftApp, mut f: impl FnMut(&ParaStyle)) { + let Some(st) = app.session.active() else { return }; if let Some((id, a, b)) = super::character::text_editing(app) && let Some(NodeKind::Text(text)) = st.doc.node(id).map(|n| &n.kind) { let range = text.paragraphs_in(a, b); for i in range { - if !f(text.para_at(i)) { - return false; - } + f(text.para_at(i)); } - return true; + return; } - let mut ok = true; for node in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) { node.walk(&mut |c| { if let NodeKind::Text(t) = &c.kind { for i in 0..t.paragraph_count() { - if !f(t.para_at(i)) { - ok = false; - } + f(t.para_at(i)); } } }); - if !ok { - return false; - } } - ok } /// A distance shared by every paragraph in scope, or `None` when they differ (blank spin field). fn shared_para_field(app: &VectorcraftApp, field: impl Fn(&ParaStyle) -> f64) -> Option { - let mut acc = ParaShared::None; + let mut acc = Shared::None; let mut any = false; - let record = |p: &ParaStyle| { + visit_paragraphs(app, |p| { any = true; - acc.add(ParaMetric(field(p))); - true - }; - visit_paragraphs(app, record); + acc.add(Metric(field(p))); + }); if !any { return text_style(app).map(|(_, p)| field(&p)); }