From 304930164374f1ddd4915a04349ec2b704749f9a Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:45:03 +0200 Subject: [PATCH 1/4] Paragraph Border and Shading: link the four sides, as InDesign's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The course's chapter 40 (Applying Borders to Styles). Paragraph Style Options › Paragraph Border and Paragraph Shading get InDesign's "Make all settings the same" link for the stroke weights, the offsets and the corners. It starts on when the four are the same, so a side typed in sets all four; with the link off a side is set alone, as the lesson gives a border only a thick left side. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/dialogs.rs | 108 ++++++++++++++++++++++++++++++---- 2 files changed, 97 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..ca615a7e1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · Paragraph Border and Paragraph Shading link their four sides as InDesign's: the stroke weights, offsets and corners have the "Make all settings the same" link, on when the four are the same, so a side typed in sets all four; with the link off a side is set alone (a border with only a thick left side). Found in the Learn Skills Daily course, chapter 40. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..4ebb424b1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4477,18 +4477,27 @@ fn para_choice(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: & /// edited side stores the whole array. fn para_sides(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &str, field: &dyn Fn(&mut egui::Ui, &str, Option) -> Option) { let mut sides: [f64; 4] = serde_json::from_value(para_value(d, pv, key)).unwrap_or_default(); + let linked = linked(d, key, sides.iter().all(|v| (v - sides[0]).abs() < 1e-9)); let mut changed = false; - egui::Grid::new(("pssides", key)).num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { + // As InDesign's: Top and Bottom, the link, Left and Right. + egui::Grid::new(("pssides", key)).num_columns(5).spacing([8.0, 6.0]).show(ui, |ui| { for (i, label) in ["Top:", "Left:", "Bottom:", "Right:"].into_iter().enumerate() { crate::rtl::label(ui, crate::i18n::tr(lang, label)); - if let Some(side) = sides.get_mut(i) - && let Some(v) = field(ui, &format!("ps{key}{i}"), Some(*side)) - { - *side = v; + if let Some(v) = field(ui, &format!("ps{key}{i}"), sides.get(i).copied()) { + // Linked, a side typed in sets all four. + if linked { + sides = [v; 4]; + } else if let Some(side) = sides.get_mut(i) { + *side = v; + } changed = true; } - if i % 2 == 1 { - ui.end_row(); + match i { + 0 => link_toggle(lang, ui, d, key, linked), + 2 => { + ui.label(""); + } + _ => ui.end_row(), } } }); @@ -4497,6 +4506,20 @@ fn para_sides(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &s } } +/// Whether the four values of `key` are linked ("Make all settings the same"): as the user left +/// the link, else when the four are the same, as InDesign starts it. +fn linked(d: &Dialog, key: &str, same: bool) -> bool { + d.fields.get(&format!("_linked.{key}")).and_then(Value::as_bool).unwrap_or(same) +} + +/// The link button between a border's or shading's four sides or corners. +fn link_toggle(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, key: &str, linked: bool) { + let tip = crate::i18n::tr(lang, "Make all settings the same"); + if crate::widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, tip, egui::vec2(22.0, 21.0)).clicked() { + d.fields.insert(format!("_linked.{key}"), json!(!linked)); + } +} + /// A swatch menu with a colour chip per swatch (hidden swatches left out); returns the swatch /// picked. fn swatch_menu(ui: &mut egui::Ui, doc: &designcraft_doc::Document, id: impl egui::AsIdSalt, current: &str, width: f32) -> Option { @@ -4551,14 +4574,15 @@ fn border_type_menu(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, d fn para_corners(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &str, units: Unit) { use designcraft_geom::corners::{Corner, CornerShape}; let mut corners: [Corner; 4] = serde_json::from_value(para_value(d, pv, key)).unwrap_or_default(); - let mut changed = false; - egui::Grid::new(("pscorners", key)).num_columns(3).spacing([8.0, 6.0]).show(ui, |ui| { + let linked = linked(d, key, corners.iter().all(|c| *c == corners[0])); + let mut changed = None; + egui::Grid::new(("pscorners", key)).num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { for (i, label) in ["Top Left:", "Top Right:", "Bottom Right:", "Bottom Left:"].into_iter().enumerate() { let Some(c) = corners.get_mut(i) else { continue }; crate::rtl::label(ui, crate::i18n::tr(lang, label)); if let Some(v) = crate::widgets::measure(ui, &format!("ps{key}{i}"), Some(c.size), units, 70.0) { c.size = if v.is_finite() { v.clamp(0.0, 10_000.0) } else { 0.0 }; - changed = true; + changed = Some(i); } egui::ComboBox::from_id_salt(("pscornershape", key, i)) .selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, c.shape.label()))) @@ -4567,14 +4591,21 @@ fn para_corners(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: for shape in CornerShape::ALL { if ui.selectable_label(c.shape == shape, crate::rtl::widget(ui, crate::i18n::tr(lang, shape.label()))).clicked() { c.shape = shape; - changed = true; + changed = Some(i); } } }); + if i == 0 { + link_toggle(lang, ui, d, key, linked); + } ui.end_row(); } }); - if changed { + if let Some(i) = changed { + // Linked, the corner changed sets all four. + if linked { + corners = [corners[i]; 4]; + } para_set(d, key, serde_json::to_value(corners).unwrap_or_default()); } } @@ -9165,6 +9196,59 @@ mod tests { } } + /// Replace the text of the field beside the `nth` label `label` (on its row, to its right) with + /// `new`, and leave the field. + fn retype_beside(h: &mut egui_kittest::Harness<'static, crate::test_window::Window>, label: &str, nth: usize, new: &str) { + use egui_kittest::kittest::Queryable; + let l = h.get_all_by_label(label).nth(nth).unwrap_or_else(|| panic!("no label {label} #{nth}")).rect(); + let field = h + .get_all_by(|n| n.role() == egui::accesskit::Role::TextInput) + .map(|n| n.rect()) + .filter(|r| r.center().x > l.max.x && (r.center().y - l.center().y).abs() < l.height().max(8.0)) + .min_by(|a, b| a.min.x.total_cmp(&b.min.x)) + .unwrap_or_else(|| panic!("no field beside {label} #{nth}")) + .center(); + crate::test_window::click_at(h, field); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text(new.into())); + h.run_steps(1); + // Tab leaves the field (Enter would be the dialog's OK). + h.key_press(egui::Key::Tab); + h.run_steps(3); + } + + /// The course's chapter 40: Paragraph Border's four weights are linked as InDesign's, so a + /// side typed in sets all four; with the link off ("Make all settings the same") a side is set + /// alone, as the lesson gives the border only a thick left side. The corners link the same way. + #[test] + fn paragraph_border_sides_and_corners_link_as_indesigns() { + use egui_kittest::kittest::Queryable; + let (app, _) = ruled_paragraph(); + let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); + h.state_mut().app.ui.dialog = + Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "border", "p.borderOn": true}))); + h.run_steps(4); + let field = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, key: &str| { + h.state().app.ui.dialog.as_ref().and_then(|d| d.fields.get(&format!("p.{key}")).cloned()).unwrap_or_default() + }; + // The four weights start the same, so linked: Top sets them all. + retype_beside(&mut h, "Top:", 0, "3"); + assert_eq!(field(&h, "borderWeights"), json!([3.0, 3.0, 3.0, 3.0])); + // Unlinked, Left alone takes 10 pt. + let links: Vec = h.get_all_by_label("Make all settings the same").map(|n| n.rect()).collect(); + assert_eq!(links.len(), 3, "weights, corners and offsets each have a link"); + crate::test_window::click_at(&mut h, links[0].center()); + retype_beside(&mut h, "Left:", 0, "10"); + assert_eq!(field(&h, "borderWeights"), json!([3.0, 10.0, 3.0, 3.0])); + // The corners start the same too: Top Left's size goes to all four. + retype_beside(&mut h, "Top Left:", 0, "0p4"); + let corners = field(&h, "borderCorners"); + assert!((0..4).all(|i| corners[i]["size"] == json!(4.0)), "{corners}"); + confirm(&mut h.state_mut().app).unwrap(); + let style = h.state().app.session.doc().unwrap().doc.styles.para("Ruled Child").cloned().unwrap(); + assert_eq!(style.para.border_weights, Some([3.0, 10.0, 3.0, 3.0])); + } + #[test] fn paragraph_style_options_tabs_section_adds_a_stop_on_its_ruler() { use egui_kittest::kittest::Queryable; From 4d968d8adc16abed149558c1aeba1243effd4ddc Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 01:06:12 +0200 Subject: [PATCH 2/4] Paragraph border links: linking again makes the four the same Clicking "Make all settings the same" back on gives the four weights or offsets Top's value, and the four corners Top Left's size and shape, so OK never saves sides that differ under a closed link. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 56 +++++++++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 4ebb424b1..737bf1d9c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4493,7 +4493,12 @@ fn para_sides(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &s changed = true; } match i { - 0 => link_toggle(lang, ui, d, key, linked), + // Linked again, the four take Top's value. + 0 if link_toggle(lang, ui, d, key, linked) => { + sides = [sides[0]; 4]; + changed = true; + } + 0 => {} 2 => { ui.label(""); } @@ -4512,12 +4517,15 @@ fn linked(d: &Dialog, key: &str, same: bool) -> bool { d.fields.get(&format!("_linked.{key}")).and_then(Value::as_bool).unwrap_or(same) } -/// The link button between a border's or shading's four sides or corners. -fn link_toggle(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, key: &str, linked: bool) { +/// The link button between a border's or shading's four sides or corners; true when it was +/// clicked to link them, and the caller makes the four the same. +fn link_toggle(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, key: &str, linked: bool) -> bool { let tip = crate::i18n::tr(lang, "Make all settings the same"); - if crate::widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, tip, egui::vec2(22.0, 21.0)).clicked() { + let clicked = crate::widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, tip, egui::vec2(22.0, 21.0)).clicked(); + if clicked { d.fields.insert(format!("_linked.{key}"), json!(!linked)); } + clicked && !linked } /// A swatch menu with a colour chip per swatch (hidden swatches left out); returns the swatch @@ -4576,6 +4584,7 @@ fn para_corners(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: let mut corners: [Corner; 4] = serde_json::from_value(para_value(d, pv, key)).unwrap_or_default(); let linked = linked(d, key, corners.iter().all(|c| *c == corners[0])); let mut changed = None; + let mut relinked = false; egui::Grid::new(("pscorners", key)).num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { for (i, label) in ["Top Left:", "Top Right:", "Bottom Right:", "Bottom Left:"].into_iter().enumerate() { let Some(c) = corners.get_mut(i) else { continue }; @@ -4595,12 +4604,17 @@ fn para_corners(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: } } }); - if i == 0 { - link_toggle(lang, ui, d, key, linked); + // Linked again, the four take Top Left's size and shape. + if i == 0 && link_toggle(lang, ui, d, key, linked) { + relinked = true; } ui.end_row(); } }); + if relinked { + corners = [corners[0]; 4]; + changed = Some(0); + } if let Some(i) = changed { // Linked, the corner changed sets all four. if linked { @@ -9235,15 +9249,39 @@ mod tests { retype_beside(&mut h, "Top:", 0, "3"); assert_eq!(field(&h, "borderWeights"), json!([3.0, 3.0, 3.0, 3.0])); // Unlinked, Left alone takes 10 pt. - let links: Vec = h.get_all_by_label("Make all settings the same").map(|n| n.rect()).collect(); - assert_eq!(links.len(), 3, "weights, corners and offsets each have a link"); - crate::test_window::click_at(&mut h, links[0].center()); + // The link of the weights (0), corners (1) or offsets (2), where it is now: the dialog + // scrolls as fields take focus. + let link = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, n: usize| { + let links: Vec = h.get_all_by_label("Make all settings the same").map(|n| n.rect()).collect(); + assert_eq!(links.len(), 3, "weights, corners and offsets each have a link"); + links[n].center() + }; + let at = link(&h, 0); + crate::test_window::click_at(&mut h, at); + retype_beside(&mut h, "Left:", 0, "10"); + assert_eq!(field(&h, "borderWeights"), json!([3.0, 10.0, 3.0, 3.0])); + // Linked again, the four take Top's weight; unlinked once more for the thick left side. + let at = link(&h, 0); + crate::test_window::click_at(&mut h, at); + assert_eq!(field(&h, "borderWeights"), json!([3.0, 3.0, 3.0, 3.0])); + let at = link(&h, 0); + crate::test_window::click_at(&mut h, at); retype_beside(&mut h, "Left:", 0, "10"); assert_eq!(field(&h, "borderWeights"), json!([3.0, 10.0, 3.0, 3.0])); // The corners start the same too: Top Left's size goes to all four. retype_beside(&mut h, "Top Left:", 0, "0p4"); let corners = field(&h, "borderCorners"); assert!((0..4).all(|i| corners[i]["size"] == json!(4.0)), "{corners}"); + // Unlinked, Top Right alone takes 0p8; linked again, all four take Top Left's 4 pt. + let at = link(&h, 1); + crate::test_window::click_at(&mut h, at); + retype_beside(&mut h, "Top Right:", 0, "0p8"); + assert_eq!(field(&h, "borderCorners")[1]["size"], json!(8.0)); + assert_eq!(field(&h, "borderCorners")[0]["size"], json!(4.0)); + let at = link(&h, 1); + crate::test_window::click_at(&mut h, at); + let corners = field(&h, "borderCorners"); + assert!((0..4).all(|i| corners[i] == corners[0] && corners[i]["size"] == json!(4.0)), "{corners}"); confirm(&mut h.state_mut().app).unwrap(); let style = h.state().app.session.doc().unwrap().doc.styles.para("Ruled Child").cloned().unwrap(); assert_eq!(style.para.border_weights, Some([3.0, 10.0, 3.0, 3.0])); From 0d486c91cac11f0d0fefb499eb3dd6bf42675084 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:26:19 +0100 Subject: [PATCH 3/4] Tests: the branch's dialog tests beside main's dialog test helpers (dialogs/tests.rs) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 77 ----------------------------- crates/ui-egui/src/dialogs/tests.rs | 77 +++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 77 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9a607c59e..868d990d9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,83 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - /// Replace the text of the field beside the `nth` label `label` (on its row, to its right) with - /// `new`, and leave the field. - fn retype_beside(h: &mut egui_kittest::Harness<'static, crate::test_window::Window>, label: &str, nth: usize, new: &str) { - use egui_kittest::kittest::Queryable; - let l = h.get_all_by_label(label).nth(nth).unwrap_or_else(|| panic!("no label {label} #{nth}")).rect(); - let field = h - .get_all_by(|n| n.role() == egui::accesskit::Role::TextInput) - .map(|n| n.rect()) - .filter(|r| r.center().x > l.max.x && (r.center().y - l.center().y).abs() < l.height().max(8.0)) - .min_by(|a, b| a.min.x.total_cmp(&b.min.x)) - .unwrap_or_else(|| panic!("no field beside {label} #{nth}")) - .center(); - crate::test_window::click_at(h, field); - h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); - h.event(egui::Event::Text(new.into())); - h.run_steps(1); - // Tab leaves the field (Enter would be the dialog's OK). - h.key_press(egui::Key::Tab); - h.run_steps(3); - } - - /// The course's chapter 40: Paragraph Border's four weights are linked as InDesign's, so a - /// side typed in sets all four; with the link off ("Make all settings the same") a side is set - /// alone, as the lesson gives the border only a thick left side. The corners link the same way. - #[test] - fn paragraph_border_sides_and_corners_link_as_indesigns() { - use egui_kittest::kittest::Queryable; - let (app, _) = ruled_paragraph(); - let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); - h.state_mut().app.ui.dialog = - Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "border", "p.borderOn": true}))); - h.run_steps(4); - let field = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, key: &str| { - h.state().app.ui.dialog.as_ref().and_then(|d| d.fields.get(&format!("p.{key}")).cloned()).unwrap_or_default() - }; - // The four weights start the same, so linked: Top sets them all. - retype_beside(&mut h, "Top:", 0, "3"); - assert_eq!(field(&h, "borderWeights"), json!([3.0, 3.0, 3.0, 3.0])); - // Unlinked, Left alone takes 10 pt. - // The link of the weights (0), corners (1) or offsets (2), where it is now: the dialog - // scrolls as fields take focus. - let link = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, n: usize| { - let links: Vec = h.get_all_by_label("Make all settings the same").map(|n| n.rect()).collect(); - assert_eq!(links.len(), 3, "weights, corners and offsets each have a link"); - links[n].center() - }; - let at = link(&h, 0); - crate::test_window::click_at(&mut h, at); - retype_beside(&mut h, "Left:", 0, "10"); - assert_eq!(field(&h, "borderWeights"), json!([3.0, 10.0, 3.0, 3.0])); - // Linked again, the four take Top's weight; unlinked once more for the thick left side. - let at = link(&h, 0); - crate::test_window::click_at(&mut h, at); - assert_eq!(field(&h, "borderWeights"), json!([3.0, 3.0, 3.0, 3.0])); - let at = link(&h, 0); - crate::test_window::click_at(&mut h, at); - retype_beside(&mut h, "Left:", 0, "10"); - assert_eq!(field(&h, "borderWeights"), json!([3.0, 10.0, 3.0, 3.0])); - // The corners start the same too: Top Left's size goes to all four. - retype_beside(&mut h, "Top Left:", 0, "0p4"); - let corners = field(&h, "borderCorners"); - assert!((0..4).all(|i| corners[i]["size"] == json!(4.0)), "{corners}"); - // Unlinked, Top Right alone takes 0p8; linked again, all four take Top Left's 4 pt. - let at = link(&h, 1); - crate::test_window::click_at(&mut h, at); - retype_beside(&mut h, "Top Right:", 0, "0p8"); - assert_eq!(field(&h, "borderCorners")[1]["size"], json!(8.0)); - assert_eq!(field(&h, "borderCorners")[0]["size"], json!(4.0)); - let at = link(&h, 1); - crate::test_window::click_at(&mut h, at); - let corners = field(&h, "borderCorners"); - assert!((0..4).all(|i| corners[i] == corners[0] && corners[i]["size"] == json!(4.0)), "{corners}"); - confirm(&mut h.state_mut().app).unwrap(); - let style = h.state().app.session.doc().unwrap().doc.styles.para("Ruled Child").cloned().unwrap(); - assert_eq!(style.para.border_weights, Some([3.0, 10.0, 3.0, 3.0])); - } - #[test] fn color_theme_cancel_is_read_only_and_confirm_adds_the_preview() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 84cbf3bea..f144327be 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,80 @@ fn character_style_options_set_the_digits() { assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); assert_eq!(char_style(&app, "Emphasis").unwrap().chars.digits, Some(designcraft_doc::Digits::Farsi)); } + +/// Replace the text of the field beside the `nth` label `label` (on its row, to its right) with +/// `new`, and leave the field. +fn retype_beside(h: &mut egui_kittest::Harness<'static, crate::test_window::Window>, label: &str, nth: usize, new: &str) { + use egui_kittest::kittest::Queryable; + let l = h.get_all_by_label(label).nth(nth).unwrap_or_else(|| panic!("no label {label} #{nth}")).rect(); + let field = h + .get_all_by(|n| n.role() == egui::accesskit::Role::TextInput) + .map(|n| n.rect()) + .filter(|r| r.center().x > l.max.x && (r.center().y - l.center().y).abs() < l.height().max(8.0)) + .min_by(|a, b| a.min.x.total_cmp(&b.min.x)) + .unwrap_or_else(|| panic!("no field beside {label} #{nth}")) + .center(); + crate::test_window::click_at(h, field); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text(new.into())); + h.run_steps(1); + // Tab leaves the field (Enter would be the dialog's OK). + h.key_press(egui::Key::Tab); + h.run_steps(3); +} + +/// The course's chapter 40: Paragraph Border's four weights are linked as InDesign's, so a +/// side typed in sets all four; with the link off ("Make all settings the same") a side is set +/// alone, as the lesson gives the border only a thick left side. The corners link the same way. +#[test] +fn paragraph_border_sides_and_corners_link_as_indesigns() { + use egui_kittest::kittest::Queryable; + let (app, _) = ruled_paragraph(); + let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); + h.state_mut().app.ui.dialog = + Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "border", "p.borderOn": true}))); + h.run_steps(4); + let field = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, key: &str| { + h.state().app.ui.dialog.as_ref().and_then(|d| d.fields.get(&format!("p.{key}")).cloned()).unwrap_or_default() + }; + // The four weights start the same, so linked: Top sets them all. + retype_beside(&mut h, "Top:", 0, "3"); + assert_eq!(field(&h, "borderWeights"), json!([3.0, 3.0, 3.0, 3.0])); + // Unlinked, Left alone takes 10 pt. + // The link of the weights (0), corners (1) or offsets (2), where it is now: the dialog + // scrolls as fields take focus. + let link = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, n: usize| { + let links: Vec = h.get_all_by_label("Make all settings the same").map(|n| n.rect()).collect(); + assert_eq!(links.len(), 3, "weights, corners and offsets each have a link"); + links[n].center() + }; + let at = link(&h, 0); + crate::test_window::click_at(&mut h, at); + retype_beside(&mut h, "Left:", 0, "10"); + assert_eq!(field(&h, "borderWeights"), json!([3.0, 10.0, 3.0, 3.0])); + // Linked again, the four take Top's weight; unlinked once more for the thick left side. + let at = link(&h, 0); + crate::test_window::click_at(&mut h, at); + assert_eq!(field(&h, "borderWeights"), json!([3.0, 3.0, 3.0, 3.0])); + let at = link(&h, 0); + crate::test_window::click_at(&mut h, at); + retype_beside(&mut h, "Left:", 0, "10"); + assert_eq!(field(&h, "borderWeights"), json!([3.0, 10.0, 3.0, 3.0])); + // The corners start the same too: Top Left's size goes to all four. + retype_beside(&mut h, "Top Left:", 0, "0p4"); + let corners = field(&h, "borderCorners"); + assert!((0..4).all(|i| corners[i]["size"] == json!(4.0)), "{corners}"); + // Unlinked, Top Right alone takes 0p8; linked again, all four take Top Left's 4 pt. + let at = link(&h, 1); + crate::test_window::click_at(&mut h, at); + retype_beside(&mut h, "Top Right:", 0, "0p8"); + assert_eq!(field(&h, "borderCorners")[1]["size"], json!(8.0)); + assert_eq!(field(&h, "borderCorners")[0]["size"], json!(4.0)); + let at = link(&h, 1); + crate::test_window::click_at(&mut h, at); + let corners = field(&h, "borderCorners"); + assert!((0..4).all(|i| corners[i] == corners[0] && corners[i]["size"] == json!(4.0)), "{corners}"); + confirm(&mut h.state_mut().app).unwrap(); + let style = h.state().app.session.doc().unwrap().doc.styles.para("Ruled Child").cloned().unwrap(); + assert_eq!(style.para.border_weights, Some([3.0, 10.0, 3.0, 3.0])); +} From 176b35aa332502979be0809edae676172a650a0d Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 18:42:51 +0100 Subject: [PATCH 4/4] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/tests.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index f144327be..299fc6a66 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3551,8 +3551,7 @@ fn paragraph_border_sides_and_corners_link_as_indesigns() { use egui_kittest::kittest::Queryable; let (app, _) = ruled_paragraph(); let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); - h.state_mut().app.ui.dialog = - Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "border", "p.borderOn": true}))); + h.state_mut().app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "border", "p.borderOn": true}))); h.run_steps(4); let field = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, key: &str| { h.state().app.ui.dialog.as_ref().and_then(|d| d.fields.get(&format!("p.{key}")).cloned()).unwrap_or_default()