From d14c637de8e817e25b2458db8924c0b9fdd3fff5 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 7 Nov 2026 23:47:15 -0500 Subject: [PATCH] Text: tests for the layer-level wrap style, the Type settings control, old files and inspect (#2094 review) Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium --- crates/doc/src/text.rs | 43 ++++++++++++ crates/engine/src/text_behaviour_tests.rs | 40 ++++++++++++ crates/ui-egui/src/panels/typography.rs | 80 ++++++++++++++++++++++- 3 files changed, 162 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 9ba29e5a0..48f690b8a 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -1168,6 +1168,49 @@ mod case_tests { assert!(t.paragraphs.is_empty()); assert_eq!((t.para_wrap_style(0), t.para_wrap_style(1)), (Pretty, Pretty)); } + + #[test] + fn wrap_style_loads_from_old_paragraph_styles_and_texts() { + use WrapStyle::Auto; + // A paragraph style saved before it carried a wrap style. + let old = r#"{"paragraph_spacing":4.0,"list":"bulleted"}"#; + let ps: ParagraphStyle = serde_json::from_str(old).unwrap(); + assert_eq!((ps.wrap_style, ps.paragraph_spacing, ps.list), (Auto, 4.0, ListStyle::Bulleted)); + assert!(!serde_json::to_string(&ParagraphStyle::default()).unwrap().contains("wrap_style"), "Auto is not saved"); + // A text saved before its paragraphs carried a wrap style. + let mut t = TextData::new("a\nb"); + t.set_para_lists(0, 0, |p| p.list = ListStyle::Bulleted); + let mut v = serde_json::to_value(&t).unwrap(); + for p in v["paragraphs"].as_array_mut().unwrap() { + assert!(p.as_object_mut().unwrap().remove("wrap").is_none(), "Auto is not saved"); + } + v.as_object_mut().unwrap().remove("wrap_style"); + let old: TextData = serde_json::from_value(v).unwrap(); + assert_eq!(old.wrap_style, Auto); + assert_eq!(old.paragraphs.len(), 2); + assert!(old.paragraphs.iter().all(|p| p.wrap.is_none())); + assert_eq!((old.para_wrap_style(0), old.para_wrap_style(1)), (Auto, Auto)); + assert_eq!(old, t); + } + + #[test] + fn set_wrap_style_clears_an_override_on_the_empty_last_paragraph() { + use WrapStyle::{Auto, Balance, Pretty}; + let mut t = TextData::new("ab\n"); + assert_eq!(t.paragraph_count(), 2); + // The empty paragraph after the final newline: a caret there reaches it. + t.set_para_wrap_styles(3, 3, Balance); + assert_eq!((t.para_wrap_style(0), t.para_wrap_style(1)), (Auto, Balance)); + t.set_wrap_style(Pretty); + assert!(t.paragraphs.is_empty(), "{:?}", t.paragraphs); + assert_eq!((t.para_wrap_style(0), t.para_wrap_style(1)), (Pretty, Pretty)); + // Back to the layer's own style: no override is left on either paragraph. + t.set_para_wrap_styles(3, 3, Auto); + assert_eq!(t.para_wrap_style(1), Auto); + t.set_wrap_style(Auto); + assert!(t.paragraphs.is_empty()); + assert_eq!(t.common_wrap_style(0, t.char_count()), Some(Auto)); + } } #[cfg(test)] diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs index 779002dc1..f6a308eec 100644 --- a/crates/engine/src/text_behaviour_tests.rs +++ b/crates/engine/src/text_behaviour_tests.rs @@ -541,6 +541,46 @@ fn wrap_style_per_paragraph_overrides_the_layers() { assert!(s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "zigzag", "start": 0, "end": 0}})).is_err()); } +#[test] +fn text_wrap_style_without_editing_sets_the_layer_and_folds_paragraph_overrides() { + let mut s = Session::new(); + let t = text(&mut s, "one\ntwo\nthree", json!({})); + let inspect = |s: &Session| crate::inspect::node_json(s.doc().unwrap(), t, 0); + let td = |s: &Session| s.doc().unwrap().node(t).unwrap().kind.text().unwrap().clone(); + s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "balance", "start": 5, "end": 5}})).unwrap(); + s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "pretty", "start": 9, "end": 9}})).unwrap(); + assert_eq!(inspect(&s)["lineWrapStyles"], json!(["auto", "balance", "pretty"])); + assert!(s.ds().unwrap().text_edit.is_none(), "no edit session"); + s.execute("text.wrapStyle", &json!({"ids": [t], "style": "balance"})).unwrap(); + let after = td(&s); + assert!(after.paragraphs.iter().all(|p| p.wrap.is_none()), "{:?}", after.paragraphs); + assert_eq!(after.wrap_style, figcraft_doc::WrapStyle::Balance); + assert!((0..3).all(|p| after.para_wrap_style(p) == figcraft_doc::WrapStyle::Balance)); + assert_eq!(inspect(&s)["wrapStyle"], json!("balance")); + assert!(inspect(&s).get("lineWrapStyles").is_none()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(inspect(&s)["lineWrapStyles"], json!(["auto", "balance", "pretty"]), "undo restores the overrides"); +} + +#[test] +fn inspect_reports_wrap_style_only_when_not_auto_and_counts_a_trailing_empty_paragraph() { + let mut s = Session::new(); + let t = text(&mut s, "one\ntwo\n", json!({})); + let inspect = |s: &Session| crate::inspect::node_json(s.doc().unwrap(), t, 0); + let quiet = |s: &Session| inspect(s).get("wrapStyle").is_none() && inspect(s).get("lineWrapStyles").is_none(); + assert!(quiet(&s), "uniform Auto says nothing"); + // An override on the empty last paragraph (a caret after the final newline) is listed with the others. + s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "balance", "start": 8, "end": 8}})).unwrap(); + assert_eq!(inspect(&s)["lineWrapStyles"], json!(["auto", "auto", "balance"])); + assert!(inspect(&s).get("wrapStyle").is_none()); + // Every paragraph the same non-Auto style: the one key; back to Auto: neither. + s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "balance"}})).unwrap(); + assert_eq!(inspect(&s)["wrapStyle"], json!("balance")); + assert!(inspect(&s).get("lineWrapStyles").is_none()); + s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "auto"}})).unwrap(); + assert!(quiet(&s)); +} + #[test] fn max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines() { // "Max lines is available only if truncate text is enabled, and: Text resizing is set to diff --git a/crates/ui-egui/src/panels/typography.rs b/crates/ui-egui/src/panels/typography.rs index a3a5bd66a..ccd970bea 100644 --- a/crates/ui-egui/src/panels/typography.rs +++ b/crates/ui-egui/src/panels/typography.rs @@ -210,6 +210,7 @@ pub(crate) fn field_row(ui: &mut Ui, label: &str, add: impl FnOnce(&mut Ui, f #[cfg(test)] mod variable_tests { use egui::{Event, Modifiers, PointerButton, Pos2, RawInput}; + use figcraft_doc::WrapStyle; use figcraft_engine::Session; use super::*; @@ -223,6 +224,8 @@ mod variable_tests { ctx: egui::Context, time: f64, text: NodeId, + /// The AccessKit nodes of the last frame. + nodes: Vec, } impl Panel { @@ -236,7 +239,8 @@ mod variable_tests { s.execute("variable.create", &json!({"collection": "Type", "name": name, "type": ty, "values": {"Base": value}})).unwrap(); } s.execute("select.set", &json!({ "ids": [text] })).unwrap(); - let mut p = Self { app: FigApp::new(s, Box::new(NoServices)), ctx: egui::Context::default(), time: 0.0, text }; + let mut p = Self { app: FigApp::new(s, Box::new(NoServices)), ctx: egui::Context::default(), time: 0.0, text, nodes: Vec::new() }; + p.ctx.enable_accesskit(); crate::theme::install_fonts(&p.ctx, false, 0); crate::theme::apply(&p.ctx, Tokens::light()); for _ in 0..3 { @@ -269,6 +273,9 @@ mod variable_tests { crate::variable_ui::picker(&mut self.app, ui.ctx()); }); out.textures_delta.clear(); + if let Some(update) = out.platform_output.accesskit_update.take() { + self.nodes = update.nodes.into_iter().map(|(_, n)| n).collect(); + } let mut texts = Vec::new(); for c in out.shapes { walk(&c.shape, &mut texts); @@ -351,4 +358,75 @@ mod variable_tests { assert!(shows(&texts, "Inter") && shows(&texts, "Bold"), "the dropdowns show the last values"); assert!(p.app.ui.last_error.is_none(), "{:?}", p.app.ui.last_error); } + + /// The centre of the AccessKit node labelled `label` (a button, say). + fn node_center(p: &Panel, label: &str) -> Pos2 { + let n = p.nodes.iter().find(|n| n.label() == Some(label)).unwrap_or_else(|| panic!("no node {label:?}")); + let r = n.bounds().expect("bounds"); + Pos2::new(((r.x0 + r.x1) / 2.0) as f32, ((r.y0 + r.y1) / 2.0) as f32) + } + + const WRAP: [&str; 3] = [ + "Auto: as many words as fit on each line", + "Balance: every line close to the same length", + "Pretty: full lines, no one word alone on the last", + ]; + + /// The Wrap style buttons that are on, by short name. + fn wrap_on(p: &Panel) -> Vec<&'static str> { + let on = |label: &str| p.nodes.iter().find(|n| n.label() == Some(label)).unwrap_or_else(|| panic!("no node {label:?}")).toggled(); + ["Auto", "Balance", "Pretty"].into_iter().zip(WRAP).filter(|(_, l)| on(l) == Some(egui::accesskit::Toggled::True)).map(|(n, _)| n).collect() + } + + /// The panel with the Type settings popover open, over two paragraphs. + fn settings_panel() -> Panel { + let mut p = Panel::new(); + p.app.session.execute("node.set", &json!({"id": p.text, "props": {"characters": "one\ntwo"}})).unwrap(); + p.frame(vec![]); + let at = node_center(&p, "Type settings"); + p.click(at); + p.frame(vec![]); + p + } + + #[test] + fn wrap_style_control_follows_the_caret_paragraph() { + // #1827: with the caret in one paragraph the control shows and sets that paragraph's style. + let mut p = settings_panel(); + assert_eq!(wrap_on(&p), ["Auto"], "the layer's style when not editing"); + figcraft_engine::textedit::begin(&mut p.app.session, p.text, Some(5), false).unwrap(); + p.frame(vec![]); + assert_eq!(wrap_on(&p), ["Auto"]); + let at = node_center(&p, WRAP[1]); + p.click(at); + let td = |p: &Panel| p.app.session.doc().unwrap().node(p.text).unwrap().kind.text().unwrap().clone(); + assert_eq!((td(&p).para_wrap_style(0), td(&p).para_wrap_style(1)), (WrapStyle::Auto, WrapStyle::Balance), "only the caret's paragraph"); + assert_eq!(td(&p).wrap_style, WrapStyle::Auto); + assert_eq!(wrap_on(&p), ["Balance"], "the control shows the paragraph's value"); + figcraft_engine::textedit::set_caret(&mut p.app.session, 1, false).unwrap(); + p.frame(vec![]); + assert_eq!(wrap_on(&p), ["Auto"], "another paragraph shows its own"); + assert!(p.app.ui.last_error.is_none(), "{:?}", p.app.ui.last_error); + } + + #[test] + fn wrap_style_control_has_nothing_active_for_mixed_paragraphs() { + let mut p = settings_panel(); + figcraft_engine::textedit::begin(&mut p.app.session, p.text, Some(5), false).unwrap(); + p.frame(vec![]); + let at = node_center(&p, WRAP[2]); + p.click(at); + assert_eq!(wrap_on(&p), ["Pretty"]); + // A selection across both paragraphs, which differ: none is on. + figcraft_engine::textedit::set_caret(&mut p.app.session, 1, false).unwrap(); + figcraft_engine::textedit::set_caret(&mut p.app.session, 6, true).unwrap(); + p.frame(vec![]); + assert!(wrap_on(&p).is_empty(), "{:?}", wrap_on(&p)); + // Choosing one sets both paragraphs. + let at = node_center(&p, WRAP[1]); + p.click(at); + assert_eq!(wrap_on(&p), ["Balance"]); + let t = p.app.session.doc().unwrap().node(p.text).unwrap().kind.text().unwrap().clone(); + assert_eq!((t.para_wrap_style(0), t.para_wrap_style(1)), (WrapStyle::Balance, WrapStyle::Balance)); + } }