From be0fbb75c047bb3579a90625456aa29919df314b Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 12:32:25 +0200 Subject: [PATCH 001/207] Numbering & Section Options opens as New Section on a page without one, as InDesign's Chris Nielsen's "Numbering Page Sections" (4:43): with page 3 selected, the Pages panel menu's Numbering & Section Options opens InDesign's New Section window: Start Section checked, Automatic Page Numbering chosen and 1 offered in Start Page Numbering at, which stays editable. The window here was titled Numbering & Section Options, Start Section was off and the field showed the page's current number, greyed out. - Opened on a page that doesn't start a section, the window is New Section: Start Section on and 1 offered; on a section's first page it is Numbering & Section Options, as before. - Start Page Numbering at and Start Chapter Numbering at stay editable; typing a number chooses that option. - "New Section" translated. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/dialogs.rs | 50 +++++++++++++++++++++++++++ crates/ui-egui/src/i18n.rs | 1 + crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + crates/ui-egui/src/section_options.rs | 24 ++++++++++--- 9 files changed, 76 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 805dfeb6c..f9dfe361a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · Numbering & Section Options opened on a page that doesn't start a section is New Section, as in InDesign: Start Section on, 1 offered, and the start numbers stay editable (typing one chooses it). No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c56bf267e..26c339e72 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1130,6 +1130,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "insertTable" => crate::i18n::tr(&app.ui.language, "Create Table"), "textFrameOptions" => crate::i18n::tr(&app.ui.language, "Text Frame Options"), "documentSetup" => crate::i18n::tr(&app.ui.language, "Document Setup"), + // On a page that doesn't start a section yet, InDesign titles it New Section. + "numberingSection" if d.b("newSection") => crate::i18n::tr(&app.ui.language, "New Section"), "numberingSection" => crate::i18n::tr(&app.ui.language, "Numbering & Section Options…").trim_end_matches('…'), "findChange" => crate::i18n::tr(&app.ui.language, "Find/Change"), "paragraphStyleOptions" => crate::i18n::tr(&app.ui.language, "Paragraph Style Options"), @@ -4156,6 +4158,54 @@ mod tests { } } + #[test] + fn on_a_page_without_a_section_the_window_is_new_section() { + // Chris Nielsen's "Numbering Page Sections" (4:43): page 3 selected, the Pages panel menu's + // Numbering & Section Options opens New Section with Start Section on and 1 offered. + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 6, "facingPages": true})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 800.0))); + crate::canvas::go_to_page(&mut app, 2); + crate::menus::activate(&mut app, "layout.section", &Value::Null); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!( + (d.n("page"), d.b("newSection"), d.b("startSection"), d.b("automatic"), d.s("startNumber")), + (Some(3.0), true, true, true, "1".into()) + ); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1178.0, 814.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "New Section"); + // Typing a number in the field chooses Start Page Numbering at; OK: page 3 is page 2. + let field = visible_label(&labels, screen, "1"); + click_dialog(&mut app, &ctx, screen, field.center()); + dialog_frame( + &mut app, + &ctx, + screen, + vec![ + egui::Event::Key { key: egui::Key::End, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }, + egui::Event::Key { key: egui::Key::Backspace, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }, + egui::Event::Text("2".into()), + ], + ); + assert!(!app.ui.dialog.as_ref().unwrap().b("automatic"), "typing chose Start Page Numbering at"); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "OK").center()); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.active().unwrap().doc.page_name(2), "2"); + // Opened again on that page, it is Numbering & Section Options, showing the section. + crate::menus::activate(&mut app, "layout.section", &Value::Null); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.b("newSection"), d.b("startSection"), d.b("automatic"), d.s("startNumber")), (false, true, false, "2".into())); + let labels = (0..4).map(|_| dialog_frame(&mut app, &ctx, screen, vec![])).last().unwrap(); + visible_label(&labels, screen, "Numbering & Section Options"); + assert!(!labels.iter().any(|(t, _, _)| t == "New Section")); + } + #[test] fn preferences_sidebar_scroll_keeps_selected_options_visible() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index bff4e0bd8..2d715ffbe 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -55,6 +55,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("New Section", ["Neuer Abschnitt", "Nouvelle section", "Nueva sección", "新規セクション", "新建章节"]), ("Reverse Path", ["Pfadrichtung umkehren", "Inverser le tracé", "Invertir trazado", "パスの方向反転", "反转路径"]), ("Set Anchor", ["Ankerpunkt festlegen", "Définir le point d’ancrage", "Definir punto de ancla", "アンカーポイントを設定", "设置锚点"]), ("Clear", ["Löschen", "Effacer", "Borrar", "消去", "清除"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index a182d09ec..f40ed5518 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("New Section", "قسم جديد"), ("Reverse Path", "عكس المسار"), ("Set Anchor", "تعيين نقطة الارتساء"), ("Clear", "مسح"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index aa8963f2c..f96181b3b 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("New Section", "Nuova sezione"), ("Reverse Path", "Inverti tracciato"), ("Set Anchor", "Imposta ancoraggio"), ("Clear", "Cancella"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 642b4a4c0..fdfcf0534 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("New Section", "新規セクション"), ("Reverse Path", "パスの方向反転"), ("Set Anchor", "アンカーポイントを設定"), ("Clear", "消去"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 982dfb20d..f144bbf2a 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("New Section", "Nova seção"), ("Reverse Path", "Inverter demarcador"), ("Set Anchor", "Definir ponto de ancoragem"), ("Clear", "Limpar"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index f3bc8fb35..a133bd753 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("New Section", "Новий розділ"), ("Reverse Path", "Розвернути контур"), ("Set Anchor", "Задати прив’язку"), ("Clear", "Очистити"), diff --git a/crates/ui-egui/src/section_options.rs b/crates/ui-egui/src/section_options.rs index 899b9fbed..428f60828 100644 --- a/crates/ui-egui/src/section_options.rs +++ b/crates/ui-egui/src/section_options.rs @@ -36,13 +36,17 @@ pub(crate) fn open(app: &mut DesignApp) { let doc = &st.doc; let page = current.min(doc.page_count().saturating_sub(1)); let own = doc.sections.iter().find(|s| s.start == page); - // A new section starts from the one the page is in. + // A new section starts from the one the page is in. As in InDesign, the window opened on a + // page that doesn't start a section is New Section: Start Section checked, Start Page + // Numbering at offering 1, Automatic Page Numbering chosen. let base = own.or_else(|| doc.section_of(page)); + let new = page != 0 && own.is_none(); let fields = json!({ "page": page + 1, - "startSection": page == 0 || own.is_some(), + "newSection": new, + "startSection": true, "automatic": own.is_none_or(|s| s.start_number.is_none()), - "startNumber": doc.page_number(page).to_string(), + "startNumber": if new { "1".to_string() } else { doc.page_number(page).to_string() }, "prefix": base.map(|s| s.prefix.clone()).unwrap_or_default(), "style": base.map(|s| s.style).unwrap_or_default(), "marker": base.map(|s| s.marker.clone()).unwrap_or_default(), @@ -160,7 +164,12 @@ pub(crate) fn body(app: &DesignApp, ui: &mut Ui, d: &mut Dialog) { if ui.radio(!automatic, crate::rtl::widget(ui, tr("Start Page Numbering at:"))).clicked() { d.fields.insert("automatic".into(), json!(false)); } - ui.add_enabled_ui(!automatic, |ui| text(ui, d, "startNumber", FIELD_W)); + // As in InDesign, the number stays editable; typing one chooses Start Page Numbering at. + let mut n = d.s("startNumber"); + if ui.add(egui::TextEdit::singleline(&mut n).desired_width(FIELD_W)).changed() { + d.fields.insert("startNumber".into(), Value::String(n)); + d.fields.insert("automatic".into(), json!(false)); + } }); ui.add_space(4.0); group(ui, rtl, tr("Page Numbering"), |ui| { @@ -200,7 +209,12 @@ pub(crate) fn body(app: &DesignApp, ui: &mut Ui, d: &mut Dialog) { } }); if value == ChapterSource::UserDefined { - ui.add_enabled_ui(source == value, |ui| text(ui, d, "chapterStart", FIELD_W)); + // Editable whichever is chosen, as in InDesign; typing a number chooses it. + let mut n = d.s("chapterStart"); + if ui.add(egui::TextEdit::singleline(&mut n).desired_width(FIELD_W)).changed() { + d.fields.insert("chapterStart".into(), Value::String(n)); + d.fields.insert("chapterSource".into(), json!(value)); + } } }); } From d70093244692d55e57cecbb46dddf9835dfb5162 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 14:07:17 +0200 Subject: [PATCH 002/207] New Section test: start page 3 at 5, an odd number, so it holds with #889's facing-page sides Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 26c339e72..a289d4005 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4179,7 +4179,8 @@ mod tests { } let labels = dialog_frame(&mut app, &ctx, screen, vec![]); visible_label(&labels, screen, "New Section"); - // Typing a number in the field chooses Start Page Numbering at; OK: page 3 is page 2. + // Typing a number in the field chooses Start Page Numbering at; OK: page 3 is page 5 (odd, + // so a facing page stays on the right). let field = visible_label(&labels, screen, "1"); click_dialog(&mut app, &ctx, screen, field.center()); dialog_frame( @@ -4189,18 +4190,18 @@ mod tests { vec![ egui::Event::Key { key: egui::Key::End, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }, egui::Event::Key { key: egui::Key::Backspace, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }, - egui::Event::Text("2".into()), + egui::Event::Text("5".into()), ], ); assert!(!app.ui.dialog.as_ref().unwrap().b("automatic"), "typing chose Start Page Numbering at"); let labels = dialog_frame(&mut app, &ctx, screen, vec![]); click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "OK").center()); assert!(app.ui.dialog.is_none()); - assert_eq!(app.session.active().unwrap().doc.page_name(2), "2"); + assert_eq!(app.session.active().unwrap().doc.page_name(2), "5"); // Opened again on that page, it is Numbering & Section Options, showing the section. crate::menus::activate(&mut app, "layout.section", &Value::Null); let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!((d.b("newSection"), d.b("startSection"), d.b("automatic"), d.s("startNumber")), (false, true, false, "2".into())); + assert_eq!((d.b("newSection"), d.b("startSection"), d.b("automatic"), d.s("startNumber")), (false, true, false, "5".into())); let labels = (0..4).map(|_| dialog_frame(&mut app, &ctx, screen, vec![])).last().unwrap(); visible_label(&labels, screen, "Numbering & Section Options"); assert!(!labels.iter().any(|(t, _, _)| t == "New Section")); From bf2cf8842bc28e4fcef146bcd9f7a73b77afbbdb Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 14:53:40 +0200 Subject: [PATCH 003/207] Direct Selection: pressing on a corner of an unselected object pulls just that corner Chris Nielsen's "The Adobe InDesign Toolbox" (10:10): with the White Arrow he clicks outside, then presses on a frame's corner and pulls it up, turning the rectangle into a lopsided triangle. DesignCraft only looked for anchors on objects already selected, so the press fell through to moving the whole frame. The Direct Selection tool now also looks at the anchors of the object under the pointer: on one, it selects the object and drags that point, in one undo step. Elsewhere on the object it still drags it whole; Shift- click still toggles the selection. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/tests.rs | 42 ++++++++++++++++++++++++++++++++++++++ crates/tools/src/select.rs | 13 ++++++++++++ 3 files changed, 56 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index c60a04107..0473076a4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · The Direct Selection tool pressed on a corner or anchor of an object that isn't selected selects it and pulls just that point, as in InDesign (it moved the whole object). No readiness percentage change. - **2026-10-12** · Text threads by hand: click a selected text frame's in or out port to load the text cursor, then click a frame to thread it, click empty space for a column-wide frame or drag one (it snaps like frame drawing); Shift-click autoflows, Alt-click stays loaded on the diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9d6308983..e40174c48 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -669,3 +669,45 @@ fn type_tool_frames_snap_to_the_margins() { assert!((r.x0 - margin.x0).abs() < 1e-6 && (r.y0 - margin.y0).abs() < 1e-6, "press point {r:?} vs {margin:?}"); assert!((r.x1 - margin.x1).abs() < 1e-6, "right edge {} vs {}", r.x1, margin.x1); } + +#[test] +fn direct_selection_pulls_one_corner_of_an_unselected_frame() { + // Chris Nielsen's "The Adobe InDesign Toolbox" (10:10): with the White Arrow, click outside, + // then press on a frame's corner and pull it: only that corner moves (a triangle, nearly). + use designcraft_tools::{PointerEvent, PointerKind, SnapView}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 612, "height": 792, "facingPages": false})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [156, 219, 455, 569], "content": "graphic"})).unwrap()["id"].as_u64().unwrap(); + s.execute("edit.deselectAll", &json!({})).unwrap(); + let corners = |s: &Session| { + let d = &s.doc().unwrap().doc; + let it = d.item(designcraft_doc::ItemId(id)).unwrap(); + it.path.subpaths[0].anchors.iter().map(|a| it.xf * a.p).collect::>() + }; + let before = corners(&s); + s.set_tool("directSelection"); + let v = ViewInfo { snap: SnapView::OFF, ..ViewInfo::at_zoom(1.0) }; + let layout = s.layout(); + let at = |x: f64, y: f64| layout.to_canvas(designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Point::new(x, y)); + let (a, b) = (at(455.0, 219.0), at(455.0, 150.0)); + s.pointer(&PointerEvent::new(PointerKind::Down, a.x, a.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, a.x, a.y - 30.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, b.x, b.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, b.x, b.y), v).unwrap(); + let after = corners(&s); + let moved: Vec<_> = before.iter().zip(&after).filter(|(p, q)| (**p - **q).hypot() > 1e-6).collect(); + assert_eq!(moved.len(), 1, "one corner moved: {before:?} → {after:?}"); + assert!((*moved[0].1 - designcraft_geom::Point::new(455.0, 150.0)).hypot() < 1e-6, "{after:?}"); + assert_eq!(s.doc().unwrap().selection.items, [designcraft_doc::ItemId(id)], "the frame is selected"); + // One undo step puts the corner back. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(corners(&s), before); + // Away from its corners, the White Arrow still drags the whole unselected frame. + s.execute("edit.deselectAll", &json!({})).unwrap(); + let (c, d) = (at(300.0, 219.0), at(300.0, 269.0)); + s.pointer(&PointerEvent::new(PointerKind::Down, c.x, c.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, c.x, c.y + 20.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, d.x, d.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, d.x, d.y), v).unwrap(); + assert!(corners(&s).iter().zip(&before).all(|(p, q)| (p.y - q.y - 50.0).abs() < 1e-6), "{:?}", corners(&s)); +} diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index b2ac97731..5dab5c128 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -507,6 +507,19 @@ impl Tool for SelectionTool { self.drag = Drag::Anchor { id, si, ai, handle, start: p, at }; return vec![Action::Begin(if handle.is_some() { "Move Direction Handle".into() } else { "Move Anchor".into() })]; } + // As in InDesign, the Direct Selection tool pressed on a corner or anchor of an object + // that isn't selected selects it and moves just that point (Chris Nielsen's "The + // InDesign Toolbox", 10:10: click outside, then pull a frame's corner). + if self.direct + && !ev.mods.shift + && let Some((_, hit)) = cx.hit(p) + && !cx.selection.contains(hit) + && let Some((id, si, ai, None)) = anchor_at_in(cx, &[hit], p) + { + let at = anchor_spread_point(cx, id, si, ai); + self.drag = Drag::Anchor { id, si, ai, handle: None, start: p, at }; + return vec![Action::Exec("selection.set".into(), json!({"ids": [id], "content": true})), Action::Begin("Move Anchor".into())]; + } if !self.direct && handle_at(cx, p).is_none() && let Some(center) = rotate_zone(cx, p) From b642cec8cbcff5cf51869aca728d915e4499606c Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 15:32:19 +0200 Subject: [PATCH 004/207] Anchored objects wrap their own story as their anchor lines are set #563 composed the story again with its objects where the previous pass put them, up to four times. In a long story whose objects move between frames from one pass to the next (Learn Skills Daily's 03_HP_LongDoc_Class, 79 pages), that never settled: stale wrap areas pushed headings into the next frame. Now the composer places a custom or above-line object when its anchor line is set and the lines after it wrap it in the same pass, as in InDesign. Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/lib.rs | 131 +++++++++++++++++++++++++------------ crates/compose/src/wrap.rs | 41 ++---------- 2 files changed, 94 insertions(+), 78 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 5689ced9f..7bebaf9a5 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -514,6 +514,9 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let mut out = ComposedStory { story: story.id, rev: story.rev, text_len: story.text.len(), ..Default::default() }; let mut styles_tab: Vec = Vec::new(); let mut missing_fonts: HashMap = HashMap::new(); + // The wrap of the story's own custom and above-line anchored objects, per frame, added as + // their anchor lines are set (see `own_wrap`). + let mut own_ex: Vec> = vec![Vec::new(); frames.len()]; let cols: Vec> = frames .iter() .map(|f| { @@ -986,7 +989,16 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C para_box_decos(&mut out, pi, &pp); break 'paras; } - let f = &frames[cur.fi]; + let merged; + let f = match own_ex.get(cur.fi).filter(|e| !e.is_empty()) { + Some(extra) => { + let mut spec = frames[cur.fi].clone(); + spec.exclusions.extend(extra.iter().cloned()); + merged = spec; + &merged + } + None => &frames[cur.fi], + }; let base = cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)]; let col = cur.clip(base); // A spanning paragraph isn't held to the balanced bottom of the columns above it. @@ -1232,16 +1244,32 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C continue 'paras; } } + let mut wraps_below = false; if !sub_objects.is_empty() { let ft = &mut out.frames[cur.fi]; let li = ft.lines.len() - 1; let text_ascent = line_glyphs.iter().filter(|g| g.ch != designcraft_doc::OBJECT_MARK).map(|g| g.ascent).fold(0.0, f64::max); let text_ascent = if text_ascent > 0.0 { text_ascent } else { base_chars.size * 0.75 }; + let first = ft.objects.len(); for g in ft.lines[li].glyphs.iter().filter(|g| g.len > 0) { if let Some(o) = sub_objects.get(&g.byte) { ft.objects.push(PlacedObject { index: o.index, origin: Point::ZERO, size: (o.w, o.h), line: li, x: g.x, text_ascent }); } } + // Custom and above-line objects anchored here wrap the lines after this one. + for o in ft.objects.get(first..).unwrap_or_default() { + if let (Some(l), Some(obj)) = (ft.lines.get(li), story.objects.get(o.index)) + && let Some(ex) = own_wrap(story, obj, o, l, &ft.columns, f) + { + for e in &mut own_ex { + e.retain(|x| x.after != ex.after); + } + wraps_below |= ex.rect.x0 < col.x1 && ex.rect.x1 > col.x0 && ex.rect.y1 > baseline && ex.rect.y0 < col.y1; + if let Some(e) = own_ex.get_mut(cur.fi) { + e.push(ex); + } + } + } } cur.last_baseline = Some(baseline); cur.last_reference = reference; @@ -1263,6 +1291,11 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C } // An own-story anchored object anchored in this line wraps the lines after it, // which were broken without it: break them again. + if k + 1 < breaks.len() && wraps_below { + g0 += b.next; + moved = true; + break; + } if k + 1 < breaks.len() && f.exclusions.iter().any(|ex| { ex.after.is_some_and(|a| (range_start..range_end).contains(&a)) @@ -1404,47 +1437,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C for o in &mut ft.objects { let Some(l) = ft.lines.get(o.line) else { continue }; let Some(obj) = story.objects.get(o.index) else { continue }; - let (w, h) = o.size; - o.origin = match &obj.position { - designcraft_doc::AnchorPosition::Inline { y_offset } => Point::new(o.x, l.baseline - y_offset - h), - designcraft_doc::AnchorPosition::AboveLine { align, space_after, .. } => { - let x = match align { - designcraft_doc::anchored::AnchorAlign::Left => l.x0, - designcraft_doc::anchored::AnchorAlign::Center => (l.x0 + l.x1 - w) / 2.0, - designcraft_doc::anchored::AnchorAlign::Right => l.x1 - w, - }; - Point::new(x, l.baseline - o.text_ascent - space_after - h) - } - designcraft_doc::AnchorPosition::Custom { - x_relative, - y_relative, - x_offset, - y_offset, - object_point, - ref_point, - keep_within_column, - } => { - use designcraft_doc::anchored::AnchorRelative as R; - let col = ft.columns.get(l.column as usize).copied().unwrap_or(f.area); - let area = |r: R| match r { - R::Anchor => None, - R::TextFrame => Some(f.area), - R::ColumnEdge => Some(col), - R::PageMargin => Some(f.page_rect.map_or(f.area, |p| p.1)), - R::PageEdge => Some(f.page_rect.map_or(f.area, |p| p.0)), - }; - let frac = |i: u8| ([0.0, 0.5, 1.0][(i % 3) as usize], [0.0, 0.5, 1.0][(i / 3).min(2) as usize]); - let (rfx, rfy) = frac(*ref_point); - let (ofx, ofy) = frac(*object_point); - let rx = area(*x_relative).map_or(o.x, |a| a.x0 + a.width() * rfx); - let ry = area(*y_relative).map_or(l.baseline, |a| a.y0 + a.height() * rfy); - let mut y = ry + y_offset - h * ofy; - if *keep_within_column { - y = y.clamp(col.y0, (col.y1 - h).max(col.y0)); - } - Point::new(rx + x_offset - w * ofx, y) - } - }; + o.origin = object_origin(obj, o, l, &ft.columns, f); } } } @@ -1471,6 +1464,60 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C out } +/// The wrap of a custom or above-line anchored object `obj` of `story`, placed as `o` on line `l` +/// of a frame (`columns`, spec `f`), in the frame's inner space: it wraps the lines of the story +/// that start after its anchor. None for inline objects (they sit in their line) and objects +/// without text wrap. +fn own_wrap(story: &Story, obj: &designcraft_doc::AnchoredObject, o: &PlacedObject, l: &Line, columns: &[Rect], f: &FrameSpec) -> Option { + if obj.item.wrap.mode == WrapMode::None || obj.item.hidden || matches!(obj.position, designcraft_doc::AnchorPosition::Inline { .. }) { + return None; + } + let anchor = story.text.match_indices(designcraft_doc::OBJECT_MARK).nth(o.index).map(|(i, _)| i)?; + let origin = object_origin(obj, o, l, columns, f); + let w = obj.item.wrap.offsets; + let b = obj.item.bounds(); + let rect = Rect::new(b.x0 - w[1], b.y0 - w[0], b.x1 + w[3], b.y1 + w[2]) + origin.to_vec2(); + rect.is_finite().then_some(Exclusion { rect, mode: obj.item.wrap.mode, after: Some(anchor) }) +} + +/// Where anchored object `obj`, placed as `o` on line `l` of a frame (`columns`, spec `f`), has its +/// top-left, in the frame's inner space. +fn object_origin(obj: &designcraft_doc::AnchoredObject, o: &PlacedObject, l: &Line, columns: &[Rect], f: &FrameSpec) -> Point { + let (w, h) = o.size; + match &obj.position { + designcraft_doc::AnchorPosition::Inline { y_offset } => Point::new(o.x, l.baseline - y_offset - h), + designcraft_doc::AnchorPosition::AboveLine { align, space_after, .. } => { + let x = match align { + designcraft_doc::anchored::AnchorAlign::Left => l.x0, + designcraft_doc::anchored::AnchorAlign::Center => (l.x0 + l.x1 - w) / 2.0, + designcraft_doc::anchored::AnchorAlign::Right => l.x1 - w, + }; + Point::new(x, l.baseline - o.text_ascent - space_after - h) + } + designcraft_doc::AnchorPosition::Custom { x_relative, y_relative, x_offset, y_offset, object_point, ref_point, keep_within_column } => { + use designcraft_doc::anchored::AnchorRelative as R; + let col = columns.get(l.column as usize).copied().unwrap_or(f.area); + let area = |r: R| match r { + R::Anchor => None, + R::TextFrame => Some(f.area), + R::ColumnEdge => Some(col), + R::PageMargin => Some(f.page_rect.map_or(f.area, |p| p.1)), + R::PageEdge => Some(f.page_rect.map_or(f.area, |p| p.0)), + }; + let frac = |i: u8| ([0.0, 0.5, 1.0][(i % 3) as usize], [0.0, 0.5, 1.0][(i / 3).min(2) as usize]); + let (rfx, rfy) = frac(*ref_point); + let (ofx, ofy) = frac(*object_point); + let rx = area(*x_relative).map_or(o.x, |a| a.x0 + a.width() * rfx); + let ry = area(*y_relative).map_or(l.baseline, |a| a.y0 + a.height() * rfy); + let mut y = ry + y_offset - h * ofy; + if *keep_within_column { + y = y.clamp(col.y0, (col.y1 - h).max(col.y0)); + } + Point::new(rx + x_offset - w * ofx, y) + } + } +} + /// The columns a spanning paragraph is set across, from `col`'s top to its bottom. fn span_rect(cols: &[Rect], col: Rect, span: SpanColumns) -> Rect { let SpanColumns::Span(n) = span else { return col }; diff --git a/crates/compose/src/wrap.rs b/crates/compose/src/wrap.rs index 31ec719d6..9962e1a70 100644 --- a/crates/compose/src/wrap.rs +++ b/crates/compose/src/wrap.rs @@ -10,22 +10,16 @@ //! other's text don't depend on each other in a cycle. //! - **Its own story.** A custom or above-line anchored object wraps the lines of its own story //! that come after the line holding its anchor; that line and the lines before it are never -//! wrapped by it (an inline object sits in its line and wraps none of its own story). The story -//! is composed again with the objects where the previous pass placed them until they stop -//! moving, at most [`MAX_PASSES`] more times. +//! wrapped by it (an inline object sits in its line and wraps none of its own story). As in +//! InDesign, the object is placed when its anchor line is set and wraps the text that follows +//! in the same composition (`own_wrap` in the composer), so the layout doesn't depend on where +//! an earlier composition put the objects. use designcraft_doc::{AnchorPosition, AnchoredObject, Document, OBJECT_MARK, SpreadRef, Story, StoryId, WrapMode}; use designcraft_geom::{Affine, Rect}; use crate::{ComposeOptions, ComposedStory, Exclusion, FrameSpec}; -/// Extra compositions of a story with its own anchored objects' wrap; a layout that keeps moving -/// its objects (keep options, vertical justification) stops here. -pub(crate) const MAX_PASSES: usize = 4; - -/// How far an obstacle may move between passes and still count as settled (points). -const SETTLED: f64 = 0.01; - /// A wrapping anchored object where its story's composition placed it. #[derive(Clone, Debug)] pub(crate) struct Obstacle { @@ -161,18 +155,6 @@ fn with_exclusions(specs: &[FrameSpec], added: &[Vec]) -> Vec], b: &[Vec]) -> bool { - let near = |x: &Exclusion, y: &Exclusion| { - x.mode == y.mode - && x.after == y.after - && (x.rect.x0 - y.rect.x0).abs() <= SETTLED - && (x.rect.y0 - y.rect.y0).abs() <= SETTLED - && (x.rect.x1 - y.rect.x1).abs() <= SETTLED - && (x.rect.y1 - y.rect.y1).abs() <= SETTLED - }; - a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.len() == y.len() && x.iter().zip(y).all(|(p, q)| near(p, q))) -} - /// Compose story `sid` with the wrapping anchored objects of `others` (compositions of its /// [`neighbours`]) and of its own. pub(crate) fn compose_among(doc: &Document, sid: StoryId, opts: &ComposeOptions, others: &[&ComposedStory]) -> ComposedStory { @@ -190,18 +172,5 @@ pub(crate) fn compose_among(doc: &Document, sid: StoryId, opts: &ComposeOptions, specs = with_exclusions(&specs, &added); } } - let mut out = crate::compose(doc, st, &specs, opts); - if !has_wrapping_objects(st) { - return out; - } - let mut prev = vec![Vec::new(); specs.len()]; - for _ in 0..MAX_PASSES { - let added = exclusions(doc, &specs, &obstacles(doc, st, &out, true)); - if settled(&added, &prev) { - break; - } - out = crate::compose(doc, st, &with_exclusions(&specs, &added), opts); - prev = added; - } - out + crate::compose(doc, st, &specs, opts) } From 56cef8aeeeb1eeea2927c17af840e90c9d43f87f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 16:07:17 +0200 Subject: [PATCH 005/207] Test and ROADMAP: an object wraps the frame its anchor lands in Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/compose/src/tests.rs | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 8f8523f0b..4b7faf5eb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -347,6 +347,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-15** · Anchored objects wrap their own story as their anchor lines are set, as in InDesign: the layout no longer depends on where an earlier composition put them (a long story whose pictures moved between frames from pass to pass never settled). No readiness percentage change. - **2026-10-12** · Anchored objects with text wrap push text aside: other stories' text under them and the lines of their own story after the anchor's line (#557). No readiness percentage change. - **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change. - **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change. diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 1f01498f9..2dc339697 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -4569,3 +4569,40 @@ fn anchored_wrap_that_moves_its_own_anchor_terminates() { assert!(cs.line_count() > 0); assert_eq!(cs.frames[0].objects.len(), 1); } + +/// An object's wrap pushes the text so that a later object's anchor goes on into the next frame +/// (Learn Skills Daily's long-document exercise: pictures anchored in headings through a 79-page +/// story). Each object is placed when its anchor line is set and wraps only the lines after it, +/// in the frame its anchor lands in: the layout doesn't depend on where an earlier composition put +/// the objects, and composing again gives the same lines. +#[test] +fn an_object_wraps_the_frame_its_anchor_lands_in_after_an_earlier_object_pushed_it_on() { + let text = [LOREM; 6].join(" "); + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (a, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 300.0, 300.0), lid, &text, ParaFormat::default()).unwrap(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(320.0, 0.0, 620.0, 300.0), lid, "", ParaFormat::default()).unwrap(); + d.thread(a, b).unwrap(); + let plain = compose_story(&d, sid, &ComposeOptions::default()); + // The second object's anchor sits near the end of the first frame without wrap. + let late = plain.frames[0].lines.iter().rev().nth(2).unwrap().range.start + 2; + anchor_box(&mut d, sid, late, 0.0, 0.0, true, WrapMode::BoundingBox); + // The first, early in the first frame: 60 pt over its whole width pushes the later anchor on. + let early = plain.frames[0].lines[1].range.start + 2; + anchor_box(&mut d, sid, early, 0.0, 0.0, true, WrapMode::JumpObject); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let late = late + OBJECT_LEN; + let anchor_frame = cs.frames.iter().position(|f| f.range.contains(&late) || f.range.end == late).unwrap(); + assert_eq!(anchor_frame, 1, "the later anchor went on into the second frame"); + // The first frame's lines under the first object's band keep the full measure (the second + // object is in the other frame); in the second frame, the lines under its object move right. + let left = cs.frames[0].lines[0].x0; + assert!(cs.frames[0].lines.iter().all(|l| (l.x0 - left).abs() < 1e-6)); + let f1 = &cs.frames[1]; + let (li, line) = f1.lines.iter().enumerate().find(|(_, l)| l.range.contains(&late)).unwrap(); + let under: Vec<_> = f1.lines[li + 1..].iter().filter(|l| l.baseline - l.ascent < line.baseline + 60.0).collect(); + assert!(!under.is_empty() && under.iter().all(|l| l.x0 >= f1.lines[0].x0 + 100.0 - 1e-6)); + assert_eq!(f1.objects.len(), 1); + // Deterministic: the same lines again. + assert_eq!(line_layout(&compose_story(&d, sid, &ComposeOptions::default())), line_layout(&cs)); +} From a04c805ad72dd9aac51e433977eed2d7c98f13ac Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 16 Oct 2026 10:53:30 +0200 Subject: [PATCH 006/207] Direct Selection on a selected frame selects its image, so Delete keeps the frame A Direct Selection click on an image left the selection alone when its frame was already selected with the Selection tool, so Delete removed the frame and the image together. The click now switches to the image inside, and Delete leaves the empty frame (Fixes #1124). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/tests.rs | 31 +++++++++++++++++++++++++++++++ crates/tools/src/select.rs | 8 +++++++- 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2aacdaf9b..46f5907bf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -347,6 +347,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-16** · A Direct Selection click on an image whose frame is already selected selects the image, so Delete removes only the image and leaves the empty frame, as in InDesign. No readiness percentage change. - **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change. - **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change. - **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 616a86398..cdce8b218 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1110,3 +1110,34 @@ fn a_line_fits_when_its_baseline_does() { assert!(!overset(&mut s, 78.5), "the line is set; its descenders hang below the frame"); assert!(overset(&mut s, 50.0), "a baseline below the frame still oversets the line"); } + +#[test] +fn direct_selection_on_a_selected_frame_deletes_only_its_image() { + use designcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + let dir = std::env::temp_dir().join(format!("dc-direct-delete-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let png = dir.join("photo.png"); + std::fs::write(&png, designcraft_render::Rendered { width: 100, height: 100, pixels: vec![40u8; 100 * 100 * 4] }.to_png()).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let frame = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + s.execute("file.place", &json!({"path": png.to_string_lossy(), "frame": frame})).unwrap(); + let v = ViewInfo::at_zoom(1.0); + let click = |s: &mut Session| { + s.pointer(&PointerEvent::new(PointerKind::Down, 150.0, 150.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 150.0, 150.0), v).unwrap(); + }; + // The frame is selected with the Selection tool, then the image is clicked with Direct Selection. + s.set_tool("selection"); + click(&mut s); + assert!(!s.doc().unwrap().selection.content); + s.set_tool("directSelection"); + click(&mut s); + assert!(s.doc().unwrap().selection.content, "Direct Selection selects the image inside the selected frame"); + assert!(s.tool_key(ToolKey::Delete, Mods::default(), v).unwrap()); + let st = s.doc().unwrap(); + let it = st.doc.item(designcraft_doc::ItemId(frame)).expect("the frame stays"); + assert!(matches!(it.content, designcraft_doc::Content::Unassigned), "{:?}", it.content); + assert_eq!(st.selection.items, vec![designcraft_doc::ItemId(frame)]); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 10da23f85..b66f0a0bd 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -225,6 +225,11 @@ pub fn handles(r: Rect) -> [Point; 8] { ] } +/// The item is a frame holding a placed graphic. +fn has_graphic(cx: &ToolContext, id: ItemId) -> bool { + cx.doc.item(id).is_some_and(|it| matches!(it.content, designcraft_doc::Content::Graphic(_))) +} + fn handle_at(cx: &ToolContext, p: Point) -> Option { let b = cx.selection_bounds()?; let tol = cx.tol(5.0); @@ -538,7 +543,8 @@ impl Tool for SelectionTool { } else { out.push(Action::Exec("selection.toggle".into(), json!({"id": id.0}))); } - } else if !cx.selection.contains(id) { + } else if !cx.selection.contains(id) || (self.direct && !cx.selection.content && has_graphic(cx, id)) { + // Direct Selection on a selected frame selects the graphic inside it. out.push(Action::Exec("selection.set".into(), json!({"ids": [id.0], "content": self.direct}))); } self.drag = Drag::Pending { start: p, hit: true }; From 37e632881a71b474f201adb9c44fd43368b6d1b2 Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:52:01 +0200 Subject: [PATCH 007/207] UI.ColorDefaults: show document defaults in the Color panel When nothing is selected, display the new-object fill and stroke supplied by #1081. Existing selection targets retain their path. Exercise a real spectrum click, existing/new shape colors and undo in the whole-window regression. Co-Authored-By: GPT-6 Astra --- crates/ui-egui/src/panels/swatches.rs | 47 ++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 3e7030e87..9cd71ac82 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -340,7 +340,15 @@ pub enum ColorMode { /// swatch), the spectrum ramp, Add to Swatches. Edits apply unnamed colours (`object.color`). pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let Some((fill, fill_tint, stroke_sw, stroke_tint)) = super::color_target(app) else { + let target = super::color_target(app).or_else(|| { + let state = app.session.active()?; + if !state.selection.items.is_empty() || state.selection.text.is_some() || state.selection.cells.is_some() { + return None; + } + let colors = &state.doc.settings.new_object; + Some((colors.fill.clone(), colors.fill_tint, colors.stroke.clone(), colors.stroke_tint)) + }); + let Some((fill, fill_tint, stroke_sw, stroke_tint)) = target else { ui.label(crate::rtl::widget( ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Select an object or text to color its fill or stroke.")).color(t.text_dim), @@ -546,6 +554,7 @@ fn cmyk_ramp(k: usize, x: f32) -> egui::Color32 { /// The spectrum ramp: hue across, light to dark down; click to apply. fn spectrum(app: &mut DesignApp, ui: &mut egui::Ui, target: &str) { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(&app.ui.language, "Color spectrum"))); let at = |u: f32, v: f32| -> [f32; 3] { let c = egui::ecolor::Hsva::new(u, (v * 2.0).min(1.0), (2.0 - v * 2.0).min(1.0), 1.0).to_rgb(); [c[0], c[1], c[2]] @@ -586,6 +595,42 @@ fn color_json(c: &designcraft_color::Color) -> serde_json::Value { } } +#[cfg(test)] +mod color_panel_defaults_tests { + use super::*; + use egui_kittest::kittest::Queryable; + + #[test] + fn color_panel_with_no_selection_edits_future_shapes_without_recoloring_existing_ones() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let old = + designcraft_doc::ItemId(app.run("frame.create", json!({"rect":[72,72,200,200],"content":"unassigned"})).unwrap()["id"].as_u64().unwrap()); + let original = app.session.active().unwrap().doc.item(old).unwrap().stroke.clone(); + app.run("selection.set", json!({"ids":[]})).unwrap(); + app.ui.stroke_front = true; + app.run("window.panel", json!({"panel":"color"})).unwrap(); + let mut h = crate::test_window::open(app, vec2(1440.0, 900.0)); + assert!(h.query_by_label("Select an object or text to color its fill or stroke.").is_none()); + let ramp = h.get_by_label("Color spectrum").rect(); + crate::test_window::click_at(&mut h, egui::pos2(ramp.left() + ramp.width() / 3.0, ramp.center().y)); + let defaults = h.state().app.session.active().unwrap().doc.settings.new_object.clone(); + assert_ne!(defaults.stroke, original.swatch); + assert_eq!(h.state().app.session.active().unwrap().doc.item(old).unwrap().stroke, original); + h.state_mut().app.run("selection.set", json!({"ids":[old.0]})).unwrap(); + assert_eq!(crate::panels::color_target(&mut h.state_mut().app).unwrap().2, original.swatch); + h.state_mut().app.run("selection.set", json!({"ids":[]})).unwrap(); + let new = designcraft_doc::ItemId( + h.state_mut().app.run("frame.create", json!({"rect":[240,72,368,200],"content":"unassigned"})).unwrap()["id"].as_u64().unwrap(), + ); + assert_eq!(h.state().app.session.active().unwrap().doc.item(new).unwrap().stroke.swatch, defaults.stroke); + h.state_mut().app.run("edit.undo", json!({})).unwrap(); + h.state_mut().app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.state().app.session.active().unwrap().doc.settings.new_object.stroke, original.swatch); + assert_eq!(h.state().app.session.active().unwrap().doc.item(old).unwrap().stroke, original); + } +} + /// Stops in `object.gradient` form. fn stops_json(g: &designcraft_color::Gradient) -> Vec { g.stops From a7e1c0e974c226985b61f96f75eb486443cb635c Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:52:53 +0200 Subject: [PATCH 008/207] Format.NullableOverrides: preserve automatic annotation and list-tab values Use the existing explicit-null deserializer for Ruby and Kenten sizes, tints and stroke weights, plus the automatic list tab. Extend native ZIP and legacy JSON inheritance regressions for local and named-style clears, absent overrides and explicit values. Found when a no-edit course-book roundtrip discarded eight CJK defaults. Co-Authored-By: GPT-6 Astra --- crates/doc/src/attrs.rs | 18 +++++++++--------- crates/format/src/lib.rs | 29 ++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index 077637672..d30489c9e 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -1086,7 +1086,7 @@ attr_set! { kenten_font: String = String::new(), kenten_font_style: String = String::new(), /// Mark size in points; `None` is half the text size. - kenten_size: Option = None, + kenten_size: Option = None => "deserialize_explicit_value", /// Horizontal / vertical scale of the mark, 1.0 = 100%. kenten_x_scale: f64 = 1.0, kenten_y_scale: f64 = 1.0, @@ -1099,10 +1099,10 @@ attr_set! { /// Mark fill and stroke swatches (empty = the text's), tints and stroke weight (`None` = /// the text's). kenten_fill: String = String::new(), - kenten_fill_tint: Option = None, + kenten_fill_tint: Option = None => "deserialize_explicit_value", kenten_stroke: String = String::new(), - kenten_stroke_tint: Option = None, - kenten_stroke_weight: Option = None, + kenten_stroke_tint: Option = None => "deserialize_explicit_value", + kenten_stroke_weight: Option = None => "deserialize_explicit_value", kenten_overprint_fill: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, kenten_overprint_stroke: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, // Shatai (斜体): the character is compressed across the direction at `shatai_angle`. @@ -1126,7 +1126,7 @@ attr_set! { ruby_font: String = String::new(), ruby_font_style: String = String::new(), /// Points; `None` is half the parent size. - ruby_font_size: Option = None, + ruby_font_size: Option = None => "deserialize_explicit_value", /// 1.0 = 100%. ruby_x_scale: f64 = 1.0, ruby_y_scale: f64 = 1.0, @@ -1147,10 +1147,10 @@ attr_set! { ruby_scaling_min: f64 = 0.66, /// Ruby colour: empty is the text's colour; `None` tint and weight follow the text. ruby_fill: String = String::new(), - ruby_fill_tint: Option = None, + ruby_fill_tint: Option = None => "deserialize_explicit_value", ruby_stroke: String = String::new(), - ruby_stroke_tint: Option = None, - ruby_stroke_weight: Option = None, + ruby_stroke_tint: Option = None => "deserialize_explicit_value", + ruby_stroke_weight: Option = None => "deserialize_explicit_value", ruby_overprint_fill: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, ruby_overprint_stroke: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, /// Digits (World-Ready): how 0–9 are drawn. @@ -1351,7 +1351,7 @@ attr_set! { bullet_align: ListAlign = ListAlign::Left, number_align: ListAlign = ListAlign::Left, /// A tab stop for the text after the bullet or number, from the column's left edge. - list_tab: Option = None, + list_tab: Option = None => "deserialize_explicit_value", /// Restart Numbers at This Level After: a paragraph at a higher level (0 = any; otherwise /// that level) starts this level's numbering again. restart_numbers: bool = true, diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index d5653eb9d..c77896b48 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -794,6 +794,14 @@ mod tests { underline_offset: Some(Some(1.0)), strikethrough_weight: Some(Some(2.0)), strikethrough_offset: Some(Some(1.0)), + kenten_size: Some(Some(14.0)), + kenten_fill_tint: Some(Some(0.3)), + kenten_stroke_tint: Some(Some(0.4)), + kenten_stroke_weight: Some(Some(2.0)), + ruby_font_size: Some(Some(14.0)), + ruby_fill_tint: Some(Some(0.3)), + ruby_stroke_tint: Some(Some(0.4)), + ruby_stroke_weight: Some(Some(2.0)), ..Default::default() }; // Legacy JSON loads as format 1, whose upgrade pins Keep Lines Together and Align Left @@ -803,6 +811,7 @@ mod tests { start_at: Some(Some(7)), paragraph_kashida_width: Some(Some(0.5)), kinsoku: Some(Some(Default::default())), + list_tab: Some(Some(36.0)), keep_all_lines: Some(true), drop_cap_align_left: Some(true), ..Default::default() @@ -817,9 +826,18 @@ mod tests { underline_offset: Some(None), strikethrough_weight: Some(None), strikethrough_offset: Some(None), + kenten_size: Some(None), + kenten_fill_tint: Some(None), + kenten_stroke_tint: Some(None), + kenten_stroke_weight: Some(None), + ruby_font_size: Some(None), + ruby_fill_tint: Some(None), + ruby_stroke_tint: Some(None), + ruby_stroke_weight: Some(None), ..Default::default() }; - let clear_para = ParaAttrs { start_at: Some(None), paragraph_kashida_width: Some(None), kinsoku: Some(None), ..Default::default() }; + let clear_para = + ParaAttrs { start_at: Some(None), paragraph_kashida_width: Some(None), kinsoku: Some(None), list_tab: Some(None), ..Default::default() }; let selected = if style_clear { std::sync::Arc::make_mut(&mut d.styles).paragraph.push(designcraft_doc::styles::ParagraphStyle { name: "Defaults".into(), @@ -860,6 +878,15 @@ mod tests { assert_eq!(expected.0.paragraph_kashida_width, None); assert_eq!(expected.0.kinsoku, None); assert_eq!(expected.1.leading_aki, None); + assert_eq!(expected.0.list_tab, None); + assert_eq!(expected.1.kenten_size, None); + assert_eq!(expected.1.kenten_fill_tint, None); + assert_eq!(expected.1.kenten_stroke_tint, None); + assert_eq!(expected.1.kenten_stroke_weight, None); + assert_eq!(expected.1.ruby_font_size, None); + assert_eq!(expected.1.ruby_fill_tint, None); + assert_eq!(expected.1.ruby_stroke_tint, None); + assert_eq!(expected.1.ruby_stroke_weight, None); // Legacy JSON is format 1: its upgrade pins older defaults on the other styles, so only // the native ZIP compares as the whole document. for (bytes, native) in [(save(d).unwrap(), true), (serde_json::to_vec(d).unwrap(), false)] { From 71869ac3c7500dfcdaa0d1c064d2efb3f79dd099 Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:40:37 +0200 Subject: [PATCH 009/207] UI.NewDocument: delete saved presets from their cards Show the existing trash icon on selected or hovered custom Saved cards and route it through file.deletePreset. Keep the factory card protected, prevent the action from selecting or creating a document, and retain the configured preview. Cover both selected and hovered-card clicks in the whole-window regression. Co-Authored-By: GPT-6 Astra --- crates/ui-egui/src/new_document.rs | 64 ++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/new_document.rs b/crates/ui-egui/src/new_document.rs index ad415c83d..3830b1244 100644 --- a/crates/ui-egui/src/new_document.rs +++ b/crates/ui-egui/src/new_document.rs @@ -396,7 +396,7 @@ fn card(ui: &mut Ui, c: &Card, selected: bool) -> egui::Response { let (r, resp) = ui.allocate_exact_size(CARD, Sense::click()); if selected { ui.painter().rect(r.shrink(1.0), 3.0, t.hover, Stroke::new(2.0, t.accent), StrokeKind::Inside); - } else if resp.hovered() { + } else if ui.rect_contains_pointer(r) { ui.painter().rect_filled(r, 3.0, t.hover); } let page = Rect::from_center_size(pos2(r.center().x, r.top() + 56.0), vec2(72.0, 72.0)); @@ -501,13 +501,20 @@ fn presets(app: &mut DesignApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.spacing_mut().item_spacing = vec2(10.0, 10.0); for c in &list { let resp = card(ui, c, current == c.key); - if resp.clicked() || resp.double_clicked() { - select(d, c); - } - if resp.double_clicked() { - create = true; - } + let mut remove = false; if tab == "saved" && c.key != DEFAULT_CARD { + if current == c.key || ui.rect_contains_pointer(resp.rect) { + let x = if rtl { resp.rect.left() + 4.0 } else { resp.rect.right() - 26.0 }; + let rect = Rect::from_min_size(pos2(x, resp.rect.top() + 4.0), vec2(22.0, 22.0)); + let button = ui.interact(rect, ui.id().with(("delete-preset", &c.key)), Sense::click()); + let label = format!("{}: {}", crate::i18n::tr(&lang, "Delete Preset"), c.label); + button.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label.as_str())); + if button.hovered() { + ui.painter().rect_filled(rect, 2.0, t.hover); + } + crate::icons::paint(ui.painter(), rect.shrink(3.0), "trash", t.icon); + remove = button.on_hover_text(label).clicked(); + } resp.context_menu(|ui| { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, "Delete Preset"))).clicked() { delete = Some(c.nd.title.clone()); @@ -515,6 +522,12 @@ fn presets(app: &mut DesignApp, ui: &mut Ui, d: &mut Dialog) -> bool { } }); } + if remove { + delete = Some(c.nd.title.clone()); + } else if resp.clicked() || resp.double_clicked() { + select(d, c); + create |= resp.double_clicked(); + } } }); if let Some(name) = delete @@ -1085,6 +1098,43 @@ mod tests { assert!(!egui::Popup::is_any_open(&h.ctx), "the popup closed"); } + #[test] + fn saved_preset_delete_buttons_keep_the_preview_and_never_create_a_document() { + use egui_kittest::kittest::Queryable as _; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.savePreset", json!({"name":"Portrait draft","pages":3,"facingPages":false})).unwrap(); + app.run("file.savePreset", json!({"name":"Landscape draft","width":792,"height":612,"pages":2})).unwrap(); + app.run("app.newDocumentDialog", json!({})).unwrap(); + let mut h = crate::test_window::open(app, vec2(1440.0, 900.0)); + h.run_steps(4); + let saved = h.get_by_label("Saved").rect().center(); + crate::test_window::click_at(&mut h, saved); + let portrait = h.get_by_label("Portrait draft").rect().center(); + crate::test_window::click_at(&mut h, portrait); + h.get_by_label("Preview").click(); + h.run_steps(20); + assert!(h.query_by_label("Delete Preset: Portrait draft").is_some(), "the selected preset has its action"); + assert!(h.query_by_label("Delete Preset: [Default]").is_none(), "the factory card cannot be deleted"); + let before = h.state().app.session.active().unwrap().doc.clone(); + let preview = h.state().app.session.new_document_preview(); + let count = h.state().app.session.documents().len(); + let fields = params(h.state().app.ui.dialog.as_ref().unwrap()); + let landscape = h.get_by_label("Landscape draft").rect().center(); + h.event(egui::Event::PointerMoved(landscape)); + h.run_steps(3); + let remove = h.get_by_label("Delete Preset: Landscape draft").rect().center(); + crate::test_window::click_at(&mut h, remove); + assert_eq!(h.state().app.session.prefs.document_presets.iter().map(|p| p.title.as_str()).collect::>(), ["Portrait draft"]); + let remove = h.get_by_label("Delete Preset: Portrait draft").rect().center(); + crate::test_window::click_at(&mut h, remove); + let app = &h.state().app; + assert!(app.session.prefs.document_presets.is_empty()); + assert_eq!(app.session.new_document_preview(), preview); + assert_eq!(app.session.documents().len(), count); + assert_eq!(*app.session.active().unwrap().doc, *before); + assert_eq!(params(app.ui.dialog.as_ref().unwrap()), fields, "deletion does not select another preset or create the preview"); + } + #[test] fn escape_in_the_preset_popup_closes_only_the_popup() { let mut h = window_with_preset_popup(); From 924d9c1b73489a7ec135d3661ef18fec780387c0 Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:24:56 +0200 Subject: [PATCH 010/207] TOC-2: generate and refresh nested PDF bookmarks --- ROADMAP.md | 2 + crates/doc/src/lib.rs | 6 +++ crates/engine/src/cmd/interactive.rs | 2 +- crates/engine/src/cmd/style.rs | 1 + crates/engine/src/cmd/toc.rs | 65 +++++++++++++++++++++++++++- crates/engine/src/tests_idml.rs | 3 +- crates/idml/src/export.rs | 2 +- crates/idml/src/import.rs | 3 +- crates/ui-egui/src/dialogs.rs | 33 +++++++++++++- docs/agents.md | 8 ++++ 10 files changed, 118 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9bd0274ef..71aa1b62b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -346,6 +346,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-10-18** · Table of Contents gains Create PDF Bookmarks: included headings become nested page destinations, regenerated on update without replacing manual bookmarks. The setting survives native and IDML files. No readiness percentage change. + Newest first. - **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 1bf834ae0..00c679ef9 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -793,6 +793,9 @@ pub struct Toc { /// Paragraph style of the title; `None` is the generated "TOC Title" style. #[serde(default, skip_serializing_if = "Option::is_none")] pub title_style: Option, + /// Generate a nested PDF outline from the included paragraphs when the TOC is updated. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub create_bookmarks: bool, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -803,6 +806,9 @@ pub struct Bookmark { pub page: usize, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub children: Vec, + /// Owned by the document's generated TOC, so updating it preserves manual bookmarks. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub generated_by_toc: bool, } impl Document { diff --git a/crates/engine/src/cmd/interactive.rs b/crates/engine/src/cmd/interactive.rs index 32562737c..2d918a01f 100644 --- a/crates/engine/src/cmd/interactive.rs +++ b/crates/engine/src/cmd/interactive.rs @@ -73,7 +73,7 @@ pub fn specs() -> Vec { let name = str_param(p, "name").map(str::to_string); s.edit(|d, _| { let name = name.clone().unwrap_or_else(|| format!("Bookmark {}", d.bookmarks.len() + 1)); - d.bookmarks.push(Bookmark { name, page, children: vec![] }); + d.bookmarks.push(Bookmark { name, page, children: vec![], generated_by_toc: false }); Ok(json!({"index": d.bookmarks.len() - 1})) }) }), diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index d1bd06ad0..642992e76 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -2987,6 +2987,7 @@ mod delete_tests { entries: vec![TocEntry { entry_style: Some("Table Body".into()), ..TocEntry::new("Table Body", 1) }], page_numbers: false, title_style: Some("Table Body".into()), + create_bookmarks: false, }); Ok(()) }) diff --git a/crates/engine/src/cmd/toc.rs b/crates/engine/src/cmd/toc.rs index 23f5847a4..0515df478 100644 --- a/crates/engine/src/cmd/toc.rs +++ b/crates/engine/src/cmd/toc.rs @@ -19,7 +19,7 @@ pub fn specs() -> Vec { "Table of Contents…", ["Layout"], None, - "{entries: [{style, level?: 1 (1-9), entryStyle?: paragraph style of its entries (default \"TOC Level n\"), pageNumber?: \"after\"|\"before\"|\"none\", separator?: text between entry and number (default a tab), pageNumberStyle?: character style of the page number, separatorStyle?: character style of the separator, sort?: bool (Sort Entries in Alphabetical Order: among their siblings, each with the entries under it)}] | styles: [names] (levels 1, 2, …), title?: \"Contents\", titleStyle?: paragraph style of the title (default \"TOC Title\"), pageNumbers?: true, replace?: true (regenerate the existing table of contents in place; false makes a new frame), page?: 1-based page for a new frame (its margins), rect?: [x0,y0,x1,y1] (spread coords)} → {story, entries}", + "{entries: [{style, level?: 1 (1-9), entryStyle?: paragraph style of its entries (default \"TOC Level n\"), pageNumber?: \"after\"|\"before\"|\"none\", separator?: text between entry and number (default a tab), pageNumberStyle?: character style of the page number, separatorStyle?: character style of the separator, sort?: bool (Sort Entries in Alphabetical Order: among their siblings, each with the entries under it)}] | styles: [names] (levels 1, 2, …), title?: \"Contents\", titleStyle?: paragraph style of the title (default \"TOC Title\"), pageNumbers?: true, createBookmarks?: bool (generate PDF bookmarks; keeps the existing setting when omitted), replace?: true (regenerate the existing table of contents in place; false makes a new frame), page?: 1-based page for a new frame (its margins), rect?: [x0,y0,x1,y1] (spread coords)} → {story, entries}", has_doc, generate ), @@ -302,9 +302,27 @@ fn fill(d: &mut Document, toc: &Toc, width: Option) -> Result { st.format_paras(r.start..r.start, |f| *f = ParaFormat { style: name.clone(), ..Default::default() }); } } + d.bookmarks.retain(|b| !b.generated_by_toc); + if toc.create_bookmarks { + d.bookmarks.extend(bookmarks(&found)); + } Ok(found.len()) } +/// Levels are validated to 1–9. Recursion follows strictly increasing levels, including when +/// a document skips a level; each destination is the physical page, not its section label. +fn bookmarks(found: &[Found]) -> Vec { + fn children(iter: &mut std::iter::Peekable>, parent: u8) -> Vec { + let mut out = Vec::new(); + while iter.peek().is_some_and(|f| f.level > parent) { + let Some(f) = iter.next() else { break }; + out.push(designcraft_doc::Bookmark { name: f.text.clone(), page: f.key.0, children: children(iter, f.level), generated_by_toc: true }); + } + out + } + children(&mut found.iter().peekable(), 0) +} + fn generate(s: &mut Session, p: &Value) -> Result { let entries = entries_param(p)?; if entries.is_empty() { @@ -320,6 +338,10 @@ fn generate(s: &mut Session, p: &Value) -> Result { return Err(bad("toc.generate", format!("style names are at most {MAX_NAME} characters"))); } let page_numbers = p.get("pageNumbers").and_then(Value::as_bool).unwrap_or(true); + let create_bookmarks = match p.get("createBookmarks") { + Some(v) => v.as_bool().ok_or_else(|| bad("toc.generate", "createBookmarks must be a boolean"))?, + None => s.doc()?.doc.toc.as_ref().is_some_and(|t| t.create_bookmarks), + }; let replace = p.get("replace").and_then(Value::as_bool).unwrap_or(true); let rect = super::rect_param(p, "rect"); if rect.is_some_and(|r| ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) || r.width() <= 0.0 || r.height() <= 0.0) { @@ -345,7 +367,7 @@ fn generate(s: &mut Session, p: &Value) -> Result { (sid, w) } }; - let toc = Toc { story, title: title.clone(), entries: entries.clone(), page_numbers, title_style: title_style.clone() }; + let toc = Toc { story, title: title.clone(), entries: entries.clone(), page_numbers, title_style: title_style.clone(), create_bookmarks }; let n = fill(d, &toc, width)?; d.toc = Some(toc); Ok(json!({"story": story.0, "entries": n})) @@ -623,6 +645,45 @@ mod tests { assert!(toc_runs(&s).iter().any(|(t, st)| t == "1" && st == "Folio"), "{:?}", toc_runs(&s)); } + #[test] + fn toc_bookmarks_refresh_nested_page_destinations_and_preserve_manual_bookmarks() { + let (mut s, page) = doc_with(&[("First", "Head"), ("Child", "Sub"), ("Last", "Head")]); + s.execute("layout.section", &json!({"page": 1, "style": "lowerRoman", "startNumber": 4})).unwrap(); + s.execute("bookmark.add", &json!({"name": "Manual", "page": page})).unwrap(); + let p = json!({"page": page, "createBookmarks": true, "entries": [ + {"style": "Head", "level": 1}, {"style": "Sub", "level": 3} + ]}); + s.execute("toc.generate", &p).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.bookmarks.iter().map(|b| b.name.as_str()).collect::>(), ["Manual", "First", "Last"]); + assert_eq!(d.bookmarks[1].page, 0, "physical page, although its label is iv"); + assert_eq!(d.bookmarks[1].children[0].name, "Child"); + assert!(d.bookmarks[1].children[0].generated_by_toc); + let sid = d.stories.iter().find(|(_, st)| st.text.starts_with("First")).map(|(sid, _)| *sid).unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 5})).unwrap(); + s.execute("text.insert", &json!({"text": "Changed"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.bookmarks[1].name, "First", "refresh is explicit"); + s.execute("toc.update", &json!({})).unwrap(); + let refreshed = s.doc().unwrap().doc.bookmarks.clone(); + assert_eq!(refreshed[1].name, "Changed"); + s.execute("toc.update", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.bookmarks, refreshed, "updates do not duplicate bookmarks"); + // Native storage retains both the option and ownership; old bookmarks default to manual. + let stored = serde_json::to_value(&s.doc().unwrap().doc).unwrap(); + let back: Document = serde_json::from_value(stored).unwrap(); + assert!(back.toc.as_ref().unwrap().create_bookmarks); + assert_eq!(back.bookmarks, refreshed); + let old: designcraft_doc::Bookmark = serde_json::from_value(json!({"name": "Old", "page": 0})).unwrap(); + assert!(!old.generated_by_toc); + let mut disabled = p; + disabled["createBookmarks"] = json!(false); + s.execute("toc.generate", &disabled).unwrap(); + assert_eq!(s.doc().unwrap().doc.bookmarks.len(), 1); + assert_eq!(s.doc().unwrap().doc.bookmarks[0].name, "Manual"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.bookmarks, refreshed); + } + #[test] fn toc_entry_options_round_trip_through_the_native_format() { let mut e = TocEntry::new("Head", 2); diff --git a/crates/engine/src/tests_idml.rs b/crates/engine/src/tests_idml.rs index 0701052d0..7afb2acd5 100644 --- a/crates/engine/src/tests_idml.rs +++ b/crates/engine/src/tests_idml.rs @@ -509,7 +509,7 @@ fn table_of_contents_definition_round_trips_through_idml_and_updates() { let basic = designcraft_doc::story::BASIC_PARAGRAPH; s.execute( "toc.generate", - &json!({"page": page, "title": "Inside", "titleStyle": basic, "entries": [ + &json!({"page": page, "title": "Inside", "titleStyle": basic, "createBookmarks": true, "entries": [ {"style": "Cover Title", "level": 1, "pageNumber": "before", "separator": " ^ "}, {"style": "Headline", "level": 2, "entryStyle": basic, "pageNumber": "none"}, ]}), @@ -524,6 +524,7 @@ fn table_of_contents_definition_round_trips_through_idml_and_updates() { let b64 = s.execute("file.exportIdml", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); s.execute("file.openIdml", &json!({"base64": b64, "name": "toc.idml"})).unwrap(); let after = s.doc().unwrap().doc.toc.clone().expect("the TOC definition is imported"); + assert!(after.create_bookmarks); assert_eq!((&after.title, &after.title_style, &after.entries), (&before.title, &before.title_style, &before.entries)); assert_eq!(toc_text(&s), text); s.execute("toc.update", &json!({})).unwrap(); diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index a773ec9e5..180f25305 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1111,7 +1111,7 @@ impl<'a> Ex<'a> { .attr("RunIn", "false") .attr("IncludeHidden", "false") .attr("IncludeBookDocuments", "false") - .attr("CreatePdfBookmarks", "false"); + .attr("CreateBookmarks", if toc.create_bookmarks { "true" } else { "false" }); for e in &toc.entries { let format = e.entry_style.clone().unwrap_or_else(|| format!("TOC Level {}", e.level)); let position = match e.page_number { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 9a8985348..92e6b250b 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1519,7 +1519,8 @@ impl<'r> Importer<'r> { } let title: String = ts.get("Title").unwrap_or("").chars().filter(|c| !matches!(c, '\n' | '\r' | '\u{2029}')).take(1024).collect(); let title_style = known(ts.get("TitleStyle")).filter(|n| n != "TOC Title"); - Some(designcraft_doc::Toc { story, title, entries, page_numbers: true, title_style }) + let create_bookmarks = ts.get("CreateBookmarks").or_else(|| ts.get("CreatePdfBookmarks")) == Some("true"); + Some(designcraft_doc::Toc { story, title, entries, page_numbers: true, title_style, create_bookmarks }) } fn para_style_ref(&self, r: &str) -> String { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 358416a4b..2a5cd7b24 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6261,6 +6261,7 @@ pub fn open_toc(app: &mut DesignApp) { "charStyles": char_styles, "hasToc": toc.is_some(), "replace": toc.is_some(), + "createBookmarks": toc.is_none_or(|t| t.create_bookmarks), }); app.ui.dialog = Some(Dialog::new(TOC, fields)); } @@ -6289,7 +6290,7 @@ fn toc_params(app: &DesignApp, d: &Dialog) -> Value { e }) .collect(); - let mut p = json!({"title": d.s("title"), "entries": entries, "replace": d.b("replace")}); + let mut p = json!({"title": d.s("title"), "entries": entries, "replace": d.b("replace"), "createBookmarks": d.b("createBookmarks")}); let title_style = d.s("titleStyle"); if !title_style.is_empty() { p["titleStyle"] = json!(title_style); @@ -6427,6 +6428,7 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } ui.add_space(8.0); crate::rtl::label(ui, egui::RichText::new(tr("Options")).font(semibold(12.0))); + check(ui, d, "createBookmarks", &tr("Create PDF Bookmarks")); let has_toc = d.b("hasToc"); ui.add_enabled_ui(has_toc, |ui| check(ui, d, "replace", &tr("Replace Existing Table of Contents"))); if let Some(st) = d.fields.get("status").and_then(Value::as_str) { @@ -7957,6 +7959,35 @@ mod tests { assert_eq!(opts(&app), before); } + #[test] + fn toc_bookmarks_checkbox_sets_and_reopens_the_persisted_option() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.newSample", json!({})).unwrap(); + open_toc(&mut app); + assert!(app.ui.dialog.as_ref().unwrap().b("createBookmarks")); + app.ui.dialog.as_mut().unwrap().fields.insert("included".into(), json!([{"style": "Headline", "level": 1}])); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "Create PDF Bookmarks"); + confirm(&mut app).unwrap(); + assert!(app.session.doc().unwrap().doc.toc.as_ref().unwrap().create_bookmarks); + assert!(!app.session.doc().unwrap().doc.bookmarks.is_empty()); + open_toc(&mut app); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "Create PDF Bookmarks").center()); + confirm(&mut app).unwrap(); + assert!(app.session.doc().unwrap().doc.bookmarks.is_empty()); + open_toc(&mut app); + assert!(!app.ui.dialog.as_ref().unwrap().b("createBookmarks")); + } + #[test] fn toc_dialog_adds_a_style_and_ok_makes_the_table_of_contents() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/docs/agents.md b/docs/agents.md index ed0d3bbd8..2358235ec 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -54,6 +54,14 @@ same references, and MCP's `batch` tool takes `commands` or `script` text with t `.epub`, `.html`, and `.rtf` or `.txt` (the story being edited or of the selected frame). A name with no extension is a PNG of the page; another extension is an error, and nothing runs. +## Table of contents bookmarks + +`toc.generate` accepts `createBookmarks: true` to generate page-level PDF bookmarks from the +included paragraph styles and their levels. `toc.update` refreshes their titles, hierarchy and +destinations. Manual bookmarks stay intact; disabling the option removes only the generated +bookmarks. Omitting the parameter keeps the existing TOC setting (false for a new command-created +TOC). The Table of Contents dialog exposes Create PDF Bookmarks and defaults it on for a new TOC. + ## Color themes `color.extractTheme {id?}` previews up to five RGB colours from a placed raster image (or the selected image). From ec179d5e69667f283037a766377e7e308f7e47ef Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:59:55 +0200 Subject: [PATCH 011/207] TOC-2: preserve manual bookmark ordering on refresh --- ROADMAP.md | 2 +- crates/engine/src/cmd/interactive.rs | 1 + crates/engine/src/cmd/style.rs | 1 + crates/engine/src/cmd/toc.rs | 13 +++++++++---- crates/engine/src/tests_idml.rs | 1 + crates/ui-egui/src/dialogs.rs | 1 + docs/agents.md | 3 ++- 7 files changed, 16 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 71aa1b62b..255c941d7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -346,7 +346,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log -- **2026-10-18** · Table of Contents gains Create PDF Bookmarks: included headings become nested page destinations, regenerated on update without replacing manual bookmarks. The setting survives native and IDML files. No readiness percentage change. +- **2026-10-18** · Table of Contents gains Create PDF Bookmarks: included headings become nested page destinations, regenerated in their existing outline position on update without replacing or moving surrounding manual bookmarks. The setting survives native and IDML files. No readiness percentage change. Newest first. diff --git a/crates/engine/src/cmd/interactive.rs b/crates/engine/src/cmd/interactive.rs index 2d918a01f..c49425d7a 100644 --- a/crates/engine/src/cmd/interactive.rs +++ b/crates/engine/src/cmd/interactive.rs @@ -6,6 +6,7 @@ use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, str_param}; use crate::{Result, Session}; +/// Register commands for editing hyperlinks and manual bookmarks. pub fn specs() -> Vec { vec![ cmd!( diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 642992e76..5e22142cb 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -2967,6 +2967,7 @@ mod delete_tests { assert_eq!(s.doc().unwrap().history.undo.len(), undo); } + /// Deleting a paragraph style also redirects TOC style references to its replacement. #[test] fn deleting_a_paragraph_style_points_every_use_at_the_replacement() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/toc.rs b/crates/engine/src/cmd/toc.rs index 0515df478..d3697c677 100644 --- a/crates/engine/src/cmd/toc.rs +++ b/crates/engine/src/cmd/toc.rs @@ -12,6 +12,7 @@ use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, str_param}; use crate::{Result, Session}; +/// Register TOC generation and refresh commands. pub fn specs() -> Vec { vec![ cmd!( @@ -302,9 +303,10 @@ fn fill(d: &mut Document, toc: &Toc, width: Option) -> Result { st.format_paras(r.start..r.start, |f| *f = ParaFormat { style: name.clone(), ..Default::default() }); } } + let insertion = d.bookmarks.iter().position(|b| b.generated_by_toc).unwrap_or(d.bookmarks.len()); d.bookmarks.retain(|b| !b.generated_by_toc); if toc.create_bookmarks { - d.bookmarks.extend(bookmarks(&found)); + d.bookmarks.splice(insertion..insertion, bookmarks(&found)); } Ok(found.len()) } @@ -312,6 +314,7 @@ fn fill(d: &mut Document, toc: &Toc, width: Option) -> Result { /// Levels are validated to 1–9. Recursion follows strictly increasing levels, including when /// a document skips a level; each destination is the physical page, not its section label. fn bookmarks(found: &[Found]) -> Vec { + /// Collect descendants until the next sibling or ancestor entry. fn children(iter: &mut std::iter::Peekable>, parent: u8) -> Vec { let mut out = Vec::new(); while iter.peek().is_some_and(|f| f.level > parent) { @@ -323,6 +326,7 @@ fn bookmarks(found: &[Found]) -> Vec { children(&mut found.iter().peekable(), 0) } +/// Validate the TOC settings and generate its frame, text, and outline in one edit. fn generate(s: &mut Session, p: &Value) -> Result { let entries = entries_param(p)?; if entries.is_empty() { @@ -645,6 +649,7 @@ mod tests { assert!(toc_runs(&s).iter().any(|(t, st)| t == "1" && st == "Folio"), "{:?}", toc_runs(&s)); } + /// Refresh preserves manual outline positions, nested destinations, storage, and undo. #[test] fn toc_bookmarks_refresh_nested_page_destinations_and_preserve_manual_bookmarks() { let (mut s, page) = doc_with(&[("First", "Head"), ("Child", "Sub"), ("Last", "Head")]); @@ -660,12 +665,13 @@ mod tests { assert_eq!(d.bookmarks[1].children[0].name, "Child"); assert!(d.bookmarks[1].children[0].generated_by_toc); let sid = d.stories.iter().find(|(_, st)| st.text.starts_with("First")).map(|(sid, _)| *sid).unwrap(); + s.execute("bookmark.add", &json!({"name": "Manual after", "page": page})).unwrap(); s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 5})).unwrap(); s.execute("text.insert", &json!({"text": "Changed"})).unwrap(); assert_eq!(s.doc().unwrap().doc.bookmarks[1].name, "First", "refresh is explicit"); s.execute("toc.update", &json!({})).unwrap(); let refreshed = s.doc().unwrap().doc.bookmarks.clone(); - assert_eq!(refreshed[1].name, "Changed"); + assert_eq!(refreshed.iter().map(|b| b.name.as_str()).collect::>(), ["Manual", "Changed", "Last", "Manual after"]); s.execute("toc.update", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc.bookmarks, refreshed, "updates do not duplicate bookmarks"); // Native storage retains both the option and ownership; old bookmarks default to manual. @@ -678,8 +684,7 @@ mod tests { let mut disabled = p; disabled["createBookmarks"] = json!(false); s.execute("toc.generate", &disabled).unwrap(); - assert_eq!(s.doc().unwrap().doc.bookmarks.len(), 1); - assert_eq!(s.doc().unwrap().doc.bookmarks[0].name, "Manual"); + assert_eq!(s.doc().unwrap().doc.bookmarks.iter().map(|b| b.name.as_str()).collect::>(), ["Manual", "Manual after"]); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc.bookmarks, refreshed); } diff --git a/crates/engine/src/tests_idml.rs b/crates/engine/src/tests_idml.rs index 7afb2acd5..7c8fe8609 100644 --- a/crates/engine/src/tests_idml.rs +++ b/crates/engine/src/tests_idml.rs @@ -500,6 +500,7 @@ fn type_para_sets_a_drop_cap_that_survives_idml() { assert_eq!(p.nested_styles, Some(vec![designcraft_doc::NestedStyle::drop_cap("Initial")])); } +/// Imported TOC definitions retain their settings and remain refreshable. #[test] fn table_of_contents_definition_round_trips_through_idml_and_updates() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2a5cd7b24..c4f8823c7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -7959,6 +7959,7 @@ mod tests { assert_eq!(opts(&app), before); } + /// The bookmarks checkbox persists through generation and reopening the dialog. #[test] fn toc_bookmarks_checkbox_sets_and_reopens_the_persisted_option() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/docs/agents.md b/docs/agents.md index 2358235ec..532811664 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -58,7 +58,8 @@ is a PNG of the page; another extension is an error, and nothing runs. `toc.generate` accepts `createBookmarks: true` to generate page-level PDF bookmarks from the included paragraph styles and their levels. `toc.update` refreshes their titles, hierarchy and -destinations. Manual bookmarks stay intact; disabling the option removes only the generated +destinations. Refresh keeps the generated outline at its previous position, preserving manual +bookmarks before and after it; disabling the option removes only the generated bookmarks. Omitting the parameter keeps the existing TOC setting (false for a new command-created TOC). The Table of Contents dialog exposes Create PDF Bookmarks and defaults it on for a new TOC. From 73088006237901ae990c50ec9ab063e82ecc4016 Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:29:40 +0200 Subject: [PATCH 012/207] TOC-3: keep the table of contents title readable --- ROADMAP.md | 2 ++ crates/ui-egui/src/dialogs.rs | 23 ++++++++++++++++++++++- docs/ui-parity.md | 5 ++++- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9bd0274ef..835c968ed 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-18** · Table of Contents reserves the title field's width so Contents is readable next to its style picker; the grid previously squeezed it to a few characters. No readiness percentage change. + - **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. - **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change. - **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 358416a4b..6885dc6d0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6334,7 +6334,10 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let mut title_style = d.s("titleStyle"); egui::Grid::new("toc_title").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, tr("Title:")); - text_field(ui, d, "title", 200.0); + let mut title = d.s("title"); + if ui.add_sized([200.0, ui.spacing().interact_size.y], egui::TextEdit::singleline(&mut title).horizontal_align(egui::Align::Min)).changed() { + d.fields.insert("title".into(), json!(title)); + } crate::rtl::label(ui, tr("Style:")); toc_style_combo(ui, "title", &styles, "TOC Title", &mut title_style); ui.end_row(); @@ -7957,6 +7960,24 @@ mod tests { assert_eq!(opts(&app), before); } + /// The title stays readable beside the style selector after the grid settles. + #[test] + fn toc_title_field_shows_the_whole_default_title() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.newSample", json!({})).unwrap(); + open_toc(&mut app); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "Contents"); + app.ui.dialog.as_mut().unwrap().fields.insert("title".into(), json!("Table of Contents")); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "Table of Contents"); + } + #[test] fn toc_dialog_adds_a_style_and_ok_makes_the_table_of_contents() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2dcf4687a..00f2df462 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) Tools, handles, snapping, nudging, modifiers, shortcuts, panels, menus, context menus and feel, against InDesign 2026. The look was measured from the running app @@ -70,6 +70,9 @@ history (DC-1). ## Remaining effort +The Table of Contents title field reserves its edit width inside the title/style row. The +default Contents title is fully visible instead of being clipped to its first few characters. + Applying a paragraph or character style now removes local values equal to the applied style, so text used to capture a style follows later edits to that style. Different local formatting and named character styles stay intact. Paragraph-style application treats selection endpoints From 8cc92a50b63f4339263474a3e87c5dd433f53434 Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:36:38 +0200 Subject: [PATCH 013/207] TOC-6: toggle advanced table of contents options --- ROADMAP.md | 2 ++ crates/ui-egui/src/dialogs.rs | 49 +++++++++++++++++++++++++++++++- crates/ui-egui/src/i18n.rs | 3 +- crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + docs/ui-parity.md | 3 ++ 14 files changed, 65 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9bd0274ef..6be95b6ad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-18** · Table of Contents offers More Options / Fewer Options, preserving page-number, separator, sorting and level settings while their controls are hidden. No readiness percentage change. + - **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. - **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change. - **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 358416a4b..f4be6cf2a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6328,6 +6328,7 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); let styles: Vec = d.fields.get("styles").cloned().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); let char_styles: Vec = d.fields.get("charStyles").cloned().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); + let mut expanded = d.fields.get("moreOptions").and_then(Value::as_bool).unwrap_or(true); let mut rows = toc_rows(d); let mut sel = d.n("selected").map_or(0, |n| n.max(0.0) as usize); let mut other = d.s("other"); @@ -6340,7 +6341,12 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.end_row(); }); ui.add_space(8.0); - crate::rtl::label(ui, egui::RichText::new(tr("Styles in Table of Contents")).font(semibold(12.0))); + ui.horizontal(|ui| { + crate::rtl::label(ui, egui::RichText::new(tr("Styles in Table of Contents")).font(semibold(12.0))); + if ui.button(tr(if expanded { "Fewer Options" } else { "More Options" })).clicked() { + expanded = !expanded; + } + }); let others: Vec<&String> = styles.iter().filter(|n| !rows.iter().any(|r| &r.style == *n)).collect(); ui.horizontal_top(|ui| { ui.vertical(|ui| { @@ -6393,6 +6399,9 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let auto = format!("TOC Level {}", r.level.clamp(1, 9)); toc_style_combo(ui, "entry", &styles, &auto, &mut r.entry_style); ui.end_row(); + if !expanded { + return; + } crate::rtl::label(ui, tr("Page Number:")); let choices = [("after", "After Entry"), ("before", "Before Entry"), ("none", "No Page Number")]; let cur = choices.iter().find(|(v, _)| *v == r.page_number).map_or("After Entry", |(_, l)| l); @@ -6433,6 +6442,7 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.add_space(6.0); crate::rtl::label(ui, egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); } + d.fields.insert("moreOptions".into(), json!(expanded)); d.fields.insert("included".into(), json!(rows)); d.fields.insert("selected".into(), json!(sel)); d.fields.insert("other".into(), json!(other)); @@ -7957,6 +7967,43 @@ mod tests { assert_eq!(opts(&app), before); } + /// Collapsing advanced TOC controls changes visibility without discarding their values. + #[test] + fn toc_more_options_preserves_hidden_settings() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.newSample", json!({})).unwrap(); + open_toc(&mut app); + app.ui.dialog.as_mut().unwrap().fields.insert( + "included".into(), + json!([{ + "style": "Headline", "level": 3, "separator": " ^t", "pageNumber": "before", + "pageNumberStyle": "Accent", "separatorStyle": "Emphasis", "sort": true + }]), + ); + let before = toc_params(&app, app.ui.dialog.as_ref().unwrap()); + let revision = app.session.doc().unwrap().revision; + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "Between Entry and Number:"); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "Fewer Options").center()); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + assert!(!labels.iter().any(|(text, _, _)| text == "Between Entry and Number:")); + visible_label(&labels, screen, "Entry Style:"); + assert_eq!(toc_params(&app, app.ui.dialog.as_ref().unwrap()), before); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "More Options").center()); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "Between Entry and Number:"); + assert_eq!(toc_params(&app, app.ui.dialog.as_ref().unwrap()), before); + assert_eq!(app.session.doc().unwrap().revision, revision); + } + #[test] fn toc_dialog_adds_a_style_and_ok_makes_the_table_of_contents() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..514f68da2 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1351,7 +1351,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ "适合框架高度到文本", ], ), - ("More Options", ["More Options", "Plus d'options", "More Options", "More Options", "更多选项"]), + ("More Options", ["Weitere Optionen", "Plus d'options", "Más opciones", "その他のオプション", "更多选项"]), + ("Fewer Options", ["Weniger Optionen", "Moins d'options", "Menos opciones", "オプションを減らす", "较少选项"]), ("Flip Horizontal", ["Flip Horizontal", "Retourner horizontalement", "Flip Horizontal", "Flip Horizontal", "水平翻转"]), ( "Rotate 90° Clockwise", diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..eb98ea711 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1,5 +1,6 @@ //! Original Arabic interface translations, stored in logical Unicode order. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "خيارات أقل"), ("File", "ملف"), ("Edit", "تحرير"), ("Layout", "تخطيط"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..8781a7025 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -3,6 +3,7 @@ //! product's localization was used). Phrases shared with the MIT OR Apache-2.0 Czech catalogs of //! the ArtCraft siblings (VectorCraft, PhotoCraft) are kept identical; see ASSETS.md for sources. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "Méně možností"), ("File", "Soubor"), ("Edit", "Úpravy"), ("Layout", "Rozvržení"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index efc128858..aeed5daa9 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -3,6 +3,7 @@ //! Brazilian Portuguese, Italian and Ukrainian tables has a row here; the German column of the shared //! table is carried over and stays the fallback for keys added there after this table. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "Weniger Optionen"), ("File", "Datei"), ("Edit", "Bearbeiten"), ("Layout", "Layout"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..820ebe9cf 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1,6 +1,7 @@ //! Original Italian interface translations, written from the English source //! using ordinary Italian desktop-publishing vocabulary. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "Meno opzioni"), ("File", "File"), ("Edit", "Modifica"), ("Layout", "Layout"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 5647ca940..d4e65dab1 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -3,6 +3,7 @@ //! Portuguese tables has a row here; the menu titles and panel names that were already Japanese in the //! shared table are carried over unchanged. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "オプションを減らす"), ("File", "ファイル"), ("Edit", "編集"), ("Layout", "レイアウト"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 975da9ca1..157339a31 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -4,6 +4,7 @@ //! Portuguese, Japanese, Italian and Ukrainian tables has a row here, plus a few labels that only //! the code or the command table use. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "ნაკლები პარამეტრი"), ("File", "ფაილი"), ("Edit", "რედაქტირება"), ("Layout", "განლაგება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..6b0e60473 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -2,6 +2,7 @@ //! are welcome. Terminology follows common Polish DTP usage //! (rozkładówka, łam, strona wzorcowa, obszar informacyjny) and the sibling ArtCraft catalogs. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "Mniej opcji"), ("File", "Plik"), ("Edit", "Edycja"), ("Layout", "Układ"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..2bb8c8de3 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1,6 +1,7 @@ //! Original Brazilian Portuguese interface translations, written from the English source //! using ordinary Brazilian Portuguese computing vocabulary. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "Menos opções"), ("File", "Arquivo"), ("Edit", "Editar"), ("Layout", "Layout"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..9ab1e23be 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -3,6 +3,7 @@ //! The translations are our own; counted nouns are genitive plurals because the caption puts the //! category before the number ("страниц: 5"), see `count_label`. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "Меньше параметров"), ("File", "Файл"), ("Edit", "Редактирование"), ("Can't Open the File", "Не удалось открыть файл"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..67c33cb8a 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -2,6 +2,7 @@ //! Common phrases are adapted from the MIT OR Apache-2.0 ArtCraft sibling catalogs //! (VectorCraft, PhotoCraft, WordCraft and DeckCraft); see ASSETS.md for pinned sources. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "Менше параметрів"), ("File", "Файл"), ("Edit", "Редагування"), ("Layout", "Макет"), diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2dcf4687a..32fe9c5ab 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -126,3 +126,6 @@ covers checking the sampled and redefined settings in the styles tutorial (2:45 | 2026-10-11 | minor | Added held Cmd+Space / Cmd+Alt+Space Zoom navigation and canvas input regression tests | | 2026-10-10 | minor | Threading row corrected to ✗ after checking the code (no out-port interaction, no thread command) | | 2026-10-10 | major | Created: measured menu coverage, 27-row interaction checklist from the code and issues | + +The Table of Contents dialog can collapse to its entry-style controls or expand to show page +number, separator, sorting and level controls. Toggling visibility preserves their settings. From 13576c365d806c80461ef51f59aac7f1448197f4 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 16:57:48 +0200 Subject: [PATCH 014/207] PR-1125: Direct Selection selects pasted-in content too; test in a private temp dir Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests.rs | 23 +++++++++++++++++++---- crates/tools/src/select.rs | 9 ++------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ef08c2a6b..66b1889f1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1654,9 +1654,8 @@ fn a_line_fits_when_its_baseline_does() { #[test] fn direct_selection_on_a_selected_frame_deletes_only_its_image() { use designcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; - let dir = std::env::temp_dir().join(format!("dc-direct-delete-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let png = dir.join("photo.png"); + let dir = tempfile::tempdir().unwrap(); + let png = dir.path().join("photo.png"); std::fs::write(&png, designcraft_render::Rendered { width: 100, height: 100, pixels: vec![40u8; 100 * 100 * 4] }.to_png()).unwrap(); let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); @@ -1680,5 +1679,21 @@ fn direct_selection_on_a_selected_frame_deletes_only_its_image() { let it = st.doc.item(designcraft_doc::ItemId(frame)).expect("the frame stays"); assert!(matches!(it.content, designcraft_doc::Content::Unassigned), "{:?}", it.content); assert_eq!(st.selection.items, vec![designcraft_doc::ItemId(frame)]); - let _ = std::fs::remove_dir_all(&dir); + // A frame holding pasted-in items: the click selects them too, so arrow keys move them. + let inner = s.execute("frame.create", &json!({"rect": [320, 120, 360, 160]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [inner]})).unwrap(); + s.execute("edit.cut", &json!({})).unwrap(); + let holder = s.execute("frame.create", &json!({"rect": [300, 100, 400, 200]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [holder]})).unwrap(); + s.execute("edit.pasteInto", &json!({})).unwrap(); + s.set_tool("selection"); + s.pointer(&PointerEvent::new(PointerKind::Down, 340.0, 140.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 340.0, 140.0), v).unwrap(); + assert_eq!(s.doc().unwrap().selection.items, vec![designcraft_doc::ItemId(holder)]); + assert!(!s.doc().unwrap().selection.content); + s.set_tool("directSelection"); + s.pointer(&PointerEvent::new(PointerKind::Down, 340.0, 140.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 340.0, 140.0), v).unwrap(); + let st = s.doc().unwrap(); + assert!(st.selection.content && st.selection.items == [designcraft_doc::ItemId(holder)], "the pasted-in items are selected: {:?}", st.selection); } diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 8a4cb1bd4..32573b19e 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -369,11 +369,6 @@ pub fn handles(r: Rect) -> [Point; 8] { ] } -/// The item is a frame holding a placed graphic. -fn has_graphic(cx: &ToolContext, id: ItemId) -> bool { - cx.doc.item(id).is_some_and(|it| matches!(it.content, designcraft_doc::Content::Graphic(_))) -} - /// The selected placed graphic (content selection of one graphic frame) and its bounds on the canvas. fn selected_graphic(cx: &ToolContext) -> Option<(ItemId, Rect)> { let [id] = cx.selection.items.as_slice() else { return None }; @@ -791,8 +786,8 @@ impl Tool for SelectionTool { } else { out.push(Action::Exec("selection.toggle".into(), json!({"id": id.0}))); } - } else if !cx.selection.contains(id) || (self.direct && !cx.selection.content && has_graphic(cx, id)) { - // Direct Selection on a selected frame selects the graphic inside it. + } else if !cx.selection.contains(id) || (self.direct && !cx.selection.content && cx.doc.item(id).is_some_and(holds_content)) { + // Direct Selection on a selected frame selects its content (a graphic or pasted-in items). out.push(Action::Exec("selection.set".into(), json!({"ids": [id.0], "content": self.direct}))); } let content = self.direct || on_selected_content(cx, p); From 31e4c346f1969e586747bc4a6f5d10142fa9c44e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 17:17:07 +0200 Subject: [PATCH 015/207] PR-1285: the TOC title field narrows in a small dialog so its style selector stays in view Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ab4070568..1abf914a3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6334,10 +6334,15 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let mut sel = d.n("selected").map_or(0, |n| n.max(0.0) as usize); let mut other = d.s("other"); let mut title_style = d.s("titleStyle"); + // The dialog body's width, less the two labels and the style selector (200 pt) beside the title. + let title_room = ui.available_width() - 300.0; egui::Grid::new("toc_title").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, tr("Title:")); let mut title = d.s("title"); - if ui.add_sized([200.0, ui.spacing().interact_size.y], egui::TextEdit::singleline(&mut title).horizontal_align(egui::Align::Min)).changed() { + // 200 pt, reserved: a desired width alone is squeezed by the grid to a few characters. + // Narrower when the dialog is, so the style selector beside it stays in view. + let w = 200.0_f32.min(title_room.max(80.0)); + if ui.add_sized([w, ui.spacing().interact_size.y], egui::TextEdit::singleline(&mut title).horizontal_align(egui::Align::Min)).changed() { d.fields.insert("title".into(), json!(title)); } crate::rtl::label(ui, tr("Style:")); @@ -7993,6 +7998,18 @@ mod tests { app.ui.dialog.as_mut().unwrap().fields.insert("title".into(), json!("Table of Contents")); let labels = dialog_frame(&mut app, &ctx, screen, vec![]); visible_label(&title_row(&labels), screen, "Table of Contents"); + // A small window: the title narrows and the style selector beside it stays in view. + let small = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(450.0, 280.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, small, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, small, vec![]); + let y = visible_label(&labels, small, "Title:").center().y; + let row: Vec<_> = labels.iter().filter(|(_, rect, _)| (rect.center().y - y).abs() < 2.0).cloned().collect(); + let style = visible_label(&row, small, "TOC Title"); + // The selector is 200 pt wide from just before its text: all of it is in the dialog body. + let (_, _, body) = row.iter().find(|(text, _, _)| text == "Style:").unwrap(); + assert!(style.min.x - 4.0 + 200.0 <= body.max.x, "the style selector fits the dialog: {style:?} in {body:?}"); } #[test] @@ -8931,7 +8948,7 @@ mod tests { #[test] fn dialog_title_and_actions_fit_scaled_viewports() { for language in ["en", "ar"] { - for size in [egui::vec2(1178.0, 814.0), egui::vec2(589.0, 407.0), egui::vec2(392.0, 271.0), egui::vec2(450.0, 280.0)] { + for size in [egui::vec2(1178.0, 814.0), egui::vec2(589.0, 407.0), egui::vec2(392.0, 271.0), egui::vec2(360.0, 280.0)] { for (command, title, ok, cancel) in [("app.preferences", "Preferences", "OK", "Cancel"), ("app.newDocumentDialog", "New Document", "Create", "Close")] { @@ -9047,7 +9064,7 @@ mod tests { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.run("app.preferences", json!({})).unwrap(); let ctx = dialog_context("en"); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(450.0, 280.0)); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(360.0, 280.0)); for _ in 0..3 { dialog_frame(&mut app, &ctx, screen, vec![]); } @@ -9107,7 +9124,7 @@ mod tests { #[test] fn arabic_new_document_fields_remain_reachable_and_editable() { - for size in [egui::vec2(392.0, 271.0), egui::vec2(450.0, 280.0), egui::vec2(1178.0, 814.0)] { + for size in [egui::vec2(392.0, 271.0), egui::vec2(360.0, 280.0), egui::vec2(1178.0, 814.0)] { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.ui.language = "ar".into(); app.run("app.newDocumentDialog", json!({})).unwrap(); From 54c369418a462029f8eba9339b64a9506353ef82 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 18:16:30 +0200 Subject: [PATCH 016/207] PR-1257: document TOC bookmark ownership across IDML and the createBookmarks default Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/lib.rs | 7 +++++++ crates/engine/src/cmd/toc.rs | 2 +- docs/agents.md | 6 ++++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 00c679ef9..4175776d4 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -807,6 +807,13 @@ pub struct Bookmark { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub children: Vec, /// Owned by the document's generated TOC, so updating it preserves manual bookmarks. + /// + /// Stored only in native files. Bookmarks (and so this flag) are not written to or read + /// from IDML yet, so after an IDML round trip the next TOC update builds the outline fresh. + /// If IDML bookmark import is added, imported bookmarks arrive with this flag false and the + /// next update would keep them as manual and add a second, duplicate outline. That import + /// must set this flag on the bookmarks the TOC generated (for example by matching them to + /// the TOC entries) when the TOC has `create_bookmarks` on. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub generated_by_toc: bool, } diff --git a/crates/engine/src/cmd/toc.rs b/crates/engine/src/cmd/toc.rs index d3697c677..b69d17e26 100644 --- a/crates/engine/src/cmd/toc.rs +++ b/crates/engine/src/cmd/toc.rs @@ -20,7 +20,7 @@ pub fn specs() -> Vec { "Table of Contents…", ["Layout"], None, - "{entries: [{style, level?: 1 (1-9), entryStyle?: paragraph style of its entries (default \"TOC Level n\"), pageNumber?: \"after\"|\"before\"|\"none\", separator?: text between entry and number (default a tab), pageNumberStyle?: character style of the page number, separatorStyle?: character style of the separator, sort?: bool (Sort Entries in Alphabetical Order: among their siblings, each with the entries under it)}] | styles: [names] (levels 1, 2, …), title?: \"Contents\", titleStyle?: paragraph style of the title (default \"TOC Title\"), pageNumbers?: true, createBookmarks?: bool (generate PDF bookmarks; keeps the existing setting when omitted), replace?: true (regenerate the existing table of contents in place; false makes a new frame), page?: 1-based page for a new frame (its margins), rect?: [x0,y0,x1,y1] (spread coords)} → {story, entries}", + "{entries: [{style, level?: 1 (1-9), entryStyle?: paragraph style of its entries (default \"TOC Level n\"), pageNumber?: \"after\"|\"before\"|\"none\", separator?: text between entry and number (default a tab), pageNumberStyle?: character style of the page number, separatorStyle?: character style of the separator, sort?: bool (Sort Entries in Alphabetical Order: among their siblings, each with the entries under it)}] | styles: [names] (levels 1, 2, …), title?: \"Contents\", titleStyle?: paragraph style of the title (default \"TOC Title\"), pageNumbers?: true, createBookmarks?: bool (generate PDF bookmarks; keeps the existing setting when omitted, which is off for a new table), replace?: true (regenerate the existing table of contents in place; false makes a new frame), page?: 1-based page for a new frame (its margins), rect?: [x0,y0,x1,y1] (spread coords)} → {story, entries}", has_doc, generate ), diff --git a/docs/agents.md b/docs/agents.md index 532811664..fcfffd933 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -60,8 +60,10 @@ is a PNG of the page; another extension is an error, and nothing runs. included paragraph styles and their levels. `toc.update` refreshes their titles, hierarchy and destinations. Refresh keeps the generated outline at its previous position, preserving manual bookmarks before and after it; disabling the option removes only the generated -bookmarks. Omitting the parameter keeps the existing TOC setting (false for a new command-created -TOC). The Table of Contents dialog exposes Create PDF Bookmarks and defaults it on for a new TOC. +bookmarks. Without `createBookmarks`, `toc.generate` keeps the document's existing TOC setting, +which is off when there is no TOC yet, so a script that makes a new TOC gets no bookmarks unless +it passes `createBookmarks: true`. The Table of Contents dialog differs: its Create PDF Bookmarks +option is on by default for a new TOC, and for an existing one it shows the saved setting. ## Color themes From 83e789340c8244c3ab0595ea62df109f9e244e0d Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 18:17:13 +0200 Subject: [PATCH 017/207] PR-1141: Swatches panel shows new-shape defaults; translate the spectrum label; test the fill Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n.rs | 9 ++++ crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + crates/ui-egui/src/panels/mod.rs | 13 ++++++ crates/ui-egui/src/panels/swatches.rs | 62 +++++++++++++++++++++++---- 13 files changed, 85 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..1bd7074bd 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -826,6 +826,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Choose Image…", ["Choose Image…", "Choisir une image…", "Choose Image…", "Choose Image…", "选择图像…"]), ("Class:", ["Class:", "Classe :", "Class:", "Class:", "类别:"]), ("Color Picker", ["Color Picker", "Sélecteur de couleurs", "Color Picker", "Color Picker", "拾色器"]), + ("Color spectrum", ["Farbspektrum", "Spectre de couleurs", "Espectro de color", "カラースペクトル", "色谱"]), ("Color Settings", ["Color Settings", "Réglages des couleurs", "Color Settings", "Color Settings", "颜色设置"]), ("Color:", ["Color:", "Couleur :", "Color:", "Color:", "颜色:"]), ("Columns", ["Columns", "Colonnes", "Columns", "Columns", "栏"]), @@ -5132,4 +5133,12 @@ mod tests { } } } + + #[test] + fn color_panel_spectrum_label_is_translated_in_every_language() { + for (code, _) in LANGUAGES.iter().filter(|(c, _)| !c.is_empty()) { + assert_ne!(tr(code, "Color spectrum"), "Color spectrum", "{code}"); + } + assert_eq!(tr("", "Color spectrum"), "Color spectrum"); + } } diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..d3466915a 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -349,6 +349,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object Layer Options", "خيارات طبقات الكائن"), ("Colour Picker", "منتقي الألوان"), ("Color Picker", "أداة اختيار الألوان"), + ("Color spectrum", "طيف الألوان"), ("Color Settings", "إعدادات الألوان"), ("User Dictionary", "قاموس المستخدم"), ("Menu Customization", "تخصيص القوائم"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..8ee256913 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -154,6 +154,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "Vybrat obrázek…"), ("Class:", "Třída:"), ("Color Picker", "Výběr barvy"), + ("Color spectrum", "Barevné spektrum"), ("Color Settings", "Nastavení barev"), ("Color:", "Barva:"), ("Columns", "Sloupce"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index efc128858..9747be28e 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -198,6 +198,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "Bild auswählen …"), ("Class:", "Klasse:"), ("Color Picker", "Farbwähler"), + ("Color spectrum", "Farbspektrum"), ("Color Settings", "Farbeinstellungen"), ("Color:", "Farbe:"), ("Columns", "Spalten"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..9d4fcdd2f 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -265,6 +265,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "Scegli immagine…"), ("Class:", "Classe:"), ("Color Picker", "Selettore colore"), + ("Color spectrum", "Spettro dei colori"), ("Color Settings", "Impostazioni colore"), ("Color:", "Colore:"), ("Columns", "Colonne"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 5647ca940..c8f33adf3 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -383,6 +383,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "画像を選択…"), ("Class:", "クラス:"), ("Color Picker", "カラーピッカー"), + ("Color spectrum", "カラースペクトル"), ("Color Settings", "カラー設定"), ("Color:", "カラー:"), ("Columns", "列数"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 975da9ca1..7a80d8455 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -187,6 +187,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "გამოსახულების არჩევა…"), ("Class:", "კლასი:"), ("Color Picker", "ფერის ამრჩევი"), + ("Color spectrum", "ფერთა სპექტრი"), ("Color Settings", "ფერის პარამეტრები"), ("Color:", "ფერი:"), ("Columns", "სვეტები"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..c70ba75ad 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -154,6 +154,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "Wybierz obraz…"), ("Class:", "Klasa:"), ("Color Picker", "Próbnik kolorów"), + ("Color spectrum", "Spektrum kolorów"), ("Color Settings", "Ustawienia kolorów"), ("Color:", "Kolor:"), ("Columns", "Łamy"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..8b1a1af7e 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -357,6 +357,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object Layer Options", "Opções de camada do objeto"), ("Colour Picker", "Seletor de cores"), ("Color Picker", "Seletor de cores"), + ("Color spectrum", "Espectro de cores"), ("Color Settings", "Configurações de cores"), ("User Dictionary", "Dicionário do usuário"), ("Menu Customization", "Personalização de menus"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..a4bc3e7f5 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -154,6 +154,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "Выбрать изображение…"), ("Class:", "Класс:"), ("Color Picker", "Палитра цветов"), + ("Color spectrum", "Цветовой спектр"), ("Color Settings", "Настройка цветов"), ("Color:", "Цвет:"), ("Columns", "Колонки"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..1fb033cce 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -268,6 +268,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "Вибрати зображення…"), ("Class:", "Клас:"), ("Color Picker", "Палітра кольорів"), + ("Color spectrum", "Колірний спектр"), ("Color Settings", "Налаштування кольору"), ("Color:", "Колір:"), ("Columns", "Колонки"), diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 7c40f3b1b..bc3f71795 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -80,6 +80,19 @@ pub fn color_target(app: &mut DesignApp) -> Option<(String, f32, String, f32)> { Some((i.fill, sel_tint(app, false), i.stroke, sel_tint(app, true))) } +/// [`color_target`], or with nothing selected the colours new shapes get (the Color and Swatches +/// panels then edit those, as InDesign's do). +pub fn color_target_or_defaults(app: &mut DesignApp) -> Option<(String, f32, String, f32)> { + color_target(app).or_else(|| { + let state = app.session.active()?; + if !state.selection.items.is_empty() || state.selection.text.is_some() || state.selection.cells.is_some() { + return None; + } + let colors = &state.doc.settings.new_object; + Some((colors.fill.clone(), colors.fill_tint, colors.stroke.clone(), colors.stroke_tint)) + }) +} + /// Apply a swatch (and tint) to the fill or stroke of what colour edits apply to. pub fn apply_swatch(app: &mut DesignApp, stroke: bool, swatch: &str, tint: Option) -> Result { remember_swatch(app, swatch); diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 9cd71ac82..87984b7a0 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -11,7 +11,8 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); - let target = super::color_target(app); + // With nothing selected, the proxy, Tint field and highlighted row are those of new shapes. + let target = super::color_target_or_defaults(app); header(app, ui, &doc, target.as_ref()); // The row of the colour in front of the proxy is highlighted. let fill_cur = target.map(|(fill, _, stroke, _)| if app.ui.stroke_front { stroke } else { fill }); @@ -340,14 +341,7 @@ pub enum ColorMode { /// swatch), the spectrum ramp, Add to Swatches. Edits apply unnamed colours (`object.color`). pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let target = super::color_target(app).or_else(|| { - let state = app.session.active()?; - if !state.selection.items.is_empty() || state.selection.text.is_some() || state.selection.cells.is_some() { - return None; - } - let colors = &state.doc.settings.new_object; - Some((colors.fill.clone(), colors.fill_tint, colors.stroke.clone(), colors.stroke_tint)) - }); + let target = super::color_target_or_defaults(app); let Some((fill, fill_tint, stroke_sw, stroke_tint)) = target else { ui.label(crate::rtl::widget( ui, @@ -624,10 +618,60 @@ mod color_panel_defaults_tests { h.state_mut().app.run("frame.create", json!({"rect":[240,72,368,200],"content":"unassigned"})).unwrap()["id"].as_u64().unwrap(), ); assert_eq!(h.state().app.session.active().unwrap().doc.item(new).unwrap().stroke.swatch, defaults.stroke); + // Two undo steps: the new frame, then the spectrum click's default stroke. h.state_mut().app.run("edit.undo", json!({})).unwrap(); h.state_mut().app.run("edit.undo", json!({})).unwrap(); assert_eq!(h.state().app.session.active().unwrap().doc.settings.new_object.stroke, original.swatch); assert_eq!(h.state().app.session.active().unwrap().doc.item(old).unwrap().stroke, original); + // With the Fill box in front, the ramp sets the default fill instead. + let original_fill = h.state().app.session.active().unwrap().doc.item(old).unwrap().fill.clone(); + h.state_mut().app.ui.stroke_front = false; + h.run_steps(2); + let ramp = h.get_by_label("Color spectrum").rect(); + crate::test_window::click_at(&mut h, egui::pos2(ramp.left() + ramp.width() * 2.0 / 3.0, ramp.center().y)); + let defaults = h.state().app.session.active().unwrap().doc.settings.new_object.clone(); + assert_ne!(defaults.fill, original_fill.swatch); + assert_eq!(defaults.stroke, original.swatch, "the stroke default is left alone"); + assert_eq!(h.state().app.session.active().unwrap().doc.item(old).unwrap().fill, original_fill); + } + + /// The Swatches panel shows the same defaults: its Tint field reads the default fill's tint, + /// and a click on a swatch row sets the default fill, not an existing frame's. + #[test] + fn swatches_panel_with_no_selection_shows_and_edits_future_shape_colors() { + use egui::accesskit::Role; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let old = + designcraft_doc::ItemId(app.run("frame.create", json!({"rect":[72,72,200,200],"content":"unassigned"})).unwrap()["id"].as_u64().unwrap()); + let original = app.session.active().unwrap().doc.item(old).unwrap().fill.clone(); + app.run("selection.set", json!({"ids":[]})).unwrap(); + app.run("object.fill", json!({"swatch": "[Black]", "tint": 0.4})).unwrap(); + app.ui.stroke_front = false; + let mut h = egui_kittest::Harness::builder().with_size(vec2(320.0, 480.0)).build_ui_state( + |ui, app: &mut DesignApp| { + // The panel's fonts are installed on the first frame and used from the next. + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + let fields = h.query_all_by(|n| matches!(n.role(), Role::SpinButton | Role::TextInput)).filter_map(|n| n.value()).collect::>(); + assert!(fields.iter().any(|v| v.starts_with("40")), "the Tint field shows the default fill's 40 %: {fields:?}"); + let row = h.get_by_label("[Paper]").rect().center(); + h.hover_at(row); + h.drag_at(row); + h.drop_at(row); + h.run_steps(3); + let defaults = h.state().session.active().unwrap().doc.settings.new_object.clone(); + assert_eq!((defaults.fill.as_str(), defaults.fill_tint), ("[Paper]", 1.0)); + assert_eq!(h.state().session.active().unwrap().doc.item(old).unwrap().fill, original); } } From 44cbf0b214603e0ddabc45f62dd95d8e22177ae7 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 18:27:07 +0200 Subject: [PATCH 018/207] Properties: the arrow beside Opacity opens a slider, as in InDesign Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/panels/properties.rs | 84 ++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..a4a902e2f 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-20** · Properties › Appearance: the arrow beside Opacity opens a slider under the field, as in InDesign, instead of the Effects panel; a drag shows the opacity live and is one undo step (the Opacity link still opens Effects). 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/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 44acf6e2c..fbdb417d8 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -856,9 +856,8 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { let mresp = ui.interact(more, ui.id().with("opmore"), Sense::click()); ui.painter().rect(more, 0.0, if mresp.hovered() { t.hover } else { t.input }, Stroke::new(1.0, t.field_border), StrokeKind::Inside); icons::paint(ui.painter(), more.shrink(4.0), "chevron-right", t.icon); - if mresp.clicked() { - show_panel(app, "effects"); - } + mresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(&app.ui.language, "Opacity"))); + opacity_slider(app, &mresp, i.opacity); ui.add_space(6.0); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 10.0; @@ -871,6 +870,36 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { }); } +/// The Opacity slider that the arrow beside the field opens, as in InDesign: a drag shows the +/// new opacity live and is one undo step when let go. +fn opacity_slider(app: &mut DesignApp, resp: &egui::Response, opacity: f64) { + let (mut slid, mut held) = (None, false); + egui::Popup::menu(resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + // A thin rail and a round knob, as InDesign draws it. + ui.spacing_mut().slider_width = 130.0; + ui.spacing_mut().slider_rail_height = 2.0; + let mut v = (opacity * 100.0).round(); + let slider = ui.add( + egui::Slider::new(&mut v, 0.0..=100.0).integer().show_value(false).handle_shape(egui::style::HandleShape::Circle).trailing_fill(false), + ); + let pressed = slider.is_pointer_button_down_on() || slider.dragged(); + if slider.changed() { + slid = Some((v, pressed)); + } + held = pressed; + }); + match slid { + Some((v, true)) => app.live_preview("Opacity", "object.opacity", json!({"opacity": v / 100.0})), + Some((v, false)) => { + // The keyboard (arrow keys on the focused slider): applied at once. + app.end_live_preview(); + let _ = app.run("object.opacity", json!({"opacity": v / 100.0})); + } + None if held => app.hold_live_preview(), + None => {} + } +} + /// The Fill (or Stroke) popup of Properties › Appearance, as InDesign's: Apply To (a text frame's /// frame or its text), the tint, then the swatches. `cur` is the frame's swatch. With `text_only` /// (text selected) it colours the selected text. @@ -3343,6 +3372,55 @@ mod tests { assert!((r.x0 - 200.0).abs() < 0.5 && (r.y1 - 200.0).abs() < 0.5 && (r.x1 - 300.0).abs() < 0.5, "{r:?}"); } + /// The arrow beside Opacity opens a slider, as in InDesign: a drag shows the opacity live and + /// is one undo step; the Opacity link still opens the Effects panel. + #[test] + fn the_opacity_arrow_opens_a_slider_and_a_drag_is_one_undo_step() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let opacity = |app: &DesignApp| app.session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().opacity; + let steps = undo_steps(&app); + let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + app.settle_live_preview(); + show(app, ui); + }, + app, + ); + h.run_steps(3); + assert!(h.query_by_role(egui::accesskit::Role::Slider).is_none()); + h.get_by(|n| n.role() == egui::accesskit::Role::Button && n.label().as_deref() == Some("Opacity")).click(); + h.run_steps(3); + let track = h.get_by_role(egui::accesskit::Role::Slider).rect(); + let y = track.center().y; + h.hover_at(egui::pos2(track.right() - 1.0, y)); + h.run_steps(1); + h.drag_at(egui::pos2(track.right() - 1.0, y)); + h.run_steps(1); + for k in 1..=6 { + h.hover_at(egui::pos2(track.right() - track.width() * 0.05 * k as f32, y)); + h.run_steps(1); + assert_eq!(undo_steps(h.state()), steps, "no undo step while dragging"); + } + let previewed = opacity(h.state()); + assert!(previewed < 0.9 && previewed > 0.5, "the drag shows the opacity live: {previewed}"); + h.drop_at(egui::pos2(track.right() - track.width() * 0.3, y)); + h.run_steps(3); + assert_eq!(undo_steps(h.state()), steps + 1, "the whole drag is one undo step"); + assert!(opacity(h.state()) < 0.9); + assert_ne!(h.state().ui.open_panel.as_deref(), Some("effects"), "the arrow doesn't open Effects"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(opacity(h.state()), 1.0); + } + const CYAN: &str = "C=100 M=0 Y=0 K=0"; #[test] From 9b48a538d5ad0745ffaaedfadc8ce88e95d2da55 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 18:35:46 +0200 Subject: [PATCH 019/207] Properties: Text Wrap shows the four wrap offsets, as in InDesign Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/panels/properties.rs | 99 +++++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..35fbb08f8 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-20** · Properties › Text Wrap shows the four wrap offsets under the wrap buttons, with the link that makes them the same, as InDesign's Properties panel does (greyed with no wrap). 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/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 44acf6e2c..3f8ed3353 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1015,11 +1015,64 @@ fn wrap_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { let _ = app.run("object.textWrap", json!({"mode": i.wrap, "invert": inv})); } }); + wrap_offsets(app, ui, i); if more_options(ui, &app.ui.language).clicked() { show_panel(app, "textWrap"); } } +/// The wrap offsets under the wrap buttons, as InDesign's Properties panel shows them: Top and +/// Bottom, the link that makes all four the same, then Left and Right. Greyed with no wrap. +fn wrap_offsets(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + let u = units(app); + let enabled = i.wrap != "none"; + let offsets = i.wrap_offsets; + ui.add_space(4.0); + let b = block(ui, 2.0 * FIELD_H + 5.0); + let mut set = None; + ui.add_enabled_ui(enabled, |ui| { + // Linked while the four are the same, until the link is clicked. + let linked = temp_bool(ui, "prop_wrap_linked", offsets.iter().all(|v| (v - offsets[0]).abs() < 1e-9)); + // Offsets are [top, left, bottom, right]. + for (k, key, icon, tip, x, y) in [ + (0, "wrapTop", "margin-top", "Top", 0.0, 0.0), + (2, "wrapBottom", "margin-bottom", "Bottom", 0.0, FIELD_H + 5.0), + (1, "wrapLeft", "margin-inside", "Left", 140.0, 0.0), + (3, "wrapRight", "margin-outside", "Right", 140.0, FIELD_H + 5.0), + ] { + let ir = sub(b, x, y + 2.0, 17.0, 17.0); + icons::paint(ui.painter(), ir, icon, if enabled { t.icon } else { t.text_dim }); + ui.interact(ir, ui.id().with(("prop_wrap_off", k)), Sense::hover()).on_hover_ui(|ui| { + crate::rtl::label(ui, tr(tip)); + }); + let nv = place(ui, sub(b, x + 20.0, y, 76.0, FIELD_H), |ui| { + NumField::measure(key, Some(offsets[k]), u).width(76.0).spinner().range(-8000.0, 8000.0).enabled(enabled).show(ui) + }); + if let Some(v) = nv { + let mut o = offsets; + if linked { + o = [v; 4]; + } else { + o[k] = v; + } + set = Some(o); + } + } + if place(ui, sub(b, 106.0, (FIELD_H + 5.0) / 2.0, 22.0, FIELD_H), |ui| { + widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, &tr("Make all settings the same"), vec2(22.0, 21.0)) + .clicked() + }) { + set_temp_bool(ui, "prop_wrap_linked", !linked); + } + }); + if let Some(o) = set { + let _ = app.run("object.textWrap", json!({"mode": i.wrap, "offset": o})); + } +} + fn arrange_menu(app: &mut DesignApp, resp: &egui::Response) { egui::Popup::menu(resp).show(|ui| { for (label, cmd) in [ @@ -3343,6 +3396,52 @@ mod tests { assert!((r.x0 - 200.0).abs() < 0.5 && (r.y1 - 200.0).abs() < 0.5 && (r.x1 - 300.0).abs() < 0.5, "{r:?}"); } + /// Properties › Text Wrap shows the four offsets under the wrap buttons, as InDesign's + /// Properties panel does: linked at first, so one value sets all four; unlinked, one side. + #[test] + fn properties_text_wrap_shows_and_sets_the_offsets() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + app.run("object.textWrap", json!({"mode": "boundingBox", "offset": 0})).unwrap(); + let offsets = |app: &DesignApp| app.session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().wrap.offsets; + let mut h = panel(app); + // The fields between the Text Wrap heading and Quick Actions: Top, Left, Bottom, Right. + let fields = |h: &Harness<'_, DesignApp>| { + let (top, bottom) = (h.get_by_label("Text Wrap").rect().bottom(), h.get_by_label("Quick Actions").rect().top()); + let mut v: Vec = h + .get_all_by_role(egui::accesskit::Role::TextInput) + .map(|n| n.rect()) + .filter(|r| r.center().y > top && r.center().y < bottom) + .collect(); + v.sort_by(|a, b| (a.top(), a.left()).partial_cmp(&(b.top(), b.left())).unwrap()); + v + }; + let f = fields(&h); + assert_eq!(f.len(), 4, "four offsets: {f:?}"); + let type_in = |h: &mut Harness<'_, DesignApp>, at: egui::Rect, text: &str| { + h.hover_at(at.center()); + h.drag_at(at.center()); + h.drop_at(at.center()); + h.run_steps(2); + h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); + h.event(egui::Event::Text(text.into())); + h.key_press(egui::Key::Enter); + h.run_steps(3); + }; + // Linked: the top offset sets all four. + type_in(&mut h, f[0], "12pt"); + assert_eq!(offsets(h.state()), [12.0; 4]); + // Unlinked: the bottom one alone. + let (top, bottom) = (h.get_by_label("Text Wrap").rect().bottom(), h.get_by_label("Quick Actions").rect().top()); + h.get_all_by_label("Make all settings the same").find(|n| n.rect().center().y > top && n.rect().center().y < bottom).unwrap().click(); + h.run_steps(2); + let f = fields(&h); + type_in(&mut h, f[2], "6pt"); + assert_eq!(offsets(h.state()), [12.0, 12.0, 6.0, 12.0], "[top, left, bottom, right]"); + } + const CYAN: &str = "C=100 M=0 Y=0 K=0"; #[test] From 6e2d3497990184bf22099913542c24f8e699458a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 19:17:46 +0200 Subject: [PATCH 020/207] Properties: Frame Fitting has Auto-Fit and Options, as in InDesign; checkboxes are named for screen readers Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/panels/properties.rs | 40 +++++++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 3 ++ 3 files changed, 44 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..3a334b9d3 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-20** · Properties › Frame Fitting has InDesign's Auto-Fit checkbox and Options button (Frame Fitting Options); the app's checkboxes are now named and ticked-or-not for screen readers. 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/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 44acf6e2c..b15e3471d 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -167,7 +167,21 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let _ = app.run("object.fit", json!({"mode": mode})); } } + // Frame Fitting Options…, as InDesign's Options button beside the fitting buttons. + if outline_button(ui, crate::i18n::tr(&app.ui.language, "Options"), 64.0).clicked() { + crate::menus::activate(app, "app.fittingOptions", &Value::Null); + } }); + // Auto-Fit: the graphic is refitted whenever its frame is resized. + let mut auto = app + .session + .active() + .and_then(|st| st.selection.items.first().and_then(|id| st.doc.item(*id))) + .and_then(|it| it.graphic()) + .is_some_and(|g| g.auto_fit != designcraft_doc::Fitting::None); + if widgets::checkbox(ui, &mut auto, crate::i18n::tr(&app.ui.language, "Auto-Fit")).changed() { + let _ = app.run("object.fittingOptions", json!({"autoFit": auto})); + } divider(ui); } section(ui, crate::i18n::tr(&app.ui.language, "Text Wrap"), true); @@ -3343,6 +3357,32 @@ mod tests { assert!((r.x0 - 200.0).abs() < 0.5 && (r.y1 - 200.0).abs() < 0.5 && (r.x1 - 300.0).abs() < 0.5, "{r:?}"); } + /// Properties › Frame Fitting has Auto-Fit and an Options button, as InDesign's: ticking + /// Auto-Fit makes the picture refit when its frame is resized. + #[test] + fn properties_frame_fitting_sets_auto_fit_and_opens_its_options() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 272, 172]})).unwrap()["id"].as_u64().unwrap(); + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); + app.run("file.place", json!({"base64": designcraft_engine::cmd::base64_encode(&png), "name": "p.png", "frame": id})).unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let graphic = |app: &DesignApp| app.session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().graphic().unwrap().clone(); + assert_eq!(graphic(&app).auto_fit, designcraft_doc::Fitting::None); + let mut h = panel(app); + h.get_by_label("Auto-Fit").click(); + h.run_steps(3); + assert_ne!(graphic(h.state()).auto_fit, designcraft_doc::Fitting::None, "Auto-Fit is on"); + // The frame resized: the picture follows it. + h.state_mut().run("transform.resize", json!({"from": [72, 72, 272, 172], "to": [72, 72, 372, 272]})).unwrap(); + let g = graphic(h.state()); + let r = g.xf.transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1)); + assert!(r.width() > 250.0, "refitted to the larger frame: {r:?}"); + h.get_by_label("Options").click(); + h.run_steps(3); + assert!(h.state().ui.dialog.is_some(), "Frame Fitting Options opens"); + } + const CYAN: &str = "C=100 M=0 Y=0 K=0"; #[test] diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index fb9876aa2..645c83c1a 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -567,6 +567,9 @@ pub fn checkbox(ui: &mut Ui, on: &mut bool, label: &str) -> Response { *on = !*on; resp.mark_changed(); } + // Named and ticked or not for screen readers (and tests). + let checked = *on; + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, checked, label)); resp } From be9ee05a58a99dafcce72b8432c999019d92318f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 20:31:38 +0200 Subject: [PATCH 021/207] PR-1389: name the Opacity slider for assistive technology; keep the release frame in the drag Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index fbdb417d8..b781ed4b3 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -882,7 +882,13 @@ fn opacity_slider(app: &mut DesignApp, resp: &egui::Response, opacity: f64) { let slider = ui.add( egui::Slider::new(&mut v, 0.0..=100.0).integer().show_value(false).handle_shape(egui::style::HandleShape::Circle).trailing_fill(false), ); - let pressed = slider.is_pointer_button_down_on() || slider.dragged(); + // The slider has no visible caption (the field beside the arrow is the label), so it's + // named for assistive technology only. + let label = crate::i18n::tr(&app.ui.language, "Opacity"); + slider.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), v, label)); + // The release frame can move the value too; it stays part of the drag's preview, which + // the next frame commits as the one undo step. + let pressed = slider.is_pointer_button_down_on() || slider.dragged() || slider.drag_stopped(); if slider.changed() { slid = Some((v, pressed)); } @@ -3399,23 +3405,28 @@ mod tests { assert!(h.query_by_role(egui::accesskit::Role::Slider).is_none()); h.get_by(|n| n.role() == egui::accesskit::Role::Button && n.label().as_deref() == Some("Opacity")).click(); h.run_steps(3); + assert!( + h.query_by(|n| n.role() == egui::accesskit::Role::Slider && n.label().as_deref() == Some("Opacity")).is_some(), + "assistive technology names the slider" + ); let track = h.get_by_role(egui::accesskit::Role::Slider).rect(); let y = track.center().y; h.hover_at(egui::pos2(track.right() - 1.0, y)); h.run_steps(1); h.drag_at(egui::pos2(track.right() - 1.0, y)); h.run_steps(1); - for k in 1..=6 { + for k in 1..=5 { h.hover_at(egui::pos2(track.right() - track.width() * 0.05 * k as f32, y)); h.run_steps(1); assert_eq!(undo_steps(h.state()), steps, "no undo step while dragging"); } let previewed = opacity(h.state()); assert!(previewed < 0.9 && previewed > 0.5, "the drag shows the opacity live: {previewed}"); + // Let go further along than the last move: the release frame changes the value too. h.drop_at(egui::pos2(track.right() - track.width() * 0.3, y)); h.run_steps(3); assert_eq!(undo_steps(h.state()), steps + 1, "the whole drag is one undo step"); - assert!(opacity(h.state()) < 0.9); + assert!(opacity(h.state()) < previewed, "the release position is applied: {previewed} → {}", opacity(h.state())); assert_ne!(h.state().ui.open_panel.as_deref(), Some("effects"), "the arrow doesn't open Effects"); h.state_mut().run("edit.undo", json!({})).unwrap(); assert_eq!(opacity(h.state()), 1.0); From 21c86ddcb7dc9c152d915ac2775de618bec83b1a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 20:48:43 +0200 Subject: [PATCH 022/207] Layout: Create Guides puts its guides on both pages of the spread in view, as InDesign does Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/dialogs.rs | 29 ++++++++++++++++------------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..5f8ccdf11 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-20** · Layout › Create Guides puts its guides on both pages of the spread in view, as InDesign does; "This page" in Apply to narrows them to one page. 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..407e05775 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -869,19 +869,20 @@ fn doc_unit(app: &DesignApp) -> Unit { app.session.active().map(|s| s.doc.settings.horizontal_units).unwrap_or(Unit::Picas) } -/// Layout › Create Guides…: guides go on the page in view (or the parent page being edited), not -/// on the first page, and the gutters are typed in the document's ruler unit. +/// Layout › Create Guides…: guides go on every page of the spread in view (or of the parent +/// spread being edited), as InDesign's do; "This page" narrows them to +/// the page in view. The gutters are typed in the document's ruler unit. pub fn open_create_guides(app: &mut DesignApp) { let unit = doc_unit(app); let Some((spread, page)) = crate::canvas::current_spread_page(app) else { return }; - let editing_parents = matches!(spread, SpreadRef::Parent(_)); let gutter = json!(format_measure(12.0, unit)); let mut d = Dialog::new( "createGuides", json!({"rows": 0, "columns": 0, "rowGutter": gutter, "columnGutter": gutter, "fitTo": "margins", "removeExisting": false, "spread": serde_json::to_value(spread).unwrap_or(json!(0)), "page": page, - // A parent spread is edited as a whole (both pages of a facing spread), so the guides show on every page that uses it. - "target": if editing_parents { "spread" } else { "page" }, + // InDesign puts the guides on both pages of a facing spread (and a parent spread is + // edited as a whole, so they show on every page that uses it). + "target": "spread", "where": target_description(app, spread, page)}), ); d.unit = unit; @@ -9875,18 +9876,20 @@ mod tests { } #[test] - fn create_guides_dialog_targets_the_page_in_view() { + fn create_guides_dialog_covers_the_spread_in_view() { let mut app = facing_app(6); + // As in InDesign, both pages of the facing spread in view get the guides. + crate::canvas::go_to_page(&mut app, 1); + crate::menus::activate(&mut app, "layout.createGuides", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("target"), "spread"); + ctl(&mut app, "ui.dialog.set", json!({"field": "columns", "value": "2"})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + assert_eq!(guide_counts(&app), vec![0, 2, 2, 0, 0, 0]); + // "This page" narrows them to the page in view. crate::canvas::go_to_page(&mut app, 4); crate::menus::activate(&mut app, "layout.createGuides", &Value::Null); ctl(&mut app, "ui.dialog.set", json!({"field": "columns", "value": "3"})); - assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); - assert_eq!(guide_counts(&app), vec![0, 0, 0, 0, 4, 0]); - // "Both pages of the spread" covers the facing pair. - crate::canvas::go_to_page(&mut app, 1); - crate::menus::activate(&mut app, "layout.createGuides", &Value::Null); - ctl(&mut app, "ui.dialog.set", json!({"field": "columns", "value": "2"})); - ctl(&mut app, "ui.dialog.set", json!({"field": "target", "value": "spread"})); + ctl(&mut app, "ui.dialog.set", json!({"field": "target", "value": "page"})); assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); assert_eq!(guide_counts(&app), vec![0, 2, 2, 0, 4, 0]); } From 39dfbdb36b006f66499c8d0030672089cb2c409a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 20:55:08 +0200 Subject: [PATCH 023/207] Units: centimetres show four decimals, as InDesign's fields do Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/geom/src/units.rs | 14 +++++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..1692c590f 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-20** · Measures in centimetres show four decimals, as InDesign's fields do (a 1p gutter reads 0.4233 cm, not 0.423 cm). 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/geom/src/units.rs b/crates/geom/src/units.rs index da1676d4b..f0457e75d 100644 --- a/crates/geom/src/units.rs +++ b/crates/geom/src/units.rs @@ -258,9 +258,9 @@ fn parse_single(tok: &str, default: Unit, percent_base: Option) -> Option String { - // As InDesign shows them: inches to four decimals (a 1p gutter is 0.1667 in, A4 is - // 8.2677 in wide), the other units to three. - let decimals = if matches!(unit, Unit::Inches | Unit::InchesDecimal) { 4 } else { 3 }; + // As InDesign shows them: inches and centimetres to four decimals (a 1p gutter is 0.1667 in + // or 0.4233 cm, A4 is 8.2677 in wide), the other units to three. + let decimals = if matches!(unit, Unit::Inches | Unit::InchesDecimal | Unit::Centimeters) { 4 } else { 3 }; format_measure_prec(pt, unit, decimals) } @@ -304,14 +304,18 @@ mod tests { (a - b).abs() < 1e-6 } - /// Inches show four decimals, as InDesign's fields do; other units three. + /// Inches and centimetres show four decimals, as InDesign's fields do; other units three. #[test] - fn inches_show_four_decimals() { + fn inches_and_centimeters_show_four_decimals() { assert_eq!(format_measure(12.0, Unit::Inches), "0.1667 in"); assert_eq!(format_measure(210.0 * PT_PER_MM, Unit::Inches), "8.2677 in"); assert_eq!(format_measure(612.0, Unit::Inches), "8.5 in"); assert_eq!(format_measure(9.0, Unit::Inches), "0.125 in"); assert_eq!(format_measure(10.0, Unit::Millimeters), "3.528 mm"); + // A 1p gutter and a frame's height, as InDesign's New Document and Transform fields show them. + assert_eq!(format_measure(12.0, Unit::Centimeters), "0.4233 cm"); + assert_eq!(format_measure(17.1125 / 2.54 * 72.0, Unit::Centimeters), "17.1125 cm"); + assert_eq!(format_measure(21.0 / 2.54 * 72.0, Unit::Centimeters), "21 cm"); assert_eq!(format_measure(10.0, Unit::Points), "10 pt"); } From 83f2f252c7330bc1cef68694600b54761611b63a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 22:08:23 +0200 Subject: [PATCH 024/207] Object: Corner Options opens a dialog laid out as InDesign's, from the menu and the Properties Corner link Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/dialogs.rs | 255 ++++++++++++++++++++++++ crates/ui-egui/src/i18n.rs | 4 + crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + crates/ui-egui/src/panels/properties.rs | 8 +- 14 files changed, 277 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..8c841853d 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-20** · Object › Corner Options opens a Corner Options dialog laid out as InDesign's: each corner's size and shape, the link button that makes all four the same, and Preview. The Corner link in Properties › Appearance opens it too (it opened a Rectangle size dialog). 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..4c646e956 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2757,6 +2757,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); } STEP_AND_REPEAT => step_and_repeat_dialog(app, ui, &mut d), + CORNER_OPTIONS => corner_options_dialog(app, ui, &mut d), TOC => toc_dialog(app, ui, &mut d), id if id.starts_with("cmd:") => command_form(app, ui, &mut d), "goToPage" => { @@ -3579,6 +3580,22 @@ pub fn confirm(app: &mut DesignApp) -> Result { app.session.reset_find_scope(); result } + CORNER_OPTIONS => { + // The preview goes; the corners change once, as one undo step. + if d.fields.contains_key(PREVIEWED) { + app.session.cancel_interaction(); + } + let mut d = d; + seed_corner_options(app, &mut d); + link_corners(&mut d); + let r = corner_options_params(&d).and_then(|p| app.run("object.cornerOptions", p)); + if let Err(e) = &r { + d.fields.remove(PREVIEWED); + d.fields.insert("status".into(), json!(e)); + app.ui.dialog = Some(d); + } + r + } STEP_AND_REPEAT => { // The preview goes; the copies are made once, as one undo step. if d.fields.contains_key(PREVIEWED) { @@ -6195,6 +6212,7 @@ fn command_form(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } const STEP_AND_REPEAT: &str = "cmd:edit.stepAndRepeat"; +pub(crate) const CORNER_OPTIONS: &str = "cmd:object.cornerOptions"; const TOC: &str = "cmd:toc.generate"; /// One included style of the Table of Contents dialog, as `toc.generate` takes it, with the @@ -6554,6 +6572,179 @@ fn step_and_repeat_preview(app: &mut DesignApp, d: &mut Dialog) { d.fields.insert(PREVIEWED.into(), params); } +/// The corners of Object › Corner Options, in `object.cornerOptions` path order (top-left, +/// top-right, bottom-right, bottom-left), with the tip InDesign gives each. +const CORNERS: [(&str, &str); 4] = [("0", "Top Left:"), ("1", "Top Right:"), ("2", "Bottom Right:"), ("3", "Bottom Left:")]; + +/// The Corner Options dialog's fields from the first selected object's corners, once: each +/// corner's size (in the ruler units) and shape, linked when all four are the same. +fn seed_corner_options(app: &DesignApp, d: &mut Dialog) { + use designcraft_geom::corners::Corner; + let Some(st) = app.session.active() else { return }; + d.unit = st.doc.settings.horizontal_units; + if d.fields.contains_key("_seeded") { + return; + } + let corners: [Corner; 4] = st.selection.items.iter().find_map(|id| st.doc.item(*id)).map(|it| it.corners.corners).unwrap_or_default(); + for ((k, _), c) in CORNERS.iter().zip(corners) { + d.fields.entry(format!("size{k}")).or_insert(json!(format_measure(c.size, d.unit))); + d.fields.entry(format!("shape{k}")).or_insert(serde_json::to_value(c.shape).unwrap_or(json!("none"))); + } + let first = corners[0]; + d.fields.entry("link").or_insert(json!(corners.iter().all(|c| *c == first))); + d.fields.insert("_seeded".into(), json!(true)); +} + +/// Linked corners: a corner changed since the dialog last looked (size or shape, typed or set +/// over the control channel) is all four. +fn link_corners(d: &mut Dialog) { + let now: Vec<(String, String)> = CORNERS.iter().map(|(k, _)| (d.s(&format!("size{k}")), d.s(&format!("shape{k}")))).collect(); + let seen: Vec<(String, String)> = d.fields.get("_corners").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_else(|| now.clone()); + let mut now = now; + if d.b("link") + && let Some(i) = now.iter().zip(&seen).position(|(a, b)| a != b) + && let Some(changed) = now.get(i).cloned() + { + for (k, _) in &CORNERS { + d.fields.insert(format!("size{k}"), json!(changed.0)); + d.fields.insert(format!("shape{k}"), json!(changed.1)); + } + now = vec![changed; CORNERS.len()]; + } + d.fields.insert("_corners".into(), json!(now)); +} + +/// `object.cornerOptions`' params from the dialog. A size that does not parse is an error to show. +fn corner_options_params(d: &Dialog) -> Result { + use designcraft_geom::corners::CornerShape; + let mut corners = Vec::with_capacity(CORNERS.len()); + for (k, _) in &CORNERS { + let key = format!("size{k}"); + let size = + d.m(&key).filter(|v| v.is_finite() && *v >= 0.0).ok_or_else(|| format!("Enter a valid corner size, e.g. 12 or 3 mm: {}", d.s(&key)))?; + let shape: CornerShape = d.fields.get(&format!("shape{k}")).and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + corners.push(json!({"shape": shape, "size": size})); + } + Ok(json!({"corners": corners})) +} + +/// A corner's icon: the corner of a small square, with a dot at the corner it sets. +fn paint_corner_icon(ui: &mut egui::Ui, corner: usize, tip: &str) { + let t = crate::theme::Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); + resp.on_hover_text(tip); + let b = r.shrink(3.0); + ui.painter().rect_stroke(b, 0.0, egui::Stroke::new(1.0, t.icon), egui::StrokeKind::Inside); + let at = match corner { + 0 => b.left_top(), + 1 => b.right_top(), + 2 => b.right_bottom(), + _ => b.left_bottom(), + }; + ui.painter().rect_filled(egui::Rect::from_center_size(at, egui::vec2(4.0, 4.0)), 0.0, t.accent); +} + +/// Object › Corner Options, as InDesign's: under "Corner Size and Shape:", each corner's size and +/// shape (top corners above, bottom ones below, as on the frame), the link button that makes all +/// four the same, and Preview. +fn corner_options_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + use designcraft_geom::corners::CornerShape; + seed_corner_options(app, d); + link_corners(d); + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + crate::rtl::label(ui, tr("Corner Size and Shape:")); + ui.add_space(8.0); + let corner = |ui: &mut egui::Ui, d: &mut Dialog, i: usize| { + let Some((k, tip)) = CORNERS.get(i) else { return }; + ui.horizontal(|ui| { + paint_corner_icon(ui, i, &tr(tip)); + measure_field(ui, d, &format!("size{k}"), 70.0); + let key = format!("shape{k}"); + let shape: CornerShape = d.fields.get(&key).and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + let resp = crate::panels::properties::corner_shape_button(ui, shape, 46.0); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, format!("{} {}", tr(tip), tr(shape.label())))); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(150.0); + for s in CornerShape::ALL { + if ui.selectable_label(s == shape, crate::rtl::widget(ui, tr(s.label()))).clicked() { + d.fields.insert(key.clone(), json!(s)); + ui.close(); + } + } + }); + }); + }; + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + corner(ui, d, 0); + ui.add_space(6.0); + corner(ui, d, 3); + }); + ui.add_space(8.0); + let linked = d.b("link"); + let tip = tr("Make all settings the same"); + let resp = ui + .vertical(|ui| { + ui.add_space(12.0); + crate::icons::button(ui, if linked { "link" } else { "link-broken" }, 20.0, linked, &tip) + }) + .inner; + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &tip)); + if resp.clicked() { + d.fields.insert("link".into(), json!(!linked)); + } + ui.add_space(8.0); + ui.vertical(|ui| { + corner(ui, d, 1); + ui.add_space(6.0); + corner(ui, d, 2); + }); + }); + link_corners(d); + ui.add_space(10.0); + check(ui, d, "preview", &tr("Preview")); + corner_options_preview(app, d); + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + ui.add_space(6.0); + crate::rtl::label(ui, egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + } +} + +/// Preview on: the corners show on the frame (redone when a field changes); off: they go back. +fn corner_options_preview(app: &mut DesignApp, d: &mut Dialog) { + let shown = d.fields.get(PREVIEWED).cloned(); + if !d.b("preview") { + if shown.is_some() { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + d.fields.remove("status"); + } + return; + } + let params = match corner_options_params(d) { + Ok(p) => p, + Err(e) => { + d.fields.insert("status".into(), json!(e)); + return; + } + }; + if shown.as_ref() == Some(¶ms) { + return; + } + if shown.is_none() + && let Err(e) = app.session.begin_interaction("Corner Options") + { + d.fields.insert("status".into(), json!(e.to_string())); + return; + } + match app.session.preview("object.cornerOptions", params.clone()) { + Ok(()) => d.fields.remove("status"), + Err(e) => d.fields.insert("status".into(), json!(e.to_string())), + }; + d.fields.insert(PREVIEWED.into(), params); +} + /// New Cross-Reference: link to a paragraph (paragraph styles on the left, their paragraphs on /// the right) or a text anchor, with a cross-reference format. fn insert_xref(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { @@ -6965,6 +7156,70 @@ mod tests { assert_eq!(paragraph_style_summary("", &d, &style, &styles), "Parent"); } + #[test] + fn corner_options_dialog_sets_each_corner_with_preview_and_one_undo_step() { + use designcraft_geom::corners::CornerShape; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 360, 360]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let corners = |app: &DesignApp| app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().corners.corners; + let undo = |app: &DesignApp| app.session.doc().unwrap().history.undo.len(); + let set = |app: &mut DesignApp, k: &str, v: Value| { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + }; + let u0 = undo(&app); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); + // Object › Corner Options… opens InDesign's dialog, not a form of the command's params. + crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some(CORNER_OPTIONS)); + dialog_frame(&mut app, &ctx, screen, vec![]); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for expected in ["Corner Options", "Corner Size and Shape:", "Preview", "OK", "Cancel"] { + visible_label(&labels, screen, expected); + } + assert!(!labels.iter().any(|(t, _, _)| t.trim() == "Ids"), "no raw command fields"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.s("size1"), d.s("shape1"), d.b("link")), ("0p0".into(), "none".into(), true), "seeded from the frame, linked"); + // Linked: a corner changed is all four. + set(&mut app, "size0", json!("1p0")); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("size2"), "1p0"); + // Unlinked, only the top-right corner rounds, as in the tutorial. + set(&mut app, "link", json!(false)); + for k in 0..4 { + set(&mut app, &format!("size{k}"), json!("0")); + } + set(&mut app, "size1", json!("3 cm")); + set(&mut app, "shape1", json!("rounded")); + set(&mut app, "preview", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(corners(&app)[1].shape, CornerShape::Rounded, "Preview shows it"); + assert_eq!(undo(&app), u0, "without an undo step"); + set(&mut app, "preview", json!(false)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(corners(&app)[1].shape, CornerShape::None, "Preview off puts it back"); + set(&mut app, "preview", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + confirm(&mut app).unwrap(); + let c = corners(&app); + assert_eq!(c.map(|c| c.shape), [CornerShape::None, CornerShape::Rounded, CornerShape::None, CornerShape::None]); + assert!((c[1].size - 3.0 / 2.54 * 72.0).abs() < 1e-6, "{}", c[1].size); + assert_eq!(undo(&app), u0 + 1, "one undo step"); + // Reopened, it shows the frame's corners, no longer linked. + crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); + dialog_frame(&mut app, &ctx, screen, vec![]); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.s("shape1"), d.s("shape0"), d.b("link")), ("rounded".into(), "none".into(), false)); + // A size that does not parse keeps the dialog open with the error. + set(&mut app, "size0", json!("abc")); + assert!(confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some()); + cancel(&mut app); + assert_eq!(corners(&app), c); + } + #[test] fn step_and_repeat_dialog_previews_and_makes_the_copies_as_one_undo_step() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..f4fbc2458 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -89,6 +89,10 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ( + "Corner Size and Shape:", + ["Corner Size and Shape:", "Taille et forme des angles:", "Corner Size and Shape:", "Corner Size and Shape:", "角大小和形状:"], + ), ("Hide Rulers", ["Lineale ausblenden", "Masquer les règles", "Ocultar reglas", "定規を隠す", "隐藏标尺"]), ("Inches Decimal", ["Zoll dezimal", "Pouces décimaux", "Pulgadas decimales", "インチ(10 進)", "十进制英寸"]), ( diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..ae9cc3259 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Corner Size and Shape:", "حجم الزوايا وشكلها:"), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..3f4db1024 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Corner Size and Shape:", "Velikost a tvar rohů:"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index efc128858..1b3dd08cc 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("Corner Size and Shape:", "Eckengröße und -form:"), ("Inches Decimal", "Zoll dezimal"), ("Back", "Zurück"), ("Warning", "Warnung"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..f403c1fea 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Corner Size and Shape:", "Dimensione e forma angoli:"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 5647ca940..bed2b966d 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Corner Size and Shape:", "角のサイズとシェイプ:"), ("Paste Vector Art", "ベクトルアートをペースト"), ("Baseline Options", "ベースラインオプション"), ("Column Rules", "段落境界線"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..a02711406 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Corner Size and Shape:", "კუთხეების ზომა და ფორმა:"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..3a19441d9 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Corner Size and Shape:", "Rozmiar i kształt narożników:"), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..5fea60b67 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Corner Size and Shape:", "Tamanho e forma dos cantos:"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..2c3489c6f 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Corner Size and Shape:", "Размер и форма углов:"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..b3884bd8e 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Corner Size and Shape:", "Розмір і форма кутів:"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 44acf6e2c..50f60c6cd 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -761,6 +761,11 @@ fn paint_corner_shape(p: &egui::Painter, r: Rect, shape: CornerShape) { p.add(egui::Shape::line(pts, s)); } +/// A corner shape picker's face: the shape's glyph and the dropdown arrow (the menu is the caller's). +pub(crate) fn corner_shape_button(ui: &mut Ui, shape: CornerShape, width: f32) -> egui::Response { + preview_dropdown(ui, width, |p, r| paint_corner_shape(p, r, shape)) +} + pub(crate) fn corner_type_dropdown(app: &mut DesignApp, ui: &mut Ui, shape: CornerShape, size: f64, width: f32) { let resp = preview_dropdown(ui, width, |p, r| paint_corner_shape(p, r, shape)); egui::Popup::menu(&resp).show(|ui| { @@ -828,7 +833,8 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { let b = block(ui, FIELD_H); icons::paint(ui.painter(), sub(b, 3.0, 1.0, 19.0, 19.0), "corner", t.accent); if place(ui, sub(b, 34.0, 0.0, 56.0, FIELD_H), |ui| link_label(ui, crate::i18n::tr(&app.ui.language, "Corner"), 56.0)).clicked() { - app.ui.dialog = Some(crate::dialogs::Dialog::new("frameSize", json!({}))); + // Corner Options, as Object › Corner Options… opens it. + crate::menus::activate(app, "object.cornerOptions", &Value::Null); } let (shape, size) = i.corner; if let Some(v) = place(ui, sub(b, 96.0, 0.0, 69.0, FIELD_H), |ui| { From 4a65451d404eb424491a9ab2d4c5fbf633282bea Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 22:20:26 +0200 Subject: [PATCH 025/207] Type tool: a drag that starts in an empty frame draws a new text frame over it, as in InDesign Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/tools/src/tests.rs | 24 ++++++++++++++++++++++++ crates/tools/src/text.rs | 14 ++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..62dc3a8dc 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-20** · Type tool: a drag that starts in an empty frame draws a new text frame over it, as in InDesign; a click still turns the frame into a text frame. 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/tools/src/tests.rs b/crates/tools/src/tests.rs index ee3b362f1..5295708cb 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -1058,6 +1058,30 @@ fn double_click_on_a_port_breaks_its_link() { } } +/// With the Type tool, a drag that starts in an empty rectangle draws a new text frame over it +/// (the rectangle stays as it is), as InDesign's does; a click still turns the rectangle into a +/// text frame with the caret in it. +#[test] +fn type_tool_drag_over_an_empty_frame_draws_a_new_text_frame() { + let mut d = Document::new(&NewDocument::default()); + let id = add_rect(&mut d, Rect::new(100.0, 100.0, 300.0, 300.0)); + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let off = l.xf(SpreadRef::Doc(0)).translation(); + let at = |x: f64, y: f64| (x + off.x, y + off.y); + let mut t = create("type"); + let (x, y) = at(120.0, 120.0); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)).is_empty()); + let (x1, y1) = at(280.0, 200.0); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x1, y1)); + assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(cmd, p)] if cmd == "frame.create" && p["content"] == "text"), "{a:?}"); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x1, y1)), vec![Action::Commit]); + // A click: the caret goes into the rectangle, which becomes a text frame. + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)).is_empty()); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x, y)); + assert!(matches!(&a[..], [Action::Exec(cmd, p)] if cmd == "text.placeCaret" && p["frame"] == id.0), "{a:?}"); +} + #[test] fn clicks_in_a_row_edit_and_select_text() { let mut d = Document::new(&NewDocument::default()); diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 56658f41e..c8cb778a9 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -31,6 +31,9 @@ pub struct TypeTool { start: Option, selecting: Option, drawing: bool, + /// A press on an empty frame: (frame, press point in spread space). A click turns it into a + /// text frame with the caret in it; a drag draws a new text frame over it, as in InDesign. + convert: Option<(u64, Point)>, /// A press on a table whose whole rows or columns are selected: (frame, press point in /// spread space) — a drag moves them, a click places the caret. cell_drag: Option<(u64, Point)>, @@ -226,6 +229,7 @@ impl Tool for TypeTool { self.cell_drag = None; self.unit = None; self.border = None; + self.convert = None; if ev.kind == PointerKind::Down && let Some(b) = border_at(cx, ev.pos) { @@ -246,6 +250,11 @@ impl Tool for TypeTool { self.cell_drag = Some((id.0, sp)); return vec![]; } + // An empty frame: wait to see whether this is a click or a drag. + if clicks == 1 && !ev.mods.shift && !it.is_text_frame() { + self.convert = Some((id.0, sp)); + return vec![]; + } self.selecting = Some(id.0); // Two presses select a word (or a run of spaces), three a line, four a // paragraph, five the story. @@ -296,6 +305,7 @@ impl Tool for TypeTool { let mut out = vec![]; if !self.drawing { self.drawing = true; + self.convert = None; out.push(Action::Begin("Create Text Frame".into())); } out.push(Action::Preview( @@ -323,6 +333,10 @@ impl Tool for TypeTool { self.selecting = None; return vec![Action::Commit]; } + // A click in an empty frame: it becomes a text frame with the caret in it. + if let Some((fid, at)) = self.convert.take() { + return vec![Action::Exec("text.placeCaret".into(), json!({"frame": fid, "point": [at.x, at.y]}))]; + } // Ends a press in text: drops dragged text, or is ignored by the engine. if let Some(fid) = self.selecting.take() { let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos); From 076de16622e599e2b88e7559671d7e566ded546e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 22:34:16 +0200 Subject: [PATCH 026/207] Properties: Apply To Frame or Text for several text frames, as InDesign offers Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/panels/properties.rs | 36 +++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..4289a88ce 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-20** · Properties › Appearance: with several text frames selected, the Fill and Stroke popups offer Apply To Frame or Text, as with one frame, so their text can be coloured at once, as in InDesign. 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/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 44acf6e2c..f2ceeeaba 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -777,6 +777,10 @@ pub(crate) fn corner_type_dropdown(app: &mut DesignApp, ui: &mut Ui, shape: Corn fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { let t = Tokens::get(ui.ctx()); let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; + // Every selected object is a text frame: the popups offer Apply To Frame or Text, for one + // frame or several, as InDesign's do. + let text_frames = + i.is_text && app.session.active().is_some_and(|st| st.selection.items.iter().all(|id| doc.item(*id).is_some_and(|it| it.is_text_frame()))); // Fill. let b = block(ui, FIELD_H); let (fc, fg) = widgets::swatch_colors(&doc, &i.fill, 1.0); @@ -789,7 +793,7 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { i.fill )); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Fill swatch")); - swatch_menu(app, &resp, &doc, &i.fill, false, i.is_text && i.count == 1, false); + swatch_menu(app, &resp, &doc, &i.fill, false, text_frames, false); crate::rtl::paint( ui.painter(), b.min + vec2(34.0, FIELD_H / 2.0), @@ -811,7 +815,7 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { i.stroke )); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Stroke swatch")); - swatch_menu(app, &resp, &doc, &i.stroke, true, i.is_text && i.count == 1, false); + swatch_menu(app, &resp, &doc, &i.stroke, true, text_frames, false); if place(ui, sub(b, 34.0, 0.0, 56.0, FIELD_H), |ui| link_label(ui, crate::i18n::tr(&app.ui.language, "Stroke"), 56.0)).clicked() { show_panel(app, "stroke"); } @@ -3419,6 +3423,34 @@ mod tests { assert_eq!(doc.item(designcraft_doc::ids::ItemId(id)).unwrap().fill.swatch, "[None]", "the frame isn't"); } + #[test] + fn apply_to_text_colours_the_text_of_several_text_frames() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let mut ids = vec![]; + let mut stories = vec![]; + for x in [72, 250, 430] { + let r = app.run("frame.create", json!({"rect": [x, 72, x + 150, 140], "content": "text", "text": "75%"})).unwrap(); + ids.push(r["id"].as_u64().unwrap()); + stories.push(designcraft_doc::StoryId(r["story"].as_u64().unwrap())); + } + app.run("selection.set", json!({"ids": ids})).unwrap(); + let mut h = panel(app); + h.get_by_label("Fill swatch").click(); + h.run_steps(3); + // Three text frames, as one: Apply To is offered. + assert!(h.query_by_label("Apply To:").is_some()); + h.get_by_label("Text").click(); + h.run_steps(3); + h.get_by_label(CYAN).click(); + h.run_steps(3); + let doc = &h.state().session.active().unwrap().doc; + for (id, story) in ids.iter().zip(&stories) { + assert_eq!(doc.story(*story).unwrap().char_format_at(0).over.fill.as_deref(), Some(CYAN), "every frame's text is cyan"); + assert_eq!(doc.item(designcraft_doc::ids::ItemId(*id)).unwrap().fill.swatch, "[None]", "the frames aren't"); + } + } + #[test] fn transform_flips_and_rotates_from_properties() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 31b95b83f2916c56b4dbcb1075a9fcd719d352f4 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 23:08:29 +0200 Subject: [PATCH 027/207] Swatches: a new document has InDesign's default swatches Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/color/src/swatch.rs | 39 +++++++++++++++++++++++---------- crates/render/tests/colorize.rs | 2 +- 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..8c565060c 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-20** · A new document's Swatches panel lists InDesign's defaults: [None], [Registration], [Paper], [Black] and six process colours (C=100, M=100, Y=100, C=15 M=100 Y=100, C=75 M=5 Y=100, C=100 M=90 Y=10); the extra M=100 Y=100, C=100 Y=100 and C=100 M=100 swatches are gone. 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/color/src/swatch.rs b/crates/color/src/swatch.rs index e47bf4eef..76ccddd9e 100644 --- a/crates/color/src/swatch.rs +++ b/crates/color/src/swatch.rs @@ -143,7 +143,9 @@ pub fn apply_tint(c: Color, tint: f32) -> Color { } } -/// A new document's swatches, in panel order. +/// A new document's swatches, in panel order: InDesign's four specials, then its six process +/// colours (cyan, magenta, yellow, red, green and blue as C=15 M=100 Y=100, C=75 M=5 Y=100 and +/// C=100 M=90 Y=10). pub fn default_swatches() -> Vec { let special = |name: &str, value: SwatchValue| Swatch { name: name.into(), value, locked: true, named: true, hidden: false }; let mut v = vec![ @@ -152,17 +154,9 @@ pub fn default_swatches() -> Vec { special(PAPER, SwatchValue::Paper { color: Color::WHITE }), special(BLACK, SwatchValue::Color { color: Color::cmyk(0.0, 0.0, 0.0, 1.0), color_type: ColorType::Process }), ]; - for (c, m, y, k) in [ - (1.0, 0.0, 0.0, 0.0), - (0.0, 1.0, 0.0, 0.0), - (0.0, 0.0, 1.0, 0.0), - (0.0, 1.0, 1.0, 0.0), - (1.0, 0.0, 1.0, 0.0), - (1.0, 1.0, 0.0, 0.0), - (0.15, 1.0, 1.0, 0.0), - (0.75, 0.05, 1.0, 0.0), - (1.0, 0.9, 0.1, 0.0), - ] { + for (c, m, y, k) in + [(1.0, 0.0, 0.0, 0.0), (0.0, 1.0, 0.0, 0.0), (0.0, 0.0, 1.0, 0.0), (0.15, 1.0, 1.0, 0.0), (0.75, 0.05, 1.0, 0.0), (1.0, 0.9, 0.1, 0.0)] + { v.push(Swatch::cmyk(c, m, y, k)); } v @@ -181,6 +175,27 @@ mod tests { assert!(s.iter().any(|s| s.name == "C=100 M=0 Y=0 K=0")); } + /// The Swatches panel of a new InDesign document (Zero2Graphic's page layout tutorial, 3:22). + #[test] + fn defaults_are_indesigns_ten() { + let names: Vec = default_swatches().into_iter().map(|s| s.name).collect(); + assert_eq!( + names, + [ + "[None]", + "[Registration]", + "[Paper]", + "[Black]", + "C=100 M=0 Y=0 K=0", + "C=0 M=100 Y=0 K=0", + "C=0 M=0 Y=100 K=0", + "C=15 M=100 Y=100 K=0", + "C=75 M=5 Y=100 K=0", + "C=100 M=90 Y=10 K=0", + ] + ); + } + #[test] fn resolves_tints_and_specials() { let mut s = default_swatches(); diff --git a/crates/render/tests/colorize.rs b/crates/render/tests/colorize.rs index 883b36b8e..c4dcaad1c 100644 --- a/crates/render/tests/colorize.rs +++ b/crates/render/tests/colorize.rs @@ -13,7 +13,7 @@ use designcraft_geom::{Affine, Rect, shapes}; use designcraft_render::{RenderOptions, Renderer, color_of}; use image::{ExtendedColorType, ImageEncoder}; -const RED: &str = "C=0 M=100 Y=100 K=0"; +const RED: &str = "C=15 M=100 Y=100 K=0"; const CYAN: &str = "C=100 M=0 Y=0 K=0"; fn encode(px: &[u8], w: u32, ct: ExtendedColorType, gif: bool) -> Vec { From 09970a24b0a92636654bd15a577989ed87dc161e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 23:12:05 +0200 Subject: [PATCH 028/207] Frame tools: the size readout shows the document's units, as InDesign's Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/tools/src/frame.rs | 10 +++++++--- crates/tools/src/tests.rs | 24 ++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..56ff46903 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-20** · Drawing a frame or shape shows its width and height in the document's ruler units, to two decimals and on two lines ("W: 6.08 cm", "H: 6.08 cm"), as InDesign does; it was always whole points. 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/tools/src/frame.rs b/crates/tools/src/frame.rs index 4b52e2ee5..aaebaefd7 100644 --- a/crates/tools/src/frame.rs +++ b/crates/tools/src/frame.rs @@ -457,10 +457,14 @@ impl Tool for FrameTool { fn overlays(&self, cx: &ToolContext) -> Vec { if let (true, Some(a)) = (self.active, self.start) { - let r = Rect::from_points(a, self.cur); - let _ = cx; + // The frame being drawn (Shift, Alt and snapping included), in the document's units + // to two decimals, on two lines: InDesign's readout ("W: 3.13 cm", "H: 3.13 cm"). + let r = self.last.as_ref().map(|(_, r)| *r).unwrap_or_else(|| Rect::from_points(a, self.cur)); + let units = &cx.doc.settings; + let w = designcraft_geom::units::format_measure_prec(r.width().abs(), units.horizontal_units, 2); + let h = designcraft_geom::units::format_measure_prec(r.height().abs(), units.vertical_units, 2); let mut v = self.guides.clone(); - v.push(Overlay::Measure { p: self.cur, text: format!("W: {:.0} pt H: {:.0} pt", r.width(), r.height()) }); + v.push(Overlay::Measure { p: self.cur, text: format!("W: {w}\nH: {h}") }); return v; } vec![] diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index ee3b362f1..d39fb6433 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -69,6 +69,30 @@ fn frame_tool_drag_emits_begin_preview_commit() { assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 110.0, 60.0)), vec![Action::Commit]); } +/// While a frame is drawn, its size shows in the document's units, as InDesign's readout does +/// ("W: 6.08 cm" over "H: 6.08 cm" in Zero2Graphic's page layout tutorial, 0:43). +#[test] +fn frame_tool_readout_shows_the_size_in_the_document_units() { + let mut d = Document::new(&NewDocument::default()); + d.settings.horizontal_units = Unit::Centimeters; + d.settings.vertical_units = Unit::Centimeters; + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let mut t = create("rectangleFrame"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + // 172.35 pt square: 6.08 cm. + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 182.35, 182.35)); + let texts: Vec = t + .overlays(&cx) + .into_iter() + .filter_map(|o| match o { + Overlay::Measure { text, .. } => Some(text), + _ => None, + }) + .collect(); + assert_eq!(texts, vec!["W: 6.08 cm\nH: 6.08 cm".to_string()]); +} + #[test] fn selection_click_and_marquee() { let mut d = Document::new(&NewDocument::default()); From d7d9c4230ac26fb0f2416a65bff068134e319def Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 23:38:48 +0200 Subject: [PATCH 029/207] Object menu: InDesign's order and groups, Join/Open/Close Path under Paths, Convert Point Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/i18n.rs | 2 + crates/ui-egui/src/i18n/ar.rs | 2 + crates/ui-egui/src/i18n/cs.rs | 4 + crates/ui-egui/src/i18n/de.rs | 4 + crates/ui-egui/src/i18n/it.rs | 2 + crates/ui-egui/src/i18n/ja.rs | 2 + crates/ui-egui/src/i18n/ka.rs | 4 + crates/ui-egui/src/i18n/pl.rs | 4 + crates/ui-egui/src/i18n/pt_br.rs | 2 + crates/ui-egui/src/i18n/ru.rs | 4 + crates/ui-egui/src/i18n/uk.rs | 2 + crates/ui-egui/src/menus.rs | 220 +++++++++++++++++++++---------- 13 files changed, 186 insertions(+), 67 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..57285e793 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-20** · The Object menu is in InDesign's order and groups; Paths has Join, Open Path and Close Path (Convert Shape lists only shapes, as InDesign's does) and a Convert Point submenu (Plain, Corner, Smooth, Symmetrical) is added. 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/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..22e8a0a87 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -89,6 +89,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("Join", ["Join", "Joindre", "Join", "Join", "连接"]), + ("Convert Point", ["Convert Point", "Convertir le point", "Convert Point", "Convert Point", "转换点"]), ("Hide Rulers", ["Lineale ausblenden", "Masquer les règles", "Ocultar reglas", "定規を隠す", "隐藏标尺"]), ("Inches Decimal", ["Zoll dezimal", "Pouces décimaux", "Pulgadas decimales", "インチ(10 進)", "十进制英寸"]), ( diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..c9e6f463e 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Join", "وصل"), + ("Convert Point", "تحويل النقطة"), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..dcebc1180 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Join", "Spojit"), + ("Convert Point", "Převést bod"), + ("Plain", "Obyčejný"), + ("Symmetrical", "Symetrický"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index efc128858..778f028ae 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -31,6 +31,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("Join", "Verbinden"), + ("Convert Point", "Punkt konvertieren"), + ("Plain", "Einfach"), + ("Symmetrical", "Symmetrisch"), ("Inches Decimal", "Zoll dezimal"), ("Back", "Zurück"), ("Warning", "Warnung"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..818eafcf3 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Join", "Unisci"), + ("Convert Point", "Converti punto"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 5647ca940..9f98222dd 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Join", "連結"), + ("Convert Point", "ポイントを切り換え"), ("Paste Vector Art", "ベクトルアートをペースト"), ("Baseline Options", "ベースラインオプション"), ("Column Rules", "段落境界線"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..d0c67ae3c 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Join", "შეერთება"), + ("Convert Point", "წერტილის გარდაქმნა"), + ("Plain", "მარტივი"), + ("Symmetrical", "სიმეტრიული"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..4b5763383 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Join", "Połącz"), + ("Convert Point", "Konwertuj punkt"), + ("Plain", "Zwykły"), + ("Symmetrical", "Symetryczny"), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..4042fe559 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Join", "Unir"), + ("Convert Point", "Converter ponto"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..0503aa350 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Join", "Соединить"), + ("Convert Point", "Преобразовать точку"), + ("Plain", "Простая"), + ("Symmetrical", "Симметричная"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..e21385496 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Join", "Зʼєднати"), + ("Convert Point", "Перетворити точку"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 79b8b2c4e..667593746 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -597,57 +597,12 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:transform.again|Transform Sequence Again|{\"sequence\": true}", "cmd:transform.again|Transform Sequence Again Individually|{\"sequence\": true, \"individually\": true}", "<", - ">Flex Layout", - "cmd:flex.add", - "cmd:flex.convert", - "cmd:flex.remove", - "-", - "ui:window.panel|Show Flex Controls|{\"panel\": \"flexLayout\"}", - "<", - ">Paths", - "cmd:object.makeCompoundPath", - "cmd:object.releaseCompoundPath", - "-", - "cmd:path.reverse", - "<", - ">Pathfinder", - "cmd:object.pathfinder|Add|{\"op\": \"add\"}", - "cmd:object.pathfinder|Subtract|{\"op\": \"subtract\"}", - "cmd:object.pathfinder|Intersect|{\"op\": \"intersect\"}", - "cmd:object.pathfinder|Exclude Overlap|{\"op\": \"exclude\"}", - "cmd:object.pathfinder|Minus Back|{\"op\": \"minusBack\"}", - "<", ">Arrange", "cmd:object.bringToFront", "cmd:object.bringForward", "cmd:object.sendBackward", "cmd:object.sendToBack", "<", - "-", - "cmd:object.group", - "cmd:object.ungroup", - "cmd:object.lock", - "cmd:object.unlockAll", - "cmd:object.hide", - "cmd:object.showAll", - "-", - "cmd:object.textFrameOptions", - "ui:app.layerOptions", - "cmd:object.exportOptions", - "cmd:object.primaryTextFrame", - "cmd:object.cornerOptions", - "-", - ">Effects", - "ui:effects.dialog|Transparency…|{\"page\": \"transparency\"}", - "cmd:object.dropShadow", - "cmd:object.innerShadow", - "cmd:object.outerGlow", - "cmd:object.innerGlow", - "cmd:object.bevel", - "cmd:object.satin", - "cmd:object.feather", - "cmd:object.directionalFeather", - "<", ">Select", "cmd:select.firstAbove", "cmd:select.nextAbove", @@ -660,34 +615,23 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:select.previousInGroup", "cmd:select.nextInGroup", "<", - ">Convert Shape", - "cmd:object.convertShape|Rectangle|{\"to\": \"rectangle\"}", - "cmd:object.convertShape|Rounded Rectangle|{\"to\": \"roundedRectangle\"}", - "cmd:object.convertShape|Beveled Rectangle|{\"to\": \"beveledRectangle\"}", - "cmd:object.convertShape|Inverse Rounded Rectangle|{\"to\": \"inverseRoundedRectangle\"}", - "cmd:object.convertShape|Ellipse|{\"to\": \"ellipse\"}", - "cmd:object.convertShape|Triangle|{\"to\": \"triangle\"}", - "cmd:object.convertShape|Polygon|{\"to\": \"polygon\"}", - "cmd:object.convertShape|Line|{\"to\": \"line\"}", - "cmd:object.convertShape|Orthogonal Line|{\"to\": \"orthogonalLine\"}", "-", - "cmd:object.convertShape|Open Path|{\"to\": \"openPath\"}", - "cmd:object.convertShape|Closed Path|{\"to\": \"closedPath\"}", - "<", + "cmd:object.group", + "cmd:object.ungroup", + "cmd:object.lock", + "cmd:object.unlockAll", + "cmd:object.hide", + "cmd:object.showAll", + "-", "ui:app.qrCode", - ">Clipping Path", - "cmd:object.clippingPathOptions|None|{\"type\": \"none\"}", - "cmd:object.clippingPath|Alpha Channel|{\"type\": \"alpha\"}", - "cmd:object.clippingPath|Detect Edges|{\"type\": \"edges\"}", - "<", - ">Captions", - "cmd:object.caption", - "cmd:object.caption|Generate Live Caption|{\"live\": true}", - "<", + "-", + "cmd:object.textFrameOptions", + "cmd:object.primaryTextFrame", ">Anchored Object", "cmd:anchored.options", "cmd:anchored.release", "<", + "-", ">Fitting", "ui:fit.fillFrameProportionally", "ui:fit.contentProportionally", @@ -702,6 +646,73 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:object.content|Text|{\"type\":\"text\"}", "cmd:object.content|Unassigned|{\"type\":\"unassigned\"}", "<", + "-", + ">Effects", + "ui:effects.dialog|Transparency…|{\"page\": \"transparency\"}", + "cmd:object.dropShadow", + "cmd:object.innerShadow", + "cmd:object.outerGlow", + "cmd:object.innerGlow", + "cmd:object.bevel", + "cmd:object.satin", + "cmd:object.feather", + "cmd:object.directionalFeather", + "<", + "cmd:object.cornerOptions", + "-", + "ui:app.layerOptions", + "cmd:object.exportOptions", + ">Captions", + "cmd:object.caption", + "cmd:object.caption|Generate Live Caption|{\"live\": true}", + "<", + ">Clipping Path", + "cmd:object.clippingPathOptions|None|{\"type\": \"none\"}", + "cmd:object.clippingPath|Alpha Channel|{\"type\": \"alpha\"}", + "cmd:object.clippingPath|Detect Edges|{\"type\": \"edges\"}", + "<", + "-", + ">Flex Layout", + "cmd:flex.add", + "cmd:flex.convert", + "cmd:flex.remove", + "-", + "ui:window.panel|Show Flex Controls|{\"panel\": \"flexLayout\"}", + "<", + "-", + ">Paths", + "cmd:path.join", + "cmd:object.convertShape|Open Path|{\"to\": \"openPath\"}", + "cmd:object.convertShape|Close Path|{\"to\": \"closedPath\"}", + "cmd:path.reverse", + "-", + "cmd:object.makeCompoundPath", + "cmd:object.releaseCompoundPath", + "<", + ">Pathfinder", + "cmd:object.pathfinder|Add|{\"op\": \"add\"}", + "cmd:object.pathfinder|Subtract|{\"op\": \"subtract\"}", + "cmd:object.pathfinder|Intersect|{\"op\": \"intersect\"}", + "cmd:object.pathfinder|Exclude Overlap|{\"op\": \"exclude\"}", + "cmd:object.pathfinder|Minus Back|{\"op\": \"minusBack\"}", + "<", + ">Convert Shape", + "cmd:object.convertShape|Rectangle|{\"to\": \"rectangle\"}", + "cmd:object.convertShape|Rounded Rectangle|{\"to\": \"roundedRectangle\"}", + "cmd:object.convertShape|Beveled Rectangle|{\"to\": \"beveledRectangle\"}", + "cmd:object.convertShape|Inverse Rounded Rectangle|{\"to\": \"inverseRoundedRectangle\"}", + "cmd:object.convertShape|Ellipse|{\"to\": \"ellipse\"}", + "cmd:object.convertShape|Triangle|{\"to\": \"triangle\"}", + "cmd:object.convertShape|Polygon|{\"to\": \"polygon\"}", + "cmd:object.convertShape|Line|{\"to\": \"line\"}", + "cmd:object.convertShape|Orthogonal Line|{\"to\": \"orthogonalLine\"}", + "<", + ">Convert Point", + "cmd:path.convertPoints|Plain|{\"kind\": \"plain\"}", + "cmd:path.convertPoints|Corner|{\"kind\": \"corner\"}", + "cmd:path.convertPoints|Smooth|{\"kind\": \"smooth\"}", + "cmd:path.convertPoints|Symmetrical|{\"kind\": \"symmetrical\"}", + "<", ], ), ( @@ -3254,6 +3265,81 @@ mod tests { items.iter().filter_map(|e| e.split_once(':').map(|(_, rest)| rest.split('|').next().unwrap_or(rest))).collect() } + /// The Object menu's top level in InDesign's order and groups (as InDesign 2023 shows it in + /// Learn Skills Daily's course), with Paths holding Join, Open and Close Path, Convert Shape + /// only shapes, and a Convert Point submenu. + #[test] + fn the_object_menu_is_in_indesigns_order() { + let items = MENUS.iter().find(|(t, _)| *t == "Object").map(|(_, items)| *items).unwrap(); + // The top-level entries: submenus by name, separators as "-", items by command id. + let mut top = Vec::new(); + let mut depth = 0; + for e in items { + match *e { + "<" => depth -= 1, + e if e.starts_with('>') => { + if depth == 0 { + top.push(e.trim_start_matches('>')); + } + depth += 1; + } + e if depth == 0 => top.push(e.split_once(':').map_or(e, |(_, r)| r.split('|').next().unwrap_or(r))), + _ => {} + } + } + assert_eq!( + top, + [ + "Transform", + "Transform Again", + "Arrange", + "Select", + "-", + "object.group", + "object.ungroup", + "object.lock", + "object.unlockAll", + "object.hide", + "object.showAll", + "-", + "app.qrCode", + "-", + "object.textFrameOptions", + "object.primaryTextFrame", + "Anchored Object", + "-", + "Fitting", + "Content", + "-", + "Effects", + "object.cornerOptions", + "-", + "app.layerOptions", + "object.exportOptions", + "Captions", + "Clipping Path", + "-", + "Flex Layout", + "-", + "Paths", + "Pathfinder", + "Convert Shape", + "Convert Point", + ] + ); + let ids = menu_ids("Object"); + for id in ["path.join", "path.reverse", "path.convertPoints"] { + assert!(ids.contains(&id), "{id}"); + } + let sub = |name: &str| { + let i = items.iter().position(|e| *e == format!(">{name}")).unwrap(); + items[i + 1..].iter().take_while(|e| **e != "<").copied().collect::>() + }; + assert!(sub("Paths").iter().any(|e| e.contains("|Open Path|")) && sub("Paths").iter().any(|e| e.contains("|Close Path|"))); + assert!(!sub("Convert Shape").iter().any(|e| e.contains("openPath") || e.contains("closedPath")), "shapes only"); + assert_eq!(sub("Convert Point").len(), 4); + } + #[test] fn view_items_are_in_the_view_menu_as_in_indesign() { let (view, typ) = (menu_ids("View"), menu_ids("Type")); From a91438db8d5feaa3880f8da6ec4830ae054093c8 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 23:55:05 +0200 Subject: [PATCH 030/207] Properties: corner shape and stroke type lists show their glyphs, as in InDesign Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/i18n.rs | 2 + crates/ui-egui/src/i18n/ar.rs | 2 + crates/ui-egui/src/i18n/cs.rs | 2 + crates/ui-egui/src/i18n/de.rs | 2 + crates/ui-egui/src/i18n/it.rs | 2 + crates/ui-egui/src/i18n/ja.rs | 2 + crates/ui-egui/src/i18n/ka.rs | 2 + crates/ui-egui/src/i18n/pl.rs | 2 + crates/ui-egui/src/i18n/pt_br.rs | 2 + crates/ui-egui/src/i18n/ru.rs | 2 + crates/ui-egui/src/i18n/uk.rs | 2 + crates/ui-egui/src/panels/properties.rs | 140 ++++++++++++++++++------ 13 files changed, 130 insertions(+), 33 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..e1e4b8510 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-20** · Properties › Appearance: the corner shape list shows each shape's glyph beside its name, drawn as InDesign draws them (Fancy has its own glyph now), and the stroke type list shows each type's line; both dropdowns are named for screen readers. 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/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..b5494731c 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -89,6 +89,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("Stroke Type", ["Stroke Type", "Type de contour", "Stroke Type", "Stroke Type", "描边类型"]), + ("Corner Shape", ["Corner Shape", "Forme d'angle", "Corner Shape", "Corner Shape", "角形状"]), ("Hide Rulers", ["Lineale ausblenden", "Masquer les règles", "Ocultar reglas", "定規を隠す", "隐藏标尺"]), ("Inches Decimal", ["Zoll dezimal", "Pouces décimaux", "Pulgadas decimales", "インチ(10 進)", "十进制英寸"]), ( diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..7c7406c21 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Stroke Type", "نوع الحد"), + ("Corner Shape", "شكل الزاوية"), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..bc28598ae 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Stroke Type", "Typ tahu"), + ("Corner Shape", "Tvar rohu"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index efc128858..05a4b5b25 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("Stroke Type", "Konturtyp"), + ("Corner Shape", "Eckenform"), ("Inches Decimal", "Zoll dezimal"), ("Back", "Zurück"), ("Warning", "Warnung"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..ce38d066e 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Stroke Type", "Tipo traccia"), + ("Corner Shape", "Forma angolo"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 5647ca940..bd69f6628 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Stroke Type", "線の種類"), + ("Corner Shape", "角のシェイプ"), ("Paste Vector Art", "ベクトルアートをペースト"), ("Baseline Options", "ベースラインオプション"), ("Column Rules", "段落境界線"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..5a0736c09 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Stroke Type", "კონტურის ტიპი"), + ("Corner Shape", "კუთხის ფორმა"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..4b01ab6af 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Stroke Type", "Rodzaj obrysu"), + ("Corner Shape", "Kształt narożnika"), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..75406e9b0 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Stroke Type", "Tipo de traçado"), + ("Corner Shape", "Forma do canto"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..72df9edd4 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Stroke Type", "Тип обводки"), + ("Corner Shape", "Форма угла"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..f50e567fc 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Stroke Type", "Тип обведення"), + ("Corner Shape", "Форма кута"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 44acf6e2c..c28f02958 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -674,11 +674,11 @@ pub(crate) fn stroke_type_dropdown(app: &mut DesignApp, ui: &mut Ui, cur: &str, ("Thin - Thin", "thinThin"), ("Thick - Thick", "thickThick"), ]; - let resp = preview_dropdown(ui, w, |p, r| paint_stroke_type(p, r, cur)); + let resp = preview_dropdown(ui, w, crate::i18n::tr(&app.ui.language, "Stroke Type"), |p, r| paint_stroke_type(p, r, cur, egui::Color32::BLACK)); egui::Popup::menu(&resp).show(|ui| { - ui.set_min_width(140.0); + ui.set_min_width(170.0); for (label, kind) in TYPES { - if ui.selectable_label(*kind == cur, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { + if preview_row(ui, *kind == cur, crate::i18n::tr(&app.ui.language, label), 40.0, |p, r, c| paint_stroke_type(p, r, kind, c)).clicked() { let ty = if *kind == "dashed" { json!({"kind": "dashed", "pattern": [12.0, 4.0]}) } else { json!({"kind": kind}) }; let _ = app.run("object.stroke", json!({"type": ty})); ui.close(); @@ -687,8 +687,7 @@ pub(crate) fn stroke_type_dropdown(app: &mut DesignApp, ui: &mut Ui, cur: &str, }); } -fn paint_stroke_type(p: &egui::Painter, r: Rect, kind: &str) { - let c = egui::Color32::BLACK; +fn paint_stroke_type(p: &egui::Painter, r: Rect, kind: &str, c: egui::Color32) { let (x0, x1, y) = (r.min.x + 5.0, r.max.x - 5.0, r.center().y); match kind { "dashed" => { @@ -721,10 +720,35 @@ fn paint_stroke_type(p: &egui::Painter, r: Rect, kind: &str) { } } +/// A row of a preview dropdown's list, as InDesign's: a small preview of the choice (`preview_w` +/// wide), then its name. Selected rows are highlighted. +fn preview_row(ui: &mut Ui, selected: bool, label: &str, preview_w: f32, paint: impl FnOnce(&egui::Painter, Rect, egui::Color32)) -> egui::Response { + let t = Tokens::get(ui.ctx()); + // As wide as the preview and the longest name (the popup otherwise grows to all it's offered). + let w = preview_w + 130.0; + let (r, resp) = ui.allocate_exact_size(vec2(w, 22.0), Sense::click()); + if selected || resp.hovered() { + ui.painter().rect_filled(r, 2.0, if selected { t.row_selected } else { t.hover }); + } + let ink = if selected { t.text_strong } else { t.text }; + paint(ui.painter(), Rect::from_min_size(r.min + vec2(4.0, 0.0), vec2(preview_w, 22.0)), ink); + crate::rtl::paint( + ui.painter(), + pos2(r.min.x + preview_w + 12.0, r.center().y), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(12.0), + ink, + ); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, label)); + resp +} + /// A dropdown whose face is a light preview box (stroke type, corner shape). -fn preview_dropdown(ui: &mut Ui, w: f32, paint: impl FnOnce(&egui::Painter, Rect)) -> egui::Response { +fn preview_dropdown(ui: &mut Ui, w: f32, label: &str, paint: impl FnOnce(&egui::Painter, Rect)) -> egui::Response { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, label)); let face = Rect::from_min_max(r.min, pos2(r.max.x - widgets::SEG_W, r.max.y)); ui.painter().rect_filled(face, 0.0, egui::Color32::from_gray(0xdf)); paint(ui.painter(), face); @@ -735,38 +759,56 @@ fn preview_dropdown(ui: &mut Ui, w: f32, paint: impl FnOnce(&egui::Painter, Rect resp } -fn paint_corner_shape(p: &egui::Painter, r: Rect, shape: CornerShape) { - let c = egui::Color32::BLACK; - let s = Stroke::new(1.5, c); - let (x0, y0, x1, y1) = (r.min.x + 4.0, r.min.y + 4.0, r.max.x - 9.0, r.max.y - 3.0); - let k = 8.0; - let pts: Vec = match shape { - CornerShape::None => vec![pos2(x0, y0), pos2(x1, y0), pos2(x1, y1)], - CornerShape::Bevel => vec![pos2(x0, y0), pos2(x1 - k, y0), pos2(x1, y0 + k), pos2(x1, y1)], - CornerShape::Inset => vec![pos2(x0, y0), pos2(x1 - k, y0), pos2(x1 - k, y0 + k), pos2(x1, y0 + k), pos2(x1, y1)], - CornerShape::Rounded | CornerShape::InverseRounded | CornerShape::Fancy => { - let mut v = vec![pos2(x0, y0)]; - for i in 0..=8 { - let a = i as f32 / 8.0 * std::f32::consts::FRAC_PI_2; - v.push(if shape == CornerShape::InverseRounded { - pos2(x1 - k * a.cos(), y0 + k * a.sin()) - } else { - pos2(x1 - k + k * a.sin(), y0 + k - k * a.cos()) - }); - } - v.push(pos2(x1, y1)); - v - } +/// A corner shape's glyph, as InDesign draws it in its corner lists: the top edge running into the +/// corner and the right edge leaving it, in a square centred in `r`. +fn paint_corner_shape(p: &egui::Painter, r: Rect, shape: CornerShape, c: egui::Color32) { + let side = (r.width() - 3.0).min(r.height() - 3.0).max(4.0); + let o = pos2(r.center().x - side / 2.0, r.center().y - side / 2.0); + let at = |x: f32, y: f32| pos2(o.x + x * side, o.y + y * side); + let quarter = |cx: f32, cy: f32, rad: f32, from: f32, to: f32| -> Vec { + (0..=10).map(|i| from + (to - from) * i as f32 / 10.0).map(|a| at(cx + rad * a.cos(), cy + rad * a.sin())).collect() }; - p.add(egui::Shape::line(pts, s)); + use std::f32::consts::{FRAC_PI_2, PI}; + let stroke = Stroke::new(1.6, c); + let line = |pts: Vec| { + p.add(egui::Shape::line(pts, stroke)); + }; + match shape { + CornerShape::None => line(vec![at(0.0, 0.0), at(1.0, 0.0), at(1.0, 1.0)]), + // The corner cut by a diagonal, half the side. + CornerShape::Bevel => line(vec![at(0.0, 0.0), at(0.5, 0.0), at(1.0, 0.5), at(1.0, 1.0)]), + // A square notch. + CornerShape::Inset => line(vec![at(0.0, 0.0), at(0.5, 0.0), at(0.5, 0.45), at(1.0, 0.45), at(1.0, 1.0)]), + // A concave quarter circle centred on the corner. + CornerShape::InverseRounded => { + let mut v = vec![at(0.0, 0.0)]; + v.extend(quarter(1.0, 0.0, 0.5, PI, FRAC_PI_2)); + v.push(at(1.0, 1.0)); + line(v) + } + // A large convex arc. + CornerShape::Rounded => { + let mut v = vec![at(0.0, 0.0)]; + v.extend(quarter(0.08, 0.92, 0.92, -FRAC_PI_2, 0.0)); + v.push(at(1.0, 1.0)); + line(v) + } + // Steps into the corner with a small square loop between them. + CornerShape::Fancy => { + line(vec![at(0.0, 0.0), at(0.38, 0.0), at(0.38, 0.2), at(0.52, 0.2)]); + p.rect_stroke(Rect::from_min_max(at(0.52, 0.06), at(0.76, 0.3)), 0.0, stroke, StrokeKind::Middle); + line(vec![at(0.76, 0.3), at(0.76, 0.48), at(1.0, 0.48), at(1.0, 1.0)]); + } + } } pub(crate) fn corner_type_dropdown(app: &mut DesignApp, ui: &mut Ui, shape: CornerShape, size: f64, width: f32) { - let resp = preview_dropdown(ui, width, |p, r| paint_corner_shape(p, r, shape)); + let resp = + preview_dropdown(ui, width, crate::i18n::tr(&app.ui.language, "Corner Shape"), |p, r| paint_corner_shape(p, r, shape, egui::Color32::BLACK)); egui::Popup::menu(&resp).show(|ui| { - ui.set_min_width(140.0); + ui.set_min_width(150.0); for s in CornerShape::ALL { - if ui.selectable_label(s == shape, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, s.label()))).clicked() { + if preview_row(ui, s == shape, crate::i18n::tr(&app.ui.language, s.label()), 22.0, |p, r, c| paint_corner_shape(p, r, s, c)).clicked() { let _ = app.run("object.cornerOptions", json!({"shape": s, "size": if size > 0.0 { size } else { 12.0 }})); ui.close(); } @@ -3343,6 +3385,38 @@ mod tests { assert!((r.x0 - 200.0).abs() < 0.5 && (r.y1 - 200.0).abs() < 0.5 && (r.x1 - 300.0).abs() < 0.5, "{r:?}"); } + /// The corner shape list shows each shape's glyph beside its name, as InDesign's; a row picks + /// the shape and the current one is marked. + #[test] + fn the_corner_shape_list_has_glyph_rows_and_picks_a_shape() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let mut h = panel(app); + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Corner Shape").click(); + h.run_steps(3); + let rows = ["None", "Fancy", "Bevel", "Inset", "Inverse Rounded", "Rounded"]; + for r in rows { + let _ = h.get_by_role_and_label(egui::accesskit::Role::Button, r); + } + h.get_by_role_and_label(egui::accesskit::Role::Button, "Bevel").click(); + h.run_steps(3); + let it = h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().clone(); + assert!(it.corners.corners.iter().all(|c| c.shape == designcraft_geom::corners::CornerShape::Bevel), "{:?}", it.corners); + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Corner Shape").click(); + h.run_steps(3); + use egui_kittest::kittest::NodeT; + let n = h.get_by_role_and_label(egui::accesskit::Role::Button, "Bevel"); + assert_eq!(n.accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "the current shape is marked"); + // The stroke type list has the same rows, by name. + h.key_press(egui::Key::Escape); + h.run_steps(2); + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Stroke Type").click(); + h.run_steps(3); + let _ = h.get_by_role_and_label(egui::accesskit::Role::Button, "Thick - Thin"); + } + const CYAN: &str = "C=100 M=0 Y=0 K=0"; #[test] @@ -3746,7 +3820,7 @@ fn flex_section(app: &mut DesignApp, ui: &mut Ui) { let cur = serde_json::to_value(cur).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); let icon = FLEX_JUSTIFY.iter().find(|j| j.0 == cur).map(|j| j.1).unwrap_or("flex-start"); let resp = place(ui, sub(b, 0.0, 0.0, 58.0, FIELD_H), |ui| { - preview_dropdown(ui, 58.0, |p, r| icons::paint(p, r.shrink2(vec2(6.0, 2.0)), icon, egui::Color32::from_gray(60))) + preview_dropdown(ui, 58.0, &tr("Justification"), |p, r| icons::paint(p, r.shrink2(vec2(6.0, 2.0)), icon, egui::Color32::from_gray(60))) }); egui::Popup::menu(&resp).show(|ui| { ui.set_min_width(150.0); From c0bf260b5731f14044d5f871ad873ce9bb22381d Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 21 Oct 2026 00:13:51 +0200 Subject: [PATCH 031/207] PR-1394: checkboxes report whether they're enabled Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties.rs | 4 ++++ crates/ui-egui/src/widgets.rs | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index b15e3471d..f562d4d05 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -3370,6 +3370,10 @@ mod tests { let graphic = |app: &DesignApp| app.session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().graphic().unwrap().clone(); assert_eq!(graphic(&app).auto_fit, designcraft_doc::Fitting::None); let mut h = panel(app); + // A checkbox its section disables says so: Invert, with no text wrap. + use egui_kittest::kittest::NodeT; + assert!(h.get_by_label("Invert").accesskit_node().is_disabled()); + assert!(!h.get_by_label("Auto-Fit").accesskit_node().is_disabled()); h.get_by_label("Auto-Fit").click(); h.run_steps(3); assert_ne!(graphic(h.state()).auto_fit, designcraft_doc::Fitting::None, "Auto-Fit is on"); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 645c83c1a..7ebd175a6 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -569,7 +569,7 @@ pub fn checkbox(ui: &mut Ui, on: &mut bool, label: &str) -> Response { } // Named and ticked or not for screen readers (and tests). let checked = *on; - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, checked, label)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), checked, label)); resp } From aa66d3455790710109185fb2eb1875c6f40f758e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 21 Oct 2026 00:29:58 +0200 Subject: [PATCH 032/207] i18n: Spanish for the new labels Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 22e8a0a87..1b04cd89c 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -89,8 +89,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), - ("Join", ["Join", "Joindre", "Join", "Join", "连接"]), - ("Convert Point", ["Convert Point", "Convertir le point", "Convert Point", "Convert Point", "转换点"]), + ("Join", ["Join", "Joindre", "Unir", "Join", "连接"]), + ("Convert Point", ["Convert Point", "Convertir le point", "Convertir punto", "Convert Point", "转换点"]), ("Hide Rulers", ["Lineale ausblenden", "Masquer les règles", "Ocultar reglas", "定規を隠す", "隐藏标尺"]), ("Inches Decimal", ["Zoll dezimal", "Pouces décimaux", "Pulgadas decimales", "インチ(10 進)", "十进制英寸"]), ( From f66621ff5f8703973677069bce040b002a0b0532 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 21 Oct 2026 00:30:03 +0200 Subject: [PATCH 033/207] i18n: Spanish for the new labels Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index b5494731c..d5208664d 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -89,8 +89,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), - ("Stroke Type", ["Stroke Type", "Type de contour", "Stroke Type", "Stroke Type", "描边类型"]), - ("Corner Shape", ["Corner Shape", "Forme d'angle", "Corner Shape", "Corner Shape", "角形状"]), + ("Stroke Type", ["Stroke Type", "Type de contour", "Tipo de trazo", "Stroke Type", "描边类型"]), + ("Corner Shape", ["Corner Shape", "Forme d'angle", "Forma de esquina", "Corner Shape", "角形状"]), ("Hide Rulers", ["Lineale ausblenden", "Masquer les règles", "Ocultar reglas", "定規を隠す", "隐藏标尺"]), ("Inches Decimal", ["Zoll dezimal", "Pouces décimaux", "Pulgadas decimales", "インチ(10 進)", "十进制英寸"]), ( From 7db54c52e3a3fcad575a615b09f6024e4147316f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 21 Oct 2026 01:40:20 +0200 Subject: [PATCH 034/207] Canvas: the cursor readout is InDesign's light grey box The readout beside the cursor while drawing or measuring uses a new readout_bg token, InDesign's #a8a8a8 with white text as measured from the tutorials' frames. High Contrast keeps it black. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/theme.rs | 24 ++++++++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..40ed35432 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** · The readout beside the cursor while drawing or measuring ("W: 3.13 cm") is InDesign's light grey box with white text, measured from the tutorials' frames; High Contrast keeps it black. It was a dark translucent box. 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/canvas.rs b/crates/ui-egui/src/canvas.rs index 8d94f099f..5f37574bf 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1705,7 +1705,7 @@ fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, xf: &Xf) { let s = xf.to_screen(p) + vec2(14.0, 14.0); let g = painter.layout_no_wrap(text, egui::FontId::proportional(11.0), tok.measure_text); let r = Rect::from_min_size(s, g.size() + vec2(10.0, 6.0)); - painter.rect_filled(r, 3.0, tok.measure_bg); + painter.rect_filled(r, 3.0, tok.readout_bg); painter.galley(r.min + vec2(5.0, 3.0), g, tok.measure_text); } Overlay::Line { a, b, color, dashed: d } => { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 7b2364ffe..cce524de3 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -83,6 +83,9 @@ pub struct Tokens { /// Measurement labels on the canvas (smart dimensions, gaps). pub measure_bg: Color32, pub measure_text: Color32, + /// The readout beside the cursor while drawing, moving or measuring ("W: 3.13 cm"): InDesign's + /// light grey box with white text, in every brightness but High Contrast. + pub readout_bg: Color32, /// Live-corner widget and corner diamonds. pub live_corner: Color32, /// The Windows caption Close button when hovered, and its glyph. @@ -130,6 +133,7 @@ impl Tokens { smart_guide: hex(0x00c853), measure_bg: Color32::from_rgba_unmultiplied(70, 70, 70, 230), measure_text: Color32::WHITE, + readout_bg: hex(0xa8a8a8), live_corner: hex(0xffe500), caption_close: hex(0xc42b1c), caption_close_text: Color32::WHITE, @@ -255,6 +259,8 @@ impl Tokens { ruler_text: hex(0xffffff), section_divider: hex(0xffffff), tab_strip: hex(0x000000), + // White on InDesign's grey is too faint here: black, as the rest of the chrome. + readout_bg: hex(0x000000), ..dark }, } @@ -797,3 +803,21 @@ mod russian_font_tests { } } } + +#[cfg(test)] +mod readout_tests { + use super::{Brightness, Tokens}; + + /// The readout beside the cursor ("W: 3.13 cm" while drawing a frame) is InDesign's light + /// grey box with white text in every brightness, as measured from the tutorials' frames; + /// High Contrast keeps it black. + #[test] + fn the_cursor_readout_is_indesigns_light_grey() { + for b in [Brightness::Dark, Brightness::MediumDark, Brightness::MediumLight, Brightness::Light] { + let t = Tokens::for_brightness(b); + assert_eq!(t.readout_bg, egui::Color32::from_rgb(0xa8, 0xa8, 0xa8), "{b:?}"); + assert_eq!(t.measure_text, egui::Color32::WHITE, "{b:?}"); + } + assert_eq!(Tokens::for_brightness(Brightness::HighContrast).readout_bg, egui::Color32::BLACK); + } +} From d24ce2cfd37e7b5024c0e1ff2da8b76c25570fd3 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 21 Oct 2026 02:08:17 +0200 Subject: [PATCH 035/207] Swatches: InDesign's type columns on each row Rows show a crossed pencil on the swatches that can't be edited ([None], [Registration], [Black]), the colour type (process halftone or spot disc) and the colour mode (CMYK, RGB, Gray, or the [None] and [Registration] glyphs), drawn in code, in place of a lock and "CMYK" text. The Tint field reads 100 with nothing selected, as InDesign's. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/panels/swatches.rs | 183 +++++++++++++++++++++++--- 2 files changed, 169 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..7ec6db9cf 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** · Swatches panel rows show InDesign's columns, drawn in code: a crossed pencil on the swatches that can't be edited ([None], [Registration], [Black]), the colour type (process or spot) and the colour mode (CMYK, RGB, Gray, or [None]'s and [Registration]'s own glyphs), in place of a lock and "CMYK" text. Tint reads 100 with nothing selected. 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/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 3e7030e87..32bc9e128 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -196,7 +196,8 @@ fn header(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Documen } ui.add_space(6.0); crate::widgets::caption(ui, crate::i18n::tr(&app.ui.language, "Tint:")); - let tint = target.map(|(_, ft, _, st)| f64::from(if stroke_front { *st } else { *ft }) * 100.0); + // With nothing selected InDesign's field reads 100. + let tint = target.map_or(Some(100.0), |(_, ft, _, st)| Some(f64::from(if stroke_front { *st } else { *ft }) * 100.0)); if let Some(v) = crate::widgets::number(ui, "swatches_tint", tint, "", 44.0, 0) { let _ = set_tint(app, v); } @@ -232,6 +233,127 @@ fn folder_button(ui: &mut egui::Ui, tip: &str) -> egui::Response { }) } +/// What a Swatches panel row marks on its right, as InDesign's columns. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct RowMarks { + /// [None], [Registration] and [Black] can't be edited ([Paper] can). + not_editable: bool, + kind: Option, + mode: Option, +} + +/// The colour-mode column: the ink model, or the special swatches' own glyphs. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RowMode { + Cmyk, + Rgb, + Gray, + None, + Registration, +} + +fn row_marks(sw: &designcraft_color::swatch::Swatch) -> RowMarks { + use designcraft_color::Color; + let not_editable = sw.locked && sw.name != designcraft_color::swatch::PAPER; + match &sw.value { + SwatchValue::Color { color, color_type } => { + let mode = match color { + Color::Cmyk { .. } => RowMode::Cmyk, + Color::Rgb { .. } => RowMode::Rgb, + Color::Gray { .. } => RowMode::Gray, + }; + RowMarks { not_editable, kind: Some(*color_type), mode: Some(mode) } + } + SwatchValue::None => RowMarks { not_editable, kind: None, mode: Some(RowMode::None) }, + SwatchValue::Registration => RowMarks { not_editable, kind: None, mode: Some(RowMode::Registration) }, + _ => RowMarks { not_editable, kind: None, mode: None }, + } +} + +/// A pencil with a stroke through it: the swatch can't be edited. Drawn in code. +fn paint_not_editable(p: &egui::Painter, r: egui::Rect, ink: egui::Color32) { + // The pencil: its tip at the lower left, its body up to the right. + let tip = r.left_bottom() + vec2(1.5, -1.5); + let d = vec2(1.0, -1.0).normalized(); + let n = vec2(d.y, -d.x) * 1.9; + let (b0, b1) = (tip + d * 3.4, tip + d * 12.5); + p.add(egui::Shape::convex_polygon(vec![tip, b0 + n, b0 - n], ink, egui::Stroke::NONE)); + p.add(egui::Shape::convex_polygon(vec![b0 + n, b1 + n, b1 - n, b0 - n], ink, egui::Stroke::NONE)); + // The stroke through it, with a gap of the panel's colour around it so it reads on the body. + let (s0, s1) = (r.left_top() + vec2(1.5, 1.5), r.right_bottom() - vec2(1.5, 1.5)); + p.line_segment([s0, s1], egui::Stroke::new(3.2, egui::Color32::from_black_alpha(140))); + p.line_segment([s0, s1], egui::Stroke::new(1.2, ink)); +} + +/// The colour type: a process colour is a halftone square, a spot colour a disc in a square. +fn paint_color_type(p: &egui::Painter, r: egui::Rect, kind: ColorType, ink: egui::Color32) { + let sq = egui::Rect::from_center_size(r.center(), vec2(10.0, 10.0)); + p.rect_filled(sq, 0.0, ink); + if kind == ColorType::Spot { + p.circle_filled(sq.center(), 3.2, egui::Color32::from_black_alpha(110)); + } else { + // A fine halftone: a grey square with a light dot screen. + let dot = egui::Color32::from_black_alpha(70); + for i in 0..4u8 { + for j in 0..4u8 { + let o = if j % 2 == 0 { 1.5 } else { 2.6 }; + p.circle_filled(sq.min + vec2(o + 2.2 * f32::from(i), 1.6 + 2.3 * f32::from(j)), 0.6, dot); + } + } + } +} + +/// The colour mode: CMYK's four inks meeting in the middle, RGB's three primaries, a grey ramp, +/// [None]'s red slash and [Registration]'s target. +fn paint_mode(p: &egui::Painter, r: egui::Rect, mode: RowMode, ink: egui::Color32) { + let sq = egui::Rect::from_center_size(r.center(), vec2(11.0, 11.0)); + let c = sq.center(); + let rgb = egui::Color32::from_rgb; + match mode { + RowMode::Cmyk => { + let corners = [sq.left_top(), sq.right_top(), sq.right_bottom(), sq.left_bottom()]; + // Top, right, bottom, left: magenta, yellow, black, cyan. + let inks = [rgb(236, 0, 140), rgb(255, 230, 0), rgb(30, 30, 30), rgb(0, 174, 239)]; + for (k, colour) in inks.into_iter().enumerate() { + let (a, b) = (corners[k], corners[(k + 1) % 4]); + p.add(egui::Shape::convex_polygon(vec![a, b, c], colour, egui::Stroke::NONE)); + } + p.rect_stroke(sq, 0.0, egui::Stroke::new(1.0, ink), egui::StrokeKind::Outside); + } + RowMode::Rgb => { + let w = sq.width() / 3.0; + for (k, colour) in [rgb(230, 40, 40), rgb(40, 190, 60), rgb(40, 90, 230)].into_iter().enumerate() { + let x = sq.min.x + w * k as f32; + p.rect_filled(egui::Rect::from_min_max(egui::pos2(x, sq.min.y), egui::pos2(x + w, sq.max.y)), 0.0, colour); + } + p.rect_stroke(sq, 0.0, egui::Stroke::new(1.0, ink), egui::StrokeKind::Outside); + } + RowMode::Gray => { + let w = sq.width() / 4.0; + for k in 0..4u8 { + let x = sq.min.x + w * f32::from(k); + p.rect_filled( + egui::Rect::from_min_max(egui::pos2(x, sq.min.y), egui::pos2(x + w, sq.max.y)), + 0.0, + egui::Color32::from_gray(255 - 70 * k), + ); + } + p.rect_stroke(sq, 0.0, egui::Stroke::new(1.0, ink), egui::StrokeKind::Outside); + } + RowMode::None => { + p.rect_filled(sq, 0.0, egui::Color32::WHITE); + p.line_segment([sq.left_bottom(), sq.right_top()], egui::Stroke::new(1.5, rgb(220, 30, 30))); + p.rect_stroke(sq, 0.0, egui::Stroke::new(1.0, ink), egui::StrokeKind::Outside); + } + RowMode::Registration => { + p.rect_stroke(sq, 0.0, egui::Stroke::new(1.0, ink), egui::StrokeKind::Inside); + p.circle_stroke(c, 3.0, egui::Stroke::new(1.0, ink)); + p.line_segment([c - vec2(4.5, 0.0), c + vec2(4.5, 0.0)], egui::Stroke::new(1.0, ink)); + p.line_segment([c - vec2(0.0, 4.5), c + vec2(0.0, 4.5)], egui::Stroke::new(1.0, ink)); + } + } +} + /// One Swatches panel row: chip, name, type; click applies it to the fill or stroke in front of /// the proxy (of the frames or their text, see [`super::colors_affect_text`]), the context menu /// to the stroke, colour groups and delete. @@ -267,24 +389,26 @@ fn swatch_row( let chip = egui::Rect::from_min_size(row.min + vec2(4.0 + indent, 3.0), vec2(16.0, 16.0)); crate::widgets::paint_chip(ui.painter(), chip, c, g); ui.painter().text(row.min + vec2(28.0 + indent, 11.0), egui::Align2::LEFT_CENTER, &sw.name, egui::FontId::proportional(12.5), t.text); - // Type indicators: spot dot / process square, colour mode. - let kind = match &sw.value { - SwatchValue::Color { color, color_type } => { - let m = match color { - designcraft_color::Color::Cmyk { .. } => "CMYK", - designcraft_color::Color::Rgb { .. } => "RGB", - designcraft_color::Color::Gray { .. } => crate::i18n::tr(&app.ui.language, "Gray"), - }; - format!("{}{}", if *color_type == ColorType::Spot { "● " } else { "" }, m) - } - SwatchValue::Gradient { .. } => "Gradient".into(), + // InDesign's columns on the right: not editable, colour type, colour mode. + let marks = row_marks(sw); + let col = |k: f32| egui::Rect::from_min_size(egui::pos2(row.max.x - 19.0 - 18.0 * k, row.min.y + 4.5), vec2(13.0, 13.0)); + if marks.not_editable { + paint_not_editable(ui.painter(), col(2.0), t.text_dim); + } + if let Some(kind) = marks.kind { + paint_color_type(ui.painter(), col(1.0), kind, t.text_dim); + } + if let Some(mode) = marks.mode { + paint_mode(ui.painter(), col(0.0), mode, t.text_dim); + } + let label = match &sw.value { + SwatchValue::Gradient { .. } => crate::i18n::tr(&app.ui.language, "Gradient").to_string(), SwatchValue::Tint { tint, .. } => format!("{:.0}%", tint * 100.0), _ => String::new(), }; - if sw.locked { - icons::paint(ui.painter(), egui::Rect::from_min_size(egui::pos2(row.max.x - 64.0, row.min.y + 4.0), vec2(13.0, 13.0)), "lock", t.text_dim); + if !label.is_empty() { + ui.painter().text(row.right_center() - vec2(6.0, 0.0), egui::Align2::RIGHT_CENTER, label, egui::FontId::proportional(10.5), t.text_dim); } - ui.painter().text(row.right_center() - vec2(6.0, 0.0), egui::Align2::RIGHT_CENTER, kind, egui::FontId::proportional(10.5), t.text_dim); if resp.clicked() && !resp.double_clicked() { ui.data_mut(|d| d.insert_temp(egui::Id::new(PICKED), sw.name.clone())); let _ = super::apply_swatch(app, app.ui.stroke_front, &sw.name, None); @@ -1161,3 +1285,32 @@ mod swatches_header_tests { assert_eq!((it[1].fill.swatch.as_str(), it[1].fill.tint), ("C=0 M=100 Y=0 K=0", 0.4)); } } + +#[cfg(test)] +mod row_mark_tests { + use designcraft_color::swatch::{Swatch, default_swatches}; + use designcraft_color::{Color, ColorType, SwatchValue}; + + use super::{RowMarks, RowMode, row_marks}; + + /// The Swatches panel's right-hand columns, as InDesign's: [None], [Registration] and [Black] + /// can't be edited, [Paper] can; colours show their type and mode. + #[test] + fn swatch_rows_mark_type_mode_and_whether_they_can_be_edited() { + let all = default_swatches(); + let marks = |name: &str| row_marks(all.iter().find(|s| s.name == name).unwrap()); + assert_eq!(marks("[None]"), RowMarks { not_editable: true, kind: None, mode: Some(RowMode::None) }); + assert_eq!(marks("[Registration]"), RowMarks { not_editable: true, kind: None, mode: Some(RowMode::Registration) }); + assert_eq!(marks("[Paper]"), RowMarks { not_editable: false, kind: None, mode: None }); + assert_eq!(marks("[Black]"), RowMarks { not_editable: true, kind: Some(ColorType::Process), mode: Some(RowMode::Cmyk) }); + let cyan = all.iter().find(|s| matches!(&s.value, SwatchValue::Color { .. }) && !s.locked).unwrap(); + assert_eq!(row_marks(cyan), RowMarks { not_editable: false, kind: Some(ColorType::Process), mode: Some(RowMode::Cmyk) }); + let spot = Swatch { + name: "PANTONE".into(), + value: SwatchValue::Color { color: Color::Rgb { r: 1.0, g: 0.0, b: 0.0 }, color_type: ColorType::Spot }, + locked: false, + ..cyan.clone() + }; + assert_eq!(row_marks(&spot), RowMarks { not_editable: false, kind: Some(ColorType::Spot), mode: Some(RowMode::Rgb) }); + } +} From af36dc399de32adc3c60c9a24f8c900c6f58bf28 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 21 Oct 2026 02:38:12 +0200 Subject: [PATCH 036/207] Fitting: Clear Frame Fitting Options; the submenu grouped as InDesign's object.clearFittingOptions puts the frames' auto-fit, crop amounts and reference point back to their defaults and leaves the picture where it is. Object > Fitting (and the context menu) groups the proportional fits, the frame and content fits, Clear Frame Fitting Options and the dialog. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/object.rs | 50 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n.rs | 10 +++++++ crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + crates/ui-egui/src/menus.rs | 28 ++++++++++++++++++ 14 files changed, 99 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..7e0ff1b56 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** · Object › Fitting › Clear Frame Fitting Options (`object.clearFittingOptions`): the frames' auto-fit, crop amounts and reference point go back to their defaults, and the picture stays where it is. The Fitting submenu is grouped as InDesign's. Found in the Learn Skills Daily course, chapter 11. 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/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 82f33a6d8..f709b6dec 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -638,6 +638,15 @@ pub fn specs() -> Vec { has_selection, fitting_options ), + cmd!( + "object.clearFittingOptions", + "Clear Frame Fitting Options", + ["Object", "Fitting"], + None, + "{ids?} — the frames' fitting options back to the defaults: no auto-fit, no crop amounts, the centre reference point; the content stays where it is", + has_selection, + clear_fitting_options + ), cmd!("object.rename", "Rename", [], None, "{id | ids?, name}", has_doc, |s, p| { let name = str_param(p, "name").unwrap_or("").to_string(); set_flag(s, p, move |i| i.name = name.clone(), false) @@ -2884,6 +2893,24 @@ fn fitting_options(s: &mut Session, p: &Value) -> Result { }) } +/// Clear Frame Fitting Options: auto-fit off, no crop, the centre reference point. The content +/// stays where it is, as in InDesign. +fn clear_fitting_options(s: &mut Session, p: &Value) -> Result { + let ids = targets(s, p)?; + s.edit(|d, _| { + let mut n = 0; + for id in &ids { + let Some(it) = d.item_mut(*id) else { continue }; + let Content::Graphic(g) = &mut it.content else { continue }; + g.auto_fit = designcraft_doc::Fitting::None; + g.crop = [0.0; 4]; + g.fit_align = 4; + n += 1; + } + Ok(json!({"changed": n})) + }) +} + fn fit(s: &mut Session, p: &Value) -> Result { let doc = &s.doc()?.doc; let ids: Vec<_> = @@ -4264,6 +4291,29 @@ mod fitting_tests { let g = back.spreads.iter().flat_map(|sp| sp.items.iter()).find_map(|i| i.graphic().cloned()).unwrap(); assert_eq!(g.auto_fit, designcraft_doc::Fitting::FillProportionally); } + + /// Clear Frame Fitting Options puts auto-fit, crop and the reference point back to their + /// defaults and leaves the picture where it is. + #[test] + fn clear_fitting_options_resets_the_frames_options_and_keeps_the_picture() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [72, 72, 272, 172]})).unwrap()["id"].as_u64().unwrap(); + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); + s.execute("file.place", &json!({"base64": crate::cmd::base64_encode(&png), "name": "p.png", "frame": f})).unwrap(); + s.execute("object.fittingOptions", &json!({"ids": [f], "autoFit": true, "fitting": "fitProportionally", "crop": 10, "align": 0})).unwrap(); + let graphic = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().graphic().unwrap().clone(); + let before = graphic(&s); + assert_ne!(before.auto_fit, designcraft_doc::Fitting::None); + s.execute("object.clearFittingOptions", &json!({"ids": [f]})).unwrap(); + let g = graphic(&s); + assert_eq!(g.auto_fit, designcraft_doc::Fitting::None); + assert_eq!(g.crop, [0.0; 4]); + assert_eq!(g.fit_align, 4, "the centre reference point"); + assert_eq!(g.xf, before.xf, "the picture stays where it is"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(graphic(&s).crop, [10.0; 4], "one undo step"); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..46ac7b73d 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -36,6 +36,16 @@ pub const LANGUAGES: &[(&str, &str)] = &[ /// English → [German, French, Spanish, Japanese, Simplified Chinese]. const TABLE: &[(&str, [&str; 5])] = &[ + ( + "Clear Frame Fitting Options", + [ + "Rahmeneinpassungsoptionen löschen", + "Effacer les options d’ajustement du bloc", + "Borrar opciones de encaje de marco", + "フレーム調整オプションを消去", + "清除框架适合选项", + ], + ), // Menus. ("File", ["Datei", "Fichier", "Archivo", "ファイル", "文件"]), ("Edit", ["Bearbeiten", "Édition", "Edición", "編集", "编辑"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..41295ee7e 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Clear Frame Fitting Options", "مسح خيارات ملاءمة الإطار"), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..bc39aea28 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Clear Frame Fitting Options", "Vymazat volby přizpůsobení rámečku"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index efc128858..626918117 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("Clear Frame Fitting Options", "Rahmeneinpassungsoptionen löschen"), ("Inches Decimal", "Zoll dezimal"), ("Back", "Zurück"), ("Warning", "Warnung"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..cf860b35c 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Clear Frame Fitting Options", "Cancella opzioni di adattamento cornice"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 5647ca940..b39c78ca0 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Clear Frame Fitting Options", "フレーム調整オプションを消去"), ("Paste Vector Art", "ベクトルアートをペースト"), ("Baseline Options", "ベースラインオプション"), ("Column Rules", "段落境界線"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..6ba9ed276 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Clear Frame Fitting Options", "ჩარჩოს მორგების პარამეტრების გასუფთავება"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..99b6de911 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Clear Frame Fitting Options", "Wyczyść opcje dopasowania ramki"), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..e9f7acbfa 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Clear Frame Fitting Options", "Limpar opções de ajuste do quadro"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..0e43dc91f 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Clear Frame Fitting Options", "Очистить параметры подгонки фрейма"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..bf5a25a93 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Clear Frame Fitting Options", "Очистити параметри припасування фрейму"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 79b8b2c4e..4424f4f13 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -688,13 +688,17 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:anchored.options", "cmd:anchored.release", "<", + // InDesign's groups: proportional fits, frame and content fits, clearing, the dialog. ">Fitting", "ui:fit.fillFrameProportionally", "ui:fit.contentProportionally", + "-", "ui:fit.frameToContent", "ui:fit.contentToFrame", "ui:fit.centerContent", "-", + "cmd:object.clearFittingOptions", + "-", "ui:app.fittingOptions", "<", ">Content", @@ -3276,6 +3280,30 @@ mod tests { assert_eq!(ui_label("view.togglePreview").and_then(|c| c.1), Some("W")); } + /// Object › Fitting in InDesign's groups: the proportional fits, the frame and content fits, + /// Clear Frame Fitting Options, then the dialog. + #[test] + fn the_fitting_menu_is_grouped_as_indesigns() { + let items = MENUS.iter().find(|(t, _)| *t == "Object").map(|(_, items)| *items).unwrap(); + let start = items.iter().position(|e| *e == ">Fitting").unwrap() + 1; + let end = start + items[start..].iter().position(|e| *e == "<").unwrap(); + assert_eq!( + &items[start..end], + [ + "ui:fit.fillFrameProportionally", + "ui:fit.contentProportionally", + "-", + "ui:fit.frameToContent", + "ui:fit.contentToFrame", + "ui:fit.centerContent", + "-", + "cmd:object.clearFittingOptions", + "-", + "ui:app.fittingOptions", + ] + ); + } + /// The submenu paths of a menu bar menu ("View/Screen Mode"), as Edit › Menus keys them. fn submenu_paths(title: &str) -> Vec { let items = MENUS.iter().find(|(t, _)| *t == title).map(|(_, items)| *items).unwrap_or_default(); From 72541208031cd1898d7614a8176a5da13c1e3132 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 21 Oct 2026 03:18:17 +0200 Subject: [PATCH 037/207] Properties: the Align To button shows its target, as InDesign's One glyph each for the selection, the key object, the margins, the page and the spread, drawn in code, so the button tells what the objects align to. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/icons.rs | 41 +++++++++++++++++++++++++ crates/ui-egui/src/panels/properties.rs | 23 +++++++++++++- 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..35f5b8d7c 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** · Properties › Align: the Align To button shows what the objects align to, one glyph each for the selection, the key object, the margins, the page and the spread, as InDesign's. Found in the Learn Skills Daily course, chapter 12. 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/icons.rs b/crates/ui-egui/src/icons.rs index a90fe989e..2142b2b54 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -828,6 +828,33 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { _ => {} } } + // Properties › Align To, one glyph per target, with the drop-down mark: a page with its + // margins, a page, a spread of two pages, a key object drawn bold. + "align-to-margins" | "align-to-page" | "align-to-spread" | "align-to-key" => { + match name { + "align-to-margins" => { + pen.closed(&[(4.0, 2.5), (11.5, 2.5), (14.5, 5.5), (14.5, 16.0), (4.0, 16.0)]); + pen.w = 1.0; + pen.rect(6.5, 6.0, 12.0, 13.5); + } + "align-to-page" => { + pen.closed(&[(4.0, 2.5), (11.5, 2.5), (14.5, 5.5), (14.5, 16.0), (4.0, 16.0)]); + pen.line(&[(11.5, 2.5), (11.5, 5.5), (14.5, 5.5)]); + } + "align-to-spread" => { + pen.rect(1.5, 4.0, 8.5, 15.0); + pen.rect(8.5, 4.0, 15.5, 15.0); + } + _ => { + pen.rect(3.0, 4.0, 15.0, 14.0); + pen.frect(3.0, 4.0, 15.0, 6.0); + pen.frect(3.0, 12.0, 15.0, 14.0); + pen.frect(3.0, 4.0, 5.0, 14.0); + pen.frect(13.0, 4.0, 15.0, 14.0); + } + } + pen.fill(&[(16.0, 17.0), (19.5, 17.0), (17.75, 19.0)]); + } "align-to" | "frame-dashed" => { pen.w = 1.0; for (a, b) in [((3.0, 3.0), (15.0, 3.0)), ((15.0, 3.0), (15.0, 15.0)), ((15.0, 15.0), (3.0, 15.0)), ((3.0, 15.0), (3.0, 3.0))] { @@ -1152,6 +1179,20 @@ mod tests { assert!(!arrow_filled("tool-direct"), "Direct Selection draws a filled arrow"); } + /// Each Align To target has its own glyph, none the placeholder. + #[test] + fn align_to_glyphs_are_distinct() { + let names = ["align-to", "align-to-key", "align-to-margins", "align-to-page", "align-to-spread"]; + let unknown = drawing("no-such-icon"); + let glyphs: Vec = names.iter().map(|n| drawing(n)).collect(); + for (k, g) in glyphs.iter().enumerate() { + assert_ne!(*g, unknown, "{} falls back to the placeholder", names[k]); + for (j, other) in glyphs.iter().enumerate().skip(k + 1) { + assert_ne!(g, other, "{} and {} draw alike", names[k], names[j]); + } + } + } + #[test] fn distribute_icons_are_distinct_from_align_and_each_other() { // `objalign-*` ship upstream and back the Properties Align row; only the diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 44acf6e2c..81ba86643 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -947,6 +947,17 @@ fn swatch_menu( } } +/// The Align To button's glyph shows what the objects align to, as InDesign's. +fn align_to_icon(to: &str) -> &'static str { + match to { + "keyObject" => "align-to-key", + "margins" => "align-to-margins", + "page" => "align-to-page", + "spread" => "align-to-spread", + _ => "align-to", + } +} + fn align_row(app: &mut DesignApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); ui.horizontal(|ui| { @@ -961,7 +972,7 @@ fn align_row(app: &mut DesignApp, ui: &mut Ui) { other => other, }; let tip = format!("{}: {}", crate::i18n::tr(&app.ui.language, "Align To"), crate::i18n::tr(&app.ui.language, to_label)); - let resp = icon_toggle(ui, "align-to", false, &tip); + let resp = icon_toggle(ui, align_to_icon(&to), false, &tip); egui::Popup::menu(&resp).show(|ui| { for (label, id) in [ ("Align to Selection", "selection"), @@ -3419,6 +3430,16 @@ mod tests { assert_eq!(doc.item(designcraft_doc::ids::ItemId(id)).unwrap().fill.swatch, "[None]", "the frame isn't"); } + /// The Align To button shows the target picked in its menu. + #[test] + fn the_align_to_button_shows_its_target() { + assert_eq!(align_to_icon("selection"), "align-to"); + assert_eq!(align_to_icon("keyObject"), "align-to-key"); + assert_eq!(align_to_icon("margins"), "align-to-margins"); + assert_eq!(align_to_icon("page"), "align-to-page"); + assert_eq!(align_to_icon("spread"), "align-to-spread"); + } + #[test] fn transform_flips_and_rotates_from_properties() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 161b359c37ce2545724c86fa7985df186c167567 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 21 Oct 2026 08:33:09 +0200 Subject: [PATCH 038/207] Effects dialog: InDesign's angle dial beside each Angle field A circle whose radius points where the light comes from; a click or drag on it sets the angle (0 degrees to the right, counter-clockwise). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/effects_dialog.rs | 50 ++++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..8c7b008b0 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** · Effects dialog: the Angle field of the shadows, Bevel and Emboss, Satin and Gradient Feather has InDesign's dial beside it, its radius pointing where the light comes from; a click or drag on it sets the angle. Found in the Learn Skills Daily course, chapter 14. 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/effects_dialog.rs b/crates/ui-egui/src/effects_dialog.rs index 456ca4a58..d0c2228b8 100644 --- a/crates/ui-egui/src/effects_dialog.rs +++ b/crates/ui-egui/src/effects_dialog.rs @@ -204,10 +204,41 @@ fn percent(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str]) { fn angle(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str], enabled: bool, global: f64) { let mut v = if enabled { num(d, path) } else { global }; - let r = ui.add_enabled(enabled, egui::DragValue::new(&mut v).range(-180.0..=180.0).suffix("°").max_decimals(1)); - if r.changed() { - set(d, path, json!(v)); + ui.horizontal(|ui| { + if let Some(a) = angle_dial(ui, v, enabled) { + v = a; + set(d, path, json!(v)); + } + let r = ui.add_enabled(enabled, egui::DragValue::new(&mut v).range(-180.0..=180.0).suffix("°").max_decimals(1)); + if r.changed() { + set(d, path, json!(v)); + } + }); +} + +/// InDesign's angle dial: a circle with a radius pointing where the light comes from (0° to the +/// right, counter-clockwise). A click or a drag on it points it at the pointer. +fn angle_dial(ui: &mut egui::Ui, deg: f64, enabled: bool) -> Option { + let sense = if enabled { egui::Sense::click_and_drag() } else { egui::Sense::hover() }; + let (r, resp) = ui.allocate_exact_size(egui::vec2(26.0, 26.0), sense); + resp.widget_info(|| egui::WidgetInfo::slider(enabled, deg, "Angle")); + let col = if enabled { ui.visuals().text_color() } else { ui.visuals().weak_text_color() }; + let (c, rad) = (r.center(), 11.0); + ui.painter().circle_stroke(c, rad, egui::Stroke::new(1.2, col)); + let a = deg.to_radians() as f32; + ui.painter().line_segment([c, c + egui::vec2(a.cos(), -a.sin()) * rad], egui::Stroke::new(1.4, col)); + if enabled && (resp.dragged() || resp.clicked()) { + return resp.interact_pointer_pos().and_then(|p| dial_angle(c, p)); } + None +} + +/// The angle a dial at `c` points at for the pointer at `p`, in whole degrees (−180 to 180, +/// counter-clockwise from the right); none too close to the centre to tell. +fn dial_angle(c: egui::Pos2, p: egui::Pos2) -> Option { + let d = p - c; + // Straight left is 180° (not −180°, which a negative zero would give). + (d.length() > 2.0).then(|| f64::from((-d.y).atan2(d.x)).to_degrees().round()).map(|a| if a <= -180.0 { 180.0 } else { a }) } fn flag(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str], label: &str) { @@ -725,6 +756,19 @@ mod tests { assert!(!fx(&app).inner_shadow.on); } + /// The angle dial points where the pointer is, as InDesign's: right is 0°, up 90°, left 180°, + /// down −90°, upper left 135°. + #[test] + fn the_angle_dial_points_at_the_pointer() { + let c = egui::pos2(100.0, 100.0); + assert_eq!(dial_angle(c, egui::pos2(120.0, 100.0)), Some(0.0)); + assert_eq!(dial_angle(c, egui::pos2(100.0, 80.0)), Some(90.0)); + assert_eq!(dial_angle(c, egui::pos2(80.0, 100.0)), Some(180.0)); + assert_eq!(dial_angle(c, egui::pos2(100.0, 120.0)), Some(-90.0)); + assert_eq!(dial_angle(c, egui::pos2(90.0, 90.0)), Some(135.0)); + assert_eq!(dial_angle(c, egui::pos2(101.0, 100.0)), None, "too close to the centre"); + } + #[test] fn offsets_follow_distance_and_angle_as_in_indesign() { let (mut app, _) = app_with_frame(); From 85932f18ab9d5ec903f7842559f15dba6d615147 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:29:19 +0200 Subject: [PATCH 039/207] Paragraph rules: Type, Gap Color and Tint, Keep In Frame A paragraph rule has InDesign's Type: solid, the thick/thin stripes, dashed, dotted, straight hash, wavy, or a stroke style of the document. Composition draws it as that shape in the rule's box (dashes and dots as boxes and circles, stripes, waves and hashes as their outlines), with Gap Color and Gap Tint underneath; a pattern that can't be laid out draws solid. Keep In Frame keeps a rule above the first line of a frame inside the frame. The Paragraph Rules page (paragraph style options and the Paragraph Rules dialog) is laid out as InDesign's: Weight and Type, Color and Tint, Gap Color and Gap Tint, Width and Offset, Left and Right Indent, Keep In Frame. IDML reads and writes RuleAboveType/RuleBelowType, the gap colour and tint, and KeepRuleAboveInFrame. A rule's new settings at their defaults stay out of the JSON. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 59 +++++++++++++++--- crates/compose/src/tests.rs | 36 +++++++++++ crates/doc/src/attrs.rs | 42 ++++++++++++- crates/idml/src/export.rs | 6 ++ crates/idml/src/import.rs | 13 ++++ crates/idml/src/tests.rs | 4 ++ crates/ui-egui/src/dialogs.rs | 100 +++++++++++++++++++++++++++++-- crates/ui-egui/src/i18n.rs | 1 + crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 2 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 2 + crates/ui-egui/src/i18n/pl.rs | 2 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 2 + crates/ui-egui/src/i18n/uk.rs | 1 + 17 files changed, 259 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..33918dc8f 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 rules have InDesign's Type (solid, thick/thin stripes, dashed, dotted, straight hash, wavy, or a stroke style of the document), Gap Color and Gap Tint, and Keep In Frame (rule above); the Paragraph Rules page is laid out as InDesign's (Weight and Type, Color and Tint, Gap Color and Gap Tint, Width and Offset, Left and Right Indent). IDML reads and writes them (`RuleAboveType`, `RuleBelowGapColor`, `KeepRuleAboveInFrame`…). Found in the Learn Skills Daily course, chapter 20. 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/compose/src/lib.rs b/crates/compose/src/lib.rs index 4a2c66397..1920b3020 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1872,12 +1872,11 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C .map(|l| if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) }) .unwrap_or((0.0, 0.0)); let (color, tint) = rule_color(r, &above); - ft.decos.push(Deco { - rect: Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y), - color, - tint, - ..Deco::default() - }); + // Keep In Frame: not above the frame's text area. + let top = frames.get(fi).map_or(f64::NEG_INFINITY, |f| f.area.y0); + let y = if r.keep_in_frame && y - r.weight < top { top + r.weight } else { y }; + let rect = Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y); + ft.decos.extend(rule_decos(doc, r, rect, color, tint)); } } if pp.rule_below.on @@ -1888,7 +1887,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let y = l.baseline + r.offset; let (x0, x1) = if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) }; let (color, tint) = rule_color(r, &below); - ft.decos.push(Deco { rect: Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color, tint, ..Deco::default() }); + ft.decos.extend(rule_decos(doc, r, Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color, tint)); } // The end of a split block: text after it continues below its deepest sub-column, which // are balanced first. A block that ends the story fills its sub-columns in turn. @@ -4062,6 +4061,52 @@ fn hyphenation_points( out } +/// A paragraph rule drawn in `rect` (its length and weight) with its Type: the gap colour under +/// it, then the line, as dashes, dots, stripes, waves or hash marks. A pattern that can't be laid +/// out along the rule draws solid. +fn rule_decos(doc: &Document, r: &designcraft_doc::Rule, rect: Rect, color: String, tint: f32) -> Vec { + use designcraft_doc::StrokeType; + let kind = doc.stroke_kind(&r.kind); + let (w, len) = (rect.height(), rect.width()); + let mut out = Vec::new(); + if !matches!(kind, StrokeType::Solid) && r.gap_color != designcraft_color::swatch::NONE && w > 0.0 && len > 0.0 { + out.push(Deco { rect, color: r.gap_color.clone(), tint: r.gap_tint, ..Deco::default() }); + } + let y = rect.center().y; + let mut line = designcraft_geom::BezPath::new(); + line.move_to((rect.x0, y)); + line.line_to((rect.x1, y)); + let path = match &kind { + StrokeType::Dashed { pattern } if StrokeType::expandable_dashes(pattern, len) => { + let mut p = designcraft_geom::BezPath::new(); + let (mut x, mut k, mut on) = (rect.x0, 0, true); + while x < rect.x1 { + let step = pattern[k % pattern.len()]; + if on && step > 0.0 { + let r = Rect::new(x, rect.y0, (x + step).min(rect.x1), rect.y1); + p.extend(designcraft_geom::Shape::path_elements(&r, 0.1)); + } + x += step; + k += 1; + on = !on; + } + Some(p) + } + StrokeType::Dotted if StrokeType::expandable_dashes(&[w, w], len) => { + let mut p = designcraft_geom::BezPath::new(); + let mut x = rect.x0 + w / 2.0; + while x <= rect.x1 - w / 2.0 + 1e-9 { + p.extend(designcraft_geom::Shape::path_elements(&designcraft_geom::kurbo::Circle::new((x, y), w / 2.0), 0.1)); + x += 2.0 * w; + } + Some(p) + } + k => k.outline(&line, w, 0.05), + }; + out.push(Deco { rect, color, tint, path, ..Deco::default() }); + out +} + /// `spanned`: a paragraph spans the frame's columns, so the lines move together. fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool) { if ft.lines.is_empty() || f.opts.vertical_justification == VerticalJustification::Top { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index ed4c7c476..e394e4fe1 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -3694,6 +3694,42 @@ fn drop_cap_scales_with_its_point_size() { assert!(b2 - fitted * 1.5 < b0 - base[0].ascent, "a 15 pt cap rises above the first line"); } +/// Paragraph Rules › Type: a wavy, dashed or dotted rule is drawn as its shape (filled paths in +/// the rule's box), a striped one as its bands; Gap Color goes under it. Keep In Frame keeps a rule +/// above the first line inside the frame. +#[test] +fn paragraph_rules_have_a_type_a_gap_colour_and_keep_in_frame() { + use designcraft_doc::{Rule, StrokeType}; + let rule = |kind: StrokeType| Rule { on: true, weight: 4.0, kind, ..Default::default() }; + let decos = |r: Rule| { + let para = ParaAttrs { rule_below: Some(r), ..Default::default() }; + let (d, sid, fid) = doc_with("Recipe Categories", Rect::new(0.0, 0.0, 300.0, 200.0), para); + compose_story(&d, sid, &ComposeOptions::default()).frame(fid).unwrap().decos.clone() + }; + let solid = decos(rule(StrokeType::Solid)); + assert!(solid.len() == 1 && solid[0].path.is_none()); + let wavy = decos(rule(StrokeType::Wavy)); + let p = wavy[0].path.as_ref().expect("a wavy rule is a shape"); + assert!(designcraft_geom::Shape::area(p).abs() > 0.0 && (wavy[0].rect.height() - 4.0).abs() < 1e-9); + let subpaths = |p: &designcraft_geom::BezPath| p.elements().iter().filter(|e| matches!(e, designcraft_geom::PathEl::MoveTo(_))).count(); + let dashed = decos(rule(StrokeType::Dashed { pattern: vec![12.0, 4.0] })); + assert!(subpaths(dashed[0].path.as_ref().unwrap()) > 5, "one box per dash"); + let dotted = decos(rule(StrokeType::Dotted)); + assert!(subpaths(dotted[0].path.as_ref().unwrap()) > 10, "one dot per 2 weights"); + let gap = decos(Rule { gap_color: "[Black]".into(), gap_tint: 0.3, ..rule(StrokeType::Dotted) }); + assert_eq!((gap.len(), gap[0].color.as_str(), gap[0].tint, gap[0].path.is_none()), (2, "[Black]", 0.3, true), "the gap colour under the dots"); + assert_eq!(decos(Rule { gap_color: "[Black]".into(), ..rule(StrokeType::Solid) }).len(), 1, "a solid rule has no gaps"); + // Keep In Frame: a rule above raised out of the frame comes back to its top. + let above = |keep: bool| { + let r = Rule { on: true, weight: 2.0, offset: 30.0, keep_in_frame: keep, ..Default::default() }; + let para = ParaAttrs { rule_above: Some(r), ..Default::default() }; + let (d, sid, fid) = doc_with("Title", Rect::new(0.0, 50.0, 300.0, 200.0), para); + compose_story(&d, sid, &ComposeOptions::default()).frame(fid).unwrap().decos[0].rect + }; + assert!(above(false).y0 < 50.0, "{:?}", above(false)); + assert!((above(true).y0 - 50.0).abs() < 1e-6, "{:?}", above(true)); +} + /// A rule in Text Color takes the colour of the paragraph's text: the first character's for the /// rule above, the last character's for the rule below. A swatch colour is used as is. #[test] diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index 077637672..3fb2a2a00 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -776,11 +776,46 @@ pub struct Rule { pub offset: f64, pub left_indent: f64, pub right_indent: f64, + /// Type: the rule's stroke type (solid, dashed, wavy…, or a stroke style of the document). + #[serde(rename = "type", skip_serializing_if = "rule_solid")] + pub kind: crate::item::StrokeType, + /// Gap Color (a swatch; [None]: none) and Gap Tint: under the gaps of a type that has them. + #[serde(skip_serializing_if = "rule_no_gap")] + pub gap_color: String, + #[serde(skip_serializing_if = "rule_full_tint")] + pub gap_tint: f32, + /// Keep In Frame: a rule above the first line of a frame stays inside the frame. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub keep_in_frame: bool, +} + +// A rule's settings at their defaults are left out of the document (and of the attributes). +fn rule_solid(k: &crate::item::StrokeType) -> bool { + *k == crate::item::StrokeType::Solid +} +fn rule_no_gap(c: &str) -> bool { + c == designcraft_color::swatch::NONE +} +fn rule_full_tint(t: &f32) -> bool { + *t == 1.0 } impl Default for Rule { fn default() -> Self { - Rule { on: false, weight: 1.0, color: TEXT_COLOR.into(), tint: 1.0, column_width: true, offset: 0.0, left_indent: 0.0, right_indent: 0.0 } + Rule { + on: false, + weight: 1.0, + color: TEXT_COLOR.into(), + tint: 1.0, + column_width: true, + offset: 0.0, + left_indent: 0.0, + right_indent: 0.0, + kind: crate::item::StrokeType::Solid, + gap_color: designcraft_color::swatch::NONE.into(), + gap_tint: 1.0, + keep_in_frame: false, + } } } @@ -1385,8 +1420,11 @@ impl ParaAttrs { let serde_json::Value::Object(patch) = value else { return self.set_json(key, value) }; let mut merged = serde_json::to_value(rule).map_err(|e| format!("{key}: {e}"))?; let serde_json::Value::Object(fields) = &mut merged else { return Err(format!("{key}: not an object")) }; + // Every field, those at their defaults (left out of the JSON) included. + const RULE_FIELDS: [&str; 12] = + ["on", "weight", "color", "tint", "columnWidth", "offset", "leftIndent", "rightIndent", "type", "gapColor", "gapTint", "keepInFrame"]; for (k, v) in patch { - if !fields.contains_key(k) { + if !RULE_FIELDS.contains(&k.as_str()) { return Err(format!("{key}: unknown rule field `{k}`")); } fields.insert(k.clone(), v.clone()); diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 86fef8e46..24822cecf 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1746,6 +1746,12 @@ impl<'a> Ex<'a> { el.set(&format!("{k}Offset"), num(r.offset)); el.set(&format!("{k}LeftIndent"), num(r.left_indent)); el.set(&format!("{k}RightIndent"), num(r.right_indent)); + el.set(&format!("{k}Type"), names::stroke_type_out(&r.kind)); + el.set(&format!("{k}GapColor"), self.sw(&r.gap_color)); + el.set(&format!("{k}GapTint"), pct(r.gap_tint as f64)); + if k == "RuleAbove" { + el.set("KeepRuleAboveInFrame", bool_s(r.keep_in_frame)); + } props.push(if r.color == designcraft_doc::TEXT_COLOR { p(&format!("{k}Color"), "string", designcraft_doc::TEXT_COLOR) } else { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index d770aef79..58cd70271 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1959,6 +1959,19 @@ impl<'r> Importer<'r> { r.offset = num(5).unwrap_or(0.0); r.left_indent = num(6).unwrap_or(0.0); r.right_indent = num(7).unwrap_or(0.0); + // Type, gap colour and tint, Keep In Frame (rule above). + if let Some(t) = self.para_setting(e, &format!("{k}Type")) { + r.kind = names::stroke_type_in(t.trim()); + } + if let Some(c) = self.para_setting(e, &format!("{k}GapColor")).filter(|c| c.contains('/')) { + r.gap_color = self.swatch_ref(c.trim()); + } + if let Some(t) = tint(self.para_setting(e, &format!("{k}GapTint")).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite())) { + r.gap_tint = t; + } + if k == "RuleAbove" { + r.keep_in_frame = self.para_setting(e, "KeepRuleAboveInFrame").is_some_and(|v| v.trim() == "true"); + } if let Some(c) = get(2) { let c = c.trim(); if c == designcraft_doc::TEXT_COLOR { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 035333da4..e03eeffb7 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -327,6 +327,10 @@ fn indesign_text_defaults() -> Vec { "RuleBelow": "false" => false, "true" => true, |r| r.0.rule_below.on; "RuleBelowColor": "Text Color" => designcraft_doc::TEXT_COLOR, "Color/Black" => "[Black]", |r| r.0.rule_below.color; "RuleBelowTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.0.rule_below.tint; + "RuleBelowType": "StrokeStyle/$ID/Solid" => designcraft_doc::StrokeType::Solid, "StrokeStyle/$ID/Wavy" => designcraft_doc::StrokeType::Wavy, |r| r.0.rule_below.kind; + "RuleBelowGapColor": "Swatch/None" => "[None]", "Color/Black" => "[Black]", |r| r.0.rule_below.gap_color; + "RuleBelowGapTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.0.rule_below.gap_tint; + "KeepRuleAboveInFrame": "false" => false, "true" => true, |r| r.0.rule_above.keep_in_frame; "ParagraphShadingOn": "false" => false, "true" => true, |r| r.0.shading_on; "ParagraphShadingColor": "Color/Black" => "[Black]", "Swatch/None" => "[None]", |r| r.0.shading_color; "ParagraphShadingTint": "20" => 0.2f32, "50" => 0.5f32, |r| r.0.shading_tint; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..de57d77e9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -5882,22 +5882,64 @@ fn paragraph_rules(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, prefix: & }); ui.add_space(6.0); let on = rule["on"].as_bool().unwrap_or(false); + let kind: designcraft_doc::StrokeType = serde_json::from_value(rule["type"].clone()).unwrap_or_default(); + let doc = app.session.active().map(|st| &st.doc); + // As InDesign's: Weight and Type, Color and Tint, Gap Color and Gap Tint, Width and Offset, + // Left and Right Indent, then Keep In Frame (rule above). ui.add_enabled_ui(on, |ui| { egui::Grid::new(("rules", which)).num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, crate::i18n::tr(lang, "Weight:")); - if let Some(v) = sized(ui, &mut |ui| crate::widgets::number(ui, &key("weight"), rule["weight"].as_f64(), " pt", 80.0, 2)) { + if let Some(v) = sized(ui, &mut |ui| { + crate::widgets::NumField::number(&key("weight"), rule["weight"].as_f64(), " pt", 2).width(80.0).spinner().range(0.0, 1000.0).show(ui) + }) { d.fields.insert(key("weight"), json!(v.clamp(0.0, 1000.0))); } + crate::rtl::label(ui, crate::i18n::tr(lang, "Type:")); + let custom: Vec = doc.map_or_else(Vec::new, |doc| doc.stroke_styles.iter().map(|s| s.name.clone()).collect()); + let shown = match &kind { + designcraft_doc::StrokeType::Style { name } => name.clone(), + k => crate::i18n::tr(lang, k.label()).to_string(), + }; + egui::ComboBox::from_id_salt(("rule_type", which)).selected_text(crate::rtl::widget(ui, shown)).width(130.0).show_ui(ui, |ui| { + for k in rule_types().into_iter().chain(custom.into_iter().map(|name| designcraft_doc::StrokeType::Style { name })) { + let label = match &k { + designcraft_doc::StrokeType::Style { name } => name.clone(), + k => crate::i18n::tr(lang, k.label()).to_string(), + }; + if ui.selectable_label(k == kind, crate::rtl::widget(ui, label)).clicked() { + d.fields.insert(key("type"), serde_json::to_value(&k).unwrap_or_default()); + } + } + }); + ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Color:")); let color = rule["color"].as_str().unwrap_or("").to_string(); - if let Some(w) = app.session.active().and_then(|st| swatch_menu(ui, &st.doc, ("rule_color", which), &color, 130.0)) { + if let Some(w) = doc.and_then(|doc| swatch_menu(ui, doc, ("rule_color", which), &color, 130.0)) { d.fields.insert(key("color"), json!(w)); } - ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Tint:")); if let Some(v) = sized(ui, &mut |ui| crate::widgets::number(ui, &key("tint"), rule["tint"].as_f64().map(|t| t * 100.0), "%", 80.0, 0)) { d.fields.insert(key("tint"), json!((v / 100.0).clamp(0.0, 1.0))); } + ui.end_row(); + // A solid rule has no gaps. + let gaps = !matches!(kind, designcraft_doc::StrokeType::Solid); + crate::rtl::label(ui, crate::i18n::tr(lang, "Gap Color:")); + let gap = rule["gapColor"].as_str().unwrap_or(designcraft_color::swatch::NONE).to_string(); + ui.add_enabled_ui(gaps, |ui| { + if let Some(w) = doc.and_then(|doc| swatch_menu(ui, doc, ("rule_gap", which), &gap, 130.0)) { + d.fields.insert(key("gapColor"), json!(w)); + } + }); + crate::rtl::label(ui, crate::i18n::tr(lang, "Gap Tint:")); + ui.add_enabled_ui(gaps && gap != designcraft_color::swatch::NONE, |ui| { + if let Some(v) = + sized(ui, &mut |ui| crate::widgets::number(ui, &key("gapTint"), rule["gapTint"].as_f64().map(|t| t * 100.0), "%", 80.0, 0)) + { + d.fields.insert(key("gapTint"), json!((v / 100.0).clamp(0.0, 1.0))); + } + }); + ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Width:")); let column = rule["columnWidth"].as_bool().unwrap_or(true); egui::ComboBox::from_id_salt(("rule_width", which)) @@ -5910,23 +5952,50 @@ fn paragraph_rules(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, prefix: & } } }); - ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Offset:")); - if let Some(v) = sized(ui, &mut |ui| crate::widgets::measure(ui, &key("offset"), rule["offset"].as_f64(), v_units, 80.0)) { + if let Some(v) = sized(ui, &mut |ui| { + crate::widgets::NumField::measure(&key("offset"), rule["offset"].as_f64(), v_units).width(80.0).spinner().show(ui) + }) { d.fields.insert(key("offset"), json!(v)); } ui.end_row(); for (label, field) in [("Left Indent:", "leftIndent"), ("Right Indent:", "rightIndent")] { crate::rtl::label(ui, crate::i18n::tr(lang, label)); - if let Some(v) = sized(ui, &mut |ui| crate::widgets::measure(ui, &key(field), rule[field].as_f64(), h_units, 80.0)) { + if let Some(v) = + sized(ui, &mut |ui| crate::widgets::NumField::measure(&key(field), rule[field].as_f64(), h_units).width(80.0).spinner().show(ui)) + { d.fields.insert(key(field), json!(v)); } } ui.end_row(); }); + if which == "ruleAbove" { + let mut keep = rule["keepInFrame"].as_bool().unwrap_or(false); + if ui.checkbox(&mut keep, crate::rtl::widget(ui, crate::i18n::tr(lang, "Keep In Frame"))).changed() { + d.fields.insert(key("keepInFrame"), json!(keep)); + } + } }); } +/// The built-in stroke types a paragraph rule can have (Paragraph Rules › Type). +fn rule_types() -> Vec { + use designcraft_doc::StrokeType as T; + vec![ + T::Solid, + T::ThickThick, + T::ThickThin, + T::ThickThinThick, + T::ThinThick, + T::ThinThickThin, + T::ThinThin, + T::Dashed { pattern: vec![12.0, 4.0] }, + T::Dotted, + T::Hashed, + T::Wavy, + ] +} + /// Document Footnote Options: Numbering and Formatting / Layout tabs. fn footnote_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.horizontal(|ui| { @@ -7493,6 +7562,25 @@ mod tests { assert_eq!(app.run("type.selectionAttrs", json!({})).unwrap()["para"]["ruleBelow"], rule); } + /// The course's chapter 20: a paragraph style's rule below becomes wavy, 3 pt, with a gap + /// colour, from Paragraph Rules › Type and Gap Color. + #[test] + fn paragraph_style_rules_take_a_type_and_a_gap_colour() { + let (mut app, _) = ruled_paragraph(); + let ctx = egui::Context::default(); + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "rules", "rule": "ruleBelow"}))); + draw(&mut app, &ctx); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("p.ruleBelow.type".into(), json!({"kind": "wavy"})); + d.fields.insert("p.ruleBelow.weight".into(), json!(3.0)); + d.fields.insert("p.ruleBelow.gapColor".into(), json!("[Paper]")); + } + draw(&mut app, &ctx); + confirm(&mut app).unwrap(); + let r = app.session.doc().unwrap().doc.styles.resolve_para_style("Ruled Child").0.rule_below; + assert_eq!((r.kind, r.weight, r.gap_color.as_str()), (designcraft_doc::StrokeType::Wavy, 3.0, "[Paper]")); + } + #[test] fn paragraph_style_options_edit_one_rule_field() { let (mut app, rule) = ruled_paragraph(); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..a574af928 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -36,6 +36,7 @@ pub const LANGUAGES: &[(&str, &str)] = &[ /// English → [German, French, Spanish, Japanese, Simplified Chinese]. const TABLE: &[(&str, [&str; 5])] = &[ + ("Keep In Frame", ["Im Rahmen behalten", "Conserver dans le bloc", "Mantener en el marco", "フレーム内に保持", "保持在框架内"]), // Menus. ("File", ["Datei", "Fichier", "Archivo", "ファイル", "文件"]), ("Edit", ["Bearbeiten", "Édition", "Edición", "編集", "编辑"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..9e7f9594d 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Keep In Frame", "الإبقاء داخل الإطار"), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..d1987e741 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Keep In Frame", "Zachovat v rámečku"), + ("Gap Tint:", "Odstín mezery:"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..e1858b6d8 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Keep In Frame", "Mantieni nella cornice"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..5c0afb06c 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Keep In Frame", "ჩარჩოში შენარჩუნება"), + ("Gap Tint:", "ღრეჩოს ტონი:"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..e563c58a8 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Keep In Frame", "Zachowaj w ramce"), + ("Gap Tint:", "Odcień odstępu:"), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..608656aed 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Keep In Frame", "Manter no quadro"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..8876dd677 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Keep In Frame", "Сохранить во фрейме"), + ("Gap Tint:", "Оттенок пробелов:"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..fc34659e9 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Keep In Frame", "Зберегти у фреймі"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), From 151f54ee46648f48829230d3341571435b46738f Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:48:16 +0200 Subject: [PATCH 040/207] Paragraph styles: a new style from the selected text, Next Style, Reset To Base New Paragraph Style with text selected starts from it, as InDesign's: Based On [No Paragraph Style] and every setting of the text that differs from it, with Apply Style to Selection (on), so OK gives the paragraph the new style. General has Next Style ([Same style] or a style) and Reset To Base (the style keeps nothing of its own; edits made in the dialog still apply). Basic Character Formats is laid out in two columns as InDesign's: the font, then Size | Leading, Kerning | Tracking, Case | Position. style.paragraph.edit honours nextStyle (string, or null for [Same style]); it was described but ignored. It takes resetToBase. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/style.rs | 20 ++++- crates/ui-egui/src/dialogs.rs | 140 +++++++++++++++++++++++++++++-- crates/ui-egui/src/i18n.rs | 13 +++ crates/ui-egui/src/i18n/ar.rs | 4 + crates/ui-egui/src/i18n/cs.rs | 4 + crates/ui-egui/src/i18n/it.rs | 4 + crates/ui-egui/src/i18n/ka.rs | 4 + crates/ui-egui/src/i18n/pl.rs | 4 + crates/ui-egui/src/i18n/pt_br.rs | 4 + crates/ui-egui/src/i18n/ru.rs | 4 + crates/ui-egui/src/i18n/uk.rs | 4 + 12 files changed, 196 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..41b3ab40d 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** · New Paragraph Style with text selected starts from it, as InDesign's (Based On [No Paragraph Style], every setting of the text that differs) and Apply Style to Selection gives the paragraph the new style; General has Next Style and Reset To Base; Basic Character Formats is in two columns (Size | Leading, Kerning | Tracking, Case | Position). `style.paragraph.edit` honours `nextStyle` (it was ignored) and takes `resetToBase`. Found in the Learn Skills Daily course, chapter 20. 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/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index d1bd06ad0..4526db5b0 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -311,7 +311,7 @@ pub fn specs() -> Vec { "Paragraph Style Options…", [], None, - "{name, rename?, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, …; ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules); a grepStyles pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}", + "{name, rename?, basedOn?, nextStyle?: style | null ([Same style]), resetToBase?: bool (Reset To Base: the style sets nothing of its own; `para`/`chars` then apply over its Based On), para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, …; ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules); a grepStyles pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}", has_doc, edit_para ), @@ -936,12 +936,25 @@ fn edit_para(s: &mut Session, p: &Value) -> Result { super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.edit")?; let rename = str_param(p, "rename").map(str::to_string); let based = p.get("basedOn").cloned(); + let next = p.get("nextStyle").cloned(); + let reset = p.get("resetToBase").and_then(Value::as_bool).unwrap_or(false); s.edit(|d, _| { if let Some(Value::String(b)) = &based && d.styles.para_based_on_cycles(&name, b) { return Err(bad("style.paragraph.edit", "based-on would create a cycle")); } + if let Some(Value::String(n)) = &next + && d.styles.para(n).is_none() + { + return Err(bad("style.paragraph.edit", format!("no style `{n}`"))); + } + // Reset To Base: what the style set of its own goes before the edits apply. + if reset { + let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?; + st.para = ParaAttrs::default(); + st.chars = CharAttrs::default(); + } // A rule object changes only the rule fields it names, over the style's resolved rule. let (current, _) = d.styles.resolve_para_style(&name); let mut para: ParaAttrs = attrs("style.paragraph.edit", "para", p, |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, ¤t))?; @@ -956,6 +969,11 @@ fn edit_para(s: &mut Session, p: &Value) -> Result { Some(Value::Null) => st.based_on = None, _ => {} } + match next { + Some(Value::String(n)) => st.next_style = Some(n), + Some(Value::Null) => st.next_style = None, + _ => {} + } if let Some(n) = rename.clone() { // Every use: other styles, stories, table cells, footnotes, object styles. rename_style(d, true, &name, &n); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..dc6a5d7e7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3680,16 +3680,30 @@ fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Resul } let based = d.s("basedOn"); let rename = d.s("rename"); + let next = d.fields.get("nextStyle").cloned(); let (r, style) = if d.b("new") { let mut params = json!({"name": if rename.trim().is_empty() { "Paragraph Style 1" } else { rename.trim() }, "para": para, "chars": chars}); if !based.is_empty() && based != designcraft_doc::NO_PARA_STYLE { params["basedOn"] = json!(based); } + if let Some(Value::String(n)) = &next { + params["nextStyle"] = json!(n); + } let r = app.run("style.paragraph.create", params)?; let style = r["name"].as_str().unwrap_or_default().to_string(); + // Apply Style to Selection. + if d.b("apply") && app.session.active().is_some_and(|st| st.selection.text.is_some()) { + app.run("style.paragraph.apply", json!({"name": style}))?; + } (r, style) } else { let mut params = json!({"name": name, "para": para, "chars": chars}); + if let Some(n) = next { + params["nextStyle"] = n; + } + if d.b("resetToBase") { + params["resetToBase"] = json!(true); + } if !based.is_empty() { params["basedOn"] = if based == designcraft_doc::NO_PARA_STYLE { Value::Null } else { json!(based) }; } @@ -3804,9 +3818,16 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo if new && !d.fields.contains_key("rename") { d.fields.insert("rename".into(), json!(next_style_name(&doc.styles, true))); } + let mut style = style; + let reset = !new && d.b("resetToBase"); + if reset { + style.para = Default::default(); + style.chars = Default::default(); + } let names: Vec = doc.styles.paragraph.iter().map(|p| p.name.clone()).filter(|n| new || *n != name).collect(); - // A new style shows what it would inherit from its Based On. - let (pp, cp) = doc.styles.resolve_para_style(if new { d.fields.get("basedOn").and_then(Value::as_str).unwrap_or("") } else { &name }); + // A new style shows what it would inherit from its Based On (as does one reset to it). + let based_name = d.fields.get("basedOn").and_then(Value::as_str).map(str::to_string).or(style.based_on.clone()).unwrap_or_default(); + let (pp, cp) = doc.styles.resolve_para_style(if new || reset { &based_name } else { &name }); let units = doc.settings.horizontal_units; let pv = serde_json::to_value(&pp).unwrap_or_default(); let cv = serde_json::to_value(&cp).unwrap_or_default(); @@ -4239,13 +4260,51 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo } }); ui.end_row(); + // Next Style: the style of the paragraph that follows when Enter ends this one. + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Next Style:")); + let next = match d.fields.get("nextStyle") { + Some(Value::String(n)) => Some(n.clone()), + Some(_) => None, + None => style.next_style.clone().filter(|n| *n != name), + }; + let same = crate::i18n::tr(&app.ui.language, "[Same style]").to_string(); + let shown = next.as_deref().map_or(same.clone(), |n| crate::i18n::style_name(&app.ui.language, n).to_string()); + egui::ComboBox::from_id_salt("psnext").selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| { + if ui.selectable_label(next.is_none(), crate::rtl::widget(ui, &same)).clicked() { + d.fields.insert("nextStyle".into(), Value::Null); + } + for n in names.iter().filter(|n| *n != designcraft_doc::NO_PARA_STYLE) { + if ui + .selectable_label(next.as_deref() == Some(n), crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n))) + .clicked() + { + d.fields.insert("nextStyle".into(), json!(n)); + } + } + }); + ui.end_row(); }); + // Reset To Base: the style keeps nothing of its own (the edits made here stay). + if !new + && ui + .add_enabled( + !based_name.is_empty() && based_name != designcraft_doc::NO_PARA_STYLE, + egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Reset To Base"))), + ) + .clicked() + { + d.fields.insert("resetToBase".into(), json!(true)); + } ui.add_space(8.0); crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Style Settings:")).font(semibold(12.0))); let summary = paragraph_style_summary(&app.ui.language, d, &style, &doc.styles); - egui::ScrollArea::vertical().id_salt("paragraph_style_settings").max_height(220.0).show(ui, |ui| { + egui::ScrollArea::vertical().id_salt("paragraph_style_settings").max_height(180.0).show(ui, |ui| { ui.add(egui::Label::new(egui::RichText::new(summary).color(crate::theme::Tokens::get(ui.ctx()).text_dim)).wrap()); }); + if new { + ui.add_space(8.0); + check(ui, d, "apply", crate::i18n::tr(&app.ui.language, "Apply Style to Selection")); + } } }); }); @@ -5155,8 +5214,28 @@ pub fn open_style_options(app: &mut DesignApp, para: bool, name: &str) { pub fn open_new_style(app: &mut DesignApp, para: bool) { let Some(st) = app.session.active() else { return }; let name = next_style_name(&st.doc.styles, para); + let has_text = st.selection.text.is_some(); + let (bp, bc) = st.doc.styles.resolve_para_style(designcraft_doc::NO_PARA_STYLE); let id = if para { "paragraphStyleOptions" } else { "characterStyleOptions" }; - app.ui.dialog = Some(Dialog::new(id, json!({"new": true, "rename": name}))); + let mut fields = json!({"new": true, "rename": name}); + // New Paragraph Style with text selected starts from it, as InDesign's: Based On [No Paragraph + // Style] and every setting of the text that differs, applied to the selection on OK. + if para + && has_text + && let Some(sel) = app.session.execute("type.selectionAttrs", &json!({})).ok().filter(|v| !v.is_null()) + { + for (prefix, now, base) in + [("p.", &sel["para"], serde_json::to_value(bp).unwrap_or_default()), ("c.", &sel["chars"], serde_json::to_value(bc).unwrap_or_default())] + { + for (k, v) in now.as_object().into_iter().flatten() { + if !v.is_null() && base.get(k) != Some(v) && base.get(k).is_some() { + fields[format!("{prefix}{k}")] = v.clone(); + } + } + } + fields["apply"] = json!(true); + } + app.ui.dialog = Some(Dialog::new(id, fields)); } /// Character Style Options: General, then the character sections it shares with Paragraph Style @@ -5321,7 +5400,7 @@ fn char_number(ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, su fn char_choice(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, opts: &[(Value, &str)]) { let cur = cf.get(d, key); let shown = opts.iter().find(|o| o.0 == cur).map_or("", |o| crate::i18n::tr(lang, o.1)); - egui::ComboBox::from_id_salt(("cs", key)).selected_text(crate::rtl::widget(ui, shown)).width(200.0).show_ui(ui, |ui| { + egui::ComboBox::from_id_salt(("cs", key)).selected_text(crate::rtl::widget(ui, shown)).width(140.0).show_ui(ui, |ui| { if cf.sparse && ui.selectable_label(cur.is_null(), " ").clicked() { cf.set(d, key, Value::Null); } @@ -5364,6 +5443,7 @@ fn basic_character_formats(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo let lang = lang.as_str(); let fonts = crate::panels::fonts(app); let menu = crate::panels::font_menu(app); + // Two columns, as InDesign's: the font, then Size | Leading, Kerning | Tracking, Case | Position. egui::Grid::new("psc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, crate::i18n::tr(lang, "Font Family:")); let fam = cf.get(d, "fontFamily").as_str().unwrap_or("").to_string(); @@ -5406,9 +5486,10 @@ fn basic_character_formats(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo }); } ui.end_row(); + }); + egui::Grid::new("psc2").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, crate::i18n::tr(lang, "Size:")); char_number(ui, d, cf, "size", " pt", 1.0, (0.1, 1296.0)); - ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Leading:")); let lv = match cf.get(d, "leading") { v if v["kind"] == "points" => v["value"].as_f64(), @@ -5429,7 +5510,6 @@ fn basic_character_formats(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo "kerning", &[(json!({"kind": "metrics"}), "Metrics"), (json!({"kind": "optical"}), "Optical"), (json!({"kind": "none"}), "0")], ); - ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Tracking:")); char_number(ui, d, cf, "tracking", "", 1.0, (-1000.0, 10000.0)); ui.end_row(); @@ -5447,7 +5527,6 @@ fn basic_character_formats(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo (json!("openTypeAllSmallCaps"), "OpenType All Small Caps"), ], ); - ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Position:")); char_choice( lang, @@ -7493,6 +7572,48 @@ mod tests { assert_eq!(app.run("type.selectionAttrs", json!({})).unwrap()["para"]["ruleBelow"], rule); } + /// The course's chapter 20: the "Recipe Categories" title formatted on the page (size, All + /// Caps, tracking, space after), then New Paragraph Style from it: the dialog starts from the + /// text, and with Apply Style to Selection the paragraph takes the new style. Next Style and + /// Reset To Base are set from General. + #[test] + fn a_new_paragraph_style_starts_from_the_selected_text() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Egg Recipes\rBoil them."})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 11})).unwrap(); + app.run("type.char", json!({"attrs": {"size": 20, "capitalization": "allCaps", "tracking": 50}})).unwrap(); + app.run("type.para", json!({"attrs": {"spaceAfter": 6}})).unwrap(); + open_new_style(&mut app, true); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!( + (d.fields["c.size"].clone(), d.fields["c.capitalization"].clone(), d.fields["p.spaceAfter"].clone()), + (json!(20.0), json!("allCaps"), json!(6.0)) + ); + assert!(d.b("apply") && !d.fields.contains_key("c.fontFamily"), "only what differs from [No Paragraph Style]"); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("rename".into(), json!("Recipe Categories")); + d.fields.insert("nextStyle".into(), json!(designcraft_doc::BASIC_PARAGRAPH)); + } + confirm(&mut app).unwrap(); + let a = app.run("type.selectionAttrs", json!({})).unwrap(); + assert_eq!(a["paragraphStyle"], "Recipe Categories", "applied to the selection"); + assert_eq!(a["paraOverrides"].as_u64().unwrap_or(0) + a["charOverrides"].as_u64().unwrap_or(0), 0); + let style = |app: &DesignApp| app.session.doc().unwrap().doc.styles.para("Recipe Categories").cloned().unwrap(); + assert_eq!(style(&app).next_style.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); + assert_eq!(style(&app).chars.size, Some(20.0)); + // Reset To Base: the style keeps nothing of its own, apart from the edits made then. + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Recipe Categories", "basedOn": designcraft_doc::BASIC_PARAGRAPH}))); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("resetToBase".into(), json!(true)); + d.fields.insert("c.tracking".into(), json!(10.0)); + } + confirm(&mut app).unwrap(); + let s = style(&app); + assert_eq!((s.chars.size, s.chars.tracking, s.para.space_after), (None, Some(10.0), None), "{s:?}"); + assert_eq!(s.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); + } + #[test] fn paragraph_style_options_edit_one_rule_field() { let (mut app, rule) = ruled_paragraph(); @@ -9660,7 +9781,8 @@ mod tests { assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); let st = app.session.active().unwrap().doc.styles.para("Paragraph Style 1").cloned().unwrap(); assert_eq!(st.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); - assert_eq!(st.chars, CharAttrs { size: Some(20.0), ..Default::default() }); + // The selected text's own Italic comes along, as InDesign's New Paragraph Style takes it. + assert_eq!(st.chars, CharAttrs { size: Some(20.0), font_style: Some("Italic".into()), ..Default::default() }); } #[test] diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..caaede51c 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -36,6 +36,19 @@ pub const LANGUAGES: &[(&str, &str)] = &[ /// English → [German, French, Spanish, Japanese, Simplified Chinese]. const TABLE: &[(&str, [&str; 5])] = &[ + ("Next Style:", ["Nächstes Format:", "Style suivant :", "Estilo siguiente:", "次のスタイル :", "下一样式:"]), + ("[Same style]", ["[Gleiches Format]", "[Même style]", "[Mismo estilo]", "[同じスタイル]", "[相同样式]"]), + ("Reset To Base", ["Auf Basis zurücksetzen", "Rétablir la base", "Restablecer a base", "基準に戻す", "重置为基准"]), + ( + "Apply Style to Selection", + [ + "Format auf Auswahl anwenden", + "Appliquer le style à la sélection", + "Aplicar estilo a la selección", + "選択範囲にスタイルを適用", + "将样式应用于选区", + ], + ), // Menus. ("File", ["Datei", "Fichier", "Archivo", "ファイル", "文件"]), ("Edit", ["Bearbeiten", "Édition", "Edición", "編集", "编辑"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..bc300eac8 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Next Style:", "النمط التالي:"), + ("[Same style]", "[النمط نفسه]"), + ("Reset To Base", "إعادة التعيين إلى الأساس"), + ("Apply Style to Selection", "تطبيق النمط على التحديد"), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..aa4215745 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Next Style:", "Další styl:"), + ("[Same style]", "[Stejný styl]"), + ("Reset To Base", "Obnovit podle základu"), + ("Apply Style to Selection", "Použít styl na výběr"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..ab153084e 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Next Style:", "Stile successivo:"), + ("[Same style]", "[Stesso stile]"), + ("Reset To Base", "Ripristina base"), + ("Apply Style to Selection", "Applica stile alla selezione"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..128a872f6 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Next Style:", "შემდეგი სტილი:"), + ("[Same style]", "[იგივე სტილი]"), + ("Reset To Base", "საბაზოზე დაბრუნება"), + ("Apply Style to Selection", "სტილის გამოყენება მონიშნულზე"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..100168f3a 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Next Style:", "Następny styl:"), + ("[Same style]", "[Ten sam styl]"), + ("Reset To Base", "Przywróć bazowy"), + ("Apply Style to Selection", "Zastosuj styl do zaznaczenia"), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..3f2b2afe5 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Next Style:", "Próximo estilo:"), + ("[Same style]", "[Mesmo estilo]"), + ("Reset To Base", "Redefinir para base"), + ("Apply Style to Selection", "Aplicar estilo à seleção"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..e567ec5a3 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Next Style:", "Следующий стиль:"), + ("[Same style]", "[Тот же стиль]"), + ("Reset To Base", "Сбросить до основы"), + ("Apply Style to Selection", "Применить стиль к выделенному"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..33ebb766d 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Next Style:", "Наступний стиль:"), + ("[Same style]", "[Той самий стиль]"), + ("Reset To Base", "Скинути до основи"), + ("Apply Style to Selection", "Застосувати стиль до виділеного"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), From ee48326797fd3c99cdbe4a53a8a7622acb79efc8 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:58:36 +0200 Subject: [PATCH 041/207] i18n: Corner Size and Shape: in Spanish (and the German and Japanese fallbacks) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index f4fbc2458..5eeb4b357 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -91,7 +91,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), ( "Corner Size and Shape:", - ["Corner Size and Shape:", "Taille et forme des angles:", "Corner Size and Shape:", "Corner Size and Shape:", "角大小和形状:"], + ["Eckengröße und -form:", "Taille et forme des angles:", "Tamaño y forma de esquinas:", "角のサイズとシェイプ:", "角大小和形状:"], ), ("Hide Rulers", ["Lineale ausblenden", "Masquer les règles", "Ocultar reglas", "定規を隠す", "隐藏标尺"]), ("Inches Decimal", ["Zoll dezimal", "Pouces décimaux", "Pulgadas decimales", "インチ(10 進)", "十进制英寸"]), From 717fcd6decef246b84797e2e9086a001eae3df74 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:00:01 +0200 Subject: [PATCH 042/207] Type tool: a release away from the press doesn't convert the empty frame Co-Authored-By: Claude Opus 5.5 --- crates/tools/src/tests.rs | 3 +++ crates/tools/src/text.rs | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 5295708cb..2e5ded8a3 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -1080,6 +1080,9 @@ fn type_tool_drag_over_an_empty_frame_draws_a_new_text_frame() { assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)).is_empty()); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x, y)); assert!(matches!(&a[..], [Action::Exec(cmd, p)] if cmd == "text.placeCaret" && p["frame"] == id.0), "{a:?}"); + // A release away from the press with no drag in between isn't a click: no caret, no conversion. + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)).is_empty()); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x1, y1)).is_empty()); } #[test] diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index c8cb778a9..e7ac00355 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -333,8 +333,12 @@ impl Tool for TypeTool { self.selecting = None; return vec![Action::Commit]; } - // A click in an empty frame: it becomes a text frame with the caret in it. + // A click in an empty frame: it becomes a text frame with the caret in it. A release + // away from the press (with no drag in between) isn't a click: nothing changes. if let Some((fid, at)) = self.convert.take() { + if start.is_some_and(|a| (ev.pos - a).hypot() >= cx.tol(3.0)) { + return vec![]; + } return vec![Action::Exec("text.placeCaret".into(), json!({"frame": fid, "point": [at.x, at.y]}))]; } // Ends a press in text: drops dragged text, or is ignored by the engine. From 9892ae05604b3b2e645f856a0e8b9610020da535 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:00:18 +0200 Subject: [PATCH 043/207] Docs: format_measure's precision per unit Co-Authored-By: Claude Opus 5.5 --- crates/geom/src/units.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/geom/src/units.rs b/crates/geom/src/units.rs index f0457e75d..45b0ddaee 100644 --- a/crates/geom/src/units.rs +++ b/crates/geom/src/units.rs @@ -255,8 +255,9 @@ fn parse_single(tok: &str, default: Unit, percent_base: Option) -> Option String { // As InDesign shows them: inches and centimetres to four decimals (a 1p gutter is 0.1667 in // or 0.4233 cm, A4 is 8.2677 in wide), the other units to three. From 7c07c883b0838f1ea41763740308d3a88a5ddc19 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:02:29 +0200 Subject: [PATCH 044/207] =?UTF-8?q?i18n:=20Object/Convert=20Point=20for=20?= =?UTF-8?q?Edit=20=E2=80=BA=20Menus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/fr.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + 8 files changed, 8 insertions(+) diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index c9e6f463e..ff86a04a4 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1925,6 +1925,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "كائن/ملاءمة"), ("Object/Pathfinder", "كائن/باحث المسارات"), ("Object/Paths", "كائن/مسارات"), + ("Object/Convert Point", "كائن/تحويل النقطة"), ("Object/Select", "كائن/تحديد"), ("Object/Transform", "كائن/تحويل"), ("Object/Transform Again", "كائن/تحويل مجدداً"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index dcebc1180..98cdfe317 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -1609,6 +1609,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "Objekt/Přizpůsobení"), ("Object/Pathfinder", "Objekt/Operace s cestami"), ("Object/Paths", "Objekt/Cesty"), + ("Object/Convert Point", "Objekt/Převést bod"), ("Object/Select", "Objekt/Vybrat"), ("Object/Transform", "Objekt/Transformace"), ("Object/Transform Again", "Objekt/Transformovat znovu"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 778f028ae..a12a70295 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -1639,6 +1639,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "Objekt/Anpassen"), ("Object/Pathfinder", "Objekt/Pathfinder"), ("Object/Paths", "Objekt/Pfade"), + ("Object/Convert Point", "Objekt/Punkt konvertieren"), ("Object/Select", "Objekt/Auswählen"), ("Object/Transform", "Objekt/Transformieren"), ("Object/Transform Again", "Objekt/Erneut transformieren"), diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 78c5d5948..d9985411a 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -128,6 +128,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "Objet/Ajustement"), ("Object/Pathfinder", "Objet/Pathfinder"), ("Object/Paths", "Objet/Tracés"), + ("Object/Convert Point", "Objet/Convertir le point"), ("Object/Select", "Objet/Sélectionner"), ("Object/Transform", "Objet/Transformation"), ("Object/Transform Again", "Objet/Répéter la transformation"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 9f98222dd..0fd41bd4a 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1841,6 +1841,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "オブジェクト/オブジェクトサイズの調整"), ("Object/Pathfinder", "オブジェクト/パスファインダー"), ("Object/Paths", "オブジェクト/パス"), + ("Object/Convert Point", "オブジェクト/ポイントを切り換え"), ("Object/Select", "オブジェクト/選択"), ("Object/Transform", "オブジェクト/変形"), ("Object/Transform Again", "オブジェクト/変形を再適用"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d0c67ae3c..6d25d5d72 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -1656,6 +1656,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "ობიექტი/ზომაზე მორგება"), ("Object/Pathfinder", "ობიექტი/ტრაექტორიების ოპერაციები"), ("Object/Paths", "ობიექტი/ტრაექტორიები"), + ("Object/Convert Point", "ობიექტი/წერტილის გარდაქმნა"), ("Object/Select", "ობიექტი/მონიშვნა"), ("Object/Transform", "ობიექტი/გარდაქმნა"), ("Object/Transform Again", "ობიექტი/გარდაქმნის გამეორება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 4b5763383..b3e0eb79f 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -1587,6 +1587,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "Obiekt/Dopasowanie"), ("Object/Pathfinder", "Obiekt/Kształtowanie ścieżek"), ("Object/Paths", "Obiekt/Ścieżki"), + ("Object/Convert Point", "Obiekt/Konwertuj punkt"), ("Object/Select", "Obiekt/Zaznacz"), ("Object/Transform", "Obiekt/Przekształć"), ("Object/Transform Again", "Obiekt/Przekształć ponownie"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 0503aa350..4a1e3d41f 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -1591,6 +1591,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "Объект/Подгонка"), ("Object/Pathfinder", "Объект/Обработка контуров"), ("Object/Paths", "Объект/Контуры"), + ("Object/Convert Point", "Объект/Преобразовать точку"), ("Object/Select", "Объект/Выбрать"), ("Object/Transform", "Объект/Трансформирование"), ("Object/Transform Again", "Объект/Повторить трансформирование"), From f3800df46ba5376babdfdf53cc31cf16efbdfb3f Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:06:21 +0200 Subject: [PATCH 045/207] Properties: corner and stroke type rows fit their translated names Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties.rs | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index c28f02958..e3e12257f 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -724,8 +724,11 @@ fn paint_stroke_type(p: &egui::Painter, r: Rect, kind: &str, c: egui::Color32) { /// wide), then its name. Selected rows are highlighted. fn preview_row(ui: &mut Ui, selected: bool, label: &str, preview_w: f32, paint: impl FnOnce(&egui::Painter, Rect, egui::Color32)) -> egui::Response { let t = Tokens::get(ui.ctx()); - // As wide as the preview and the longest name (the popup otherwise grows to all it's offered). - let w = preview_w + 130.0; + // As wide as the preview and its name, translated (at least 130 pt for the name), and as the + // popup once its widest row has set it: the rows' highlights then end together. + let font = egui::FontId::proportional(12.0); + let text_w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_string(), font, egui::Color32::WHITE).size().x); + let w = (preview_w + 12.0 + text_w + 12.0).max(preview_w + 130.0).max(ui.available_width().min(600.0)); let (r, resp) = ui.allocate_exact_size(vec2(w, 22.0), Sense::click()); if selected || resp.hovered() { ui.painter().rect_filled(r, 2.0, if selected { t.row_selected } else { t.hover }); @@ -3387,6 +3390,24 @@ mod tests { /// The corner shape list shows each shape's glyph beside its name, as InDesign's; a row picks /// the shape and the current one is marked. + /// A row is as wide as its translated name needs: a long one isn't cut at a fixed width. + #[test] + fn preview_rows_fit_long_translated_names() { + let ctx = egui::Context::default(); + let long = "Скругленный прямоугольник с обратным углом"; + let mut widths = (0.0, 0.0); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + egui::Area::new(egui::Id::new("rows")).show(ui.ctx(), |ui| { + ui.set_max_width(10.0); + let r = preview_row(ui, false, long, 40.0, |_, _, _| {}); + let text = ui.fonts_mut(|f| f.layout_no_wrap(long.to_string(), egui::FontId::proportional(12.0), egui::Color32::WHITE).size().x); + widths = (r.rect.width(), 40.0 + 12.0 + text); + }); + }); + out.textures_delta.clear(); + assert!(widths.0 >= widths.1, "{widths:?}"); + } + #[test] fn the_corner_shape_list_has_glyph_rows_and_picks_a_shape() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From bd8abe3972cb7d94161ccb07ace4c36c00e274e4 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:22:04 +0200 Subject: [PATCH 046/207] Docs: Swatches rows' type columns in the parity overview Co-Authored-By: Claude Opus 5.5 --- docs/target-app-parity.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cdd6da689..20f30db6d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DesignCraft parity with Adobe InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Swatches rows show InDesign's type columns; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature @@ -268,7 +268,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together — at start/end of paragraph by default, as in InDesign — widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing. - FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends. - Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text. -- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts and the Fill/Stroke proxy (D, X, Shift+X, / as `color.*` commands), document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), document grid (View ▸ Grids & Guides, ⌘', on the snap positions, behind or in front of items), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches (InDesign's header: Fill/Stroke proxy, Formatting Affects Container/Text, Tint; a swatch goes to the fill or stroke in front; New Color Group / New Swatch / Delete Swatch row), Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. +- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts and the Fill/Stroke proxy (D, X, Shift+X, / as `color.*` commands), document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), document grid (View ▸ Grids & Guides, ⌘', on the snap positions, behind or in front of items), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches (InDesign's header: Fill/Stroke proxy, Formatting Affects Container/Text, Tint, which reads 100 with nothing selected; a swatch goes to the fill or stroke in front; rows show InDesign's type columns: the crossed pencil on swatches that can't be edited, process or spot, and the colour mode; New Color Group / New Swatch / Delete Swatch row), Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. - Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows, expandable groups and drag-and-drop reorder of layers and objects (the selection square moves the selection to another layer). - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type; Option/Alt+Left/Right kerns at the caret or tracks a selection by the Kerning/Tracking increment, ×5 with Cmd/Ctrl; words move with Cmd+Left/Right on macOS and Ctrl+Left/Right elsewhere), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. From e811b65e82c2565682ed18042cb3b4e7239a9825 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:38:29 +0200 Subject: [PATCH 047/207] Clear Fitting Options skips locked frames Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/object.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index f709b6dec..2fa9f4117 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -2900,6 +2900,10 @@ fn clear_fitting_options(s: &mut Session, p: &Value) -> Result { s.edit(|d, _| { let mut n = 0; for id in &ids { + // Locked frames (or frames on a locked layer) keep their options, as with Fit. + if !d.item(*id).is_some_and(|it| unlocked(d, it)) { + continue; + } let Some(it) = d.item_mut(*id) else { continue }; let Content::Graphic(g) = &mut it.content else { continue }; g.auto_fit = designcraft_doc::Fitting::None; @@ -4313,6 +4317,10 @@ mod fitting_tests { assert_eq!(g.xf, before.xf, "the picture stays where it is"); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(graphic(&s).crop, [10.0; 4], "one undo step"); + s.execute("object.lock", &json!({"ids": [f]})).unwrap(); + let r = s.execute("object.clearFittingOptions", &json!({"ids": [f]})).unwrap(); + assert_eq!(r["changed"], 0); + assert_eq!(graphic(&s).crop, [10.0; 4], "a locked frame keeps its options"); } } From 5544efdbdcff10f41dd49892b133665c54b7c4a9 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:03:03 +0200 Subject: [PATCH 048/207] Effects angle dial: arrow keys and assistive-technology steps turn it Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/effects_dialog.rs | 98 ++++++++++++++++++++++++++-- 1 file changed, 94 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/effects_dialog.rs b/crates/ui-egui/src/effects_dialog.rs index d0c2228b8..5341c15a6 100644 --- a/crates/ui-egui/src/effects_dialog.rs +++ b/crates/ui-egui/src/effects_dialog.rs @@ -217,8 +217,11 @@ fn angle(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str], enabled: bool, global } /// InDesign's angle dial: a circle with a radius pointing where the light comes from (0° to the -/// right, counter-clockwise). A click or a drag on it points it at the pointer. +/// right, counter-clockwise). A click or a drag on it points it at the pointer. Focused, the +/// arrow keys turn it a degree (Shift: 10°) — Right and Up counter-clockwise — and assistive +/// technology can step it or set its value, as with a slider. fn angle_dial(ui: &mut egui::Ui, deg: f64, enabled: bool) -> Option { + use egui::accesskit::{Action, ActionData}; let sense = if enabled { egui::Sense::click_and_drag() } else { egui::Sense::hover() }; let (r, resp) = ui.allocate_exact_size(egui::vec2(26.0, 26.0), sense); resp.widget_info(|| egui::WidgetInfo::slider(enabled, deg, "Angle")); @@ -227,10 +230,64 @@ fn angle_dial(ui: &mut egui::Ui, deg: f64, enabled: bool) -> Option { ui.painter().circle_stroke(c, rad, egui::Stroke::new(1.2, col)); let a = deg.to_radians() as f32; ui.painter().line_segment([c, c + egui::vec2(a.cos(), -a.sin()) * rad], egui::Stroke::new(1.4, col)); - if enabled && (resp.dragged() || resp.clicked()) { - return resp.interact_pointer_pos().and_then(|p| dial_angle(c, p)); + if resp.has_focus() { + ui.painter().circle_stroke(c, rad + 2.0, ui.visuals().selection.stroke); } - None + if !enabled { + return None; + } + ui.ctx().accesskit_node_builder(resp.id, |node| { + node.set_min_numeric_value(-180.0); + node.set_max_numeric_value(180.0); + node.set_numeric_value_step(1.0); + node.add_action(Action::SetValue); + node.add_action(Action::Increment); + node.add_action(Action::Decrement); + }); + let mut next = None; + let mut steps = 0i64; + if resp.has_focus() { + // The arrows turn the dial instead of moving the focus. + ui.memory_mut(|m| { + m.set_focus_lock_filter(resp.id, egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }) + }); + ui.input(|i| { + for e in &i.events { + if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { + let dir = match key { + egui::Key::ArrowRight | egui::Key::ArrowUp => 1, + egui::Key::ArrowLeft | egui::Key::ArrowDown => -1, + _ => 0, + }; + steps += dir * if modifiers.shift { 10 } else { 1 }; + } + } + }); + } + ui.input(|i| { + steps += i.num_accesskit_action_requests(resp.id, Action::Increment) as i64; + steps -= i.num_accesskit_action_requests(resp.id, Action::Decrement) as i64; + for request in i.accesskit_action_requests(resp.id, Action::SetValue) { + if let Some(ActionData::NumericValue(v)) = request.data { + next = Some(v); + } + } + }); + if steps != 0 { + next = Some(deg + steps as f64); + } + if (resp.dragged() || resp.clicked()) + && let Some(a) = resp.interact_pointer_pos().and_then(|p| dial_angle(c, p)) + { + next = Some(a); + } + next.filter(|v| v.is_finite()).map(wrap_angle) +} + +/// `deg` turned into the dial's range, −180 (exclusive) to 180°. +fn wrap_angle(deg: f64) -> f64 { + let a = (deg + 180.0).rem_euclid(360.0) - 180.0; + if a <= -180.0 { 180.0 } else { a } } /// The angle a dial at `c` points at for the pointer at `p`, in whole degrees (−180 to 180, @@ -769,6 +826,39 @@ mod tests { assert_eq!(dial_angle(c, egui::pos2(101.0, 100.0)), None, "too close to the centre"); } + /// Without a pointer: Tab focuses the dial, the arrows turn it (Shift: 10°), wrapping at 180°. + #[test] + fn the_angle_dial_turns_with_the_keyboard() { + let ctx = egui::Context::default(); + let key = |key, shift| egui::Event::Key { + key, + physical_key: None, + pressed: true, + repeat: false, + modifiers: if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE }, + }; + let mut deg = 175.0; + let frame = |events: Vec, deg: &mut f64| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + if let Some(a) = angle_dial(ui, *deg, true) { + *deg = a; + } + }); + out.textures_delta.clear(); + }; + frame(vec![], &mut deg); + frame(vec![key(egui::Key::Tab, false)], &mut deg); + frame(vec![key(egui::Key::ArrowRight, false)], &mut deg); + assert_eq!(deg, 176.0); + frame(vec![key(egui::Key::ArrowUp, true)], &mut deg); + assert_eq!(deg, -174.0, "past 180° it comes round to −174°"); + frame(vec![key(egui::Key::ArrowLeft, true)], &mut deg); + frame(vec![key(egui::Key::ArrowDown, false)], &mut deg); + assert_eq!(deg, 175.0); + assert_eq!(wrap_angle(-180.0), 180.0); + assert_eq!(wrap_angle(540.0), 180.0); + } + #[test] fn offsets_follow_distance_and_angle_as_in_indesign() { let (mut app, _) = app_with_frame(); From 46d830cb3b753ee0b52eb77a0956ababd040d059 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:33:24 +0200 Subject: [PATCH 049/207] Paragraph rules: gap in Text Color, IDML overrides from the new fields alone, Text Color in the Color menu Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/lib.rs | 11 ++++-- crates/compose/src/tests.rs | 3 ++ crates/idml/src/import.rs | 8 +++- crates/idml/src/tests.rs | 27 +++++++++++++ crates/ui-egui/src/dialogs.rs | 74 ++++++++++++++++++++++++++++++++++- 5 files changed, 115 insertions(+), 8 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 1920b3020..c97171208 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1876,7 +1876,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let top = frames.get(fi).map_or(f64::NEG_INFINITY, |f| f.area.y0); let y = if r.keep_in_frame && y - r.weight < top { top + r.weight } else { y }; let rect = Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y); - ft.decos.extend(rule_decos(doc, r, rect, color, tint)); + ft.decos.extend(rule_decos(doc, r, rect, (color, tint), &above)); } } if pp.rule_below.on @@ -1887,7 +1887,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let y = l.baseline + r.offset; let (x0, x1) = if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) }; let (color, tint) = rule_color(r, &below); - ft.decos.extend(rule_decos(doc, r, Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color, tint)); + ft.decos.extend(rule_decos(doc, r, Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), (color, tint), &below)); } // The end of a split block: text after it continues below its deepest sub-column, which // are balanced first. A block that ends the story fills its sub-columns in turn. @@ -4064,13 +4064,16 @@ fn hyphenation_points( /// A paragraph rule drawn in `rect` (its length and weight) with its Type: the gap colour under /// it, then the line, as dashes, dots, stripes, waves or hash marks. A pattern that can't be laid /// out along the rule draws solid. -fn rule_decos(doc: &Document, r: &designcraft_doc::Rule, rect: Rect, color: String, tint: f32) -> Vec { +/// `(color, tint)` is the rule's (Text Color already resolved); `text`, the colour of the text +/// the rule goes with, for a gap in Text Color. +fn rule_decos(doc: &Document, r: &designcraft_doc::Rule, rect: Rect, (color, tint): (String, f32), text: &(String, f32)) -> Vec { use designcraft_doc::StrokeType; let kind = doc.stroke_kind(&r.kind); let (w, len) = (rect.height(), rect.width()); let mut out = Vec::new(); if !matches!(kind, StrokeType::Solid) && r.gap_color != designcraft_color::swatch::NONE && w > 0.0 && len > 0.0 { - out.push(Deco { rect, color: r.gap_color.clone(), tint: r.gap_tint, ..Deco::default() }); + let gap = if r.gap_color == designcraft_doc::TEXT_COLOR { text.0.clone() } else { r.gap_color.clone() }; + out.push(Deco { rect, color: gap, tint: r.gap_tint, ..Deco::default() }); } let y = rect.center().y; let mut line = designcraft_geom::BezPath::new(); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index e394e4fe1..bd7235a69 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -3719,6 +3719,9 @@ fn paragraph_rules_have_a_type_a_gap_colour_and_keep_in_frame() { let gap = decos(Rule { gap_color: "[Black]".into(), gap_tint: 0.3, ..rule(StrokeType::Dotted) }); assert_eq!((gap.len(), gap[0].color.as_str(), gap[0].tint, gap[0].path.is_none()), (2, "[Black]", 0.3, true), "the gap colour under the dots"); assert_eq!(decos(Rule { gap_color: "[Black]".into(), ..rule(StrokeType::Solid) }).len(), 1, "a solid rule has no gaps"); + // A gap in Text Color takes the text's colour, whatever swatch the rule itself has. + let gap = decos(Rule { color: "[Paper]".into(), gap_color: designcraft_doc::TEXT_COLOR.into(), ..rule(StrokeType::Dotted) }); + assert_eq!((gap[0].color.as_str(), gap[1].color.as_str()), ("[Black]", "[Paper]")); // Keep In Frame: a rule above raised out of the frame comes back to its top. let above = |keep: bool| { let r = Rule { on: true, weight: 2.0, offset: 30.0, keep_in_frame: keep, ..Default::default() }; diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 58cd70271..827b39ea8 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1939,7 +1939,12 @@ impl<'r> Importer<'r> { // A style or range can set some of a rule's settings (such as only its colour); the others // come from its paragraph style chain, so the rule is whole. let fields = ["", "LineWeight", "Color", "Tint", "Width", "Offset", "LeftIndent", "RightIndent"]; - if !fields.iter().any(|f| e.prop(&format!("{k}{f}")).is_some()) { + // Any of them, or of the type, gap and Keep In Frame settings, makes it an override (the + // rest then come from the style chain, or the defaults: off, 1 pt). + let more = ["Type", "GapColor", "GapTint"]; + let own = fields.iter().chain(&more).any(|f| e.prop(&format!("{k}{f}")).is_some()) + || (k == "RuleAbove" && e.prop("KeepRuleAboveInFrame").is_some()); + if !own { return None; } let v: Vec> = fields.iter().map(|f| self.para_setting(e, &format!("{k}{f}"))).collect(); @@ -1947,7 +1952,6 @@ impl<'r> Importer<'r> { let num = |i: usize| get(i).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()); let on = get(0).map(|v| v.trim() == "true"); let weight = num(1); - on.or(weight.map(|_| false))?; let mut r = Rule { on: on.unwrap_or(false), ..Rule::default() }; if let Some(w) = weight { r.weight = w; diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index e03eeffb7..51ce9e7ff 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -3705,6 +3705,33 @@ fn style_names_ending_in_a_space_and_partial_rule_overrides_import() { assert_ne!(black.color, white.color, "only the colour is overridden"); } +#[test] +fn a_style_setting_only_a_rules_type_gap_or_keep_in_frame_overrides_it() { + let styles = STYLES.replace( + r#" "#, + r#" + $ID/[No paragraph style] + + + ParagraphStyle/Base + + + $ID/[No paragraph style] + + + $ID/[No paragraph style] + + "#, + ); + let d = import_idml(&fixture_with_table_styles(&styles, STORY)).unwrap(); + let style = |n: &str| d.styles.paragraph.iter().find(|s| s.name == n).unwrap().para.clone(); + let wavy = style("Wavy").rule_below.expect("the type alone overrides the rule"); + assert_eq!((wavy.on, wavy.weight, wavy.kind), (true, 3.0, designcraft_doc::StrokeType::Wavy), "the rest from the based-on style"); + let gap = style("Gap").rule_below.expect("the gap colour alone, with no on or weight in the chain"); + assert!(!gap.on && gap.gap_color != designcraft_color::swatch::NONE, "{gap:?}"); + assert!(style("Kept").rule_above.expect("Keep In Frame alone").keep_in_frame); +} + #[test] fn missing_linked_graphics_show_their_preview_and_keep_their_link() { // InDesign's preview of the missing photo (a PNG header is all the importer reads: 4 × 3). diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index de57d77e9..3d984fc13 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4500,8 +4500,30 @@ fn para_sides(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &s /// 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 { + swatch_menu_with(ui, doc, id, current, width, None) +} + +/// [`swatch_menu`] with, given its translated label, Text Color first (a paragraph rule's +/// colour: the text's own), as InDesign's Paragraph Rules › Color. +fn swatch_menu_with( + ui: &mut egui::Ui, + doc: &designcraft_doc::Document, + id: impl egui::AsIdSalt, + current: &str, + width: f32, + text_color: Option<&str>, +) -> Option { let mut picked = None; - egui::ComboBox::from_id_salt(id).selected_text(current).width(width).show_ui(ui, |ui| { + let shown = match text_color { + Some(label) if current == designcraft_doc::TEXT_COLOR => label, + _ => current, + }; + egui::ComboBox::from_id_salt(id).selected_text(shown).width(width).show_ui(ui, |ui| { + if let Some(label) = text_color + && ui.selectable_label(current == designcraft_doc::TEXT_COLOR, label).clicked() + { + picked = Some(designcraft_doc::TEXT_COLOR.to_string()); + } for w in doc.swatches.iter().filter(|w| !w.hidden) { let (c, g) = crate::widgets::swatch_colors(doc, &w.name, 1.0); ui.horizontal(|ui| { @@ -5914,7 +5936,8 @@ fn paragraph_rules(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, prefix: & ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Color:")); let color = rule["color"].as_str().unwrap_or("").to_string(); - if let Some(w) = doc.and_then(|doc| swatch_menu(ui, doc, ("rule_color", which), &color, 130.0)) { + let text_color = crate::i18n::tr(lang, "Text Color"); + if let Some(w) = doc.and_then(|doc| swatch_menu_with(ui, doc, ("rule_color", which), &color, 130.0, Some(text_color))) { d.fields.insert(key("color"), json!(w)); } crate::rtl::label(ui, crate::i18n::tr(lang, "Tint:")); @@ -7562,6 +7585,53 @@ mod tests { assert_eq!(app.run("type.selectionAttrs", json!({})).unwrap()["para"]["ruleBelow"], rule); } + /// Paragraph Rules › Color offers Text Color above the swatches, so a rule set to a swatch can + /// go back to it; Gap Color lists only swatches. + #[test] + fn the_rule_colour_menu_offers_text_color() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let doc = app.session.doc().unwrap().doc.clone(); + let menu = |ctx: &egui::Context, text: Option<&str>, click: Option| { + let mut picked = None; + let mut labels = Vec::new(); + let mut events = Vec::new(); + if let Some(pos) = click { + events.push(egui::Event::PointerMoved(pos)); + events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + } + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 600.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + picked = swatch_menu_with(ui, &doc, "m", "[Black]", 130.0, text); + }); + out.textures_delta.clear(); + for s in out.shapes { + if let egui::Shape::Text(t) = s.shape { + labels.push((t.galley.job.text.clone(), t.visual_bounding_rect().center())); + } + } + (picked, labels) + }; + for text in [Some("Text Color"), None] { + let ctx = egui::Context::default(); + let (_, labels) = menu(&ctx, text, None); + let combo = labels.iter().find(|(t, _)| t == "[Black]").unwrap().1; + menu(&ctx, text, Some(combo)); + let (_, labels) = menu(&ctx, text, None); + let item = labels.iter().find(|(t, _)| t == "Text Color").map(|l| l.1); + assert_eq!(item.is_some(), text.is_some(), "{labels:?}"); + if let Some(pos) = item { + let (picked, _) = menu(&ctx, text, Some(pos)); + assert_eq!(picked.as_deref(), Some(designcraft_doc::TEXT_COLOR)); + } + } + } + /// The course's chapter 20: a paragraph style's rule below becomes wavy, 3 pt, with a gap /// colour, from Paragraph Rules › Type and Gap Color. #[test] From f972cebb960351db4bb23fef77ae875e1caccf10 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:47:05 +0200 Subject: [PATCH 050/207] Paragraph style edits: Reset To Base with a new Based On resolves over it, is checked against it and previewed Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/engine/src/cmd/style.rs | 14 ++++++---- crates/engine/src/tests.rs | 15 ++++++++++ crates/ui-egui/src/dialogs.rs | 50 ++++++++++++++++++++++++++++++++-- 4 files changed, 72 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 41b3ab40d..9a8a08c3b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,7 +11,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. -## Status (2026-10-01) +## Status (2026-10-21) **Working today** - Document model: facing/non-facing spreads, parent pages with page-number markers, sections, layers, frames (text / graphic / unassigned), threaded stories, paragraph/character/object styles with based-on, swatches (process/spot/tints/gradients), corner options, text wrap, drop shadow, opacity/blend. diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 4526db5b0..903cd7d0f 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -955,6 +955,15 @@ fn edit_para(s: &mut Session, p: &Value) -> Result { st.para = ParaAttrs::default(); st.chars = CharAttrs::default(); } + // A new Based On first, so the fields below resolve over the new parent. + if based.is_some() { + let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?; + match &based { + Some(Value::String(b)) => st.based_on = Some(b.clone()), + Some(Value::Null) => st.based_on = None, + _ => {} + } + } // A rule object changes only the rule fields it names, over the style's resolved rule. let (current, _) = d.styles.resolve_para_style(&name); let mut para: ParaAttrs = attrs("style.paragraph.edit", "para", p, |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, ¤t))?; @@ -964,11 +973,6 @@ fn edit_para(s: &mut Session, p: &Value) -> Result { let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?; st.para.merge(¶); st.chars.merge(&chars); - match based { - Some(Value::String(b)) => st.based_on = Some(b), - Some(Value::Null) => st.based_on = None, - _ => {} - } match next { Some(Value::String(n)) => st.next_style = Some(n), Some(Value::Null) => st.next_style = None, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9aacf469d..4bc7e360c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1650,3 +1650,18 @@ fn a_line_fits_when_its_baseline_does() { assert!(!overset(&mut s, 78.5), "the line is set; its descenders hang below the frame"); assert!(overset(&mut s, 50.0), "a baseline below the frame still oversets the line"); } + +/// Reset To Base with a new Based On: a partial rule edit resolves over the new parent, not the +/// old one. +#[test] +fn reset_to_base_with_a_new_parent_resolves_rule_edits_over_it() { + let mut s = session(); + let rule = |weight: f64, offset: f64| json!({"on": true, "weight": weight, "color": "[Black]", "offset": offset}); + s.execute("style.paragraph.create", &json!({"name": "Old", "para": {"ruleBelow": rule(3.0, 1.0)}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "New", "para": {"ruleBelow": rule(1.0, 9.0)}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Child", "basedOn": "Old"})).unwrap(); + s.execute("style.paragraph.edit", &json!({"name": "Child", "resetToBase": true, "basedOn": "New", "para": {"ruleBelow": {"weight": 2}}})) + .unwrap(); + let r = s.doc().unwrap().doc.styles.resolve_para_style("Child").0.rule_below; + assert_eq!((r.weight, r.offset), (2.0, 9.0), "the offset is the new parent's"); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index dc6a5d7e7..d5341722e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3646,9 +3646,25 @@ fn grep_error(p: &str) -> Option { /// The Justification spacing check of Paragraph Style Options, against what the section showed: /// the style's own values, or a new style's Based On. fn check_paragraph_style_justification(app: &DesignApp, d: &Dialog) -> Result<(), String> { - let shown = if d.b("new") { d.s("basedOn") } else { d.s("name") }; - let base = - app.session.active().map(|st| serde_json::to_value(st.doc.styles.resolve_para_style(&shown).0).unwrap_or_default()).unwrap_or_default(); + // After Reset To Base the style starts from its Based On (the one chosen here, if any). + let base_of = |st: &designcraft_engine::DocState| { + let chosen = d.s("basedOn"); + if chosen.is_empty() { st.doc.styles.para(&d.s("name")).and_then(|s| s.based_on.clone()).unwrap_or_default() } else { chosen } + }; + let base = app + .session + .active() + .map(|st| { + let shown = if d.b("new") { + d.s("basedOn") + } else if d.b("resetToBase") { + base_of(st) + } else { + d.s("name") + }; + serde_json::to_value(st.doc.styles.resolve_para_style(&shown).0).unwrap_or_default() + }) + .unwrap_or_default(); check_justification(d, "p.", &base) } @@ -3777,6 +3793,10 @@ fn style_options_preview(app: &mut DesignApp, d: &mut Dialog) { _ => based.clone(), }; } + // Reset To Base shows on the page too. + if paragraph && d.b("resetToBase") { + params["resetToBase"] = json!(true); + } if shown.as_ref() == Some(¶ms) { return; } @@ -7614,6 +7634,30 @@ mod tests { assert_eq!(s.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); } + /// With Reset To Base, the justification fields are checked against the base style (the + /// style's own minimum no longer applies), and Preview shows the reset on the page. + #[test] + fn reset_to_base_checks_and_previews_against_the_base() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Loose words"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 5})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Loose", "basedOn": designcraft_doc::BASIC_PARAGRAPH, "para": {"wordSpaceMin": 1.2, "wordSpaceDesired": 1.3}, "chars": {"size": 30}})) + .unwrap(); + app.run("style.paragraph.apply", json!({"name": "Loose"})).unwrap(); + let mut d = Dialog::new("paragraphStyleOptions", json!({"name": "Loose"})); + d.fields.insert("p.wordSpaceDesired".into(), json!(1.1)); + assert!(check_paragraph_style_justification(&app, &d).is_err(), "below the style's own minimum"); + d.fields.insert("resetToBase".into(), json!(true)); + assert_eq!(check_paragraph_style_justification(&app, &d), Ok(()), "the base's minimum is 100%"); + d.fields.insert("preview".into(), json!(true)); + style_options_preview(&mut app, &mut d); + assert_eq!(d.fields[PREVIEWED]["resetToBase"], true); + let size = app.run("type.selectionAttrs", json!({})).unwrap()["chars"]["size"].clone(); + assert_eq!(size, json!(12.0), "the page shows the style reset to its base"); + app.session.cancel_interaction(); + } + #[test] fn paragraph_style_options_edit_one_rule_field() { let (mut app, rule) = ruled_paragraph(); From ff56cdde4ec14646a2fe81d850de2c6afde4080a Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:22:35 +0200 Subject: [PATCH 051/207] Clear Fitting Options: no edit, and no undo step, when nothing can change Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/object.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 2fa9f4117..09e7324b4 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -2896,14 +2896,19 @@ fn fitting_options(s: &mut Session, p: &Value) -> Result { /// Clear Frame Fitting Options: auto-fit off, no crop, the centre reference point. The content /// stays where it is, as in InDesign. fn clear_fitting_options(s: &mut Session, p: &Value) -> Result { - let ids = targets(s, p)?; + // Locked frames (or frames on a locked layer) keep their options, as with Fit; with nothing + // to change, no edit (and no empty undo step). + let doc = &s.doc()?.doc; + let ids: Vec = targets(s, p)? + .into_iter() + .filter(|id| doc.item(*id).is_some_and(|it| unlocked(doc, it) && matches!(it.content, Content::Graphic(_)))) + .collect(); + if ids.is_empty() { + return Ok(json!({"changed": 0})); + } s.edit(|d, _| { let mut n = 0; for id in &ids { - // Locked frames (or frames on a locked layer) keep their options, as with Fit. - if !d.item(*id).is_some_and(|it| unlocked(d, it)) { - continue; - } let Some(it) = d.item_mut(*id) else { continue }; let Content::Graphic(g) = &mut it.content else { continue }; g.auto_fit = designcraft_doc::Fitting::None; @@ -4318,9 +4323,11 @@ mod fitting_tests { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(graphic(&s).crop, [10.0; 4], "one undo step"); s.execute("object.lock", &json!({"ids": [f]})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); let r = s.execute("object.clearFittingOptions", &json!({"ids": [f]})).unwrap(); assert_eq!(r["changed"], 0); assert_eq!(graphic(&s).crop, [10.0; 4], "a locked frame keeps its options"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no empty undo step"); } } From 4c5c25c4e4147cd32e14971efa96a923514a1210 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:24:41 +0200 Subject: [PATCH 052/207] Swatches: a Paper swatch is editable whatever its name Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/swatches.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 32bc9e128..bbb4b21a9 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -254,7 +254,8 @@ enum RowMode { fn row_marks(sw: &designcraft_color::swatch::Swatch) -> RowMarks { use designcraft_color::Color; - let not_editable = sw.locked && sw.name != designcraft_color::swatch::PAPER; + // Paper's colour can be edited, whatever the swatch is called (as the options dialog allows). + let not_editable = sw.locked && !matches!(sw.value, SwatchValue::Paper { .. }); match &sw.value { SwatchValue::Color { color, color_type } => { let mode = match color { @@ -1312,5 +1313,9 @@ mod row_mark_tests { ..cyan.clone() }; assert_eq!(row_marks(&spot), RowMarks { not_editable: false, kind: Some(ColorType::Spot), mode: Some(RowMode::Rgb) }); + // A locked Paper swatch under another name (a document from elsewhere) is still editable. + let paper = all.iter().find(|s| s.name == designcraft_color::swatch::PAPER).unwrap(); + let renamed = designcraft_color::swatch::Swatch { name: "Papier".into(), ..paper.clone() }; + assert!(!row_marks(&renamed).not_editable); } } From 03754e381a074078cf713a59bce4c6815fe06c46 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:58:44 +0200 Subject: [PATCH 053/207] Paragraph styles: Based On and Next Style must name styles; the dialog resolves over the chosen Based On Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/style.rs | 23 ++++++++++++++--- crates/engine/src/tests.rs | 17 ++++++++++++ crates/ui-egui/src/dialogs.rs | 47 +++++++++++++++++++++++++++++----- 3 files changed, 77 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 903cd7d0f..699b01ea8 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -875,6 +875,13 @@ fn create_para(s: &mut Session, p: &Value) -> Result { { return Err(bad("style.paragraph.create", format!("no style `{b}`"))); } + // Next Style: a style of the document, or this one. + if let Some(n) = &next + && *n != name + && d.styles.para(n).is_none() + { + return Err(bad("style.paragraph.create", format!("no style `{n}`"))); + } d.styles_mut().paragraph.push(ParagraphStyle { name: name.clone(), based_on, next_style: next, para, chars, shortcut: String::new() }); Ok(json!({"name": name})) }) @@ -938,11 +945,19 @@ fn edit_para(s: &mut Session, p: &Value) -> Result { let based = p.get("basedOn").cloned(); let next = p.get("nextStyle").cloned(); let reset = p.get("resetToBase").and_then(Value::as_bool).unwrap_or(false); + for (key, v) in [("basedOn", &based), ("nextStyle", &next)] { + if v.as_ref().is_some_and(|v| !v.is_string() && !v.is_null()) { + return Err(bad("style.paragraph.edit", format!("`{key}` must be a style name or null"))); + } + } s.edit(|d, _| { - if let Some(Value::String(b)) = &based - && d.styles.para_based_on_cycles(&name, b) - { - return Err(bad("style.paragraph.edit", "based-on would create a cycle")); + if let Some(Value::String(b)) = &based { + if d.styles.para(b).is_none() { + return Err(bad("style.paragraph.edit", format!("no style `{b}`"))); + } + if d.styles.para_based_on_cycles(&name, b) { + return Err(bad("style.paragraph.edit", "based-on would create a cycle")); + } } if let Some(Value::String(n)) = &next && d.styles.para(n).is_none() diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4bc7e360c..33ae449e0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1665,3 +1665,20 @@ fn reset_to_base_with_a_new_parent_resolves_rule_edits_over_it() { let r = s.doc().unwrap().doc.styles.resolve_para_style("Child").0.rule_below; assert_eq!((r.weight, r.offset), (2.0, 9.0), "the offset is the new parent's"); } + +/// Paragraph style references are checked: a Based On or Next Style that names no style, or +/// that isn't a name or null, is refused and changes nothing. +#[test] +fn paragraph_style_references_must_name_styles() { + let mut s = session(); + s.execute("style.paragraph.create", &json!({"name": "Body"})).unwrap(); + assert!(s.execute("style.paragraph.create", &json!({"name": "Lost", "nextStyle": "Nowhere"})).is_err()); + assert!(s.doc().unwrap().doc.styles.para("Lost").is_none()); + s.execute("style.paragraph.create", &json!({"name": "Self", "nextStyle": "Self"})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + for p in [json!({"name": "Body", "basedOn": "Nowhere"}), json!({"name": "Body", "basedOn": 3}), json!({"name": "Body", "nextStyle": true})] { + assert!(s.execute("style.paragraph.edit", &p).is_err(), "{p}"); + } + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "nothing changed"); + s.execute("style.paragraph.edit", &json!({"name": "Body", "basedOn": null, "nextStyle": "Self"})).unwrap(); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d5341722e..491b82866 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3645,6 +3645,21 @@ fn grep_error(p: &str) -> Option { /// The Justification spacing check of Paragraph Style Options, against what the section showed: /// the style's own values, or a new style's Based On. +/// What paragraph style `style` resolves to when based on `based` (the Based On chosen in its +/// dialog, which may not be its own yet): `based`'s chain with the style's own settings over it. +fn style_over( + styles: &designcraft_doc::Styles, + based: &str, + style: &designcraft_doc::ParagraphStyle, +) -> (designcraft_doc::ParaProps, designcraft_doc::CharProps) { + styles.resolve_para(&designcraft_doc::ParaFormat { + style: based.into(), + para: style.para.clone(), + chars: style.chars.clone(), + ..Default::default() + }) +} + fn check_paragraph_style_justification(app: &DesignApp, d: &Dialog) -> Result<(), String> { // After Reset To Base the style starts from its Based On (the one chosen here, if any). let base_of = |st: &designcraft_engine::DocState| { @@ -3655,14 +3670,18 @@ fn check_paragraph_style_justification(app: &DesignApp, d: &Dialog) -> Result<() .session .active() .map(|st| { - let shown = if d.b("new") { - d.s("basedOn") + let pp = if d.b("new") { + st.doc.styles.resolve_para_style(&d.s("basedOn")).0 } else if d.b("resetToBase") { - base_of(st) + st.doc.styles.resolve_para_style(&base_of(st)).0 } else { - d.s("name") + // The style's own settings over the Based On chosen in the dialog. + match st.doc.styles.para(&d.s("name")) { + Some(style) => style_over(&st.doc.styles, &base_of(st), style).0, + None => st.doc.styles.resolve_para_style(&d.s("name")).0, + } }; - serde_json::to_value(st.doc.styles.resolve_para_style(&shown).0).unwrap_or_default() + serde_json::to_value(pp).unwrap_or_default() }) .unwrap_or_default(); check_justification(d, "p.", &base) @@ -3847,7 +3866,8 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo let names: Vec = doc.styles.paragraph.iter().map(|p| p.name.clone()).filter(|n| new || *n != name).collect(); // A new style shows what it would inherit from its Based On (as does one reset to it). let based_name = d.fields.get("basedOn").and_then(Value::as_str).map(str::to_string).or(style.based_on.clone()).unwrap_or_default(); - let (pp, cp) = doc.styles.resolve_para_style(if new || reset { &based_name } else { &name }); + // An existing style shows its own settings over the Based On chosen here. + let (pp, cp) = if new || reset { doc.styles.resolve_para_style(&based_name) } else { style_over(&doc.styles, &based_name, &style) }; let units = doc.settings.horizontal_units; let pv = serde_json::to_value(&pp).unwrap_or_default(); let cv = serde_json::to_value(&cp).unwrap_or_default(); @@ -7634,6 +7654,21 @@ mod tests { assert_eq!(s.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); } + /// A Based On changed in the dialog (without Reset To Base) is what the fields are checked + /// against: the style's own settings over the new parent. + #[test] + fn a_changed_based_on_is_what_the_fields_are_checked_against() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Loose", "para": {"wordSpaceMin": 1.2, "wordSpaceDesired": 1.3}})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Child", "basedOn": "Loose"})).unwrap(); + let mut d = Dialog::new("paragraphStyleOptions", json!({"name": "Child"})); + d.fields.insert("p.wordSpaceDesired".into(), json!(1.0)); + assert!(check_paragraph_style_justification(&app, &d).is_err(), "below Loose's minimum"); + d.fields.insert("basedOn".into(), json!(designcraft_doc::BASIC_PARAGRAPH)); + assert_eq!(check_paragraph_style_justification(&app, &d), Ok(()), "[Basic Paragraph]'s minimum is lower"); + } + /// With Reset To Base, the justification fields are checked against the base style (the /// style's own minimum no longer applies), and Preview shows the reset on the page. #[test] From 1ffa37aaa53928ba825159943a28187e3151c878 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:11:47 +0200 Subject: [PATCH 054/207] Opacity slider: shown without a change, it commits the drag's preview at once Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index b781ed4b3..216f2ecc3 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -873,8 +873,9 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { /// The Opacity slider that the arrow beside the field opens, as in InDesign: a drag shows the /// new opacity live and is one undo step when let go. fn opacity_slider(app: &mut DesignApp, resp: &egui::Response, opacity: f64) { - let (mut slid, mut held) = (None, false); + let (mut slid, mut held, mut shown) = (None, false, false); egui::Popup::menu(resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + shown = true; // A thin rail and a round knob, as InDesign draws it. ui.spacing_mut().slider_width = 130.0; ui.spacing_mut().slider_rail_height = 2.0; @@ -902,6 +903,9 @@ fn opacity_slider(app: &mut DesignApp, resp: &egui::Response, opacity: f64) { let _ = app.run("object.opacity", json!({"opacity": v / 100.0})); } None if held => app.hold_live_preview(), + // Shown but let go without a change (a click outside, Escape): the drag's preview is + // committed now, before whatever that click does, so that keeps its own undo step. + None if shown => app.end_live_preview(), None => {} } } From a401aba8c46ce724e7cd29e14f28f2d4cc5011ea Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:28:11 +0200 Subject: [PATCH 055/207] Clear Fitting Options leaves frames whose options are already clear Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/object.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 09e7324b4..c59acc2ff 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -2896,12 +2896,14 @@ fn fitting_options(s: &mut Session, p: &Value) -> Result { /// Clear Frame Fitting Options: auto-fit off, no crop, the centre reference point. The content /// stays where it is, as in InDesign. fn clear_fitting_options(s: &mut Session, p: &Value) -> Result { - // Locked frames (or frames on a locked layer) keep their options, as with Fit; with nothing - // to change, no edit (and no empty undo step). + // Locked frames (or frames on a locked layer) keep their options, as with Fit; frames whose + // options are already clear are left out; with nothing to change, no edit (and no empty undo + // step). let doc = &s.doc()?.doc; + let set = |g: &designcraft_doc::Graphic| g.auto_fit != designcraft_doc::Fitting::None || g.crop != [0.0; 4] || g.fit_align != 4; let ids: Vec = targets(s, p)? .into_iter() - .filter(|id| doc.item(*id).is_some_and(|it| unlocked(doc, it) && matches!(it.content, Content::Graphic(_)))) + .filter(|id| doc.item(*id).is_some_and(|it| unlocked(doc, it) && matches!(&it.content, Content::Graphic(g) if set(g)))) .collect(); if ids.is_empty() { return Ok(json!({"changed": 0})); @@ -4320,6 +4322,9 @@ mod fitting_tests { assert_eq!(g.crop, [0.0; 4]); assert_eq!(g.fit_align, 4, "the centre reference point"); assert_eq!(g.xf, before.xf, "the picture stays where it is"); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("object.clearFittingOptions", &json!({"ids": [f]})).unwrap(); + assert_eq!((r["changed"].as_u64(), s.doc().unwrap().history.undo.len()), (Some(0), undo), "already clear: no change, no step"); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(graphic(&s).crop, [10.0; 4], "one undo step"); s.execute("object.lock", &json!({"ids": [f]})).unwrap(); From d755ea2b09ca8c97a1c0721a31e2efecd0eba230 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:07:30 +0200 Subject: [PATCH 056/207] Properties: Bullets and Numbering's Options opens the Bullets and Numbering dialog Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/panels/properties.rs | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..1cecb8acc 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** · Properties › Bullets and Numbering › Options opens the Bullets and Numbering dialog for the selected paragraphs, as InDesign's, instead of the Paragraph panel. Found in the Learn Skills Daily course, chapter 21. 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/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 44acf6e2c..a01aeebc2 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1374,8 +1374,9 @@ fn bullets_section(app: &mut DesignApp, ui: &mut Ui) { } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.add_space(8.0); + // As InDesign's: the Bullets and Numbering dialog for the selected paragraphs. if outline_button(ui, crate::i18n::tr(&app.ui.language, "Options"), 66.0).clicked() { - show_panel(app, "paragraph"); + let _ = app.run("app.bulletsNumberingDialog", json!({})); } }); }); @@ -3317,6 +3318,22 @@ mod tests { h } + /// The course's chapter 21: Properties › Bullets and Numbering › Options opens the Bullets + /// and Numbering dialog, as InDesign's, not the Paragraph panel. + #[test] + fn bullets_options_open_the_bullets_and_numbering_dialog() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "4 eggs"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 6})).unwrap(); + let mut h = panel(app); + h.get_by_label("Options").click(); + h.run_steps(3); + let d = h.state().ui.dialog.clone().expect("a dialog"); + assert_eq!((d.id.as_str(), d.s("section").as_str()), ("paragraphLocal", "bullets")); + assert_ne!(h.state().ui.open_panel.as_deref(), Some("paragraph")); + } + #[test] fn several_shapes_show_pathfinder_and_intersect_combines_them() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 9d46967f160a3556e72e9b7fc149059bf6299075 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:15:28 +0200 Subject: [PATCH 057/207] Character styles: a new style from the selected text, applied to it New Character Style with text selected starts from what the text has of its own over its paragraph style (its character style's attributes and its overrides), as InDesign's "[None] + Bold + color", and Apply Style to Selection (on) gives the text the new style with no override left. A new style's dialog is titled New Paragraph Style / New Character Style. type.selectionAttrs reports the text's own character attributes as ownChars. Found in the Learn Skills Daily course, chapter 22. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/text.rs | 7 ++- crates/ui-egui/src/dialogs.rs | 76 +++++++++++++++++++++++++++++++- crates/ui-egui/src/i18n.rs | 2 + crates/ui-egui/src/i18n/ar.rs | 2 + crates/ui-egui/src/i18n/cs.rs | 2 + crates/ui-egui/src/i18n/it.rs | 2 + crates/ui-egui/src/i18n/ka.rs | 2 + crates/ui-egui/src/i18n/pl.rs | 2 + crates/ui-egui/src/i18n/pt_br.rs | 2 + crates/ui-egui/src/i18n/ru.rs | 2 + crates/ui-egui/src/i18n/uk.rs | 2 + 12 files changed, 98 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9a8a08c3b..95c6f7054 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** · New Character Style with text selected starts from what the text has of its own over its paragraph style, as InDesign's ("[None] + Bold + color"), and Apply Style to Selection gives the text the new style, with no override left. A new style's dialog is titled New Paragraph Style / New Character Style. `type.selectionAttrs` reports the text's own character attributes (`ownChars`). Found in the Learn Skills Daily course, chapter 22. No readiness percentage change. - **2026-10-21** · New Paragraph Style with text selected starts from it, as InDesign's (Based On [No Paragraph Style], every setting of the text that differs) and Apply Style to Selection gives the paragraph the new style; General has Next Style and Reset To Base; Basic Character Formats is in two columns (Size | Leading, Kerning | Tracking, Case | Position). `style.paragraph.edit` honours `nextStyle` (it was ignored) and takes `resetToBase`. Found in the Learn Skills Daily course, chapter 20. 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. diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 12bd540b7..c45a8b467 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -374,7 +374,7 @@ pub fn specs() -> Vec { "Selection Attributes", [], None, - "{mixed?: bool} → resolved character/paragraph attributes at the text selection; with mixed, paraMixed lists the paragraph attributes that differ across the selected paragraphs; the selection's start as `selectedText` (one line, up to 60 characters)", + "{mixed?: bool} → resolved character/paragraph attributes at the text selection; `ownChars`: what the text has of its own over its paragraph (its character style's attributes and its overrides, what a character style made from the selection sets); with mixed, paraMixed lists the paragraph attributes that differ across the selected paragraphs; the selection's start as `selectedText` (one line, up to 60 characters)", has_doc, selection_attrs ), @@ -1813,9 +1813,12 @@ fn selection_attrs(s: &mut Session, p: &Value) -> Result { let char_delta = character_base.delta(&cp); let style_keys = character_style.keys(); let character_style_overrides = char_delta.keys().iter().filter(|key| style_keys.contains(key)).count(); + // The text's own formatting over its paragraph, as Create New Style from Selection captures it. + let mut own = character_style.clone(); + own.merge(&cf.over); // The selected text (its start, on one line), for previews such as the font menu's. let selected = selected_text_preview(story.text.get(r.start..r.end).unwrap_or_default().chars()); - let mut out = json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp, + let mut out = json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp, "ownChars": own, "paraOverrides": style_para.delta(&pp).count() + style_chars.delta(&base).count(), "charOverrides": char_delta.count(), "characterStyleOverrides": character_style_overrides, "selectedText": selected.trim()}); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 491b82866..fbd24101c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2046,8 +2046,10 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "documentSetup" => crate::i18n::tr(&app.ui.language, "Document Setup"), "numberingSection" => crate::i18n::tr(&app.ui.language, "Numbering & Section Options…").trim_end_matches('…'), "findChange" => crate::i18n::tr(&app.ui.language, "Find/Change"), + "paragraphStyleOptions" if d.b("new") => crate::i18n::tr(&app.ui.language, "New Paragraph Style"), "paragraphStyleOptions" => crate::i18n::tr(&app.ui.language, "Paragraph Style Options"), "deleteParagraphStyle" => crate::i18n::tr(&app.ui.language, "Delete Paragraph Style"), + "characterStyleOptions" if d.b("new") => crate::i18n::tr(&app.ui.language, "New Character Style"), "characterStyleOptions" => crate::i18n::tr(&app.ui.language, "Character Style Options"), "deleteCharacterStyle" => crate::i18n::tr(&app.ui.language, "Delete Character Style"), "footnoteOptions" => crate::i18n::tr(&app.ui.language, "Footnote Options"), @@ -3202,7 +3204,13 @@ pub fn confirm(app: &mut DesignApp) -> Result { if let Some(b) = based.filter(|b| b != designcraft_doc::NO_CHAR_STYLE) { params["basedOn"] = json!(b); } - app.run("style.character.create", params) + // Apply Style to Selection. + app.run("style.character.create", params).and_then(|r| { + if d.b("apply") && app.session.active().is_some_and(|st| st.selection.text.is_some()) { + app.run("style.character.apply", json!({"name": r["name"]}))?; + } + Ok(r) + }) } else { let mut params = json!({"name": name, "chars": chars}); if let Some(b) = based { @@ -5275,6 +5283,17 @@ pub fn open_new_style(app: &mut DesignApp, para: bool) { } fields["apply"] = json!(true); } + // New Character Style with text selected starts from what the text has of its own over its + // paragraph ("[None] + Bold + color"), applied to the selection on OK, as InDesign's. + if !para + && has_text + && let Some(sel) = app.session.execute("type.selectionAttrs", &json!({})).ok().filter(|v| !v.is_null()) + { + for (k, v) in sel["ownChars"].as_object().into_iter().flatten().filter(|(_, v)| !v.is_null()) { + fields[format!("c.{k}")] = v.clone(); + } + fields["apply"] = json!(true); + } app.ui.dialog = Some(Dialog::new(id, fields)); } @@ -5379,6 +5398,10 @@ fn character_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo let mut parts = vec![crate::i18n::style_name(&lang, &based).to_string()]; parts.extend(own.as_object().into_iter().flatten().map(|(k, v)| format!("{}: {}", setting_label(&lang, k), setting_text(v)))); ui.add(egui::Label::new(egui::RichText::new(parts.join(" + ")).color(crate::theme::Tokens::get(ui.ctx()).text_dim)).wrap()); + if new { + ui.add_space(8.0); + check(ui, d, "apply", crate::i18n::tr(&lang, "Apply Style to Selection")); + } } }); }); @@ -7654,6 +7677,53 @@ mod tests { assert_eq!(s.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); } + /// The course's chapter 22: "eggs" made bold and red on the page, then New Character Style + /// from it. The dialog is titled as InDesign's, starts from what the text has of its own + /// over its paragraph style ("[None] + Bold + color"), and Apply Style to Selection gives the + /// text the style, with no override left. + #[test] + fn a_new_character_style_starts_from_the_selected_text() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Drop eggs into boiling water."})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 29})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Recipe", "chars": {"size": 14}})).unwrap(); + app.run("style.paragraph.apply", json!({"name": "Recipe"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 5, "focus": 9})).unwrap(); + app.run("type.char", json!({"attrs": {"fontStyle": "Bold", "fill": "C=15 M=100 Y=100 K=0"}})).unwrap(); + open_new_style(&mut app, false); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!((d.fields["c.fontStyle"].clone(), d.fields["c.fill"].clone()), (json!("Bold"), json!("C=15 M=100 Y=100 K=0"))); + assert!(d.b("apply")); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("new_character_style_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(4); + for l in ["New Character Style", "Apply Style to Selection"] { + assert!(h.query_by_label(l).is_some(), "{l}"); + } + let app = h.state_mut(); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("rename".into(), json!("eggs")); + } + confirm(app).unwrap(); + let style = app.session.doc().unwrap().doc.styles.char_style("eggs").cloned().unwrap(); + assert_eq!((style.chars.font_style.as_deref(), style.chars.fill.as_deref()), (Some("Bold"), Some("C=15 M=100 Y=100 K=0"))); + assert_eq!(style.chars.size, None, "the size is the paragraph style's, not the text's own"); + let a = app.run("type.selectionAttrs", json!({})).unwrap(); + assert_eq!((a["characterStyle"].clone(), a["charOverrides"].clone()), (json!("eggs"), json!(0)), "applied to the selection"); + } + /// A Based On changed in the dialog (without Reset To Base) is what the fields are checked /// against: the style's own settings over the new parent. #[test] @@ -9844,7 +9914,9 @@ mod tests { ctl(&mut app, "ui.dialog.set", json!({"field": "c.size", "value": 9})); assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); let st = char_style(&app, "Character Style 1").unwrap(); - assert_eq!((st.based_on, st.chars), (None, CharAttrs { size: Some(9.0), ..Default::default() })); + // The selected text's own Italic (its Emphasis style) comes along, as InDesign's New + // Character Style takes it. + assert_eq!((st.based_on, st.chars), (None, CharAttrs { size: Some(9.0), font_style: Some("Italic".into()), ..Default::default() })); crate::menus::activate(&mut app, "style.character.create", &Value::Null); assert_eq!(app.ui.dialog.as_ref().unwrap().s("rename"), "Character Style 2"); } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index caaede51c..ff637d7fb 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -39,6 +39,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Next Style:", ["Nächstes Format:", "Style suivant :", "Estilo siguiente:", "次のスタイル :", "下一样式:"]), ("[Same style]", ["[Gleiches Format]", "[Même style]", "[Mismo estilo]", "[同じスタイル]", "[相同样式]"]), ("Reset To Base", ["Auf Basis zurücksetzen", "Rétablir la base", "Restablecer a base", "基準に戻す", "重置为基准"]), + ("New Paragraph Style", ["Neues Absatzformat", "Nouveau style de paragraphe", "Nuevo estilo de párrafo", "新規段落スタイル", "新建段落样式"]), + ("New Character Style", ["Neues Zeichenformat", "Nouveau style de caractère", "Nuevo estilo de carácter", "新規文字スタイル", "新建字符样式"]), ( "Apply Style to Selection", [ diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index bc300eac8..6f3d54e71 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("New Paragraph Style", "نمط فقرة جديد"), + ("New Character Style", "نمط حرف جديد"), ("Next Style:", "النمط التالي:"), ("[Same style]", "[النمط نفسه]"), ("Reset To Base", "إعادة التعيين إلى الأساس"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index aa4215745..412f72019 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("New Paragraph Style", "Nový styl odstavce"), + ("New Character Style", "Nový styl znaku"), ("Next Style:", "Další styl:"), ("[Same style]", "[Stejný styl]"), ("Reset To Base", "Obnovit podle základu"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index ab153084e..a872b7cf5 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("New Paragraph Style", "Nuovo stile di paragrafo"), + ("New Character Style", "Nuovo stile di carattere"), ("Next Style:", "Stile successivo:"), ("[Same style]", "[Stesso stile]"), ("Reset To Base", "Ripristina base"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 128a872f6..f96dad86b 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("New Paragraph Style", "აბზაცის ახალი სტილი"), + ("New Character Style", "სიმბოლოს ახალი სტილი"), ("Next Style:", "შემდეგი სტილი:"), ("[Same style]", "[იგივე სტილი]"), ("Reset To Base", "საბაზოზე დაბრუნება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 100168f3a..d116bde3b 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("New Paragraph Style", "Nowy styl akapitowy"), + ("New Character Style", "Nowy styl znakowy"), ("Next Style:", "Następny styl:"), ("[Same style]", "[Ten sam styl]"), ("Reset To Base", "Przywróć bazowy"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 3f2b2afe5..e9dd4d6de 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("New Paragraph Style", "Novo estilo de parágrafo"), + ("New Character Style", "Novo estilo de caractere"), ("Next Style:", "Próximo estilo:"), ("[Same style]", "[Mesmo estilo]"), ("Reset To Base", "Redefinir para base"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index e567ec5a3..9b844616e 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("New Paragraph Style", "Новый стиль абзаца"), + ("New Character Style", "Новый стиль символов"), ("Next Style:", "Следующий стиль:"), ("[Same style]", "[Тот же стиль]"), ("Reset To Base", "Сбросить до основы"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 33ebb766d..fb547efe3 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("New Paragraph Style", "Новий стиль абзацу"), + ("New Character Style", "Новий стиль символів"), ("Next Style:", "Наступний стиль:"), ("[Same style]", "[Той самий стиль]"), ("Reset To Base", "Скинути до основи"), From 95006e9e9e2fd18c0dff17d85a167cf7ad7c9d5b Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:44:12 +0200 Subject: [PATCH 058/207] Find/Change: Change Format, with a character or paragraph style find.change takes a Change Format: charStyle and paraStyle as well as the character attributes (attrs) it took. With a Change Format and an empty change, the found text stays and is only formatted, as InDesign does; without one an empty change still deletes. The style-apply code of style.paragraph.apply / style.character.apply moves into helpers that find.change shares. The Find/Change dialog has InDesign's Change Format box, with Specify Attributes (opening Change Format Settings over Find/Change: Style Options, then the character sections of Character Style Options) and Clear. An info icon beside Change to says an empty Change to keeps the text. Found in the Learn Skills Daily course, chapter 22. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/find.rs | 80 +++++++- crates/engine/src/cmd/style.rs | 99 +++++----- crates/ui-egui/src/dialogs.rs | 324 ++++++++++++++++++++++++++++++- crates/ui-egui/src/i18n.rs | 37 ++++ crates/ui-egui/src/i18n/ar.rs | 10 + crates/ui-egui/src/i18n/cs.rs | 10 + crates/ui-egui/src/i18n/it.rs | 10 + crates/ui-egui/src/i18n/ka.rs | 10 + crates/ui-egui/src/i18n/pl.rs | 10 + crates/ui-egui/src/i18n/pt_br.rs | 10 + crates/ui-egui/src/i18n/ru.rs | 10 + crates/ui-egui/src/i18n/uk.rs | 10 + crates/ui-egui/src/icons.rs | 11 ++ 14 files changed, 578 insertions(+), 54 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..fa44d189e 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** · Find/Change has InDesign's Change Format. Specify Attributes opens Change Format Settings: Style Options (the character and paragraph style to apply, [Any Style] for none), then Basic and Advanced Character Formats, Character Color, OpenType Features, Underline and Strikethrough Options. The box shows the format ("Character Style: eggs"), and Change All gives it to the changed text. With a Change Format and nothing in Change to, the found text stays and is only formatted, as InDesign's (an ⓘ beside Change to says so). `find.change` takes `charStyle` and `paraStyle`. Found in the Learn Skills Daily course, chapter 22. 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/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index 3ba6970a1..d6a43855d 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -61,18 +61,18 @@ pub fn specs() -> Vec { } ), cmd!(query "find.find", "Find", ["Edit", "Find/Change"], None, - "{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, change?|first?|attrs?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text}]", + "{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, change?|first?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text}]", has_doc, find), cmd!( "find.change", "Change All", ["Edit", "Find/Change"], None, - "{find, change, grep?, caseSensitive?, wholeWord?, scope?, story?, first?: bool (change only the first match), attrs?: {character attributes applied to changed text}} → {count}", + "{find, change, grep?, caseSensitive?, wholeWord?, scope?, story?, first?: bool (change only the first match), attrs?: {character attributes applied to changed text}, charStyle?: a character style applied to it, paraStyle?: a paragraph style applied to its paragraphs} → {count} — attrs, charStyle and paraStyle are the Change Format: with one and an empty change, the found text stays and is only formatted", has_doc, change ), - cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, caseSensitive?, wholeWord?, scope?: document|story|selection, story?, change?|first?|attrs?: ignored (find.change takes them; one request serves all three Find/Change commands)} — Selection starts at its original beginning; later calls advance within the scope", has_doc, find_next), + cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, caseSensitive?, wholeWord?, scope?: document|story|selection, story?, change?|first?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} — Selection starts at its original beginning; later calls advance within the scope", has_doc, find_next), ] } @@ -314,8 +314,20 @@ fn change(s: &mut Session, p: &Value) -> Result { } } super::text::validate_char_fill(&s.doc()?.doc, &over, "find.change")?; + let char_style = str_param(p, "charStyle").map(str::to_string); + let para_style = str_param(p, "paraStyle").map(str::to_string); + let styles = &s.doc()?.doc.styles; + if let Some(n) = char_style.as_ref().filter(|n| styles.char_style(n).is_none()) { + return Err(bad("find.change", format!("no character style `{n}`"))); + } + if let Some(n) = para_style.as_ref().filter(|n| styles.para(n).is_none()) { + return Err(bad("find.change", format!("no paragraph style `{n}`"))); + } + // InDesign: with a Change Format and nothing in Change to, the found text is only formatted. + let format_only = str_param(p, "change").unwrap_or("").is_empty() && (char_style.is_some() || para_style.is_some() || !over.is_empty()); let targets = scope(s, p)?.targets; s.edit(|d, sel| { + let styles = d.styles.clone(); let mut count = 0usize; for (sid, r) in targets { let Some(story) = d.story(sid) else { continue }; @@ -332,9 +344,22 @@ fn change(s: &mut Session, p: &Value) -> Result { .collect(); let Some(st) = d.story_mut(sid) else { continue }; for (range, new) in edits.iter().rev() { - st.replace(range.clone(), new); - if !over.is_empty() && !new.is_empty() { - st.format_chars(range.start..range.start + new.len(), |f| f.over.merge(&over)); + let changed = if format_only { + range.clone() + } else { + st.replace(range.clone(), new); + range.start..range.start + new.len() + }; + if !changed.is_empty() { + if let Some(n) = ¶_style { + super::style::apply_para_style(st, &styles, n, &changed, false); + } + if let Some(n) = &char_style { + super::style::apply_char_style(st, &styles, n, &changed); + } + if !over.is_empty() { + st.format_chars(changed, |f| f.over.merge(&over)); + } } count += 1; } @@ -433,6 +458,49 @@ mod tests { assert!(s.doc().unwrap().selection.text.is_some()); } + #[test] + fn change_format_styles_the_found_text_and_keeps_it() { + // The course's chapter 22: every lowercase "eggs" gets the character style "eggs", with + // Change to empty, so the words stay. + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let text = "Poached Eggs\nBreak eggs; cook until eggs are set."; + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": text})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + s.execute("style.character.create", &json!({"name": "eggs", "chars": {"fontStyle": "Bold", "fill": "C=15 M=100 Y=100 K=0"}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("find.change", &json!({"find": "eggs", "change": "", "caseSensitive": true, "charStyle": "eggs"})).unwrap(); + assert_eq!(r["count"], 2); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + let style_at = |s: &Session, at: usize| s.doc().unwrap().doc.story(sid).unwrap().format_after(at).style.clone(); + let story = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert_eq!(story.text, text, "the words stay"); + for at in [19, 36] { + assert_eq!(&story.text[at..at + 4], "eggs"); + assert_eq!(style_at(&s, at), "eggs"); + } + assert_eq!(style_at(&s, 8), designcraft_doc::NO_CHAR_STYLE, "case sensitive: not \"Eggs\""); + assert_eq!(style_at(&s, 25), designcraft_doc::NO_CHAR_STYLE, "only the found text"); + // A paragraph style goes to the found text's paragraphs, with attributes over the text. + s.execute("find.change", &json!({"find": "cook", "change": "", "paraStyle": "Method", "attrs": {"underline": true}})).unwrap(); + let story = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert_eq!((story.paras[0].style.as_str(), story.paras[1].style.as_str()), (designcraft_doc::BASIC_PARAGRAPH, "Method")); + assert_eq!(story.format_after(25).over.underline, Some(true)); + assert_eq!(story.format_after(19).style, "eggs", "the other text keeps its character style"); + // Change to and a Change Format: the new text is formatted. + s.execute("find.change", &json!({"find": "set", "change": "firm", "charStyle": "eggs"})).unwrap(); + assert_eq!(style_at(&s, s.doc().unwrap().doc.story(sid).unwrap().text.find("firm").unwrap()), "eggs"); + // No such style: refused, nothing changed. + let before = s.doc().unwrap().doc.clone(); + assert!(s.execute("find.change", &json!({"find": "eggs", "change": "", "charStyle": "Nope"})).is_err()); + assert!(s.execute("find.change", &json!({"find": "eggs", "change": "", "paraStyle": "Nope"})).is_err()); + assert_eq!(s.doc().unwrap().doc, before); + // With no Change Format, an empty Change to still deletes the found text. + s.execute("find.change", &json!({"find": "Break ", "change": ""})).unwrap(); + assert!(!s.doc().unwrap().doc.story(sid).unwrap().text.contains("Break")); + } + #[test] fn change_first_changes_one_match_in_the_whole_document() { for grep in [false, true] { diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index d1bd06ad0..9ae967613 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -763,45 +763,50 @@ fn apply_para(s: &mut Session, p: &Value) -> Result { let targets = super::text::format_targets_pub(s); s.edit(|d, _| { let styles = d.styles.clone(); - let (style_para, style_chars) = styles.resolve_para_style(&name); for t in &targets { - let r = &t.range; if let Some(st) = d.text_story_mut(t.story, t.cell) { - // Text selections are half-open: a selection ending at the next paragraph's - // start must not apply a style to that paragraph too. - let end = if r.is_empty() { r.start } else { r.end.saturating_sub(1) }; - let (first, last) = (st.para_at(r.start), st.para_at(end)); - st.format_paras(r.start..end, |f| { - f.style = name.clone(); - if clear { - f.para = ParaAttrs::default(); - f.chars = CharAttrs::default(); - } else { - f.para.retain_differences(&style_para); - f.chars.retain_differences(&style_chars); - } - }); - for (pi, range) in st.para_ranges().into_iter().enumerate().take(last.saturating_add(1)).skip(first) { - let Some(pf) = st.paras.get(pi) else { continue }; - let (_, base) = styles.resolve_para(pf); - let end = range.end.saturating_add(1).min(st.len()); - let range = if clear && !r.is_empty() { range.start.max(r.start)..end.min(r.end) } else { range.start..end }; - st.format_chars(range, |f| { - if clear { - f.over = CharAttrs::default(); - } else { - let mut inherited = base.clone(); - inherited.apply(&styles.char_style_attrs(&f.style)); - f.over.retain_differences(&inherited); - } - }); - } + apply_para_style(st, &styles, &name, &t.range, clear); } } ok() }) } +/// Paragraph style `name` applied to the paragraphs of `r` in `st`: the paragraphs' and their +/// text's overrides that the style now gives go (all of them with `clear`). +pub(super) fn apply_para_style(st: &mut designcraft_doc::Story, styles: &Styles, name: &str, r: &std::ops::Range, clear: bool) { + let (style_para, style_chars) = styles.resolve_para_style(name); + // Text selections are half-open: a selection ending at the next paragraph's + // start must not apply a style to that paragraph too. + let end = if r.is_empty() { r.start } else { r.end.saturating_sub(1) }; + let (first, last) = (st.para_at(r.start), st.para_at(end)); + st.format_paras(r.start..end, |f| { + f.style = name.to_string(); + if clear { + f.para = ParaAttrs::default(); + f.chars = CharAttrs::default(); + } else { + f.para.retain_differences(&style_para); + f.chars.retain_differences(&style_chars); + } + }); + for (pi, range) in st.para_ranges().into_iter().enumerate().take(last.saturating_add(1)).skip(first) { + let Some(pf) = st.paras.get(pi) else { continue }; + let (_, base) = styles.resolve_para(pf); + let end = range.end.saturating_add(1).min(st.len()); + let range = if clear && !r.is_empty() { range.start.max(r.start)..end.min(r.end) } else { range.start..end }; + st.format_chars(range, |f| { + if clear { + f.over = CharAttrs::default(); + } else { + let mut inherited = base.clone(); + inherited.apply(&styles.char_style_attrs(&f.style)); + f.over.retain_differences(&inherited); + } + }); + } +} + fn apply_char(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("style.character.apply", "missing name"))?.to_string(); if s.doc()?.doc.styles.char_style(&name).is_none() { @@ -812,25 +817,31 @@ fn apply_char(s: &mut Session, p: &Value) -> Result { let styles = d.styles.clone(); for t in &targets { if let Some(st) = d.text_story_mut(t.story, t.cell) { - for (pi, range) in st.para_ranges().into_iter().enumerate() { - let range = range.start.max(t.range.start)..range.end.saturating_add(1).min(st.len()).min(t.range.end); - if range.is_empty() { - continue; - } - let Some(pf) = st.paras.get(pi) else { continue }; - let (_, mut inherited) = styles.resolve_para(pf); - inherited.apply(&styles.char_style_attrs(&name)); - st.format_chars(range, |f| { - f.style = name.clone(); - f.over.retain_differences(&inherited); - }); - } + apply_char_style(st, &styles, &name, &t.range); } } ok() }) } +/// Character style `name` applied to `r` in `st`: the text's overrides that the style (over its +/// paragraph) now gives go. +pub(super) fn apply_char_style(st: &mut designcraft_doc::Story, styles: &Styles, name: &str, r: &std::ops::Range) { + for (pi, range) in st.para_ranges().into_iter().enumerate() { + let range = range.start.max(r.start)..range.end.saturating_add(1).min(st.len()).min(r.end); + if range.is_empty() { + continue; + } + let Some(pf) = st.paras.get(pi) else { continue }; + let (_, mut inherited) = styles.resolve_para(pf); + inherited.apply(&styles.char_style_attrs(name)); + st.format_chars(range, |f| { + f.style = name.to_string(); + f.over.retain_differences(&inherited); + }); + } +} + /// In a style, `startAt: null` is Continue from Previous Number: it is stored as an explicit /// "continue" so it also overrides a Start At the style would inherit. fn continue_numbering(p: Option<&Value>, para: &mut ParaAttrs) { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..6359a6c84 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2026,9 +2026,10 @@ fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let Some(mut d) = app.ui.dialog.clone() else { return }; - // Enter and Escape belong to an open popup (a menu, a name field) rather than the dialog. - // Read before the dialog draws: a popup closed by this frame's Escape is still open here. - let popup_open = egui::Popup::is_any_open(ctx); + // Enter and Escape belong to an open popup (a menu, a name field) or to Find/Change's Change + // Format Settings rather than the dialog. Read before the dialog draws: a popup closed by this + // frame's Escape is still open here. + let popup_open = egui::Popup::is_any_open(ctx) || d.fields.get(FORMAT_EDIT).is_some_and(Value::is_object); let mut result: Option = None; let alert_title = d.s("title"); let title = match d.id.as_str() { @@ -2280,11 +2281,24 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); ui.label(d.s("status")); } else { + let formatted = d.fields.get(CHANGE_FORMAT).and_then(Value::as_object).is_some_and(|f| !f.is_empty()); egui::Grid::new("fc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Find what:")); text_field(ui, &mut d, "find", 260.0); ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Change to:")); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Change to:")); + // InDesign's ⓘ: with a Change Format, an empty Change to keeps the text. + if formatted { + info_icon( + ui, + crate::i18n::tr( + &app.ui.language, + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + ), + ); + } + }); text_field(ui, &mut d, "change", 260.0); ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Search:")); @@ -2306,6 +2320,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { check(ui, &mut d, "caseSensitive", crate::i18n::tr(&app.ui.language, "Case sensitive")); check(ui, &mut d, "wholeWord", crate::i18n::tr(&app.ui.language, "Whole word")); }); + change_format_box(&app.ui.language, ui, &mut d); ui.horizontal(|ui| { let params0 = find_change_params(&d); let params = || params0.clone(); @@ -2871,6 +2886,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { if d.id == "newDocument" && result.is_none() { crate::new_document::preview(app, &mut d, ctx); } + if d.id == "findChange" && result.is_none() && d.fields.get(FORMAT_EDIT).is_some_and(Value::is_object) { + change_format_settings(app, ctx, &mut d); + } app.ui.dialog = Some(d); match result { Some(true) => { @@ -3026,9 +3044,217 @@ fn find_change_params(d: &Dialog) -> Value { if let Some(story) = d.fields.get("story") { p["story"] = story.clone(); } + // Change Format: find.change applies it to the changed text (the other commands ignore it). + if let Some(f) = d.fields.get(CHANGE_FORMAT) { + for key in ["charStyle", "paraStyle"] { + if let Some(v) = f.get(key).filter(|v| v.is_string()) { + p[key] = v.clone(); + } + } + if let Some(chars) = f.get("chars").filter(|c| c.as_object().is_some_and(|o| !o.is_empty())) { + p["attrs"] = chars.clone(); + } + } p } +/// Find/Change's Change Format ({charStyle?, paraStyle?, chars?}), as Change Format Settings set +/// it; absent when there is none. +const CHANGE_FORMAT: &str = "changeFormat"; +/// The fields of Change Format Settings while it is open (its section, `charStyle`, `paraStyle` +/// and `c.`), until OK makes them the Change Format. +const FORMAT_EDIT: &str = "formatEdit"; + +/// The sections of Change Format Settings: Style Options, then those of Character Style Options. +const FORMAT_SECTIONS: &[(&str, &str)] = &[ + ("style", "Style Options"), + ("chars", "Basic Character Formats"), + ("advanced", "Advanced Character Formats"), + ("color", "Character Color"), + ("openType", "OpenType Features"), + ("underline", "Underline Options"), + ("strikethrough", "Strikethrough Options"), +]; + +/// A small ⓘ whose tooltip is `tip`. +fn info_icon(ui: &mut egui::Ui, tip: &str) { + let (r, resp) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); + crate::icons::paint(ui.painter(), r, "info", crate::theme::Tokens::get(ui.ctx()).icon); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, tip)); + resp.on_hover_text(tip); +} + +/// What the Change Format box shows: "Character Style: eggs + …". +fn change_format_summary(lang: &str, format: Option<&Value>) -> String { + let Some(f) = format else { return String::new() }; + let mut parts = Vec::new(); + for (key, label) in [("charStyle", "Character Style:"), ("paraStyle", "Paragraph Style:")] { + if let Some(name) = f.get(key).and_then(Value::as_str) { + parts.push(format!("{} {}", crate::i18n::tr(lang, label), crate::i18n::style_name(lang, name))); + } + } + parts.extend( + f.get("chars").and_then(Value::as_object).into_iter().flatten().map(|(k, v)| format!("{}: {}", setting_label(lang, k), setting_text(v))), + ); + parts.join(" + ") +} + +/// Find/Change's Change Format, as InDesign's: a box with what Change All also gives the changed +/// text, a button that opens Change Format Settings and one that clears it. +fn change_format_box(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) { + ui.add_space(6.0); + crate::rtl::label(ui, crate::i18n::tr(lang, "Change Format:")); + ui.horizontal_top(|ui| { + let t = crate::theme::Tokens::get(ui.ctx()); + egui::Frame::new().fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(6.0).show(ui, |ui| { + ui.set_width(340.0); + ui.set_min_height(40.0); + let summary = change_format_summary(lang, d.fields.get(CHANGE_FORMAT)); + ui.add(egui::Label::new(egui::RichText::new(summary).color(t.text)).wrap()); + }); + ui.vertical(|ui| { + if crate::icons::button(ui, "find-format", 22.0, false, crate::i18n::tr(lang, "Specify attributes to change")).clicked() { + let f = d.fields.get(CHANGE_FORMAT).cloned().unwrap_or_default(); + let mut edit = Map::new(); + edit.insert("section".into(), json!("style")); + for key in ["charStyle", "paraStyle"] { + if let Some(v) = f.get(key) { + edit.insert(key.into(), v.clone()); + } + } + for (k, v) in f.get("chars").and_then(Value::as_object).into_iter().flatten() { + edit.insert(format!("c.{k}"), v.clone()); + } + d.fields.insert(FORMAT_EDIT.into(), Value::Object(edit)); + } + if crate::icons::button(ui, "trash", 22.0, false, crate::i18n::tr(lang, "Clear specified attributes")).clicked() { + d.fields.remove(CHANGE_FORMAT); + } + }); + }); +} + +/// Find/Change › Change Format Settings, over Find/Change as InDesign's: Style Options (the +/// character and paragraph style to apply, [Any Style] for none), then the character sections of +/// Character Style Options with every field blank (left as it is). OK makes it the Change Format. +fn change_format_settings(app: &mut DesignApp, ctx: &egui::Context, d: &mut Dialog) { + let lang = app.ui.language.clone(); + let Some(doc) = app.session.active().map(|st| st.doc.clone()) else { + d.fields.remove(FORMAT_EDIT); + return; + }; + let mut fd = + Dialog { id: "changeFormat".into(), fields: d.fields.get(FORMAT_EDIT).and_then(Value::as_object).cloned().unwrap_or_default(), unit: d.unit }; + let base = json!({}); + let cf = CharFields { base: &base, sparse: true }; + let popup_open = egui::Popup::is_any_open(ctx); + let mut result = None; + let host = egui::Id::new("change_format_settings"); + crate::modal_drag::modal(ctx, host, host).backdrop_color(egui::Color32::TRANSPARENT).show(ctx, |ui| { + let title_top = ui.cursor().top(); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Change Format Settings")).font(semibold(16.0))); + let margin = egui::Frame::popup(ui.style()).inner_margin; + let title_row = egui::Rect::from_min_max( + egui::pos2(ui.max_rect().left() - margin.leftf(), title_top - margin.topf()), + egui::pos2(ui.max_rect().right() + margin.rightf(), ui.min_rect().bottom()), + ); + crate::modal_drag::title_drag(ui, host, title_row); + ui.add_space(10.0); + ui.horizontal_top(|ui| { + // A fixed height: the separator would otherwise stretch the dialog to the window. + ui.set_min_height(380.0); + ui.set_max_height(380.0); + section_list(&lang, ui, &mut fd, false, FORMAT_SECTIONS); + ui.separator(); + ui.vertical(|ui| { + ui.set_min_width(440.0); + match fd.s("section").as_str() { + "chars" => basic_character_formats(app, ui, &mut fd, &cf), + "advanced" => advanced_character_formats(&lang, ui, &mut fd, &cf), + "color" => character_color(&lang, ui, &mut fd, &cf, &doc), + "openType" => open_type_features(&lang, ui, &mut fd, &cf), + "underline" => line_options(&lang, ui, &mut fd, &cf, &doc, "underline"), + "strikethrough" => line_options(&lang, ui, &mut fd, &cf, &doc, "strikethrough"), + _ => format_style_options(&lang, ui, &mut fd, &doc.styles), + } + }); + }); + ui.add_space(12.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add( + egui::Button::new(crate::rtl::widget(ui, egui::RichText::new(crate::i18n::tr(&lang, " OK ")).color(egui::Color32::WHITE))) + .fill(crate::theme::Tokens::get(ui.ctx()).accent_strong), + ) + .clicked() + || (!popup_open && ui.input(|i| i.key_pressed(egui::Key::Enter))) + { + result = Some(true); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, "Cancel"))).clicked() + || (!popup_open && ui.input(|i| i.key_pressed(egui::Key::Escape))) + { + result = Some(false); + } + }); + }); + match result { + Some(true) => { + d.fields.remove(FORMAT_EDIT); + let mut f = Map::new(); + for key in ["charStyle", "paraStyle"] { + if let Some(v) = fd.fields.get(key).filter(|v| v.is_string()) { + f.insert(key.into(), v.clone()); + } + } + let chars: Map = + fd.fields.iter().filter(|(_, v)| !v.is_null()).filter_map(|(k, v)| Some((k.strip_prefix("c.")?.to_string(), v.clone()))).collect(); + if !chars.is_empty() { + f.insert("chars".into(), Value::Object(chars)); + } + if f.is_empty() { + d.fields.remove(CHANGE_FORMAT); + } else { + d.fields.insert(CHANGE_FORMAT.into(), Value::Object(f)); + } + } + Some(false) => { + d.fields.remove(FORMAT_EDIT); + } + None => { + d.fields.insert(FORMAT_EDIT.into(), Value::Object(fd.fields)); + } + } +} + +/// Change Format Settings › Style Options: the character and paragraph style the changed text +/// gets ([Any Style]: as it is). +fn format_style_options(lang: &str, ui: &mut egui::Ui, fd: &mut Dialog, styles: &designcraft_doc::Styles) { + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Style Options")).font(semibold(13.0))); + ui.add_space(6.0); + let any = crate::i18n::tr(lang, "[Any Style]"); + let chars: Vec<&str> = styles.character.iter().map(|s| s.name.as_str()).collect(); + let paras: Vec<&str> = styles.paragraph.iter().map(|s| s.name.as_str()).collect(); + egui::Grid::new("format_style_options").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + for (key, label, names) in [("charStyle", "Character Style:", &chars), ("paraStyle", "Paragraph Style:", ¶s)] { + crate::rtl::label(ui, crate::i18n::tr(lang, label)); + let cur = fd.fields.get(key).and_then(Value::as_str).map(str::to_string); + let shown = cur.as_deref().map_or_else(|| any.to_string(), |n| crate::i18n::style_name(lang, n).to_string()); + egui::ComboBox::from_id_salt(("format_style", key)).selected_text(crate::rtl::widget(ui, shown)).width(240.0).show_ui(ui, |ui| { + if ui.selectable_label(cur.is_none(), crate::rtl::widget(ui, any)).clicked() { + fd.fields.remove(key); + } + for n in names.iter() { + if ui.selectable_label(cur.as_deref() == Some(*n), crate::rtl::widget(ui, crate::i18n::style_name(lang, n))).clicked() { + fd.fields.insert(key.into(), json!(n)); + } + } + }); + ui.end_row(); + } + }); +} + /// Apply the open dialog. pub fn confirm(app: &mut DesignApp) -> Result { let Some(d) = app.ui.dialog.take() else { return Err("no dialog open".into()) }; @@ -8259,6 +8485,96 @@ mod tests { } } + /// The course's chapter 22: Find/Change › Change Format. "eggs", case sensitive, nothing in + /// Change to; Change Format Settings › Style Options sets Character Style "eggs", and the box + /// shows it (with Change to's ⓘ). Escape leaves the settings, not Find/Change. Change All + /// gives every lowercase "eggs" the style and keeps the words; the trash clears the format. + #[test] + fn find_change_gives_the_found_text_a_change_format() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let text = "Poached Eggs\nBreak eggs; cook until eggs are set."; + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": text})).unwrap(); + let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); + app.run("style.character.create", json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); + app.run("app.findChange", json!({})).unwrap(); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("find".into(), json!("eggs")); + d.fields.insert("caseSensitive".into(), json!(true)); + } + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("change_format_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(4); + const TIP: &str = "With a Change Format and nothing in Change to, the found text stays and is only formatted"; + assert!(h.query_by_label("Change Format:").is_some()); + assert!(h.query_by_label(TIP).is_none(), "no ⓘ without a Change Format"); + h.get_by_label("Specify attributes to change").click(); + h.run_steps(3); + // Style Options is the section's name in the list and its heading. + for l in ["Change Format Settings", "Style Options", "Basic Character Formats", "Character Color"] { + assert!(h.query_all_by_label(l).next().is_some(), "{l}"); + } + // Style Options › Character Style: eggs. + h.get_all_by_value("[Any Style]").next().unwrap().click(); + h.run_steps(3); + h.get_by_label("eggs").click(); + h.run_steps(3); + h.get_all_by_label(" OK ").last().unwrap().click(); + h.run_steps(3); + let field = |h: &Harness<'_, DesignApp>, k: &str| h.state().ui.dialog.as_ref().unwrap().fields.get(k).cloned(); + assert_eq!((field(&h, CHANGE_FORMAT), field(&h, FORMAT_EDIT)), (Some(json!({"charStyle": "eggs"})), None)); + assert!(h.query_by_label("Character Style: eggs").is_some(), "the box shows it"); + assert!(h.query_by_label(TIP).is_some(), "Change to's ⓘ"); + // Escape closes Change Format Settings only, and keeps the Change Format. + h.get_by_label("Specify attributes to change").click(); + h.run_steps(3); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.id.as_str()), Some("findChange")); + assert_eq!((field(&h, CHANGE_FORMAT), field(&h, FORMAT_EDIT)), (Some(json!({"charStyle": "eggs"})), None)); + // Change All: the words stay, every lowercase "eggs" has the style. + h.get_by_label("Change All").click(); + h.run_steps(3); + assert_eq!(field(&h, "status"), Some(json!("2 replacement(s) made"))); + let story = h.state().session.doc().unwrap().doc.story(sid).cloned().unwrap(); + assert_eq!(story.text, text); + assert_eq!( + [8, 19, 36].map(|at| story.format_after(at).style.clone()), + [designcraft_doc::NO_CHAR_STYLE.to_string(), "eggs".into(), "eggs".into()] + ); + // The trash clears it. + h.get_by_label("Clear specified attributes").click(); + h.run_steps(3); + assert_eq!(field(&h, CHANGE_FORMAT), None); + assert!(h.query_by_label(TIP).is_none()); + } + + /// What Change Format Settings sets goes to find.change: a paragraph style and character + /// attributes as well as a character style; the box sums them up. + #[test] + fn a_change_format_goes_with_change_all() { + let mut d = Dialog::new("findChange", json!({"find": "cook"})); + d.fields.insert(CHANGE_FORMAT.into(), json!({"charStyle": "eggs", "paraStyle": "Method", "chars": {"underline": true}})); + let p = find_change_params(&d); + assert_eq!( + (p["charStyle"].clone(), p["paraStyle"].clone(), p["attrs"].clone()), + (json!("eggs"), json!("Method"), json!({"underline": true})) + ); + assert_eq!(change_format_summary("en", d.fields.get(CHANGE_FORMAT)), "Character Style: eggs + Paragraph Style: Method + Underline: on"); + assert!(find_change_params(&Dialog::new("findChange", json!({"find": "cook"}))).get("attrs").is_none()); + } + #[test] fn find_scope_dialog_control_effective_noops_retain_bounds() { for (field, value) in [("objectMode", json!(false)), ("story", Value::Null)] { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..0e8b47103 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -820,6 +820,43 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Center Content", ["Center Content", "Centrer le contenu", "Center Content", "Center Content", "内容居中"]), ("Change All", ["Change All", "Tout remplacer", "Change All", "Change All", "全部更改"]), ("Change to:", ["Change to:", "Remplacer par :", "Change to:", "Change to:", "更改为:"]), + ("Change Format:", ["Format ersetzen:", "Remplacer le format :", "Cambiar formato:", "置換形式 :", "更改格式:"]), + ( + "Change Format Settings", + [ + "Einstellungen für Format ersetzen", + "Paramètres de remplacement du format", + "Ajustes de cambio de formato", + "置換形式の設定", + "更改格式设置", + ], + ), + ("Style Options", ["Formatoptionen", "Options de style", "Opciones de estilo", "スタイルオプション", "样式选项"]), + ("[Any Style]", ["[Beliebiges Format]", "[Tout style]", "[Cualquier estilo]", "[任意のスタイル]", "[任意样式]"]), + ( + "Specify attributes to change", + [ + "Zu ersetzende Attribute festlegen", + "Indiquer les attributs à remplacer", + "Especificar atributos que cambiar", + "置換する属性を指定", + "指定要更改的属性", + ], + ), + ( + "Clear specified attributes", + ["Festgelegte Attribute löschen", "Effacer les attributs indiqués", "Borrar atributos especificados", "指定した属性を消去", "清除指定的属性"], + ), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + [ + "Mit einem Ersetzungsformat und leerem Feld „Ersetzen durch“ bleibt der gefundene Text erhalten und wird nur formatiert", + "Avec un format de remplacement et « Remplacer par » vide, le texte trouvé reste et seule sa mise en forme change", + "Con un formato de cambio y «Cambiar a» vacío, el texto encontrado se conserva y solo cambia su formato", + "置換形式があり置換文字列が空のときは、検索したテキストはそのままで書式だけが変わります", + "设置了更改格式且“更改为”为空时,找到的文本保持不变,只更改其格式", + ], + ), ("Character Style:", ["Character Style:", "Style de caractère :", "Character Style:", "Character Style:", "字符样式:"]), ("Checked by default", ["Checked by default", "Cochée par défaut", "Checked by default", "Checked by default", "默认选中"]), ("Choices", ["Choices", "Choix", "Choices", "Choices", "选项"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..33eff157d 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,16 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Change Format:", "تغيير التنسيق:"), + ("Change Format Settings", "إعدادات تغيير التنسيق"), + ("Style Options", "خيارات النمط"), + ("[Any Style]", "[أي نمط]"), + ("Specify attributes to change", "تحديد السمات المراد تغييرها"), + ("Clear specified attributes", "مسح السمات المحددة"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "مع تنسيق للتغيير وحقل «تغيير إلى» فارغ، يبقى النص الذي تم العثور عليه ويتغير تنسيقه فقط", + ), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..633978380 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,16 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Change Format:", "Změnit formát:"), + ("Change Format Settings", "Nastavení změny formátu"), + ("Style Options", "Volby stylu"), + ("[Any Style]", "[Libovolný styl]"), + ("Specify attributes to change", "Určit atributy ke změně"), + ("Clear specified attributes", "Vymazat určené atributy"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "Se změnou formátu a prázdným polem „Změnit na“ nalezený text zůstane a změní se jen jeho formát", + ), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..101167ea2 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,16 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Change Format:", "Modifica formato:"), + ("Change Format Settings", "Impostazioni di modifica formato"), + ("Style Options", "Opzioni stile"), + ("[Any Style]", "[Qualsiasi stile]"), + ("Specify attributes to change", "Specifica gli attributi da modificare"), + ("Clear specified attributes", "Cancella gli attributi specificati"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "Con un formato di modifica e «Sostituisci con» vuoto, il testo trovato resta e cambia solo la formattazione", + ), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..357350579 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,16 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Change Format:", "ფორმატის შეცვლა:"), + ("Change Format Settings", "ფორმატის შეცვლის პარამეტრები"), + ("Style Options", "სტილის პარამეტრები"), + ("[Any Style]", "[ნებისმიერი სტილი]"), + ("Specify attributes to change", "შესაცვლელი ატრიბუტების მითითება"), + ("Clear specified attributes", "მითითებული ატრიბუტების გასუფთავება"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "ფორმატის შეცვლით და ცარიელი „ჩანაცვლება“ ველით ნაპოვნი ტექსტი რჩება და მხოლოდ ფორმატი იცვლება", + ), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..e13ee7de2 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,16 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Change Format:", "Zmień format:"), + ("Change Format Settings", "Ustawienia zmiany formatu"), + ("Style Options", "Opcje stylu"), + ("[Any Style]", "[Dowolny styl]"), + ("Specify attributes to change", "Określ atrybuty do zmiany"), + ("Clear specified attributes", "Wyczyść określone atrybuty"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "Przy zmianie formatu i pustym polu „Zmień na” znaleziony tekst zostaje, a zmienia się tylko jego format", + ), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..c96ced85e 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,16 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Change Format:", "Alterar formato:"), + ("Change Format Settings", "Configurações de alteração de formato"), + ("Style Options", "Opções de estilo"), + ("[Any Style]", "[Qualquer estilo]"), + ("Specify attributes to change", "Especificar atributos a alterar"), + ("Clear specified attributes", "Limpar atributos especificados"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "Com um formato de alteração e “Alterar para” vazio, o texto encontrado permanece e só a formatação muda", + ), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..719a6d653 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,16 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Change Format:", "Заменить формат:"), + ("Change Format Settings", "Параметры замены формата"), + ("Style Options", "Параметры стиля"), + ("[Any Style]", "[Любой стиль]"), + ("Specify attributes to change", "Задать атрибуты для замены"), + ("Clear specified attributes", "Очистить заданные атрибуты"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "Если задан формат замены, а поле «Заменить на» пусто, найденный текст остаётся, меняется только его формат", + ), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..ed57d814c 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,16 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Change Format:", "Замінити формат:"), + ("Change Format Settings", "Параметри заміни формату"), + ("Style Options", "Параметри стилю"), + ("[Any Style]", "[Будь-який стиль]"), + ("Specify attributes to change", "Задати атрибути для заміни"), + ("Clear specified attributes", "Очистити задані атрибути"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "Якщо задано формат заміни, а поле «Замінити на» порожнє, знайдений текст лишається, змінюється лише його формат", + ), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index a90fe989e..29952ca1d 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -1040,6 +1040,17 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { pen.rect(3.0, 4.0, 17.0, 16.0); pen.line(&[(3.0, 7.0), (17.0, 7.0)]); } + // Find/Change's Specify Attributes: a letter under a magnifier. + "find-format" => { + pen.text(7.5, 8.5, "A", 12.0); + pen.circle(13.0, 13.0, 3.2); + pen.line(&[(15.3, 15.3), (18.0, 18.0)]); + } + "info" => { + pen.circle(10.0, 10.0, 7.0); + pen.fcircle(10.0, 6.6, 1.0); + pen.line(&[(10.0, 9.0), (10.0, 14.2)]); + } _ => { pen.rect(4.0, 4.0, 16.0, 16.0); } From 9792554e00c2fee5d74f2188b046a9515ea1dabd Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:19:32 +0200 Subject: [PATCH 059/207] Properties: a double-click on a Fill or Stroke chip opens the Color Picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As in InDesign, a double-click on Properties › Appearance's Fill or Stroke chip opens the Color Picker with that colour (a click still opens the swatches). For selected text, its own Fill and Stroke chips open it for the text. Found in the Learn Skills Daily course, chapter 23. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/panels/properties.rs | 79 +++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..3230114b1 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** · A double-click on a Fill or Stroke chip of Properties › Appearance opens the Color Picker with its colour, as in InDesign (a click still opens the swatches); for selected text, its own chips open it for the text. Found in the Learn Skills Daily course, chapter 23. 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/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 44acf6e2c..ea103f57c 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -790,6 +790,9 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { )); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Fill swatch")); swatch_menu(app, &resp, &doc, &i.fill, false, i.is_text && i.count == 1, false); + if resp.double_clicked() { + open_color_picker(app, &doc, &i.fill, false, false); + } crate::rtl::paint( ui.painter(), b.min + vec2(34.0, FIELD_H / 2.0), @@ -812,6 +815,9 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { )); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Stroke swatch")); swatch_menu(app, &resp, &doc, &i.stroke, true, i.is_text && i.count == 1, false); + if resp.double_clicked() { + open_color_picker(app, &doc, &i.stroke, true, false); + } if place(ui, sub(b, 34.0, 0.0, 56.0, FIELD_H), |ui| link_label(ui, crate::i18n::tr(&app.ui.language, "Stroke"), 56.0)).clicked() { show_panel(app, "stroke"); } @@ -871,6 +877,14 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { }); } +/// The Color Picker for a Fill or Stroke chip (a double-click on it, as in InDesign), with the +/// chip's colour: the objects', or with `text` the selected text's. +fn open_color_picker(app: &mut DesignApp, doc: &designcraft_doc::Document, swatch: &str, stroke: bool, text: bool) { + let mut fields = super::swatches::picker_fields(doc.resolve_color(swatch, 1.0), stroke); + fields["text"] = json!(text); + app.ui.dialog = Some(crate::dialogs::Dialog::new("colorPicker", fields)); +} + /// The Fill (or Stroke) popup of Properties › Appearance, as InDesign's: Apply To (a text frame's /// frame or its text), the tint, then the swatches. `cur` is the frame's swatch. With `text_only` /// (text selected) it colours the selected text. @@ -1219,7 +1233,13 @@ fn text_appearance_section(app: &mut DesignApp, ui: &mut Ui) { ui.painter().text(chip.center(), egui::Align2::CENTER_CENTER, "T", crate::theme::semibold(12.0), tc); } let resp = ui.interact(chip, ui.id().with(("tchip", k)), Sense::click()).on_hover_text(format!("{label}: {sw}")); + resp.widget_info(|| { + egui::WidgetInfo::labeled(egui::WidgetType::Button, true, if k == 0 { "Text fill swatch" } else { "Text stroke swatch" }) + }); swatch_menu(app, &resp, &doc, &sw, k != 0, false, true); + if resp.double_clicked() { + open_color_picker(app, &doc, &sw, k != 0, true); + } if k == 0 { crate::rtl::paint( ui.painter(), @@ -3300,6 +3320,65 @@ mod tests { assert_eq!(after, name, "clicking the swatch chip applies it"); } + /// The course's chapter 23: a double-click on Properties › Appearance › Fill opens the Color + /// Picker with the fill's colour, as InDesign's (a click opens the swatches). For selected + /// text, the text's Fill chip opens it for the text. + #[test] + fn double_clicking_a_fill_chip_opens_the_color_picker() { + let double_click = |h: &mut Harness<'_, DesignApp>, label: &str| { + let at = h.get_by_label(label).rect().center(); + h.hover_at(at); + h.step(); + for pressed in [true, false, true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + }; + // Frames 1 ms apart, so two clicks make a double-click. + let panel = |app: DesignApp| { + let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).with_step_dt(0.001).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + h + }; + let frame = || { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + app.run("object.fill", json!({"swatch": "C=0 M=0 Y=100 K=0"})).unwrap(); + app + }; + let mut h = panel(frame()); + double_click(&mut h, "Fill swatch"); + let d = h.state().ui.dialog.clone().expect("the Color Picker"); + assert_eq!((d.id.as_str(), d.s("target"), d.s("model"), d.b("text")), ("colorPicker", "fill".into(), "cmyk".into(), false)); + assert_eq!(d.fields["cmyk"], json!([0.0, 0.0, 100.0, 0.0])); + let mut h = panel(frame()); + double_click(&mut h, "Stroke swatch"); + assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.s("target")), Some("stroke".into())); + // Selected text: its own Fill chip. + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Lemon"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 5})).unwrap(); + let mut h = panel(app); + double_click(&mut h, "Text fill swatch"); + let d = h.state().ui.dialog.clone().expect("the Color Picker"); + assert_eq!((d.id.as_str(), d.s("target"), d.b("text")), ("colorPicker", "fill".into(), true)); + } + fn panel(app: DesignApp) -> Harness<'static, DesignApp> { let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( |ui, app: &mut DesignApp| { From 114e3682fd57901abcc36dcffb17d1dd8d0c6ab7 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:48:00 +0200 Subject: [PATCH 060/207] Properties: the test checks a double-click leaves no swatches popup open Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index ea103f57c..9989ec56a 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -3362,6 +3362,7 @@ mod tests { }; let mut h = panel(frame()); double_click(&mut h, "Fill swatch"); + assert!(h.query_by_label("Tint:").is_none(), "the swatches popup isn't left open too"); let d = h.state().ui.dialog.clone().expect("the Color Picker"); assert_eq!((d.id.as_str(), d.s("target"), d.s("model"), d.b("text")), ("colorPicker", "fill".into(), "cmyk".into(), false)); assert_eq!(d.fields["cmyk"], json!([0.0, 0.0, 100.0, 0.0])); From f0ae1c318add8067879d667495cec2b1d188dd9e Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:36:22 +0200 Subject: [PATCH 061/207] Color panel edits the gradient stop selected in the Gradient panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a gradient fill or stroke, the Color panel showed "[None] — choose a colour on the ramp", and a spectrum click replaced the whole gradient with a flat colour. As in InDesign, it now shows the stop selected in the Gradient panel (the stop before a selected midpoint) with its values, and its C/M/Y/K or R/G/B sliders and spectrum recolour that stop, keeping the others. Found in the Learn Skills Daily course, chapter 24. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + .../ui-egui/src/panels/color_panel_tests.rs | 39 ++++++++ crates/ui-egui/src/panels/swatches.rs | 96 +++++++++++++++++-- 3 files changed, 126 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..e59952263 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** · With a gradient fill or stroke, the Color panel shows the stop selected in the Gradient panel (the stop before a selected midpoint) with its values, and its C/M/Y/K or R/G/B sliders and spectrum colour that stop, keeping the others, as in InDesign. It showed "[None] — choose a colour on the ramp", and a spectrum click replaced the whole gradient with a flat colour. Found in the Learn Skills Daily course, chapter 24. 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/panels/color_panel_tests.rs b/crates/ui-egui/src/panels/color_panel_tests.rs index cd102b2e3..05d5899b6 100644 --- a/crates/ui-egui/src/panels/color_panel_tests.rs +++ b/crates/ui-egui/src/panels/color_panel_tests.rs @@ -81,3 +81,42 @@ fn a_typed_tint_is_kept_while_typing_and_applied_on_enter_or_leaving_the_field() h.run_steps(3); assert_eq!(fill_tint(h.state(), id), 0.0, "dragging the slider to the left end applies 0 %"); } + +/// The course's chapter 24: with a gradient fill, the Color panel shows the stop selected in the +/// Gradient panel (black, then) and its C/M/Y/K fields colour that stop, keeping the others. +#[test] +fn the_color_panel_edits_the_gradient_stop_selected_in_the_gradient_panel() { + use super::swatches::{GRAD_SEL, RampSel}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + // InDesign's default White → Black. + app.run("object.gradient", json!({})).unwrap(); + let mut h = harness(app); + h.run_steps(2); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(GRAD_SEL), RampSel::Stop(1))); + h.run_steps(3); + assert!(h.query_by_label("R=0 G=0 B=0").is_some(), "the black stop's values"); + // C: the topmost field. + let at = h + .query_all_by(|n| matches!(n.role(), Role::SpinButton | Role::TextInput)) + .map(|n| n.rect()) + .min_by(|a, b| a.min.y.total_cmp(&b.min.y)) + .unwrap() + .center(); + h.hover_at(at); + h.drag_at(at); + h.drop_at(at); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("100".into())); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let doc = h.state().session.doc().unwrap().doc.clone(); + let fill = &doc.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch; + let g = designcraft_color::swatch::resolve_gradient(&doc.swatches, fill).cloned().expect("still a gradient"); + assert_eq!(g.stops[1].color, designcraft_color::Color::cmyk(1.0, 0.0, 0.0, 1.0), "C 100 on the black stop"); + assert_eq!(g.stops[0].color, designcraft_color::Gradient::default().stops[0].color, "the white stop stays"); +} diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 3e7030e87..8ae422e06 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -397,6 +397,11 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }); app.ui.stroke_front = stroke; ui.data_mut(|d| d.insert_temp(mode_id, mode)); + // A gradient: the stop selected in the Gradient panel, as InDesign's Color panel shows it. + if !text && let Some(g) = designcraft_color::swatch::resolve_gradient(&doc.swatches, &name).cloned() { + gradient_stop(app, ui, &g, target, mode); + return; + } let sw = doc.swatch(&name).cloned(); let Some(c) = doc.resolve_color(&name, 1.0) else { ui.label(crate::rtl::widget( @@ -544,7 +549,79 @@ fn cmyk_ramp(k: usize, x: f32) -> egui::Color32 { } /// The spectrum ramp: hue across, light to dark down; click to apply. +/// The Color panel on a gradient fill (or stroke): the stop selected in the Gradient panel (the +/// stop before a selected midpoint), in the panel's colour mode, as InDesign's. Its fields and the +/// spectrum colour that stop. +fn gradient_stop(app: &mut DesignApp, ui: &mut egui::Ui, g: &designcraft_color::Gradient, target: &str, mode: ColorMode) { + use designcraft_color::Color; + let t = Tokens::get(ui.ctx()); + let sel: RampSel = ui.data(|d| d.get_temp(egui::Id::new(GRAD_SEL))).unwrap_or(RampSel::Stop(0)); + let i = match sel { + RampSel::Stop(i) | RampSel::Mid(i) => i, + } + .min(g.stops.len().saturating_sub(1)); + let Some(stop) = g.stops.get(i) else { return }; + let c = stop.color; + let pct = |v: f32| (v * 100.0).round(); + let values = match c { + Color::Cmyk { c, m, y, k } => format!("C={} M={} Y={} K={}", pct(c), pct(m), pct(y), pct(k)), + Color::Gray { k } => format!("K={}", pct(k)), + Color::Rgb { .. } => { + let [r, g, b, _] = c.to_rgba8(1.0); + format!("R={r} G={g} B={b}") + } + }; + ui.label(egui::RichText::new(values).size(11.0).color(t.text_dim)); + let mut new = None; + match mode { + ColorMode::Cmyk => { + let mut v = c.to_cmyk(); + let mut done = false; + for (k, label) in ["C", "M", "Y", "K"].iter().enumerate() { + done |= channel(ui, label, &mut v[k], 100.0, "%", |x| cmyk_ramp(k, x)); + } + if done { + new = Some(Color::cmyk(v[0], v[1], v[2], v[3])); + } + } + ColorMode::Rgb => { + let mut v = c.to_rgb(); + let mut done = false; + for (k, label) in ["R", "G", "B"].iter().enumerate() { + done |= channel(ui, label, &mut v[k], 255.0, "", |x| { + let mut p = [0.0; 3]; + p[k] = x; + egui::Color32::from_rgb((p[0] * 255.0) as u8, (p[1] * 255.0) as u8, (p[2] * 255.0) as u8) + }); + } + if done { + new = Some(Color::rgb(v[0], v[1], v[2])); + } + } + ColorMode::Lab => { + let lab = c.to_lab(); + ui.label(egui::RichText::new(format!("L {:.0} a {:.0} b {:.0}", lab.l, lab.a, lab.b)).color(t.text_dim)); + } + } + if let Some([r, gr, b]) = spectrum_pick(ui) { + new = Some(Color::rgb(r, gr, b)); + } + if let Some(c) = new { + let mut stops = stops_json(g); + set_stop(&mut stops, i, "color", color_json(&c)); + let _ = app.run("object.gradient", json!({"stops": stops, "target": target})); + } +} + fn spectrum(app: &mut DesignApp, ui: &mut egui::Ui, target: &str) { + if let Some([r, g, b]) = spectrum_pick(ui) { + let text = super::colors_affect_text(app); + let _ = app.run("object.color", json!({"color": {"model": "rgb", "r": r, "g": g, "b": b}, "target": target, "text": text})); + } +} + +/// The spectrum ramp; a click on it gives that colour (RGB, 0–1, in 1/255 steps). +fn spectrum_pick(ui: &mut egui::Ui) -> Option<[f32; 3]> { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::click()); let at = |u: f32, v: f32| -> [f32; 3] { let c = egui::ecolor::Hsva::new(u, (v * 2.0).min(1.0), (2.0 - v * 2.0).min(1.0), 1.0).to_rgb(); @@ -561,14 +638,9 @@ fn spectrum(app: &mut DesignApp, ui: &mut egui::Ui, target: &str) { ui.painter().rect_filled(cell, 0.0, egui::Color32::from_rgb((c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8)); } } - if resp.clicked() - && let Some(p) = resp.interact_pointer_pos() - { - let c = at(((p.x - r.min.x) / r.width()).clamp(0.0, 1.0), ((p.y - r.min.y) / r.height()).clamp(0.0, 1.0)); - let text = super::colors_affect_text(app); - let q = |v: f32| (v * 255.0).round() / 255.0; - let _ = app.run("object.color", json!({"color": {"model": "rgb", "r": q(c[0]), "g": q(c[1]), "b": q(c[2])}, "target": target, "text": text})); - } + let p = resp.interact_pointer_pos().filter(|_| resp.clicked())?; + let c = at(((p.x - r.min.x) / r.width()).clamp(0.0, 1.0), ((p.y - r.min.y) / r.height()).clamp(0.0, 1.0)); + Some(c.map(|v| (v * 255.0).round() / 255.0)) } /// A swatch dragged out of the Swatches panel (the Gradient panel's ramp takes it as a stop). @@ -621,9 +693,13 @@ fn thumbnail(p: &egui::Painter, r: egui::Rect, g: &designcraft_color::Gradient, p.rect_stroke(r, 0.0, egui::Stroke::new(1.0, border), egui::StrokeKind::Outside); } +/// Temp-data key of what's selected on the Gradient panel's ramp ([`RampSel`]): the Color panel +/// edits that stop. +pub(super) const GRAD_SEL: &str = "grad_sel"; + /// What's selected on the ramp: a stop, or the midpoint after a stop. #[derive(Clone, Copy, PartialEq, Debug)] -enum RampSel { +pub(super) enum RampSel { Stop(usize), Mid(usize), } @@ -666,7 +742,7 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let is_gradient = cur.is_some(); let g = cur.unwrap_or_default(); let radial = g.kind == designcraft_color::GradientKind::Radial; - let sel_id = egui::Id::new("grad_sel"); + let sel_id = egui::Id::new(GRAD_SEL); let drag_id = egui::Id::new("grad_drag"); let mut sel: RampSel = ui.data(|d| d.get_temp(sel_id)).unwrap_or(RampSel::Stop(0)); let last = g.stops.len().saturating_sub(1); From 87675f5b0bfe8c127f1c4920939cf3382b294433 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:49:49 +0200 Subject: [PATCH 062/207] Status bar page box names the parent in view and lists the parents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While a parent was shown, the status bar's page box and Properties › Page still read page 1. As in InDesign, they now name the parent ("A-Parent"), and their lists have the parents after the pages: choosing a parent shows it, choosing a page leaves the parents for that page. Found in the Learn Skills Daily course, chapter 25. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 46 +++++++++++++ crates/ui-egui/src/chrome.rs | 86 +++++++++++++++++++++---- crates/ui-egui/src/panels/properties.rs | 13 ++-- 4 files changed, 128 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..1cead9f02 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** · While a parent is shown, the status bar's page box and Properties › Page name it ("A-Parent") instead of page 1, as InDesign's; their lists have the parents after the pages, and choosing one shows that parent (a page leaves the parents). Found in the Learn Skills Daily course, chapter 25. 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/canvas.rs b/crates/ui-egui/src/canvas.rs index 8d94f099f..505fd9bce 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -238,6 +238,52 @@ pub fn current_parent_page(app: &DesignApp) -> Option<(usize, usize)> { Some((pi, qi)) } +/// The page box's entries (the status bar's, and Properties › Page), as InDesign's: the +/// document's pages by name, then the parents by label ("A-Parent"). +pub struct PageBox { + pub names: Vec, + /// The entry in view: the parent shown while parents are edited, else the page. + pub current: usize, + /// How many of `names` are pages; the parents follow. + pub pages: usize, + parents: Vec, +} + +impl PageBox { + /// The name of the entry in view ("3", "A-Parent"). + pub fn current_name(&self) -> &str { + self.names.get(self.current).map_or("", String::as_str) + } +} + +pub fn page_box(app: &DesignApp) -> Option { + let st = app.session.active()?; + let pages = st.doc.page_count(); + let mut names: Vec = (0..pages).map(|i| app.session.page_label(i)).collect(); + names.extend(st.doc.parents.iter().map(|p| p.parent.as_ref().map(|i| i.label()).unwrap_or_default())); + let current = match current_parent_page(app) { + Some((pi, _)) => pages + pi, + None => current_page(app).unwrap_or(0).min(pages.saturating_sub(1)), + }; + Some(PageBox { names, current, pages, parents: st.doc.parents.iter().map(|p| p.id).collect() }) +} + +/// Shows entry `i` of `b`: a page (leaving the parents, if they are shown) or a parent. +pub fn page_box_go(app: &mut DesignApp, b: &PageBox, i: usize) { + if i < b.pages { + if app.session.active().is_some_and(|st| st.editing_parents) { + let _ = app.run("layout.parents.edit", json!({"on": false})); + // The canvas refits to the page's spread once it is centred. + if let Some(v) = app.view_mut() { + v.fitted = false; + } + } + go_to_page(app, i); + } else if let Some(id) = b.parents.get(i - b.pages) { + let _ = app.run("layout.parents.edit", json!({"parent": id.0})); + } +} + /// Scroll so absolute page `abs` is centred (keeps zoom). pub fn go_to_page(app: &mut DesignApp, abs: usize) { let Some(st) = app.session.active() else { return }; diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 0df5361f9..d4814c49c 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -776,22 +776,45 @@ pub fn status_bar(app: &mut DesignApp, ui: &mut egui::Ui) { if icons::button(ui, "prev", 14.0, false, crate::i18n::tr(&app.ui.language, "Previous Spread")).clicked() { crate::canvas::go_to_page(app, cur.saturating_sub(1)); } - let name = app.session.page_label(cur); + // The page box, as InDesign's: the page in view, or the parent while one is shown + // ("A-Parent"); its menu lists the pages, then the parents. + let pb = crate::canvas::page_box(app); + let name = pb.as_ref().map_or_else(String::new, |b| b.current_name().to_string()); let (fr, fresp) = ui.allocate_exact_size(vec2(80.0, 14.0), Sense::click()); ui.painter().rect(fr, 0.0, t.input, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); - ui.painter().text(fr.left_center() + vec2(5.0, 0.0), egui::Align2::LEFT_CENTER, &name, small.clone(), t.text); + // A long parent name is cut at the arrow. + ui.painter().with_clip_rect(egui::Rect::from_min_max(fr.min, egui::pos2(fr.max.x - 14.0, fr.max.y))).text( + fr.left_center() + vec2(5.0, 0.0), + egui::Align2::LEFT_CENTER, + &name, + small.clone(), + t.text, + ); ui.painter().text(fr.right_center() - vec2(5.0, 0.0), egui::Align2::RIGHT_CENTER, "▾", small.clone(), t.text); - egui::Popup::menu(&fresp).show(|ui| { - let names: Vec = (0..n).map(|i| app.session.page_label(i)).collect(); - egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { - for (i, nm) in names.iter().enumerate() { - if ui.button(nm).clicked() { - crate::canvas::go_to_page(app, i); - ui.close(); - } - } - }); + fresp.widget_info(|| { + let mut info = egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, "Page"); + info.current_text_value = Some(name.clone()); + info }); + let mut picked = None; + if let Some(b) = &pb { + egui::Popup::menu(&fresp).show(|ui| { + egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { + for (i, nm) in b.names.iter().enumerate() { + if i == b.pages && i > 0 { + ui.separator(); + } + if ui.selectable_label(i == b.current, nm).clicked() { + picked = Some(i); + ui.close(); + } + } + }); + }); + } + if let (Some(b), Some(i)) = (&pb, picked) { + crate::canvas::page_box_go(app, b, i); + } if icons::button(ui, "next", 14.0, false, crate::i18n::tr(&app.ui.language, "Next Spread")).clicked() { crate::canvas::go_to_page(app, (cur + 1).min(n - 1)); } @@ -1261,6 +1284,45 @@ mod tests { assert_eq!(h.state().app.ui.open_panel.as_deref(), Some("preflight")); } + /// The course's chapter 25: while a parent is shown, the status bar's page box names it + /// ("A-Parent"), as InDesign's. Its menu lists the pages, then the parents: a parent shows + /// that parent, a page leaves the parents for that page. + #[test] + fn the_page_box_names_the_parent_in_view_and_lists_the_parents() { + use egui::accesskit::Role; + let mut app = app(); + app.run("file.new", serde_json::json!({"pages": 4, "facingPages": true})).unwrap(); + app.run("layout.parents.new", serde_json::json!({"prefix": "B", "name": "Cover"})).unwrap(); + app.ui.dock_tab = "properties".into(); + let mut h = test_window::open(app, vec2(1200.0, 700.0)); + let shown = |h: &egui_kittest::Harness<'static, test_window::Window>| { + let b = h.get_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Page")); + b.value().unwrap_or_default() + }; + assert_eq!(shown(&h), "1"); + h.state_mut().app.run("layout.parents.edit", serde_json::json!({"on": true})).unwrap(); + h.run_steps(6); + assert_eq!(shown(&h), "A-Parent"); + // The menu: a parent. + h.get_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Page")).click(); + h.run_steps(3); + for name in ["1", "2", "3", "4", "A-Parent"] { + assert!(h.query_by_label(name).is_some(), "{name} is listed"); + } + h.get_by_label("B-Cover").click(); + h.run_steps(6); + assert_eq!(shown(&h), "B-Cover"); + assert_eq!(crate::canvas::current_parent_page(&h.state().app).map(|(i, _)| i), Some(1)); + // A page: the parents are left. + h.get_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Page")).click(); + h.run_steps(3); + h.get_by_label("3").click(); + h.run_steps(6); + assert!(!h.state().app.session.active().unwrap().editing_parents); + assert_eq!(crate::canvas::current_page(&h.state().app), Some(2)); + assert_eq!(shown(&h), "3"); + } + /// As in InDesign, double-clicking the preflight count (or its light) shows the panel. #[test] fn double_clicking_the_status_bar_error_count_opens_the_preflight_panel() { diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 44acf6e2c..b700fcddc 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -444,12 +444,13 @@ fn document_sections(app: &mut DesignApp, ui: &mut Ui) { // Page. section(ui, crate::i18n::tr(&app.ui.language, "Page"), true); let b = block(ui, FIELD_H); - let names: Vec = (0..d.page_count()).map(|i| app.session.page_label(i)).collect(); - let picked = place(ui, sub(b, 0.0, 0.0, 93.0, FIELD_H), |ui| { - widgets::dropdown_list(ui, &names[cur.min(names.len().saturating_sub(1))], 93.0, &names, Some(cur)) - }); - if let Some(pg) = picked { - crate::canvas::go_to_page(app, pg); + // As the status bar's page box: the page, or the parent shown ("A-Parent"); then the parents. + if let Some(pb) = crate::canvas::page_box(app) { + let picked = + place(ui, sub(b, 0.0, 0.0, 93.0, FIELD_H), |ui| widgets::dropdown_list(ui, pb.current_name(), 93.0, &pb.names, Some(pb.current))); + if let Some(i) = picked { + crate::canvas::page_box_go(app, &pb, i); + } } let (add, del) = place(ui, sub(b, 172.0, 0.0, 50.0, FIELD_H), |ui| { ui.spacing_mut().item_spacing.x = 4.0; From e91f74835062f8ac8ca4d75affb0e93e01d054a0 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:17:21 +0200 Subject: [PATCH 063/207] Insert Pages dialog as InDesign's; pages can go before a page or first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Insert Pages… opened the generic command form (Count, a 0-based After index, a parent id). It now opens InDesign's dialog: Pages; Insert After Page, Before Page, At Start of Document or At End of Document, with the page by name; and Parent ([None] or a parent). It is set to the page in view (or the page right-clicked) and that page's parent. `layout.pages.insert` takes `before` (0: at the start of the document); `after` and `before` together are refused. Found in the Learn Skills Daily course, chapter 25. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/layout.rs | 44 +++++++++- crates/ui-egui/src/dialogs.rs | 133 +++++++++++++++++++++++++++++ crates/ui-egui/src/i18n.rs | 6 ++ crates/ui-egui/src/i18n/ar.rs | 6 ++ crates/ui-egui/src/i18n/cs.rs | 6 ++ crates/ui-egui/src/i18n/it.rs | 7 ++ crates/ui-egui/src/i18n/ka.rs | 6 ++ crates/ui-egui/src/i18n/pl.rs | 6 ++ crates/ui-egui/src/i18n/pt_br.rs | 7 ++ crates/ui-egui/src/i18n/ru.rs | 6 ++ crates/ui-egui/src/i18n/uk.rs | 7 ++ crates/ui-egui/src/menus.rs | 5 ++ crates/ui-egui/src/panels/pages.rs | 6 +- 14 files changed, 240 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..e214f8fbe 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** · Insert Pages… opens InDesign's dialog: Pages, Insert (After Page, Before Page, At Start of Document, At End of Document) with the page by name, and Parent ([None] or a parent), set to the page in view and its parent. It was a form asking for a page index and a parent id, and pages could not go before the first page; `layout.pages.insert` takes `before` (0: at the start). Found in the Learn Skills Daily course, chapter 25. 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/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 615e7c1df..3ddaaf4c6 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -112,16 +112,22 @@ pub fn specs() -> Vec { "Insert Pages…", ["Layout", "Pages"], Some("Cmd+Shift+P"), - "{count?: 1, after?: page index (default: last), parent?: id|\"A-Parent\"|\"A\"|null}", + "{count?: 1, after?: page index (default: last), before?: page index (instead of `after`; 0: at the start), parent?: id|\"A-Parent\"|\"A\"|null}", has_doc, |s, p| { let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 9999) as usize; let st = s.doc()?; let n = st.doc.page_count(); - let after = p.get("after").and_then(Value::as_u64).map(|v| usize::try_from(v).unwrap_or(usize::MAX)).unwrap_or(n.saturating_sub(1)); + let index = |k: &str| p.get(k).and_then(Value::as_u64).map(|v| usize::try_from(v).unwrap_or(usize::MAX)); + // The page the new ones follow; none: they go first. + let after = match (index("after"), index("before")) { + (Some(_), Some(_)) => return Err(bad("layout.pages.insert", "give `after` or `before`, not both")), + (None, Some(b)) => b.checked_sub(1), + (a, None) => Some(a.unwrap_or(n.saturating_sub(1))), + }; let parent = parent_ref("layout.pages.insert", &st.doc, p.get("parent"))?.unwrap_or_else(|| st.doc.parents.first().map(|x| x.id)); s.edit(|d, _| { - let ids = d.insert_pages(Some(after), count, parent)?; + let ids = d.insert_pages(after, count, parent)?; Ok(json!({"pages": ids.len(), "total": d.page_count()})) }) } @@ -1852,6 +1858,38 @@ mod parent_margin_tests { assert!(s.execute("layout.marginsAndColumns", &json!({"parent": "Z", "margins": 10})).is_err()); } + /// Insert Pages before a page, or at the start of the document (`before: 0`), as InDesign's + /// dialog offers; `after` and `before` together are refused without an undo step. + #[test] + fn pages_go_in_before_a_page_or_at_the_start() { + let mut s = Session::new(); + // Facing: [1] [2 3]. + s.execute("file.new", &json!({"pages": 3})).unwrap(); + let b = s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap()["id"].as_u64().unwrap(); + let ids = |s: &Session| -> Vec { + let d = &s.doc().unwrap().doc; + (0..d.page_count()).map(|i| d.page(i).unwrap().id.0).collect() + }; + let parent = |s: &Session, i: usize| s.doc().unwrap().doc.page(i).unwrap().parent.map(|p| p.0); + let old = ids(&s); + s.execute("layout.pages.insert", &json!({"before": 0, "parent": "B"})).unwrap(); + let now = ids(&s); + assert_eq!(now.len(), 4); + assert_eq!(now[1..], old[..], "the old pages follow the new first page"); + assert_eq!(parent(&s, 0), Some(b)); + // Before page 4 (index 3, the old page 3): two pages without a parent. + s.execute("layout.pages.insert", &json!({"before": 3, "count": 2, "parent": null})).unwrap(); + let then = ids(&s); + assert_eq!(then.len(), 6); + assert_eq!(then[..3], now[..3]); + assert_eq!(then[5], old[2]); + assert_eq!((parent(&s, 3), parent(&s, 4)), (None, None)); + let undo = s.doc().unwrap().history.undo.len(); + assert!(s.execute("layout.pages.insert", &json!({"after": 0, "before": 1})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + assert_eq!(ids(&s), then); + } + #[test] fn applying_a_parent_gives_its_margins_and_local_margins_stay_local() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..13ce41a6e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -137,6 +137,56 @@ fn text_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32) { } } +/// Insert Pages…, after page `abs` and with that page's parent, as InDesign's. +pub(crate) fn open_insert_pages(app: &mut DesignApp, abs: usize) { + let Some(st) = app.session.active() else { return }; + let abs = abs.min(st.doc.page_count().saturating_sub(1)); + let parent = st.doc.page(abs).and_then(|p| p.parent).map_or_else(String::new, |id| id.0.to_string()); + app.ui.dialog = Some(Dialog::new("insertPages", json!({"count": 1, "insert": "after", "page": abs, "parent": parent}))); +} + +/// Insert Pages, as InDesign's: how many, where (after or before a page, at the start or the end +/// of the document) and with which parent. +fn insert_pages_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lang = app.ui.language.clone(); + let Some(st) = app.session.active() else { return }; + let pages: Vec = (0..st.doc.page_count()).map(|i| app.session.page_label(i)).collect(); + let mut parents = vec![(String::new(), crate::i18n::tr(&lang, "[None]").to_string())]; + parents.extend(st.doc.parents.iter().map(|p| (p.id.0.to_string(), p.parent.as_ref().map(|i| i.label()).unwrap_or_default()))); + egui::Grid::new("insert_pages").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(&lang, "Pages:")); + let mut n = d.n("count").unwrap_or(1.0).clamp(1.0, 9999.0) as u32; + if ui.add(egui::DragValue::new(&mut n).range(1..=9999)).changed() { + d.fields.insert("count".into(), json!(n)); + } + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&lang, "Insert:")); + ui.horizontal(|ui| { + let wheres = [("after", "After Page"), ("before", "Before Page"), ("start", "At Start of Document"), ("end", "At End of Document")]; + translated_combo(&lang, ui, d, "insert", &wheres); + // The page, by name: At Start and At End need none. + ui.add_enabled_ui(matches!(d.s("insert").as_str(), "after" | "before"), |ui| { + let cur = (d.n("page").unwrap_or(0.0).max(0.0) as usize).min(pages.len().saturating_sub(1)); + egui::ComboBox::from_id_salt("insert_page").selected_text(pages.get(cur).cloned().unwrap_or_default()).width(64.0).show_ui( + ui, + |ui| { + for (i, name) in pages.iter().enumerate() { + if ui.selectable_label(i == cur, name).clicked() { + d.fields.insert("page".into(), json!(i)); + } + } + }, + ); + }); + }); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&lang, "Parent:")); + let opts: Vec<(&str, &str)> = parents.iter().map(|(v, l)| (v.as_str(), l.as_str())).collect(); + combo(ui, d, "parent", &opts); + ui.end_row(); + }); +} + /// Extract once when opened. Cancel is read-only; confirmation adds exactly these colours. pub(crate) fn open_color_theme(app: &mut DesignApp, params: Value) { match app.run("color.extractTheme", params) { @@ -2037,6 +2087,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "newDocument" => crate::i18n::tr(&app.ui.language, "New Document"), "frameSize" => crate::i18n::tr(&app.ui.language, "Rectangle"), "goToPage" => crate::i18n::tr(&app.ui.language, "Go to Page"), + "insertPages" => crate::i18n::tr(&app.ui.language, "Insert Pages"), "insertTable" => crate::i18n::tr(&app.ui.language, "Create Table"), "cellOptions" => crate::i18n::tr(&app.ui.language, "Cell Options"), "createGuides" => crate::i18n::tr(&app.ui.language, "Create Guides"), @@ -2101,6 +2152,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let preferred_width: f32 = match d.id.as_str() { "alert" => 440.0, "closeDocument" | "updateLink" | "deletePages" | "newWorkspace" | "pagesPanelOptions" => 380.0, + "insertPages" => 440.0, "newDocument" => 960.0, "numberingSection" => 560.0, crate::effects_dialog::ID => 720.0, @@ -2759,6 +2811,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { STEP_AND_REPEAT => step_and_repeat_dialog(app, ui, &mut d), TOC => toc_dialog(app, ui, &mut d), id if id.starts_with("cmd:") => command_form(app, ui, &mut d), + "insertPages" => insert_pages_dialog(app, ui, &mut d), "goToPage" => { ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Page")); @@ -3077,6 +3130,20 @@ pub fn confirm(app: &mut DesignApp) -> Result { ) } "ruby" => app.run("type.ruby", style_cjk::ruby_params(&d)), + "insertPages" => { + let n = app.session.active().map_or(1, |st| st.doc.page_count()); + let page = d.n("page").unwrap_or(0.0).max(0.0) as usize; + let mut p = json!({"count": d.n("count").unwrap_or(1.0).clamp(1.0, 9999.0) as u64}); + match d.s("insert").as_str() { + "before" => p["before"] = json!(page), + "start" => p["before"] = json!(0), + "end" => p["after"] = json!(n.saturating_sub(1)), + _ => p["after"] = json!(page), + } + // A parent's id, or [None]. + p["parent"] = d.s("parent").parse::().map_or(Value::Null, |id| json!(id)); + app.run("layout.pages.insert", p) + } "goToPage" => { // A page name ("iv", "A-3"), a number, or "+n" for an absolute position. let typed = match d.fields.get("page") { @@ -7418,6 +7485,72 @@ mod tests { assert!(matches!(fill(&app, id), designcraft_color::Color::Rgb { .. }), "{:?}", fill(&app, id)); } + /// The course's chapter 25: Insert Pages as InDesign's. It opens after the page in view with + /// that page's parent; At Start of Document with B-Cover puts a B page first, and Before Page + /// with [None] puts a page without a parent before the page chosen. + #[test] + fn insert_pages_goes_before_a_page_or_at_the_start_with_the_chosen_parent() { + use egui::accesskit::Role; + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + // Facing: [1] [2 3] [4]. + app.run("file.new", json!({"pages": 4})).unwrap(); + let b = app.run("layout.parents.new", json!({"prefix": "B", "name": "Cover"})).unwrap()["id"].as_u64().unwrap(); + let ids = |app: &DesignApp| -> Vec { + let d = &app.session.doc().unwrap().doc; + (0..d.page_count()).map(|i| d.page(i).unwrap().id.0).collect() + }; + let old = ids(&app); + crate::menus::activate(&mut app, "layout.pages.insert", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().unwrap().id, "insertPages"); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("insert_pages_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(3); + for l in ["Pages:", "Insert:", "Parent:"] { + assert!(h.query_by_label(l).is_some(), "{l}"); + } + // The dropdown showing `shown`, then its item `choice`. + let pick = |h: &mut Harness<'_, DesignApp>, shown: &str, choice: &str| { + h.get_by(|n| n.role() == Role::ComboBox && n.value().as_deref() == Some(shown)).click(); + h.run_steps(2); + h.get_by_label(choice).click(); + h.run_steps(2); + }; + // After page 1 with A, at first; then At Start of Document, with B-Cover. + pick(&mut h, "After Page", "At Start of Document"); + pick(&mut h, "A-Parent", "B-Cover"); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().ui.dialog.is_none()); + let now = ids(h.state()); + assert_eq!(now[1..], old[..], "the new page is first"); + let parent = |app: &DesignApp, i: usize| app.session.doc().unwrap().doc.page(i).unwrap().parent.map(|p| p.0); + assert_eq!(parent(h.state(), 0), Some(b)); + // Before page 4, with [None]. + crate::dialogs::open_insert_pages(h.state_mut(), 0); + h.run_steps(3); + pick(&mut h, "After Page", "Before Page"); + pick(&mut h, "1", "4"); + pick(&mut h, "B-Cover", "[None]"); + h.get_by_label(" OK ").click(); + h.run_steps(3); + let then = ids(h.state()); + assert_eq!(then.len(), 6); + assert_eq!(then[..3], now[..3], "the pages before it stay"); + assert_eq!(then[4..], now[3..], "page 4 and those after it follow the new page"); + assert_eq!(parent(h.state(), 3), None); + } + #[test] fn ok_without_edits_leaves_the_fill_alone() { let (mut app, _, _, id) = picker(json!({"target": "fill", "hex": "#ff8000", "model": "cmyk", "cmyk": [0, 50, 100, 0]})); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..ddc27e2eb 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1846,6 +1846,12 @@ const TABLE: &[(&str, [&str; 5])] = &[ ["Insert Math Expression…", "Insérer une expression mathématique…", "Insert Math Expression…", "Insert Math Expression…", "插入数学公式…"], ), ("Insert Pages…", ["Insert Pages…", "Insérer des pages…", "Insert Pages…", "Insert Pages…", "插入页面…"]), + ("Insert:", ["Einfügen:", "Insérer :", "Insertar:", "挿入:", "插入:"]), + ("Parent:", ["Vorlagenseite:", "Gabarit :", "Página principal:", "親ページ:", "主页:"]), + ("After Page", ["Nach Seite", "Après la page", "Después de la página", "ページの後", "页面后"]), + ("Before Page", ["Vor Seite", "Avant la page", "Antes de la página", "ページの前", "页面前"]), + ("At Start of Document", ["Am Dokumentanfang", "Au début du document", "Al principio del documento", "ドキュメントの先頭", "文档开头"]), + ("At End of Document", ["Am Dokumentende", "À la fin du document", "Al final del documento", "ドキュメントの末尾", "文档末尾"]), ("Insert Variable", ["Insert Variable", "Insérer une variable", "Insert Variable", "Insert Variable", "插入变量"]), ("Inspect Document", ["Inspect Document", "Inspecter le document", "Inspect Document", "Inspect Document", "检查文档"]), ( diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..4a3a7b427 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("At End of Document", "في نهاية المستند"), + ("At Start of Document", "في بداية المستند"), + ("Before Page", "قبل الصفحة"), + ("After Page", "بعد الصفحة"), + ("Parent:", "الصفحة الأصلية:"), + ("Insert:", "إدراج:"), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..d299266f2 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("At End of Document", "Na konec dokumentu"), + ("At Start of Document", "Na začátek dokumentu"), + ("Before Page", "Před stránku"), + ("After Page", "Za stránku"), + ("Parent:", "Vzor:"), + ("Insert:", "Vložit:"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..b0d49feaa 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("At End of Document", "Alla fine del documento"), + ("At Start of Document", "All'inizio del documento"), + ("Before Page", "Prima della pagina"), + ("After Page", "Dopo la pagina"), + ("Parent:", "Pagina mastro:"), + ("Insert:", "Inserisci:"), + ("Insert Pages", "Inserisci pagine"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..fb802b878 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("At End of Document", "დოკუმენტის ბოლოს"), + ("At Start of Document", "დოკუმენტის დასაწყისში"), + ("Before Page", "გვერდამდე"), + ("After Page", "გვერდის შემდეგ"), + ("Parent:", "საბაზო გვერდი:"), + ("Insert:", "ჩასმა:"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..68a44a7cd 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("At End of Document", "Na końcu dokumentu"), + ("At Start of Document", "Na początku dokumentu"), + ("Before Page", "Przed stroną"), + ("After Page", "Po stronie"), + ("Parent:", "Strona wzorcowa:"), + ("Insert:", "Wstaw:"), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..50706f734 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("At End of Document", "No final do documento"), + ("At Start of Document", "No início do documento"), + ("Before Page", "Antes da página"), + ("After Page", "Após a página"), + ("Parent:", "Página principal:"), + ("Insert:", "Inserir:"), + ("Insert Pages", "Inserir páginas"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..4e4839a65 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("At End of Document", "В конец документа"), + ("At Start of Document", "В начало документа"), + ("Before Page", "Перед страницей"), + ("After Page", "После страницы"), + ("Parent:", "Шаблон:"), + ("Insert:", "Вставить:"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..ab11cf1c3 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("At End of Document", "У кінець документа"), + ("At Start of Document", "На початок документа"), + ("Before Page", "Перед сторінкою"), + ("After Page", "Після сторінки"), + ("Parent:", "Шаблон:"), + ("Insert:", "Вставити:"), + ("Insert Pages", "Вставити сторінки"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 79b8b2c4e..321991a5e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2505,6 +2505,11 @@ fn activate_inner(app: &mut DesignApp, id: &str, params: &Value) { crate::section_options::open(app); return; } + if params.is_null() && id == "layout.pages.insert" { + let cur = crate::canvas::current_page(app).unwrap_or(0); + crate::dialogs::open_insert_pages(app, cur); + return; + } if params.is_null() && id == "layout.createGuides" { crate::dialogs::open_create_guides(app); return; diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 4b2360f8f..4a65abd4d 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -646,7 +646,7 @@ fn empty_menu(app: &mut DesignApp, ui: &mut egui::Ui) { let cur = crate::canvas::current_page(app).unwrap_or(0); let item = |ui: &mut egui::Ui, label: &str| ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).clicked(); if item(ui, "Insert Pages…") { - app.ui.dialog = Some(crate::dialogs::Dialog::new("cmd:layout.pages.insert", json!({"after": cur, "count": 1}))); + crate::dialogs::open_insert_pages(app, cur); ui.close(); } if item(ui, "Move Pages…") { @@ -680,7 +680,7 @@ fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Docu let item = |ui: &mut egui::Ui, label: &str| ui.button(crate::rtl::widget(ui, tr(label))).clicked(); // InDesign's order: pages, parents, layouts and numbering, shuffling. if item(ui, "Insert Pages…") { - app.ui.dialog = Some(crate::dialogs::Dialog::new("cmd:layout.pages.insert", json!({"after": abs, "count": 1}))); + crate::dialogs::open_insert_pages(app, abs); ui.close(); } if item(ui, "Move Pages…") { @@ -1123,7 +1123,7 @@ mod tests { assert_eq!(h.state().session.doc().unwrap().doc.page_count(), 3); h.get_by_label("Insert Pages…").click(); h.run_steps(3); - assert!(h.state().ui.dialog.as_ref().is_some_and(|d| d.id == "cmd:layout.pages.insert")); + assert!(h.state().ui.dialog.as_ref().is_some_and(|d| d.id == "insertPages")); } #[test] From c0e38160d6f187becb656543a2bbde9b89bbf650 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:50:19 +0200 Subject: [PATCH 064/207] Spelling: "we", "if", "email" and curly-apostrophe contractions are known MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spell checker uses the Moby Hyphenator II word list, which lacks some very common short words (we, us, if, Mr, pm, vs) and most computer and web vocabulary (email, online, website, database, desktop, download...). Contractions typed with a typographer's apostrophe (don’t, we’re), which Use Typographer's Quotes produces by default, were looked up with the curly apostrophe and flagged too. Dynamic Spelling underlined most words of an ordinary sentence. `known` now reads U+2019 as a straight apostrophe (also in the user dictionary) and accepts a short sorted list of such common words, with the existing inflection rules applied to them. Found in the Learn Skills Daily course, chapter 26. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/spelling.rs | 135 ++++++++++++++++++++++++++++-- 2 files changed, 131 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..d4cf65258 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** · Spelling (Check Spelling and Dynamic Spelling) no longer flags everyday words the bundled hyphenation word list lacks: "we", "us", "if", titles such as "Mr", and words such as email, online, website, database, desktop and download, with their plurals and other forms. Contractions typed with a typographer's apostrophe (don’t, we’re), which Use Typographer's Quotes produces by default, count like their straight-apostrophe forms. Dynamic Spelling used to underline most words of an ordinary sentence. Found in the Learn Skills Daily course, chapter 26. 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/engine/src/cmd/spelling.rs b/crates/engine/src/cmd/spelling.rs index 914165387..c799f9b1f 100644 --- a/crates/engine/src/cmd/spelling.rs +++ b/crates/engine/src/cmd/spelling.rs @@ -81,13 +81,91 @@ pub fn specs() -> Vec { ] } +/// Common words the hyphenation word list (from the early 1990s) lacks: everyday short words +/// such as "we", "us" and "if", titles, and words that came with computers and the web. Sorted, +/// lowercase, base forms (the inflections below reach "emails", "downloaded", "bloggers"). +const COMMON: &[&str] = &[ + "ah", + "bitmap", + "blog", + "bluetooth", + "chatbot", + "checkbox", + "cybersecurity", + "database", + "dataset", + "desktop", + "download", + "dr", + "dropdown", + "ebook", + "ecommerce", + "eh", + "email", + "emoji", + "firewall", + "gigabyte", + "hashtag", + "hm", + "hmm", + "homepage", + "hyperlink", + "if", + "inbox", + "internet", + "jr", + "kilobyte", + "laptop", + "livestream", + "login", + "logout", + "malware", + "megabyte", + "metadata", + "mr", + "offline", + "ok", + "online", + "ow", + "pm", + "podcast", + "popup", + "printout", + "screenshot", + "selfie", + "smartphone", + "smartwatch", + "spreadsheet", + "sr", + "startup", + "terabyte", + "touchscreen", + "um", + "upload", + "us", + "username", + "voicemail", + "vs", + "we", + "webinar", + "webmaster", + "webpage", + "website", + "wifi", + "workflow", + "yay", +]; + /// Is `w` (lowercase) a known word, allowing common inflections of dictionary stems? pub fn known(dict: &Dictionary, user: &[String], w: &str) -> bool { - if w.len() <= 1 || dict.get(w).is_some() || user.iter().any(|u| u == w) { + // A typographer's apostrophe (don’t, we’re) is the word list's straight one. + let w = &w.replace('’', "'"); + let has = |w: &str| dict.get(w).is_some() || COMMON.binary_search(&w).is_ok(); + if w.len() <= 1 || has(w) || user.iter().any(|u| u.replace('’', "'") == *w) { return true; } - let w = w.trim_end_matches("'s").trim_end_matches("’s"); - if dict.get(w).is_some() { + let w = w.trim_end_matches("'s"); + if has(w) { return true; } let stems: [(&str, &[&str]); 9] = [ @@ -105,14 +183,14 @@ pub fn known(dict: &Dictionary, user: &[String], w: &str) -> bool { if let Some(base) = w.strip_suffix(suf) { for add in adds { let cand = format!("{base}{add}"); - if cand.len() > 1 && dict.get(&cand).is_some() { + if cand.len() > 1 && has(&cand) { return true; } // Doubled consonant: "stopped" → "stop". if let Some(c) = base.chars().last() && base.len() > 2 && base.ends_with(&format!("{c}{c}")) - && dict.get(&base[..base.len() - c.len_utf8()]).is_some() + && has(&base[..base.len() - c.len_utf8()]) { return true; } @@ -253,6 +331,53 @@ mod tests { assert_eq!(bad, vec![6..10]); } + /// Everyday words the hyphenation list lacks aren't misspelled ("we", "us", "if", "email"…), + /// with their inflections, and neither are contractions typed with a typographer's + /// apostrophe, which Use Typographer's Quotes turns ' into. + #[test] + fn common_words_and_curly_apostrophes_are_known() { + let d = Dictionary::en_us(); + if d.is_empty() { + return; // data not bundled in this build + } + assert!(COMMON.is_sorted(), "COMMON is binary-searched"); + for w in [ + "we", + "us", + "if", + "ok", + "mr", + "email", + "emails", + "emailed", + "online", + "website", + "websites", + "downloaded", + "database", + "databases", + "blogger", + "don’t", + "can’t", + "i’m", + "we’re", + "they’ve", + "it’s", + "don't", + ] { + assert!(known(d, &[], w), "{w}"); + } + for w in ["dont", "wee’re", "emial"] { + assert!(!known(d, &[], w), "{w}"); + } + // A user word added with a typographer's apostrophe matches either form. + assert!(known(d, &["zorbo’s".into()], "zorbo's") && known(d, &["zorbo's".into()], "zorbo’s")); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "We don’t email us if we’re online."})).unwrap(); + assert_eq!(s.execute("spelling.check", &json!({})).unwrap(), json!([])); + } + #[test] fn check_finds_misspellings() { let mut s = Session::new(); From 1d11e0a8c41b9fa43d8537613295ec8c01759871 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:57:24 +0200 Subject: [PATCH 065/207] =?UTF-8?q?Preferences=20=E2=80=BA=20Interface:=20?= =?UTF-8?q?Color=20Theme=20swatches=20and=20the=20Tools=20panel's=20column?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preferences › Interface held only UI Size, and the interface theme could be changed only from Window › Interface Color Theme. As in InDesign, Interface now has Appearance, with a Color Theme swatch per theme (a click shows it at once, Cancel goes back to the theme Preferences opened with), and Panels, where Tools Panel sets a single or double column. UI Size moves to a UI Scaling section of its own, as in InDesign's list. Found in the Learn Skills Daily course, chapter 26. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/dialogs.rs | 126 ++++++++++++++++++++++++++++++- crates/ui-egui/src/i18n.rs | 3 + crates/ui-egui/src/i18n/ar.rs | 3 + crates/ui-egui/src/i18n/cs.rs | 3 + crates/ui-egui/src/i18n/it.rs | 3 + crates/ui-egui/src/i18n/ka.rs | 3 + crates/ui-egui/src/i18n/pl.rs | 3 + crates/ui-egui/src/i18n/pt_br.rs | 3 + crates/ui-egui/src/i18n/ru.rs | 3 + crates/ui-egui/src/i18n/uk.rs | 3 + crates/ui-egui/src/menus.rs | 4 + 12 files changed, 155 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..62a641894 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** · Preferences › Interface follows InDesign's: Appearance has a Color Theme swatch per interface theme (a click shows it at once, Cancel goes back) and Panels sets the Tools panel to a single or double column; UI Size moves to a UI Scaling section of its own. Interface held only UI Size, and the theme could be changed only from the Window menu. Found in the Learn Skills Daily course, chapter 26. 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..e73ac5c49 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1570,6 +1570,7 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he &[ ("general", "General"), ("interface", "Interface"), + ("uiScaling", "UI Scaling"), ("type", "Type"), ("advancedType", "Advanced Type"), ("composition", "Composition"), @@ -1590,6 +1591,7 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he &[ ("general", "General"), ("interface", "Interface"), + ("uiScaling", "UI Scaling"), ("type", "Type"), ("composition", "Composition"), ("spelling", "Spelling"), @@ -1809,6 +1811,40 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he } } "interface" => { + let lang = app.ui.language.clone(); + let t = crate::theme::Tokens::get(ui.ctx()); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Appearance")).font(semibold(12.0))); + // Color Theme: a swatch per theme, as InDesign's. A click shows it at once; + // Cancel goes back to the theme Preferences opened with. + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&lang, "Color Theme:")); + let cur = d.s("brightness"); + for b in crate::theme::Brightness::ALL { + let (r, resp) = ui.allocate_exact_size(egui::vec2(24.0, 24.0), egui::Sense::click()); + let on = cur == b.id(); + let swatch = r.shrink(4.0); + ui.painter().rect_filled(swatch, 1.0, crate::theme::Tokens::for_brightness(b).panel); + ui.painter().rect_stroke(swatch, 1.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + if on || resp.hovered() { + let ring = if on { t.accent_strong } else { t.field_border }; + ui.painter().rect_stroke(r.shrink(1.0), 2.0, egui::Stroke::new(2.0, ring), egui::StrokeKind::Inside); + } + let name = crate::i18n::tr(&lang, b.label()).to_string(); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, true, on, &name)); + if resp.on_hover_text(&name).clicked() && !on { + d.fields.insert("brightness".into(), json!(b.id())); + d.fields.insert("applyBrightness".into(), json!(b.id())); + } + } + }); + ui.add_space(8.0); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Panels")).font(semibold(12.0))); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&lang, "Tools Panel:")); + translated_combo(&lang, ui, d, "toolsColumns", &[("single", "Single Column"), ("double", "Double Column")]); + }); + } + "uiScaling" => { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "UI Scaling")).font(semibold(12.0))); ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "UI Size:")); @@ -2868,6 +2904,12 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { { d.fields.insert("status".into(), json!(e)); } + // So does Preferences › Interface › Color Theme, as InDesign's. + if let Some(b) = d.fields.remove("applyBrightness") + && let Err(e) = app.run("window.brightness", json!({"brightness": b})) + { + d.fields.insert("status".into(), json!(e)); + } if d.id == "newDocument" && result.is_none() { crate::new_document::preview(app, &mut d, ctx); } @@ -3011,10 +3053,18 @@ pub fn cancel(app: &mut DesignApp) { app.cancel_pdf_import(); if let Some(d) = d && d.id == "preferences" - && let Some(lang) = d.fields.get("openedLanguage").and_then(Value::as_str) - && lang != app.ui.language { - let _ = app.run("app.language", json!({"lang": lang})); + if let Some(lang) = d.fields.get("openedLanguage").and_then(Value::as_str) + && lang != app.ui.language + { + let _ = app.run("app.language", json!({"lang": lang})); + } + // The Color Theme shown while choosing goes back too. + if let Some(b) = d.fields.get("openedBrightness").and_then(Value::as_str) + && b != app.ui.brightness.id() + { + let _ = app.run("window.brightness", json!({"brightness": b})); + } } } @@ -3371,6 +3421,13 @@ pub fn confirm(app: &mut DesignApp) -> Result { }; app.run(view, json!({}))?; app.run("window.richBlack", json!({"on": d.b("richBlack")}))?; + // Interface: the theme (already showing, unless set without a click) and the Tools panel. + if let Some(b) = d.fields.get("brightness").and_then(Value::as_str).filter(|b| *b != app.ui.brightness.id()) { + app.run("window.brightness", json!({"brightness": b}))?; + } + if d.fields.contains_key("toolsColumns") { + app.ui.tools_double_column = d.s("toolsColumns") == "double"; + } if let Some(v) = d.n("uiScale") { app.run("window.uiScale", json!({"scale": v / 100.0}))?; } @@ -7418,6 +7475,69 @@ mod tests { assert!(matches!(fill(&app, id), designcraft_color::Color::Rgb { .. }), "{:?}", fill(&app, id)); } + /// The course's chapter 26: Preferences › Interface as InDesign's. A Color Theme swatch shows + /// its theme at once and Cancel goes back; OK keeps the theme and the Tools panel's columns. + /// UI Size is in a UI Scaling section of its own. + #[test] + fn preferences_interface_sets_the_color_theme_and_tools_panel() { + use crate::theme::Brightness; + use egui::accesskit::Role; + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let opened = app.ui.brightness; + let open = |app: &mut DesignApp| { + app.run("app.preferences", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!("interface")); + }; + open(&mut app); + let mut h = Harness::builder().with_size(egui::vec2(1100.0, 760.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("prefs_interface_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(3); + let swatch = + |h: &Harness<'_, DesignApp>, name: &str| h.get_by(|n| n.role() == Role::RadioButton && n.label().as_deref() == Some(name)).click(); + for l in ["Appearance", "Color Theme:", "Panels", "Tools Panel:"] { + assert!(h.query_by_label(l).is_some(), "{l}"); + } + // A click shows the theme at once; Cancel goes back. + swatch(&h, "Light"); + h.run_steps(3); + assert_eq!(h.state().ui.brightness, Brightness::Light); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert!(h.state().ui.dialog.is_none()); + assert_eq!(h.state().ui.brightness, opened, "Cancel goes back to the theme Preferences opened with"); + // OK keeps the theme and the Tools panel's columns. + open(h.state_mut()); + h.run_steps(3); + swatch(&h, "Dark"); + h.run_steps(2); + h.get_by(|n| n.role() == Role::ComboBox && n.value().as_deref() == Some("Single Column")).click(); + h.run_steps(2); + h.get_by_label("Double Column").click(); + h.run_steps(2); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().ui.dialog.is_none()); + assert_eq!(h.state().ui.brightness, Brightness::Dark); + assert!(h.state().ui.tools_double_column); + // UI Size has a section of its own. + open(h.state_mut()); + h.state_mut().ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!("uiScaling")); + h.run_steps(3); + assert!(h.query_by_label("UI Size:").is_some()); + } + #[test] fn ok_without_edits_leaves_the_fill_alone() { let (mut app, _, _, id) = picker(json!({"target": "fill", "hex": "#ff8000", "model": "cmyk", "cmyk": [0, 50, 100, 0]})); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..b61e0257c 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2162,6 +2162,9 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ("Typical Display", ["Typical Display", "Affichage standard", "Typical Display", "Typical Display", "典型显示"]), ("UI Scaling", ["UI Scaling", "Mise à l'échelle de l'interface", "UI Scaling", "UI Scaling", "UI 缩放"]), + ("Color Theme:", ["Farbdesign:", "Thème de couleur :", "Tema de color:", "カラーテーマ:", "颜色主题:"]), + ("Tools Panel:", ["Werkzeugbedienfeld:", "Panneau Outils :", "Panel Herramientas:", "ツールパネル:", "工具面板:"]), + ("Double Column", ["Doppelte Spalte", "Double colonne", "Columna doble", "2 列", "双栏"]), ("Update Link", ["Update Link", "Mettre à jour le lien", "Update Link", "Update Link", "更新链接"]), ( "User Dictionary Words", diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..53f5ce1eb 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Double Column", "عمود مزدوج"), + ("Tools Panel:", "لوحة الأدوات:"), + ("Color Theme:", "سمة الألوان:"), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..4bc7ce13b 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Double Column", "Dvojitý sloupec"), + ("Tools Panel:", "Panel nástrojů:"), + ("Color Theme:", "Barevný motiv:"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..33fbe5787 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Double Column", "Colonna doppia"), + ("Tools Panel:", "Pannello Strumenti:"), + ("Color Theme:", "Tema colore:"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..9f6a8f5f1 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Double Column", "ორმაგი სვეტი"), + ("Tools Panel:", "ხელსაწყოების პანელი:"), + ("Color Theme:", "ფერთა თემა:"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..731683d7e 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Double Column", "Podwójna kolumna"), + ("Tools Panel:", "Panel narzędzi:"), + ("Color Theme:", "Motyw kolorów:"), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..af15d07ec 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Double Column", "Coluna dupla"), + ("Tools Panel:", "Painel Ferramentas:"), + ("Color Theme:", "Tema de cores:"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..4fff60238 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Double Column", "Двойной столбец"), + ("Tools Panel:", "Панель инструментов:"), + ("Color Theme:", "Цветовая тема:"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..ac7d599e1 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Double Column", "Подвійна колонка"), + ("Tools Panel:", "Панель інструментів:"), + ("Color Theme:", "Колірна тема:"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 79b8b2c4e..fbf5467f5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1411,6 +1411,10 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option "high", }); f["uiScale"] = json!((app.ui.ui_scale * 100.0).round()); + // Interface: Cancel goes back to the theme Preferences opened with. + f["brightness"] = json!(app.ui.brightness.id()); + f["openedBrightness"] = json!(app.ui.brightness.id()); + f["toolsColumns"] = json!(if app.ui.tools_double_column { "double" } else { "single" }); f["richBlack"] = json!(app.ui.rich_black); f["dynamicSpelling"] = json!(app.ui.dynamic_spelling); f["storyEditorSize"] = json!(app.ui.story_editor_size); From e5e3f091d34f8508d9cddd21c79ac1f42234aa23 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:02:24 +0200 Subject: [PATCH 066/207] =?UTF-8?q?Preferences=20=E2=80=BA=20File=20Handli?= =?UTF-8?q?ng:=20the=20recent=20items=20count=20is=20a=20whole=20number?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Number of Recent Items to Display read "20.0": its field's drag speed of 0.2 gave it a decimal place. It now shows "20", as InDesign's does. Found in the Learn Skills Daily course, chapter 26. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/dialogs.rs | 24 +++++++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..a03b7845b 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** · Preferences › File Handling shows Number of Recent Items to Display as a whole number ("20"; it read "20.0"). Found in the Learn Skills Daily course, chapter 26. 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..bed4cdf04 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1695,7 +1695,10 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Number of Recent Items to Display:")); let mut v = d.n("recentFilesMax").unwrap_or(20.0); - if ui.add(egui::DragValue::new(&mut v).range(0.0..=f64::from(designcraft_engine::MAX_RECENT_FILES)).speed(0.2)).changed() { + // A count: a whole number ("20", not "20.0"). + let field = + egui::DragValue::new(&mut v).range(0.0..=f64::from(designcraft_engine::MAX_RECENT_FILES)).speed(0.2).fixed_decimals(0); + if ui.add(field).changed() { d.fields.insert("recentFilesMax".into(), json!(v.round())); } }); @@ -7418,6 +7421,25 @@ mod tests { assert!(matches!(fill(&app, id), designcraft_color::Color::Rgb { .. }), "{:?}", fill(&app, id)); } + /// Preferences › File Handling shows the number of recent items as a whole number, as + /// InDesign's does ("20"; it read "20.0"). + #[test] + fn file_handling_shows_the_recent_items_count_as_a_whole_number() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("app.preferences", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!("files")); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 700.0)); + dialog_frame(&mut app, &ctx, screen, vec![]); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "Number of Recent Items to Display:"); + let count = app.ui.dialog.as_ref().unwrap().n("recentFilesMax").unwrap(); + assert_eq!(count.fract(), 0.0); + visible_label(&labels, screen, &format!("{count}")); + assert!(!labels.iter().any(|(t, _, _)| t.trim() == format!("{count:.1}")), "no decimal shown"); + } + #[test] fn ok_without_edits_leaves_the_fill_alone() { let (mut app, _, _, id) = picker(json!({"target": "fill", "hex": "#ff8000", "model": "cmyk", "cmyk": [0, 50, 100, 0]})); 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 067/207] 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 8e980b1f87b974af3c9933f5f8684fb21d28f3b9 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 21 Oct 2026 23:52:23 +0200 Subject: [PATCH 068/207] Paragraph rules: Gap Tint shows 100% at its default A full gap tint is left out of the rule's JSON, so the field read nothing even after a Gap Color was chosen. It now reads a missing gap tint as 100%. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 3d984fc13..9e03c6b09 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -5956,9 +5956,9 @@ fn paragraph_rules(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, prefix: & }); crate::rtl::label(ui, crate::i18n::tr(lang, "Gap Tint:")); ui.add_enabled_ui(gaps && gap != designcraft_color::swatch::NONE, |ui| { - if let Some(v) = - sized(ui, &mut |ui| crate::widgets::number(ui, &key("gapTint"), rule["gapTint"].as_f64().map(|t| t * 100.0), "%", 80.0, 0)) - { + // A full gap tint is left out of the rule's JSON: it reads 100%. + let gap_tint = rule["gapTint"].as_f64().unwrap_or(1.0) * 100.0; + if let Some(v) = sized(ui, &mut |ui| crate::widgets::number(ui, &key("gapTint"), Some(gap_tint), "%", 80.0, 0)) { d.fields.insert(key("gapTint"), json!((v / 100.0).clamp(0.0, 1.0))); } }); @@ -7651,6 +7651,29 @@ mod tests { assert_eq!((r.kind, r.weight, r.gap_color.as_str()), (designcraft_doc::StrokeType::Wavy, 3.0, "[Paper]")); } + /// A rule's Gap Tint shows 100% at its default (the rule's JSON leaves a full gap tint out). + #[test] + fn a_rules_default_gap_tint_shows_100() { + use egui_kittest::kittest::Queryable; + let (mut app, _) = ruled_paragraph(); + app.run( + "style.paragraph.edit", + json!({"name": "Ruled Child", "para": {"ruleBelow": {"type": {"kind": "dashed", "pattern": [4.0, 2.0]}, "gapColor": "[Paper]"}}}), + ) + .unwrap(); + 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": "rules", "rule": "ruleBelow"}))); + h.run_steps(4); + let label = h.get_by_label("Gap Tint:").rect(); + let field = h + .get_all_by(|n| n.role() == egui::accesskit::Role::TextInput) + .filter(|n| n.rect().center().x > label.max.x && (n.rect().center().y - label.center().y).abs() < 8.0) + .min_by(|a, b| a.rect().min.x.total_cmp(&b.rect().min.x)) + .and_then(|n| n.value()); + assert_eq!(field.as_deref(), Some("100%")); + } + #[test] fn paragraph_style_options_edit_one_rule_field() { let (mut app, rule) = ruled_paragraph(); From e108408c31cffb7ec86aa359cf2628d8ba873582 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 21 Oct 2026 23:58:07 +0200 Subject: [PATCH 069/207] Insert Pages: a placement given must be a page index; the dialog's page is clamped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `layout.pages.insert` refuses `after` and `before` given together (null counts as given) and a placement that isn't a page index, instead of inserting at the default position; the default applies only when neither is given. The dialog sends the page its menu shows (a page past the end is the last). The dialog title is in the shared table, and Georgian "Before Page" reads "გვერდის წინ". Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/layout.rs | 36 ++++++++++++++++++++++++++------- crates/ui-egui/src/dialogs.rs | 21 ++++++++++++++++++- crates/ui-egui/src/i18n.rs | 1 + crates/ui-egui/src/i18n/fr.rs | 1 - crates/ui-egui/src/i18n/ka.rs | 2 +- 5 files changed, 51 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 3ddaaf4c6..5e1879573 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -118,11 +118,21 @@ pub fn specs() -> Vec { let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 9999) as usize; let st = s.doc()?; let n = st.doc.page_count(); - let index = |k: &str| p.get(k).and_then(Value::as_u64).map(|v| usize::try_from(v).unwrap_or(usize::MAX)); + // A placement given (null included) must be a page index; only when neither is + // given do the pages go after the last. + if p.get("after").is_some() && p.get("before").is_some() { + return Err(bad("layout.pages.insert", "give `after` or `before`, not both")); + } + let index = |k: &str| match p.get(k) { + None => Ok(None), + Some(v) => v + .as_u64() + .map(|v| Some(usize::try_from(v).unwrap_or(usize::MAX))) + .ok_or_else(|| bad("layout.pages.insert", format!("`{k}` must be a page index, not {v}"))), + }; // The page the new ones follow; none: they go first. - let after = match (index("after"), index("before")) { - (Some(_), Some(_)) => return Err(bad("layout.pages.insert", "give `after` or `before`, not both")), - (None, Some(b)) => b.checked_sub(1), + let after = match (index("after")?, index("before")?) { + (_, Some(b)) => b.checked_sub(1), (a, None) => Some(a.unwrap_or(n.saturating_sub(1))), }; let parent = parent_ref("layout.pages.insert", &st.doc, p.get("parent"))?.unwrap_or_else(|| st.doc.parents.first().map(|x| x.id)); @@ -1884,10 +1894,22 @@ mod parent_margin_tests { assert_eq!(then[..3], now[..3]); assert_eq!(then[5], old[2]); assert_eq!((parent(&s, 3), parent(&s, 4)), (None, None)); + // Refused without a change or an undo step: both placements, even one of them null, and a + // placement that isn't a page index. let undo = s.doc().unwrap().history.undo.len(); - assert!(s.execute("layout.pages.insert", &json!({"after": 0, "before": 1})).is_err()); - assert_eq!(s.doc().unwrap().history.undo.len(), undo); - assert_eq!(ids(&s), then); + for bad in [ + json!({"after": 0, "before": 1}), + json!({"after": 0, "before": null}), + json!({"after": null, "before": 1}), + json!({"before": -1}), + json!({"before": "0"}), + json!({"before": null}), + json!({"after": 1.5}), + ] { + assert!(s.execute("layout.pages.insert", &bad).is_err(), "{bad}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{bad}"); + assert_eq!(ids(&s), then, "{bad}"); + } } #[test] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 13ce41a6e..640888f24 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3132,7 +3132,8 @@ pub fn confirm(app: &mut DesignApp) -> Result { "ruby" => app.run("type.ruby", style_cjk::ruby_params(&d)), "insertPages" => { let n = app.session.active().map_or(1, |st| st.doc.page_count()); - let page = d.n("page").unwrap_or(0.0).max(0.0) as usize; + // The page the menu shows: a page past the end is the last. + let page = (d.n("page").unwrap_or(0.0).max(0.0) as usize).min(n.saturating_sub(1)); let mut p = json!({"count": d.n("count").unwrap_or(1.0).clamp(1.0, 9999.0) as u64}); match d.s("insert").as_str() { "before" => p["before"] = json!(page), @@ -7485,6 +7486,24 @@ mod tests { assert!(matches!(fill(&app, id), designcraft_color::Color::Rgb { .. }), "{:?}", fill(&app, id)); } + /// A page past the end shows as the last page in the dialog, and Before Page puts the new + /// page before that one rather than at the end. + #[test] + fn insert_pages_before_a_page_past_the_end_goes_before_the_last() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 4, "facingPages": false})).unwrap(); + let ids = |app: &DesignApp| { + let d = &app.session.doc().unwrap().doc; + (0..d.page_count()).map(|i| d.page(i).unwrap().id).collect::>() + }; + let old = ids(&app); + app.ui.dialog = Some(Dialog::new("insertPages", json!({"count": 1, "insert": "before", "page": 100, "parent": ""}))); + confirm(&mut app).unwrap(); + let now = ids(&app); + assert_eq!(now.len(), 5); + assert_eq!((now[..3].to_vec(), now[4]), (old[..3].to_vec(), old[3]), "the new page is fourth, before the old last page"); + } + /// The course's chapter 25: Insert Pages as InDesign's. It opens after the page in view with /// that page's parent; At Start of Document with B-Cover puts a B page first, and Before Page /// with [None] puts a page without a parent before the page chosen. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index ddc27e2eb..ee60b7d27 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1846,6 +1846,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ["Insert Math Expression…", "Insérer une expression mathématique…", "Insert Math Expression…", "Insert Math Expression…", "插入数学公式…"], ), ("Insert Pages…", ["Insert Pages…", "Insérer des pages…", "Insert Pages…", "Insert Pages…", "插入页面…"]), + ("Insert Pages", ["Seiten einfügen", "Insérer des pages", "Insertar páginas", "ページを挿入", "插入页面"]), ("Insert:", ["Einfügen:", "Insérer :", "Insertar:", "挿入:", "插入:"]), ("Parent:", ["Vorlagenseite:", "Gabarit :", "Página principal:", "親ページ:", "主页:"]), ("After Page", ["Nach Seite", "Après la page", "Después de la página", "ページの後", "页面后"]), diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 78c5d5948..46558bb58 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -84,7 +84,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Insert Anchored Object", "Insérer un objet ancré"), ("Insert Cross-Reference", "Insérer une référence croisée"), ("Insert Math Expression", "Insérer une expression mathématique"), - ("Insert Pages", "Insérer des pages"), ("Internet Standard RGB (sRGB)", "RVB standard Internet (sRGB)"), ("Join the ArtCraft Discord", "Rejoindre le Discord d'ArtCraft"), ("Join the community", "Rejoindre la communauté"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index fb802b878..35561798a 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -33,7 +33,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paste", "ჩასმა"), ("At End of Document", "დოკუმენტის ბოლოს"), ("At Start of Document", "დოკუმენტის დასაწყისში"), - ("Before Page", "გვერდამდე"), + ("Before Page", "გვერდის წინ"), ("After Page", "გვერდის შემდეგ"), ("Parent:", "საბაზო გვერდი:"), ("Insert:", "ჩასმა:"), From c5bca17a9bec4c36c27bd288ec487ee5b1aabd10 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 00:02:56 +0200 Subject: [PATCH 070/207] New Paragraph Style: apply to the selection once the dialog knows the style The dialog notes that the created style exists (so a second OK edits it) before Apply Style to Selection runs, so a failure there can't lead to a duplicate style. The Justification check's doc comment is back above its function. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 491b82866..4c5ed581a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3643,8 +3643,6 @@ fn grep_error(p: &str) -> Option { designcraft_compose::grep::Grep::new(p, Default::default()).err().map(|e| e.to_string()) } -/// The Justification spacing check of Paragraph Style Options, against what the section showed: -/// the style's own values, or a new style's Based On. /// What paragraph style `style` resolves to when based on `based` (the Based On chosen in its /// dialog, which may not be its own yet): `based`'s chain with the style's own settings over it. fn style_over( @@ -3660,6 +3658,8 @@ fn style_over( }) } +/// The Justification spacing check of Paragraph Style Options, against what the section showed: +/// the style's own values, or a new style's Based On. fn check_paragraph_style_justification(app: &DesignApp, d: &Dialog) -> Result<(), String> { // After Reset To Base the style starts from its Based On (the one chosen here, if any). let base_of = |st: &designcraft_engine::DocState| { @@ -3726,10 +3726,6 @@ fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Resul } let r = app.run("style.paragraph.create", params)?; let style = r["name"].as_str().unwrap_or_default().to_string(); - // Apply Style to Selection. - if d.b("apply") && app.session.active().is_some_and(|st| st.selection.text.is_some()) { - app.run("style.paragraph.apply", json!({"name": style}))?; - } (r, style) } else { let mut params = json!({"name": name, "para": para, "chars": chars}); @@ -3750,9 +3746,14 @@ fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Resul }; // The style now exists under `style`: if a later step fails, the dialog stays open on it and // a second OK edits it rather than creating another one. + let created = d.b("new"); d.fields.insert("new".into(), json!(false)); d.fields.insert("name".into(), json!(&style)); d.fields.insert("rename".into(), json!(&style)); + // Apply Style to Selection. + if created && d.b("apply") && app.session.active().is_some_and(|st| st.selection.text.is_some()) { + app.run("style.paragraph.apply", json!({"name": style}))?; + } if d.fields.contains_key("x.tag") { let tag = d.s("x.tag"); let tag = if tag == "[Automatic]" { String::new() } else { tag }; From ea5a2f790faa516f574dc80fcef11da86d20e431 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 00:49:45 +0200 Subject: [PATCH 071/207] =?UTF-8?q?Preferences=20=E2=80=BA=20Interface:=20?= =?UTF-8?q?Single=20Column=20in=20Czech,=20Georgian,=20Polish=20and=20Russ?= =?UTF-8?q?ian?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tools Panel menu's Single Column fell back to English in these catalogs. Russian Double Column reads "Две колонки", the table's word for layout columns. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 3 ++- 4 files changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 4bc7ce13b..d4ebe17d3 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -32,6 +32,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Copy", "Kopírovat"), ("Paste", "Vložit"), ("Double Column", "Dvojitý sloupec"), + ("Single Column", "Jednoduchý sloupec"), ("Tools Panel:", "Panel nástrojů:"), ("Color Theme:", "Barevný motiv:"), ("Reverse Path", "Obrátit cestu"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 9f6a8f5f1..9bbdfdf87 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -32,6 +32,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Copy", "კოპირება"), ("Paste", "ჩასმა"), ("Double Column", "ორმაგი სვეტი"), + ("Single Column", "ერთმაგი სვეტი"), ("Tools Panel:", "ხელსაწყოების პანელი:"), ("Color Theme:", "ფერთა თემა:"), ("Inches Decimal", "ინჩები (ათობითი)"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 731683d7e..4db68518d 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -30,6 +30,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Copy", "Kopiuj"), ("Paste", "Wklej"), ("Double Column", "Podwójna kolumna"), + ("Single Column", "Pojedyncza kolumna"), ("Tools Panel:", "Panel narzędzi:"), ("Color Theme:", "Motyw kolorów:"), ("Clear", "Wyczyść"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 4fff60238..befbe21d4 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,7 +33,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), - ("Double Column", "Двойной столбец"), + ("Double Column", "Две колонки"), + ("Single Column", "Одна колонка"), ("Tools Panel:", "Панель инструментов:"), ("Color Theme:", "Цветовая тема:"), ("Clear", "Очистить"), From cb9d23dd521ccffa634402e0eda795320ff2dd89 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 00:51:01 +0200 Subject: [PATCH 072/207] Preferences test: the recent items count is 20 The test checked only that the count was whole, so another default would pass. It asserts 20, InDesign's default. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index bed4cdf04..a0ce16e47 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -7435,7 +7435,7 @@ mod tests { let labels = dialog_frame(&mut app, &ctx, screen, vec![]); visible_label(&labels, screen, "Number of Recent Items to Display:"); let count = app.ui.dialog.as_ref().unwrap().n("recentFilesMax").unwrap(); - assert_eq!(count.fract(), 0.0); + assert_eq!(count, 20.0, "InDesign's default"); visible_label(&labels, screen, &format!("{count}")); assert!(!labels.iter().any(|(t, _, _)| t.trim() == format!("{count:.1}")), "no decimal shown"); } From 4d968d8adc16abed149558c1aeba1243effd4ddc Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 01:06:12 +0200 Subject: [PATCH 073/207] 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 8f9c9dd6cc49331991ff6caa8a2aa1168799fa93 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 01:38:43 +0200 Subject: [PATCH 074/207] Color panel: the spectrum's doc comment back above `spectrum` Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/swatches.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 8ae422e06..02d5c51c1 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -548,7 +548,6 @@ fn cmyk_ramp(k: usize, x: f32) -> egui::Color32 { egui::Color32::from_rgb((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8) } -/// The spectrum ramp: hue across, light to dark down; click to apply. /// The Color panel on a gradient fill (or stroke): the stop selected in the Gradient panel (the /// stop before a selected midpoint), in the panel's colour mode, as InDesign's. Its fields and the /// spectrum colour that stop. @@ -613,6 +612,7 @@ fn gradient_stop(app: &mut DesignApp, ui: &mut egui::Ui, g: &designcraft_color:: } } +/// The spectrum ramp: hue across, light to dark down; click to apply. fn spectrum(app: &mut DesignApp, ui: &mut egui::Ui, target: &str) { if let Some([r, g, b]) = spectrum_pick(ui) { let text = super::colors_affect_text(app); From 1a11284bc72205a05e49ccc8baddeafff89368a2 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 01:39:01 +0200 Subject: [PATCH 075/207] Properties tests: the corner shape list's doc comment back on its test Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index e3e12257f..f3705f9b5 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -3388,8 +3388,6 @@ mod tests { assert!((r.x0 - 200.0).abs() < 0.5 && (r.y1 - 200.0).abs() < 0.5 && (r.x1 - 300.0).abs() < 0.5, "{r:?}"); } - /// The corner shape list shows each shape's glyph beside its name, as InDesign's; a row picks - /// the shape and the current one is marked. /// A row is as wide as its translated name needs: a long one isn't cut at a fixed width. #[test] fn preview_rows_fit_long_translated_names() { @@ -3408,6 +3406,8 @@ mod tests { assert!(widths.0 >= widths.1, "{widths:?}"); } + /// The corner shape list shows each shape's glyph beside its name, as InDesign's; a row picks + /// the shape and the current one is marked. #[test] fn the_corner_shape_list_has_glyph_rows_and_picks_a_shape() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From a74ea0d3c98c1f9bc3bf49342c5cf364f28018dd Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 09:44:13 +0200 Subject: [PATCH 076/207] Styles: a non-string Based On or Next Style is refused on creation too `style.paragraph.create` and `style.character.create` refuse a `basedOn` or `nextStyle` that is neither a style name nor null, instead of dropping it and creating the style without the reference, as `style.paragraph.edit` does. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/style.rs | 15 ++++++++++++--- crates/engine/src/tests.rs | 7 +++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 699b01ea8..cfd2bbbe9 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -841,8 +841,19 @@ fn continue_numbering(p: Option<&Value>, para: &mut ParaAttrs) { } } +/// The style `p[key]` names: none when absent or null; anything but a name is refused. +fn style_name_param(cmd: &str, p: &Value, key: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(n)) => Ok(Some(n.clone())), + Some(_) => Err(bad(cmd, format!("`{key}` is a style name or null"))), + } +} + fn create_para(s: &mut Session, p: &Value) -> Result { let base = str_param(p, "name").unwrap_or("Paragraph Style 1").to_string(); + let based_on = style_name_param("style.paragraph.create", p, "basedOn")?; + let next = style_name_param("style.paragraph.create", p, "nextStyle")?; let mut para: ParaAttrs = attrs("style.paragraph.create", "para", p, |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, &designcraft_doc::ParaProps::default()))?; continue_numbering(p.get("para"), &mut para); @@ -866,8 +877,6 @@ fn create_para(s: &mut Session, p: &Value) -> Result { chars = full; } } - let based_on = str_param(p, "basedOn").map(str::to_string); - let next = str_param(p, "nextStyle").map(str::to_string); s.edit(|d, _| { let name = Styles::unique_name(|n| d.styles.para(n).is_some(), &base); if let Some(b) = &based_on @@ -1231,7 +1240,7 @@ fn create_char(s: &mut Session, p: &Value) -> Result { chars = captured; } // Based on [None] is based on nothing. - let based_on = str_param(p, "basedOn").filter(|b| *b != designcraft_doc::NO_CHAR_STYLE).map(str::to_string); + let based_on = style_name_param("style.character.create", p, "basedOn")?.filter(|b| b != designcraft_doc::NO_CHAR_STYLE); s.edit(|d, _| { if let Some(b) = &based_on && d.styles.char_style(b).is_none() diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 67ad4626c..769051740 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1675,6 +1675,13 @@ fn paragraph_style_references_must_name_styles() { s.execute("style.paragraph.create", &json!({"name": "Body"})).unwrap(); assert!(s.execute("style.paragraph.create", &json!({"name": "Lost", "nextStyle": "Nowhere"})).is_err()); assert!(s.doc().unwrap().doc.styles.para("Lost").is_none()); + // On creation too, a reference that isn't a name or null is refused, not dropped. + for p in [json!({"name": "Lost", "nextStyle": true}), json!({"name": "Lost", "basedOn": 3})] { + assert!(s.execute("style.paragraph.create", &p).is_err(), "{p}"); + assert!(s.doc().unwrap().doc.styles.para("Lost").is_none(), "{p}"); + } + assert!(s.execute("style.character.create", &json!({"name": "Lost", "basedOn": ["Body"]})).is_err()); + assert!(s.doc().unwrap().doc.styles.char_style("Lost").is_none()); s.execute("style.paragraph.create", &json!({"name": "Self", "nextStyle": "Self"})).unwrap(); let before = s.doc().unwrap().doc.clone(); for p in [json!({"name": "Body", "basedOn": "Nowhere"}), json!({"name": "Body", "basedOn": 3}), json!({"name": "Body", "nextStyle": true})] { From 02749d3b7b9b9297b2c8615213416fc5c8cb93ba Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 09:57:50 +0200 Subject: [PATCH 077/207] Paragraph rules: Keep In Frame stops at the frame's edge; Text Color gaps import and export Keep In Frame holds a rule above inside the frame (its top), not below the top inset. A rule's gap colour in Text Color (`Text Color` or `$ID/Text Color`) imports as Text Color, as the rule's colour already did, and exports as a string property rather than as no swatch. Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/lib.rs | 5 +++-- crates/compose/src/tests.rs | 11 +++++++++++ crates/idml/src/export.rs | 7 ++++++- crates/idml/src/import.rs | 26 +++++++++++++++++--------- crates/idml/src/tests.rs | 12 ++++++++++++ 5 files changed, 49 insertions(+), 12 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 111910929..0e875e77b 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1873,8 +1873,9 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C .map(|l| if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) }) .unwrap_or((0.0, 0.0)); let (color, tint) = rule_color(r, &above); - // Keep In Frame: not above the frame's text area. - let top = frames.get(fi).map_or(f64::NEG_INFINITY, |f| f.area.y0); + // Keep In Frame: not above the frame, as InDesign's (its top, not the text area's + // below the top inset). + let top = frames.get(fi).map_or(f64::NEG_INFINITY, |f| f.area.y0 - if f.vertical { 0.0 } else { f.opts.inset[0] }); let y = if r.keep_in_frame && y - r.weight < top { top + r.weight } else { y }; let rect = Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y); ft.decos.extend(rule_decos(doc, r, rect, (color, tint), &above)); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 6ea012270..2fc53cc0b 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -3731,6 +3731,17 @@ fn paragraph_rules_have_a_type_a_gap_colour_and_keep_in_frame() { }; assert!(above(false).y0 < 50.0, "{:?}", above(false)); assert!((above(true).y0 - 50.0).abs() < 1e-6, "{:?}", above(true)); + // With a top inset the rule may still rise into it: raised out of the frame, it comes back + // to the frame's edge, not to the text area's below the inset. + let r = Rule { on: true, weight: 2.0, offset: 60.0, keep_in_frame: true, ..Default::default() }; + let para = ParaAttrs { rule_above: Some(r), ..Default::default() }; + let (mut d, sid, fid) = doc_with("Title", Rect::new(0.0, 50.0, 300.0, 200.0), para); + if let Some(tf) = d.item_mut(fid).and_then(|it| it.text_frame_mut()) { + tf.options.inset = [20.0, 0.0, 0.0, 0.0]; + } + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let rule = cs.frame(fid).unwrap().decos[0].rect; + assert!((rule.y0 - 50.0).abs() < 1e-6, "kept at the frame's top, in the inset: {rule:?}"); } /// A rule in Text Color takes the colour of the paragraph's text: the first character's for the diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index b247ac709..9f8298760 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1747,7 +1747,12 @@ impl<'a> Ex<'a> { el.set(&format!("{k}LeftIndent"), num(r.left_indent)); el.set(&format!("{k}RightIndent"), num(r.right_indent)); el.set(&format!("{k}Type"), names::stroke_type_out(&r.kind)); - el.set(&format!("{k}GapColor"), self.sw(&r.gap_color)); + // A gap in Text Color is a string property, as the rule's own colour is. + if r.gap_color == designcraft_doc::TEXT_COLOR { + props.push(p(&format!("{k}GapColor"), "string", designcraft_doc::TEXT_COLOR)); + } else { + el.set(&format!("{k}GapColor"), self.sw(&r.gap_color)); + } el.set(&format!("{k}GapTint"), pct(r.gap_tint as f64)); if k == "RuleAbove" { el.set("KeepRuleAboveInFrame", bool_s(r.keep_in_frame)); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index c60380a27..7ee7c0e8a 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1970,8 +1970,10 @@ impl<'r> Importer<'r> { if let Some(t) = self.para_setting(e, &format!("{k}Type")) { r.kind = names::stroke_type_in(t.trim()); } - if let Some(c) = self.para_setting(e, &format!("{k}GapColor")).filter(|c| c.contains('/')) { - r.gap_color = self.swatch_ref(c.trim()); + if let Some(c) = self.para_setting(e, &format!("{k}GapColor")) + && let Some(c) = self.rule_color_ref(&c) + { + r.gap_color = c; } if let Some(t) = tint(self.para_setting(e, &format!("{k}GapTint")).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite())) { r.gap_tint = t; @@ -1979,17 +1981,23 @@ impl<'r> Importer<'r> { if k == "RuleAbove" { r.keep_in_frame = self.para_setting(e, "KeepRuleAboveInFrame").is_some_and(|v| v.trim() == "true"); } - if let Some(c) = get(2) { - let c = c.trim(); - if c == designcraft_doc::TEXT_COLOR { - r.color = c.to_string(); - } else if c.contains('/') { - r.color = self.swatch_ref(c); - } + if let Some(c) = get(2).and_then(|c| self.rule_color_ref(&c)) { + r.color = c; } Some(r) } + /// A rule's colour or gap colour: Text Color (written `Text Color` or `$ID/Text Color`), or + /// a swatch reference; anything else leaves the default. + fn rule_color_ref(&mut self, c: &str) -> Option { + let c = c.trim(); + if c == designcraft_doc::TEXT_COLOR || c.strip_prefix("$ID/") == Some(designcraft_doc::TEXT_COLOR) { + Some(designcraft_doc::TEXT_COLOR.to_string()) + } else { + c.contains('/').then(|| self.swatch_ref(c)) + } + } + /// Paragraph setting `k` of `e` (a paragraph style or a range): its own, else from its /// paragraph style chain (`BasedOn` of a style, the applied style of a range). fn para_setting(&self, e: &El, k: &str) -> Option { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index ba0acb78d..924eba437 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -3733,6 +3733,9 @@ fn a_style_setting_only_a_rules_type_gap_or_keep_in_frame_overrides_it() { $ID/[No paragraph style] + + $ID/[No paragraph style]Text Color + "#, ); let d = import_idml(&fixture_with_table_styles(&styles, STORY)).unwrap(); @@ -3742,6 +3745,15 @@ fn a_style_setting_only_a_rules_type_gap_or_keep_in_frame_overrides_it() { let gap = style("Gap").rule_below.expect("the gap colour alone, with no on or weight in the chain"); assert!(!gap.on && gap.gap_color != designcraft_color::swatch::NONE, "{gap:?}"); assert!(style("Kept").rule_above.expect("Keep In Frame alone").keep_in_frame); + // Text Color, in either spelling, for a gap and a rule colour. + let text = style("TextGap"); + let (below, above) = (text.rule_below.expect("rule below"), text.rule_above.expect("rule above")); + assert_eq!(below.gap_color, designcraft_doc::TEXT_COLOR); + assert_eq!((above.gap_color.as_str(), above.color.as_str()), (designcraft_doc::TEXT_COLOR, designcraft_doc::TEXT_COLOR)); + // And it is written back as Text Color, not as no swatch. + let back = import_idml(&export_idml(&d)).unwrap(); + let below = back.styles.paragraph.iter().find(|s| s.name == "TextGap").unwrap().para.rule_below.clone().unwrap(); + assert_eq!(below.gap_color, designcraft_doc::TEXT_COLOR, "round-trips"); } #[test] From 638824413211b54f04b9e1d8da61eeb76cd3d4b2 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 10:00:12 +0200 Subject: [PATCH 078/207] Line tool: the readout measures the line as drawn The Line tool keeps the line it previews (in spread space, Shift and snapping included) for the readout, instead of measuring from the press to the pointer in canvas coordinates. Nothing shows before the first drag step. Co-Authored-By: Claude Opus 5.5 --- crates/tools/src/frame.rs | 14 ++++++++++---- crates/tools/src/tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/crates/tools/src/frame.rs b/crates/tools/src/frame.rs index de2d410a2..86f621c61 100644 --- a/crates/tools/src/frame.rs +++ b/crates/tools/src/frame.rs @@ -405,6 +405,9 @@ impl Tool for FrameTool { snap_free_line(cx, sr, sa, b, &mut self.guides) }; } + // The readout measures the line as drawn (Shift and snapping included), in + // spread space. + self.last = Some((spread_json(sr), Rect::from_points(sa, b))); out.push(Action::Preview("line.create".into(), json!({"spread": spread_json(sr), "a": [sa.x, sa.y], "b": [b.x, b.y]}))); } else { let end = cx.layout.to_spread(sr, ev.pos); @@ -458,10 +461,13 @@ impl Tool for FrameTool { } fn overlays(&self, cx: &ToolContext) -> Vec { - if let (true, Some(a)) = (self.active, self.start) { - // The frame being drawn (Shift, Alt and snapping included), in the document's units - // to two decimals, on two lines: InDesign's readout ("W: 3.13 cm", "H: 3.13 cm"). - let r = self.last.as_ref().map(|(_, r)| *r).unwrap_or_else(|| Rect::from_points(a, self.cur)); + // The frame or line being drawn (Shift, Alt and snapping included, in spread space), in the + // document's units to two decimals, on two lines: InDesign's readout ("W: 3.13 cm", + // "H: 3.13 cm"). Nothing before the first drag step. + if self.active + && self.start.is_some() + && let Some((_, r)) = self.last.as_ref() + { let units = &cx.doc.settings; let w = designcraft_geom::units::format_measure_prec(r.width().abs(), units.horizontal_units, 2); let h = designcraft_geom::units::format_measure_prec(r.height().abs(), units.vertical_units, 2); diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index d39fb6433..42543848e 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -93,6 +93,32 @@ fn frame_tool_readout_shows_the_size_in_the_document_units() { assert_eq!(texts, vec!["W: 6.08 cm\nH: 6.08 cm".to_string()]); } +/// The Line tool's readout measures the line as drawn: Shift makes it level, so it has no +/// height, wherever the pointer is. +#[test] +fn line_tool_readout_measures_the_line_as_drawn() { + let mut d = Document::new(&NewDocument::default()); + d.settings.horizontal_units = Unit::Centimeters; + d.settings.vertical_units = Unit::Centimeters; + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let mut t = create("line"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + let mut drag = PointerEvent::new(PointerKind::Drag, 182.35, 15.0); + drag.mods.shift = true; + t.pointer(&cx, &drag); + let texts: Vec = t + .overlays(&cx) + .into_iter() + .filter_map(|o| match o { + Overlay::Measure { text, .. } => Some(text), + _ => None, + }) + .collect(); + assert_eq!(texts.len(), 1, "{texts:?}"); + assert_eq!(texts[0], "W: 6.08 cm\nH: 0 cm", "level, as drawn, not 5 pt down to the pointer"); +} + #[test] fn selection_click_and_marquee() { let mut d = Document::new(&NewDocument::default()); From 88020e0914e7499706658e841f8a751f8566d323 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 11:03:03 +0200 Subject: [PATCH 079/207] =?UTF-8?q?Properties=20=E2=80=BA=20Text=20Wrap:?= =?UTF-8?q?=20one=20side=20edits=20that=20side=20on=20each=20object;=20the?= =?UTF-8?q?=20link=20follows=20the=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `object.textWrap`'s `offset` takes null for a side to keep each object's own offset there. Unlinked, Properties sends only the edited side, so with several objects selected each keeps its other three offsets. The link's state is kept per selection: another selection whose four offsets are the same starts linked. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/object.rs | 14 +++++++--- crates/ui-egui/src/panels/properties.rs | 36 +++++++++++++++++++++---- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 82f33a6d8..eb2144416 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -616,7 +616,7 @@ pub fn specs() -> Vec { "Text Wrap", ["Window"], Some("Cmd+Alt+W"), - "{mode?: none|boundingBox|contour|jumpObject|jumpToNextColumn (boundingBox when absent, unless side or contour is given: then unchanged), offset?: number|[t,l,b,r], invert?: bool, side?: bothSides|leftSide|rightSide|towardsSpine|awayFromSpine|largestArea, contour?: boundingBox|graphicFrame|sameAsClipping, content?: bool (the placed graphic instead of its frame; default: whether the selection is the frame's content), ids?}; offset or contour drops an imported custom wrap boundary", + "{mode?: none|boundingBox|contour|jumpObject|jumpToNextColumn (boundingBox when absent, unless side or contour is given: then unchanged), offset?: number|[t,l,b,r] (a null side keeps each object's own), invert?: bool, side?: bothSides|leftSide|rightSide|towardsSpine|awayFromSpine|largestArea, contour?: boundingBox|graphicFrame|sameAsClipping, content?: bool (the placed graphic instead of its frame; default: whether the selection is the frame's content), ids?}; offset or contour drops an imported custom wrap boundary", has_selection, text_wrap ), @@ -2104,8 +2104,10 @@ fn text_wrap(s: &mut Session, p: &Value) -> Result { None if side.is_some() || contour.is_some() => None, None => Some(WrapMode::BoundingBox), }; - let off = match p.get("offset") { - Some(Value::Number(n)) => Some([n.as_f64().unwrap_or(0.0); 4]), + // Per side [top, left, bottom, right]; a null side keeps each object's own offset there, so + // an edit of one side keeps the others of every object. + let off: Option<[Option; 4]> = match p.get("offset") { + Some(Value::Number(n)) => Some([n.as_f64(); 4]), Some(v) => serde_json::from_value(v.clone()).ok(), None => None, }; @@ -2126,7 +2128,11 @@ fn text_wrap(s: &mut Session, p: &Value) -> Result { w.mode = m; } if let Some(o) = off { - w.offsets = o; + for (w, v) in w.offsets.iter_mut().zip(o) { + if let Some(v) = v { + *w = v; + } + } } if let Some(v) = invert { w.invert = v; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 9f3e23eca..302855e06 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1064,9 +1064,14 @@ fn wrap_offsets(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { ui.add_space(4.0); let b = block(ui, 2.0 * FIELD_H + 5.0); let mut set = None; + // The link's state belongs to the selection: another selection starts linked again when its + // four offsets are the same. + let selected: Vec = app.session.active().map(|st| st.selection.items.iter().map(|i| i.0).collect()).unwrap_or_default(); + let link_id = egui::Id::new(("prop_wrap_linked", selected)); ui.add_enabled_ui(enabled, |ui| { // Linked while the four are the same, until the link is clicked. - let linked = temp_bool(ui, "prop_wrap_linked", offsets.iter().all(|v| (v - offsets[0]).abs() < 1e-9)); + let same = offsets.iter().all(|v| (v - offsets[0]).abs() < 1e-9); + let linked = ui.data(|d| d.get_temp::(link_id)).unwrap_or(same); // Offsets are [top, left, bottom, right]. for (k, key, icon, tip, x, y) in [ (0, "wrapTop", "margin-top", "Top", 0.0, 0.0), @@ -1083,11 +1088,12 @@ fn wrap_offsets(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { NumField::measure(key, Some(offsets[k]), u).width(76.0).spinner().range(-8000.0, 8000.0).enabled(enabled).show(ui) }); if let Some(v) = nv { - let mut o = offsets; + // Linked, all four; else this side alone, each object keeping its other offsets. + let mut o = [None; 4]; if linked { - o = [v; 4]; + o = [Some(v); 4]; } else { - o[k] = v; + o[k] = Some(v); } set = Some(o); } @@ -1096,7 +1102,7 @@ fn wrap_offsets(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, &tr("Make all settings the same"), vec2(22.0, 21.0)) .clicked() }) { - set_temp_bool(ui, "prop_wrap_linked", !linked); + ui.data_mut(|d| d.insert_temp(link_id, !linked)); } }); if let Some(o) = set { @@ -3471,6 +3477,26 @@ mod tests { let f = fields(&h); type_in(&mut h, f[2], "6pt"); assert_eq!(offsets(h.state()), [12.0, 12.0, 6.0, 12.0], "[top, left, bottom, right]"); + // Two objects with different offsets: a side typed in changes that side on each, and + // each keeps its other three. + let other = h.state_mut().run("frame.create", json!({"rect": [72, 300, 300, 400], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); + h.state_mut().run("object.textWrap", json!({"mode": "boundingBox", "offset": [3, 4, 5, 6], "ids": [other]})).unwrap(); + let of = |h: &Harness<'_, DesignApp>, id: u64| { + h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().wrap.offsets + }; + h.state_mut().run("selection.set", json!({"ids": [id, other]})).unwrap(); + h.run_steps(3); + let f = fields(&h); + type_in(&mut h, f[0], "9pt"); + assert_eq!((of(&h, id), of(&h, other)), ([9.0, 12.0, 6.0, 12.0], [9.0, 4.0, 5.0, 6.0]), "the top alone, on each"); + // An object whose offsets are the same starts linked, though the link was off before. + let third = h.state_mut().run("frame.create", json!({"rect": [320, 72, 500, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); + h.state_mut().run("object.textWrap", json!({"mode": "boundingBox", "offset": 4, "ids": [third]})).unwrap(); + h.state_mut().run("selection.set", json!({"ids": [third]})).unwrap(); + h.run_steps(3); + let f = fields(&h); + type_in(&mut h, f[2], "8pt"); + assert_eq!(of(&h, third), [8.0; 4], "linked again for this selection"); } const CYAN: &str = "C=100 M=0 Y=0 K=0"; From a1bbf1c7a105aeca70bef3856128018688eb3e7f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 11:26:09 +0200 Subject: [PATCH 080/207] =?UTF-8?q?Properties=20=E2=80=BA=20Text=20Wrap:?= =?UTF-8?q?=20an=20offset=20or=20Invert=20edit=20keeps=20each=20object's?= =?UTF-8?q?=20wrap=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `object.textWrap` takes `mode: null` for each object's own mode. Properties sends it with an offset or Invert edit, so objects with another wrap mode than the first keep theirs. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/object.rs | 4 +++- crates/ui-egui/src/panels/properties.rs | 15 ++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index eb2144416..1543b6d5d 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -616,7 +616,7 @@ pub fn specs() -> Vec { "Text Wrap", ["Window"], Some("Cmd+Alt+W"), - "{mode?: none|boundingBox|contour|jumpObject|jumpToNextColumn (boundingBox when absent, unless side or contour is given: then unchanged), offset?: number|[t,l,b,r] (a null side keeps each object's own), invert?: bool, side?: bothSides|leftSide|rightSide|towardsSpine|awayFromSpine|largestArea, contour?: boundingBox|graphicFrame|sameAsClipping, content?: bool (the placed graphic instead of its frame; default: whether the selection is the frame's content), ids?}; offset or contour drops an imported custom wrap boundary", + "{mode?: none|boundingBox|contour|jumpObject|jumpToNextColumn (boundingBox when absent, unless side or contour is given; null or those: each object's own, unchanged), offset?: number|[t,l,b,r] (a null side keeps each object's own), invert?: bool, side?: bothSides|leftSide|rightSide|towardsSpine|awayFromSpine|largestArea, contour?: boundingBox|graphicFrame|sameAsClipping, content?: bool (the placed graphic instead of its frame; default: whether the selection is the frame's content), ids?}; offset or contour drops an imported custom wrap boundary", has_selection, text_wrap ), @@ -2100,6 +2100,8 @@ fn text_wrap(s: &mut Session, p: &Value) -> Result { None => None, }; let mode: Option = match p.get("mode") { + // Null keeps each object's own mode (an edit of the offsets or Invert alone). + Some(Value::Null) => None, Some(v) => Some(serde_json::from_value(v.clone()).map_err(|_| bad("object.textWrap", "unknown mode"))?), None if side.is_some() || contour.is_some() => None, None => Some(WrapMode::BoundingBox), diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 302855e06..08bb64c04 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1043,7 +1043,8 @@ fn wrap_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { let enabled = i.wrap != "none"; let r = ui.add_enabled_ui(enabled, |ui| widgets::checkbox(ui, &mut inv, crate::i18n::tr(&app.ui.language, "Invert"))).inner; if r.changed() { - let _ = app.run("object.textWrap", json!({"mode": i.wrap, "invert": inv})); + // Each object keeps its own mode. + let _ = app.run("object.textWrap", json!({"mode": null, "invert": inv})); } }); wrap_offsets(app, ui, i); @@ -1106,7 +1107,8 @@ fn wrap_offsets(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { } }); if let Some(o) = set { - let _ = app.run("object.textWrap", json!({"mode": i.wrap, "offset": o})); + // Each object keeps its own mode: only the offsets change. + let _ = app.run("object.textWrap", json!({"mode": null, "offset": o})); } } @@ -3480,7 +3482,7 @@ mod tests { // Two objects with different offsets: a side typed in changes that side on each, and // each keeps its other three. let other = h.state_mut().run("frame.create", json!({"rect": [72, 300, 300, 400], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); - h.state_mut().run("object.textWrap", json!({"mode": "boundingBox", "offset": [3, 4, 5, 6], "ids": [other]})).unwrap(); + h.state_mut().run("object.textWrap", json!({"mode": "contour", "offset": [3, 4, 5, 6], "ids": [other]})).unwrap(); let of = |h: &Harness<'_, DesignApp>, id: u64| { h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().wrap.offsets }; @@ -3489,6 +3491,13 @@ mod tests { let f = fields(&h); type_in(&mut h, f[0], "9pt"); assert_eq!((of(&h, id), of(&h, other)), ([9.0, 12.0, 6.0, 12.0], [9.0, 4.0, 5.0, 6.0]), "the top alone, on each"); + let mode = + |h: &Harness<'_, DesignApp>, id: u64| h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().wrap.mode; + assert_eq!( + (mode(&h, id), mode(&h, other)), + (designcraft_doc::WrapMode::BoundingBox, designcraft_doc::WrapMode::Contour), + "each keeps its mode" + ); // An object whose offsets are the same starts linked, though the link was off before. let third = h.state_mut().run("frame.create", json!({"rect": [320, 72, 500, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); h.state_mut().run("object.textWrap", json!({"mode": "boundingBox", "offset": 4, "ids": [third]})).unwrap(); From 3537343622d387ad215da0de422c55fe5e0b57a3 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 11:38:06 +0200 Subject: [PATCH 081/207] Preferences: Units & Increments with no document open set new documents' units As in InDesign, Preferences with no document open has Units & Increments: the horizontal and vertical ruler units, the ruler origin and the cursor-key and kerning increments. They are kept with the application preferences (`prefs.set {defaultUnits}`) and new documents start with them; New Document opens in those units, and its [Default] card uses them. A document keeps its own Units & Increments once made, and Preferences with a document open still edits that document's. `file.new` without a preset or `units` uses the default units; units picked in New Document set both rulers unless they are the default horizontal ones, which keep the default vertical units. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/file.rs | 16 ++++++-- crates/engine/src/cmd/prefs.rs | 48 ++++++++++++++++++++++- crates/engine/src/lib.rs | 56 +++++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 62 ++++++++++++++++++++++++++++-- crates/ui-egui/src/menus.rs | 13 ++++++- crates/ui-egui/src/new_document.rs | 11 +++++- 7 files changed, 196 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a2352e86..264336ad8 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-22** · Preferences › Units & Increments with no document open, as in InDesign: the ruler units (horizontal and vertical), the ruler origin and the cursor-key and kerning increments set there are what new documents start with, and New Document opens in those units. They are kept with the application preferences (`prefs.set {defaultUnits}`); a document keeps its own once made. Reported by a reader who works in inches and had to change every file. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. - **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change. diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index da38b8132..f658d4b92 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -221,10 +221,11 @@ fn find_preset(s: &Session, name: &str) -> Option { } /// The settings `file.new` makes a document with for `p`: its preset, then the settings over it. +/// Without a preset or `units`, it is in the default units (Preferences with no document). fn new_document_settings(s: &Session, p: &Value) -> Result { let mut nd = match str_param(p, "preset") { Some(name) => find_preset(s, name).ok_or_else(|| bad("file.new", format!("unknown preset `{name}`")))?, - None => NewDocument::default(), + None => s.default_new_document(), }; apply_settings(&mut nd, p); check_size("file.new", &nd)?; @@ -235,12 +236,19 @@ fn file_new(s: &mut Session, p: &Value) -> Result { let mut nd = new_document_settings(s, p)?; s.untitled += 1; nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| format!("Untitled-{}", s.untitled)); - let d = Document::new(&nd); + let mut d = Document::new(&nd); + s.prefs.default_units.apply(&mut d.settings); let i = s.add_document(DocState::new(d, None)); Ok(json!({"index": i})) } impl Session { + /// What New Document starts with: the factory settings, in the default units (Preferences + /// with no document open). + pub fn default_new_document(&self) -> NewDocument { + NewDocument { units: self.prefs.default_units.horizontal_units, ..NewDocument::default() } + } + /// New Document's Preview: a document made as `file.new` would make it with `p`, shown while /// the New Document window is open. It replaces the previous preview document in its tab and /// takes no Untitled number, no undo step and no recovery entry. Settings `file.new` refuses @@ -259,7 +267,9 @@ impl Session { self.untitled += 1; } nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| self.next_untitled_title()); - let st = DocState::new(Document::new(&nd), None); + let mut d = Document::new(&nd); + self.prefs.default_units.apply(&mut d.settings); + let st = DocState::new(d, None); let uid = st.uid; match replace.and_then(|i| self.docs.get_mut(i).map(|d| (i, d))) { Some((i, d)) => { diff --git a/crates/engine/src/cmd/prefs.rs b/crates/engine/src/cmd/prefs.rs index 989494507..781d3ef13 100644 --- a/crates/engine/src/cmd/prefs.rs +++ b/crates/engine/src/cmd/prefs.rs @@ -50,12 +50,14 @@ pub fn specs() -> Vec { "Preferences", [], None, - "{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?, absolutePageNumbers?, highlightHj?, highlightKeeps?, highlightCustomTracking?, highlightSubstitutedFonts?, richBlackOutput?, favoriteFonts?: [family], recentFonts?: [family], showFontNamesInEnglish?, smartTextReflow?, autocorrect?, autocorrectList?: [[word, correction]], showAddedText?, showNoteAnchors?, cjkFeatures?: bool|null (CJK-only menu items and dialogs shown; null follows the interface language), linkTextFiles? (Create Links When Placing Text Files), recoveryMinutes?, recentFilesMax?: 0–100} → all application preferences", + "{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?, absolutePageNumbers?, highlightHj?, highlightKeeps?, highlightCustomTracking?, highlightSubstitutedFonts?, richBlackOutput?, favoriteFonts?: [family], recentFonts?: [family], showFontNamesInEnglish?, smartTextReflow?, autocorrect?, autocorrectList?: [[word, correction]], showAddedText?, showNoteAnchors?, cjkFeatures?: bool|null (CJK-only menu items and dialogs shown; null follows the interface language), linkTextFiles? (Create Links When Placing Text Files), recoveryMinutes?, recentFilesMax?: 0–100, defaultUnits?: {horizontalUnits?, verticalUnits?, rulerOrigin?, keyboardIncrement? (pt), kerningIncrement? (1/1000 em)} (Units & Increments of new documents)} → all application preferences", super::always, |s, p| { let cur = serde_json::to_value(&s.prefs).map_err(|e| bad("prefs.set", e.to_string()))?; let new = merge("prefs.set", cur, p, None)?; - s.prefs = serde_json::from_value(new).map_err(|e| bad("prefs.set", e.to_string()))?; + let prefs: crate::Prefs = serde_json::from_value(new).map_err(|e| bad("prefs.set", e.to_string()))?; + prefs.default_units.check().map_err(|e| bad("prefs.set", e))?; + s.prefs = prefs; s.prefs.trim_recent_files(); serde_json::to_value(&s.prefs).map_err(|e| bad("prefs.set", e.to_string())) } @@ -141,6 +143,48 @@ mod tests { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc.settings.horizontal_units, designcraft_geom::Unit::Picas); } + + /// Units & Increments set with no document open are what new documents start with, as in + /// InDesign: a reader who works in inches sets them once, not in every file. + #[test] + fn default_units_are_what_new_documents_start_with() { + use designcraft_geom::Unit; + let mut s = Session::new(); + let r = s + .execute( + "prefs.set", + &json!({"defaultUnits": {"horizontalUnits": "inches", "verticalUnits": "inches", "keyboardIncrement": 0.5, "rulerOrigin": "page"}}), + ) + .unwrap(); + assert_eq!(r["defaultUnits"]["horizontalUnits"], "inches"); + assert_eq!(r["defaultUnits"]["kerningIncrement"], 20.0, "the rest kept"); + s.execute("file.new", &json!({})).unwrap(); + let st = s.doc().unwrap().doc.settings.clone(); + assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Inches, Unit::Inches)); + assert_eq!((st.keyboard_increment, st.ruler_origin), (0.5, designcraft_doc::RulerOrigin::Page)); + assert!(!s.doc().unwrap().is_dirty(), "made so, not edited"); + // Inches across, millimetres down: New Document's units (the horizontal ones) keep the + // vertical preference; other units picked there set both rulers. + s.execute("prefs.set", &json!({"defaultUnits": {"verticalUnits": "millimeters"}})).unwrap(); + s.execute("file.new", &json!({"units": "inches"})).unwrap(); + let st = s.doc().unwrap().doc.settings.clone(); + assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Inches, Unit::Millimeters)); + s.execute("file.new", &json!({"units": "points"})).unwrap(); + let st = s.doc().unwrap().doc.settings.clone(); + assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Points, Unit::Points)); + // A preset keeps its own units. + s.execute("file.new", &json!({"preset": "A4"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.settings.horizontal_units, Unit::Millimeters); + // New Document opens with them; bad values are refused and change nothing. + assert_eq!(s.default_new_document().units, Unit::Inches); + for bad in [json!({"horizontalUnits": "furlongs"}), json!({"keyboardIncrement": 0}), json!({"kerningIncrement": -5})] { + assert!(s.execute("prefs.set", &json!({"defaultUnits": bad})).is_err(), "{bad}"); + } + assert_eq!(s.prefs.default_units.keyboard_increment, 0.5); + // Kept with the application preferences. + let saved: crate::Prefs = serde_json::from_slice(&serde_json::to_vec(&s.prefs).unwrap()).unwrap(); + assert_eq!(saved.default_units, s.prefs.default_units); + } } #[cfg(test)] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d16dc9dd1..9c5c64a35 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -243,6 +243,61 @@ pub struct Prefs { pub recent_files: Vec, /// Preferences › File Handling › Number of Recent Items to Display (0 keeps no list). pub recent_files_max: u32, + /// Preferences › Units & Increments set with no document open: what new documents start + /// with, as InDesign's application defaults. A document keeps its own once made. + pub default_units: DefaultUnits, +} + +/// Units & Increments for new documents (Preferences with no document open), in the keys +/// `document.preferences` uses. +#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DefaultUnits { + pub horizontal_units: designcraft_geom::Unit, + pub vertical_units: designcraft_geom::Unit, + pub ruler_origin: designcraft_doc::RulerOrigin, + /// Cursor Key, in points. + pub keyboard_increment: f64, + /// Kerning/Tracking, in 1/1000 em. + pub kerning_increment: f64, +} + +impl Default for DefaultUnits { + /// A new document's own settings. + fn default() -> Self { + let d = designcraft_doc::DocSettings::default(); + DefaultUnits { + horizontal_units: designcraft_geom::Unit::Picas, + vertical_units: designcraft_geom::Unit::Picas, + ruler_origin: d.ruler_origin, + keyboard_increment: d.keyboard_increment, + kerning_increment: d.kerning_increment, + } + } +} + +impl DefaultUnits { + /// Gives a new document made in `units` these defaults. Its vertical ruler follows the + /// preference while the document keeps the default horizontal units (units picked in New + /// Document set both rulers). + pub fn apply(&self, settings: &mut designcraft_doc::DocSettings) { + if settings.horizontal_units == self.horizontal_units { + settings.vertical_units = self.vertical_units; + } + settings.ruler_origin = self.ruler_origin; + settings.keyboard_increment = self.keyboard_increment; + settings.kerning_increment = self.kerning_increment; + } + + /// Refuses increments that aren't finite and positive (at most 1000). + pub fn check(&self) -> std::result::Result<(), String> { + for (name, v) in [("keyboardIncrement", self.keyboard_increment), ("kerningIncrement", self.kerning_increment)] { + if !(v.is_finite() && v > 0.0 && v <= 1000.0) { + return Err(format!("`defaultUnits.{name}` must be more than 0 and at most 1000")); + } + } + Ok(()) + } } /// The most recent files Preferences lets File › Open Recent keep. @@ -317,6 +372,7 @@ impl Default for Prefs { link_text_files: false, recent_files: Vec::new(), recent_files_max: 20, + default_units: DefaultUnits::default(), } } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..502b8e0e7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1562,10 +1562,11 @@ fn confirm_document_setup(app: &mut DesignApp, d: &Dialog) -> Result Result { } app.run("view.snapPreferences", snap)?; } + if d.b("defaults") { + // No document open: the Units & Increments new documents start with. + let mut du = json!({}); + for k in ["horizontalUnits", "verticalUnits", "rulerOrigin"] { + if let Some(v) = d.fields.get(k).filter(|v| v.is_string()) { + du[k] = v.clone(); + } + } + if let Some(v) = d.fields.get("keyboardIncrement").and_then(Value::as_str).and_then(|s| parse_measure(s, Unit::Points).ok()) { + du["keyboardIncrement"] = json!(v.clamp(0.001, 1000.0)); + } + if let Some(v) = d.n("kerningIncrement").filter(|v| v.is_finite()) { + du["kerningIncrement"] = json!(v.clamp(1.0, 1000.0)); + } + app.run("prefs.set", json!({"defaultUnits": du}))?; + return Ok(Value::Null); + } let Some(horizontal) = d.fields.get("horizontalUnits") else { return Ok(Value::Null) }; let mut doc = json!({"horizontalUnits": horizontal}); // Fields can come from `ui.dialog.open`; rulers it doesn't name keep their units. @@ -7218,6 +7237,43 @@ mod tests { } } + /// With no document open, Preferences has Units & Increments, as InDesign's: OK keeps them + /// for new documents, and New Document opens in them (Lollo's inches, set once). + #[test] + fn preferences_with_no_document_set_the_units_of_new_documents() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + crate::menus::activate(&mut app, "app.preferences", &Value::Null); + let mut d = app.ui.dialog.take().expect("Preferences"); + assert!(d.b("defaults"), "for new documents"); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1178.0, 814.0)); + app.ui.dialog = Some(d.clone()); + let mut labels = vec![]; + for _ in 0..3 { + labels = dialog_frame(&mut app, &ctx, screen, vec![]); + } + assert!(labels.iter().any(|(t, _, _)| t == "Units & Increments"), "listed with no document: {labels:?}"); + d.fields.insert("horizontalUnits".into(), json!("inches")); + d.fields.insert("verticalUnits".into(), json!("inches")); + d.fields.insert("keyboardIncrement".into(), json!("0.5 pt")); + app.ui.dialog = Some(d); + confirm(&mut app).unwrap(); + let du = app.session.prefs.default_units; + assert_eq!((du.horizontal_units, du.vertical_units, du.keyboard_increment), (Unit::Inches, Unit::Inches, 0.5)); + assert!(app.session.active().is_none(), "no document made"); + // New Document opens in inches, and the document it makes has inch rulers. + crate::menus::activate(&mut app, "app.newDocumentDialog", &Value::Null); + let nd = app.ui.dialog.clone().expect("New Document"); + assert_eq!(nd.fields["units"], json!("inches")); + assert_eq!(nd.s("width"), designcraft_geom::format_measure(612.0, Unit::Inches)); + confirm(&mut app).unwrap(); + let st = &app.session.doc().unwrap().doc.settings; + assert_eq!((st.horizontal_units, st.vertical_units, st.keyboard_increment), (Unit::Inches, Unit::Inches, 0.5)); + // With the document open, Preferences edits that document's units, not the defaults. + crate::menus::activate(&mut app, "app.preferences", &Value::Null); + assert!(!app.ui.dialog.as_ref().unwrap().b("defaults")); + } + #[test] fn preferences_ok_sets_only_the_ruler_units_it_is_given() { // `ui.dialog.open` takes any fields, so the vertical units can be missing. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index fb43f8cb9..6f514dd9f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1024,7 +1024,9 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { - app.ui.dialog = Some(crate::dialogs::Dialog::new("newDocument", json!({}))); + // In the default units (Preferences with no document open). + let fields = crate::new_document::defaults_for(&app.session.default_new_document()); + app.ui.dialog = Some(crate::dialogs::Dialog::new("newDocument", fields)); Ok(Value::Null) } "app.openDialog" => app.pick_and_open("open"), @@ -1394,6 +1396,15 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option Map { /// The fields a new New Document dialog starts with: the factory settings on the Recent tab. pub(crate) fn defaults() -> Value { - let mut f = fields_for(&NewDocument::default()); + defaults_for(&NewDocument::default()) +} + +/// The fields New Document starts with for `nd` (the session's default new document, in the +/// default units), on the Recent tab. +pub(crate) fn defaults_for(nd: &NewDocument) -> Value { + let mut f = fields_for(nd); f.insert("tab".into(), json!("recent")); f.insert("preset".into(), json!(DEFAULT_CARD)); f.insert("name".into(), json!("")); @@ -325,7 +331,8 @@ fn cards(app: &DesignApp, tab: &str) -> Vec { Card { key, label, nd, custom } }; // Recent and Saved both start with the factory settings, as in InDesign. - let default = Card { key: DEFAULT_CARD.into(), label: crate::i18n::tr(lang, DEFAULT_CARD).into(), nd: NewDocument::default(), custom: false }; + let default = + Card { key: DEFAULT_CARD.into(), label: crate::i18n::tr(lang, DEFAULT_CARD).into(), nd: app.session.default_new_document(), custom: false }; match tab { "recent" => std::iter::once(default) .chain(app.ui.recent_new_documents.iter().enumerate().map(|(i, nd)| named(format!("recent:{i}"), nd.clone()))) From c87e9d261d42410d4df301c64282b4dfe5939c9a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 11:47:29 +0200 Subject: [PATCH 082/207] Insert Pages: `before` and `after` must name a page of the document An index past the last page was clamped to the end; now it is refused. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/layout.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index c5cbb36ed..4362ae339 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -172,12 +172,15 @@ pub fn specs() -> Vec { if p.get("after").is_some() && p.get("before").is_some() { return Err(bad("layout.pages.insert", "give `after` or `before`, not both")); } + // A page of the document: one past the last isn't, and would insert at the end. let index = |k: &str| match p.get(k) { None => Ok(None), Some(v) => v .as_u64() - .map(|v| Some(usize::try_from(v).unwrap_or(usize::MAX))) - .ok_or_else(|| bad("layout.pages.insert", format!("`{k}` must be a page index, not {v}"))), + .and_then(|v| usize::try_from(v).ok()) + .filter(|&i| i < n) + .map(Some) + .ok_or_else(|| bad("layout.pages.insert", format!("`{k}` must be the index of one of the {n} pages, not {v}"))), }; // The page the new ones follow; none: they go first. let after = match (index("after")?, index("before")?) { @@ -2207,6 +2210,9 @@ mod parent_margin_tests { json!({"before": "0"}), json!({"before": null}), json!({"after": 1.5}), + json!({"before": 6}), + json!({"after": 6}), + json!({"before": 99}), ] { assert!(s.execute("layout.pages.insert", &bad).is_err(), "{bad}"); assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{bad}"); From d9e8438cbbae386c5dfca0bedf1f2f638e16e33d Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 12:02:44 +0200 Subject: [PATCH 083/207] Preferences: the Tools Panel columns go through window.toolsDoubleColumn The command takes `on` (default: toggle), and Preferences OK runs it with the chosen state instead of setting the flag itself. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 6 +++++- crates/ui-egui/src/menus.rs | 7 +++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e73ac5c49..4fe09bbd1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3426,7 +3426,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { app.run("window.brightness", json!({"brightness": b}))?; } if d.fields.contains_key("toolsColumns") { - app.ui.tools_double_column = d.s("toolsColumns") == "double"; + app.run("window.toolsDoubleColumn", json!({"on": d.s("toolsColumns") == "double"}))?; } if let Some(v) = d.n("uiScale") { app.run("window.uiScale", json!({"scale": v / 100.0}))?; @@ -7531,6 +7531,10 @@ mod tests { assert!(h.state().ui.dialog.is_none()); assert_eq!(h.state().ui.brightness, Brightness::Dark); assert!(h.state().ui.tools_double_column); + // The same command the Window menu toggles, with an explicit state for Preferences. + assert_eq!(h.state_mut().run("window.toolsDoubleColumn", json!({"on": false})).unwrap(), json!(false)); + assert_eq!(h.state_mut().run("window.toolsDoubleColumn", json!({"on": false})).unwrap(), json!(false), "set, not toggled"); + assert_eq!(h.state_mut().run("window.toolsDoubleColumn", json!({})).unwrap(), json!(true), "toggled without `on`"); // UI Size has a section of its own. open(h.state_mut()); h.state_mut().ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!("uiScaling")); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 97401b1c5..5b943319a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -291,7 +291,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ None, "{open?: true, tab?: \"about\"|\"contributors\"|\"models\"} — the About window: splash with community links, contributor and model credits (open: false closes it)", ), - ("window.toolsDoubleColumn", "Tools: Double Column", None, "{}"), + ("window.toolsDoubleColumn", "Tools: Double Column", None, "{on?: bool (default: toggle)} → whether the Tools panel has two columns"), ( "window.workspace", "Workspace", @@ -1829,7 +1829,10 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option flag(&mut app.ui.tools_double_column), + "window.toolsDoubleColumn" => { + app.ui.tools_double_column = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.tools_double_column); + Ok(json!(app.ui.tools_double_column)) + } "window.collapseToIcons" => { app.ui.dock_expanded = false; app.ui.open_panel = None; From ebb428619a6bf469e987840c9c344e43833bd835 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 12:34:43 +0200 Subject: [PATCH 084/207] Insert Pages bounds test: an out-of-range placement is refused `after` or `before` past the last page is refused without overflowing and changes nothing; inserting after the last page still appends. Co-Authored-By: Claude Opus 5.5 --- crates/engine/tests/insert_pages_bounds.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/engine/tests/insert_pages_bounds.rs b/crates/engine/tests/insert_pages_bounds.rs index e96a6cc4b..340e2fc81 100644 --- a/crates/engine/tests/insert_pages_bounds.rs +++ b/crates/engine/tests/insert_pages_bounds.rs @@ -1,16 +1,21 @@ -//! `layout.pages.insert` clamps an out-of-range `after` to the end instead of overflowing. +//! `layout.pages.insert` refuses an out-of-range `after` or `before` (they must name a page) +//! instead of overflowing, and changes nothing. use designcraft_engine::Session; use serde_json::json; #[test] -fn insert_after_u64_max_appends_at_the_end() { +fn insert_after_u64_max_is_refused_without_overflow() { let mut s = Session::new(); s.execute("file.new", &json!({"pages":2})).unwrap(); let before: Vec<_> = s.doc().unwrap().doc.pages().map(|(_, _, p)| p.id).collect(); - let out = s.execute("layout.pages.insert", &json!({"after": u64::MAX, "count": 2})).unwrap(); - assert_eq!(out["total"], 4); + let undo = s.doc().unwrap().history.undo.len(); + for p in [json!({"after": u64::MAX, "count": 2}), json!({"before": u64::MAX, "count": 2})] { + assert!(s.execute("layout.pages.insert", &p).is_err(), "{p}"); + } let after: Vec<_> = s.doc().unwrap().doc.pages().map(|(_, _, p)| p.id).collect(); - assert_eq!(after.len(), 4); - assert_eq!(after[..2], before[..], "the original pages must stay first; new pages go at the end"); - assert!(after[2..].iter().all(|id| !before.contains(id))); + assert_eq!(after, before, "no pages added"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step"); + // The last page is still a valid place: the new pages go at the end. + let out = s.execute("layout.pages.insert", &json!({"after": 1, "count": 2})).unwrap(); + assert_eq!(out["total"], 4); } From 6f8a0bf09eb42a34bff3512b71d0a677b5ae8590 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 12:43:28 +0200 Subject: [PATCH 085/207] Default units: a preset keeps both its units; bad increments are refused A document made from a preset keeps the preset's units on both rulers; the default origin and increments still apply. Preferences with no document open passes the increments it reads to `prefs.set` unclamped: a Cursor Key of 0, or text that isn't a measure or number, is refused and the dialog stays open with the reason; a Kerning/Tracking of 0.5 is kept. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 4 ++-- crates/engine/src/cmd/prefs.rs | 9 ++++++-- crates/engine/src/lib.rs | 10 ++++----- crates/ui-egui/src/dialogs.rs | 41 ++++++++++++++++++++++++++++------ 4 files changed, 48 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f658d4b92..5b5cb2827 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -237,7 +237,7 @@ fn file_new(s: &mut Session, p: &Value) -> Result { s.untitled += 1; nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| format!("Untitled-{}", s.untitled)); let mut d = Document::new(&nd); - s.prefs.default_units.apply(&mut d.settings); + s.prefs.default_units.apply(&mut d.settings, p.get("preset").is_some_and(|v| !v.is_null())); let i = s.add_document(DocState::new(d, None)); Ok(json!({"index": i})) } @@ -268,7 +268,7 @@ impl Session { } nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| self.next_untitled_title()); let mut d = Document::new(&nd); - self.prefs.default_units.apply(&mut d.settings); + self.prefs.default_units.apply(&mut d.settings, p.get("preset").is_some_and(|v| !v.is_null())); let st = DocState::new(d, None); let uid = st.uid; match replace.and_then(|i| self.docs.get_mut(i).map(|d| (i, d))) { diff --git a/crates/engine/src/cmd/prefs.rs b/crates/engine/src/cmd/prefs.rs index 781d3ef13..a6b53d605 100644 --- a/crates/engine/src/cmd/prefs.rs +++ b/crates/engine/src/cmd/prefs.rs @@ -172,9 +172,14 @@ mod tests { s.execute("file.new", &json!({"units": "points"})).unwrap(); let st = s.doc().unwrap().doc.settings.clone(); assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Points, Unit::Points)); - // A preset keeps its own units. + // A preset keeps its own units, both of them, even when the default horizontal units are + // the preset's. + s.execute("prefs.set", &json!({"defaultUnits": {"horizontalUnits": "millimeters", "verticalUnits": "inches"}})).unwrap(); s.execute("file.new", &json!({"preset": "A4"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.settings.horizontal_units, Unit::Millimeters); + let st = s.doc().unwrap().doc.settings.clone(); + assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Millimeters, Unit::Millimeters)); + assert_eq!(st.keyboard_increment, 0.5, "the increments still apply"); + s.execute("prefs.set", &json!({"defaultUnits": {"horizontalUnits": "inches", "verticalUnits": "millimeters"}})).unwrap(); // New Document opens with them; bad values are refused and change nothing. assert_eq!(s.default_new_document().units, Unit::Inches); for bad in [json!({"horizontalUnits": "furlongs"}), json!({"keyboardIncrement": 0}), json!({"kerningIncrement": -5})] { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9c5c64a35..0dcd348f3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -277,11 +277,11 @@ impl Default for DefaultUnits { } impl DefaultUnits { - /// Gives a new document made in `units` these defaults. Its vertical ruler follows the - /// preference while the document keeps the default horizontal units (units picked in New - /// Document set both rulers). - pub fn apply(&self, settings: &mut designcraft_doc::DocSettings) { - if settings.horizontal_units == self.horizontal_units { + /// Gives a new document these defaults. Unless it was made from a preset (whose units are its + /// own), its vertical ruler follows the preference while it keeps the default horizontal + /// units (units picked in New Document set both rulers). + pub fn apply(&self, settings: &mut designcraft_doc::DocSettings, preset: bool) { + if !preset && settings.horizontal_units == self.horizontal_units { settings.vertical_units = self.vertical_units; } settings.ruler_origin = self.ruler_origin; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 502b8e0e7..eb11c1367 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3389,20 +3389,32 @@ pub fn confirm(app: &mut DesignApp) -> Result { app.run("view.snapPreferences", snap)?; } if d.b("defaults") { - // No document open: the Units & Increments new documents start with. + // No document open: the Units & Increments new documents start with. A value that + // doesn't read, or that `prefs.set` refuses, keeps the dialog open to be fixed. let mut du = json!({}); for k in ["horizontalUnits", "verticalUnits", "rulerOrigin"] { if let Some(v) = d.fields.get(k).filter(|v| v.is_string()) { du[k] = v.clone(); } } - if let Some(v) = d.fields.get("keyboardIncrement").and_then(Value::as_str).and_then(|s| parse_measure(s, Unit::Points).ok()) { - du["keyboardIncrement"] = json!(v.clamp(0.001, 1000.0)); + let mut read = || -> Result<(), String> { + if let Some(t) = d.fields.get("keyboardIncrement").and_then(Value::as_str) { + du["keyboardIncrement"] = json!(parse_measure(t, Unit::Points).map_err(|_| format!("Cursor Key: `{t}` is not a measurement"))?); + } + if let Some(v) = d.fields.get("kerningIncrement").filter(|v| !v.is_null()) { + let n = d.n("kerningIncrement").filter(|v| v.is_finite()); + du["kerningIncrement"] = json!(n.ok_or_else(|| format!("Kerning/Tracking: `{}` is not a number", v.as_str().unwrap_or_default()))?); + } + Ok(()) + }; + let r = read().and_then(|()| app.run("prefs.set", json!({"defaultUnits": du}))); + if let Err(e) = &r { + let mut d = d.clone(); + d.fields.insert("status".into(), json!(e)); + app.status(e.clone()); + app.ui.dialog = Some(d); + return Err(e.clone()); } - if let Some(v) = d.n("kerningIncrement").filter(|v| v.is_finite()) { - du["kerningIncrement"] = json!(v.clamp(1.0, 1000.0)); - } - app.run("prefs.set", json!({"defaultUnits": du}))?; return Ok(Value::Null); } let Some(horizontal) = d.fields.get("horizontalUnits") else { return Ok(Value::Null) }; @@ -7261,6 +7273,21 @@ mod tests { let du = app.session.prefs.default_units; assert_eq!((du.horizontal_units, du.vertical_units, du.keyboard_increment), (Unit::Inches, Unit::Inches, 0.5)); assert!(app.session.active().is_none(), "no document made"); + // A Cursor Key of 0 or text that isn't a measure is refused, keeping the dialog open and + // the saved value; a Kerning/Tracking of 0.5 is kept as it is. + for bad in [json!({"keyboardIncrement": "0 pt"}), json!({"keyboardIncrement": "wide"}), json!({"kerningIncrement": "lots"})] { + let mut d = Dialog::new("preferences", json!({"defaults": true, "horizontalUnits": "inches"})); + for (k, v) in bad.as_object().unwrap() { + d.fields.insert(k.clone(), v.clone()); + } + app.ui.dialog = Some(d); + assert!(confirm(&mut app).is_err(), "{bad}"); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.id == "preferences"), "still open: {bad}"); + assert_eq!(app.session.prefs.default_units.keyboard_increment, 0.5, "{bad}"); + } + app.ui.dialog = Some(Dialog::new("preferences", json!({"defaults": true, "horizontalUnits": "inches", "kerningIncrement": "0.5"}))); + confirm(&mut app).unwrap(); + assert_eq!(app.session.prefs.default_units.kerning_increment, 0.5, "not rounded up to 1"); // New Document opens in inches, and the document it makes has inch rulers. crate::menus::activate(&mut app, "app.newDocumentDialog", &Value::Null); let nd = app.ui.dialog.clone().expect("New Document"); From dc973c5395287a8cd874545f6194b601937a13e9 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 17:19:54 +0200 Subject: [PATCH 086/207] Preferences with no document: the default units are saved with the other preferences Units & Increments for new documents go in the same prefs.set as the rest, which checks everything before it changes anything, so a refused increment keeps the dialog open with no preference saved. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 74 +++++++++++++++++++++-------------- 1 file changed, 45 insertions(+), 29 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9cde3798f..fac5ca40d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3361,7 +3361,23 @@ pub fn confirm(app: &mut DesignApp) -> Result { if d.b("cjkFeatures") != d.b("cjkFeatures.initial") { prefs["cjkFeatures"] = json!(d.b("cjkFeatures")); } - app.run("prefs.set", prefs)?; + // No document open: the Units & Increments new documents start with, saved with the + // rest in one `prefs.set` (which checks everything before it changes anything). A value + // that doesn't read, or that it refuses, keeps the dialog open to be fixed, with no + // preference changed. + let saved = default_units_of(&d).and_then(|du| { + if let Some(du) = du { + prefs["defaultUnits"] = du; + } + app.run("prefs.set", prefs) + }); + if let Err(e) = saved { + let mut d = d.clone(); + d.fields.insert("status".into(), json!(e)); + app.status(e.clone()); + app.ui.dialog = Some(d); + return Err(e); + } app.ui.dynamic_spelling = d.b("dynamicSpelling"); if d.fields.contains_key("userWords") { let words: Vec = d.s("userWords").lines().map(str::to_string).collect(); @@ -3396,32 +3412,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { app.run("view.snapPreferences", snap)?; } if d.b("defaults") { - // No document open: the Units & Increments new documents start with. A value that - // doesn't read, or that `prefs.set` refuses, keeps the dialog open to be fixed. - let mut du = json!({}); - for k in ["horizontalUnits", "verticalUnits", "rulerOrigin"] { - if let Some(v) = d.fields.get(k).filter(|v| v.is_string()) { - du[k] = v.clone(); - } - } - let mut read = || -> Result<(), String> { - if let Some(t) = d.fields.get("keyboardIncrement").and_then(Value::as_str) { - du["keyboardIncrement"] = json!(parse_measure(t, Unit::Points).map_err(|_| format!("Cursor Key: `{t}` is not a measurement"))?); - } - if let Some(v) = d.fields.get("kerningIncrement").filter(|v| !v.is_null()) { - let n = d.n("kerningIncrement").filter(|v| v.is_finite()); - du["kerningIncrement"] = json!(n.ok_or_else(|| format!("Kerning/Tracking: `{}` is not a number", v.as_str().unwrap_or_default()))?); - } - Ok(()) - }; - let r = read().and_then(|()| app.run("prefs.set", json!({"defaultUnits": du}))); - if let Err(e) = &r { - let mut d = d.clone(); - d.fields.insert("status".into(), json!(e)); - app.status(e.clone()); - app.ui.dialog = Some(d); - return Err(e.clone()); - } + // The default units went with the preferences above. return Ok(Value::Null); } let Some(horizontal) = d.fields.get("horizontalUnits") else { return Ok(Value::Null) }; @@ -5170,6 +5161,29 @@ fn section_list(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cjk: bool, sectio }); } +/// Preferences with no document open: the Units & Increments new documents start with, as +/// `prefs.set`'s `defaultUnits` (None with a document open). An increment that doesn't read is an +/// error naming the field. +fn default_units_of(d: &Dialog) -> Result, String> { + if !d.b("defaults") { + return Ok(None); + } + let mut du = json!({}); + for k in ["horizontalUnits", "verticalUnits", "rulerOrigin"] { + if let Some(v) = d.fields.get(k).filter(|v| v.is_string()) { + du[k] = v.clone(); + } + } + if let Some(t) = d.fields.get("keyboardIncrement").and_then(Value::as_str) { + du["keyboardIncrement"] = json!(parse_measure(t, Unit::Points).map_err(|_| format!("Cursor Key: `{t}` is not a measurement"))?); + } + if let Some(v) = d.fields.get("kerningIncrement").filter(|v| !v.is_null()) { + let n = d.n("kerningIncrement").filter(|v| v.is_finite()); + du["kerningIncrement"] = json!(n.ok_or_else(|| format!("Kerning/Tracking: `{}` is not a number", v.as_str().unwrap_or_default()))?); + } + Ok(Some(du)) +} + /// "Paragraph Style n" / "Character Style n": the first name no style has. fn next_style_name(styles: &designcraft_doc::Styles, para: bool) -> String { let (base, count) = if para { ("Paragraph Style", styles.paragraph.len()) } else { ("Character Style", styles.character.len()) }; @@ -7281,9 +7295,10 @@ mod tests { assert_eq!((du.horizontal_units, du.vertical_units, du.keyboard_increment), (Unit::Inches, Unit::Inches, 0.5)); assert!(app.session.active().is_none(), "no document made"); // A Cursor Key of 0 or text that isn't a measure is refused, keeping the dialog open and - // the saved value; a Kerning/Tracking of 0.5 is kept as it is. + // the saved values, the other preferences' too; a Kerning/Tracking of 0.5 is kept as it is. + let scale = app.session.prefs.scale_strokes; for bad in [json!({"keyboardIncrement": "0 pt"}), json!({"keyboardIncrement": "wide"}), json!({"kerningIncrement": "lots"})] { - let mut d = Dialog::new("preferences", json!({"defaults": true, "horizontalUnits": "inches"})); + let mut d = Dialog::new("preferences", json!({"defaults": true, "horizontalUnits": "inches", "scaleStrokes": !scale})); for (k, v) in bad.as_object().unwrap() { d.fields.insert(k.clone(), v.clone()); } @@ -7291,6 +7306,7 @@ mod tests { assert!(confirm(&mut app).is_err(), "{bad}"); assert!(app.ui.dialog.as_ref().is_some_and(|d| d.id == "preferences"), "still open: {bad}"); assert_eq!(app.session.prefs.default_units.keyboard_increment, 0.5, "{bad}"); + assert_eq!(app.session.prefs.scale_strokes, scale, "no other preference saved: {bad}"); } app.ui.dialog = Some(Dialog::new("preferences", json!({"defaults": true, "horizontalUnits": "inches", "kerningIncrement": "0.5"}))); confirm(&mut app).unwrap(); From 3886ad2fd140f3bf4c4f6d0f812d7371080b2b09 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 23 Oct 2026 20:33:44 +0200 Subject: [PATCH 087/207] Tables: Shift+Tab goes back a cell and Esc toggles text and cell selection As in InDesign (Learn Skills Daily / Adobe "How to make a table" lesson): - With the Type tool in a table cell, Shift+Tab goes to the previous cell. It went on to the next one: the tool sent a plain Tab whatever the modifiers. - Esc in a cell toggles between the text and the cell: the insertion point or text in a cell selects that cell, and selected cells select the text of the first one. Esc left the table and selected the frame. New engine command table.toggleSelection does the toggle. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/table.rs | 36 ++++++++++++++++++++++++++++++++ crates/engine/src/tests_table.rs | 26 +++++++++++++++++++++++ crates/tools/src/tests.rs | 23 ++++++++++++++++++++ crates/tools/src/text.rs | 5 +++++ 5 files changed, 91 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index d3ef17e47..e9c2b6fa3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -349,6 +349,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. +- **2026-10-23** · Tables: with the Type tool in a cell, Shift+Tab goes back a cell (it went on to the next one) and Esc toggles between the text and the cell, as in InDesign: the insertion point selects its cell, and selected cells select the text of the first one (`table.toggleSelection`). Esc left the table for the frame. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. - **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change. diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 7049c2f39..5c662834c 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -403,6 +403,15 @@ fn table_specs() -> Vec { }), cmd!(noundo "table.nextCell", "Next Cell", [], None, "{}", in_cell, |s, _| step_cell(s, true)), cmd!(noundo "table.prevCell", "Previous Cell", [], None, "{}", in_cell, |s, _| step_cell(s, false)), + cmd!( + noundo "table.toggleSelection", + "Toggle Text and Cell Selection", + [], + None, + "{} — Esc in a table, as InDesign: the insertion point or text in a cell selects that cell; selected cells select the text of the first one", + in_cell, + |s, _| toggle_selection(s) + ), cmd!(query "table.get", "Get Table", [], None, "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} → rows, columns, cells (text, spans, fill), options", in_table_or_ids, get), ] } @@ -1537,6 +1546,33 @@ pub(crate) fn step_cell(s: &mut Session, forward: bool) -> Result { Ok(json!({"row": row, "col": col})) } +/// Esc in a table: text in a cell becomes that cell's selection, and selected cells the text of +/// the first one (InDesign toggles between the two). +fn toggle_selection(s: &mut Session) -> Result { + const ID: &str = "table.toggleSelection"; + let st = s.doc_mut()?; + let t = st.selection.text.ok_or_else(|| bad(ID, "no caret"))?; + let c = t.cell.ok_or_else(|| bad(ID, "not in a cell"))?; + let cells = match st.selection.cells { + Some(cs) => { + let cell = CellAddr { table: cs.table, row: cs.range.r0, col: cs.range.c0 }; + let len = st.doc.text_story(cs.story, Some(cell)).map_or(0, |x| x.len()); + st.selection = Selection::text(TextSel { story: cs.story, anchor: 0, focus: len, cell: Some(cell), ..t }); + false + } + None => { + st.selection = Selection { + cells: Some(TableSel { story: t.story, table: c.table, range: CellRange::cell(c.row, c.col) }), + text: Some(TextSel { anchor: 0, focus: 0, ..t }), + ..Default::default() + }; + true + } + }; + st.bump_revision(); + Ok(json!({"cells": cells})) +} + fn get(s: &mut Session, p: &Value) -> Result { let g = target(s, p, "table.get")?; let st = s.doc()?; diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 59d2f267b..3933bd0d0 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -127,6 +127,32 @@ fn table_edit_after_anchor_removal_floors_parent_caret() { } } +/// Esc in a table toggles between the text and the cell (InDesign's Esc): the caret in a cell +/// selects that cell, and selected cells select the first one's text; Shift+Tab goes back a cell. +#[test] +fn escape_toggles_text_and_cell_selection() { + let (mut s, sid, _) = session_with_frame(); + let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + s.execute("table.nextCell", &json!({})).unwrap(); + s.execute("text.insert", &json!({"text": "Phone"})).unwrap(); + let cell = designcraft_doc::CellAddr { table: tid, row: 0, col: 1 }; + assert_eq!(s.execute("table.toggleSelection", &json!({})).unwrap(), json!({"cells": true})); + let sel = s.doc().unwrap().selection.clone(); + let cells = sel.cells.unwrap(); + assert_eq!((cells.story.0, cells.table, cells.range), (sid, tid, designcraft_doc::CellRange::cell(0, 1))); + assert_eq!(sel.text.unwrap().cell, Some(cell)); + assert_eq!(s.execute("table.toggleSelection", &json!({})).unwrap(), json!({"cells": false})); + let sel = s.doc().unwrap().selection.clone(); + assert!(sel.cells.is_none()); + let t = sel.text.unwrap(); + assert_eq!((t.cell, t.range()), (Some(cell), 0..5), "the cell's text is selected"); + s.execute("table.prevCell", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().selection.text.unwrap().cell, Some(designcraft_doc::CellAddr { col: 0, ..cell })); + // Outside a table there is nothing to toggle. + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + assert!(s.execute("table.toggleSelection", &json!({})).is_err()); +} + fn selected_merged_table(column: bool) -> (Session, u64, u64, u64) { let (mut s, sid, fid) = session_with_frame(); let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index ee3b362f1..dce3629b6 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -1452,6 +1452,29 @@ fn table_frame(degrees: f64) -> (Document, ItemId) { (d, id) } +/// In a table cell Shift+Tab goes back a cell and Esc toggles cell selection, as InDesign; out of +/// a table Tab types a tab and Esc leaves the text. +#[test] +fn type_tool_keys_in_a_table_cell() { + let (d, id) = table_frame(0.0); + let sid = d.item(id).unwrap().text_frame().unwrap().story; + let table = *d.story(sid).unwrap().tables.keys().next().unwrap(); + let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); + let cell = designcraft_doc::CellAddr { table, row: 1, col: 1 }; + let s = Selection::text(designcraft_doc::TextSel { cell: Some(cell), ..designcraft_doc::TextSel::caret(sid, 0) }); + let cx = ctx(&d, &s, &c, &l); + let mut t = create("type"); + let shift = Mods { shift: true, ..Default::default() }; + let exec = |c: &str, p: serde_json::Value| vec![Action::Exec(c.into(), p)]; + assert_eq!(t.key(&cx, ToolKey::Tab, shift), exec("table.prevCell", serde_json::json!({}))); + assert_eq!(t.key(&cx, ToolKey::Tab, Mods::default()), exec("text.insert", serde_json::json!({"text": "\t"}))); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), exec("table.toggleSelection", serde_json::json!({}))); + let s = Selection::text(designcraft_doc::TextSel::caret(sid, 0)); + let cx = ctx(&d, &s, &c, &l); + assert_eq!(t.key(&cx, ToolKey::Tab, shift), exec("text.insert", serde_json::json!({"text": "\t"}))); + assert!(matches!(t.key(&cx, ToolKey::Escape, Mods::default()).first(), Some(Action::SwitchTool(_)))); +} + #[test] fn type_tool_drags_a_table_column_border() { for degrees in [0.0, 90.0] { diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 56658f41e..79d0e9e09 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -359,6 +359,7 @@ impl Tool for TypeTool { let steps = if mods.cmd || mods.ctrl { 5.0 } else { 1.0 }; vec![Action::Exec("type.kernStep".into(), json!({"steps": sign * steps}))] }; + let in_cell = cx.selection.text.is_some_and(|t| t.cell.is_some()); match key { ToolKey::Left if mods.alt && !mods.shift => kern(-1.0), ToolKey::Right if mods.alt && !mods.shift => kern(1.0), @@ -371,7 +372,11 @@ impl Tool for TypeTool { ToolKey::Backspace => vec![Action::Exec("text.delete".into(), json!({"forward": false, "word": mods.alt}))], ToolKey::Delete => vec![Action::Exec("text.delete".into(), json!({"forward": true, "word": mods.alt}))], ToolKey::Enter => vec![Action::Exec("text.insert".into(), json!({"text": if mods.shift { "\u{2028}" } else { "\n" }}))], + // In a table cell Shift+Tab goes back a cell (Tab goes on through `text.insert`). + ToolKey::Tab if mods.shift && in_cell => vec![Action::Exec("table.prevCell".into(), json!({}))], ToolKey::Tab => vec![Action::Exec("text.insert".into(), json!({"text": "\t"}))], + // In a table Esc toggles between the cell's text and the cell, as InDesign. + ToolKey::Escape if in_cell => vec![Action::Exec("table.toggleSelection".into(), json!({}))], ToolKey::Escape => vec![Action::SwitchTool("selection".into()), Action::Exec("text.exitToFrame".into(), json!({}))], _ => vec![], } From cb988aef83f52963fe2de505fff334734c511799 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 23 Oct 2026 21:31:06 +0200 Subject: [PATCH 088/207] Show Hidden Characters marks table cells, as InDesign Every text cell now shows its own hidden characters: spaces, tabs, paragraph returns and an end-of-story # after its text (an empty cell shows just the #). The table paragraph's return sits after the table's bottom right corner. Cells showed no marks at all, so an empty table looked like plain lines. The table marks come from table_hidden_marks (frame space), drawn with the story's own marks. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 65 +++++++++++++++++++++++++++++++----- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d3ef17e47..520b7bf52 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -349,6 +349,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. +- **2026-10-23** · Show Hidden Characters marks table cells as InDesign does: every cell shows its own spaces, tabs and paragraph returns and an end-of-story # after its text (an empty cell shows just the #), and the table paragraph's ¶ sits after the table's bottom right corner. Cells showed no marks. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. - **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 318f82388..388244b78 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2744,6 +2744,8 @@ fn ime_output(app: &mut DesignApp, ctx: &egui::Context, xf: &Xf) { } /// View → Show Hidden Characters: ¶ paragraph ends, » tabs, · spaces, ¬ forced line breaks, # end of story. +/// As InDesign, every table cell shows its own marks (an empty cell its #), and a table's +/// paragraph return sits after the table's bottom right corner. fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout) { for story in doc.stories.values() { let cs = app.session.cache.get(doc, story.id, None); @@ -2751,19 +2753,39 @@ fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { continue }; let m = a * doc.text_xf(it); let col = layer_color(doc, it); - for l in &ft.lines { - let size = (l.ascent * 0.75 * xf.zoom).clamp(6.0, 40.0) as f32; - let font = egui::FontId::proportional(size); - for (x, mark, centered) in hidden_marks(&story.text, l) { - let p = xf.to_screen(m * Point::new(x, l.baseline)); - let align = if centered { egui::Align2::CENTER_BOTTOM } else { egui::Align2::LEFT_BOTTOM }; - painter.text(p, align, mark, font.clone(), col); - } + let lines = ft + .lines + .iter() + .flat_map(|l| hidden_marks(&story.text, l).into_iter().map(move |(x, mark, c)| (Point::new(x, l.baseline), mark, c, l.ascent))); + for (p, mark, centered, ascent) in lines.chain(ft.tables.iter().flat_map(table_hidden_marks)) { + let size = (ascent * 0.75 * xf.zoom).clamp(6.0, 40.0) as f32; + let align = if centered { egui::Align2::CENTER_BOTTOM } else { egui::Align2::LEFT_BOTTOM }; + painter.text(xf.to_screen(m * p), align, mark, egui::FontId::proportional(size), col); } } } } +/// A table fragment's hidden-character marks in frame space (position at the baseline, mark, +/// centered, the line's ascent): each text cell's own marks, its # where its text ends, and the +/// table paragraph's ¶ after the last row's bottom right corner. +fn table_hidden_marks(t: &compose::TableFrag) -> Vec<(Point, &'static str, bool, f64)> { + let mut out = Vec::new(); + for c in t.cells.iter().filter(|c| c.graphic.is_none()) { + for l in c.text.frames.first().map_or(&[][..], |f| &f.lines) { + out.extend( + hidden_marks(&c.source, l) + .into_iter() + .map(|(x, mark, centered)| (c.origin + designcraft_geom::Vec2::new(x, l.baseline), mark, centered, l.ascent)), + ); + } + } + if t.last { + out.push((Point::new(t.rect.x1 + 1.0, t.rect.y1), "¶", false, 12.0)); + } + out +} + /// The hidden-character marks on line `l` of a story with text `text`: frame-space x, mark, and /// whether the mark is centered on x (spaces) or starts at it. fn hidden_marks(text: &str, l: &compose::Line) -> Vec<(f64, &'static str, bool)> { @@ -5668,6 +5690,33 @@ mod tests { assert!(para_x >= space.x + space.adv, "{mark} at {para_x} overlaps the last space"); } } + + /// As InDesign: an end-of-story # in every cell (after its text), a ¶ where a cell paragraph + /// ends, and the table paragraph's ¶ just after the table's bottom right corner. + #[test] + fn table_cells_show_their_hidden_characters() { + let mut d = Document::new(&designcraft_doc::build::NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), DRect::new(0.0, 0.0, 300.0, 300.0), lid, "", Default::default()).unwrap(); + let data = vec![vec!["Name:".to_string(), "".into()], vec!["a\nb".into(), "".into()]]; + let t = designcraft_doc::Table::from_strings(d.alloc(), &data, 200.0, &Default::default(), &Default::default()); + d.story_mut(sid).unwrap().insert_table(0, t); + let cs = compose::compose_story(&d, sid, &compose::ComposeOptions::default()); + let frag = &cs.frames[0].tables[0]; + let marks = table_hidden_marks(frag); + let ends = |m: &str| marks.iter().filter(|x| x.1 == m).count(); + assert_eq!(ends("#"), 4, "one per cell: {marks:?}"); + assert_eq!(ends("¶"), 2, "the cell's first paragraph and the table's: {marks:?}"); + // Each # is inside its cell, after the cell's text. + for c in &frag.cells { + let inside = marks.iter().filter(|m| m.1 == "#" && c.rect.contains(m.0)).count(); + assert_eq!(inside, 1, "cell {},{}: {marks:?}", c.row, c.col); + } + let name = frag.cell(0, 0).unwrap(); + assert!(marks.iter().any(|m| m.1 == "#" && name.rect.contains(m.0) && m.0.x > name.origin.x + 20.0), "after Name:"); + let last = marks.last().unwrap(); + assert_eq!((last.0, last.1), (designcraft_geom::Point::new(frag.rect.x1 + 1.0, frag.rect.y1), "¶")); + } } #[cfg(test)] From f5a67336ad4fb06a5ff20e997763984bbc1fa9ec Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 23 Oct 2026 21:59:37 +0200 Subject: [PATCH 089/207] Esc on a cell selection starting in a merged cell selects that cell's text selection.cells may start at a covered grid position; the toggle now goes to the merged cell (its owner at the top left), whose story holds the text. Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/table.rs | 8 +++++++- crates/engine/src/tests_table.rs | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 5c662834c..f07294329 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -1555,7 +1555,13 @@ fn toggle_selection(s: &mut Session) -> Result { let c = t.cell.ok_or_else(|| bad(ID, "not in a cell"))?; let cells = match st.selection.cells { Some(cs) => { - let cell = CellAddr { table: cs.table, row: cs.range.r0, col: cs.range.c0 }; + // A merged cell's text belongs to the cell at its top left. + let (row, col) = st + .doc + .story(cs.story) + .and_then(|x| x.tables.get(&cs.table)) + .map_or((cs.range.r0, cs.range.c0), |tb| tb.owner(cs.range.r0, cs.range.c0)); + let cell = CellAddr { table: cs.table, row, col }; let len = st.doc.text_story(cs.story, Some(cell)).map_or(0, |x| x.len()); st.selection = Selection::text(TextSel { story: cs.story, anchor: 0, focus: len, cell: Some(cell), ..t }); false diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 3933bd0d0..0c7b3a50a 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -148,6 +148,12 @@ fn escape_toggles_text_and_cell_selection() { assert_eq!((t.cell, t.range()), (Some(cell), 0..5), "the cell's text is selected"); s.execute("table.prevCell", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().selection.text.unwrap().cell, Some(designcraft_doc::CellAddr { col: 0, ..cell })); + // A selection that starts inside a merged cell goes back to the merged cell's text. + s.execute("table.select", &json!({"rows": [0, 1], "cols": [0, 0]})).unwrap(); + s.execute("table.merge", &json!({})).unwrap(); + s.execute("table.select", &json!({"rows": [1, 1], "cols": [0, 1]})).unwrap(); + s.execute("table.toggleSelection", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().selection.text.unwrap().cell, Some(designcraft_doc::CellAddr { row: 0, col: 0, ..cell })); // Outside a table there is nothing to toggle. s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); assert!(s.execute("table.toggleSelection", &json!({})).is_err()); From e1aa6b7f15964835cd3900e1f669d546f75c1b6c Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 23 Oct 2026 22:15:39 +0200 Subject: [PATCH 090/207] Hidden characters: one table mark, nested tables marked too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A table's glyph-less line drew its own end mark at the table's bottom right, sized to the table's height, under the new one: table lines now have no line marks (the drawn ¶ is the table paragraph's). - Marks come from frame_hidden_marks, which visits a cell's own tables, so nested tables' cells are marked too. Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 51 ++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 388244b78..ba4ccfe15 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2753,11 +2753,7 @@ fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { continue }; let m = a * doc.text_xf(it); let col = layer_color(doc, it); - let lines = ft - .lines - .iter() - .flat_map(|l| hidden_marks(&story.text, l).into_iter().map(move |(x, mark, c)| (Point::new(x, l.baseline), mark, c, l.ascent))); - for (p, mark, centered, ascent) in lines.chain(ft.tables.iter().flat_map(table_hidden_marks)) { + for (p, mark, centered, ascent) in frame_hidden_marks(&story.text, ft) { let size = (ascent * 0.75 * xf.zoom).clamp(6.0, 40.0) as f32; let align = if centered { egui::Align2::CENTER_BOTTOM } else { egui::Align2::LEFT_BOTTOM }; painter.text(xf.to_screen(m * p), align, mark, egui::FontId::proportional(size), col); @@ -2766,22 +2762,28 @@ fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc } } -/// A table fragment's hidden-character marks in frame space (position at the baseline, mark, -/// centered, the line's ascent): each text cell's own marks, its # where its text ends, and the -/// table paragraph's ¶ after the last row's bottom right corner. -fn table_hidden_marks(t: &compose::TableFrag) -> Vec<(Point, &'static str, bool, f64)> { - let mut out = Vec::new(); - for c in t.cells.iter().filter(|c| c.graphic.is_none()) { - for l in c.text.frames.first().map_or(&[][..], |f| &f.lines) { - out.extend( - hidden_marks(&c.source, l) - .into_iter() - .map(|(x, mark, centered)| (c.origin + designcraft_geom::Vec2::new(x, l.baseline), mark, centered, l.ascent)), - ); +/// A frame's hidden-character marks in frame space (position at the baseline, mark, centered, +/// the line's ascent): its lines' marks, then its tables' — each text cell's own marks (nested +/// tables included) with its # where its text ends, and a table paragraph's ¶ after the last +/// row's bottom right corner. A table's own glyph-less line has no marks: its ¶ is that one. +fn frame_hidden_marks(text: &str, ft: &compose::FrameText) -> Vec<(Point, &'static str, bool, f64)> { + let table_lines: Vec = ft.tables.iter().map(|t| t.line).collect(); + let mut out: Vec<_> = ft + .lines + .iter() + .enumerate() + .filter(|(i, _)| !table_lines.contains(i)) + .flat_map(|(_, l)| hidden_marks(text, l).into_iter().map(move |(x, mark, c)| (Point::new(x, l.baseline), mark, c, l.ascent))) + .collect(); + for t in &ft.tables { + for c in t.cells.iter().filter(|c| c.graphic.is_none()) { + if let Some(cf) = c.text.frames.first() { + out.extend(frame_hidden_marks(&c.source, cf).into_iter().map(|(p, mark, centered, a)| (p + c.origin.to_vec2(), mark, centered, a))); + } + } + if t.last { + out.push((Point::new(t.rect.x1 + 1.0, t.rect.y1), "¶", false, 12.0)); } - } - if t.last { - out.push((Point::new(t.rect.x1 + 1.0, t.rect.y1), "¶", false, 12.0)); } out } @@ -5703,10 +5705,15 @@ mod tests { d.story_mut(sid).unwrap().insert_table(0, t); let cs = compose::compose_story(&d, sid, &compose::ComposeOptions::default()); let frag = &cs.frames[0].tables[0]; - let marks = table_hidden_marks(frag); + // The whole frame: the table's own line adds no second ¶; the story's last paragraph + // (after the table) has its #. + let story = d.story(sid).unwrap().text.clone(); + let all = frame_hidden_marks(&story, &cs.frames[0]); + let marks: Vec<_> = all.iter().filter(|m| frag.rect.inflate(2.0, 2.0).contains(m.0)).cloned().collect(); let ends = |m: &str| marks.iter().filter(|x| x.1 == m).count(); assert_eq!(ends("#"), 4, "one per cell: {marks:?}"); - assert_eq!(ends("¶"), 2, "the cell's first paragraph and the table's: {marks:?}"); + assert_eq!(ends("¶"), 2, "the cell's first paragraph and the table's, once: {marks:?}"); + assert_eq!(all.iter().filter(|m| m.1 == "¶" && m.3 > 20.0).count(), 0, "no ¶ the size of the table: {all:?}"); // Each # is inside its cell, after the cell's text. for c in &frag.cells { let inside = marks.iter().filter(|m| m.1 == "#" && c.rect.contains(m.0)).count(); From 5c3d25a092554fbb2b8289b1f996c664c39c4f41 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sat, 24 Oct 2026 11:06:27 +0200 Subject: [PATCH 091/207] Tables: typing with one cell selected replaces its text A cell selected with Esc (or a drag inside it) kept the caret at the start of its text, so typing went in front. With one cell (or one merged cell) selected, text.insert now selects the cell's whole text first, as InDesign replaces the contents. Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/table.rs | 16 ++++++++++++++++ crates/engine/src/cmd/text.rs | 2 ++ crates/engine/src/tests_table.rs | 9 +++++++++ 3 files changed, 27 insertions(+) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 99931deeb..8a3c75102 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -1558,6 +1558,22 @@ pub(crate) fn step_cell(s: &mut Session, forward: bool) -> Result { Ok(json!({"row": row, "col": col})) } +/// With one cell selected (a merged one counts as one), select its whole text instead, so what is +/// typed next replaces it, as InDesign does. Other selections are left as they are. +pub(crate) fn one_selected_cell_to_text(s: &mut Session) -> Result<()> { + let st = s.doc_mut()?; + let (Some(cs), Some(t)) = (st.selection.cells, st.selection.text) else { return Ok(()) }; + let Some(tb) = st.doc.story(cs.story).and_then(|x| x.tables.get(&cs.table)) else { return Ok(()) }; + let (row, col) = tb.owner(cs.range.r0, cs.range.c0); + if tb.owner(cs.range.r1, cs.range.c1) != (row, col) { + return Ok(()); + } + let cell = CellAddr { table: cs.table, row, col }; + let len = st.doc.text_story(cs.story, Some(cell)).map_or(0, |x| x.len()); + st.selection = Selection::text(TextSel { story: cs.story, anchor: 0, focus: len, cell: Some(cell), ..t }); + Ok(()) +} + /// Esc in a table: text in a cell becomes that cell's selection, and selected cells the text of /// the first one (InDesign toggles between the two). fn toggle_selection(s: &mut Session) -> Result { diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index dbfbcfe15..305830a59 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -663,6 +663,8 @@ fn insert(s: &mut Session, p: &Value) -> Result { if text == "\t" && s.doc()?.selection.text.is_some_and(|t| t.cell.is_some()) { return super::table::step_cell(s, true); } + // Typing with one cell selected replaces its text, as InDesign. + super::table::one_selected_cell_to_text(s)?; let raw = p.get("raw").and_then(Value::as_bool).unwrap_or(false); let text = if s.prefs.typographers_quotes && !raw { smart_quotes(s, &text) } else { text }; let tracking = s.doc()?.doc.settings.track_changes; diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 1fb1b6cdc..2557b961a 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -148,6 +148,15 @@ fn escape_toggles_text_and_cell_selection() { assert_eq!((t.cell, t.range()), (Some(cell), 0..5), "the cell's text is selected"); s.execute("table.prevCell", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().selection.text.unwrap().cell, Some(designcraft_doc::CellAddr { col: 0, ..cell })); + // With the cell selected, typing replaces its text and leaves the caret after it. + s.execute("table.nextCell", &json!({})).unwrap(); + s.execute("table.toggleSelection", &json!({})).unwrap(); + s.execute("text.insert", &json!({"text": "Fax"})).unwrap(); + let t = s.execute("table.get", &json!({"table": tid})).unwrap(); + assert_eq!(t["cells"][1]["text"], "Fax"); + let sel = s.doc().unwrap().selection.clone(); + assert!(sel.cells.is_none()); + assert_eq!(sel.text.unwrap().range(), 3..3); // A selection that starts inside a merged cell goes back to the merged cell's text. s.execute("table.select", &json!({"rows": [0, 1], "cols": [0, 0]})).unwrap(); s.execute("table.merge", &json!({})).unwrap(); From 89f44620d5837bf3612b7d93c9c464fc8b3e672d Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sat, 24 Oct 2026 12:08:55 +0200 Subject: [PATCH 092/207] Insert Pages: an after past the last page appends again, as main pins; before stays strict Main's insert_pages_bounds.rs and a_page_count_insert_pages_cannot_mean_is_refused pin that an `after` past the last page appends. This branch had made it an error; `before` (new here) still has to name a page, and both together are refused. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/layout.rs | 14 ++++----- crates/engine/tests/it/insert_pages_bounds.rs | 30 +++++++++++-------- 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index b88419538..0bbc5eab7 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -161,7 +161,7 @@ pub fn specs() -> Vec { "Insert Pages…", ["Layout", "Pages"], Some("Cmd+Shift+P"), - "{count?: 1 (1 to 9999), after?: 0-based page index (default: the last page), before?: 0-based page index (instead of `after`; 0: at the start), parent?: id|\"A-Parent\"|\"A\"|null}", + "{count?: 1 (1 to 9999), after?: 0-based page index (default: the last page; past it: at the end), before?: 0-based page index (instead of `after`; 0: at the start), parent?: id|\"A-Parent\"|\"A\"|null}", has_doc, |s, p| { const ID: &str = "layout.pages.insert"; @@ -181,15 +181,16 @@ pub fn specs() -> Vec { if p.get("after").is_some() && p.get("before").is_some() { return Err(bad("layout.pages.insert", "give `after` or `before`, not both")); } - // A page of the document: one past the last isn't, and would insert at the end. + // `before` names a page of the document; an `after` past the last page appends + // (`insert_pages_bounds.rs` pins that). let index = |k: &str| match p.get(k) { None => Ok(None), Some(v) => v .as_u64() - .and_then(|v| usize::try_from(v).ok()) - .filter(|&i| i < n) - .map(Some) - .ok_or_else(|| bad("layout.pages.insert", format!("`{k}` must be the index of one of the {n} pages, not {v}"))), + .map(|v| usize::try_from(v).unwrap_or(usize::MAX)) + .filter(|&i| k == "after" || i < n) + .map(|i| Some(i.min(n.saturating_sub(1)))) + .ok_or_else(|| bad(ID, format!("`{k}` must be the index of one of the {n} pages, not {v}"))), }; // The page the new ones follow; none: they go first. let after = match (index("after")?, index("before")?) { @@ -2220,7 +2221,6 @@ mod parent_margin_tests { json!({"before": null}), json!({"after": 1.5}), json!({"before": 6}), - json!({"after": 6}), json!({"before": 99}), ] { assert!(s.execute("layout.pages.insert", &bad).is_err(), "{bad}"); diff --git a/crates/engine/tests/it/insert_pages_bounds.rs b/crates/engine/tests/it/insert_pages_bounds.rs index 340e2fc81..5f8856246 100644 --- a/crates/engine/tests/it/insert_pages_bounds.rs +++ b/crates/engine/tests/it/insert_pages_bounds.rs @@ -1,21 +1,27 @@ -//! `layout.pages.insert` refuses an out-of-range `after` or `before` (they must name a page) -//! instead of overflowing, and changes nothing. +//! `layout.pages.insert` clamps an out-of-range `after` to the end instead of overflowing; a +//! `before` must name a page, and one that doesn't is refused without a change. use designcraft_engine::Session; use serde_json::json; #[test] -fn insert_after_u64_max_is_refused_without_overflow() { +fn insert_after_u64_max_appends_at_the_end() { let mut s = Session::new(); s.execute("file.new", &json!({"pages":2})).unwrap(); let before: Vec<_> = s.doc().unwrap().doc.pages().map(|(_, _, p)| p.id).collect(); - let undo = s.doc().unwrap().history.undo.len(); - for p in [json!({"after": u64::MAX, "count": 2}), json!({"before": u64::MAX, "count": 2})] { - assert!(s.execute("layout.pages.insert", &p).is_err(), "{p}"); - } - let after: Vec<_> = s.doc().unwrap().doc.pages().map(|(_, _, p)| p.id).collect(); - assert_eq!(after, before, "no pages added"); - assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step"); - // The last page is still a valid place: the new pages go at the end. - let out = s.execute("layout.pages.insert", &json!({"after": 1, "count": 2})).unwrap(); + let out = s.execute("layout.pages.insert", &json!({"after": u64::MAX, "count": 2})).unwrap(); assert_eq!(out["total"], 4); + let after: Vec<_> = s.doc().unwrap().doc.pages().map(|(_, _, p)| p.id).collect(); + assert_eq!(after.len(), 4); + assert_eq!(after[..2], before[..], "the original pages must stay first; new pages go at the end"); + assert!(after[2..].iter().all(|id| !before.contains(id))); +} + +#[test] +fn insert_before_u64_max_is_refused_without_overflow() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages":2})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + assert!(s.execute("layout.pages.insert", &json!({"before": u64::MAX, "count": 2})).is_err()); + assert_eq!(s.doc().unwrap().doc.page_count(), 2, "no pages added"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step"); } From c6a0e1e8137e2c6d5561526a5bd031cc5b17d147 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sat, 24 Oct 2026 13:50:38 +0200 Subject: [PATCH 093/207] Change Format: empty matches get the paragraph style; style helpers bounded - A GREP match that is empty (^) was counted but got no paragraph style; the paragraph style now goes to the paragraphs of every match, empty ones too. Character formatting still needs characters. - apply_char_style and apply_para_style listed every paragraph of the story for each match; Story::paras_touching gives the ones a range reaches, from the range itself. Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/story.rs | 39 ++++++++++++++++++++++++++++++++++ crates/engine/src/cmd/find.rs | 36 ++++++++++++++++++++++++++++--- crates/engine/src/cmd/style.rs | 4 ++-- 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index e3a0e39ee..c21d54c4b 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -244,6 +244,24 @@ impl Story { } /// Index of the paragraph containing byte `pos`. + /// The paragraphs `r` touches (at least the one at `r.start`), as (index, byte range without + /// its return), found from `r` itself rather than by listing every paragraph of the story. + pub fn paras_touching(&self, r: std::ops::Range) -> Vec<(usize, std::ops::Range)> { + let start = floor_char_boundary(&self.text, r.start.min(self.text.len())); + let mut pi = self.para_at(start); + let mut a = self.text[..start].rfind('\n').map_or(0, |i| i + 1); + let mut out = Vec::new(); + loop { + let b = self.text[a..].find('\n').map_or(self.text.len(), |i| a + i); + out.push((pi, a..b)); + // The next paragraph starts after this one's return: is it still inside `r`? + if b >= self.text.len() || b + 1 >= r.end { + return out; + } + (a, pi) = (b + 1, pi + 1); + } + } + pub fn para_at(&self, pos: usize) -> usize { self.text[..floor_char_boundary(&self.text, pos)].bytes().filter(|b| *b == b'\n').count() } @@ -596,6 +614,27 @@ mod tests { Story::with_text(StoryId(1), t, ParaFormat::default()) } + /// `paras_touching` gives the paragraphs a range reaches into (returns included), as listing + /// every paragraph would, for every range of a story with empty and multi-byte paragraphs. + #[test] + fn paras_touching_matches_the_full_listing() { + let s = st("ab + +çé +last"); + let all = s.para_ranges(); + for a in 0..=s.len() { + for b in a..=s.len() { + if !s.text.is_char_boundary(a) || !s.text.is_char_boundary(b) { + continue; + } + let want: Vec<(usize, std::ops::Range)> = + all.iter().cloned().enumerate().filter(|(i, p)| *i == s.para_at(a) || (a < b && p.start < b && a <= p.end)).collect(); + assert_eq!(s.paras_touching(a..b), want, "{a}..{b}"); + } + } + } + #[test] fn paragraphs_track_newlines() { let mut s = st("one\ntwo"); diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index b87a7b226..9e6f984b4 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -372,10 +372,12 @@ fn change(s: &mut Session, p: &Value) -> Result { st.replace(range.clone(), new); range.start..range.start + new.len() }; + // A paragraph style goes to the match's paragraphs, an empty match's (`^`) too; + // character formatting needs characters. + if let Some(n) = ¶_style { + super::style::apply_para_style(st, &styles, n, &changed, false); + } if !changed.is_empty() { - if let Some(n) = ¶_style { - super::style::apply_para_style(st, &styles, n, &changed, false); - } if let Some(n) = &char_style { super::style::apply_char_style(st, &styles, n, &changed); } @@ -485,6 +487,34 @@ mod tests { assert!(s.doc().unwrap().selection.text.is_some()); } + /// A GREP match that is empty (`^`, the start of each paragraph) gets the paragraph style: + /// only character formatting needs characters. + #[test] + fn change_format_styles_paragraphs_of_empty_matches() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s + .execute( + "frame.create", + &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "One +Two +Three"}), + ) + .unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap(); + let r = s.execute("find.change", &json!({"find": "^", "change": "", "grep": true, "paraStyle": "Method"})).unwrap(); + assert_eq!(r["count"], 3); + let story = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert!(story.paras.iter().all(|p| p.style == "Method"), "{:?}", story.paras.iter().map(|p| &p.style).collect::>()); + assert_eq!( + story.text, + "One +Two +Three" + ); + } + #[test] fn change_format_styles_the_found_text_and_keeps_it() { // The course's chapter 22: every lowercase "eggs" gets the character style "eggs", with diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 9ae967613..27414306e 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -790,7 +790,7 @@ pub(super) fn apply_para_style(st: &mut designcraft_doc::Story, styles: &Styles, f.chars.retain_differences(&style_chars); } }); - for (pi, range) in st.para_ranges().into_iter().enumerate().take(last.saturating_add(1)).skip(first) { + for (pi, range) in st.paras_touching(r.clone()).into_iter().filter(|(pi, _)| (first..=last).contains(pi)) { let Some(pf) = st.paras.get(pi) else { continue }; let (_, base) = styles.resolve_para(pf); let end = range.end.saturating_add(1).min(st.len()); @@ -827,7 +827,7 @@ fn apply_char(s: &mut Session, p: &Value) -> Result { /// Character style `name` applied to `r` in `st`: the text's overrides that the style (over its /// paragraph) now gives go. pub(super) fn apply_char_style(st: &mut designcraft_doc::Story, styles: &Styles, name: &str, r: &std::ops::Range) { - for (pi, range) in st.para_ranges().into_iter().enumerate() { + for (pi, range) in st.paras_touching(r.clone()) { let range = range.start.max(r.start)..range.end.saturating_add(1).min(st.len()).min(r.end); if range.is_empty() { continue; From 7907cde2709469ff37b025c4c7c1bb1e9f0e3938 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sat, 24 Oct 2026 14:01:28 +0200 Subject: [PATCH 094/207] Swatches: a row's accessible name says what its marks show Screen readers got only the swatch name; the painted marks (colour type and mode, can't be edited) were not in it. The row is now named "Cyan, Process, CMYK" or "[Black], Process, CMYK, Cannot be edited" (translated; one new string). Tests find rows by name with names_swatch. Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/options.rs | 3 +- crates/ui-egui/src/i18n.rs | 1 + crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + crates/ui-egui/src/panels/swatches.rs | 62 ++++++++++++++++++++++++--- 13 files changed, 68 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/dialogs/options.rs b/crates/ui-egui/src/dialogs/options.rs index d8aba2bea..df7dc4cf0 100644 --- a/crates/ui-egui/src/dialogs/options.rs +++ b/crates/ui-egui/src/dialogs/options.rs @@ -342,7 +342,8 @@ mod tests { } fn double_click(h: &mut Harness<'static, DesignApp>, label: &str) { - let at = h.get_by_label(label).rect().center(); + // A swatch row's accessible name is its name, then what its marks show. + let at = h.get_by(|n| n.label().is_some_and(|l| crate::panels::swatches::names_swatch(&l, label))).rect().center(); h.hover_at(at); // Two clicks without the pointer leaving (`drop_at` would end the double click). for pressed in [true, false, true, false] { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index bdca83589..158262da4 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1010,6 +1010,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Load Styles", ["Formate laden", "Charger les styles", "Cargar estilos", "スタイルを読み込み", "加载样式"]), + ("Cannot be edited", ["Nicht bearbeitbar", "Non modifiable", "No editable", "編集不可", "不可编辑"]), ( "Differs from this document", ["Weicht von diesem Dokument ab", "Diffère de ce document", "Difiere de este documento", "このドキュメントと異なります", "与此文档不同"], diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 31f7fb70d..fa50476a3 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Cannot be edited", "غير قابل للتحرير"), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 4d41c30dc..e28fd4f27 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Cannot be edited", "Nelze upravit"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 367ca52f8..63f7b6172 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("Cannot be edited", "Nicht bearbeitbar"), ("Inches Decimal", "Zoll dezimal"), ("Back", "Zurück"), ("Warning", "Warnung"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 755272fe5..7de7b7ee4 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Cannot be edited", "Non modificabile"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 37b14ac5c..601801055 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Cannot be edited", "編集不可"), ("Paste Vector Art", "ベクトルアートをペースト"), ("Baseline Options", "ベースラインオプション"), ("Column Rules", "段落境界線"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 0300b62d7..1806a43e0 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Cannot be edited", "რედაქტირება შეუძლებელია"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 7d1d12c67..7c131b088 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Cannot be edited", "Nie można edytować"), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index f552e9a63..f9ff794e9 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Cannot be edited", "Não editável"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 3f6ef1be0..16c4aba10 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Cannot be edited", "Нельзя изменить"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9c7cc855a..c9f2b1abb 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Cannot be edited", "Не можна змінити"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 71b7f0ed1..fc87a4d6a 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -281,6 +281,34 @@ fn row_marks(sw: &designcraft_color::swatch::Swatch) -> RowMarks { } } +/// A row's accessible name: the swatch's name, then what its marks show (colour type, colour +/// mode, that it can't be edited). [None] and [Registration] say their mode by name. +fn row_label(lang: &str, name: &str, marks: RowMarks) -> String { + let tr = |s: &str| crate::i18n::tr(lang, s).to_string(); + let mut parts = vec![name.to_string()]; + match marks.kind { + Some(ColorType::Process) => parts.push(tr("Process")), + Some(ColorType::Spot) => parts.push(tr("Spot")), + None => {} + } + match marks.mode { + Some(RowMode::Cmyk) => parts.push(tr("CMYK")), + Some(RowMode::Rgb) => parts.push(tr("RGB")), + Some(RowMode::Gray) => parts.push(tr("Gray")), + Some(RowMode::None | RowMode::Registration) | None => {} + } + if marks.not_editable { + parts.push(tr("Cannot be edited")); + } + parts.join(", ") +} + +/// Whether accessible name `label` is swatch `name`'s row (its name, then its marks). +#[cfg(test)] +pub(crate) fn names_swatch(label: &str, name: &str) -> bool { + label.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with(", ")) +} + /// A pencil with a stroke through it: the swatch can't be edited. Drawn in code. fn paint_not_editable(p: &egui::Painter, r: egui::Rect, ink: egui::Color32) { // The pencil: its tip at the lower left, its body up to the right. @@ -379,7 +407,10 @@ fn swatch_row( let t = Tokens::get(ui.ctx()); // Rows can be dragged onto the Gradient panel's ramp. let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &sw.name)); + // What the row's marks show, for screen readers too: "Cyan, Process, CMYK". + let marks = row_marks(sw); + let spoken = row_label(&app.ui.language, &sw.name, marks); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &spoken)); resp.dnd_set_drag_payload(SwatchDrag(sw.name.clone())); if resp.dragged() && let Some(p) = ui.ctx().pointer_interact_pos() @@ -401,7 +432,6 @@ fn swatch_row( crate::widgets::paint_chip(ui.painter(), chip, c, g); ui.painter().text(row.min + vec2(28.0 + indent, 11.0), egui::Align2::LEFT_CENTER, &sw.name, egui::FontId::proportional(12.5), t.text); // InDesign's columns on the right: not editable, colour type, colour mode. - let marks = row_marks(sw); let col = |k: f32| egui::Rect::from_min_size(egui::pos2(row.max.x - 19.0 - 18.0 * k, row.min.y + 4.5), vec2(13.0, 13.0)); if marks.not_editable { paint_not_editable(ui.painter(), col(2.0), t.text_dim); @@ -1205,7 +1235,7 @@ mod gradient_panel_tests { // The thumbnail sits left of the Type row. let ty = h.get_by_label("Type:").rect(); let from = egui::pos2(ty.min.x - 21.0, ty.min.y + 12.0); - let to = h.get_by_label(label).rect().center(); + let to = h.get_by(|n| n.label().is_some_and(|l| crate::panels::swatches::names_swatch(&l, label))).rect().center(); h.event(Event::PointerMoved(from)); h.step(); h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); @@ -1317,14 +1347,14 @@ mod swatches_header_tests { let mut h = harness(app); h.run_steps(3); // Fill in front: a swatch is the fill. - h.get_by_label("C=0 M=100 Y=0 K=0").click(); + h.get_by(|n| n.label().is_some_and(|l| crate::panels::swatches::names_swatch(&l, "C=0 M=100 Y=0 K=0"))).click(); h.run_steps(2); assert_eq!(items(h.state())[0].fill.swatch, "C=0 M=100 Y=0 K=0"); // The proxy brings the stroke to the front; the next swatch is the stroke. h.get_by_label("Fill").click(); h.run_steps(2); assert!(h.state().ui.stroke_front); - h.get_by_label("C=100 M=0 Y=0 K=0").click(); + h.get_by(|n| n.label().is_some_and(|l| crate::panels::swatches::names_swatch(&l, "C=100 M=0 Y=0 K=0"))).click(); h.run_steps(2); let it = &items(h.state())[0]; assert_eq!((it.fill.swatch.as_str(), it.stroke.swatch.as_str()), ("C=0 M=100 Y=0 K=0", "C=100 M=0 Y=0 K=0")); @@ -1336,7 +1366,7 @@ mod swatches_header_tests { h.run_steps(2); assert!(!h.state().ui.formatting_affects_text); // Delete Swatch removes the swatch last clicked. - h.get_by_label("Brand").click(); + h.get_by(|n| n.label().is_some_and(|l| crate::panels::swatches::names_swatch(&l, "Brand"))).click(); h.run_steps(2); h.get_by_label("Delete Swatch").click(); h.run_steps(2); @@ -1375,7 +1405,25 @@ mod row_mark_tests { use designcraft_color::swatch::{Swatch, default_swatches}; use designcraft_color::{Color, ColorType, SwatchValue}; - use super::{RowMarks, RowMode, row_marks}; + use super::{RowMarks, RowMode, row_label, row_marks}; + + /// A row's accessible name says what its marks show. + #[test] + fn swatch_rows_say_their_marks() { + let all = default_swatches(); + let label = |name: &str| row_label("", name, row_marks(all.iter().find(|s| s.name == name).unwrap())); + assert_eq!(label("[Black]"), "[Black], Process, CMYK, Cannot be edited"); + assert_eq!(label("[None]"), "[None], Cannot be edited"); + assert_eq!(label("[Paper]"), "[Paper]"); + let cyan = all.iter().find(|s| matches!(&s.value, SwatchValue::Color { .. }) && !s.locked).unwrap(); + let spot = Swatch { + name: "Pantone".into(), + value: SwatchValue::Color { color: Color::Rgb { r: 1.0, g: 0.0, b: 0.0 }, color_type: ColorType::Spot }, + locked: false, + ..cyan.clone() + }; + assert_eq!(row_label("it", &spot.name, row_marks(&spot)), "Pantone, Tinta piatta, RGB"); + } /// The Swatches panel's right-hand columns, as InDesign's: [None], [Registration] and [Black] /// can't be edited, [Paper] can; colours show their type and mode. From 7a50e480819fc0bdd3b400d0a393064d9ba1b48f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sat, 24 Oct 2026 14:35:32 +0200 Subject: [PATCH 095/207] Text wrap: an all-null offset array keeps a custom boundary {"offset": [null, null, null, null]} parsed as an offset edit, so the custom wrap boundary (from IDML) was dropped although no side changed. An array with no number in it is no offset edit. Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/object.rs | 3 ++- crates/engine/src/tests.rs | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index b47eb3a2d..44e8dce96 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -2109,9 +2109,10 @@ fn text_wrap(s: &mut Session, p: &Value) -> Result { }; // Per side [top, left, bottom, right]; a null side keeps each object's own offset there, so // an edit of one side keeps the others of every object. + // An array with no number in it changes no offset (and so keeps a custom boundary). let off: Option<[Option; 4]> = match p.get("offset") { Some(Value::Number(n)) => Some([n.as_f64(); 4]), - Some(v) => serde_json::from_value(v.clone()).ok(), + Some(v) => serde_json::from_value::<[Option; 4]>(v.clone()).ok().filter(|o| o.iter().any(Option::is_some)), None => None, }; let invert = p.get("invert").and_then(Value::as_bool); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b1792485d..243e73759 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -868,6 +868,9 @@ fn custom_wrap_path_follows_resize_and_yields_to_offsets() { s.execute("transform.resize", &json!({"from": [b.x0, b.y0, b.x1, b.y1], "to": to})).unwrap(); let w = wrap_path(&s).unwrap().bounds().unwrap(); assert!((w.width() - 80.0).abs() < 1e-6 && (w.height() - 100.0).abs() < 1e-6, "{w:?}"); + // Offsets that are all null change nothing, so the boundary stays. + s.execute("object.textWrap", &json!({"mode": null, "offset": [null, null, null, null]})).unwrap(); + assert!(wrap_path(&s).is_some(), "an all-null offset keeps the custom boundary"); // An offset rebuilds the wrap from the frame. s.execute("object.textWrap", &json!({"offset": 6})).unwrap(); assert_eq!(wrap_path(&s), None); From ebed87e1922d1d76a90a4a3a81f66b5fb11c67b8 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sat, 24 Oct 2026 14:59:52 +0200 Subject: [PATCH 096/207] Checkbox: one widget_info call The merge of main kept both the branch's and main's accessibility call, so a click emitted two Clicked events. Main's (with the enabled state) stays. Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/widgets.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 1f97b86a8..c147c6376 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -621,8 +621,6 @@ pub fn checkbox(ui: &mut Ui, on: &mut bool, label: &str) -> Response { // Named and ticked or not for screen readers (and tests). let checked = *on; resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), checked, label)); - let checked = *on; - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, checked, label)); resp } From 2e44b1909603823e34387c478689df2bea176fa2 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sun, 25 Oct 2026 17:13:33 +0100 Subject: [PATCH 097/207] Table keys and cell commands: footnote text isn't a table cell The caret in a footnote has a CellAddr too (FOOTNOTE_TABLE). Shift+Tab and Esc there now act as outside a table, and table.nextCell, table.prevCell and table.toggleSelection refuse it. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/table.rs | 6 ++++-- crates/engine/src/tests_table.rs | 15 +++++++++++++++ crates/tools/src/tests.rs | 11 +++++++---- crates/tools/src/text.rs | 3 ++- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 55e10bd58..4dffe2a93 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -438,7 +438,9 @@ fn in_table_or_ids(s: &Session) -> std::result::Result<(), String> { fn in_cell(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; - if s.active().is_some_and(|d| d.selection.text.is_some_and(|t| t.cell.is_some())) { Ok(()) } else { Err("not in a table cell".into()) } + // Footnote text is addressed as a cell too, but isn't in a table. + let in_table = |c: CellAddr| c.footnote_id().is_none(); + if s.active().is_some_and(|d| d.selection.text.is_some_and(|t| t.cell.is_some_and(in_table))) { Ok(()) } else { Err("not in a table cell".into()) } } /// The table a command acts on and its target cell range. @@ -1594,7 +1596,7 @@ fn toggle_selection(s: &mut Session) -> Result { const ID: &str = "table.toggleSelection"; let st = s.doc_mut()?; let t = st.selection.text.ok_or_else(|| bad(ID, "no caret"))?; - let c = t.cell.ok_or_else(|| bad(ID, "not in a cell"))?; + let c = t.cell.filter(|c| c.footnote_id().is_none()).ok_or_else(|| bad(ID, "not in a cell"))?; let cells = match st.selection.cells { Some(cs) => { // A merged cell's text belongs to the cell at its top left. diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 2557b961a..a3dfd30a3 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -127,6 +127,21 @@ fn table_edit_after_anchor_removal_floors_parent_caret() { } } +/// Footnote text is addressed as a cell, but the table commands don't take it for one. +#[test] +fn the_cell_commands_refuse_footnote_text() { + let (mut s, sid, _) = session_with_frame(); + s.execute("story.setText", &json!({"story": sid, "text": "Hello"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 5, "focus": 5})).unwrap(); + s.execute("footnote.insert", &json!({"text": "Note"})).unwrap(); + let before = s.doc().unwrap().selection.clone(); + assert!(before.text.and_then(|t| t.cell).and_then(|c| c.footnote_id()).is_some(), "the caret is in the footnote"); + for cmd in ["table.toggleSelection", "table.prevCell", "table.nextCell"] { + assert!(s.execute(cmd, &json!({})).is_err(), "{cmd}"); + } + assert_eq!(s.doc().unwrap().selection, before); +} + /// Esc in a table toggles between the text and the cell (InDesign's Esc): the caret in a cell /// selects that cell, and selected cells select the first one's text; Shift+Tab goes back a cell. #[test] diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index dce3629b6..14dd65a0d 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -1469,10 +1469,13 @@ fn type_tool_keys_in_a_table_cell() { assert_eq!(t.key(&cx, ToolKey::Tab, shift), exec("table.prevCell", serde_json::json!({}))); assert_eq!(t.key(&cx, ToolKey::Tab, Mods::default()), exec("text.insert", serde_json::json!({"text": "\t"}))); assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), exec("table.toggleSelection", serde_json::json!({}))); - let s = Selection::text(designcraft_doc::TextSel::caret(sid, 0)); - let cx = ctx(&d, &s, &c, &l); - assert_eq!(t.key(&cx, ToolKey::Tab, shift), exec("text.insert", serde_json::json!({"text": "\t"}))); - assert!(matches!(t.key(&cx, ToolKey::Escape, Mods::default()).first(), Some(Action::SwitchTool(_)))); + // Out of a table, and in footnote text (addressed as a cell too). + for cell in [None, Some(designcraft_doc::CellAddr::footnote(1))] { + let s = Selection::text(designcraft_doc::TextSel { cell, ..designcraft_doc::TextSel::caret(sid, 0) }); + let cx = ctx(&d, &s, &c, &l); + assert_eq!(t.key(&cx, ToolKey::Tab, shift), exec("text.insert", serde_json::json!({"text": "\t"})), "{cell:?}"); + assert!(matches!(t.key(&cx, ToolKey::Escape, Mods::default()).first(), Some(Action::SwitchTool(_))), "{cell:?}"); + } } #[test] diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index d934ad0f2..a9b2069bd 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -364,7 +364,8 @@ impl Tool for TypeTool { let steps = if mods.cmd || mods.ctrl { 5.0 } else { 1.0 }; vec![Action::Exec("type.kernStep".into(), json!({"steps": sign * steps}))] }; - let in_cell = cx.selection.text.is_some_and(|t| t.cell.is_some()); + // A table cell's text, not a footnote's (which is addressed as a cell too). + let in_cell = cx.selection.text.is_some_and(|t| t.cell.is_some_and(|c| c.footnote_id().is_none())); match key { ToolKey::Left if mods.alt && !mods.shift => kern(-1.0), ToolKey::Right if mods.alt && !mods.shift => kern(1.0), From 61dbd814db3a1c387a3541c2ed80d32acc8e7790 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sun, 25 Oct 2026 17:14:24 +0100 Subject: [PATCH 098/207] Status bar: the page box's accessible name is in the interface language Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/chrome.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index ab96d1983..a52b164e7 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -1151,8 +1151,9 @@ pub fn status_bar(app: &mut DesignApp, ui: &mut egui::Ui) { t.text, ); ui.painter().text(fr.right_center() - vec2(5.0, 0.0), egui::Align2::RIGHT_CENTER, "▾", small.clone(), t.text); + let label = crate::i18n::tr(&app.ui.language, "Page"); fresp.widget_info(|| { - let mut info = egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, "Page"); + let mut info = egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, label); info.current_text_value = Some(name.clone()); info }); @@ -1717,6 +1718,10 @@ mod tests { assert!(!h.state().app.session.active().unwrap().editing_parents); assert_eq!(crate::canvas::current_page(&h.state().app), Some(2)); assert_eq!(shown(&h), "3"); + // Its accessible name is in the interface's language. + h.state_mut().app.ui.language = "de".into(); + h.run_steps(3); + assert!(h.query_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Seite")).is_some(), "the page box is Seite"); } /// As in InDesign, double-clicking the preflight count (or its light) shows the panel. From 5ab5f6aae6c84a56ee543ae4fcf2292193d97b71 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sun, 25 Oct 2026 17:21:05 +0100 Subject: [PATCH 099/207] Corner Options: only the corners edited change; an unknown shape is an error The dialog shows the first selected object's corners and sent all four, so other objects lost corners nobody touched. object.cornerOptions now takes null for a corner (each object keeps its own), and the dialog sends only the corners edited: changed from the first object's, or set by a linked edit (all four). A shape the dialog doesn't offer is an error that keeps it open, instead of another shape taking its place. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/object.rs | 26 +++++++--- crates/ui-egui/src/dialogs.rs | 88 ++++++++++++++++++++++++++++++--- 2 files changed, 101 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 87f601604..37c9e13f4 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -607,7 +607,7 @@ pub fn specs() -> Vec { "Corner Options…", ["Object"], None, - "{shape: none|rounded|inverseRounded|inset|bevel|fancy, size, corners?: [{shape, size}] (4 entries in path order: top-left, top-right, bottom-right, bottom-left; replaces shape and size), ids?}", + "{shape: none|rounded|inverseRounded|inset|bevel|fancy, size, corners?: [{shape, size} | null] (4 entries in path order: top-left, top-right, bottom-right, bottom-left; replaces shape and size; null keeps that corner of each object), ids?}", has_selection, corner_options ), @@ -2152,19 +2152,31 @@ fn corner_options(s: &mut Session, p: &Value) -> Result { use designcraft_geom::corners::{Corner, CornerOptions, CornerShape}; let opts = match p.get("corners") { Some(v) => { - let corners: [Corner; 4] = - serde_json::from_value(v.clone()).map_err(|e| bad("object.cornerOptions", format!("corners: four {{shape, size}} entries ({e})")))?; - if corners.iter().any(|c| !c.size.is_finite() || c.size < 0.0) { + let corners: [Option; 4] = serde_json::from_value(v.clone()) + .map_err(|e| bad("object.cornerOptions", format!("corners: four {{shape, size}} or null entries ({e})")))?; + if corners.iter().flatten().any(|c| !c.size.is_finite() || c.size < 0.0) { return Err(bad("object.cornerOptions", "corner sizes must be finite and not negative")); } - CornerOptions { corners } + corners } None => { let shape = p.get("shape").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or(CornerShape::Rounded); - CornerOptions::uniform(shape, f64_or(p, "size", 12.0)) + CornerOptions::uniform(shape, f64_or(p, "size", 12.0)).corners.map(Some) } }; - set_flag(s, p, move |i| i.corners = opts, false) + // A null corner keeps each object's own. + set_flag( + s, + p, + move |i| { + for (c, new) in i.corners.corners.iter_mut().zip(opts) { + if let Some(new) = new { + *c = new; + } + } + }, + false, + ) } /// `object.matchAttributes`: the selected objects (or `ids`) take `from`'s appearance. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a5e594fac..3d7ab9315 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6807,15 +6807,34 @@ fn seed_corner_options(app: &DesignApp, d: &mut Dialog) { } let first = corners[0]; d.fields.entry("link").or_insert(json!(corners.iter().all(|c| *c == first))); + d.fields.insert("_seed".into(), json!(corner_fields(d))); d.fields.insert("_seeded".into(), json!(true)); } +/// Each corner's size and shape fields, in `CORNERS` order. +fn corner_fields(d: &Dialog) -> Vec<(String, String)> { + CORNERS.iter().map(|(k, _)| (d.s(&format!("size{k}")), d.s(&format!("shape{k}")))).collect() +} + +/// The corners edited since the dialog opened: changed from the first object's, or set by a +/// linked edit (all four, even one that was already the value). +fn edited_corners(d: &Dialog) -> [bool; 4] { + let touched: [bool; 4] = d.fields.get("_touched").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + let seed: Vec<(String, String)> = d.fields.get("_seed").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + let now = corner_fields(d); + std::array::from_fn(|i| touched[i] || seed.get(i) != now.get(i)) +} + /// Linked corners: a corner changed since the dialog last looked (size or shape, typed or set /// over the control channel) is all four. fn link_corners(d: &mut Dialog) { - let now: Vec<(String, String)> = CORNERS.iter().map(|(k, _)| (d.s(&format!("size{k}")), d.s(&format!("shape{k}")))).collect(); + let now = corner_fields(d); let seen: Vec<(String, String)> = d.fields.get("_corners").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_else(|| now.clone()); let mut now = now; + let mut touched: [bool; 4] = d.fields.get("_touched").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + for (t, (a, b)) in touched.iter_mut().zip(now.iter().zip(&seen)) { + *t |= a != b; + } if d.b("link") && let Some(i) = now.iter().zip(&seen).position(|(a, b)| a != b) && let Some(changed) = now.get(i).cloned() @@ -6825,20 +6844,28 @@ fn link_corners(d: &mut Dialog) { d.fields.insert(format!("shape{k}"), json!(changed.1)); } now = vec![changed; CORNERS.len()]; + touched = [true; 4]; } d.fields.insert("_corners".into(), json!(now)); + d.fields.insert("_touched".into(), json!(touched)); } -/// `object.cornerOptions`' params from the dialog. A size that does not parse is an error to show. +/// `object.cornerOptions`' params from the dialog: the corners edited, the others null (each +/// selected object keeps its own). A size that does not parse, or a shape the dialog doesn't +/// offer, is an error to show. fn corner_options_params(d: &Dialog) -> Result { use designcraft_geom::corners::CornerShape; + let edited = edited_corners(d); let mut corners = Vec::with_capacity(CORNERS.len()); - for (k, _) in &CORNERS { + for ((k, _), edited) in CORNERS.iter().zip(edited) { let key = format!("size{k}"); let size = d.m(&key).filter(|v| v.is_finite() && *v >= 0.0).ok_or_else(|| format!("Enter a valid corner size, e.g. 12 or 3 mm: {}", d.s(&key)))?; - let shape: CornerShape = d.fields.get(&format!("shape{k}")).and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); - corners.push(json!({"shape": shape, "size": size})); + let shape: CornerShape = match d.fields.get(&format!("shape{k}")) { + None => CornerShape::default(), + Some(v) => serde_json::from_value(v.clone()).map_err(|_| format!("Choose a corner shape: {v} isn't one"))?, + }; + corners.push(if edited { json!({"shape": shape, "size": size}) } else { Value::Null }); } Ok(json!({"corners": corners})) } @@ -7427,14 +7454,63 @@ mod tests { dialog_frame(&mut app, &ctx, screen, vec![]); let d = app.ui.dialog.as_ref().unwrap(); assert_eq!((d.s("shape1"), d.s("shape0"), d.b("link")), ("rounded".into(), "none".into(), false)); - // A size that does not parse keeps the dialog open with the error. + // A size that does not parse keeps the dialog open with the error; so does a shape the + // dialog doesn't offer (rather than another shape taking its place). set(&mut app, "size0", json!("abc")); assert!(confirm(&mut app).is_err()); assert!(app.ui.dialog.is_some()); + set(&mut app, "size0", json!("0")); + set(&mut app, "shape0", json!("spiky")); + assert!(confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some()); cancel(&mut app); assert_eq!(corners(&app), c); } + /// Several objects: the dialog shows the first one's corners, and OK sets only the corners + /// edited; the others keep each object's own. A linked edit sets all four. + #[test] + fn corner_options_keep_each_objects_untouched_corners() { + use designcraft_geom::corners::CornerShape; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let b = app.run("frame.create", json!({"rect": [272, 72, 400, 200]})).unwrap()["id"].as_u64().unwrap(); + app.run("object.cornerOptions", json!({"ids": [b], "shape": "bevel", "size": 12})).unwrap(); + app.run("selection.set", json!({"ids": [a, b]})).unwrap(); + let corners = |app: &DesignApp, id: u64| app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().corners.corners; + let set = |app: &mut DesignApp, k: &str, v: Value| { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + }; + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); + let b0 = corners(&app, b); + // Unlinked: only the top-right corner rounds, on both. + crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); + dialog_frame(&mut app, &ctx, screen, vec![]); + set(&mut app, "link", json!(false)); + set(&mut app, "size1", json!("1p0")); + set(&mut app, "shape1", json!("rounded")); + dialog_frame(&mut app, &ctx, screen, vec![]); + confirm(&mut app).unwrap(); + let (ca, cb) = (corners(&app, a), corners(&app, b)); + assert_eq!(ca.map(|c| c.shape), [CornerShape::None, CornerShape::Rounded, CornerShape::None, CornerShape::None]); + assert_eq!(cb.map(|c| c.shape), [CornerShape::Bevel, CornerShape::Rounded, CornerShape::Bevel, CornerShape::Bevel], "b keeps its others"); + assert_eq!((cb[0], cb[2], cb[3]), (b0[0], b0[2], b0[3])); + assert_eq!(ca[1], cb[1]); + // Linked: a corner set to the first object's own value still sets all four, on both. + crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); + dialog_frame(&mut app, &ctx, screen, vec![]); + set(&mut app, "link", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + set(&mut app, "size0", json!("0p6")); + dialog_frame(&mut app, &ctx, screen, vec![]); + confirm(&mut app).unwrap(); + let (ca, cb) = (corners(&app, a), corners(&app, b)); + assert_eq!(ca, cb, "all four, on both"); + assert!(ca.iter().all(|c| c.shape == CornerShape::None && (c.size - 6.0).abs() < 1e-6), "{ca:?}"); + } + #[test] fn step_and_repeat_dialog_previews_and_makes_the_copies_as_one_undo_step() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 7ce4552e4f0fb4d86b5e0f2c2d72a1394ca718b6 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sun, 25 Oct 2026 17:22:05 +0100 Subject: [PATCH 100/207] Table of Contents: Enter on the options toggle only toggles egui activates a focused button with Enter, and the footer, drawn after it, took the same Enter for OK: the dialog closed and made the table of contents. The toggle now consumes that Enter. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b18676ebc..564f7fe06 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6560,7 +6560,13 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.add_space(8.0); ui.horizontal(|ui| { crate::rtl::label(ui, egui::RichText::new(tr("Styles in Table of Contents")).font(semibold(12.0))); - if ui.button(tr(if expanded { "Fewer Options" } else { "More Options" })).clicked() { + let toggle = ui.button(tr(if expanded { "Fewer Options" } else { "More Options" })); + // Enter on the focused toggle toggles it (egui activates a focused button with Enter): + // the footer drawn after it mustn't take that Enter for OK too. + if toggle.has_focus() { + ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); + } + if toggle.clicked() { expanded = !expanded; } }); @@ -8399,6 +8405,27 @@ mod tests { assert_eq!(app.session.doc().unwrap().revision, revision); } + /// Enter on the focused Fewer Options / More Options button toggles the options; it doesn't + /// also make the table of contents. + #[test] + fn enter_on_the_toc_options_toggle_only_toggles() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.newSample", json!({})).unwrap(); + open_toc(&mut app); + let revision = app.session.doc().unwrap().revision; + let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(|ui, app: &mut DesignApp| show(app, ui.ctx()), app); + h.run_steps(3); + h.get_by_label("Fewer Options").focus(); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let d = h.state().ui.dialog.as_ref().expect("the dialog stays open"); + assert_eq!((d.id.as_str(), d.b("moreOptions")), (TOC, false), "the options are fewer"); + assert_eq!(h.state().session.doc().unwrap().revision, revision, "no table of contents made"); + assert!(h.query_by_label("More Options").is_some()); + } + #[test] fn toc_dialog_adds_a_style_and_ok_makes_the_table_of_contents() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 976f0d63ba609f33c6e1392d12bb427bbbb8751a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sun, 25 Oct 2026 17:22:39 +0100 Subject: [PATCH 101/207] New Section: the dialog's title in every interface language Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n.rs | 1 + crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + 11 files changed, 11 insertions(+) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index e74cc0f73..b408c586a 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1075,6 +1075,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Load Styles", ["Formate laden", "Charger les styles", "Cargar estilos", "スタイルを読み込み", "加载样式"]), + ("New Section", ["Neuer Abschnitt", "Nouvelle section", "Nueva sección", "新規セクション", "新建章节"]), ("Restart Numbering", ["Nummerierung neu beginnen", "Recommencer la numérotation", "Reiniciar numeración", "番号の振り直し", "重新开始编号"]), ("Continue Numbering", ["Nummerierung fortführen", "Poursuivre la numérotation", "Continuar numeración", "番号の継続", "继续编号"]), ("Keep Options…", ["Umbruchoptionen …", "Options d'enchaînement…", "Opciones de separación…", "段落分離禁止オプション…", "保持选项…"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 49fd3046e..bf6a50fa6 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("New Section", "قسم جديد"), ("Restart Numbering", "إعادة الترقيم"), ("Continue Numbering", "متابعة الترقيم"), ("Keep Options…", "خيارات الإبقاء…"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 6f47fdaff..a6442ab94 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("New Section", "Nový oddíl"), ("Restart Numbering", "Začít číslování znovu"), ("Continue Numbering", "Pokračovat v číslování"), ("Keep Options…", "Volby zachování…"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 97b48316b..53dd46e7a 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("New Section", "Neuer Abschnitt"), ("Restart Numbering", "Nummerierung neu beginnen"), ("Continue Numbering", "Nummerierung fortführen"), ("Keep Options…", "Umbruchoptionen …"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 4a3656699..3e806899b 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("New Section", "Nuova sezione"), ("Restart Numbering", "Riavvia numerazione"), ("Continue Numbering", "Continua numerazione"), ("Keep Options…", "Opzioni di separazione…"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 6180858af..302aa8b11 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("New Section", "新規セクション"), ("Restart Numbering", "番号の振り直し"), ("Continue Numbering", "番号の継続"), ("Keep Options…", "段落分離禁止オプション…"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 4767b6c30..6bd45a2db 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("New Section", "ახალი სექცია"), ("Restart Numbering", "ნუმერაციის თავიდან დაწყება"), ("Continue Numbering", "ნუმერაციის გაგრძელება"), ("Keep Options…", "შენარჩუნების პარამეტრები…"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index bf0c7600b..d6a5f296c 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("New Section", "Nowa sekcja"), ("Restart Numbering", "Zacznij numerowanie od nowa"), ("Continue Numbering", "Kontynuuj numerowanie"), ("Keep Options…", "Opcje utrzymywania…"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 59c4f2e23..133ca9f98 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("New Section", "Nova seção"), ("Restart Numbering", "Reiniciar numeração"), ("Continue Numbering", "Continuar numeração"), ("Keep Options…", "Opções de manutenção…"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 412faea7e..abd94d8c1 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("New Section", "Новый раздел"), ("Restart Numbering", "Начать нумерацию заново"), ("Continue Numbering", "Продолжить нумерацию"), ("Keep Options…", "Параметры удержания…"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 62c594fd1..77b456a1a 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("New Section", "Новий розділ"), ("Restart Numbering", "Почати нумерацію знову"), ("Continue Numbering", "Продовжити нумерацію"), ("Keep Options…", "Параметри утримання…"), From 520fd13b0b6a2e71de7fc9a9bab871975fe35552 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sun, 25 Oct 2026 17:45:09 +0100 Subject: [PATCH 102/207] Effects dialog: drop a line left over from the merge Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/effects_dialog.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/ui-egui/src/effects_dialog.rs b/crates/ui-egui/src/effects_dialog.rs index 3f85c0252..9a4d45489 100644 --- a/crates/ui-egui/src/effects_dialog.rs +++ b/crates/ui-egui/src/effects_dialog.rs @@ -313,7 +313,6 @@ fn dial_angle(c: egui::Pos2, p: egui::Pos2) -> Option { let d = p - c; // Straight left is 180° (not −180°, which a negative zero would give). (d.length() > 2.0).then(|| f64::from((-d.y).atan2(d.x)).to_degrees().round()).map(|a| if a <= -180.0 { 180.0 } else { a }) - r } fn flag(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str], label: &str) { From 57e3a9a73b557d3fd8b1c725b61449d7497ea57f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sun, 25 Oct 2026 17:46:52 +0100 Subject: [PATCH 103/207] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/table.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index edc0b8963..cbf4d2fde 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -440,7 +440,11 @@ fn in_cell(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; // Footnote text is addressed as a cell too, but isn't in a table. let in_table = |c: CellAddr| c.footnote_id().is_none(); - if s.active().is_some_and(|d| d.selection.text.is_some_and(|t| t.cell.is_some_and(in_table))) { Ok(()) } else { Err("not in a table cell".into()) } + if s.active().is_some_and(|d| d.selection.text.is_some_and(|t| t.cell.is_some_and(in_table))) { + Ok(()) + } else { + Err("not in a table cell".into()) + } } /// The table a command acts on and its target cell range. From e96baa7f7f33de500e847047753844106bb3f4e1 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 12:19:19 +0100 Subject: [PATCH 104/207] Table keys: the selection helpers by their paths on main's split modules Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/table.rs | 2 +- crates/engine/src/cmd/table/registry.rs | 2 +- crates/engine/src/cmd/table/selection.rs | 2 +- crates/engine/src/cmd/text/edit.rs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 2a124e7a9..7c2f182ea 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -28,4 +28,4 @@ mod styles; mod target; pub use registry::specs; -pub(crate) use selection::step_cell; +pub(crate) use selection::{one_selected_cell_to_text, step_cell}; diff --git a/crates/engine/src/cmd/table/registry.rs b/crates/engine/src/cmd/table/registry.rs index 19cad9907..ee782609c 100644 --- a/crates/engine/src/cmd/table/registry.rs +++ b/crates/engine/src/cmd/table/registry.rs @@ -1,7 +1,7 @@ use super::cell::{get_cell_stroke, set_cell}; use super::content::{drop_cells, place_graphic, sort_rows}; use super::options::{get, options}; -use super::selection::{move_rc, select, step_cell}; +use super::selection::{move_rc, select, step_cell, toggle_selection}; use super::sizing::{distribute_cols, distribute_rows, resize_border, set_col_width, set_row_height}; use super::structure::{ can_convert_rows, convert_from_text, convert_rows, convert_to_text, delete_cols, delete_rows, delete_table, insert, insert_col, insert_row, diff --git a/crates/engine/src/cmd/table/selection.rs b/crates/engine/src/cmd/table/selection.rs index 00582fb4d..656dfdd78 100644 --- a/crates/engine/src/cmd/table/selection.rs +++ b/crates/engine/src/cmd/table/selection.rs @@ -84,7 +84,7 @@ pub(crate) fn one_selected_cell_to_text(s: &mut Session) -> Result<()> { /// Esc in a table: text in a cell becomes that cell's selection, and selected cells the text of /// the first one (InDesign toggles between the two). -fn toggle_selection(s: &mut Session) -> Result { +pub(super) fn toggle_selection(s: &mut Session) -> Result { const ID: &str = "table.toggleSelection"; let st = s.doc_mut()?; let t = st.selection.text.ok_or_else(|| bad(ID, "no caret"))?; diff --git a/crates/engine/src/cmd/text/edit.rs b/crates/engine/src/cmd/text/edit.rs index 7d896c999..21888cb22 100644 --- a/crates/engine/src/cmd/text/edit.rs +++ b/crates/engine/src/cmd/text/edit.rs @@ -18,7 +18,7 @@ pub(super) fn insert(s: &mut Session, p: &Value) -> Result { return crate::cmd::table::step_cell(s, true); } // Typing with one cell selected replaces its text, as InDesign. - super::table::one_selected_cell_to_text(s)?; + crate::cmd::table::one_selected_cell_to_text(s)?; let raw = p.get("raw").and_then(Value::as_bool).unwrap_or(false); let text = if s.prefs.typographers_quotes && !raw { smart_quotes(s, &text) } else { text }; let tracking = s.doc()?.doc.settings.track_changes; From e779d9eea2a0d34a8502de686c71f505a679766e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 12:19:27 +0100 Subject: [PATCH 105/207] Hidden characters: the test reaches frame_hidden_marks in its new module Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/canvas/text_overlay.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3a1f8c733..5c0926b44 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -56,7 +56,7 @@ use scroll::{mods, navigation_space, scrollbars, spring_zoom_mode}; pub use text_overlay::selected_text; use text_overlay::{draw_dynamic_spelling, draw_hidden_characters}; #[cfg(test)] -use text_overlay::{draw_text_selection, hidden_marks}; +use text_overlay::{draw_text_selection, frame_hidden_marks, hidden_marks}; use tool_overlays::draw_tool_overlays; pub(crate) use view::page_in_view; use view::present_fit; diff --git a/crates/ui-egui/src/canvas/text_overlay.rs b/crates/ui-egui/src/canvas/text_overlay.rs index 6af92d842..ebf2a6349 100644 --- a/crates/ui-egui/src/canvas/text_overlay.rs +++ b/crates/ui-egui/src/canvas/text_overlay.rs @@ -288,7 +288,7 @@ pub(super) fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, x /// the line's ascent): its lines' marks, then its tables' — each text cell's own marks (nested /// tables included) with its # where its text ends, and a table paragraph's ¶ after the last /// row's bottom right corner. A table's own glyph-less line has no marks: its ¶ is that one. -fn frame_hidden_marks(text: &str, ft: &compose::FrameText) -> Vec<(Point, &'static str, bool, f64)> { +pub(super) fn frame_hidden_marks(text: &str, ft: &compose::FrameText) -> Vec<(Point, &'static str, bool, f64)> { let table_lines: Vec = ft.tables.iter().map(|t| t.line).collect(); let mut out: Vec<_> = ft .lines From d4ecb552f5a0aacebf9f428a4f4a1adcc174403a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 12:38:46 +0100 Subject: [PATCH 106/207] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/pages/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/pages/tests.rs b/crates/ui-egui/src/panels/pages/tests.rs index 037ef6a89..b0dcafb06 100644 --- a/crates/ui-egui/src/panels/pages/tests.rs +++ b/crates/ui-egui/src/panels/pages/tests.rs @@ -343,7 +343,7 @@ fn the_panel_menu_follows_indesigns_order() { assert_eq!(h.state().session.doc().unwrap().doc.page_count(), 3); h.get_by_label("Insert Pages…").click(); h.run_steps(3); - assert!(h.state().ui.dialog.as_ref().is_some_and(|d| d.id == "insertPages")); + assert!(h.state().ui.dialog.as_ref().is_some_and(|d| d.id == "insertPages")); } #[test] From 08e954515e79fd635ca62791558510aefb067d44 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 12:43:14 +0100 Subject: [PATCH 107/207] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/chrome/tests.rs | 82 +++++++++++++++--------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 48f79c819..eb84e74ca 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -180,48 +180,48 @@ fn status_bar_error_count_opens_the_preflight_panel() { assert!(crate::panel_docking::showing(&h.state().app, "preflight")); } - /// The course's chapter 25: while a parent is shown, the status bar's page box names it - /// ("A-Parent"), as InDesign's. Its menu lists the pages, then the parents: a parent shows - /// that parent, a page leaves the parents for that page. - #[test] - fn the_page_box_names_the_parent_in_view_and_lists_the_parents() { - use egui::accesskit::Role; - let mut app = app(); - app.run("file.new", serde_json::json!({"pages": 4, "facingPages": true})).unwrap(); - app.run("layout.parents.new", serde_json::json!({"prefix": "B", "name": "Cover"})).unwrap(); - app.ui.dock_tab = "properties".into(); - let mut h = test_window::open(app, vec2(1200.0, 700.0)); - let shown = |h: &egui_kittest::Harness<'static, test_window::Window>| { - let b = h.get_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Page")); - b.value().unwrap_or_default() - }; - assert_eq!(shown(&h), "1"); - h.state_mut().app.run("layout.parents.edit", serde_json::json!({"on": true})).unwrap(); - h.run_steps(6); - assert_eq!(shown(&h), "A-Parent"); - // The menu: a parent. - h.get_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Page")).click(); - h.run_steps(3); - for name in ["1", "2", "3", "4", "A-Parent"] { - assert!(h.query_by_label(name).is_some(), "{name} is listed"); - } - h.get_by_label("B-Cover").click(); - h.run_steps(6); - assert_eq!(shown(&h), "B-Cover"); - assert_eq!(crate::canvas::current_parent_page(&h.state().app).map(|(i, _)| i), Some(1)); - // A page: the parents are left. - h.get_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Page")).click(); - h.run_steps(3); - h.get_by_label("3").click(); - h.run_steps(6); - assert!(!h.state().app.session.active().unwrap().editing_parents); - assert_eq!(crate::canvas::current_page(&h.state().app), Some(2)); - assert_eq!(shown(&h), "3"); - // Its accessible name is in the interface's language. - h.state_mut().app.ui.language = "de".into(); - h.run_steps(3); - assert!(h.query_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Seite")).is_some(), "the page box is Seite"); +/// The course's chapter 25: while a parent is shown, the status bar's page box names it +/// ("A-Parent"), as InDesign's. Its menu lists the pages, then the parents: a parent shows +/// that parent, a page leaves the parents for that page. +#[test] +fn the_page_box_names_the_parent_in_view_and_lists_the_parents() { + use egui::accesskit::Role; + let mut app = app(); + app.run("file.new", serde_json::json!({"pages": 4, "facingPages": true})).unwrap(); + app.run("layout.parents.new", serde_json::json!({"prefix": "B", "name": "Cover"})).unwrap(); + app.ui.dock_tab = "properties".into(); + let mut h = test_window::open(app, vec2(1200.0, 700.0)); + let shown = |h: &egui_kittest::Harness<'static, test_window::Window>| { + let b = h.get_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Page")); + b.value().unwrap_or_default() + }; + assert_eq!(shown(&h), "1"); + h.state_mut().app.run("layout.parents.edit", serde_json::json!({"on": true})).unwrap(); + h.run_steps(6); + assert_eq!(shown(&h), "A-Parent"); + // The menu: a parent. + h.get_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Page")).click(); + h.run_steps(3); + for name in ["1", "2", "3", "4", "A-Parent"] { + assert!(h.query_by_label(name).is_some(), "{name} is listed"); } + h.get_by_label("B-Cover").click(); + h.run_steps(6); + assert_eq!(shown(&h), "B-Cover"); + assert_eq!(crate::canvas::current_parent_page(&h.state().app).map(|(i, _)| i), Some(1)); + // A page: the parents are left. + h.get_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Page")).click(); + h.run_steps(3); + h.get_by_label("3").click(); + h.run_steps(6); + assert!(!h.state().app.session.active().unwrap().editing_parents); + assert_eq!(crate::canvas::current_page(&h.state().app), Some(2)); + assert_eq!(shown(&h), "3"); + // Its accessible name is in the interface's language. + h.state_mut().app.ui.language = "de".into(); + h.run_steps(3); + assert!(h.query_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Seite")).is_some(), "the page box is Seite"); +} /// As in InDesign, double-clicking the preflight count (or its light) shows the panel. #[test] From 3de3efccafdf84725588e4bec78a08f20e6eb9ed Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 12:50:00 +0100 Subject: [PATCH 108/207] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/tests.rs | 112 +++++++++--------- 1 file changed, 56 insertions(+), 56 deletions(-) diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 91004afa9..96a1b14b6 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -638,65 +638,65 @@ fn clicking_a_swatch_chip_in_the_appearance_fill_menu_applies_it() { assert_eq!(after, name, "clicking the swatch chip applies it"); } - /// The course's chapter 23: a double-click on Properties › Appearance › Fill opens the Color - /// Picker with the fill's colour, as InDesign's (a click opens the swatches). For selected - /// text, the text's Fill chip opens it for the text. - #[test] - fn double_clicking_a_fill_chip_opens_the_color_picker() { - let double_click = |h: &mut Harness<'_, DesignApp>, label: &str| { - let at = h.get_by_label(label).rect().center(); - h.hover_at(at); +/// The course's chapter 23: a double-click on Properties › Appearance › Fill opens the Color +/// Picker with the fill's colour, as InDesign's (a click opens the swatches). For selected +/// text, the text's Fill chip opens it for the text. +#[test] +fn double_clicking_a_fill_chip_opens_the_color_picker() { + let double_click = |h: &mut Harness<'_, DesignApp>, label: &str| { + let at = h.get_by_label(label).rect().center(); + h.hover_at(at); + h.step(); + for pressed in [true, false, true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); h.step(); - for pressed in [true, false, true, false] { - h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); - h.step(); - } - h.run_steps(3); - }; - // Frames 1 ms apart, so two clicks make a double-click. - let panel = |app: DesignApp| { - let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).with_step_dt(0.001).build_ui_state( - |ui, app: &mut DesignApp| { - let id = egui::Id::new("test_fonts"); - if ui.data(|d| d.get_temp::(id)).is_none() { - crate::theme::install_fonts(ui.ctx(), ""); - ui.data_mut(|d| d.insert_temp(id, true)); - return; - } - show(app, ui); - }, - app, - ); - h.run_steps(3); - h - }; - let frame = || { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); - app.run("selection.set", json!({"ids": [id]})).unwrap(); - app.run("object.fill", json!({"swatch": "C=0 M=0 Y=100 K=0"})).unwrap(); - app - }; - let mut h = panel(frame()); - double_click(&mut h, "Fill swatch"); - assert!(h.query_by_label("Tint:").is_none(), "the swatches popup isn't left open too"); - let d = h.state().ui.dialog.clone().expect("the Color Picker"); - assert_eq!((d.id.as_str(), d.s("target"), d.s("model"), d.b("text")), ("colorPicker", "fill".into(), "cmyk".into(), false)); - assert_eq!(d.fields["cmyk"], json!([0.0, 0.0, 100.0, 0.0])); - let mut h = panel(frame()); - double_click(&mut h, "Stroke swatch"); - assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.s("target")), Some("stroke".into())); - // Selected text: its own Fill chip. + } + h.run_steps(3); + }; + // Frames 1 ms apart, so two clicks make a double-click. + let panel = |app: DesignApp| { + let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).with_step_dt(0.001).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + h + }; + let frame = || { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({})).unwrap(); - let f = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Lemon"})).unwrap(); - app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 5})).unwrap(); - let mut h = panel(app); - double_click(&mut h, "Text fill swatch"); - let d = h.state().ui.dialog.clone().expect("the Color Picker"); - assert_eq!((d.id.as_str(), d.s("target"), d.b("text")), ("colorPicker", "fill".into(), true)); - } + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + app.run("object.fill", json!({"swatch": "C=0 M=0 Y=100 K=0"})).unwrap(); + app + }; + let mut h = panel(frame()); + double_click(&mut h, "Fill swatch"); + assert!(h.query_by_label("Tint:").is_none(), "the swatches popup isn't left open too"); + let d = h.state().ui.dialog.clone().expect("the Color Picker"); + assert_eq!((d.id.as_str(), d.s("target"), d.s("model"), d.b("text")), ("colorPicker", "fill".into(), "cmyk".into(), false)); + assert_eq!(d.fields["cmyk"], json!([0.0, 0.0, 100.0, 0.0])); + let mut h = panel(frame()); + double_click(&mut h, "Stroke swatch"); + assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.s("target")), Some("stroke".into())); + // Selected text: its own Fill chip. + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Lemon"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 5})).unwrap(); + let mut h = panel(app); + double_click(&mut h, "Text fill swatch"); + let d = h.state().ui.dialog.clone().expect("the Color Picker"); + assert_eq!((d.id.as_str(), d.s("target"), d.b("text")), ("colorPicker", "fill".into(), true)); +} fn panel(app: DesignApp) -> Harness<'static, DesignApp> { let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( From f56aa76ada740497a97e0c524549bd43b9dcde03 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 12:50:08 +0100 Subject: [PATCH 109/207] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/tests.rs | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 74fd68ba7..d8b965fb5 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -655,21 +655,21 @@ fn panel(app: DesignApp) -> Harness<'static, DesignApp> { h } - /// The course's chapter 21: Properties › Bullets and Numbering › Options opens the Bullets - /// and Numbering dialog, as InDesign's, not the Paragraph panel. - #[test] - fn bullets_options_open_the_bullets_and_numbering_dialog() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let f = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "4 eggs"})).unwrap(); - app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 6})).unwrap(); - let mut h = panel(app); - h.get_by_label("Options").click(); - h.run_steps(3); - let d = h.state().ui.dialog.clone().expect("a dialog"); - assert_eq!((d.id.as_str(), d.s("section").as_str()), ("paragraphLocal", "bullets")); - assert_ne!(h.state().ui.open_panel.as_deref(), Some("paragraph")); - } +/// The course's chapter 21: Properties › Bullets and Numbering › Options opens the Bullets +/// and Numbering dialog, as InDesign's, not the Paragraph panel. +#[test] +fn bullets_options_open_the_bullets_and_numbering_dialog() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "4 eggs"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 6})).unwrap(); + let mut h = panel(app); + h.get_by_label("Options").click(); + h.run_steps(3); + let d = h.state().ui.dialog.clone().expect("a dialog"); + assert_eq!((d.id.as_str(), d.s("section").as_str()), ("paragraphLocal", "bullets")); + assert_ne!(h.state().ui.open_panel.as_deref(), Some("paragraph")); +} /// The course's chapter 21: Properties' Bulleted List button gives the selected paragraphs /// bullets with InDesign's hanging indent; pressed again, both go. From 299a65c89042e8f0f14fc963c3b3be4984da9827 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 13:16:00 +0100 Subject: [PATCH 110/207] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus/tests.rs | 46 +++++++++++++++---------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index d65a99afc..a4d907e7a 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -29,29 +29,29 @@ fn view_items_are_in_the_view_menu_as_in_indesign() { assert_eq!(ui_label("view.togglePreview").and_then(|c| c.1), Some("W")); } - /// Object › Fitting in InDesign's groups: the proportional fits, the frame and content fits, - /// Clear Frame Fitting Options, then the dialog. - #[test] - fn the_fitting_menu_is_grouped_as_indesigns() { - let items = MENUS.iter().find(|(t, _)| *t == "Object").map(|(_, items)| *items).unwrap(); - let start = items.iter().position(|e| *e == ">Fitting").unwrap() + 1; - let end = start + items[start..].iter().position(|e| *e == "<").unwrap(); - assert_eq!( - &items[start..end], - [ - "ui:fit.fillFrameProportionally", - "ui:fit.contentProportionally", - "-", - "ui:fit.frameToContent", - "ui:fit.contentToFrame", - "ui:fit.centerContent", - "-", - "cmd:object.clearFittingOptions", - "-", - "ui:app.fittingOptions", - ] - ); - } +/// Object › Fitting in InDesign's groups: the proportional fits, the frame and content fits, +/// Clear Frame Fitting Options, then the dialog. +#[test] +fn the_fitting_menu_is_grouped_as_indesigns() { + let items = MENUS.iter().find(|(t, _)| *t == "Object").map(|(_, items)| *items).unwrap(); + let start = items.iter().position(|e| *e == ">Fitting").unwrap() + 1; + let end = start + items[start..].iter().position(|e| *e == "<").unwrap(); + assert_eq!( + &items[start..end], + [ + "ui:fit.fillFrameProportionally", + "ui:fit.contentProportionally", + "-", + "ui:fit.frameToContent", + "ui:fit.contentToFrame", + "ui:fit.centerContent", + "-", + "cmd:object.clearFittingOptions", + "-", + "ui:app.fittingOptions", + ] + ); +} /// The submenu paths of a menu bar menu ("View/Screen Mode"), as Edit › Menus keys them. fn submenu_paths(title: &str) -> Vec { From 35e2f8e66c8dfcee1c45516fc6ea95e2553c3488 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 13:51:04 +0100 Subject: [PATCH 111/207] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus/tests.rs | 140 +++++++++++++++--------------- 1 file changed, 70 insertions(+), 70 deletions(-) diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 60455a4fc..5f7f5b93d 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -6,80 +6,80 @@ fn menu_ids(title: &str) -> Vec<&'static str> { items.iter().filter_map(|e| e.split_once(':').map(|(_, rest)| rest.split('|').next().unwrap_or(rest))).collect() } - /// The Object menu's top level in InDesign's order and groups (as InDesign 2023 shows it in - /// Learn Skills Daily's course), with Paths holding Join, Open and Close Path, Convert Shape - /// only shapes, and a Convert Point submenu. - #[test] - fn the_object_menu_is_in_indesigns_order() { - let items = MENUS.iter().find(|(t, _)| *t == "Object").map(|(_, items)| *items).unwrap(); - // The top-level entries: submenus by name, separators as "-", items by command id. - let mut top = Vec::new(); - let mut depth = 0; - for e in items { - match *e { - "<" => depth -= 1, - e if e.starts_with('>') => { - if depth == 0 { - top.push(e.trim_start_matches('>')); - } - depth += 1; +/// The Object menu's top level in InDesign's order and groups (as InDesign 2023 shows it in +/// Learn Skills Daily's course), with Paths holding Join, Open and Close Path, Convert Shape +/// only shapes, and a Convert Point submenu. +#[test] +fn the_object_menu_is_in_indesigns_order() { + let items = MENUS.iter().find(|(t, _)| *t == "Object").map(|(_, items)| *items).unwrap(); + // The top-level entries: submenus by name, separators as "-", items by command id. + let mut top = Vec::new(); + let mut depth = 0; + for e in items { + match *e { + "<" => depth -= 1, + e if e.starts_with('>') => { + if depth == 0 { + top.push(e.trim_start_matches('>')); } - e if depth == 0 => top.push(e.split_once(':').map_or(e, |(_, r)| r.split('|').next().unwrap_or(r))), - _ => {} + depth += 1; } + e if depth == 0 => top.push(e.split_once(':').map_or(e, |(_, r)| r.split('|').next().unwrap_or(r))), + _ => {} } - assert_eq!( - top, - [ - "Transform", - "Transform Again", - "Arrange", - "Select", - "-", - "object.group", - "object.ungroup", - "object.lock", - "object.unlockAll", - "object.hide", - "object.showAll", - "-", - "app.qrCode", - "-", - "object.textFrameOptions", - "object.primaryTextFrame", - "Anchored Object", - "-", - "Fitting", - "Content", - "-", - "Effects", - "object.cornerOptions", - "-", - "app.layerOptions", - "object.exportOptions", - "Captions", - "Clipping Path", - "-", - "Flex Layout", - "-", - "Paths", - "Pathfinder", - "Convert Shape", - "Convert Point", - ] - ); - let ids = menu_ids("Object"); - for id in ["path.join", "path.reverse", "path.convertPoints"] { - assert!(ids.contains(&id), "{id}"); - } - let sub = |name: &str| { - let i = items.iter().position(|e| *e == format!(">{name}")).unwrap(); - items[i + 1..].iter().take_while(|e| **e != "<").copied().collect::>() - }; - assert!(sub("Paths").iter().any(|e| e.contains("|Open Path|")) && sub("Paths").iter().any(|e| e.contains("|Close Path|"))); - assert!(!sub("Convert Shape").iter().any(|e| e.contains("openPath") || e.contains("closedPath")), "shapes only"); - assert_eq!(sub("Convert Point").len(), 4); } + assert_eq!( + top, + [ + "Transform", + "Transform Again", + "Arrange", + "Select", + "-", + "object.group", + "object.ungroup", + "object.lock", + "object.unlockAll", + "object.hide", + "object.showAll", + "-", + "app.qrCode", + "-", + "object.textFrameOptions", + "object.primaryTextFrame", + "Anchored Object", + "-", + "Fitting", + "Content", + "-", + "Effects", + "object.cornerOptions", + "-", + "app.layerOptions", + "object.exportOptions", + "Captions", + "Clipping Path", + "-", + "Flex Layout", + "-", + "Paths", + "Pathfinder", + "Convert Shape", + "Convert Point", + ] + ); + let ids = menu_ids("Object"); + for id in ["path.join", "path.reverse", "path.convertPoints"] { + assert!(ids.contains(&id), "{id}"); + } + let sub = |name: &str| { + let i = items.iter().position(|e| *e == format!(">{name}")).unwrap(); + items[i + 1..].iter().take_while(|e| **e != "<").copied().collect::>() + }; + assert!(sub("Paths").iter().any(|e| e.contains("|Open Path|")) && sub("Paths").iter().any(|e| e.contains("|Close Path|"))); + assert!(!sub("Convert Shape").iter().any(|e| e.contains("openPath") || e.contains("closedPath")), "shapes only"); + assert_eq!(sub("Convert Point").len(), 4); +} #[test] fn view_items_are_in_the_view_menu_as_in_indesign() { From e7a29a8ad5968f873b5bbbdb9fa5b26cf0413b6a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 13:55:33 +0100 Subject: [PATCH 112/207] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/tests.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 900407f5e..7d1f217b7 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3469,20 +3469,20 @@ fn guide_counts(app: &DesignApp) -> Vec { } #[test] - fn create_guides_dialog_covers_the_spread_in_view() { +fn create_guides_dialog_covers_the_spread_in_view() { let mut app = facing_app(6); - // As in InDesign, both pages of the facing spread in view get the guides. + // As in InDesign, both pages of the facing spread in view get the guides. crate::canvas::go_to_page(&mut app, 1); crate::menus::activate(&mut app, "layout.createGuides", &Value::Null); - assert_eq!(app.ui.dialog.as_ref().unwrap().s("target"), "spread"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("target"), "spread"); ctl(&mut app, "ui.dialog.set", json!({"field": "columns", "value": "2"})); - assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); - assert_eq!(guide_counts(&app), vec![0, 2, 2, 0, 0, 0]); - // "This page" narrows them to the page in view. - crate::canvas::go_to_page(&mut app, 4); - crate::menus::activate(&mut app, "layout.createGuides", &Value::Null); - ctl(&mut app, "ui.dialog.set", json!({"field": "columns", "value": "3"})); - ctl(&mut app, "ui.dialog.set", json!({"field": "target", "value": "page"})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + assert_eq!(guide_counts(&app), vec![0, 2, 2, 0, 0, 0]); + // "This page" narrows them to the page in view. + crate::canvas::go_to_page(&mut app, 4); + crate::menus::activate(&mut app, "layout.createGuides", &Value::Null); + ctl(&mut app, "ui.dialog.set", json!({"field": "columns", "value": "3"})); + ctl(&mut app, "ui.dialog.set", json!({"field": "target", "value": "page"})); assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); assert_eq!(guide_counts(&app), vec![0, 2, 2, 0, 4, 0]); } From 142498f2ca5ed9d1005657d61c0db299c26945a6 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 14:00:45 +0100 Subject: [PATCH 113/207] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/tests.rs | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 2ae3bd3ac..e87d359e0 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -710,35 +710,35 @@ fn several_shapes_show_pathfinder_and_intersect_combines_them() { assert!((r.x0 - 200.0).abs() < 0.5 && (r.y1 - 200.0).abs() < 0.5 && (r.x1 - 300.0).abs() < 0.5, "{r:?}"); } - /// Properties › Frame Fitting has Auto-Fit and an Options button, as InDesign's: ticking - /// Auto-Fit makes the picture refit when its frame is resized. - #[test] - fn properties_frame_fitting_sets_auto_fit_and_opens_its_options() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let id = app.run("frame.create", json!({"rect": [72, 72, 272, 172]})).unwrap()["id"].as_u64().unwrap(); - let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); - app.run("file.place", json!({"base64": designcraft_engine::cmd::base64_encode(&png), "name": "p.png", "frame": id})).unwrap(); - app.run("selection.set", json!({"ids": [id]})).unwrap(); - let graphic = |app: &DesignApp| app.session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().graphic().unwrap().clone(); - assert_eq!(graphic(&app).auto_fit, designcraft_doc::Fitting::None); - let mut h = panel(app); - // A checkbox its section disables says so: Invert, with no text wrap. - use egui_kittest::kittest::NodeT; - assert!(h.get_by_label("Invert").accesskit_node().is_disabled()); - assert!(!h.get_by_label("Auto-Fit").accesskit_node().is_disabled()); - h.get_by_label("Auto-Fit").click(); - h.run_steps(3); - assert_ne!(graphic(h.state()).auto_fit, designcraft_doc::Fitting::None, "Auto-Fit is on"); - // The frame resized: the picture follows it. - h.state_mut().run("transform.resize", json!({"from": [72, 72, 272, 172], "to": [72, 72, 372, 272]})).unwrap(); - let g = graphic(h.state()); - let r = g.xf.transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1)); - assert!(r.width() > 250.0, "refitted to the larger frame: {r:?}"); - h.get_by_label("Options").click(); - h.run_steps(3); - assert!(h.state().ui.dialog.is_some(), "Frame Fitting Options opens"); - } +/// Properties › Frame Fitting has Auto-Fit and an Options button, as InDesign's: ticking +/// Auto-Fit makes the picture refit when its frame is resized. +#[test] +fn properties_frame_fitting_sets_auto_fit_and_opens_its_options() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 272, 172]})).unwrap()["id"].as_u64().unwrap(); + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); + app.run("file.place", json!({"base64": designcraft_engine::cmd::base64_encode(&png), "name": "p.png", "frame": id})).unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let graphic = |app: &DesignApp| app.session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().graphic().unwrap().clone(); + assert_eq!(graphic(&app).auto_fit, designcraft_doc::Fitting::None); + let mut h = panel(app); + // A checkbox its section disables says so: Invert, with no text wrap. + use egui_kittest::kittest::NodeT; + assert!(h.get_by_label("Invert").accesskit_node().is_disabled()); + assert!(!h.get_by_label("Auto-Fit").accesskit_node().is_disabled()); + h.get_by_label("Auto-Fit").click(); + h.run_steps(3); + assert_ne!(graphic(h.state()).auto_fit, designcraft_doc::Fitting::None, "Auto-Fit is on"); + // The frame resized: the picture follows it. + h.state_mut().run("transform.resize", json!({"from": [72, 72, 272, 172], "to": [72, 72, 372, 272]})).unwrap(); + let g = graphic(h.state()); + let r = g.xf.transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1)); + assert!(r.width() > 250.0, "refitted to the larger frame: {r:?}"); + h.get_by_label("Options").click(); + h.run_steps(3); + assert!(h.state().ui.dialog.is_some(), "Frame Fitting Options opens"); +} const CYAN: &str = "C=100 M=0 Y=0 K=0"; From 5e2af169ef84d40dd60854c9ab0a8c191cef5147 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 14:08:04 +0100 Subject: [PATCH 114/207] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/tests.rs | 130 ++++++++---------- 1 file changed, 61 insertions(+), 69 deletions(-) diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 40226fa84..1ac82ab9f 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -710,78 +710,70 @@ fn several_shapes_show_pathfinder_and_intersect_combines_them() { assert!((r.x0 - 200.0).abs() < 0.5 && (r.y1 - 200.0).abs() < 0.5 && (r.x1 - 300.0).abs() < 0.5, "{r:?}"); } - /// Properties › Text Wrap shows the four offsets under the wrap buttons, as InDesign's - /// Properties panel does: linked at first, so one value sets all four; unlinked, one side. - #[test] - fn properties_text_wrap_shows_and_sets_the_offsets() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); - app.run("selection.set", json!({"ids": [id]})).unwrap(); - app.run("object.textWrap", json!({"mode": "boundingBox", "offset": 0})).unwrap(); - let offsets = |app: &DesignApp| app.session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().wrap.offsets; - let mut h = panel(app); - // The fields between the Text Wrap heading and Quick Actions: Top, Left, Bottom, Right. - let fields = |h: &Harness<'_, DesignApp>| { - let (top, bottom) = (h.get_by_label("Text Wrap").rect().bottom(), h.get_by_label("Quick Actions").rect().top()); - let mut v: Vec = h - .get_all_by_role(egui::accesskit::Role::TextInput) - .map(|n| n.rect()) - .filter(|r| r.center().y > top && r.center().y < bottom) - .collect(); - v.sort_by(|a, b| (a.top(), a.left()).partial_cmp(&(b.top(), b.left())).unwrap()); - v - }; - let f = fields(&h); - assert_eq!(f.len(), 4, "four offsets: {f:?}"); - let type_in = |h: &mut Harness<'_, DesignApp>, at: egui::Rect, text: &str| { - h.hover_at(at.center()); - h.drag_at(at.center()); - h.drop_at(at.center()); - h.run_steps(2); - h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); - h.event(egui::Event::Text(text.into())); - h.key_press(egui::Key::Enter); - h.run_steps(3); - }; - // Linked: the top offset sets all four. - type_in(&mut h, f[0], "12pt"); - assert_eq!(offsets(h.state()), [12.0; 4]); - // Unlinked: the bottom one alone. +/// Properties › Text Wrap shows the four offsets under the wrap buttons, as InDesign's +/// Properties panel does: linked at first, so one value sets all four; unlinked, one side. +#[test] +fn properties_text_wrap_shows_and_sets_the_offsets() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + app.run("object.textWrap", json!({"mode": "boundingBox", "offset": 0})).unwrap(); + let offsets = |app: &DesignApp| app.session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().wrap.offsets; + let mut h = panel(app); + // The fields between the Text Wrap heading and Quick Actions: Top, Left, Bottom, Right. + let fields = |h: &Harness<'_, DesignApp>| { let (top, bottom) = (h.get_by_label("Text Wrap").rect().bottom(), h.get_by_label("Quick Actions").rect().top()); - h.get_all_by_label("Make all settings the same").find(|n| n.rect().center().y > top && n.rect().center().y < bottom).unwrap().click(); + let mut v: Vec = + h.get_all_by_role(egui::accesskit::Role::TextInput).map(|n| n.rect()).filter(|r| r.center().y > top && r.center().y < bottom).collect(); + v.sort_by(|a, b| (a.top(), a.left()).partial_cmp(&(b.top(), b.left())).unwrap()); + v + }; + let f = fields(&h); + assert_eq!(f.len(), 4, "four offsets: {f:?}"); + let type_in = |h: &mut Harness<'_, DesignApp>, at: egui::Rect, text: &str| { + h.hover_at(at.center()); + h.drag_at(at.center()); + h.drop_at(at.center()); h.run_steps(2); - let f = fields(&h); - type_in(&mut h, f[2], "6pt"); - assert_eq!(offsets(h.state()), [12.0, 12.0, 6.0, 12.0], "[top, left, bottom, right]"); - // Two objects with different offsets: a side typed in changes that side on each, and - // each keeps its other three. - let other = h.state_mut().run("frame.create", json!({"rect": [72, 300, 300, 400], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); - h.state_mut().run("object.textWrap", json!({"mode": "contour", "offset": [3, 4, 5, 6], "ids": [other]})).unwrap(); - let of = |h: &Harness<'_, DesignApp>, id: u64| { - h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().wrap.offsets - }; - h.state_mut().run("selection.set", json!({"ids": [id, other]})).unwrap(); + h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); + h.event(egui::Event::Text(text.into())); + h.key_press(egui::Key::Enter); h.run_steps(3); - let f = fields(&h); - type_in(&mut h, f[0], "9pt"); - assert_eq!((of(&h, id), of(&h, other)), ([9.0, 12.0, 6.0, 12.0], [9.0, 4.0, 5.0, 6.0]), "the top alone, on each"); - let mode = - |h: &Harness<'_, DesignApp>, id: u64| h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().wrap.mode; - assert_eq!( - (mode(&h, id), mode(&h, other)), - (designcraft_doc::WrapMode::BoundingBox, designcraft_doc::WrapMode::Contour), - "each keeps its mode" - ); - // An object whose offsets are the same starts linked, though the link was off before. - let third = h.state_mut().run("frame.create", json!({"rect": [320, 72, 500, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); - h.state_mut().run("object.textWrap", json!({"mode": "boundingBox", "offset": 4, "ids": [third]})).unwrap(); - h.state_mut().run("selection.set", json!({"ids": [third]})).unwrap(); - h.run_steps(3); - let f = fields(&h); - type_in(&mut h, f[2], "8pt"); - assert_eq!(of(&h, third), [8.0; 4], "linked again for this selection"); - } + }; + // Linked: the top offset sets all four. + type_in(&mut h, f[0], "12pt"); + assert_eq!(offsets(h.state()), [12.0; 4]); + // Unlinked: the bottom one alone. + let (top, bottom) = (h.get_by_label("Text Wrap").rect().bottom(), h.get_by_label("Quick Actions").rect().top()); + h.get_all_by_label("Make all settings the same").find(|n| n.rect().center().y > top && n.rect().center().y < bottom).unwrap().click(); + h.run_steps(2); + let f = fields(&h); + type_in(&mut h, f[2], "6pt"); + assert_eq!(offsets(h.state()), [12.0, 12.0, 6.0, 12.0], "[top, left, bottom, right]"); + // Two objects with different offsets: a side typed in changes that side on each, and + // each keeps its other three. + let other = h.state_mut().run("frame.create", json!({"rect": [72, 300, 300, 400], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); + h.state_mut().run("object.textWrap", json!({"mode": "contour", "offset": [3, 4, 5, 6], "ids": [other]})).unwrap(); + let of = + |h: &Harness<'_, DesignApp>, id: u64| h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().wrap.offsets; + h.state_mut().run("selection.set", json!({"ids": [id, other]})).unwrap(); + h.run_steps(3); + let f = fields(&h); + type_in(&mut h, f[0], "9pt"); + assert_eq!((of(&h, id), of(&h, other)), ([9.0, 12.0, 6.0, 12.0], [9.0, 4.0, 5.0, 6.0]), "the top alone, on each"); + let mode = + |h: &Harness<'_, DesignApp>, id: u64| h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().wrap.mode; + assert_eq!((mode(&h, id), mode(&h, other)), (designcraft_doc::WrapMode::BoundingBox, designcraft_doc::WrapMode::Contour), "each keeps its mode"); + // An object whose offsets are the same starts linked, though the link was off before. + let third = h.state_mut().run("frame.create", json!({"rect": [320, 72, 500, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); + h.state_mut().run("object.textWrap", json!({"mode": "boundingBox", "offset": 4, "ids": [third]})).unwrap(); + h.state_mut().run("selection.set", json!({"ids": [third]})).unwrap(); + h.run_steps(3); + let f = fields(&h); + type_in(&mut h, f[2], "8pt"); + assert_eq!(of(&h, third), [8.0; 4], "linked again for this selection"); +} const CYAN: &str = "C=100 M=0 Y=0 K=0"; From 8cb63ce5f57d2a36ba2dd8dd290387b3e7dba95d Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 14:12:43 +0100 Subject: [PATCH 115/207] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/object.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/properties/object.rs b/crates/ui-egui/src/panels/properties/object.rs index ea0fff6aa..3795bcd8a 100644 --- a/crates/ui-egui/src/panels/properties/object.rs +++ b/crates/ui-egui/src/panels/properties/object.rs @@ -555,7 +555,7 @@ fn wrap_offsets(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { ] { let ir = sub(b, x, y + 2.0, 17.0, 17.0); icons::paint(ui.painter(), ir, icon, if enabled { t.icon } else { t.text_dim }); - ui.interact(ir, ui.id().with(("prop_wrap_off", k)), Sense::hover()).on_hover_ui(|ui| { + ui.interact(ir, ui.scope_id().with(("prop_wrap_off", k)), Sense::hover()).on_hover_ui(|ui| { crate::rtl::label(ui, tr(tip)); }); let nv = place(ui, sub(b, x + 20.0, y, 76.0, FIELD_H), |ui| { From f1f7ed1fab6985ada5877f74f69d41cc90f06543 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 14:15:30 +0100 Subject: [PATCH 116/207] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/tests.rs | 102 +++++++++--------- 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 2af713820..2858392f0 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -710,59 +710,59 @@ fn several_shapes_show_pathfinder_and_intersect_combines_them() { assert!((r.x0 - 200.0).abs() < 0.5 && (r.y1 - 200.0).abs() < 0.5 && (r.x1 - 300.0).abs() < 0.5, "{r:?}"); } - /// The arrow beside Opacity opens a slider, as in InDesign: a drag shows the opacity live and - /// is one undo step; the Opacity link still opens the Effects panel. - #[test] - fn the_opacity_arrow_opens_a_slider_and_a_drag_is_one_undo_step() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); - app.run("selection.set", json!({"ids": [id]})).unwrap(); - let opacity = |app: &DesignApp| app.session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().opacity; - let steps = undo_steps(&app); - let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( - |ui, app: &mut DesignApp| { - let id = egui::Id::new("test_fonts"); - if ui.data(|d| d.get_temp::(id)).is_none() { - crate::theme::install_fonts(ui.ctx(), ""); - ui.data_mut(|d| d.insert_temp(id, true)); - return; - } - app.settle_live_preview(); - show(app, ui); - }, - app, - ); - h.run_steps(3); - assert!(h.query_by_role(egui::accesskit::Role::Slider).is_none()); - h.get_by(|n| n.role() == egui::accesskit::Role::Button && n.label().as_deref() == Some("Opacity")).click(); - h.run_steps(3); - assert!( - h.query_by(|n| n.role() == egui::accesskit::Role::Slider && n.label().as_deref() == Some("Opacity")).is_some(), - "assistive technology names the slider" - ); - let track = h.get_by_role(egui::accesskit::Role::Slider).rect(); - let y = track.center().y; - h.hover_at(egui::pos2(track.right() - 1.0, y)); +/// The arrow beside Opacity opens a slider, as in InDesign: a drag shows the opacity live and +/// is one undo step; the Opacity link still opens the Effects panel. +#[test] +fn the_opacity_arrow_opens_a_slider_and_a_drag_is_one_undo_step() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let opacity = |app: &DesignApp| app.session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().opacity; + let steps = undo_steps(&app); + let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + app.settle_live_preview(); + show(app, ui); + }, + app, + ); + h.run_steps(3); + assert!(h.query_by_role(egui::accesskit::Role::Slider).is_none()); + h.get_by(|n| n.role() == egui::accesskit::Role::Button && n.label().as_deref() == Some("Opacity")).click(); + h.run_steps(3); + assert!( + h.query_by(|n| n.role() == egui::accesskit::Role::Slider && n.label().as_deref() == Some("Opacity")).is_some(), + "assistive technology names the slider" + ); + let track = h.get_by_role(egui::accesskit::Role::Slider).rect(); + let y = track.center().y; + h.hover_at(egui::pos2(track.right() - 1.0, y)); + h.run_steps(1); + h.drag_at(egui::pos2(track.right() - 1.0, y)); + h.run_steps(1); + for k in 1..=5 { + h.hover_at(egui::pos2(track.right() - track.width() * 0.05 * k as f32, y)); h.run_steps(1); - h.drag_at(egui::pos2(track.right() - 1.0, y)); - h.run_steps(1); - for k in 1..=5 { - h.hover_at(egui::pos2(track.right() - track.width() * 0.05 * k as f32, y)); - h.run_steps(1); - assert_eq!(undo_steps(h.state()), steps, "no undo step while dragging"); - } - let previewed = opacity(h.state()); - assert!(previewed < 0.9 && previewed > 0.5, "the drag shows the opacity live: {previewed}"); - // Let go further along than the last move: the release frame changes the value too. - h.drop_at(egui::pos2(track.right() - track.width() * 0.3, y)); - h.run_steps(3); - assert_eq!(undo_steps(h.state()), steps + 1, "the whole drag is one undo step"); - assert!(opacity(h.state()) < previewed, "the release position is applied: {previewed} → {}", opacity(h.state())); - assert_ne!(h.state().ui.open_panel.as_deref(), Some("effects"), "the arrow doesn't open Effects"); - h.state_mut().run("edit.undo", json!({})).unwrap(); - assert_eq!(opacity(h.state()), 1.0); + assert_eq!(undo_steps(h.state()), steps, "no undo step while dragging"); } + let previewed = opacity(h.state()); + assert!(previewed < 0.9 && previewed > 0.5, "the drag shows the opacity live: {previewed}"); + // Let go further along than the last move: the release frame changes the value too. + h.drop_at(egui::pos2(track.right() - track.width() * 0.3, y)); + h.run_steps(3); + assert_eq!(undo_steps(h.state()), steps + 1, "the whole drag is one undo step"); + assert!(opacity(h.state()) < previewed, "the release position is applied: {previewed} → {}", opacity(h.state())); + assert_ne!(h.state().ui.open_panel.as_deref(), Some("effects"), "the arrow doesn't open Effects"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(opacity(h.state()), 1.0); +} const CYAN: &str = "C=100 M=0 Y=0 K=0"; From 90185deb8287604d7b87b801dade64b22ab0b793 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 14:35:23 +0100 Subject: [PATCH 117/207] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/tests.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 408d56ff2..3d1ef0acc 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -2401,7 +2401,7 @@ fn find_scope_dialog_domain_and_object_changes_release_original_range() { #[test] fn dialog_title_and_actions_fit_scaled_viewports() { for language in ["en", "ar"] { - for size in [egui::vec2(1178.0, 814.0), egui::vec2(589.0, 407.0), egui::vec2(392.0, 271.0), egui::vec2(360.0, 280.0)] { + for size in [egui::vec2(1178.0, 814.0), egui::vec2(589.0, 407.0), egui::vec2(392.0, 271.0), egui::vec2(360.0, 280.0)] { for (command, title, ok, cancel) in [("app.preferences", "Preferences", "OK", "Cancel"), ("app.newDocumentDialog", "New Document", "Create", "Close")] { @@ -2528,7 +2528,7 @@ fn preferences_horizontal_scroll_reaches_clipped_options() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.run("app.preferences", json!({})).unwrap(); let ctx = dialog_context("en"); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(360.0, 280.0)); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(360.0, 280.0)); for _ in 0..3 { dialog_frame(&mut app, &ctx, screen, vec![]); } @@ -2587,7 +2587,7 @@ fn new_document_vertical_scroll_reaches_every_field_and_preset() { #[test] fn arabic_new_document_fields_remain_reachable_and_editable() { - for size in [egui::vec2(392.0, 271.0), egui::vec2(360.0, 280.0), egui::vec2(1178.0, 814.0)] { + for size in [egui::vec2(392.0, 271.0), egui::vec2(360.0, 280.0), egui::vec2(1178.0, 814.0)] { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.ui.language = "ar".into(); app.run("app.newDocumentDialog", json!({})).unwrap(); From 68d96f3ec97740be9bee3af96f8fdcb28a4806b6 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 14:35:28 +0100 Subject: [PATCH 118/207] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 32cd4f690..ef4814347 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6930,7 +6930,7 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { // 200 pt, reserved: a desired width alone is squeezed by the grid to a few characters. // Narrower when the dialog is, so the style selector beside it stays in view. let w = 200.0_f32.min(title_room.max(80.0)); - if ui.add_sized([w, ui.spacing().interact_size.y], egui::TextEdit::singleline(&mut title).horizontal_align(egui::Align::Min)).changed() { + if ui.add_sized([w, ui.spacing().interact_size.y], egui::TextEdit::singleline(&mut title).align(egui::Align2::LEFT_TOP)).changed() { d.fields.insert("title".into(), json!(title)); } crate::rtl::label(ui, tr("Style:")); From 93bbbad231ea12ad39902037a3fed71f67fbe964 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 14:47:56 +0100 Subject: [PATCH 119/207] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/new_document/tests.rs | 72 ++++++++++++------------ 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/crates/ui-egui/src/new_document/tests.rs b/crates/ui-egui/src/new_document/tests.rs index 0a364c277..bf63b9c87 100644 --- a/crates/ui-egui/src/new_document/tests.rs +++ b/crates/ui-egui/src/new_document/tests.rs @@ -216,42 +216,42 @@ fn enter_in_the_preset_popup_saves_the_preset_and_keeps_the_dialog() { assert!(!egui::Popup::is_any_open(&h.ctx), "the popup closed"); } - #[test] - fn saved_preset_delete_buttons_keep_the_preview_and_never_create_a_document() { - use egui_kittest::kittest::Queryable as _; - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.savePreset", json!({"name":"Portrait draft","pages":3,"facingPages":false})).unwrap(); - app.run("file.savePreset", json!({"name":"Landscape draft","width":792,"height":612,"pages":2})).unwrap(); - app.run("app.newDocumentDialog", json!({})).unwrap(); - let mut h = crate::test_window::open(app, vec2(1440.0, 900.0)); - h.run_steps(4); - let saved = h.get_by_label("Saved").rect().center(); - crate::test_window::click_at(&mut h, saved); - let portrait = h.get_by_label("Portrait draft").rect().center(); - crate::test_window::click_at(&mut h, portrait); - h.get_by_label("Preview").click(); - h.run_steps(20); - assert!(h.query_by_label("Delete Preset: Portrait draft").is_some(), "the selected preset has its action"); - assert!(h.query_by_label("Delete Preset: [Default]").is_none(), "the factory card cannot be deleted"); - let before = h.state().app.session.active().unwrap().doc.clone(); - let preview = h.state().app.session.new_document_preview(); - let count = h.state().app.session.documents().len(); - let fields = params(h.state().app.ui.dialog.as_ref().unwrap()); - let landscape = h.get_by_label("Landscape draft").rect().center(); - h.event(egui::Event::PointerMoved(landscape)); - h.run_steps(3); - let remove = h.get_by_label("Delete Preset: Landscape draft").rect().center(); - crate::test_window::click_at(&mut h, remove); - assert_eq!(h.state().app.session.prefs.document_presets.iter().map(|p| p.title.as_str()).collect::>(), ["Portrait draft"]); - let remove = h.get_by_label("Delete Preset: Portrait draft").rect().center(); - crate::test_window::click_at(&mut h, remove); - let app = &h.state().app; - assert!(app.session.prefs.document_presets.is_empty()); - assert_eq!(app.session.new_document_preview(), preview); - assert_eq!(app.session.documents().len(), count); - assert_eq!(*app.session.active().unwrap().doc, *before); - assert_eq!(params(app.ui.dialog.as_ref().unwrap()), fields, "deletion does not select another preset or create the preview"); - } +#[test] +fn saved_preset_delete_buttons_keep_the_preview_and_never_create_a_document() { + use egui_kittest::kittest::Queryable as _; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.savePreset", json!({"name":"Portrait draft","pages":3,"facingPages":false})).unwrap(); + app.run("file.savePreset", json!({"name":"Landscape draft","width":792,"height":612,"pages":2})).unwrap(); + app.run("app.newDocumentDialog", json!({})).unwrap(); + let mut h = crate::test_window::open(app, vec2(1440.0, 900.0)); + h.run_steps(4); + let saved = h.get_by_label("Saved").rect().center(); + crate::test_window::click_at(&mut h, saved); + let portrait = h.get_by_label("Portrait draft").rect().center(); + crate::test_window::click_at(&mut h, portrait); + h.get_by_label("Preview").click(); + h.run_steps(20); + assert!(h.query_by_label("Delete Preset: Portrait draft").is_some(), "the selected preset has its action"); + assert!(h.query_by_label("Delete Preset: [Default]").is_none(), "the factory card cannot be deleted"); + let before = h.state().app.session.active().unwrap().doc.clone(); + let preview = h.state().app.session.new_document_preview(); + let count = h.state().app.session.documents().len(); + let fields = params(h.state().app.ui.dialog.as_ref().unwrap()); + let landscape = h.get_by_label("Landscape draft").rect().center(); + h.event(egui::Event::PointerMoved(landscape)); + h.run_steps(3); + let remove = h.get_by_label("Delete Preset: Landscape draft").rect().center(); + crate::test_window::click_at(&mut h, remove); + assert_eq!(h.state().app.session.prefs.document_presets.iter().map(|p| p.title.as_str()).collect::>(), ["Portrait draft"]); + let remove = h.get_by_label("Delete Preset: Portrait draft").rect().center(); + crate::test_window::click_at(&mut h, remove); + let app = &h.state().app; + assert!(app.session.prefs.document_presets.is_empty()); + assert_eq!(app.session.new_document_preview(), preview); + assert_eq!(app.session.documents().len(), count); + assert_eq!(*app.session.active().unwrap().doc, *before); + assert_eq!(params(app.ui.dialog.as_ref().unwrap()), fields, "deletion does not select another preset or create the preview"); +} #[test] fn escape_in_the_preset_popup_closes_only_the_popup() { From b92070c4e8746934ed718af66199e1eb541c0181 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 14:50:52 +0100 Subject: [PATCH 120/207] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/new_document.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/new_document.rs b/crates/ui-egui/src/new_document.rs index 1fb2b3807..54fa76a33 100644 --- a/crates/ui-egui/src/new_document.rs +++ b/crates/ui-egui/src/new_document.rs @@ -508,7 +508,7 @@ fn presets(app: &mut DesignApp, ui: &mut Ui, d: &mut Dialog) -> bool { if current == c.key || ui.rect_contains_pointer(resp.rect) { let x = if rtl { resp.rect.left() + 4.0 } else { resp.rect.right() - 26.0 }; let rect = Rect::from_min_size(pos2(x, resp.rect.top() + 4.0), vec2(22.0, 22.0)); - let button = ui.interact(rect, ui.id().with(("delete-preset", &c.key)), Sense::click()); + let button = ui.interact(rect, ui.scope_id().with(("delete-preset", &c.key)), Sense::click()); let label = format!("{}: {}", crate::i18n::tr(&lang, "Delete Preset"), c.label); button.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label.as_str())); if button.hovered() { From 4ebc675164538a7a814553272107a26c24df074b Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 14:53:46 +0100 Subject: [PATCH 121/207] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/format/src/tests.rs | 56 +++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/crates/format/src/tests.rs b/crates/format/src/tests.rs index 170e8be9a..0e7ba0d3b 100644 --- a/crates/format/src/tests.rs +++ b/crates/format/src/tests.rs @@ -555,14 +555,14 @@ fn r7a_nullable_document(local_clear: bool, style_clear: bool) -> Document { underline_offset: Some(Some(1.0)), strikethrough_weight: Some(Some(2.0)), strikethrough_offset: Some(Some(1.0)), - kenten_size: Some(Some(14.0)), - kenten_fill_tint: Some(Some(0.3)), - kenten_stroke_tint: Some(Some(0.4)), - kenten_stroke_weight: Some(Some(2.0)), - ruby_font_size: Some(Some(14.0)), - ruby_fill_tint: Some(Some(0.3)), - ruby_stroke_tint: Some(Some(0.4)), - ruby_stroke_weight: Some(Some(2.0)), + kenten_size: Some(Some(14.0)), + kenten_fill_tint: Some(Some(0.3)), + kenten_stroke_tint: Some(Some(0.4)), + kenten_stroke_weight: Some(Some(2.0)), + ruby_font_size: Some(Some(14.0)), + ruby_fill_tint: Some(Some(0.3)), + ruby_stroke_tint: Some(Some(0.4)), + ruby_stroke_weight: Some(Some(2.0)), ..Default::default() }; // Legacy JSON loads as format 1, whose upgrade pins Keep Lines Together and Align Left @@ -572,7 +572,7 @@ fn r7a_nullable_document(local_clear: bool, style_clear: bool) -> Document { start_at: Some(Some(7)), paragraph_kashida_width: Some(Some(0.5)), kinsoku: Some(Some(Default::default())), - list_tab: Some(Some(36.0)), + list_tab: Some(Some(36.0)), keep_all_lines: Some(true), drop_cap_align_left: Some(true), ..Default::default() @@ -587,18 +587,18 @@ fn r7a_nullable_document(local_clear: bool, style_clear: bool) -> Document { underline_offset: Some(None), strikethrough_weight: Some(None), strikethrough_offset: Some(None), - kenten_size: Some(None), - kenten_fill_tint: Some(None), - kenten_stroke_tint: Some(None), - kenten_stroke_weight: Some(None), - ruby_font_size: Some(None), - ruby_fill_tint: Some(None), - ruby_stroke_tint: Some(None), - ruby_stroke_weight: Some(None), + kenten_size: Some(None), + kenten_fill_tint: Some(None), + kenten_stroke_tint: Some(None), + kenten_stroke_weight: Some(None), + ruby_font_size: Some(None), + ruby_fill_tint: Some(None), + ruby_stroke_tint: Some(None), + ruby_stroke_weight: Some(None), ..Default::default() }; - let clear_para = - ParaAttrs { start_at: Some(None), paragraph_kashida_width: Some(None), kinsoku: Some(None), list_tab: Some(None), ..Default::default() }; + let clear_para = + ParaAttrs { start_at: Some(None), paragraph_kashida_width: Some(None), kinsoku: Some(None), list_tab: Some(None), ..Default::default() }; let selected = if style_clear { std::sync::Arc::make_mut(&mut d.styles).paragraph.push(designcraft_doc::styles::ParagraphStyle { name: "Defaults".into(), @@ -639,15 +639,15 @@ fn r7a_assert_nullable_defaults_survive(d: &Document) { assert_eq!(expected.0.paragraph_kashida_width, None); assert_eq!(expected.0.kinsoku, None); assert_eq!(expected.1.leading_aki, None); - assert_eq!(expected.0.list_tab, None); - assert_eq!(expected.1.kenten_size, None); - assert_eq!(expected.1.kenten_fill_tint, None); - assert_eq!(expected.1.kenten_stroke_tint, None); - assert_eq!(expected.1.kenten_stroke_weight, None); - assert_eq!(expected.1.ruby_font_size, None); - assert_eq!(expected.1.ruby_fill_tint, None); - assert_eq!(expected.1.ruby_stroke_tint, None); - assert_eq!(expected.1.ruby_stroke_weight, None); + assert_eq!(expected.0.list_tab, None); + assert_eq!(expected.1.kenten_size, None); + assert_eq!(expected.1.kenten_fill_tint, None); + assert_eq!(expected.1.kenten_stroke_tint, None); + assert_eq!(expected.1.kenten_stroke_weight, None); + assert_eq!(expected.1.ruby_font_size, None); + assert_eq!(expected.1.ruby_fill_tint, None); + assert_eq!(expected.1.ruby_stroke_tint, None); + assert_eq!(expected.1.ruby_stroke_weight, None); // Legacy JSON is format 1: its upgrade pins older defaults on the other styles, so only // the native ZIP compares as the whole document. for (bytes, native) in [(save(d).unwrap(), true), (serde_json::to_vec(d).unwrap(), false)] { From 11ef2cc8404c42ff83fd811d84dd8e51f04300f8 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:01:01 +0100 Subject: [PATCH 122/207] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/tools/src/select.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index cd47a0338..ff5d0320e 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -776,8 +776,8 @@ impl SelectionTool { } else { out.push(Action::Exec("selection.toggle".into(), json!({"id": id.0}))); } - } else if !cx.selection.contains(id) || (self.direct && !cx.selection.content && cx.doc.item(id).is_some_and(holds_content)) { - // Direct Selection on a selected frame selects its content (a graphic or pasted-in items). + } else if !cx.selection.contains(id) || (self.direct && !cx.selection.content && cx.doc.item(id).is_some_and(holds_content)) { + // Direct Selection on a selected frame selects its content (a graphic or pasted-in items). out.push(Action::Exec("selection.set".into(), json!({"ids": [id.0], "content": self.direct}))); } else if !self.direct && cx.selection.items.len() > 1 { // InDesign: a click on one of several selected objects makes it the From 785c49a1d4e4a5669578397289f07e56fe0d26fd Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:08:12 +0100 Subject: [PATCH 123/207] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/tools/src/select.rs | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index f073d063e..f6b42377d 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -703,19 +703,19 @@ impl SelectionTool { self.drag = Drag::Anchor { id, si, ai, handle, start: p, at }; return vec![Action::Begin(if handle.is_some() { "Move Direction Handle".into() } else { "Move Anchor".into() })]; } - // As in InDesign, the Direct Selection tool pressed on a corner or anchor of an object - // that isn't selected selects it and moves just that point (Chris Nielsen's "The - // InDesign Toolbox", 10:10: click outside, then pull a frame's corner). - if self.direct - && !ev.mods.shift - && let Some((_, hit)) = cx.hit(p) - && !cx.selection.contains(hit) - && let Some((id, si, ai, None)) = anchor_at_in(cx, &[hit], p) - { - let at = anchor_spread_point(cx, id, si, ai); - self.drag = Drag::Anchor { id, si, ai, handle: None, start: p, at }; - return vec![Action::Exec("selection.set".into(), json!({"ids": [id], "content": true})), Action::Begin("Move Anchor".into())]; - } + // As in InDesign, the Direct Selection tool pressed on a corner or anchor of an object + // that isn't selected selects it and moves just that point (Chris Nielsen's "The + // InDesign Toolbox", 10:10: click outside, then pull a frame's corner). + if self.direct + && !ev.mods.shift + && let Some((_, hit)) = cx.hit(p) + && !cx.selection.contains(hit) + && let Some((id, si, ai, None)) = anchor_at_in(cx, &[hit], p) + { + let at = anchor_spread_point(cx, id, si, ai); + self.drag = Drag::Anchor { id, si, ai, handle: None, start: p, at }; + return vec![Action::Exec("selection.set".into(), json!({"ids": [id], "content": true})), Action::Begin("Move Anchor".into())]; + } // A selected placed graphic's own handles resize it inside its frame (the // frame's anchors, under Direct Selection, come first). if let Some((id, h)) = content_handle_at(cx, p) From 3959cafd12e5cd965a73367f06c23b615faaa25b Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:26:16 +0100 Subject: [PATCH 124/207] 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 | 49 ----------------------------- crates/ui-egui/src/dialogs/tests.rs | 49 +++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 49 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 4801278db..ef6869f95 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,55 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - #[test] - fn on_a_page_without_a_section_the_window_is_new_section() { - // Chris Nielsen's "Numbering Page Sections" (4:43): page 3 selected, the Pages panel menu's - // Numbering & Section Options opens New Section with Start Section on and 1 offered. - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"pages": 6, "facingPages": true})).unwrap(); - app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 800.0))); - crate::canvas::go_to_page(&mut app, 2); - crate::menus::activate(&mut app, "layout.section", &Value::Null); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!( - (d.n("page"), d.b("newSection"), d.b("startSection"), d.b("automatic"), d.s("startNumber")), - (Some(3.0), true, true, true, "1".into()) - ); - let ctx = dialog_context("en"); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1178.0, 814.0)); - for _ in 0..3 { - dialog_frame(&mut app, &ctx, screen, vec![]); - } - let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - visible_label(&labels, screen, "New Section"); - // Typing a number in the field chooses Start Page Numbering at; OK: page 3 is page 5 (odd, - // so a facing page stays on the right). - let field = visible_label(&labels, screen, "1"); - click_dialog(&mut app, &ctx, screen, field.center()); - dialog_frame( - &mut app, - &ctx, - screen, - vec![ - egui::Event::Key { key: egui::Key::End, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }, - egui::Event::Key { key: egui::Key::Backspace, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }, - egui::Event::Text("5".into()), - ], - ); - assert!(!app.ui.dialog.as_ref().unwrap().b("automatic"), "typing chose Start Page Numbering at"); - let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "OK").center()); - assert!(app.ui.dialog.is_none()); - assert_eq!(app.session.active().unwrap().doc.page_name(2), "5"); - // Opened again on that page, it is Numbering & Section Options, showing the section. - crate::menus::activate(&mut app, "layout.section", &Value::Null); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!((d.b("newSection"), d.b("startSection"), d.b("automatic"), d.s("startNumber")), (false, true, false, "5".into())); - let labels = (0..4).map(|_| dialog_frame(&mut app, &ctx, screen, vec![])).last().unwrap(); - visible_label(&labels, screen, "Numbering & Section Options"); - assert!(!labels.iter().any(|(t, _, _)| t == "New Section")); - } - #[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..23bc23ef9 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,52 @@ 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)); } + +#[test] +fn on_a_page_without_a_section_the_window_is_new_section() { + // Chris Nielsen's "Numbering Page Sections" (4:43): page 3 selected, the Pages panel menu's + // Numbering & Section Options opens New Section with Start Section on and 1 offered. + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 6, "facingPages": true})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 800.0))); + crate::canvas::go_to_page(&mut app, 2); + crate::menus::activate(&mut app, "layout.section", &Value::Null); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!( + (d.n("page"), d.b("newSection"), d.b("startSection"), d.b("automatic"), d.s("startNumber")), + (Some(3.0), true, true, true, "1".into()) + ); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1178.0, 814.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "New Section"); + // Typing a number in the field chooses Start Page Numbering at; OK: page 3 is page 5 (odd, + // so a facing page stays on the right). + let field = visible_label(&labels, screen, "1"); + click_dialog(&mut app, &ctx, screen, field.center()); + dialog_frame( + &mut app, + &ctx, + screen, + vec![ + egui::Event::Key { key: egui::Key::End, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }, + egui::Event::Key { key: egui::Key::Backspace, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }, + egui::Event::Text("5".into()), + ], + ); + assert!(!app.ui.dialog.as_ref().unwrap().b("automatic"), "typing chose Start Page Numbering at"); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "OK").center()); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.active().unwrap().doc.page_name(2), "5"); + // Opened again on that page, it is Numbering & Section Options, showing the section. + crate::menus::activate(&mut app, "layout.section", &Value::Null); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.b("newSection"), d.b("startSection"), d.b("automatic"), d.s("startNumber")), (false, true, false, "5".into())); + let labels = (0..4).map(|_| dialog_frame(&mut app, &ctx, screen, vec![])).last().unwrap(); + visible_label(&labels, screen, "Numbering & Section Options"); + assert!(!labels.iter().any(|(t, _, _)| t == "New Section")); +} From b0c1bc47479a8582163a50c7f693c82aaf23ced5 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:26:17 +0100 Subject: [PATCH 125/207] 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 | 113 ---------------------------- crates/ui-egui/src/dialogs/tests.rs | 113 ++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 113 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1eccf0ac3..4704cc870 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,119 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - #[test] - fn corner_options_dialog_sets_each_corner_with_preview_and_one_undo_step() { - use designcraft_geom::corners::CornerShape; - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let id = app.run("frame.create", json!({"rect": [72, 72, 360, 360]})).unwrap()["id"].as_u64().unwrap(); - app.run("selection.set", json!({"ids": [id]})).unwrap(); - let corners = |app: &DesignApp| app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().corners.corners; - let undo = |app: &DesignApp| app.session.doc().unwrap().history.undo.len(); - let set = |app: &mut DesignApp, k: &str, v: Value| { - app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); - }; - let u0 = undo(&app); - let ctx = dialog_context("en"); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); - // Object › Corner Options… opens InDesign's dialog, not a form of the command's params. - crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); - assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some(CORNER_OPTIONS)); - dialog_frame(&mut app, &ctx, screen, vec![]); - let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - for expected in ["Corner Options", "Corner Size and Shape:", "Preview", "OK", "Cancel"] { - visible_label(&labels, screen, expected); - } - assert!(!labels.iter().any(|(t, _, _)| t.trim() == "Ids"), "no raw command fields"); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!((d.s("size1"), d.s("shape1"), d.b("link")), ("0p0".into(), "none".into(), true), "seeded from the frame, linked"); - // Linked: a corner changed is all four. - set(&mut app, "size0", json!("1p0")); - dialog_frame(&mut app, &ctx, screen, vec![]); - assert_eq!(app.ui.dialog.as_ref().unwrap().s("size2"), "1p0"); - // Unlinked, only the top-right corner rounds, as in the tutorial. - set(&mut app, "link", json!(false)); - for k in 0..4 { - set(&mut app, &format!("size{k}"), json!("0")); - } - set(&mut app, "size1", json!("3 cm")); - set(&mut app, "shape1", json!("rounded")); - set(&mut app, "preview", json!(true)); - dialog_frame(&mut app, &ctx, screen, vec![]); - assert_eq!(corners(&app)[1].shape, CornerShape::Rounded, "Preview shows it"); - assert_eq!(undo(&app), u0, "without an undo step"); - set(&mut app, "preview", json!(false)); - dialog_frame(&mut app, &ctx, screen, vec![]); - assert_eq!(corners(&app)[1].shape, CornerShape::None, "Preview off puts it back"); - set(&mut app, "preview", json!(true)); - dialog_frame(&mut app, &ctx, screen, vec![]); - confirm(&mut app).unwrap(); - let c = corners(&app); - assert_eq!(c.map(|c| c.shape), [CornerShape::None, CornerShape::Rounded, CornerShape::None, CornerShape::None]); - assert!((c[1].size - 3.0 / 2.54 * 72.0).abs() < 1e-6, "{}", c[1].size); - assert_eq!(undo(&app), u0 + 1, "one undo step"); - // Reopened, it shows the frame's corners, no longer linked. - crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); - dialog_frame(&mut app, &ctx, screen, vec![]); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!((d.s("shape1"), d.s("shape0"), d.b("link")), ("rounded".into(), "none".into(), false)); - // A size that does not parse keeps the dialog open with the error; so does a shape the - // dialog doesn't offer (rather than another shape taking its place). - set(&mut app, "size0", json!("abc")); - assert!(confirm(&mut app).is_err()); - assert!(app.ui.dialog.is_some()); - set(&mut app, "size0", json!("0")); - set(&mut app, "shape0", json!("spiky")); - assert!(confirm(&mut app).is_err()); - assert!(app.ui.dialog.is_some()); - cancel(&mut app); - assert_eq!(corners(&app), c); - } - - /// Several objects: the dialog shows the first one's corners, and OK sets only the corners - /// edited; the others keep each object's own. A linked edit sets all four. - #[test] - fn corner_options_keep_each_objects_untouched_corners() { - use designcraft_geom::corners::CornerShape; - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let a = app.run("frame.create", json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].as_u64().unwrap(); - let b = app.run("frame.create", json!({"rect": [272, 72, 400, 200]})).unwrap()["id"].as_u64().unwrap(); - app.run("object.cornerOptions", json!({"ids": [b], "shape": "bevel", "size": 12})).unwrap(); - app.run("selection.set", json!({"ids": [a, b]})).unwrap(); - let corners = |app: &DesignApp, id: u64| app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().corners.corners; - let set = |app: &mut DesignApp, k: &str, v: Value| { - app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); - }; - let ctx = dialog_context("en"); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); - let b0 = corners(&app, b); - // Unlinked: only the top-right corner rounds, on both. - crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); - dialog_frame(&mut app, &ctx, screen, vec![]); - set(&mut app, "link", json!(false)); - set(&mut app, "size1", json!("1p0")); - set(&mut app, "shape1", json!("rounded")); - dialog_frame(&mut app, &ctx, screen, vec![]); - confirm(&mut app).unwrap(); - let (ca, cb) = (corners(&app, a), corners(&app, b)); - assert_eq!(ca.map(|c| c.shape), [CornerShape::None, CornerShape::Rounded, CornerShape::None, CornerShape::None]); - assert_eq!(cb.map(|c| c.shape), [CornerShape::Bevel, CornerShape::Rounded, CornerShape::Bevel, CornerShape::Bevel], "b keeps its others"); - assert_eq!((cb[0], cb[2], cb[3]), (b0[0], b0[2], b0[3])); - assert_eq!(ca[1], cb[1]); - // Linked: a corner set to the first object's own value still sets all four, on both. - crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); - dialog_frame(&mut app, &ctx, screen, vec![]); - set(&mut app, "link", json!(true)); - dialog_frame(&mut app, &ctx, screen, vec![]); - set(&mut app, "size0", json!("0p6")); - dialog_frame(&mut app, &ctx, screen, vec![]); - confirm(&mut app).unwrap(); - let (ca, cb) = (corners(&app, a), corners(&app, b)); - assert_eq!(ca, cb, "all four, on both"); - assert!(ca.iter().all(|c| c.shape == CornerShape::None && (c.size - 6.0).abs() < 1e-6), "{ca:?}"); - } - #[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..7d91c4533 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,116 @@ 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)); } + +#[test] +fn corner_options_dialog_sets_each_corner_with_preview_and_one_undo_step() { + use designcraft_geom::corners::CornerShape; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 360, 360]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let corners = |app: &DesignApp| app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().corners.corners; + let undo = |app: &DesignApp| app.session.doc().unwrap().history.undo.len(); + let set = |app: &mut DesignApp, k: &str, v: Value| { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + }; + let u0 = undo(&app); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); + // Object › Corner Options… opens InDesign's dialog, not a form of the command's params. + crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some(CORNER_OPTIONS)); + dialog_frame(&mut app, &ctx, screen, vec![]); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for expected in ["Corner Options", "Corner Size and Shape:", "Preview", "OK", "Cancel"] { + visible_label(&labels, screen, expected); + } + assert!(!labels.iter().any(|(t, _, _)| t.trim() == "Ids"), "no raw command fields"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.s("size1"), d.s("shape1"), d.b("link")), ("0p0".into(), "none".into(), true), "seeded from the frame, linked"); + // Linked: a corner changed is all four. + set(&mut app, "size0", json!("1p0")); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("size2"), "1p0"); + // Unlinked, only the top-right corner rounds, as in the tutorial. + set(&mut app, "link", json!(false)); + for k in 0..4 { + set(&mut app, &format!("size{k}"), json!("0")); + } + set(&mut app, "size1", json!("3 cm")); + set(&mut app, "shape1", json!("rounded")); + set(&mut app, "preview", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(corners(&app)[1].shape, CornerShape::Rounded, "Preview shows it"); + assert_eq!(undo(&app), u0, "without an undo step"); + set(&mut app, "preview", json!(false)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(corners(&app)[1].shape, CornerShape::None, "Preview off puts it back"); + set(&mut app, "preview", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + confirm(&mut app).unwrap(); + let c = corners(&app); + assert_eq!(c.map(|c| c.shape), [CornerShape::None, CornerShape::Rounded, CornerShape::None, CornerShape::None]); + assert!((c[1].size - 3.0 / 2.54 * 72.0).abs() < 1e-6, "{}", c[1].size); + assert_eq!(undo(&app), u0 + 1, "one undo step"); + // Reopened, it shows the frame's corners, no longer linked. + crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); + dialog_frame(&mut app, &ctx, screen, vec![]); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.s("shape1"), d.s("shape0"), d.b("link")), ("rounded".into(), "none".into(), false)); + // A size that does not parse keeps the dialog open with the error; so does a shape the + // dialog doesn't offer (rather than another shape taking its place). + set(&mut app, "size0", json!("abc")); + assert!(confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some()); + set(&mut app, "size0", json!("0")); + set(&mut app, "shape0", json!("spiky")); + assert!(confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some()); + cancel(&mut app); + assert_eq!(corners(&app), c); +} + +/// Several objects: the dialog shows the first one's corners, and OK sets only the corners +/// edited; the others keep each object's own. A linked edit sets all four. +#[test] +fn corner_options_keep_each_objects_untouched_corners() { + use designcraft_geom::corners::CornerShape; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let b = app.run("frame.create", json!({"rect": [272, 72, 400, 200]})).unwrap()["id"].as_u64().unwrap(); + app.run("object.cornerOptions", json!({"ids": [b], "shape": "bevel", "size": 12})).unwrap(); + app.run("selection.set", json!({"ids": [a, b]})).unwrap(); + let corners = |app: &DesignApp, id: u64| app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().corners.corners; + let set = |app: &mut DesignApp, k: &str, v: Value| { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + }; + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); + let b0 = corners(&app, b); + // Unlinked: only the top-right corner rounds, on both. + crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); + dialog_frame(&mut app, &ctx, screen, vec![]); + set(&mut app, "link", json!(false)); + set(&mut app, "size1", json!("1p0")); + set(&mut app, "shape1", json!("rounded")); + dialog_frame(&mut app, &ctx, screen, vec![]); + confirm(&mut app).unwrap(); + let (ca, cb) = (corners(&app, a), corners(&app, b)); + assert_eq!(ca.map(|c| c.shape), [CornerShape::None, CornerShape::Rounded, CornerShape::None, CornerShape::None]); + assert_eq!(cb.map(|c| c.shape), [CornerShape::Bevel, CornerShape::Rounded, CornerShape::Bevel, CornerShape::Bevel], "b keeps its others"); + assert_eq!((cb[0], cb[2], cb[3]), (b0[0], b0[2], b0[3])); + assert_eq!(ca[1], cb[1]); + // Linked: a corner set to the first object's own value still sets all four, on both. + crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); + dialog_frame(&mut app, &ctx, screen, vec![]); + set(&mut app, "link", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + set(&mut app, "size0", json!("0p6")); + dialog_frame(&mut app, &ctx, screen, vec![]); + confirm(&mut app).unwrap(); + let (ca, cb) = (corners(&app, a), corners(&app, b)); + assert_eq!(ca, cb, "all four, on both"); + assert!(ca.iter().all(|c| c.shape == CornerShape::None && (c.size - 6.0).abs() < 1e-6), "{ca:?}"); +} From 9ffc1ae379bdfe5e000b7a60ab92343a74d46fab Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:26:17 +0100 Subject: [PATCH 126/207] 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 | 54 ----------------------------- crates/ui-egui/src/dialogs/tests.rs | 54 +++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 54 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a2265c288..858f08a22 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,60 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - /// With no document open, Preferences has Units & Increments, as InDesign's: OK keeps them - /// for new documents, and New Document opens in them (Lollo's inches, set once). - #[test] - fn preferences_with_no_document_set_the_units_of_new_documents() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - crate::menus::activate(&mut app, "app.preferences", &Value::Null); - let mut d = app.ui.dialog.take().expect("Preferences"); - assert!(d.b("defaults"), "for new documents"); - let ctx = dialog_context("en"); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1178.0, 814.0)); - app.ui.dialog = Some(d.clone()); - let mut labels = vec![]; - for _ in 0..3 { - labels = dialog_frame(&mut app, &ctx, screen, vec![]); - } - assert!(labels.iter().any(|(t, _, _)| t == "Units & Increments"), "listed with no document: {labels:?}"); - d.fields.insert("horizontalUnits".into(), json!("inches")); - d.fields.insert("verticalUnits".into(), json!("inches")); - d.fields.insert("keyboardIncrement".into(), json!("0.5 pt")); - app.ui.dialog = Some(d); - confirm(&mut app).unwrap(); - let du = app.session.prefs.default_units; - assert_eq!((du.horizontal_units, du.vertical_units, du.keyboard_increment), (Unit::Inches, Unit::Inches, 0.5)); - assert!(app.session.active().is_none(), "no document made"); - // A Cursor Key of 0 or text that isn't a measure is refused, keeping the dialog open and - // the saved values, the other preferences' too; a Kerning/Tracking of 0.5 is kept as it is. - let scale = app.session.prefs.scale_strokes; - for bad in [json!({"keyboardIncrement": "0 pt"}), json!({"keyboardIncrement": "wide"}), json!({"kerningIncrement": "lots"})] { - let mut d = Dialog::new("preferences", json!({"defaults": true, "horizontalUnits": "inches", "scaleStrokes": !scale})); - for (k, v) in bad.as_object().unwrap() { - d.fields.insert(k.clone(), v.clone()); - } - app.ui.dialog = Some(d); - assert!(confirm(&mut app).is_err(), "{bad}"); - assert!(app.ui.dialog.as_ref().is_some_and(|d| d.id == "preferences"), "still open: {bad}"); - assert_eq!(app.session.prefs.default_units.keyboard_increment, 0.5, "{bad}"); - assert_eq!(app.session.prefs.scale_strokes, scale, "no other preference saved: {bad}"); - } - app.ui.dialog = Some(Dialog::new("preferences", json!({"defaults": true, "horizontalUnits": "inches", "kerningIncrement": "0.5"}))); - confirm(&mut app).unwrap(); - assert_eq!(app.session.prefs.default_units.kerning_increment, 0.5, "not rounded up to 1"); - // New Document opens in inches, and the document it makes has inch rulers. - crate::menus::activate(&mut app, "app.newDocumentDialog", &Value::Null); - let nd = app.ui.dialog.clone().expect("New Document"); - assert_eq!(nd.fields["units"], json!("inches")); - assert_eq!(nd.s("width"), designcraft_geom::format_measure(612.0, Unit::Inches)); - confirm(&mut app).unwrap(); - let st = &app.session.doc().unwrap().doc.settings; - assert_eq!((st.horizontal_units, st.vertical_units, st.keyboard_increment), (Unit::Inches, Unit::Inches, 0.5)); - // With the document open, Preferences edits that document's units, not the defaults. - crate::menus::activate(&mut app, "app.preferences", &Value::Null); - assert!(!app.ui.dialog.as_ref().unwrap().b("defaults")); - } - #[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..7b0c8b5ba 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,57 @@ 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)); } + +/// With no document open, Preferences has Units & Increments, as InDesign's: OK keeps them +/// for new documents, and New Document opens in them (Lollo's inches, set once). +#[test] +fn preferences_with_no_document_set_the_units_of_new_documents() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + crate::menus::activate(&mut app, "app.preferences", &Value::Null); + let mut d = app.ui.dialog.take().expect("Preferences"); + assert!(d.b("defaults"), "for new documents"); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1178.0, 814.0)); + app.ui.dialog = Some(d.clone()); + let mut labels = vec![]; + for _ in 0..3 { + labels = dialog_frame(&mut app, &ctx, screen, vec![]); + } + assert!(labels.iter().any(|(t, _, _)| t == "Units & Increments"), "listed with no document: {labels:?}"); + d.fields.insert("horizontalUnits".into(), json!("inches")); + d.fields.insert("verticalUnits".into(), json!("inches")); + d.fields.insert("keyboardIncrement".into(), json!("0.5 pt")); + app.ui.dialog = Some(d); + confirm(&mut app).unwrap(); + let du = app.session.prefs.default_units; + assert_eq!((du.horizontal_units, du.vertical_units, du.keyboard_increment), (Unit::Inches, Unit::Inches, 0.5)); + assert!(app.session.active().is_none(), "no document made"); + // A Cursor Key of 0 or text that isn't a measure is refused, keeping the dialog open and + // the saved values, the other preferences' too; a Kerning/Tracking of 0.5 is kept as it is. + let scale = app.session.prefs.scale_strokes; + for bad in [json!({"keyboardIncrement": "0 pt"}), json!({"keyboardIncrement": "wide"}), json!({"kerningIncrement": "lots"})] { + let mut d = Dialog::new("preferences", json!({"defaults": true, "horizontalUnits": "inches", "scaleStrokes": !scale})); + for (k, v) in bad.as_object().unwrap() { + d.fields.insert(k.clone(), v.clone()); + } + app.ui.dialog = Some(d); + assert!(confirm(&mut app).is_err(), "{bad}"); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.id == "preferences"), "still open: {bad}"); + assert_eq!(app.session.prefs.default_units.keyboard_increment, 0.5, "{bad}"); + assert_eq!(app.session.prefs.scale_strokes, scale, "no other preference saved: {bad}"); + } + app.ui.dialog = Some(Dialog::new("preferences", json!({"defaults": true, "horizontalUnits": "inches", "kerningIncrement": "0.5"}))); + confirm(&mut app).unwrap(); + assert_eq!(app.session.prefs.default_units.kerning_increment, 0.5, "not rounded up to 1"); + // New Document opens in inches, and the document it makes has inch rulers. + crate::menus::activate(&mut app, "app.newDocumentDialog", &Value::Null); + let nd = app.ui.dialog.clone().expect("New Document"); + assert_eq!(nd.fields["units"], json!("inches")); + assert_eq!(nd.s("width"), designcraft_geom::format_measure(612.0, Unit::Inches)); + confirm(&mut app).unwrap(); + let st = &app.session.doc().unwrap().doc.settings; + assert_eq!((st.horizontal_units, st.vertical_units, st.keyboard_increment), (Unit::Inches, Unit::Inches, 0.5)); + // With the document open, Preferences edits that document's units, not the defaults. + crate::menus::activate(&mut app, "app.preferences", &Value::Null); + assert!(!app.ui.dialog.as_ref().unwrap().b("defaults")); +} From f713e97a7f7b2af46f8d51df75d729f458f4761b Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:26:17 +0100 Subject: [PATCH 127/207] 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 | 19 ------------------- crates/ui-egui/src/dialogs/tests.rs | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0506af15f..45ffc593b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,25 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - /// Preferences › File Handling shows the number of recent items as a whole number, as - /// InDesign's does ("20"; it read "20.0"). - #[test] - fn file_handling_shows_the_recent_items_count_as_a_whole_number() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - app.run("app.preferences", json!({})).unwrap(); - app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!("files")); - let ctx = dialog_context("en"); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 700.0)); - dialog_frame(&mut app, &ctx, screen, vec![]); - let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - visible_label(&labels, screen, "Number of Recent Items to Display:"); - let count = app.ui.dialog.as_ref().unwrap().n("recentFilesMax").unwrap(); - assert_eq!(count, 20.0, "InDesign's default"); - visible_label(&labels, screen, &format!("{count}")); - assert!(!labels.iter().any(|(t, _, _)| t.trim() == format!("{count:.1}")), "no decimal shown"); - } - #[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..167158d84 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,22 @@ 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)); } + +/// Preferences › File Handling shows the number of recent items as a whole number, as +/// InDesign's does ("20"; it read "20.0"). +#[test] +fn file_handling_shows_the_recent_items_count_as_a_whole_number() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("app.preferences", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!("files")); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 700.0)); + dialog_frame(&mut app, &ctx, screen, vec![]); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "Number of Recent Items to Display:"); + let count = app.ui.dialog.as_ref().unwrap().n("recentFilesMax").unwrap(); + assert_eq!(count, 20.0, "InDesign's default"); + visible_label(&labels, screen, &format!("{count}")); + assert!(!labels.iter().any(|(t, _, _)| t.trim() == format!("{count:.1}")), "no decimal shown"); +} From 788061a9ae8350c69c45eb5d725272d75b7df5d6 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:26:18 +0100 Subject: [PATCH 128/207] 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 | 58 ----------------------------- crates/ui-egui/src/dialogs/tests.rs | 58 +++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 58 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index cdcccc954..85ad65dce 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,64 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - /// Collapsing advanced TOC controls changes visibility without discarding their values. - #[test] - fn toc_more_options_preserves_hidden_settings() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.newSample", json!({})).unwrap(); - open_toc(&mut app); - app.ui.dialog.as_mut().unwrap().fields.insert( - "included".into(), - json!([{ - "style": "Headline", "level": 3, "separator": " ^t", "pageNumber": "before", - "pageNumberStyle": "Accent", "separatorStyle": "Emphasis", "sort": true - }]), - ); - let before = toc_params(&app, app.ui.dialog.as_ref().unwrap()); - let revision = app.session.doc().unwrap().revision; - let ctx = dialog_context("en"); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0)); - for _ in 0..3 { - dialog_frame(&mut app, &ctx, screen, vec![]); - } - let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - visible_label(&labels, screen, "Between Entry and Number:"); - click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "Fewer Options").center()); - let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - assert!(!labels.iter().any(|(text, _, _)| text == "Between Entry and Number:")); - visible_label(&labels, screen, "Entry Style:"); - assert_eq!(toc_params(&app, app.ui.dialog.as_ref().unwrap()), before); - click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "More Options").center()); - for _ in 0..3 { - dialog_frame(&mut app, &ctx, screen, vec![]); - } - let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - visible_label(&labels, screen, "Between Entry and Number:"); - assert_eq!(toc_params(&app, app.ui.dialog.as_ref().unwrap()), before); - assert_eq!(app.session.doc().unwrap().revision, revision); - } - - /// Enter on the focused Fewer Options / More Options button toggles the options; it doesn't - /// also make the table of contents. - #[test] - fn enter_on_the_toc_options_toggle_only_toggles() { - use egui_kittest::{Harness, kittest::Queryable}; - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.newSample", json!({})).unwrap(); - open_toc(&mut app); - let revision = app.session.doc().unwrap().revision; - let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(|ui, app: &mut DesignApp| show(app, ui.ctx()), app); - h.run_steps(3); - h.get_by_label("Fewer Options").focus(); - h.run_steps(2); - h.key_press(egui::Key::Enter); - h.run_steps(3); - let d = h.state().ui.dialog.as_ref().expect("the dialog stays open"); - assert_eq!((d.id.as_str(), d.b("moreOptions")), (TOC, false), "the options are fewer"); - assert_eq!(h.state().session.doc().unwrap().revision, revision, "no table of contents made"); - assert!(h.query_by_label("More Options").is_some()); - } - #[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..549b9b6dc 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,61 @@ 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)); } + +/// Collapsing advanced TOC controls changes visibility without discarding their values. +#[test] +fn toc_more_options_preserves_hidden_settings() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.newSample", json!({})).unwrap(); + open_toc(&mut app); + app.ui.dialog.as_mut().unwrap().fields.insert( + "included".into(), + json!([{ + "style": "Headline", "level": 3, "separator": " ^t", "pageNumber": "before", + "pageNumberStyle": "Accent", "separatorStyle": "Emphasis", "sort": true + }]), + ); + let before = toc_params(&app, app.ui.dialog.as_ref().unwrap()); + let revision = app.session.doc().unwrap().revision; + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "Between Entry and Number:"); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "Fewer Options").center()); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + assert!(!labels.iter().any(|(text, _, _)| text == "Between Entry and Number:")); + visible_label(&labels, screen, "Entry Style:"); + assert_eq!(toc_params(&app, app.ui.dialog.as_ref().unwrap()), before); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "More Options").center()); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "Between Entry and Number:"); + assert_eq!(toc_params(&app, app.ui.dialog.as_ref().unwrap()), before); + assert_eq!(app.session.doc().unwrap().revision, revision); +} + +/// Enter on the focused Fewer Options / More Options button toggles the options; it doesn't +/// also make the table of contents. +#[test] +fn enter_on_the_toc_options_toggle_only_toggles() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.newSample", json!({})).unwrap(); + open_toc(&mut app); + let revision = app.session.doc().unwrap().revision; + let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(|ui, app: &mut DesignApp| show(app, ui.ctx()), app); + h.run_steps(3); + h.get_by_label("Fewer Options").focus(); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let d = h.state().ui.dialog.as_ref().expect("the dialog stays open"); + assert_eq!((d.id.as_str(), d.b("moreOptions")), (TOC, false), "the options are fewer"); + assert_eq!(h.state().session.doc().unwrap().revision, revision, "no table of contents made"); + assert!(h.query_by_label("More Options").is_some()); +} From a769513826a9ef2662994bbf8ce8e066445da7ae Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:26:18 +0100 Subject: [PATCH 129/207] 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 | 34 ----------------------------- crates/ui-egui/src/dialogs/tests.rs | 34 +++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ef4814347..dc13b83ed 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,40 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - /// The title stays readable beside the style selector after the grid settles. - #[test] - fn toc_title_field_shows_the_whole_default_title() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.newSample", json!({})).unwrap(); - open_toc(&mut app); - let ctx = dialog_context("en"); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0)); - for _ in 0..3 { - dialog_frame(&mut app, &ctx, screen, vec![]); - } - let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - let title_row = |labels: &[(String, egui::Rect, egui::Rect)]| { - let y = visible_label(labels, screen, "Title:").center().y; - labels.iter().filter(|(_, rect, _)| (rect.center().y - y).abs() < 2.0).cloned().collect::>() - }; - visible_label(&title_row(&labels), screen, "Contents"); - app.ui.dialog.as_mut().unwrap().fields.insert("title".into(), json!("Table of Contents")); - let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - visible_label(&title_row(&labels), screen, "Table of Contents"); - // A small window: the title narrows and the style selector beside it stays in view. - let small = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(450.0, 280.0)); - for _ in 0..3 { - dialog_frame(&mut app, &ctx, small, vec![]); - } - let labels = dialog_frame(&mut app, &ctx, small, vec![]); - let y = visible_label(&labels, small, "Title:").center().y; - let row: Vec<_> = labels.iter().filter(|(_, rect, _)| (rect.center().y - y).abs() < 2.0).cloned().collect(); - let style = visible_label(&row, small, "TOC Title"); - // The selector is 200 pt wide from just before its text: all of it is in the dialog body. - let (_, _, body) = row.iter().find(|(text, _, _)| text == "Style:").unwrap(); - assert!(style.min.x - 4.0 + 200.0 <= body.max.x, "the style selector fits the dialog: {style:?} in {body:?}"); - } - #[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 3d1ef0acc..7415066fb 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,37 @@ 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)); } + +/// The title stays readable beside the style selector after the grid settles. +#[test] +fn toc_title_field_shows_the_whole_default_title() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.newSample", json!({})).unwrap(); + open_toc(&mut app); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + let title_row = |labels: &[(String, egui::Rect, egui::Rect)]| { + let y = visible_label(labels, screen, "Title:").center().y; + labels.iter().filter(|(_, rect, _)| (rect.center().y - y).abs() < 2.0).cloned().collect::>() + }; + visible_label(&title_row(&labels), screen, "Contents"); + app.ui.dialog.as_mut().unwrap().fields.insert("title".into(), json!("Table of Contents")); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&title_row(&labels), screen, "Table of Contents"); + // A small window: the title narrows and the style selector beside it stays in view. + let small = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(450.0, 280.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, small, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, small, vec![]); + let y = visible_label(&labels, small, "Title:").center().y; + let row: Vec<_> = labels.iter().filter(|(_, rect, _)| (rect.center().y - y).abs() < 2.0).cloned().collect(); + let style = visible_label(&row, small, "TOC Title"); + // The selector is 200 pt wide from just before its text: all of it is in the dialog body. + let (_, _, body) = row.iter().find(|(text, _, _)| text == "Style:").unwrap(); + assert!(style.min.x - 4.0 + 200.0 <= body.max.x, "the style selector fits the dialog: {style:?} in {body:?}"); +} From 0d486c91cac11f0d0fefb499eb3dd6bf42675084 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:26:19 +0100 Subject: [PATCH 130/207] 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 1d62a5875c81b8f3cf6d3bdd1e0ef3ba5eede051 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:26:19 +0100 Subject: [PATCH 131/207] 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 | 30 ----------------------------- crates/ui-egui/src/dialogs/tests.rs | 30 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index cba803f4d..9150f8d23 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,36 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - /// The bookmarks checkbox persists through generation and reopening the dialog. - #[test] - fn toc_bookmarks_checkbox_sets_and_reopens_the_persisted_option() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.newSample", json!({})).unwrap(); - open_toc(&mut app); - assert!(app.ui.dialog.as_ref().unwrap().b("createBookmarks")); - app.ui.dialog.as_mut().unwrap().fields.insert("included".into(), json!([{"style": "Headline", "level": 1}])); - let ctx = dialog_context("en"); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); - for _ in 0..3 { - dialog_frame(&mut app, &ctx, screen, vec![]); - } - let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - visible_label(&labels, screen, "Create PDF Bookmarks"); - confirm(&mut app).unwrap(); - assert!(app.session.doc().unwrap().doc.toc.as_ref().unwrap().create_bookmarks); - assert!(!app.session.doc().unwrap().doc.bookmarks.is_empty()); - open_toc(&mut app); - for _ in 0..3 { - dialog_frame(&mut app, &ctx, screen, vec![]); - } - let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "Create PDF Bookmarks").center()); - confirm(&mut app).unwrap(); - assert!(app.session.doc().unwrap().doc.bookmarks.is_empty()); - open_toc(&mut app); - assert!(!app.ui.dialog.as_ref().unwrap().b("createBookmarks")); - } - #[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..560af66bd 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,33 @@ 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)); } + +/// The bookmarks checkbox persists through generation and reopening the dialog. +#[test] +fn toc_bookmarks_checkbox_sets_and_reopens_the_persisted_option() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.newSample", json!({})).unwrap(); + open_toc(&mut app); + assert!(app.ui.dialog.as_ref().unwrap().b("createBookmarks")); + app.ui.dialog.as_mut().unwrap().fields.insert("included".into(), json!([{"style": "Headline", "level": 1}])); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "Create PDF Bookmarks"); + confirm(&mut app).unwrap(); + assert!(app.session.doc().unwrap().doc.toc.as_ref().unwrap().create_bookmarks); + assert!(!app.session.doc().unwrap().doc.bookmarks.is_empty()); + open_toc(&mut app); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "Create PDF Bookmarks").center()); + confirm(&mut app).unwrap(); + assert!(app.session.doc().unwrap().doc.bookmarks.is_empty()); + open_toc(&mut app); + assert!(!app.ui.dialog.as_ref().unwrap().b("createBookmarks")); +} From b0068ee57b7b18f7c65f46040706413ddb5d8a5a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:26:52 +0100 Subject: [PATCH 132/207] Parent page box: json! where main's split canvas view has no import Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/view.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index 7f0e7768d..b679923d2 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -187,7 +187,7 @@ pub fn page_box(app: &DesignApp) -> Option { pub fn page_box_go(app: &mut DesignApp, b: &PageBox, i: usize) { if i < b.pages { if app.session.active().is_some_and(|st| st.editing_parents) { - let _ = app.run("layout.parents.edit", json!({"on": false})); + let _ = app.run("layout.parents.edit", serde_json::json!({"on": false})); // The canvas refits to the page's spread once it is centred. if let Some(v) = app.view_mut() { v.fitted = false; @@ -195,7 +195,7 @@ pub fn page_box_go(app: &mut DesignApp, b: &PageBox, i: usize) { } go_to_page(app, i); } else if let Some(id) = b.parents.get(i - b.pages) { - let _ = app.run("layout.parents.edit", json!({"parent": id.0})); + let _ = app.run("layout.parents.edit", serde_json::json!({"parent": id.0})); } } From a9814cae3995ac610f0ddfb32994ffa436740db9 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:27:13 +0100 Subject: [PATCH 133/207] Fill colour picker: the picker opener shared with the text panel, the swatches module's path, one Fill popup per chip Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/object.rs | 4 ++-- crates/ui-egui/src/panels/properties/text.rs | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/properties/object.rs b/crates/ui-egui/src/panels/properties/object.rs index 50f8e498e..e9daa0f32 100644 --- a/crates/ui-egui/src/panels/properties/object.rs +++ b/crates/ui-egui/src/panels/properties/object.rs @@ -378,8 +378,8 @@ pub(super) fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) /// The Color Picker for a Fill or Stroke chip (a double-click on it, as in InDesign), with the /// chip's colour: the objects', or with `text` the selected text's. -fn open_color_picker(app: &mut DesignApp, doc: &designcraft_doc::Document, swatch: &str, stroke: bool, text: bool) { - let mut fields = super::swatches::picker_fields(doc.resolve_color(swatch, 1.0), stroke); +pub(super) fn open_color_picker(app: &mut DesignApp, doc: &designcraft_doc::Document, swatch: &str, stroke: bool, text: bool) { + let mut fields = crate::panels::swatches::picker_fields(doc.resolve_color(swatch, 1.0), stroke); fields["text"] = json!(text); app.ui.dialog = Some(crate::dialogs::Dialog::new("colorPicker", fields)); } diff --git a/crates/ui-egui/src/panels/properties/text.rs b/crates/ui-egui/src/panels/properties/text.rs index 05a2175a4..64c6d6890 100644 --- a/crates/ui-egui/src/panels/properties/text.rs +++ b/crates/ui-egui/src/panels/properties/text.rs @@ -1,4 +1,4 @@ -use super::object::swatch_menu; +use super::object::{open_color_picker, swatch_menu}; use super::typography::{para_field_min, show_typography_panel}; use super::{ Align, DesignApp, FIELD_H, NumField, Rect, Sense, Stroke, StrokeKind, Tokens, Ui, Value, block, full_width, icon_toggle, icons, json, link_label, @@ -128,7 +128,6 @@ pub(super) fn text_appearance_section(app: &mut DesignApp, ui: &mut Ui) { ui.painter().text(chip.center(), egui::Align2::CENTER_CENTER, "T", crate::theme::semibold(12.0), tc); } let resp = ui.interact(chip, ui.scope_id().with(("tchip", k)), Sense::click()).on_hover_text(format!("{label}: {sw}")); - swatch_menu(app, &resp, &doc, &sw, k != 0, false, true); resp.widget_info(|| { egui::WidgetInfo::labeled(egui::WidgetType::Button, true, if k == 0 { "Text fill swatch" } else { "Text stroke swatch" }) }); From 1698f69d683e58419565a4e241f38bf502223739 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:30:59 +0100 Subject: [PATCH 134/207] Hidden characters test: in canvas/tests.rs, out of the canvas paint function Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 32 ------------------------------ crates/ui-egui/src/canvas/tests.rs | 32 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5c0926b44..1b1e10d13 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -194,38 +194,6 @@ fn paint_render(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Documen painter.add(egui::Shape::mesh(mesh)); } } - - /// As InDesign: an end-of-story # in every cell (after its text), a ¶ where a cell paragraph - /// ends, and the table paragraph's ¶ just after the table's bottom right corner. - #[test] - fn table_cells_show_their_hidden_characters() { - let mut d = Document::new(&designcraft_doc::build::NewDocument::default()); - let lid = d.default_layer(); - let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), DRect::new(0.0, 0.0, 300.0, 300.0), lid, "", Default::default()).unwrap(); - let data = vec![vec!["Name:".to_string(), "".into()], vec!["a\nb".into(), "".into()]]; - let t = designcraft_doc::Table::from_strings(d.alloc(), &data, 200.0, &Default::default(), &Default::default()); - d.story_mut(sid).unwrap().insert_table(0, t); - let cs = compose::compose_story(&d, sid, &compose::ComposeOptions::default()); - let frag = &cs.frames[0].tables[0]; - // The whole frame: the table's own line adds no second ¶; the story's last paragraph - // (after the table) has its #. - let story = d.story(sid).unwrap().text.clone(); - let all = frame_hidden_marks(&story, &cs.frames[0]); - let marks: Vec<_> = all.iter().filter(|m| frag.rect.inflate(2.0, 2.0).contains(m.0)).cloned().collect(); - let ends = |m: &str| marks.iter().filter(|x| x.1 == m).count(); - assert_eq!(ends("#"), 4, "one per cell: {marks:?}"); - assert_eq!(ends("¶"), 2, "the cell's first paragraph and the table's, once: {marks:?}"); - assert_eq!(all.iter().filter(|m| m.1 == "¶" && m.3 > 20.0).count(), 0, "no ¶ the size of the table: {all:?}"); - // Each # is inside its cell, after the cell's text. - for c in &frag.cells { - let inside = marks.iter().filter(|m| m.1 == "#" && c.rect.contains(m.0)).count(); - assert_eq!(inside, 1, "cell {},{}: {marks:?}", c.row, c.col); - } - let name = frag.cell(0, 0).unwrap(); - assert!(marks.iter().any(|m| m.1 == "#" && name.rect.contains(m.0) && m.0.x > name.origin.x + 20.0), "after Name:"); - let last = marks.last().unwrap(); - assert_eq!((last.0, last.1), (designcraft_geom::Point::new(frag.rect.x1 + 1.0, frag.rect.y1), "¶")); - } } /// Presentation: a click shows the next spread, Shift-click the previous one. diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 01300bdd0..f36269479 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2837,3 +2837,35 @@ fn trailing_spaces_show_dots_before_the_paragraph_mark() { assert!(para_x >= space.x + space.adv, "{mark} at {para_x} overlaps the last space"); } } + +/// As InDesign: an end-of-story # in every cell (after its text), a ¶ where a cell paragraph +/// ends, and the table paragraph's ¶ just after the table's bottom right corner. +#[test] +fn table_cells_show_their_hidden_characters() { + let mut d = Document::new(&designcraft_doc::build::NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), DRect::new(0.0, 0.0, 300.0, 300.0), lid, "", Default::default()).unwrap(); + let data = vec![vec!["Name:".to_string(), "".into()], vec!["a\nb".into(), "".into()]]; + let t = designcraft_doc::Table::from_strings(d.alloc(), &data, 200.0, &Default::default(), &Default::default()); + d.story_mut(sid).unwrap().insert_table(0, t); + let cs = compose::compose_story(&d, sid, &compose::ComposeOptions::default()); + let frag = &cs.frames[0].tables[0]; + // The whole frame: the table's own line adds no second ¶; the story's last paragraph + // (after the table) has its #. + let story = d.story(sid).unwrap().text.clone(); + let all = frame_hidden_marks(&story, &cs.frames[0]); + let marks: Vec<_> = all.iter().filter(|m| frag.rect.inflate(2.0, 2.0).contains(m.0)).cloned().collect(); + let ends = |m: &str| marks.iter().filter(|x| x.1 == m).count(); + assert_eq!(ends("#"), 4, "one per cell: {marks:?}"); + assert_eq!(ends("¶"), 2, "the cell's first paragraph and the table's, once: {marks:?}"); + assert_eq!(all.iter().filter(|m| m.1 == "¶" && m.3 > 20.0).count(), 0, "no ¶ the size of the table: {all:?}"); + // Each # is inside its cell, after the cell's text. + for c in &frag.cells { + let inside = marks.iter().filter(|m| m.1 == "#" && c.rect.contains(m.0)).count(); + assert_eq!(inside, 1, "cell {},{}: {marks:?}", c.row, c.col); + } + let name = frag.cell(0, 0).unwrap(); + assert!(marks.iter().any(|m| m.1 == "#" && name.rect.contains(m.0) && m.0.x > name.origin.x + 20.0), "after Name:"); + let last = marks.last().unwrap(); + assert_eq!((last.0, last.1), (designcraft_geom::Point::new(frag.rect.x1 + 1.0, frag.rect.y1), "¶")); +} From a04852c8cd4fa756cb9895766f4bef679306f417 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:31:57 +0100 Subject: [PATCH 135/207] Georgian: main's rows for the Object menu's point words, once each Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ka.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index b973aaba1..812b8d9a6 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -32,10 +32,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), - ("Join", "შეერთება"), - ("Convert Point", "წერტილის გარდაქმნა"), - ("Plain", "მარტივი"), - ("Symmetrical", "სიმეტრიული"), ("Restart Numbering", "ნუმერაციის თავიდან დაწყება"), ("Continue Numbering", "ნუმერაციის გაგრძელება"), ("Keep Options…", "შენარჩუნების პარამეტრები…"), From 948482d60fe1878eaf77afac11a5ae204c23d8d2 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:34:27 +0100 Subject: [PATCH 136/207] Tab in footnote text is typed, not taken for the next table cell Footnote text is addressed as a cell too (as 2e44b190 handled for Shift+Tab, Esc and the cell commands); text.insert now types the Tab there instead of failing in table.nextCell. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/text/edit.rs | 5 +++-- crates/engine/src/tests/table.rs | 4 ++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/text/edit.rs b/crates/engine/src/cmd/text/edit.rs index 21888cb22..fd104cbcd 100644 --- a/crates/engine/src/cmd/text/edit.rs +++ b/crates/engine/src/cmd/text/edit.rs @@ -13,8 +13,9 @@ fn reserve_command_text(st: &mut designcraft_doc::Story, additional: usize) -> R pub(super) fn insert(s: &mut Session, p: &Value) -> Result { let text = crate::cmd::text_param(p, "text"); - // Tab in a table cell moves to the next cell (Shift-Tab: `table.prevCell`). - if text == "\t" && s.doc()?.selection.text.is_some_and(|t| t.cell.is_some()) { + // Tab in a table cell moves to the next cell (Shift-Tab: `table.prevCell`). Footnote text is + // addressed as a cell too: a Tab there is typed. + if text == "\t" && s.doc()?.selection.text.is_some_and(|t| t.cell.is_some_and(|c| c.footnote_id().is_none())) { return crate::cmd::table::step_cell(s, true); } // Typing with one cell selected replaces its text, as InDesign. diff --git a/crates/engine/src/tests/table.rs b/crates/engine/src/tests/table.rs index c6b2b8824..45e4b8db8 100644 --- a/crates/engine/src/tests/table.rs +++ b/crates/engine/src/tests/table.rs @@ -140,6 +140,10 @@ fn the_cell_commands_refuse_footnote_text() { assert!(s.execute(cmd, &json!({})).is_err(), "{cmd}"); } assert_eq!(s.doc().unwrap().selection, before); + // A Tab there is typed into the footnote, not taken for the next cell. + s.execute("text.insert", &json!({"text": "\t"})).unwrap(); + let notes = s.execute("footnote.list", &json!({})).unwrap(); + assert!(notes[0]["text"].as_str().is_some_and(|t| t.contains('\t')), "{notes}"); } /// Esc in a table toggles between the text and the cell (InDesign's Esc): the caret in a cell From 9fe094575a0a9a64e75e5885bb08ad3968361a7b Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 17:04:00 +0100 Subject: [PATCH 137/207] egui 0.37: Id::unique for the wrap offsets link state Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/object.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/properties/object.rs b/crates/ui-egui/src/panels/properties/object.rs index 3795bcd8a..672c0cb31 100644 --- a/crates/ui-egui/src/panels/properties/object.rs +++ b/crates/ui-egui/src/panels/properties/object.rs @@ -541,7 +541,7 @@ fn wrap_offsets(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { // The link's state belongs to the selection: another selection starts linked again when its // four offsets are the same. let selected: Vec = app.session.active().map(|st| st.selection.items.iter().map(|i| i.0).collect()).unwrap_or_default(); - let link_id = egui::Id::new(("prop_wrap_linked", selected)); + let link_id = egui::Id::unique(("prop_wrap_linked", selected)); ui.add_enabled_ui(enabled, |ui| { // Linked while the four are the same, until the link is clicked. let same = offsets.iter().all(|v| (v - offsets[0]).abs() < 1e-9); From 38d84c00814c782ad1c8bdd943c1b5d49daf537a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 17:31:39 +0100 Subject: [PATCH 138/207] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/color_panel_tests.rs | 2 +- crates/ui-egui/src/panels/swatches.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/color_panel_tests.rs b/crates/ui-egui/src/panels/color_panel_tests.rs index 05d5899b6..153216100 100644 --- a/crates/ui-egui/src/panels/color_panel_tests.rs +++ b/crates/ui-egui/src/panels/color_panel_tests.rs @@ -95,7 +95,7 @@ fn the_color_panel_edits_the_gradient_stop_selected_in_the_gradient_panel() { app.run("object.gradient", json!({})).unwrap(); let mut h = harness(app); h.run_steps(2); - h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(GRAD_SEL), RampSel::Stop(1))); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique(GRAD_SEL), RampSel::Stop(1))); h.run_steps(3); assert!(h.query_by_label("R=0 G=0 B=0").is_some(), "the black stop's values"); // C: the topmost field. diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 92d7ff2ef..854e16a89 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -581,7 +581,7 @@ fn cmyk_ramp(k: usize, x: f32) -> egui::Color32 { fn gradient_stop(app: &mut DesignApp, ui: &mut egui::Ui, g: &designcraft_color::Gradient, target: &str, mode: ColorMode) { use designcraft_color::Color; let t = Tokens::get(ui.ctx()); - let sel: RampSel = ui.data(|d| d.get_temp(egui::Id::new(GRAD_SEL))).unwrap_or(RampSel::Stop(0)); + let sel: RampSel = ui.data(|d| d.get_temp(egui::Id::unique(GRAD_SEL))).unwrap_or(RampSel::Stop(0)); let i = match sel { RampSel::Stop(i) | RampSel::Mid(i) => i, } From 0dab4137eecd8e821c65d36de6a4f8cd0c57e1f4 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 17:49:56 +0100 Subject: [PATCH 139/207] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/object.rs | 2 +- crates/ui-egui/src/panels/properties/tests.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/properties/object.rs b/crates/ui-egui/src/panels/properties/object.rs index 3d5ce2ab8..36a9fecaa 100644 --- a/crates/ui-egui/src/panels/properties/object.rs +++ b/crates/ui-egui/src/panels/properties/object.rs @@ -355,7 +355,7 @@ pub(super) fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) let mresp = ui.interact(more, ui.scope_id().with("opmore"), Sense::click()); ui.painter().rect(more, 0.0, if mresp.hovered() { t.hover } else { t.input }, Stroke::new(1.0, t.field_border), StrokeKind::Inside); icons::paint(ui.painter(), more.shrink(4.0), "chevron-right", t.icon); - mresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(&app.ui.language, "Opacity"))); + mresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::i18n::tr(&app.ui.language, "Opacity"))); opacity_slider(app, &mresp, i.opacity); ui.add_space(6.0); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 2858392f0..06d462abe 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -722,7 +722,7 @@ fn the_opacity_arrow_opens_a_slider_and_a_drag_is_one_undo_step() { let steps = undo_steps(&app); let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( |ui, app: &mut DesignApp| { - let id = egui::Id::new("test_fonts"); + let id = egui::Id::unique("test_fonts"); if ui.data(|d| d.get_temp::(id)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(id, true)); From 85991bf13f7aebc286c3f693611491017ef9fbf8 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 17:57:34 +0100 Subject: [PATCH 140/207] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/new_document.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/new_document.rs b/crates/ui-egui/src/new_document.rs index 54fa76a33..1cd90d0a7 100644 --- a/crates/ui-egui/src/new_document.rs +++ b/crates/ui-egui/src/new_document.rs @@ -510,7 +510,7 @@ fn presets(app: &mut DesignApp, ui: &mut Ui, d: &mut Dialog) -> bool { let rect = Rect::from_min_size(pos2(x, resp.rect.top() + 4.0), vec2(22.0, 22.0)); let button = ui.interact(rect, ui.scope_id().with(("delete-preset", &c.key)), Sense::click()); let label = format!("{}: {}", crate::i18n::tr(&lang, "Delete Preset"), c.label); - button.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label.as_str())); + button.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label.as_str())); if button.hovered() { ui.painter().rect_filled(rect, 2.0, t.hover); } From 7a5101d0a85344763c39c7837285867df489e47b Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 18:01:19 +0100 Subject: [PATCH 141/207] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/swatches.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 51643b2fc..8864e75ce 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -575,7 +575,7 @@ fn cmyk_ramp(k: usize, x: f32) -> egui::Color32 { /// The spectrum ramp: hue across, light to dark down; click to apply. fn spectrum(app: &mut DesignApp, ui: &mut egui::Ui, target: &str) { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(&app.ui.language, "Color spectrum"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::i18n::tr(&app.ui.language, "Color spectrum"))); let at = |u: f32, v: f32| -> [f32; 3] { let c = egui::ecolor::Hsva::new(u, (v * 2.0).min(1.0), (2.0 - v * 2.0).min(1.0), 1.0).to_rgb(); [c[0], c[1], c[2]] @@ -683,7 +683,7 @@ mod color_panel_defaults_tests { let mut h = egui_kittest::Harness::builder().with_size(vec2(320.0, 480.0)).build_ui_state( |ui, app: &mut DesignApp| { // The panel's fonts are installed on the first frame and used from the next. - let id = egui::Id::new("test_fonts"); + let id = egui::Id::unique("test_fonts"); if ui.data(|d| d.get_temp::(id)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(id, true)); From f1a0b1eb29927239abe729f086617c1867ecca03 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 18:27:22 +0100 Subject: [PATCH 142/207] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 4704cc870..7adb3f622 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -7244,7 +7244,7 @@ fn corner_options_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) let key = format!("shape{k}"); let shape: CornerShape = d.fields.get(&key).and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); let resp = crate::panels::properties::corner_shape_button(ui, shape, 46.0); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, format!("{} {}", tr(tip), tr(shape.label())))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, format!("{} {}", tr(tip), tr(shape.label())))); egui::Popup::menu(&resp).show(|ui| { ui.set_min_width(150.0); for s in CornerShape::ALL { @@ -7271,7 +7271,7 @@ fn corner_options_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) crate::icons::button(ui, if linked { "link" } else { "link-broken" }, 20.0, linked, &tip) }) .inner; - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &tip)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &tip)); if resp.clicked() { d.fields.insert("link".into(), json!(!linked)); } From d99434b18a68d15260c12fa29e40867a58aa3f96 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 18:27:45 +0100 Subject: [PATCH 143/207] Corner Options: the corner shape button re-exported from main's split Properties module Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index a05eaccc5..888302763 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -31,7 +31,7 @@ use flex::flex_section; pub(crate) use links::run_update_links; pub use links::{info_panel, links_panel, preflight_panel, update_links}; use object::{align_row, appearance_section, guide_section, quick_actions_object, transform_section, wrap_section}; -pub(crate) use object::{corner_type_dropdown, ref_point_proxy, stroke_type_dropdown}; +pub(crate) use object::{corner_shape_button, corner_type_dropdown, ref_point_proxy, stroke_type_dropdown}; use object_panels::pathfinder_row; pub use object_panels::{align_panel, attributes_panel, effects_panel, pathfinder_panel, stroke_panel, wrap_panel}; pub(crate) use text::{LANGUAGES, kerning_control, language_menu}; From 176b35aa332502979be0809edae676172a650a0d Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 18:42:51 +0100 Subject: [PATCH 144/207] 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() From 25e60b515b908e5e196a85bdf46f72d4804ba43e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 18:46:31 +0100 Subject: [PATCH 145/207] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/chrome/status_bar.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/chrome/status_bar.rs b/crates/ui-egui/src/chrome/status_bar.rs index 519830620..5fde929be 100644 --- a/crates/ui-egui/src/chrome/status_bar.rs +++ b/crates/ui-egui/src/chrome/status_bar.rs @@ -131,7 +131,7 @@ pub fn status_bar(app: &mut DesignApp, ui: &mut egui::Ui) { ui.painter().text(fr.right_center() - vec2(5.0, 0.0), egui::Align2::RIGHT_CENTER, "▾", small.clone(), t.text); let label = crate::i18n::tr(&app.ui.language, "Page"); fresp.widget_info(|| { - let mut info = egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, label); + let mut info = egui::WidgetInfo::labeled(egui::Role::ComboBox, true, label); info.current_text_value = Some(name.clone()); info }); From 5ef8f3e5b04ad8bbcb8555e6fb0bfc3aed33b6d7 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 18:46:46 +0100 Subject: [PATCH 146/207] Parent page box: re-exported from main's split canvas view module Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index de4331c7d..18aabfae5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -62,7 +62,7 @@ pub(crate) use view::page_in_view; use view::present_fit; pub use view::{ apply_view_request, can_go_through_history, current_page, current_parent_page, current_slot, current_spread, current_spread_page, drop_point, - fit, go_through_history, go_to_page, set_zoom, show_spread, step_spread, view_centre, zoom_at, + PageBox, fit, go_through_history, go_to_page, page_box, page_box_go, set_zoom, show_spread, step_spread, view_centre, zoom_at, }; pub const RULER: f32 = 15.0; From 15b2f49342cf648d5216751d027815ab654f67a4 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 18:49:03 +0100 Subject: [PATCH 147/207] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/tests.rs | 2 +- crates/ui-egui/src/panels/properties/text.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 96a1b14b6..2f6a6f4fb 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -657,7 +657,7 @@ fn double_clicking_a_fill_chip_opens_the_color_picker() { let panel = |app: DesignApp| { let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).with_step_dt(0.001).build_ui_state( |ui, app: &mut DesignApp| { - let id = egui::Id::new("test_fonts"); + let id = egui::Id::unique("test_fonts"); if ui.data(|d| d.get_temp::(id)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(id, true)); diff --git a/crates/ui-egui/src/panels/properties/text.rs b/crates/ui-egui/src/panels/properties/text.rs index 64c6d6890..ad25a5a5a 100644 --- a/crates/ui-egui/src/panels/properties/text.rs +++ b/crates/ui-egui/src/panels/properties/text.rs @@ -129,7 +129,7 @@ pub(super) fn text_appearance_section(app: &mut DesignApp, ui: &mut Ui) { } let resp = ui.interact(chip, ui.scope_id().with(("tchip", k)), Sense::click()).on_hover_text(format!("{label}: {sw}")); resp.widget_info(|| { - egui::WidgetInfo::labeled(egui::WidgetType::Button, true, if k == 0 { "Text fill swatch" } else { "Text stroke swatch" }) + egui::WidgetInfo::labeled(egui::Role::Button, true, if k == 0 { "Text fill swatch" } else { "Text stroke swatch" }) }); swatch_menu(app, &resp, &doc, &sw, k != 0, false, true); if resp.double_clicked() { From 9577b7f09cb0d67846905a05fecd966115b4f087 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 18:51:14 +0100 Subject: [PATCH 148/207] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/text.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/properties/text.rs b/crates/ui-egui/src/panels/properties/text.rs index ad25a5a5a..a9d6e9baa 100644 --- a/crates/ui-egui/src/panels/properties/text.rs +++ b/crates/ui-egui/src/panels/properties/text.rs @@ -128,9 +128,7 @@ pub(super) fn text_appearance_section(app: &mut DesignApp, ui: &mut Ui) { ui.painter().text(chip.center(), egui::Align2::CENTER_CENTER, "T", crate::theme::semibold(12.0), tc); } let resp = ui.interact(chip, ui.scope_id().with(("tchip", k)), Sense::click()).on_hover_text(format!("{label}: {sw}")); - resp.widget_info(|| { - egui::WidgetInfo::labeled(egui::Role::Button, true, if k == 0 { "Text fill swatch" } else { "Text stroke swatch" }) - }); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, if k == 0 { "Text fill swatch" } else { "Text stroke swatch" })); swatch_menu(app, &resp, &doc, &sw, k != 0, false, true); if resp.double_clicked() { open_color_picker(app, &doc, &sw, k != 0, true); From 47204fa0bb090411c7a526fe0e5e687e04ca00d4 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:44:08 +0100 Subject: [PATCH 149/207] 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 | 67 ----------------------------- crates/ui-egui/src/dialogs/tests.rs | 67 +++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 67 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5aad7fecd..d9d48e416 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,73 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - /// The course's chapter 26: Preferences › Interface as InDesign's. A Color Theme swatch shows - /// its theme at once and Cancel goes back; OK keeps the theme and the Tools panel's columns. - /// UI Size is in a UI Scaling section of its own. - #[test] - fn preferences_interface_sets_the_color_theme_and_tools_panel() { - use crate::theme::Brightness; - use egui::accesskit::Role; - use egui_kittest::{Harness, kittest::Queryable}; - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let opened = app.ui.brightness; - let open = |app: &mut DesignApp| { - app.run("app.preferences", json!({})).unwrap(); - app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!("interface")); - }; - open(&mut app); - let mut h = Harness::builder().with_size(egui::vec2(1100.0, 760.0)).build_ui_state( - |ui, app: &mut DesignApp| { - let key = egui::Id::new("prefs_interface_test_fonts"); - if ui.data(|d| d.get_temp::(key)).is_none() { - crate::theme::install_fonts(ui.ctx(), ""); - ui.data_mut(|d| d.insert_temp(key, true)); - return; - } - show(app, ui.ctx()); - }, - app, - ); - h.run_steps(3); - let swatch = - |h: &Harness<'_, DesignApp>, name: &str| h.get_by(|n| n.role() == Role::RadioButton && n.label().as_deref() == Some(name)).click(); - for l in ["Appearance", "Color Theme:", "Panels", "Tools Panel:"] { - assert!(h.query_by_label(l).is_some(), "{l}"); - } - // A click shows the theme at once; Cancel goes back. - swatch(&h, "Light"); - h.run_steps(3); - assert_eq!(h.state().ui.brightness, Brightness::Light); - h.get_by_label("Cancel").click(); - h.run_steps(3); - assert!(h.state().ui.dialog.is_none()); - assert_eq!(h.state().ui.brightness, opened, "Cancel goes back to the theme Preferences opened with"); - // OK keeps the theme and the Tools panel's columns. - open(h.state_mut()); - h.run_steps(3); - swatch(&h, "Dark"); - h.run_steps(2); - h.get_by(|n| n.role() == Role::ComboBox && n.value().as_deref() == Some("Single Column")).click(); - h.run_steps(2); - h.get_by_label("Double Column").click(); - h.run_steps(2); - h.get_by_label(" OK ").click(); - h.run_steps(3); - assert!(h.state().ui.dialog.is_none()); - assert_eq!(h.state().ui.brightness, Brightness::Dark); - assert!(h.state().ui.tools_double_column); - // The same command the Window menu toggles, with an explicit state for Preferences. - assert_eq!(h.state_mut().run("window.toolsDoubleColumn", json!({"on": false})).unwrap(), json!(false)); - assert_eq!(h.state_mut().run("window.toolsDoubleColumn", json!({"on": false})).unwrap(), json!(false), "set, not toggled"); - assert_eq!(h.state_mut().run("window.toolsDoubleColumn", json!({})).unwrap(), json!(true), "toggled without `on`"); - // UI Size has a section of its own. - open(h.state_mut()); - h.state_mut().ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!("uiScaling")); - h.run_steps(3); - assert!(h.query_by_label("UI Size:").is_some()); - } - #[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..9c99c92b4 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,70 @@ 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)); } + +/// The course's chapter 26: Preferences › Interface as InDesign's. A Color Theme swatch shows +/// its theme at once and Cancel goes back; OK keeps the theme and the Tools panel's columns. +/// UI Size is in a UI Scaling section of its own. +#[test] +fn preferences_interface_sets_the_color_theme_and_tools_panel() { + use crate::theme::Brightness; + use egui::accesskit::Role; + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let opened = app.ui.brightness; + let open = |app: &mut DesignApp| { + app.run("app.preferences", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!("interface")); + }; + open(&mut app); + let mut h = Harness::builder().with_size(egui::vec2(1100.0, 760.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("prefs_interface_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(3); + let swatch = + |h: &Harness<'_, DesignApp>, name: &str| h.get_by(|n| n.role() == Role::RadioButton && n.label().as_deref() == Some(name)).click(); + for l in ["Appearance", "Color Theme:", "Panels", "Tools Panel:"] { + assert!(h.query_by_label(l).is_some(), "{l}"); + } + // A click shows the theme at once; Cancel goes back. + swatch(&h, "Light"); + h.run_steps(3); + assert_eq!(h.state().ui.brightness, Brightness::Light); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert!(h.state().ui.dialog.is_none()); + assert_eq!(h.state().ui.brightness, opened, "Cancel goes back to the theme Preferences opened with"); + // OK keeps the theme and the Tools panel's columns. + open(h.state_mut()); + h.run_steps(3); + swatch(&h, "Dark"); + h.run_steps(2); + h.get_by(|n| n.role() == Role::ComboBox && n.value().as_deref() == Some("Single Column")).click(); + h.run_steps(2); + h.get_by_label("Double Column").click(); + h.run_steps(2); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().ui.dialog.is_none()); + assert_eq!(h.state().ui.brightness, Brightness::Dark); + assert!(h.state().ui.tools_double_column); + // The same command the Window menu toggles, with an explicit state for Preferences. + assert_eq!(h.state_mut().run("window.toolsDoubleColumn", json!({"on": false})).unwrap(), json!(false)); + assert_eq!(h.state_mut().run("window.toolsDoubleColumn", json!({"on": false})).unwrap(), json!(false), "set, not toggled"); + assert_eq!(h.state_mut().run("window.toolsDoubleColumn", json!({})).unwrap(), json!(true), "toggled without `on`"); + // UI Size has a section of its own. + open(h.state_mut()); + h.state_mut().ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!("uiScaling")); + h.run_steps(3); + assert!(h.query_by_label("UI Size:").is_some()); +} From 408c7e14b58ded1f672abab2cda1ad08054c2296 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:44:18 +0100 Subject: [PATCH 150/207] 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 | 90 ----------------------------- crates/ui-egui/src/dialogs/tests.rs | 90 +++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 90 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 438636154..e0e47e22c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,96 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - /// The course's chapter 22: Find/Change › Change Format. "eggs", case sensitive, nothing in - /// Change to; Change Format Settings › Style Options sets Character Style "eggs", and the box - /// shows it (with Change to's ⓘ). Escape leaves the settings, not Find/Change. Change All - /// gives every lowercase "eggs" the style and keeps the words; the trash clears the format. - #[test] - fn find_change_gives_the_found_text_a_change_format() { - use egui_kittest::{Harness, kittest::Queryable}; - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let text = "Poached Eggs\nBreak eggs; cook until eggs are set."; - let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": text})).unwrap(); - let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); - app.run("style.character.create", json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); - app.run("app.findChange", json!({})).unwrap(); - if let Some(d) = app.ui.dialog.as_mut() { - d.fields.insert("find".into(), json!("eggs")); - d.fields.insert("caseSensitive".into(), json!(true)); - } - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( - |ui, app: &mut DesignApp| { - let key = egui::Id::new("change_format_test_fonts"); - if ui.data(|d| d.get_temp::(key)).is_none() { - crate::theme::install_fonts(ui.ctx(), ""); - ui.data_mut(|d| d.insert_temp(key, true)); - return; - } - show(app, ui.ctx()); - }, - app, - ); - h.run_steps(4); - const TIP: &str = "With a Change Format and nothing in Change to, the found text stays and is only formatted"; - assert!(h.query_by_label("Change Format:").is_some()); - assert!(h.query_by_label(TIP).is_none(), "no ⓘ without a Change Format"); - h.get_by_label("Specify attributes to change").click(); - h.run_steps(3); - // Style Options is the section's name in the list and its heading. - for l in ["Change Format Settings", "Style Options", "Basic Character Formats", "Character Color"] { - assert!(h.query_all_by_label(l).next().is_some(), "{l}"); - } - // Style Options › Character Style: eggs. - h.get_all_by_value("[Any Style]").next().unwrap().click(); - h.run_steps(3); - h.get_by_label("eggs").click(); - h.run_steps(3); - h.get_all_by_label(" OK ").last().unwrap().click(); - h.run_steps(3); - let field = |h: &Harness<'_, DesignApp>, k: &str| h.state().ui.dialog.as_ref().unwrap().fields.get(k).cloned(); - assert_eq!((field(&h, CHANGE_FORMAT), field(&h, FORMAT_EDIT)), (Some(json!({"charStyle": "eggs"})), None)); - assert!(h.query_by_label("Character Style: eggs").is_some(), "the box shows it"); - assert!(h.query_by_label(TIP).is_some(), "Change to's ⓘ"); - // Escape closes Change Format Settings only, and keeps the Change Format. - h.get_by_label("Specify attributes to change").click(); - h.run_steps(3); - h.key_press(egui::Key::Escape); - h.run_steps(3); - assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.id.as_str()), Some("findChange")); - assert_eq!((field(&h, CHANGE_FORMAT), field(&h, FORMAT_EDIT)), (Some(json!({"charStyle": "eggs"})), None)); - // Change All: the words stay, every lowercase "eggs" has the style. - h.get_by_label("Change All").click(); - h.run_steps(3); - assert_eq!(field(&h, "status"), Some(json!("2 replacement(s) made"))); - let story = h.state().session.doc().unwrap().doc.story(sid).cloned().unwrap(); - assert_eq!(story.text, text); - assert_eq!( - [8, 19, 36].map(|at| story.format_after(at).style.clone()), - [designcraft_doc::NO_CHAR_STYLE.to_string(), "eggs".into(), "eggs".into()] - ); - // The trash clears it. - h.get_by_label("Clear specified attributes").click(); - h.run_steps(3); - assert_eq!(field(&h, CHANGE_FORMAT), None); - assert!(h.query_by_label(TIP).is_none()); - } - - /// What Change Format Settings sets goes to find.change: a paragraph style and character - /// attributes as well as a character style; the box sums them up. - #[test] - fn a_change_format_goes_with_change_all() { - let mut d = Dialog::new("findChange", json!({"find": "cook"})); - d.fields.insert(CHANGE_FORMAT.into(), json!({"charStyle": "eggs", "paraStyle": "Method", "chars": {"underline": true}})); - let p = find_change_params(&d); - assert_eq!( - (p["charStyle"].clone(), p["paraStyle"].clone(), p["attrs"].clone()), - (json!("eggs"), json!("Method"), json!({"underline": true})) - ); - assert_eq!(change_format_summary("en", d.fields.get(CHANGE_FORMAT)), "Character Style: eggs + Paragraph Style: Method + Underline: on"); - assert!(find_change_params(&Dialog::new("findChange", json!({"find": "cook"}))).get("attrs").is_none()); - } - #[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..4d9a1ddb6 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,93 @@ 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)); } + +/// The course's chapter 22: Find/Change › Change Format. "eggs", case sensitive, nothing in +/// Change to; Change Format Settings › Style Options sets Character Style "eggs", and the box +/// shows it (with Change to's ⓘ). Escape leaves the settings, not Find/Change. Change All +/// gives every lowercase "eggs" the style and keeps the words; the trash clears the format. +#[test] +fn find_change_gives_the_found_text_a_change_format() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let text = "Poached Eggs\nBreak eggs; cook until eggs are set."; + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": text})).unwrap(); + let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); + app.run("style.character.create", json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); + app.run("app.findChange", json!({})).unwrap(); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("find".into(), json!("eggs")); + d.fields.insert("caseSensitive".into(), json!(true)); + } + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("change_format_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(4); + const TIP: &str = "With a Change Format and nothing in Change to, the found text stays and is only formatted"; + assert!(h.query_by_label("Change Format:").is_some()); + assert!(h.query_by_label(TIP).is_none(), "no ⓘ without a Change Format"); + h.get_by_label("Specify attributes to change").click(); + h.run_steps(3); + // Style Options is the section's name in the list and its heading. + for l in ["Change Format Settings", "Style Options", "Basic Character Formats", "Character Color"] { + assert!(h.query_all_by_label(l).next().is_some(), "{l}"); + } + // Style Options › Character Style: eggs. + h.get_all_by_value("[Any Style]").next().unwrap().click(); + h.run_steps(3); + h.get_by_label("eggs").click(); + h.run_steps(3); + h.get_all_by_label(" OK ").last().unwrap().click(); + h.run_steps(3); + let field = |h: &Harness<'_, DesignApp>, k: &str| h.state().ui.dialog.as_ref().unwrap().fields.get(k).cloned(); + assert_eq!((field(&h, CHANGE_FORMAT), field(&h, FORMAT_EDIT)), (Some(json!({"charStyle": "eggs"})), None)); + assert!(h.query_by_label("Character Style: eggs").is_some(), "the box shows it"); + assert!(h.query_by_label(TIP).is_some(), "Change to's ⓘ"); + // Escape closes Change Format Settings only, and keeps the Change Format. + h.get_by_label("Specify attributes to change").click(); + h.run_steps(3); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.id.as_str()), Some("findChange")); + assert_eq!((field(&h, CHANGE_FORMAT), field(&h, FORMAT_EDIT)), (Some(json!({"charStyle": "eggs"})), None)); + // Change All: the words stay, every lowercase "eggs" has the style. + h.get_by_label("Change All").click(); + h.run_steps(3); + assert_eq!(field(&h, "status"), Some(json!("2 replacement(s) made"))); + let story = h.state().session.doc().unwrap().doc.story(sid).cloned().unwrap(); + assert_eq!(story.text, text); + assert_eq!( + [8, 19, 36].map(|at| story.format_after(at).style.clone()), + [designcraft_doc::NO_CHAR_STYLE.to_string(), "eggs".into(), "eggs".into()] + ); + // The trash clears it. + h.get_by_label("Clear specified attributes").click(); + h.run_steps(3); + assert_eq!(field(&h, CHANGE_FORMAT), None); + assert!(h.query_by_label(TIP).is_none()); +} + +/// What Change Format Settings sets goes to find.change: a paragraph style and character +/// attributes as well as a character style; the box sums them up. +#[test] +fn a_change_format_goes_with_change_all() { + let mut d = Dialog::new("findChange", json!({"find": "cook"})); + d.fields.insert(CHANGE_FORMAT.into(), json!({"charStyle": "eggs", "paraStyle": "Method", "chars": {"underline": true}})); + let p = find_change_params(&d); + assert_eq!( + (p["charStyle"].clone(), p["paraStyle"].clone(), p["attrs"].clone()), + (json!("eggs"), json!("Method"), json!({"underline": true})) + ); + assert_eq!(change_format_summary("en", d.fields.get(CHANGE_FORMAT)), "Character Style: eggs + Paragraph Style: Method + Underline: on"); + assert!(find_change_params(&Dialog::new("findChange", json!({"find": "cook"}))).get("attrs").is_none()); +} From 875c317e184cdad8888aa716f73b35bfa312a5d6 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:44:20 +0100 Subject: [PATCH 151/207] 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 | 89 ----------------------------- crates/ui-egui/src/dialogs/tests.rs | 89 +++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 89 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index abb4892c9..cbf7ea6a6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,95 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - /// Paragraph Rules › Color offers Text Color above the swatches, so a rule set to a swatch can - /// go back to it; Gap Color lists only swatches. - #[test] - fn the_rule_colour_menu_offers_text_color() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let doc = app.session.doc().unwrap().doc.clone(); - let menu = |ctx: &egui::Context, text: Option<&str>, click: Option| { - let mut picked = None; - let mut labels = Vec::new(); - let mut events = Vec::new(); - if let Some(pos) = click { - events.push(egui::Event::PointerMoved(pos)); - events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); - events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); - } - let input = egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 600.0))), - events, - ..Default::default() - }; - let mut out = ctx.run_ui(input, |ui| { - picked = swatch_menu_with(ui, &doc, "m", "[Black]", 130.0, text); - }); - out.textures_delta.clear(); - for s in out.shapes { - if let egui::Shape::Text(t) = s.shape { - labels.push((t.galley.job.text.clone(), t.visual_bounding_rect().center())); - } - } - (picked, labels) - }; - for text in [Some("Text Color"), None] { - let ctx = egui::Context::default(); - let (_, labels) = menu(&ctx, text, None); - let combo = labels.iter().find(|(t, _)| t == "[Black]").unwrap().1; - menu(&ctx, text, Some(combo)); - let (_, labels) = menu(&ctx, text, None); - let item = labels.iter().find(|(t, _)| t == "Text Color").map(|l| l.1); - assert_eq!(item.is_some(), text.is_some(), "{labels:?}"); - if let Some(pos) = item { - let (picked, _) = menu(&ctx, text, Some(pos)); - assert_eq!(picked.as_deref(), Some(designcraft_doc::TEXT_COLOR)); - } - } - } - - /// The course's chapter 20: a paragraph style's rule below becomes wavy, 3 pt, with a gap - /// colour, from Paragraph Rules › Type and Gap Color. - #[test] - fn paragraph_style_rules_take_a_type_and_a_gap_colour() { - let (mut app, _) = ruled_paragraph(); - let ctx = egui::Context::default(); - app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "rules", "rule": "ruleBelow"}))); - draw(&mut app, &ctx); - if let Some(d) = app.ui.dialog.as_mut() { - d.fields.insert("p.ruleBelow.type".into(), json!({"kind": "wavy"})); - d.fields.insert("p.ruleBelow.weight".into(), json!(3.0)); - d.fields.insert("p.ruleBelow.gapColor".into(), json!("[Paper]")); - } - draw(&mut app, &ctx); - confirm(&mut app).unwrap(); - let r = app.session.doc().unwrap().doc.styles.resolve_para_style("Ruled Child").0.rule_below; - assert_eq!((r.kind, r.weight, r.gap_color.as_str()), (designcraft_doc::StrokeType::Wavy, 3.0, "[Paper]")); - } - - /// A rule's Gap Tint shows 100% at its default (the rule's JSON leaves a full gap tint out). - #[test] - fn a_rules_default_gap_tint_shows_100() { - use egui_kittest::kittest::Queryable; - let (mut app, _) = ruled_paragraph(); - app.run( - "style.paragraph.edit", - json!({"name": "Ruled Child", "para": {"ruleBelow": {"type": {"kind": "dashed", "pattern": [4.0, 2.0]}, "gapColor": "[Paper]"}}}), - ) - .unwrap(); - 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": "rules", "rule": "ruleBelow"}))); - h.run_steps(4); - let label = h.get_by_label("Gap Tint:").rect(); - let field = h - .get_all_by(|n| n.role() == egui::accesskit::Role::TextInput) - .filter(|n| n.rect().center().x > label.max.x && (n.rect().center().y - label.center().y).abs() < 8.0) - .min_by(|a, b| a.rect().min.x.total_cmp(&b.rect().min.x)) - .and_then(|n| n.value()); - assert_eq!(field.as_deref(), Some("100%")); - } - #[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..37452dd27 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,92 @@ 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)); } + +/// Paragraph Rules › Color offers Text Color above the swatches, so a rule set to a swatch can +/// go back to it; Gap Color lists only swatches. +#[test] +fn the_rule_colour_menu_offers_text_color() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let doc = app.session.doc().unwrap().doc.clone(); + let menu = |ctx: &egui::Context, text: Option<&str>, click: Option| { + let mut picked = None; + let mut labels = Vec::new(); + let mut events = Vec::new(); + if let Some(pos) = click { + events.push(egui::Event::PointerMoved(pos)); + events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + } + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 600.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + picked = swatch_menu_with(ui, &doc, "m", "[Black]", 130.0, text); + }); + out.textures_delta.clear(); + for s in out.shapes { + if let egui::Shape::Text(t) = s.shape { + labels.push((t.galley.job.text.clone(), t.visual_bounding_rect().center())); + } + } + (picked, labels) + }; + for text in [Some("Text Color"), None] { + let ctx = egui::Context::default(); + let (_, labels) = menu(&ctx, text, None); + let combo = labels.iter().find(|(t, _)| t == "[Black]").unwrap().1; + menu(&ctx, text, Some(combo)); + let (_, labels) = menu(&ctx, text, None); + let item = labels.iter().find(|(t, _)| t == "Text Color").map(|l| l.1); + assert_eq!(item.is_some(), text.is_some(), "{labels:?}"); + if let Some(pos) = item { + let (picked, _) = menu(&ctx, text, Some(pos)); + assert_eq!(picked.as_deref(), Some(designcraft_doc::TEXT_COLOR)); + } + } +} + +/// The course's chapter 20: a paragraph style's rule below becomes wavy, 3 pt, with a gap +/// colour, from Paragraph Rules › Type and Gap Color. +#[test] +fn paragraph_style_rules_take_a_type_and_a_gap_colour() { + let (mut app, _) = ruled_paragraph(); + let ctx = egui::Context::default(); + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "rules", "rule": "ruleBelow"}))); + draw(&mut app, &ctx); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("p.ruleBelow.type".into(), json!({"kind": "wavy"})); + d.fields.insert("p.ruleBelow.weight".into(), json!(3.0)); + d.fields.insert("p.ruleBelow.gapColor".into(), json!("[Paper]")); + } + draw(&mut app, &ctx); + confirm(&mut app).unwrap(); + let r = app.session.doc().unwrap().doc.styles.resolve_para_style("Ruled Child").0.rule_below; + assert_eq!((r.kind, r.weight, r.gap_color.as_str()), (designcraft_doc::StrokeType::Wavy, 3.0, "[Paper]")); +} + +/// A rule's Gap Tint shows 100% at its default (the rule's JSON leaves a full gap tint out). +#[test] +fn a_rules_default_gap_tint_shows_100() { + use egui_kittest::kittest::Queryable; + let (mut app, _) = ruled_paragraph(); + app.run( + "style.paragraph.edit", + json!({"name": "Ruled Child", "para": {"ruleBelow": {"type": {"kind": "dashed", "pattern": [4.0, 2.0]}, "gapColor": "[Paper]"}}}), + ) + .unwrap(); + 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": "rules", "rule": "ruleBelow"}))); + h.run_steps(4); + let label = h.get_by_label("Gap Tint:").rect(); + let field = h + .get_all_by(|n| n.role() == egui::accesskit::Role::TextInput) + .filter(|n| n.rect().center().x > label.max.x && (n.rect().center().y - label.center().y).abs() < 8.0) + .min_by(|a, b| a.rect().min.x.total_cmp(&b.rect().min.x)) + .and_then(|n| n.value()); + assert_eq!(field.as_deref(), Some("100%")); +} From 954e3158f1e4b8d0c2c7d30cb3fc4f0a0aedc615 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:44:29 +0100 Subject: [PATCH 152/207] 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 | 128 ---------------------------- crates/ui-egui/src/dialogs/tests.rs | 128 ++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 128 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 98324fb58..9183444cf 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,134 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - /// The course's chapter 20: the "Recipe Categories" title formatted on the page (size, All - /// Caps, tracking, space after), then New Paragraph Style from it: the dialog starts from the - /// text, and with Apply Style to Selection the paragraph takes the new style. Next Style and - /// Reset To Base are set from General. - #[test] - fn a_new_paragraph_style_starts_from_the_selected_text() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Egg Recipes\rBoil them."})).unwrap(); - app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 11})).unwrap(); - app.run("type.char", json!({"attrs": {"size": 20, "capitalization": "allCaps", "tracking": 50}})).unwrap(); - app.run("type.para", json!({"attrs": {"spaceAfter": 6}})).unwrap(); - open_new_style(&mut app, true); - let d = app.ui.dialog.clone().unwrap(); - assert_eq!( - (d.fields["c.size"].clone(), d.fields["c.capitalization"].clone(), d.fields["p.spaceAfter"].clone()), - (json!(20.0), json!("allCaps"), json!(6.0)) - ); - assert!(d.b("apply") && !d.fields.contains_key("c.fontFamily"), "only what differs from [No Paragraph Style]"); - if let Some(d) = app.ui.dialog.as_mut() { - d.fields.insert("rename".into(), json!("Recipe Categories")); - d.fields.insert("nextStyle".into(), json!(designcraft_doc::BASIC_PARAGRAPH)); - } - confirm(&mut app).unwrap(); - let a = app.run("type.selectionAttrs", json!({})).unwrap(); - assert_eq!(a["paragraphStyle"], "Recipe Categories", "applied to the selection"); - assert_eq!(a["paraOverrides"].as_u64().unwrap_or(0) + a["charOverrides"].as_u64().unwrap_or(0), 0); - let style = |app: &DesignApp| app.session.doc().unwrap().doc.styles.para("Recipe Categories").cloned().unwrap(); - assert_eq!(style(&app).next_style.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); - assert_eq!(style(&app).chars.size, Some(20.0)); - // Reset To Base: the style keeps nothing of its own, apart from the edits made then. - app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Recipe Categories", "basedOn": designcraft_doc::BASIC_PARAGRAPH}))); - if let Some(d) = app.ui.dialog.as_mut() { - d.fields.insert("resetToBase".into(), json!(true)); - d.fields.insert("c.tracking".into(), json!(10.0)); - } - confirm(&mut app).unwrap(); - let s = style(&app); - assert_eq!((s.chars.size, s.chars.tracking, s.para.space_after), (None, Some(10.0), None), "{s:?}"); - assert_eq!(s.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); - } - - /// The course's chapter 22: "eggs" made bold and red on the page, then New Character Style - /// from it. The dialog is titled as InDesign's, starts from what the text has of its own - /// over its paragraph style ("[None] + Bold + color"), and Apply Style to Selection gives the - /// text the style, with no override left. - #[test] - fn a_new_character_style_starts_from_the_selected_text() { - use egui_kittest::{Harness, kittest::Queryable}; - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Drop eggs into boiling water."})).unwrap(); - app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 29})).unwrap(); - app.run("style.paragraph.create", json!({"name": "Recipe", "chars": {"size": 14}})).unwrap(); - app.run("style.paragraph.apply", json!({"name": "Recipe"})).unwrap(); - app.run("text.select", json!({"story": f["story"], "anchor": 5, "focus": 9})).unwrap(); - app.run("type.char", json!({"attrs": {"fontStyle": "Bold", "fill": "C=15 M=100 Y=100 K=0"}})).unwrap(); - open_new_style(&mut app, false); - let d = app.ui.dialog.clone().unwrap(); - assert_eq!((d.fields["c.fontStyle"].clone(), d.fields["c.fill"].clone()), (json!("Bold"), json!("C=15 M=100 Y=100 K=0"))); - assert!(d.b("apply")); - let mut h = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state( - |ui, app: &mut DesignApp| { - let key = egui::Id::new("new_character_style_test_fonts"); - if ui.data(|d| d.get_temp::(key)).is_none() { - crate::theme::install_fonts(ui.ctx(), ""); - ui.data_mut(|d| d.insert_temp(key, true)); - return; - } - show(app, ui.ctx()); - }, - app, - ); - h.run_steps(4); - for l in ["New Character Style", "Apply Style to Selection"] { - assert!(h.query_by_label(l).is_some(), "{l}"); - } - let app = h.state_mut(); - if let Some(d) = app.ui.dialog.as_mut() { - d.fields.insert("rename".into(), json!("eggs")); - } - confirm(app).unwrap(); - let style = app.session.doc().unwrap().doc.styles.char_style("eggs").cloned().unwrap(); - assert_eq!((style.chars.font_style.as_deref(), style.chars.fill.as_deref()), (Some("Bold"), Some("C=15 M=100 Y=100 K=0"))); - assert_eq!(style.chars.size, None, "the size is the paragraph style's, not the text's own"); - let a = app.run("type.selectionAttrs", json!({})).unwrap(); - assert_eq!((a["characterStyle"].clone(), a["charOverrides"].clone()), (json!("eggs"), json!(0)), "applied to the selection"); - } - - /// A Based On changed in the dialog (without Reset To Base) is what the fields are checked - /// against: the style's own settings over the new parent. - #[test] - fn a_changed_based_on_is_what_the_fields_are_checked_against() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - app.run("style.paragraph.create", json!({"name": "Loose", "para": {"wordSpaceMin": 1.2, "wordSpaceDesired": 1.3}})).unwrap(); - app.run("style.paragraph.create", json!({"name": "Child", "basedOn": "Loose"})).unwrap(); - let mut d = Dialog::new("paragraphStyleOptions", json!({"name": "Child"})); - d.fields.insert("p.wordSpaceDesired".into(), json!(1.0)); - assert!(check_paragraph_style_justification(&app, &d).is_err(), "below Loose's minimum"); - d.fields.insert("basedOn".into(), json!(designcraft_doc::BASIC_PARAGRAPH)); - assert_eq!(check_paragraph_style_justification(&app, &d), Ok(()), "[Basic Paragraph]'s minimum is lower"); - } - - /// With Reset To Base, the justification fields are checked against the base style (the - /// style's own minimum no longer applies), and Preview shows the reset on the page. - #[test] - fn reset_to_base_checks_and_previews_against_the_base() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Loose words"})).unwrap(); - app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 5})).unwrap(); - app.run("style.paragraph.create", json!({"name": "Loose", "basedOn": designcraft_doc::BASIC_PARAGRAPH, "para": {"wordSpaceMin": 1.2, "wordSpaceDesired": 1.3}, "chars": {"size": 30}})) - .unwrap(); - app.run("style.paragraph.apply", json!({"name": "Loose"})).unwrap(); - let mut d = Dialog::new("paragraphStyleOptions", json!({"name": "Loose"})); - d.fields.insert("p.wordSpaceDesired".into(), json!(1.1)); - assert!(check_paragraph_style_justification(&app, &d).is_err(), "below the style's own minimum"); - d.fields.insert("resetToBase".into(), json!(true)); - assert_eq!(check_paragraph_style_justification(&app, &d), Ok(()), "the base's minimum is 100%"); - d.fields.insert("preview".into(), json!(true)); - style_options_preview(&mut app, &mut d); - assert_eq!(d.fields[PREVIEWED]["resetToBase"], true); - let size = app.run("type.selectionAttrs", json!({})).unwrap()["chars"]["size"].clone(); - assert_eq!(size, json!(12.0), "the page shows the style reset to its base"); - app.session.cancel_interaction(); - } - #[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 ee94dd4c2..20f6e9aba 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3524,3 +3524,131 @@ 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)); } + +/// The course's chapter 20: the "Recipe Categories" title formatted on the page (size, All +/// Caps, tracking, space after), then New Paragraph Style from it: the dialog starts from the +/// text, and with Apply Style to Selection the paragraph takes the new style. Next Style and +/// Reset To Base are set from General. +#[test] +fn a_new_paragraph_style_starts_from_the_selected_text() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Egg Recipes\rBoil them."})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 11})).unwrap(); + app.run("type.char", json!({"attrs": {"size": 20, "capitalization": "allCaps", "tracking": 50}})).unwrap(); + app.run("type.para", json!({"attrs": {"spaceAfter": 6}})).unwrap(); + open_new_style(&mut app, true); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!( + (d.fields["c.size"].clone(), d.fields["c.capitalization"].clone(), d.fields["p.spaceAfter"].clone()), + (json!(20.0), json!("allCaps"), json!(6.0)) + ); + assert!(d.b("apply") && !d.fields.contains_key("c.fontFamily"), "only what differs from [No Paragraph Style]"); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("rename".into(), json!("Recipe Categories")); + d.fields.insert("nextStyle".into(), json!(designcraft_doc::BASIC_PARAGRAPH)); + } + confirm(&mut app).unwrap(); + let a = app.run("type.selectionAttrs", json!({})).unwrap(); + assert_eq!(a["paragraphStyle"], "Recipe Categories", "applied to the selection"); + assert_eq!(a["paraOverrides"].as_u64().unwrap_or(0) + a["charOverrides"].as_u64().unwrap_or(0), 0); + let style = |app: &DesignApp| app.session.doc().unwrap().doc.styles.para("Recipe Categories").cloned().unwrap(); + assert_eq!(style(&app).next_style.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); + assert_eq!(style(&app).chars.size, Some(20.0)); + // Reset To Base: the style keeps nothing of its own, apart from the edits made then. + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Recipe Categories", "basedOn": designcraft_doc::BASIC_PARAGRAPH}))); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("resetToBase".into(), json!(true)); + d.fields.insert("c.tracking".into(), json!(10.0)); + } + confirm(&mut app).unwrap(); + let s = style(&app); + assert_eq!((s.chars.size, s.chars.tracking, s.para.space_after), (None, Some(10.0), None), "{s:?}"); + assert_eq!(s.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); +} + +/// The course's chapter 22: "eggs" made bold and red on the page, then New Character Style +/// from it. The dialog is titled as InDesign's, starts from what the text has of its own +/// over its paragraph style ("[None] + Bold + color"), and Apply Style to Selection gives the +/// text the style, with no override left. +#[test] +fn a_new_character_style_starts_from_the_selected_text() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Drop eggs into boiling water."})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 29})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Recipe", "chars": {"size": 14}})).unwrap(); + app.run("style.paragraph.apply", json!({"name": "Recipe"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 5, "focus": 9})).unwrap(); + app.run("type.char", json!({"attrs": {"fontStyle": "Bold", "fill": "C=15 M=100 Y=100 K=0"}})).unwrap(); + open_new_style(&mut app, false); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!((d.fields["c.fontStyle"].clone(), d.fields["c.fill"].clone()), (json!("Bold"), json!("C=15 M=100 Y=100 K=0"))); + assert!(d.b("apply")); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("new_character_style_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(4); + for l in ["New Character Style", "Apply Style to Selection"] { + assert!(h.query_by_label(l).is_some(), "{l}"); + } + let app = h.state_mut(); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("rename".into(), json!("eggs")); + } + confirm(app).unwrap(); + let style = app.session.doc().unwrap().doc.styles.char_style("eggs").cloned().unwrap(); + assert_eq!((style.chars.font_style.as_deref(), style.chars.fill.as_deref()), (Some("Bold"), Some("C=15 M=100 Y=100 K=0"))); + assert_eq!(style.chars.size, None, "the size is the paragraph style's, not the text's own"); + let a = app.run("type.selectionAttrs", json!({})).unwrap(); + assert_eq!((a["characterStyle"].clone(), a["charOverrides"].clone()), (json!("eggs"), json!(0)), "applied to the selection"); +} + +/// A Based On changed in the dialog (without Reset To Base) is what the fields are checked +/// against: the style's own settings over the new parent. +#[test] +fn a_changed_based_on_is_what_the_fields_are_checked_against() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Loose", "para": {"wordSpaceMin": 1.2, "wordSpaceDesired": 1.3}})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Child", "basedOn": "Loose"})).unwrap(); + let mut d = Dialog::new("paragraphStyleOptions", json!({"name": "Child"})); + d.fields.insert("p.wordSpaceDesired".into(), json!(1.0)); + assert!(check_paragraph_style_justification(&app, &d).is_err(), "below Loose's minimum"); + d.fields.insert("basedOn".into(), json!(designcraft_doc::BASIC_PARAGRAPH)); + assert_eq!(check_paragraph_style_justification(&app, &d), Ok(()), "[Basic Paragraph]'s minimum is lower"); +} + +/// With Reset To Base, the justification fields are checked against the base style (the +/// style's own minimum no longer applies), and Preview shows the reset on the page. +#[test] +fn reset_to_base_checks_and_previews_against_the_base() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Loose words"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 5})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Loose", "basedOn": designcraft_doc::BASIC_PARAGRAPH, "para": {"wordSpaceMin": 1.2, "wordSpaceDesired": 1.3}, "chars": {"size": 30}})) + .unwrap(); + app.run("style.paragraph.apply", json!({"name": "Loose"})).unwrap(); + let mut d = Dialog::new("paragraphStyleOptions", json!({"name": "Loose"})); + d.fields.insert("p.wordSpaceDesired".into(), json!(1.1)); + assert!(check_paragraph_style_justification(&app, &d).is_err(), "below the style's own minimum"); + d.fields.insert("resetToBase".into(), json!(true)); + assert_eq!(check_paragraph_style_justification(&app, &d), Ok(()), "the base's minimum is 100%"); + d.fields.insert("preview".into(), json!(true)); + style_options_preview(&mut app, &mut d); + assert_eq!(d.fields[PREVIEWED]["resetToBase"], true); + let size = app.run("type.selectionAttrs", json!({})).unwrap()["chars"]["size"].clone(); + assert_eq!(size, json!(12.0), "the page shows the style reset to its base"); + app.session.cancel_interaction(); +} From d4cebe862e30fc0ceb1147763b62d0665bb037a8 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:44:30 +0100 Subject: [PATCH 153/207] 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 | 81 ----------------------------- crates/ui-egui/src/dialogs/tests.rs | 81 +++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 81 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0cf58d61d..e286dc94f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,87 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - /// The course's chapter 20: the "Recipe Categories" title formatted on the page (size, All - /// Caps, tracking, space after), then New Paragraph Style from it: the dialog starts from the - /// text, and with Apply Style to Selection the paragraph takes the new style. Next Style and - /// Reset To Base are set from General. - #[test] - fn a_new_paragraph_style_starts_from_the_selected_text() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Egg Recipes\rBoil them."})).unwrap(); - app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 11})).unwrap(); - app.run("type.char", json!({"attrs": {"size": 20, "capitalization": "allCaps", "tracking": 50}})).unwrap(); - app.run("type.para", json!({"attrs": {"spaceAfter": 6}})).unwrap(); - open_new_style(&mut app, true); - let d = app.ui.dialog.clone().unwrap(); - assert_eq!( - (d.fields["c.size"].clone(), d.fields["c.capitalization"].clone(), d.fields["p.spaceAfter"].clone()), - (json!(20.0), json!("allCaps"), json!(6.0)) - ); - assert!(d.b("apply") && !d.fields.contains_key("c.fontFamily"), "only what differs from [No Paragraph Style]"); - if let Some(d) = app.ui.dialog.as_mut() { - d.fields.insert("rename".into(), json!("Recipe Categories")); - d.fields.insert("nextStyle".into(), json!(designcraft_doc::BASIC_PARAGRAPH)); - } - confirm(&mut app).unwrap(); - let a = app.run("type.selectionAttrs", json!({})).unwrap(); - assert_eq!(a["paragraphStyle"], "Recipe Categories", "applied to the selection"); - assert_eq!(a["paraOverrides"].as_u64().unwrap_or(0) + a["charOverrides"].as_u64().unwrap_or(0), 0); - let style = |app: &DesignApp| app.session.doc().unwrap().doc.styles.para("Recipe Categories").cloned().unwrap(); - assert_eq!(style(&app).next_style.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); - assert_eq!(style(&app).chars.size, Some(20.0)); - // Reset To Base: the style keeps nothing of its own, apart from the edits made then. - app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Recipe Categories", "basedOn": designcraft_doc::BASIC_PARAGRAPH}))); - if let Some(d) = app.ui.dialog.as_mut() { - d.fields.insert("resetToBase".into(), json!(true)); - d.fields.insert("c.tracking".into(), json!(10.0)); - } - confirm(&mut app).unwrap(); - let s = style(&app); - assert_eq!((s.chars.size, s.chars.tracking, s.para.space_after), (None, Some(10.0), None), "{s:?}"); - assert_eq!(s.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); - } - - /// A Based On changed in the dialog (without Reset To Base) is what the fields are checked - /// against: the style's own settings over the new parent. - #[test] - fn a_changed_based_on_is_what_the_fields_are_checked_against() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - app.run("style.paragraph.create", json!({"name": "Loose", "para": {"wordSpaceMin": 1.2, "wordSpaceDesired": 1.3}})).unwrap(); - app.run("style.paragraph.create", json!({"name": "Child", "basedOn": "Loose"})).unwrap(); - let mut d = Dialog::new("paragraphStyleOptions", json!({"name": "Child"})); - d.fields.insert("p.wordSpaceDesired".into(), json!(1.0)); - assert!(check_paragraph_style_justification(&app, &d).is_err(), "below Loose's minimum"); - d.fields.insert("basedOn".into(), json!(designcraft_doc::BASIC_PARAGRAPH)); - assert_eq!(check_paragraph_style_justification(&app, &d), Ok(()), "[Basic Paragraph]'s minimum is lower"); - } - - /// With Reset To Base, the justification fields are checked against the base style (the - /// style's own minimum no longer applies), and Preview shows the reset on the page. - #[test] - fn reset_to_base_checks_and_previews_against_the_base() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Loose words"})).unwrap(); - app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 5})).unwrap(); - app.run("style.paragraph.create", json!({"name": "Loose", "basedOn": designcraft_doc::BASIC_PARAGRAPH, "para": {"wordSpaceMin": 1.2, "wordSpaceDesired": 1.3}, "chars": {"size": 30}})) - .unwrap(); - app.run("style.paragraph.apply", json!({"name": "Loose"})).unwrap(); - let mut d = Dialog::new("paragraphStyleOptions", json!({"name": "Loose"})); - d.fields.insert("p.wordSpaceDesired".into(), json!(1.1)); - assert!(check_paragraph_style_justification(&app, &d).is_err(), "below the style's own minimum"); - d.fields.insert("resetToBase".into(), json!(true)); - assert_eq!(check_paragraph_style_justification(&app, &d), Ok(()), "the base's minimum is 100%"); - d.fields.insert("preview".into(), json!(true)); - style_options_preview(&mut app, &mut d); - assert_eq!(d.fields[PREVIEWED]["resetToBase"], true); - let size = app.run("type.selectionAttrs", json!({})).unwrap()["chars"]["size"].clone(); - assert_eq!(size, json!(12.0), "the page shows the style reset to its base"); - app.session.cancel_interaction(); - } - #[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 0d1430061..d221100cd 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3522,3 +3522,84 @@ 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)); } + +/// The course's chapter 20: the "Recipe Categories" title formatted on the page (size, All +/// Caps, tracking, space after), then New Paragraph Style from it: the dialog starts from the +/// text, and with Apply Style to Selection the paragraph takes the new style. Next Style and +/// Reset To Base are set from General. +#[test] +fn a_new_paragraph_style_starts_from_the_selected_text() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Egg Recipes\rBoil them."})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 11})).unwrap(); + app.run("type.char", json!({"attrs": {"size": 20, "capitalization": "allCaps", "tracking": 50}})).unwrap(); + app.run("type.para", json!({"attrs": {"spaceAfter": 6}})).unwrap(); + open_new_style(&mut app, true); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!( + (d.fields["c.size"].clone(), d.fields["c.capitalization"].clone(), d.fields["p.spaceAfter"].clone()), + (json!(20.0), json!("allCaps"), json!(6.0)) + ); + assert!(d.b("apply") && !d.fields.contains_key("c.fontFamily"), "only what differs from [No Paragraph Style]"); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("rename".into(), json!("Recipe Categories")); + d.fields.insert("nextStyle".into(), json!(designcraft_doc::BASIC_PARAGRAPH)); + } + confirm(&mut app).unwrap(); + let a = app.run("type.selectionAttrs", json!({})).unwrap(); + assert_eq!(a["paragraphStyle"], "Recipe Categories", "applied to the selection"); + assert_eq!(a["paraOverrides"].as_u64().unwrap_or(0) + a["charOverrides"].as_u64().unwrap_or(0), 0); + let style = |app: &DesignApp| app.session.doc().unwrap().doc.styles.para("Recipe Categories").cloned().unwrap(); + assert_eq!(style(&app).next_style.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); + assert_eq!(style(&app).chars.size, Some(20.0)); + // Reset To Base: the style keeps nothing of its own, apart from the edits made then. + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Recipe Categories", "basedOn": designcraft_doc::BASIC_PARAGRAPH}))); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("resetToBase".into(), json!(true)); + d.fields.insert("c.tracking".into(), json!(10.0)); + } + confirm(&mut app).unwrap(); + let s = style(&app); + assert_eq!((s.chars.size, s.chars.tracking, s.para.space_after), (None, Some(10.0), None), "{s:?}"); + assert_eq!(s.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); +} + +/// A Based On changed in the dialog (without Reset To Base) is what the fields are checked +/// against: the style's own settings over the new parent. +#[test] +fn a_changed_based_on_is_what_the_fields_are_checked_against() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Loose", "para": {"wordSpaceMin": 1.2, "wordSpaceDesired": 1.3}})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Child", "basedOn": "Loose"})).unwrap(); + let mut d = Dialog::new("paragraphStyleOptions", json!({"name": "Child"})); + d.fields.insert("p.wordSpaceDesired".into(), json!(1.0)); + assert!(check_paragraph_style_justification(&app, &d).is_err(), "below Loose's minimum"); + d.fields.insert("basedOn".into(), json!(designcraft_doc::BASIC_PARAGRAPH)); + assert_eq!(check_paragraph_style_justification(&app, &d), Ok(()), "[Basic Paragraph]'s minimum is lower"); +} + +/// With Reset To Base, the justification fields are checked against the base style (the +/// style's own minimum no longer applies), and Preview shows the reset on the page. +#[test] +fn reset_to_base_checks_and_previews_against_the_base() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Loose words"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 5})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Loose", "basedOn": designcraft_doc::BASIC_PARAGRAPH, "para": {"wordSpaceMin": 1.2, "wordSpaceDesired": 1.3}, "chars": {"size": 30}})) + .unwrap(); + app.run("style.paragraph.apply", json!({"name": "Loose"})).unwrap(); + let mut d = Dialog::new("paragraphStyleOptions", json!({"name": "Loose"})); + d.fields.insert("p.wordSpaceDesired".into(), json!(1.1)); + assert!(check_paragraph_style_justification(&app, &d).is_err(), "below the style's own minimum"); + d.fields.insert("resetToBase".into(), json!(true)); + assert_eq!(check_paragraph_style_justification(&app, &d), Ok(()), "the base's minimum is 100%"); + d.fields.insert("preview".into(), json!(true)); + style_options_preview(&mut app, &mut d); + assert_eq!(d.fields[PREVIEWED]["resetToBase"], true); + let size = app.run("type.selectionAttrs", json!({})).unwrap()["chars"]["size"].clone(); + assert_eq!(size, json!(12.0), "the page shows the style reset to its base"); + app.session.cancel_interaction(); +} From 42183766680f7716bdc73d0a59f0f7f25a5f821a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:57:22 +0100 Subject: [PATCH 154/207] 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 9c99c92b4..339354ac4 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3551,8 +3551,7 @@ fn preferences_interface_sets_the_color_theme_and_tools_panel() { app, ); h.run_steps(3); - let swatch = - |h: &Harness<'_, DesignApp>, name: &str| h.get_by(|n| n.role() == Role::RadioButton && n.label().as_deref() == Some(name)).click(); + let swatch = |h: &Harness<'_, DesignApp>, name: &str| h.get_by(|n| n.role() == Role::RadioButton && n.label().as_deref() == Some(name)).click(); for l in ["Appearance", "Color Theme:", "Panels", "Tools Panel:"] { assert!(h.query_by_label(l).is_some(), "{l}"); } From c20d4864e32e167d619a76269d72907611be669b Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 20:01:16 +0100 Subject: [PATCH 155/207] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/dialogs/tests.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d9d48e416..7fb6037dc 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1988,7 +1988,7 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he ui.painter().rect_stroke(r.shrink(1.0), 2.0, egui::Stroke::new(2.0, ring), egui::StrokeKind::Inside); } let name = crate::i18n::tr(&lang, b.label()).to_string(); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, true, on, &name)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, true, on, &name)); if resp.on_hover_text(&name).clicked() && !on { d.fields.insert("brightness".into(), json!(b.id())); d.fields.insert("applyBrightness".into(), json!(b.id())); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 339354ac4..bdcac47a9 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3540,7 +3540,7 @@ fn preferences_interface_sets_the_color_theme_and_tools_panel() { open(&mut app); let mut h = Harness::builder().with_size(egui::vec2(1100.0, 760.0)).build_ui_state( |ui, app: &mut DesignApp| { - let key = egui::Id::new("prefs_interface_test_fonts"); + let key = egui::Id::unique("prefs_interface_test_fonts"); if ui.data(|d| d.get_temp::(key)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(key, true)); From fcb4b5c544893df314bf784b79d97b80bdf05af9 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 20:03:23 +0100 Subject: [PATCH 156/207] Georgian: main's row for Single Column, once Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ka.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index b5779b321..e328562c2 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -33,7 +33,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Copy", "კოპირება"), ("Paste", "ჩასმა"), ("Double Column", "ორმაგი სვეტი"), - ("Single Column", "ერთმაგი სვეტი"), ("Tools Panel:", "ხელსაწყოების პანელი:"), ("Color Theme:", "ფერთა თემა:"), ("Restart Numbering", "ნუმერაციის თავიდან დაწყება"), From e72193c210c41554ac09def7beee1ef27fbad0b2 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 20:31:27 +0100 Subject: [PATCH 157/207] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/tests.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 73c600a55..59694954e 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -786,15 +786,15 @@ fn apply_to_text_colours_a_text_frames_text_not_the_frame() { assert_eq!(doc.item(designcraft_doc::ids::ItemId(id)).unwrap().fill.swatch, "[None]", "the frame isn't"); } - /// The Align To button shows the target picked in its menu. - #[test] - fn the_align_to_button_shows_its_target() { - assert_eq!(align_to_icon("selection"), "align-to"); - assert_eq!(align_to_icon("keyObject"), "align-to-key"); - assert_eq!(align_to_icon("margins"), "align-to-margins"); - assert_eq!(align_to_icon("page"), "align-to-page"); - assert_eq!(align_to_icon("spread"), "align-to-spread"); - } +/// The Align To button shows the target picked in its menu. +#[test] +fn the_align_to_button_shows_its_target() { + assert_eq!(align_to_icon("selection"), "align-to"); + assert_eq!(align_to_icon("keyObject"), "align-to-key"); + assert_eq!(align_to_icon("margins"), "align-to-margins"); + assert_eq!(align_to_icon("page"), "align-to-page"); + assert_eq!(align_to_icon("spread"), "align-to-spread"); +} #[test] fn transform_flips_and_rotates_from_properties() { From e3fd046ecc23595f62031319e28fbbd823177a9f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 20:50:36 +0100 Subject: [PATCH 158/207] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/tests.rs | 92 +++++++++---------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 7bb88cc2c..5c9feea47 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -710,55 +710,55 @@ fn several_shapes_show_pathfinder_and_intersect_combines_them() { assert!((r.x0 - 200.0).abs() < 0.5 && (r.y1 - 200.0).abs() < 0.5 && (r.x1 - 300.0).abs() < 0.5, "{r:?}"); } - /// A row is as wide as its translated name needs: a long one isn't cut at a fixed width. - #[test] - fn preview_rows_fit_long_translated_names() { - let ctx = egui::Context::default(); - let long = "Скругленный прямоугольник с обратным углом"; - let mut widths = (0.0, 0.0); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - egui::Area::new(egui::Id::new("rows")).show(ui.ctx(), |ui| { - ui.set_max_width(10.0); - let r = preview_row(ui, false, long, 40.0, |_, _, _| {}); - let text = ui.fonts_mut(|f| f.layout_no_wrap(long.to_string(), egui::FontId::proportional(12.0), egui::Color32::WHITE).size().x); - widths = (r.rect.width(), 40.0 + 12.0 + text); - }); +/// A row is as wide as its translated name needs: a long one isn't cut at a fixed width. +#[test] +fn preview_rows_fit_long_translated_names() { + let ctx = egui::Context::default(); + let long = "Скругленный прямоугольник с обратным углом"; + let mut widths = (0.0, 0.0); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + egui::Area::new(egui::Id::new("rows")).show(ui.ctx(), |ui| { + ui.set_max_width(10.0); + let r = preview_row(ui, false, long, 40.0, |_, _, _| {}); + let text = ui.fonts_mut(|f| f.layout_no_wrap(long.to_string(), egui::FontId::proportional(12.0), egui::Color32::WHITE).size().x); + widths = (r.rect.width(), 40.0 + 12.0 + text); }); - out.textures_delta.clear(); - assert!(widths.0 >= widths.1, "{widths:?}"); - } + }); + out.textures_delta.clear(); + assert!(widths.0 >= widths.1, "{widths:?}"); +} - /// The corner shape list shows each shape's glyph beside its name, as InDesign's; a row picks - /// the shape and the current one is marked. - #[test] - fn the_corner_shape_list_has_glyph_rows_and_picks_a_shape() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); - app.run("selection.set", json!({"ids": [id]})).unwrap(); - let mut h = panel(app); - h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Corner Shape").click(); - h.run_steps(3); - let rows = ["None", "Fancy", "Bevel", "Inset", "Inverse Rounded", "Rounded"]; - for r in rows { - let _ = h.get_by_role_and_label(egui::accesskit::Role::Button, r); - } - h.get_by_role_and_label(egui::accesskit::Role::Button, "Bevel").click(); - h.run_steps(3); - let it = h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().clone(); - assert!(it.corners.corners.iter().all(|c| c.shape == designcraft_geom::corners::CornerShape::Bevel), "{:?}", it.corners); - h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Corner Shape").click(); - h.run_steps(3); - use egui_kittest::kittest::NodeT; - let n = h.get_by_role_and_label(egui::accesskit::Role::Button, "Bevel"); - assert_eq!(n.accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "the current shape is marked"); - // The stroke type list has the same rows, by name. - h.key_press(egui::Key::Escape); - h.run_steps(2); - h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Stroke Type").click(); - h.run_steps(3); - let _ = h.get_by_role_and_label(egui::accesskit::Role::Button, "Thick - Thin"); +/// The corner shape list shows each shape's glyph beside its name, as InDesign's; a row picks +/// the shape and the current one is marked. +#[test] +fn the_corner_shape_list_has_glyph_rows_and_picks_a_shape() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let mut h = panel(app); + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Corner Shape").click(); + h.run_steps(3); + let rows = ["None", "Fancy", "Bevel", "Inset", "Inverse Rounded", "Rounded"]; + for r in rows { + let _ = h.get_by_role_and_label(egui::accesskit::Role::Button, r); } + h.get_by_role_and_label(egui::accesskit::Role::Button, "Bevel").click(); + h.run_steps(3); + let it = h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().clone(); + assert!(it.corners.corners.iter().all(|c| c.shape == designcraft_geom::corners::CornerShape::Bevel), "{:?}", it.corners); + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Corner Shape").click(); + h.run_steps(3); + use egui_kittest::kittest::NodeT; + let n = h.get_by_role_and_label(egui::accesskit::Role::Button, "Bevel"); + assert_eq!(n.accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "the current shape is marked"); + // The stroke type list has the same rows, by name. + h.key_press(egui::Key::Escape); + h.run_steps(2); + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Stroke Type").click(); + h.run_steps(3); + let _ = h.get_by_role_and_label(egui::accesskit::Role::Button, "Thick - Thin"); +} const CYAN: &str = "C=100 M=0 Y=0 K=0"; From e5b17c21845f3a224e8b52da7f6f856aadc60322 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 20:56:41 +0100 Subject: [PATCH 159/207] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 5c9feea47..4458dae4f 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -717,7 +717,7 @@ fn preview_rows_fit_long_translated_names() { let long = "Скругленный прямоугольник с обратным углом"; let mut widths = (0.0, 0.0); let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - egui::Area::new(egui::Id::new("rows")).show(ui.ctx(), |ui| { + egui::Area::new(egui::Id::unique("rows")).show(ui.ctx(), |ui| { ui.set_max_width(10.0); let r = preview_row(ui, false, long, 40.0, |_, _, _| {}); let text = ui.fonts_mut(|f| f.layout_no_wrap(long.to_string(), egui::FontId::proportional(12.0), egui::Color32::WHITE).size().x); From 5677b4021cbd23c3121ecd0a3c619c7bfae93824 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 21:00:41 +0100 Subject: [PATCH 160/207] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/tests.rs | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index e2ebf6feb..ea3c385bc 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -786,33 +786,33 @@ fn apply_to_text_colours_a_text_frames_text_not_the_frame() { assert_eq!(doc.item(designcraft_doc::ids::ItemId(id)).unwrap().fill.swatch, "[None]", "the frame isn't"); } - #[test] - fn apply_to_text_colours_the_text_of_several_text_frames() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let mut ids = vec![]; - let mut stories = vec![]; - for x in [72, 250, 430] { - let r = app.run("frame.create", json!({"rect": [x, 72, x + 150, 140], "content": "text", "text": "75%"})).unwrap(); - ids.push(r["id"].as_u64().unwrap()); - stories.push(designcraft_doc::StoryId(r["story"].as_u64().unwrap())); - } - app.run("selection.set", json!({"ids": ids})).unwrap(); - let mut h = panel(app); - h.get_by_label("Fill swatch").click(); - h.run_steps(3); - // Three text frames, as one: Apply To is offered. - assert!(h.query_by_label("Apply To:").is_some()); - h.get_by_label("Text").click(); - h.run_steps(3); - h.get_by_label(CYAN).click(); - h.run_steps(3); - let doc = &h.state().session.active().unwrap().doc; - for (id, story) in ids.iter().zip(&stories) { - assert_eq!(doc.story(*story).unwrap().char_format_at(0).over.fill.as_deref(), Some(CYAN), "every frame's text is cyan"); - assert_eq!(doc.item(designcraft_doc::ids::ItemId(*id)).unwrap().fill.swatch, "[None]", "the frames aren't"); - } +#[test] +fn apply_to_text_colours_the_text_of_several_text_frames() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let mut ids = vec![]; + let mut stories = vec![]; + for x in [72, 250, 430] { + let r = app.run("frame.create", json!({"rect": [x, 72, x + 150, 140], "content": "text", "text": "75%"})).unwrap(); + ids.push(r["id"].as_u64().unwrap()); + stories.push(designcraft_doc::StoryId(r["story"].as_u64().unwrap())); } + app.run("selection.set", json!({"ids": ids})).unwrap(); + let mut h = panel(app); + h.get_by_label("Fill swatch").click(); + h.run_steps(3); + // Three text frames, as one: Apply To is offered. + assert!(h.query_by_label("Apply To:").is_some()); + h.get_by_label("Text").click(); + h.run_steps(3); + h.get_by_label(CYAN).click(); + h.run_steps(3); + let doc = &h.state().session.active().unwrap().doc; + for (id, story) in ids.iter().zip(&stories) { + assert_eq!(doc.story(*story).unwrap().char_format_at(0).over.fill.as_deref(), Some(CYAN), "every frame's text is cyan"); + assert_eq!(doc.item(designcraft_doc::ids::ItemId(*id)).unwrap().fill.swatch, "[None]", "the frames aren't"); + } +} #[test] fn transform_flips_and_rotates_from_properties() { From 10e9970cfedaaecfe0fa9e76e2e8c80f92065d89 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 21:44:05 +0100 Subject: [PATCH 161/207] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/tests.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 4d9a1ddb6..ba5531870 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3604,10 +3604,7 @@ fn a_change_format_goes_with_change_all() { let mut d = Dialog::new("findChange", json!({"find": "cook"})); d.fields.insert(CHANGE_FORMAT.into(), json!({"charStyle": "eggs", "paraStyle": "Method", "chars": {"underline": true}})); let p = find_change_params(&d); - assert_eq!( - (p["charStyle"].clone(), p["paraStyle"].clone(), p["attrs"].clone()), - (json!("eggs"), json!("Method"), json!({"underline": true})) - ); + assert_eq!((p["charStyle"].clone(), p["paraStyle"].clone(), p["attrs"].clone()), (json!("eggs"), json!("Method"), json!({"underline": true}))); assert_eq!(change_format_summary("en", d.fields.get(CHANGE_FORMAT)), "Character Style: eggs + Paragraph Style: Method + Underline: on"); assert!(find_change_params(&Dialog::new("findChange", json!({"find": "cook"}))).get("attrs").is_none()); } From 27684daabefec09489f51f323bd709f39727e8c8 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:02:59 +0100 Subject: [PATCH 162/207] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 4 ++-- crates/ui-egui/src/dialogs/tests.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e0e47e22c..302a1b069 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3279,7 +3279,7 @@ const FORMAT_SECTIONS: &[(&str, &str)] = &[ fn info_icon(ui: &mut egui::Ui, tip: &str) { let (r, resp) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); crate::icons::paint(ui.painter(), r, "info", crate::theme::Tokens::get(ui.ctx()).icon); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, tip)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, tip)); resp.on_hover_text(tip); } @@ -3348,7 +3348,7 @@ fn change_format_settings(app: &mut DesignApp, ctx: &egui::Context, d: &mut Dial let cf = CharFields { base: &base, sparse: true }; let popup_open = egui::Popup::is_any_open(ctx); let mut result = None; - let host = egui::Id::new("change_format_settings"); + let host = egui::Id::unique("change_format_settings"); crate::modal_drag::modal(ctx, host, host).backdrop_color(egui::Color32::TRANSPARENT).show(ctx, |ui| { let title_top = ui.cursor().top(); crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Change Format Settings")).font(semibold(16.0))); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index ba5531870..f6f6f0e77 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3542,7 +3542,7 @@ fn find_change_gives_the_found_text_a_change_format() { } let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( |ui, app: &mut DesignApp| { - let key = egui::Id::new("change_format_test_fonts"); + let key = egui::Id::unique("change_format_test_fonts"); if ui.data(|d| d.get_temp::(key)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(key, true)); From d42333fb9d82c3757526cde9e3204484a1c0e7bd Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:10:58 +0100 Subject: [PATCH 163/207] Story: para_at's doc comment back on para_at Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/story.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index de753891c..fb04f9c9f 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -286,7 +286,6 @@ impl Story { v } - /// Index of the paragraph containing byte `pos`. /// The paragraphs `r` touches (at least the one at `r.start`), as (index, byte range without /// its return), found from `r` itself rather than by listing every paragraph of the story. pub fn paras_touching(&self, r: std::ops::Range) -> Vec<(usize, std::ops::Range)> { @@ -305,6 +304,7 @@ impl Story { } } + /// Index of the paragraph containing byte `pos`. pub fn para_at(&self, pos: usize) -> usize { self.text[..floor_char_boundary(&self.text, pos)].bytes().filter(|b| *b == b'\n').count() } From 573566fe658617a8e7bab0f45760272e49880ec3 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:12:48 +0100 Subject: [PATCH 164/207] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/tests.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 37452dd27..3f9bbfc10 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3538,11 +3538,8 @@ fn the_rule_colour_menu_offers_text_color() { events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); } - let input = egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 600.0))), - events, - ..Default::default() - }; + let input = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 600.0))), events, ..Default::default() }; let mut out = ctx.run_ui(input, |ui| { picked = swatch_menu_with(ui, &doc, "m", "[Black]", 130.0, text); }); @@ -3599,8 +3596,7 @@ fn a_rules_default_gap_tint_shows_100() { ) .unwrap(); 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": "rules", "rule": "ruleBelow"}))); + h.state_mut().app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "rules", "rule": "ruleBelow"}))); h.run_steps(4); let label = h.get_by_label("Gap Tint:").rect(); let field = h From cb29eddb363741ab6209647f0bc245b771266981 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:24:22 +0100 Subject: [PATCH 165/207] Georgian: main's row for Gap Tint:, once Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ka.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 2304eb940..92f4843c2 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -33,7 +33,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Copy", "კოპირება"), ("Paste", "ჩასმა"), ("Keep In Frame", "ჩარჩოში შენარჩუნება"), - ("Gap Tint:", "ღრეჩოს ტონი:"), ("Restart Numbering", "ნუმერაციის თავიდან დაწყება"), ("Continue Numbering", "ნუმერაციის გაგრძელება"), ("Keep Options…", "შენარჩუნების პარამეტრები…"), From 05a2fb4d3697ecc436129d180de49c4988e42f1d Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 23:05:27 +0100 Subject: [PATCH 166/207] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/tests.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 20f6e9aba..b0c46f5ad 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3253,9 +3253,9 @@ fn new_character_style_opens_its_options_and_creates_the_style() { ctl(&mut app, "ui.dialog.set", json!({"field": "c.size", "value": 9})); assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); let st = char_style(&app, "Character Style 1").unwrap(); - // The selected text's own Italic (its Emphasis style) comes along, as InDesign's New - // Character Style takes it. - assert_eq!((st.based_on, st.chars), (None, CharAttrs { size: Some(9.0), font_style: Some("Italic".into()), ..Default::default() })); + // The selected text's own Italic (its Emphasis style) comes along, as InDesign's New + // Character Style takes it. + assert_eq!((st.based_on, st.chars), (None, CharAttrs { size: Some(9.0), font_style: Some("Italic".into()), ..Default::default() })); crate::menus::activate(&mut app, "style.character.create", &Value::Null); assert_eq!(app.ui.dialog.as_ref().unwrap().s("rename"), "Character Style 2"); } @@ -3271,8 +3271,8 @@ fn new_paragraph_style_opens_its_options_and_creates_the_style() { assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); let st = app.session.active().unwrap().doc.styles.para("Paragraph Style 1").cloned().unwrap(); assert_eq!(st.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); - // The selected text's own Italic comes along, as InDesign's New Paragraph Style takes it. - assert_eq!(st.chars, CharAttrs { size: Some(20.0), font_style: Some("Italic".into()), ..Default::default() }); + // The selected text's own Italic comes along, as InDesign's New Paragraph Style takes it. + assert_eq!(st.chars, CharAttrs { size: Some(20.0), font_style: Some("Italic".into()), ..Default::default() }); } #[test] From a05bf962e8d2ce0ec249127c12c57840f0c2264d Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 23:05:46 +0100 Subject: [PATCH 167/207] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index d221100cd..e2877fb41 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3269,8 +3269,8 @@ fn new_paragraph_style_opens_its_options_and_creates_the_style() { assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); let st = app.session.active().unwrap().doc.styles.para("Paragraph Style 1").cloned().unwrap(); assert_eq!(st.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); - // The selected text's own Italic comes along, as InDesign's New Paragraph Style takes it. - assert_eq!(st.chars, CharAttrs { size: Some(20.0), font_style: Some("Italic".into()), ..Default::default() }); + // The selected text's own Italic comes along, as InDesign's New Paragraph Style takes it. + assert_eq!(st.chars, CharAttrs { size: Some(20.0), font_style: Some("Italic".into()), ..Default::default() }); } #[test] From 741fbe9fac2f4689e60b611015ab721437a36ada Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 23:06:21 +0100 Subject: [PATCH 168/207] Paragraph styles: create_para whole again, with the style name parameter helper before it Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/style/paragraph.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/style/paragraph.rs b/crates/engine/src/cmd/style/paragraph.rs index b7842daef..1f501cf7a 100644 --- a/crates/engine/src/cmd/style/paragraph.rs +++ b/crates/engine/src/cmd/style/paragraph.rs @@ -403,8 +403,6 @@ fn continue_numbering(p: Option<&Value>, para: &mut ParaAttrs) { } } -pub(super) fn create_para(s: &mut Session, p: &Value) -> Result { - let base = str_param(p, "name").unwrap_or("Paragraph Style 1").to_string(); /// The style `p[key]` names: none when absent or null; anything but a name is refused. fn style_name_param(cmd: &str, p: &Value, key: &str) -> Result> { match p.get(key) { @@ -414,7 +412,7 @@ fn style_name_param(cmd: &str, p: &Value, key: &str) -> Result> { } } -fn create_para(s: &mut Session, p: &Value) -> Result { +pub(super) fn create_para(s: &mut Session, p: &Value) -> Result { let base = str_param(p, "name").unwrap_or("Paragraph Style 1").to_string(); let based_on = style_name_param("style.paragraph.create", p, "basedOn")?; let next = style_name_param("style.paragraph.create", p, "nextStyle")?; From 991f51479fd008640d37551cb99cdebc23af14ec Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 23:19:46 +0100 Subject: [PATCH 169/207] TOC toggle test: the app's fonts installed first, as main's dialog tests do Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/tests.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 549b9b6dc..5dc50bd67 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3568,7 +3568,18 @@ fn enter_on_the_toc_options_toggle_only_toggles() { app.run("file.newSample", json!({})).unwrap(); open_toc(&mut app); let revision = app.session.doc().unwrap().revision; - let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(|ui, app: &mut DesignApp| show(app, ui.ctx()), app); + let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::unique("toc_toggle_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); h.run_steps(3); h.get_by_label("Fewer Options").focus(); h.run_steps(2); From 6557f20c80354f4a3d312d2d90dff73e975b5763 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 23:26:34 +0100 Subject: [PATCH 170/207] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 18aabfae5..0bd0d39e6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -61,8 +61,8 @@ use tool_overlays::draw_tool_overlays; pub(crate) use view::page_in_view; use view::present_fit; pub use view::{ - apply_view_request, can_go_through_history, current_page, current_parent_page, current_slot, current_spread, current_spread_page, drop_point, - PageBox, fit, go_through_history, go_to_page, page_box, page_box_go, set_zoom, show_spread, step_spread, view_centre, zoom_at, + PageBox, apply_view_request, can_go_through_history, current_page, current_parent_page, current_slot, current_spread, current_spread_page, + drop_point, fit, go_through_history, go_to_page, page_box, page_box_go, set_zoom, show_spread, step_spread, view_centre, zoom_at, }; pub const RULER: f32 = 15.0; From 840d75b46fc02128ca572ac62531925cf0206108 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 23:36:32 +0100 Subject: [PATCH 171/207] Find/Change: Find what, Change to and Search named by their captions, for screen readers Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 302a1b069..e245b2c5c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2462,7 +2462,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let formatted = d.fields.get(CHANGE_FORMAT).and_then(Value::as_object).is_some_and(|f| !f.is_empty()); egui::Grid::new("fc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Find what:")); - text_field(ui, &mut d, "find", 260.0); + text_field_named(ui, &mut d, "find", 260.0, crate::i18n::tr(&app.ui.language, "Find what:")); ui.end_row(); ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Change to:")); @@ -2477,9 +2477,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { ); } }); - text_field(ui, &mut d, "change", 260.0); + text_field_named(ui, &mut d, "change", 260.0, crate::i18n::tr(&app.ui.language, "Change to:")); ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Search:")); + let scope_name = crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Search:")); let cur = d.s("scope"); craft_ui::ComboBox::from_id_salt("fcscope") .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &cur))) @@ -2491,7 +2491,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { app.session.reset_find_scope(); } } - }); + }) + .response + .labelled_by(scope_name.id); ui.end_row(); }); ui.horizontal(|ui| { From c35afdf719fce13b42cf2c81571b4a74d767acea Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 23:43:20 +0100 Subject: [PATCH 172/207] Insert Pages: Pages, Insert, its page and Parent named for screen readers; the page list is craft_ui's Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 40 ++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 26f87dbdc..6c3690625 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -166,20 +166,20 @@ fn insert_pages_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let mut parents = vec![(String::new(), crate::i18n::tr(&lang, "[None]").to_string())]; parents.extend(st.doc.parents.iter().map(|p| (p.id.0.to_string(), p.parent.as_ref().map(|i| i.label()).unwrap_or_default()))); egui::Grid::new("insert_pages").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&lang, "Pages:")); + let name = crate::rtl::label(ui, crate::i18n::tr(&lang, "Pages:")); let mut n = d.n("count").unwrap_or(1.0).clamp(1.0, 9999.0) as u32; - if ui.add(egui::DragValue::new(&mut n).range(1..=9999)).changed() { + if ui.add(egui::DragValue::new(&mut n).range(1..=9999)).labelled_by(name.id).changed() { d.fields.insert("count".into(), json!(n)); } ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&lang, "Insert:")); + let name = crate::rtl::label(ui, crate::i18n::tr(&lang, "Insert:")); ui.horizontal(|ui| { let wheres = [("after", "After Page"), ("before", "Before Page"), ("start", "At Start of Document"), ("end", "At End of Document")]; - translated_combo(&lang, ui, d, "insert", &wheres); + translated_combo(&lang, ui, d, "insert", &wheres).labelled_by(name.id); // The page, by name: At Start and At End need none. ui.add_enabled_ui(matches!(d.s("insert").as_str(), "after" | "before"), |ui| { let cur = (d.n("page").unwrap_or(0.0).max(0.0) as usize).min(pages.len().saturating_sub(1)); - egui::ComboBox::from_id_salt("insert_page").selected_text(pages.get(cur).cloned().unwrap_or_default()).width(64.0).show_ui( + craft_ui::ComboBox::from_id_salt("insert_page").selected_text(pages.get(cur).cloned().unwrap_or_default()).width(64.0).show_ui( ui, |ui| { for (i, name) in pages.iter().enumerate() { @@ -188,13 +188,15 @@ fn insert_pages_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } } }, - ); + ) + .response + .accessible_name(crate::i18n::tr(&lang, "Page").to_owned()); }); }); ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&lang, "Parent:")); + let name = crate::rtl::label(ui, crate::i18n::tr(&lang, "Parent:")); let opts: Vec<(&str, &str)> = parents.iter().map(|(v, l)| (v.as_str(), l.as_str())).collect(); - combo(ui, d, "parent", &opts); + combo(ui, d, "parent", &opts).labelled_by(name.id); ui.end_row(); }); } @@ -6226,22 +6228,26 @@ fn delete_character_style(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog }); } -fn combo(ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) { +fn combo(ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) -> egui::Response { let cur = d.s(key); let shown = opts.iter().find(|o| o.0 == cur).map_or(cur.as_str(), |o| o.1).to_string(); - craft_ui::ComboBox::from_id_salt(key).selected_text(crate::rtl::widget(ui, &shown)).width(170.0).show_ui(ui, |ui| { - for (v, label) in opts { - if ui.selectable_label(cur == *v, crate::rtl::widget(ui, *label)).clicked() { - d.fields.insert(key.into(), json!(v)); + craft_ui::ComboBox::from_id_salt(key) + .selected_text(crate::rtl::widget(ui, &shown)) + .width(170.0) + .show_ui(ui, |ui| { + for (v, label) in opts { + if ui.selectable_label(cur == *v, crate::rtl::widget(ui, *label)).clicked() { + d.fields.insert(key.into(), json!(v)); + } } - } - }); + }) + .response } /// Translate enum captions without changing serialized values or user-defined names. -fn translated_combo(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) { +fn translated_combo(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) -> egui::Response { let opts: Vec<_> = opts.iter().map(|(value, label)| (*value, crate::i18n::tr(lang, label))).collect(); - combo(ui, d, key, &opts); + combo(ui, d, key, &opts) } /// Style name picker (paragraph or character styles of the active document). From 3d03258989d29dade45c222d42f422dbb5893cc9 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 23:56:54 +0100 Subject: [PATCH 173/207] Preferences: the Tools Panel dropdown named by its label, for screen readers Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7fb6037dc..1802a1fcf 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1998,8 +1998,8 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he ui.add_space(8.0); crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Panels")).font(semibold(12.0))); ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&lang, "Tools Panel:")); - translated_combo(&lang, ui, d, "toolsColumns", &[("single", "Single Column"), ("double", "Double Column")]); + let name = crate::rtl::label(ui, crate::i18n::tr(&lang, "Tools Panel:")); + translated_combo(&lang, ui, d, "toolsColumns", &[("single", "Single Column"), ("double", "Double Column")]).labelled_by(name.id); }); } "uiScaling" => { @@ -6131,22 +6131,26 @@ fn delete_character_style(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog }); } -fn combo(ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) { +fn combo(ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) -> egui::Response { let cur = d.s(key); let shown = opts.iter().find(|o| o.0 == cur).map_or(cur.as_str(), |o| o.1).to_string(); - craft_ui::ComboBox::from_id_salt(key).selected_text(crate::rtl::widget(ui, &shown)).width(170.0).show_ui(ui, |ui| { - for (v, label) in opts { - if ui.selectable_label(cur == *v, crate::rtl::widget(ui, *label)).clicked() { - d.fields.insert(key.into(), json!(v)); + craft_ui::ComboBox::from_id_salt(key) + .selected_text(crate::rtl::widget(ui, &shown)) + .width(170.0) + .show_ui(ui, |ui| { + for (v, label) in opts { + if ui.selectable_label(cur == *v, crate::rtl::widget(ui, *label)).clicked() { + d.fields.insert(key.into(), json!(v)); + } } - } - }); + }) + .response } /// Translate enum captions without changing serialized values or user-defined names. -fn translated_combo(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) { +fn translated_combo(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) -> egui::Response { let opts: Vec<_> = opts.iter().map(|(value, label)| (*value, crate::i18n::tr(lang, label))).collect(); - combo(ui, d, key, &opts); + combo(ui, d, key, &opts) } /// Style name picker (paragraph or character styles of the active document). From b622e045088fee1d05160a7255ef5b27b48556ea Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 00:00:22 +0100 Subject: [PATCH 174/207] Paragraph Style Options: Next Style named by its label, for screen readers; craft_ui dropdowns Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e286dc94f..13d7e54b3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4396,7 +4396,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo let _ = crate::widgets::name_combo(combo, crate::i18n::tr(&app.ui.language, "Based On")); ui.end_row(); // Next Style: the style of the paragraph that follows when Enter ends this one. - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Next Style:")); + let next_name = crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Next Style:")); let next = match d.fields.get("nextStyle") { Some(Value::String(n)) => Some(n.clone()), Some(_) => None, @@ -4404,7 +4404,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo }; let same = crate::i18n::tr(&app.ui.language, "[Same style]").to_string(); let shown = next.as_deref().map_or(same.clone(), |n| crate::i18n::style_name(&app.ui.language, n).to_string()); - egui::ComboBox::from_id_salt("psnext").selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("psnext").selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| { if ui.selectable_label(next.is_none(), crate::rtl::widget(ui, &same)).clicked() { d.fields.insert("nextStyle".into(), Value::Null); } @@ -4416,7 +4416,9 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo d.fields.insert("nextStyle".into(), json!(n)); } } - }); + }) + .response + .labelled_by(next_name.id); ui.end_row(); }); // Reset To Base: the style keeps nothing of its own (the edits made here stay). @@ -5832,7 +5834,7 @@ fn char_number(ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, su fn char_choice(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, opts: &[(Value, &str)]) { let cur = cf.get(d, key); let shown = opts.iter().find(|o| o.0 == cur).map_or("", |o| crate::i18n::tr(lang, o.1)); - egui::ComboBox::from_id_salt(("cs", key)).selected_text(crate::rtl::widget(ui, shown)).width(140.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(("cs", key)).selected_text(crate::rtl::widget(ui, shown)).width(140.0).show_ui(ui, |ui| { if cf.sparse && ui.selectable_label(cur.is_null(), " ").clicked() { cf.set(d, key, Value::Null); } From 1c3b9280024919ba742c9e663269f979543af4da Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 00:00:52 +0100 Subject: [PATCH 175/207] Dropdowns: craft_ui::ComboBox, as main's clippy configuration asks Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9183444cf..256cfeaef 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4412,7 +4412,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo }; let same = crate::i18n::tr(&app.ui.language, "[Same style]").to_string(); let shown = next.as_deref().map_or(same.clone(), |n| crate::i18n::style_name(&app.ui.language, n).to_string()); - egui::ComboBox::from_id_salt("psnext").selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("psnext").selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| { if ui.selectable_label(next.is_none(), crate::rtl::widget(ui, &same)).clicked() { d.fields.insert("nextStyle".into(), Value::Null); } @@ -5857,7 +5857,7 @@ fn char_number(ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, su fn char_choice(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, opts: &[(Value, &str)]) { let cur = cf.get(d, key); let shown = opts.iter().find(|o| o.0 == cur).map_or("", |o| crate::i18n::tr(lang, o.1)); - egui::ComboBox::from_id_salt(("cs", key)).selected_text(crate::rtl::widget(ui, shown)).width(140.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(("cs", key)).selected_text(crate::rtl::widget(ui, shown)).width(140.0).show_ui(ui, |ui| { if cf.sparse && ui.selectable_label(cur.is_null(), " ").clicked() { cf.set(d, key, Value::Null); } From d865a7cca15fa7ed81d18e0bf6e2bad41e47a153 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 00:00:58 +0100 Subject: [PATCH 176/207] Dropdowns: craft_ui::ComboBox, as main's clippy configuration asks Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e245b2c5c..805a5ffc6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3441,7 +3441,7 @@ fn format_style_options(lang: &str, ui: &mut egui::Ui, fd: &mut Dialog, styles: crate::rtl::label(ui, crate::i18n::tr(lang, label)); let cur = fd.fields.get(key).and_then(Value::as_str).map(str::to_string); let shown = cur.as_deref().map_or_else(|| any.to_string(), |n| crate::i18n::style_name(lang, n).to_string()); - egui::ComboBox::from_id_salt(("format_style", key)).selected_text(crate::rtl::widget(ui, shown)).width(240.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(("format_style", key)).selected_text(crate::rtl::widget(ui, shown)).width(240.0).show_ui(ui, |ui| { if ui.selectable_label(cur.is_none(), crate::rtl::widget(ui, any)).clicked() { fd.fields.remove(key); } From 63340f51a55d358773c72c52f03c4e1977e7647e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 00:01:06 +0100 Subject: [PATCH 177/207] Dropdowns: craft_ui::ComboBox, as main's clippy configuration asks Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index cbf7ea6a6..82221a4d1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4616,7 +4616,7 @@ fn swatch_menu_with( Some(label) if current == designcraft_doc::TEXT_COLOR => label, _ => current, }; - egui::ComboBox::from_id_salt(id).selected_text(shown).width(width).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(id).selected_text(shown).width(width).show_ui(ui, |ui| { if let Some(label) = text_color && ui.selectable_label(current == designcraft_doc::TEXT_COLOR, label).clicked() { @@ -6476,7 +6476,7 @@ fn paragraph_rules(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, prefix: & designcraft_doc::StrokeType::Style { name } => name.clone(), k => crate::i18n::tr(lang, k.label()).to_string(), }; - egui::ComboBox::from_id_salt(("rule_type", which)).selected_text(crate::rtl::widget(ui, shown)).width(130.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(("rule_type", which)).selected_text(crate::rtl::widget(ui, shown)).width(130.0).show_ui(ui, |ui| { for k in rule_types().into_iter().chain(custom.into_iter().map(|name| designcraft_doc::StrokeType::Style { name })) { let label = match &k { designcraft_doc::StrokeType::Style { name } => name.clone(), From 34d97eae377e27f7141ade69f4e999ed1b0bce2f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 00:20:02 +0100 Subject: [PATCH 178/207] Page box test: the menu's entries found among the names the window shows elsewhere too Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/chrome/tests.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index eb84e74ca..742aba403 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -203,16 +203,18 @@ fn the_page_box_names_the_parent_in_view_and_lists_the_parents() { h.get_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Page")).click(); h.run_steps(3); for name in ["1", "2", "3", "4", "A-Parent"] { - assert!(h.query_by_label(name).is_some(), "{name} is listed"); + // The canvas or a panel may show the name too: the menu lists it. + assert!(h.query_all_by_label(name).next().is_some(), "{name} is listed"); } - h.get_by_label("B-Cover").click(); + // The menu, drawn last, holds the last match. + h.get_all_by_label("B-Cover").last().unwrap().click(); h.run_steps(6); assert_eq!(shown(&h), "B-Cover"); assert_eq!(crate::canvas::current_parent_page(&h.state().app).map(|(i, _)| i), Some(1)); // A page: the parents are left. h.get_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Page")).click(); h.run_steps(3); - h.get_by_label("3").click(); + h.get_all_by_label("3").last().unwrap().click(); h.run_steps(6); assert!(!h.state().app.session.active().unwrap().editing_parents); assert_eq!(crate::canvas::current_page(&h.state().app), Some(2)); From fd3d2f4e4922cc1bf9c891848304bdb747de0e85 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 11:46:43 +0100 Subject: [PATCH 179/207] Document properties: the page box field in its own function Keeps document_sections within clippy's cognitive complexity limit. Co-Authored-By: Claude Opus 5.5 --- .../ui-egui/src/panels/properties/document.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/panels/properties/document.rs b/crates/ui-egui/src/panels/properties/document.rs index f9ac002cb..d68d4531c 100644 --- a/crates/ui-egui/src/panels/properties/document.rs +++ b/crates/ui-egui/src/panels/properties/document.rs @@ -172,6 +172,15 @@ fn page_preset_menu(ui: &mut Ui, pw: f64, ph: f64) -> Option<(f64, f64)> { preset } +/// As the status bar's page box: the page, or the parent shown ("A-Parent"); then the parents. +fn page_box_field(app: &mut DesignApp, ui: &mut Ui, b: Rect) { + let Some(pb) = crate::canvas::page_box(app) else { return }; + let picked = place(ui, sub(b, 0.0, 0.0, 93.0, FIELD_H), |ui| widgets::dropdown_list(ui, pb.current_name(), 93.0, &pb.names, Some(pb.current))); + if let Some(i) = picked { + crate::canvas::page_box_go(app, &pb, i); + } +} + pub(super) fn document_sections(app: &mut DesignApp, ui: &mut Ui) { let u = units(app); let Some(st) = app.session.active() else { return }; @@ -287,14 +296,7 @@ pub(super) fn document_sections(app: &mut DesignApp, ui: &mut Ui) { // Page. section(ui, crate::i18n::tr(&app.ui.language, "Page"), true); let b = block(ui, FIELD_H); - // As the status bar's page box: the page, or the parent shown ("A-Parent"); then the parents. - if let Some(pb) = crate::canvas::page_box(app) { - let picked = - place(ui, sub(b, 0.0, 0.0, 93.0, FIELD_H), |ui| widgets::dropdown_list(ui, pb.current_name(), 93.0, &pb.names, Some(pb.current))); - if let Some(i) = picked { - crate::canvas::page_box_go(app, &pb, i); - } - } + page_box_field(app, ui, b); let (add, del) = place(ui, sub(b, 172.0, 0.0, 50.0, FIELD_H), |ui| { ui.spacing_mut().item_spacing.x = 4.0; let a = widgets::icon_toggle_sized(ui, "style-new", false, "Insert Page", vec2(22.0, 21.0)).clicked(); From 278b8e8bf4529c84adf3e3f8baae3223dc0be584 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 13:21:04 +0100 Subject: [PATCH 180/207] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1802a1fcf..eeb67869a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1999,7 +1999,8 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Panels")).font(semibold(12.0))); ui.horizontal(|ui| { let name = crate::rtl::label(ui, crate::i18n::tr(&lang, "Tools Panel:")); - translated_combo(&lang, ui, d, "toolsColumns", &[("single", "Single Column"), ("double", "Double Column")]).labelled_by(name.id); + translated_combo(&lang, ui, d, "toolsColumns", &[("single", "Single Column"), ("double", "Double Column")]) + .labelled_by(name.id); }); } "uiScaling" => { From 00e26ce428a10ea1a3cd9a8f183301a661cc4c07 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 15:11:34 +0100 Subject: [PATCH 181/207] Paragraph Style Options: Next Style named by its label, for screen readers; craft_ui dropdown Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 256cfeaef..6a48de653 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4404,7 +4404,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo let _ = crate::widgets::name_combo(combo, crate::i18n::tr(&app.ui.language, "Based On")); ui.end_row(); // Next Style: the style of the paragraph that follows when Enter ends this one. - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Next Style:")); + let next_name = crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Next Style:")); let next = match d.fields.get("nextStyle") { Some(Value::String(n)) => Some(n.clone()), Some(_) => None, @@ -4424,7 +4424,9 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo d.fields.insert("nextStyle".into(), json!(n)); } } - }); + }) + .response + .labelled_by(next_name.id); ui.end_row(); }); // Reset To Base: the style keeps nothing of its own (the edits made here stay). From dd0b891ef0c8e058fb72eb7d84cf49a7e9548240 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 15:35:16 +0100 Subject: [PATCH 182/207] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6a48de653..3044479cf 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4412,21 +4412,27 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo }; let same = crate::i18n::tr(&app.ui.language, "[Same style]").to_string(); let shown = next.as_deref().map_or(same.clone(), |n| crate::i18n::style_name(&app.ui.language, n).to_string()); - craft_ui::ComboBox::from_id_salt("psnext").selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| { - if ui.selectable_label(next.is_none(), crate::rtl::widget(ui, &same)).clicked() { - d.fields.insert("nextStyle".into(), Value::Null); - } - for n in names.iter().filter(|n| *n != designcraft_doc::NO_PARA_STYLE) { - if ui - .selectable_label(next.as_deref() == Some(n), crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n))) - .clicked() - { - d.fields.insert("nextStyle".into(), json!(n)); + craft_ui::ComboBox::from_id_salt("psnext") + .selected_text(crate::rtl::widget(ui, shown)) + .width(220.0) + .show_ui(ui, |ui| { + if ui.selectable_label(next.is_none(), crate::rtl::widget(ui, &same)).clicked() { + d.fields.insert("nextStyle".into(), Value::Null); } - } - }) - .response - .labelled_by(next_name.id); + for n in names.iter().filter(|n| *n != designcraft_doc::NO_PARA_STYLE) { + if ui + .selectable_label( + next.as_deref() == Some(n), + crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n)), + ) + .clicked() + { + d.fields.insert("nextStyle".into(), json!(n)); + } + } + }) + .response + .labelled_by(next_name.id); ui.end_row(); }); // Reset To Base: the style keeps nothing of its own (the edits made here stay). From f4e12a5b5048d1aac24342fb06209e86709cf27d Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 15:42:16 +0100 Subject: [PATCH 183/207] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index b0c46f5ad..4997453b4 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3588,7 +3588,7 @@ fn a_new_character_style_starts_from_the_selected_text() { assert!(d.b("apply")); let mut h = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state( |ui, app: &mut DesignApp| { - let key = egui::Id::new("new_character_style_test_fonts"); + let key = egui::Id::unique("new_character_style_test_fonts"); if ui.data(|d| d.get_temp::(key)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(key, true)); From e73209a4e59cc73716f6d128d8d4a4bf890bce9a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 15:50:46 +0100 Subject: [PATCH 184/207] Table of Contents: every field named by its label, for screen readers Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 40 ++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 85ad65dce..773aab2c7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6858,7 +6858,7 @@ fn toc_params(app: &DesignApp, d: &Dialog) -> Value { /// A style menu whose first choice, stored as "", is `auto` (the generated paragraph style, or /// [None] for a character style). -fn toc_style_combo(ui: &mut egui::Ui, salt: &str, styles: &[String], auto: &str, cur: &mut String) -> bool { +fn toc_style_combo(ui: &mut egui::Ui, salt: &str, styles: &[String], auto: &str, cur: &mut String, name: egui::Id) -> bool { let shown = if cur.is_empty() { auto.to_string() } else { cur.clone() }; let mut changed = false; craft_ui::ComboBox::from_id_salt(("toc_style", salt)).selected_text(shown).width(200.0).show_ui(ui, |ui| { @@ -6872,7 +6872,9 @@ fn toc_style_combo(ui: &mut egui::Ui, salt: &str, styles: &[String], auto: &str, changed = true; } } - }); + }) + .response + .labelled_by(name); changed } @@ -6891,9 +6893,9 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let mut title_style = d.s("titleStyle"); egui::Grid::new("toc_title").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, tr("Title:")); - text_field(ui, d, "title", 200.0); - crate::rtl::label(ui, tr("Style:")); - toc_style_combo(ui, "title", &styles, "TOC Title", &mut title_style); + text_field_named(ui, d, "title", 200.0, &tr("Title:")); + let name = crate::rtl::label(ui, tr("Style:")).id; + toc_style_combo(ui, "title", &styles, "TOC Title", &mut title_style, name); ui.end_row(); }); ui.add_space(8.0); @@ -6957,14 +6959,14 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { crate::rtl::label(ui, egui::RichText::new(format!("{} {}", tr("Style:"), r.style)).font(semibold(12.0))); let none = designcraft_doc::NO_CHAR_STYLE; egui::Grid::new("toc_entry").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, tr("Entry Style:")); + let name = crate::rtl::label(ui, tr("Entry Style:")).id; let auto = format!("TOC Level {}", r.level.clamp(1, 9)); - toc_style_combo(ui, "entry", &styles, &auto, &mut r.entry_style); + toc_style_combo(ui, "entry", &styles, &auto, &mut r.entry_style, name); ui.end_row(); if !expanded { return; } - crate::rtl::label(ui, tr("Page Number:")); + let page_number = crate::rtl::label(ui, tr("Page Number:")).id; let choices = [("after", "After Entry"), ("before", "Before Entry"), ("none", "No Page Number")]; let cur = choices.iter().find(|(v, _)| *v == r.page_number).map_or("After Entry", |(_, l)| l); craft_ui::ComboBox::from_id_salt("toc_page_number").selected_text(tr(cur)).width(200.0).show_ui(ui, |ui| { @@ -6973,26 +6975,30 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { r.page_number = v.into(); } } - }); - crate::rtl::label(ui, tr("Style:")); - toc_style_combo(ui, "page_number", &char_styles, none, &mut r.page_number_style); + }) + .response + .labelled_by(page_number); + let name = crate::rtl::label(ui, tr("Style:")).id; + toc_style_combo(ui, "page_number", &char_styles, none, &mut r.page_number_style, name); ui.end_row(); - crate::rtl::label(ui, tr("Between Entry and Number:")); - ui.add(egui::TextEdit::singleline(&mut r.separator).desired_width(80.0).align(egui::Align2::LEFT_TOP)); - crate::rtl::label(ui, tr("Style:")); - toc_style_combo(ui, "separator", &char_styles, none, &mut r.separator_style); + let between = crate::rtl::label(ui, tr("Between Entry and Number:")).id; + ui.add(egui::TextEdit::singleline(&mut r.separator).desired_width(80.0).align(egui::Align2::LEFT_TOP)).labelled_by(between); + let name = crate::rtl::label(ui, tr("Style:")).id; + toc_style_combo(ui, "separator", &char_styles, none, &mut r.separator_style, name); ui.end_row(); crate::rtl::label(ui, ""); ui.checkbox(&mut r.sort, tr("Sort Entries in Alphabetical Order")); ui.end_row(); - crate::rtl::label(ui, tr("Level:")); + let level = crate::rtl::label(ui, tr("Level:")).id; craft_ui::ComboBox::from_id_salt("toc_level").selected_text(r.level.to_string()).width(60.0).show_ui(ui, |ui| { for l in 1..=9u8 { if ui.selectable_label(r.level == l, l.to_string()).clicked() { r.level = l; } } - }); + }) + .response + .labelled_by(level); ui.end_row(); }); } From 7cb620608a2959687497796298348f8537a6e06a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 16:23:18 +0100 Subject: [PATCH 185/207] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6c3690625..d7ded3ac6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -179,18 +179,18 @@ fn insert_pages_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { // The page, by name: At Start and At End need none. ui.add_enabled_ui(matches!(d.s("insert").as_str(), "after" | "before"), |ui| { let cur = (d.n("page").unwrap_or(0.0).max(0.0) as usize).min(pages.len().saturating_sub(1)); - craft_ui::ComboBox::from_id_salt("insert_page").selected_text(pages.get(cur).cloned().unwrap_or_default()).width(64.0).show_ui( - ui, - |ui| { + craft_ui::ComboBox::from_id_salt("insert_page") + .selected_text(pages.get(cur).cloned().unwrap_or_default()) + .width(64.0) + .show_ui(ui, |ui| { for (i, name) in pages.iter().enumerate() { if ui.selectable_label(i == cur, name).clicked() { d.fields.insert("page".into(), json!(i)); } } - }, - ) - .response - .accessible_name(crate::i18n::tr(&lang, "Page").to_owned()); + }) + .response + .accessible_name(crate::i18n::tr(&lang, "Page").to_owned()); }); }); ui.end_row(); From 7e3bca3d916ccd5af8cb0a513c575813b6393123 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 16:32:41 +0100 Subject: [PATCH 186/207] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d7ded3ac6..f6fc489aa 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -276,7 +276,7 @@ mod color_theme_tests { assert_eq!(app.ui.dialog.as_ref().unwrap().id, "insertPages"); let mut h = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state( |ui, app: &mut DesignApp| { - let key = egui::Id::new("insert_pages_test_fonts"); + let key = egui::Id::unique("insert_pages_test_fonts"); if ui.data(|d| d.get_temp::(key)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(key, true)); From 133536f0e7d614cd576806b12fbe94ff5765462f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 16:48:55 +0100 Subject: [PATCH 187/207] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 13d7e54b3..8e52ac46c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4404,21 +4404,27 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo }; let same = crate::i18n::tr(&app.ui.language, "[Same style]").to_string(); let shown = next.as_deref().map_or(same.clone(), |n| crate::i18n::style_name(&app.ui.language, n).to_string()); - craft_ui::ComboBox::from_id_salt("psnext").selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| { - if ui.selectable_label(next.is_none(), crate::rtl::widget(ui, &same)).clicked() { - d.fields.insert("nextStyle".into(), Value::Null); - } - for n in names.iter().filter(|n| *n != designcraft_doc::NO_PARA_STYLE) { - if ui - .selectable_label(next.as_deref() == Some(n), crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n))) - .clicked() - { - d.fields.insert("nextStyle".into(), json!(n)); + craft_ui::ComboBox::from_id_salt("psnext") + .selected_text(crate::rtl::widget(ui, shown)) + .width(220.0) + .show_ui(ui, |ui| { + if ui.selectable_label(next.is_none(), crate::rtl::widget(ui, &same)).clicked() { + d.fields.insert("nextStyle".into(), Value::Null); } - } - }) - .response - .labelled_by(next_name.id); + for n in names.iter().filter(|n| *n != designcraft_doc::NO_PARA_STYLE) { + if ui + .selectable_label( + next.as_deref() == Some(n), + crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n)), + ) + .clicked() + { + d.fields.insert("nextStyle".into(), json!(n)); + } + } + }) + .response + .labelled_by(next_name.id); ui.end_row(); }); // Reset To Base: the style keeps nothing of its own (the edits made here stay). From 2505d8972ccd7c1a2581df291c9a3b24d334af4b Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 17:05:43 +0100 Subject: [PATCH 188/207] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 65 ++++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 28 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 773aab2c7..52f42965e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6861,20 +6861,23 @@ fn toc_params(app: &DesignApp, d: &Dialog) -> Value { fn toc_style_combo(ui: &mut egui::Ui, salt: &str, styles: &[String], auto: &str, cur: &mut String, name: egui::Id) -> bool { let shown = if cur.is_empty() { auto.to_string() } else { cur.clone() }; let mut changed = false; - craft_ui::ComboBox::from_id_salt(("toc_style", salt)).selected_text(shown).width(200.0).show_ui(ui, |ui| { - if ui.selectable_label(cur.is_empty(), auto).clicked() { - cur.clear(); - changed = true; - } - for n in styles.iter().filter(|n| *n != auto) { - if ui.selectable_label(cur == n, n.as_str()).clicked() { - *cur = n.clone(); + craft_ui::ComboBox::from_id_salt(("toc_style", salt)) + .selected_text(shown) + .width(200.0) + .show_ui(ui, |ui| { + if ui.selectable_label(cur.is_empty(), auto).clicked() { + cur.clear(); changed = true; } - } - }) - .response - .labelled_by(name); + for n in styles.iter().filter(|n| *n != auto) { + if ui.selectable_label(cur == n, n.as_str()).clicked() { + *cur = n.clone(); + changed = true; + } + } + }) + .response + .labelled_by(name); changed } @@ -6969,15 +6972,18 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let page_number = crate::rtl::label(ui, tr("Page Number:")).id; let choices = [("after", "After Entry"), ("before", "Before Entry"), ("none", "No Page Number")]; let cur = choices.iter().find(|(v, _)| *v == r.page_number).map_or("After Entry", |(_, l)| l); - craft_ui::ComboBox::from_id_salt("toc_page_number").selected_text(tr(cur)).width(200.0).show_ui(ui, |ui| { - for (v, l) in choices { - if ui.selectable_label(r.page_number == v, tr(l)).clicked() { - r.page_number = v.into(); + craft_ui::ComboBox::from_id_salt("toc_page_number") + .selected_text(tr(cur)) + .width(200.0) + .show_ui(ui, |ui| { + for (v, l) in choices { + if ui.selectable_label(r.page_number == v, tr(l)).clicked() { + r.page_number = v.into(); + } } - } - }) - .response - .labelled_by(page_number); + }) + .response + .labelled_by(page_number); let name = crate::rtl::label(ui, tr("Style:")).id; toc_style_combo(ui, "page_number", &char_styles, none, &mut r.page_number_style, name); ui.end_row(); @@ -6990,15 +6996,18 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.checkbox(&mut r.sort, tr("Sort Entries in Alphabetical Order")); ui.end_row(); let level = crate::rtl::label(ui, tr("Level:")).id; - craft_ui::ComboBox::from_id_salt("toc_level").selected_text(r.level.to_string()).width(60.0).show_ui(ui, |ui| { - for l in 1..=9u8 { - if ui.selectable_label(r.level == l, l.to_string()).clicked() { - r.level = l; + craft_ui::ComboBox::from_id_salt("toc_level") + .selected_text(r.level.to_string()) + .width(60.0) + .show_ui(ui, |ui| { + for l in 1..=9u8 { + if ui.selectable_label(r.level == l, l.to_string()).clicked() { + r.level = l; + } } - } - }) - .response - .labelled_by(level); + }) + .response + .labelled_by(level); ui.end_row(); }); } From a562053b7e9e2c24ec2feba3685ee6cc7cf63179 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 30 Oct 2026 19:46:02 +0000 Subject: [PATCH 189/207] Integration: #1406 preview_dropdown 4-argument form --- crates/ui-egui/src/panels/properties/object.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/properties/object.rs b/crates/ui-egui/src/panels/properties/object.rs index 61bc3c38d..869a741ab 100644 --- a/crates/ui-egui/src/panels/properties/object.rs +++ b/crates/ui-egui/src/panels/properties/object.rs @@ -312,7 +312,8 @@ fn paint_corner_shape(p: &egui::Painter, r: Rect, shape: CornerShape, c: egui::C /// A corner shape picker's face: the shape's glyph and the dropdown arrow (the menu is the caller's). pub(crate) fn corner_shape_button(ui: &mut Ui, shape: CornerShape, width: f32) -> egui::Response { - preview_dropdown(ui, width, |p, r| paint_corner_shape(p, r, shape)) + // The caller names the control (widget_info) once it knows the corner. + preview_dropdown(ui, width, "", |p, r| paint_corner_shape(p, r, shape, egui::Color32::BLACK)) } pub(crate) fn corner_type_dropdown(app: &mut DesignApp, ui: &mut Ui, shape: CornerShape, size: f64, width: f32) { From aabf0fa80521f3c026f77ddbd9bf083ba7d67b9b Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 30 Oct 2026 19:47:45 +0000 Subject: [PATCH 190/207] Integration: #1431/#1504 spectrum_pick takes the language (merged helper lost its app) --- crates/ui-egui/src/panels/swatches.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 236bd9388..58cc8d85c 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -781,7 +781,7 @@ fn gradient_stop(app: &mut DesignApp, ui: &mut egui::Ui, g: &designcraft_color:: ui.label(egui::RichText::new(format!("L {:.0} a {:.0} b {:.0}", lab.l, lab.a, lab.b)).color(t.text_dim)); } } - if let Some([r, gr, b]) = spectrum_pick(ui) { + if let Some([r, gr, b]) = spectrum_pick(ui, &app.ui.language) { new = Some(Color::rgb(r, gr, b)); } if let Some(c) = new { @@ -793,16 +793,16 @@ fn gradient_stop(app: &mut DesignApp, ui: &mut egui::Ui, g: &designcraft_color:: /// The spectrum ramp: hue across, light to dark down; click to apply. fn spectrum(app: &mut DesignApp, ui: &mut egui::Ui, target: &str) { - if let Some([r, g, b]) = spectrum_pick(ui) { + if let Some([r, g, b]) = spectrum_pick(ui, &app.ui.language) { let text = super::colors_affect_text(app); let _ = app.run("object.color", json!({"color": {"model": "rgb", "r": r, "g": g, "b": b}, "target": target, "text": text})); } } /// The spectrum ramp; a click on it gives that colour (RGB, 0–1, in 1/255 steps). -fn spectrum_pick(ui: &mut egui::Ui) -> Option<[f32; 3]> { +fn spectrum_pick(ui: &mut egui::Ui, language: &str) -> Option<[f32; 3]> { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::i18n::tr(&app.ui.language, "Color spectrum"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::i18n::tr(language, "Color spectrum"))); let at = |u: f32, v: f32| -> [f32; 3] { let c = egui::ecolor::Hsva::new(u, (v * 2.0).min(1.0), (2.0 - v * 2.0).min(1.0), 1.0).to_rgb(); [c[0], c[1], c[2]] From 6e9ef6248c7ced843ef036ed7bcf688427fc98f3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 30 Oct 2026 19:48:22 +0000 Subject: [PATCH 191/207] Integration: cargo fmt --- crates/ui-egui/src/panels/properties.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index ecb9e664a..7243bad44 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -30,9 +30,9 @@ pub use flex::flex_panel; use flex::flex_section; pub(crate) use links::run_update_links; pub use links::{info_panel, links_panel, preflight_panel, update_links}; +use object::{align_row, appearance_section, guide_section, quick_actions_object, transform_section, wrap_section}; #[cfg(test)] use object::{align_to_icon, preview_row}; -use object::{align_row, appearance_section, guide_section, quick_actions_object, transform_section, wrap_section}; pub(crate) use object::{corner_shape_button, corner_type_dropdown, ref_point_proxy, stroke_type_dropdown}; use object_panels::pathfinder_row; pub use object_panels::{align_panel, attributes_panel, effects_panel, pathfinder_panel, stroke_panel, wrap_panel}; From 60e3068bbfcf7d3386aea1fb88ade9dde087c433 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 30 Oct 2026 20:51:17 +0000 Subject: [PATCH 192/207] Integration: close the TOC bookmarks test where track a's tests were appended --- crates/ui-egui/src/dialogs/tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 7cf010c83..8a80198ea 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -4173,6 +4173,8 @@ fn toc_bookmarks_checkbox_sets_and_reopens_the_persisted_option() { assert!(app.session.doc().unwrap().doc.bookmarks.is_empty()); open_toc(&mut app); assert!(!app.ui.dialog.as_ref().unwrap().b("createBookmarks")); +} + #[test] fn corner_options_dialog_sets_each_corner_with_preview_and_one_undo_step() { use designcraft_geom::corners::CornerShape; From 6a1d554d4477dc43088b8cd508e54763c729f563 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 30 Oct 2026 21:22:34 +0000 Subject: [PATCH 193/207] Integration: #1554 prefs.set rejects unknown keys inside defaultUnits --- crates/engine/src/cmd/prefs.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/engine/src/cmd/prefs.rs b/crates/engine/src/cmd/prefs.rs index a6b53d605..f51ce3c70 100644 --- a/crates/engine/src/cmd/prefs.rs +++ b/crates/engine/src/cmd/prefs.rs @@ -102,6 +102,9 @@ fn merge(cmd: &str, mut cur: Value, p: &Value, allowed: Option<&[&str]>) -> Resu match (c.get_mut(k), v) { (Some(Value::Object(old)), Value::Object(v)) => { for (k2, v2) in v { + if !old.is_empty() && !old.contains_key(k2) { + return Err(bad(cmd, format!("unknown preference {k}.{k2}"))); + } old.insert(k2.clone(), v2.clone()); } } @@ -129,6 +132,7 @@ mod tests { s.execute("prefs.set", &json!({"showFontNamesInEnglish": true})).unwrap(); assert!(s.prefs.show_font_names_in_english); assert!(s.execute("prefs.set", &json!({"nope": 1})).is_err()); + assert!(s.execute("prefs.set", &json!({"defaultUnits": {"horizontalUnit": "inches"}})).is_err()); s.execute("file.new", &json!({})).unwrap(); let r = s .execute("document.preferences", &json!({"horizontalUnits": "millimeters", "keyboardIncrement": 2.5, "grid": {"subdivisions": 4}})) From 2bf89ce3708ee315393a04f5086b50412d4f611a Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 30 Oct 2026 21:23:07 +0000 Subject: [PATCH 194/207] Integration: #1394 turning Auto-Fit off alone keeps the picture where it is --- crates/engine/src/cmd/object.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index e85f20f33..6bf4a02cc 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -2968,7 +2968,9 @@ fn fitting_options(s: &mut Session, p: &Value) -> Result { } else if fitting.is_some() && g.auto_fit != designcraft_doc::Fitting::None { g.auto_fit = mode; } - if let Some(xf) = g.fitted(r, mode) { + // Turning Auto-Fit off alone leaves the picture where the user put it. + let turn_off_only = auto == Some(false) && fitting.is_none() && align.is_none() && crop.is_none(); + if !turn_off_only && let Some(xf) = g.fitted(r, mode) { g.xf = xf; } n += 1; @@ -4412,6 +4414,28 @@ mod fitting_tests { assert_eq!(g.auto_fit, designcraft_doc::Fitting::FillProportionally); } + /// Turning Auto-Fit off by itself keeps a moved picture where it is. + #[test] + fn auto_fit_off_alone_keeps_the_picture_position() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].as_u64().unwrap(); + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); + s.execute("file.place", &json!({"base64": crate::cmd::base64_encode(&png), "name": "p.png", "frame": f})).unwrap(); + s.execute("object.fittingOptions", &json!({"ids": [f], "autoFit": true, "fitting": "fitProportionally"})).unwrap(); + let xf = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().graphic().unwrap().xf; + s.edit(|d, _| { + if let Some(Content::Graphic(g)) = d.item_mut(ItemId(f)).map(|it| &mut it.content) { + g.xf = designcraft_geom::Affine::translate((7.0, 9.0)) * g.xf; + } + Ok(Value::Null) + }) + .unwrap(); + let moved = xf(&s); + s.execute("object.fittingOptions", &json!({"ids": [f], "autoFit": false})).unwrap(); + assert_eq!(xf(&s), moved); + } + /// Clear Frame Fitting Options puts auto-fit, crop and the reference point back to their /// defaults and leaves the picture where it is. #[test] From bc1be1e64667c3690698f1fffcd9cd0fa0f1bf8b Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 30 Oct 2026 21:23:46 +0000 Subject: [PATCH 195/207] Integration: #1504 a Color panel edit of a gradient stop drops the stop's swatch --- crates/ui-egui/src/panels/swatches.rs | 29 +++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 58cc8d85c..c934d6b19 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -786,7 +786,7 @@ fn gradient_stop(app: &mut DesignApp, ui: &mut egui::Ui, g: &designcraft_color:: } if let Some(c) = new { let mut stops = stops_json(g); - set_stop(&mut stops, i, "color", color_json(&c)); + set_stop_color(&mut stops, i, color_json(&c)); let _ = app.run("object.gradient", json!({"stops": stops, "target": target})); } } @@ -843,6 +843,19 @@ fn color_json(c: &designcraft_color::Color) -> serde_json::Value { } } +#[cfg(test)] +mod stop_color_tests { + use super::*; + + #[test] + fn a_new_stop_colour_drops_the_stops_swatch() { + let mut stops = vec![json!({"location": 0, "color": "#000000", "swatch": "Gold"})]; + set_stop_color(&mut stops, 0, json!("#ff0000")); + assert_eq!(stops[0]["color"], "#ff0000"); + assert!(stops[0].get("swatch").is_none()); + } +} + #[cfg(test)] mod color_panel_defaults_tests { use super::*; @@ -1050,6 +1063,14 @@ fn live_drag(d: Option, n: usize) -> Option { } /// Sets `key` of stop `i` in `object.gradient` stops; nothing if there's no such stop. +/// A new colour replaces the stop's swatch (`object.gradient` gives `swatch` precedence). +fn set_stop_color(stops: &mut [serde_json::Value], i: usize, color: serde_json::Value) { + if let Some(o) = stops.get_mut(i).and_then(serde_json::Value::as_object_mut) { + o.remove("swatch"); + } + set_stop(stops, i, "color", color); +} + fn set_stop(stops: &mut [serde_json::Value], i: usize, key: &str, v: serde_json::Value) { if let Some(o) = stops.get_mut(i).and_then(serde_json::Value::as_object_mut) { o.insert(key.to_string(), v); @@ -1239,11 +1260,7 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let mut rgb = [(r * 255.0) as u8, (gg * 255.0) as u8, (bb * 255.0) as u8]; if egui::color_picker::color_edit_button_srgb(ui, &mut rgb).changed() { let mut stops = stops_json(&g); - // A picked color replaces the stop's swatch. - if let Some(o) = stops.get_mut(i).and_then(serde_json::Value::as_object_mut) { - o.remove("swatch"); - } - set_stop(&mut stops, i, "color", json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]))); + set_stop_color(&mut stops, i, json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]))); send = Some(json!({"stops": stops})); } }); From 09fbe280ffa0958f897f1ec0baa3458147a86ff5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 30 Oct 2026 21:24:19 +0000 Subject: [PATCH 196/207] Integration: #1125 Direct Selection on one of several selected frames selects just that frame --- crates/tools/src/select.rs | 7 +++++-- crates/tools/src/tests.rs | 13 +++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 43f36b703..16ccbad0c 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -789,8 +789,11 @@ impl SelectionTool { } else { out.push(Action::Exec("selection.toggle".into(), json!({"id": id.0}))); } - } else if !cx.selection.contains(id) || (self.direct && !cx.selection.content && cx.doc.item(id).is_some_and(holds_content)) { - // Direct Selection on a selected frame selects its content (a graphic or pasted-in items). + } else if !cx.selection.contains(id) + || (self.direct && !cx.selection.content && (cx.selection.items.len() > 1 || cx.doc.item(id).is_some_and(holds_content))) + { + // Direct Selection on a selected frame selects its content (a graphic or pasted-in items), + // and on one of several selected objects just that object. out.push(Action::Exec("selection.set".into(), json!({"ids": [id.0], "content": self.direct}))); } else if !self.direct && cx.selection.items.len() > 1 { // InDesign: a click on one of several selected objects makes it the diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 94e858092..03a19c73a 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -2069,3 +2069,16 @@ fn loaded_text_cursor_threads_into_a_frame_inside_a_group() { assert_eq!(cx2.hit_text_frame(q).map(|(_, i)| i), None, "no text frame in that group"); assert_eq!(t2.cursor(&cx2, q, Mods::default()), Cursor::NotAllowed); } + +#[test] +fn direct_selection_click_on_one_of_several_selected_frames_selects_only_it() { + let mut d = Document::new(&NewDocument::default()); + let a = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0)); + let b = add_rect(&mut d, Rect::new(300.0, 100.0, 400.0, 200.0)); + let (s, c, l) = (Selection::items(vec![a, b]), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let p = spread_pt(&l, 150.0, 150.0); + let mut t = create("directSelection"); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)); + assert_eq!(acts, vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [a.0], "content": true}))]); +} From 55fb94242e7b28c14da0705b1ea67f4633253ee1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 30 Oct 2026 21:24:27 +0000 Subject: [PATCH 197/207] Integration: #902 Direct Selection anchor pull selects the frame, not its content --- crates/tools/src/select.rs | 2 +- crates/tools/src/tests.rs | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 16ccbad0c..7f4c75780 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -714,7 +714,7 @@ impl SelectionTool { { let at = anchor_spread_point(cx, id, si, ai); self.drag = Drag::Anchor { id, si, ai, handle: None, start: p, at }; - return vec![Action::Exec("selection.set".into(), json!({"ids": [id], "content": true})), Action::Begin("Move Anchor".into())]; + return vec![Action::Exec("selection.set".into(), json!({"ids": [id], "content": false})), Action::Begin("Move Anchor".into())]; } // A selected placed graphic's own handles resize it inside its frame (the // frame's anchors, under Direct Selection, come first). diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 03a19c73a..c216277aa 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -2082,3 +2082,18 @@ fn direct_selection_click_on_one_of_several_selected_frames_selects_only_it() { let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)); assert_eq!(acts, vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [a.0], "content": true}))]); } + +#[test] +fn pulling_a_corner_of_an_unselected_frame_selects_the_frame_not_its_content() { + let mut d = Document::new(&NewDocument::default()); + let a = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0)); + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let p = spread_pt(&l, 200.0, 100.0); + let mut t = create("directSelection"); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)); + assert_eq!( + acts, + vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [a.0], "content": false})), Action::Begin("Move Anchor".into())] + ); +} From 978effab49d06d80510521353974c1081c185637 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 30 Oct 2026 21:24:27 +0000 Subject: [PATCH 198/207] Integration: #902 Direct Selection anchor drags send a spread-space delta --- crates/tools/src/select.rs | 4 +++- crates/tools/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 7f4c75780..d7b8ed48e 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -1095,7 +1095,9 @@ impl SelectionTool { } self.guides = hit.guides; } - let mut params = json!({"id": id, "anchors": [[si, ai]], "dx": dx, "dy": dy}); + // path.moveAnchors takes a spread-space delta. + let delta = at.map_or(Vec2::new(dx, dy), |(spread, _)| cx.layout.delta_to_spread(spread, Vec2::new(dx, dy))); + let mut params = json!({"id": id, "anchors": [[si, ai]], "dx": delta.x, "dy": delta.y}); if let Some(h) = handle { params["handle"] = json!(h); } diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index c216277aa..3b8ea0f75 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -2097,3 +2097,22 @@ fn pulling_a_corner_of_an_unselected_frame_selects_the_frame_not_its_content() { vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [a.0], "content": false})), Action::Begin("Move Anchor".into())] ); } + +#[test] +fn anchor_drag_on_a_rotated_spread_sends_a_spread_space_delta() { + let mut d = Document::new(&NewDocument::default()); + let a = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0)); + Arc::make_mut(&mut d.spreads[0]).pages[0].view_rotation = 1; + let (s, c, l) = (Selection::items(vec![a]), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let p = l.to_canvas(SpreadRef::Doc(0), Point::new(200.0, 100.0)); + let mut t = create("directSelection"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, p.x + 50.0, p.y)); + let [Action::Preview(cmd, params)] = acts.as_slice() else { panic!("{acts:?}") }; + assert_eq!(cmd, "path.moveAnchors"); + let want = l.delta_to_spread(SpreadRef::Doc(0), Vec2::new(50.0, 0.0)); + assert!(want.y.abs() > 1.0, "the view rotation turns a canvas x move into a spread y move: {want:?}"); + let (dx, dy) = (params["dx"].as_f64().unwrap(), params["dy"].as_f64().unwrap()); + assert!((dx - want.x).abs() < 1e-6 && (dy - want.y).abs() < 1e-6, "{params} want {want:?}"); +} From 376cf3325a5f4fb6dfbd36ea4b55ab0d03bc5c6f Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 30 Oct 2026 21:24:39 +0000 Subject: [PATCH 199/207] Integration: #1505 leaving parent pages for a page fits its spread before centring the page --- crates/ui-egui/src/canvas/tests.rs | 14 ++++++++++++++ crates/ui-egui/src/canvas/view.rs | 8 +++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index f36269479..7d89a24a8 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2869,3 +2869,17 @@ fn table_cells_show_their_hidden_characters() { let last = marks.last().unwrap(); assert_eq!((last.0, last.1), (designcraft_geom::Point::new(frag.rect.x1 + 1.0, frag.rect.y1), "¶")); } + +#[test] +fn leaving_parent_editing_for_the_left_page_of_a_spread_keeps_that_page_selected() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap(); + let mut h = harness(app); + h.state_mut().run("layout.parents.edit", json!({"on": true, "parent": "A-Parent"})).unwrap(); + h.run_steps(2); + let b = page_box(h.state()).unwrap(); + // Page 2 is the left page of the second spread. + page_box_go(h.state_mut(), &b, 1); + h.run_steps(3); + assert_eq!(current_page(h.state()), Some(1)); +} diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index b679923d2..2ba0876a1 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -188,9 +188,11 @@ pub fn page_box_go(app: &mut DesignApp, b: &PageBox, i: usize) { if i < b.pages { if app.session.active().is_some_and(|st| st.editing_parents) { let _ = app.run("layout.parents.edit", serde_json::json!({"on": false})); - // The canvas refits to the page's spread once it is centred. - if let Some(v) = app.view_mut() { - v.fitted = false; + // Fit the page's spread first, then centre the page: a later refit would centre the + // spread, and the page box would then show its other page. + let spread = app.session.active().and_then(|st| st.doc.page_loc(i)).map(|(si, _)| SpreadRef::Doc(si)); + if let Some(r) = spread { + show_spread(app, r); } } go_to_page(app, i); From e05c1c5360c46cdf9901366e94b754f36c23c024 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 30 Oct 2026 21:24:39 +0000 Subject: [PATCH 200/207] Integration: #1425 the cursor readout has its own dark text colour on the light grey box --- crates/ui-egui/src/canvas/tool_overlays.rs | 4 +-- crates/ui-egui/src/theme.rs | 30 +++++++++++++++++----- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/canvas/tool_overlays.rs b/crates/ui-egui/src/canvas/tool_overlays.rs index 6fb8a0719..e6beb1e65 100644 --- a/crates/ui-egui/src/canvas/tool_overlays.rs +++ b/crates/ui-egui/src/canvas/tool_overlays.rs @@ -31,10 +31,10 @@ pub(super) fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, x } Overlay::Measure { p, text } => { let s = xf.to_screen(p) + vec2(14.0, 14.0); - let g = painter.layout_no_wrap(text, egui::FontId::proportional(11.0), tok.measure_text); + let g = painter.layout_no_wrap(text, egui::FontId::proportional(11.0), tok.readout_text); let r = Rect::from_min_size(s, g.size() + vec2(10.0, 6.0)); painter.rect_filled(r, 3.0, tok.readout_bg); - painter.galley(r.min + vec2(5.0, 3.0), g, tok.measure_text); + painter.galley(r.min + vec2(5.0, 3.0), g, tok.readout_text); } Overlay::Line { a, b, color, dashed: d } => { let st = Stroke::new(1.0, c32(color)); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index bdc6dae98..8efa2e423 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -84,8 +84,9 @@ pub struct Tokens { pub measure_bg: Color32, pub measure_text: Color32, /// The readout beside the cursor while drawing, moving or measuring ("W: 3.13 cm"): InDesign's - /// light grey box with white text, in every brightness but High Contrast. + /// light grey box, with dark text in every brightness but High Contrast. pub readout_bg: Color32, + pub readout_text: Color32, /// Live-corner widget and corner diamonds. pub live_corner: Color32, /// The Windows caption Close button when hovered, and its glyph. @@ -134,6 +135,7 @@ impl Tokens { measure_bg: Color32::from_rgba_unmultiplied(70, 70, 70, 230), measure_text: Color32::WHITE, readout_bg: hex(0xa8a8a8), + readout_text: hex(0x1e1e1e), live_corner: hex(0xffe500), caption_close: hex(0xc42b1c), caption_close_text: Color32::WHITE, @@ -261,6 +263,7 @@ impl Tokens { tab_strip: hex(0x000000), // White on InDesign's grey is too faint here: black, as the rest of the chrome. readout_bg: hex(0x000000), + readout_text: Color32::WHITE, ..dark }, } @@ -864,16 +867,31 @@ mod russian_font_tests { mod readout_tests { use super::{Brightness, Tokens}; + fn luminance(c: egui::Color32) -> f32 { + let lin = |v: u8| { + let v = v as f32 / 255.0; + if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b()) + } + + fn contrast(a: egui::Color32, b: egui::Color32) -> f32 { + let (x, y) = (luminance(a), luminance(b)); + (x.max(y) + 0.05) / (x.min(y) + 0.05) + } + /// The readout beside the cursor ("W: 3.13 cm" while drawing a frame) is InDesign's light - /// grey box with white text in every brightness, as measured from the tutorials' frames; - /// High Contrast keeps it black. + /// grey box in every brightness, with text that stays readable on it; High Contrast keeps + /// it black. #[test] - fn the_cursor_readout_is_indesigns_light_grey() { + fn the_cursor_readout_is_indesigns_light_grey_with_readable_text() { for b in [Brightness::Dark, Brightness::MediumDark, Brightness::MediumLight, Brightness::Light] { let t = Tokens::for_brightness(b); assert_eq!(t.readout_bg, egui::Color32::from_rgb(0xa8, 0xa8, 0xa8), "{b:?}"); - assert_eq!(t.measure_text, egui::Color32::WHITE, "{b:?}"); + assert!(contrast(t.readout_text, t.readout_bg) >= 4.5, "{b:?}"); } - assert_eq!(Tokens::for_brightness(Brightness::HighContrast).readout_bg, egui::Color32::BLACK); + let hc = Tokens::for_brightness(Brightness::HighContrast); + assert_eq!(hc.readout_bg, egui::Color32::BLACK); + assert!(contrast(hc.readout_text, hc.readout_bg) >= 4.5); } } From ff5d4e1f8e624a5063a78db9b3bd5143fa0ed125 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 30 Oct 2026 21:24:47 +0000 Subject: [PATCH 201/207] Integration: #1509 spelling suggestions include the common words --- crates/engine/src/cmd/spelling.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/spelling.rs b/crates/engine/src/cmd/spelling.rs index c799f9b1f..5e7a425ea 100644 --- a/crates/engine/src/cmd/spelling.rs +++ b/crates/engine/src/cmd/spelling.rs @@ -206,7 +206,7 @@ pub fn suggest(dict: &Dictionary, w: &str) -> Vec { let mut out: Vec = Vec::new(); let mut push = |c: Vec| { let c: String = c.into_iter().collect(); - if c != w && !out.contains(&c) && dict.get(&c).is_some() { + if c != w && !out.contains(&c) && (dict.get(&c).is_some() || COMMON.binary_search(&c.as_str()).is_ok()) { out.push(c); } }; @@ -316,6 +316,7 @@ mod tests { } assert!(!known(d, &[], "typograpy")); assert!(suggest(d, "typograpy").contains(&"typography".to_string())); + assert!(suggest(d, "emial").contains(&"email".to_string()), "words in COMMON are suggested too"); assert!(known(d, &["designcraft".into()], "designcraft")); } From 336eb0593c67052bff67fba2f48fe0464ed17c02 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 30 Oct 2026 21:25:12 +0000 Subject: [PATCH 202/207] Integration: #1394/#1413 ROADMAP: one Status heading, progress log newest first --- ROADMAP.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 471e16039..52bfb0f3b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -46,7 +46,6 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope default layer queries find a placed PDF anywhere in mixed selections. Regression tests cover IDML export after rejection and both selection orders. -## Status (2026-10-01) ## Status (2026-10-21) **Working today** @@ -469,8 +468,6 @@ Newest first. - **2026-10-21** · Preferences › Interface follows InDesign's: Appearance has a Color Theme swatch per interface theme (a click shows it at once, Cancel goes back) and Panels sets the Tools panel to a single or double column; UI Size moves to a UI Scaling section of its own. Interface held only UI Size, and the theme could be changed only from the Window menu. Found in the Learn Skills Daily course, chapter 26. No readiness percentage change. - **2026-10-21** · Preferences › File Handling shows Number of Recent Items to Display as a whole number ("20"; it read "20.0"). Found in the Learn Skills Daily course, chapter 26. No readiness percentage change. - **2026-10-21** · Insert Pages… opens InDesign's dialog: Pages, Insert (After Page, Before Page, At Start of Document, At End of Document) with the page by name, and Parent ([None] or a parent), set to the page in view and its parent. It was a form asking for a page index and a parent id, and pages could not go before the first page; `layout.pages.insert` takes `before` (0: at the start). Found in the Learn Skills Daily course, chapter 25. No readiness percentage change. -- **2026-10-18** · Table of Contents offers More Options / Fewer Options, preserving page-number, separator, sorting and level settings while their controls are hidden. No readiness percentage change. -- **2026-10-18** · Table of Contents reserves the title field's width so Contents is readable next to its style picker; the grid previously squeezed it to a few characters. No readiness percentage change. - **2026-10-21** · Spelling (Check Spelling and Dynamic Spelling) no longer flags everyday words the bundled hyphenation word list lacks: "we", "us", "if", titles such as "Mr", and words such as email, online, website, database, desktop and download, with their plurals and other forms. Contractions typed with a typographer's apostrophe (don’t, we’re), which Use Typographer's Quotes produces by default, count like their straight-apostrophe forms. Dynamic Spelling used to underline most words of an ordinary sentence. Found in the Learn Skills Daily course, chapter 26. No readiness percentage change. - **2026-10-21** · While a parent is shown, the status bar's page box and Properties › Page name it ("A-Parent") instead of page 1, as InDesign's; their lists have the parents after the pages, and choosing one shows that parent (a page leaves the parents). Found in the Learn Skills Daily course, chapter 25. No readiness percentage change. - **2026-10-21** · Effects dialog: the Angle field of the shadows, Bevel and Emboss, Satin and Gradient Feather has InDesign's dial beside it, its radius pointing where the light comes from; a click or drag on it sets the angle. Found in the Learn Skills Daily course, chapter 14. No readiness percentage change. @@ -484,26 +481,25 @@ Newest first. - **2026-10-21** · Properties › Align: the Align To button shows what the objects align to, one glyph each for the selection, the key object, the margins, the page and the spread, as InDesign's. Found in the Learn Skills Daily course, chapter 12. No readiness percentage change. - **2026-10-20** · Object › Corner Options opens a Corner Options dialog laid out as InDesign's: each corner's size and shape, the link button that makes all four the same, and Preview. The Corner link in Properties › Appearance opens it too (it opened a Rectangle size dialog). No readiness percentage change. - **2026-10-20** · The Object menu is in InDesign's order and groups; Paths has Join, Open Path and Close Path (Convert Shape lists only shapes, as InDesign's does) and a Convert Point submenu (Plain, Corner, Smooth, Symmetrical) is added. No readiness percentage change. -- **2026-10-14** · Numbering & Section Options opened on a page that doesn't start a section is New Section, as in InDesign: Start Section on, 1 offered, and the start numbers stay editable (typing one chooses it). No readiness percentage change. - **2026-10-20** · Layout › Create Guides puts its guides on both pages of the spread in view, as InDesign does; "This page" in Apply to narrows them to one page. No readiness percentage change. - **2026-10-21** · Object › Fitting › Clear Frame Fitting Options (`object.clearFittingOptions`): the frames' auto-fit, crop amounts and reference point go back to their defaults, and the picture stays where it is. The Fitting submenu is grouped as InDesign's. Found in the Learn Skills Daily course, chapter 11. No readiness percentage change. - **2026-10-21** · Swatches panel rows show InDesign's columns, drawn in code: a crossed pencil on the swatches that can't be edited ([None], [Registration], [Black]), the colour type (process or spot) and the colour mode (CMYK, RGB, Gray, or [None]'s and [Registration]'s own glyphs), in place of a lock and "CMYK" text. Tint reads 100 with nothing selected. No readiness percentage change. - **2026-10-21** · With a gradient fill or stroke, the Color panel shows the stop selected in the Gradient panel (the stop before a selected midpoint) with its values, and its C/M/Y/K or R/G/B sliders and spectrum colour that stop, keeping the others, as in InDesign. It showed "[None] — choose a colour on the ramp", and a spectrum click replaced the whole gradient with a flat colour. Found in the Learn Skills Daily course, chapter 24. No readiness percentage change. - **2026-10-20** · A new document's Swatches panel lists InDesign's defaults: [None], [Registration], [Paper], [Black] and six process colours (C=100, M=100, Y=100, C=15 M=100 Y=100, C=75 M=5 Y=100, C=100 M=90 Y=10); the extra M=100 Y=100, C=100 Y=100 and C=100 M=100 swatches are gone. No readiness percentage change. - **2026-10-21** · The readout beside the cursor while drawing or measuring ("W: 3.13 cm") is InDesign's light grey box with white text, measured from the tutorials' frames; High Contrast keeps it black. It was a dark translucent box. No readiness percentage change. -- **2026-10-14** · The Direct Selection tool pressed on a corner or anchor of an object that isn't selected selects it and pulls just that point, as in InDesign (it moved the whole object). No readiness percentage change. -- **2026-10-16** · A Direct Selection click on an image whose frame is already selected selects the image, so Delete removes only the image and leaves the empty frame, as in InDesign. No readiness percentage change. - **2026-10-20** · Drawing a frame or shape shows its width and height in the document's ruler units, to two decimals and on two lines ("W: 6.08 cm", "H: 6.08 cm"), as InDesign does; it was always whole points. No readiness percentage change. - **2026-10-20** · Type tool: a drag that starts in an empty frame draws a new text frame over it, as in InDesign; a click still turns the frame into a text frame. No readiness percentage change. - **2026-10-20** · Measures in centimetres show four decimals, as InDesign's fields do (a 1p gutter reads 0.4233 cm, not 0.423 cm). No readiness percentage change. -- **2026-10-15** · Anchored objects wrap their own story as their anchor lines are set, as in InDesign: the layout no longer depends on where an earlier composition put them (a long story whose pictures moved between frames from pass to pass never settled). No readiness percentage change. - **2026-10-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. - **2026-10-21** · The text Control panel has InDesign's two modes: A (Character Formatting Controls: font and style, size and leading, All Caps/Superscript/Underline and Small Caps/Subscript/Strikethrough, kerning and tracking, vertical and horizontal scale, baseline shift and skew, character style and language) and ¶ (Paragraph Formatting Controls: the nine alignments, left/first line and right/last line indents, space before and after, the drop cap's lines and characters, paragraph style, Hyphenate and Align to Baseline Grid). It used to show a fixed mix of both. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. - **2026-10-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change. - **2026-10-21** · The ¶ (Paragraph Formatting Controls) mode of the text Control panel has InDesign's Bulleted List and Numbered List buttons (with the default hanging indent) and Shading and Border: a checkbox, a button opening Paragraph Borders and Shading on that tab, and the colour. Found in the Learn Skills Daily course, chapter 21. No readiness percentage change. - **2026-10-21** · The Bulleted and Numbered List buttons (Properties, and Type › Bulleted & Numbered Lists › Apply Bullets / Apply Numbers) work as InDesign's: a paragraph with no indents gets the default hanging indent (0.25 in, −0.25 in), so the text lines up after the bullet instead of at the next default tab stop, and pressing the button again removes the list and that indent (`list.bullets`, `list.numbers`). Found in the Learn Skills Daily course, chapter 21. No readiness percentage change. +- **2026-10-18** · Table of Contents offers More Options / Fewer Options, preserving page-number, separator, sorting and level settings while their controls are hidden. No readiness percentage change. +- **2026-10-18** · Table of Contents reserves the title field's width so Contents is readable next to its style picker; the grid previously squeezed it to a few characters. No readiness percentage change. - **2026-10-17** · Inline and anchored objects paint under their frame's text, on screen and in PDF, as InDesign draws them: a picture whose frame reaches into the next line (a negative Y offset) no longer hides the top of that line. No readiness percentage change. - **2026-10-17** · Running headers: *first/last on page* read a page in text order (story order, then column by column), not by height — a multi-column page's right header showed the lowest entry of column 1 instead of the page's last entry. No readiness percentage change. +- **2026-10-16** · A Direct Selection click on an image whose frame is already selected selects the image, so Delete removes only the image and leaves the empty frame, as in InDesign. 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. @@ -516,6 +512,7 @@ Newest first. - **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. - **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change. - **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change. +- **2026-10-15** · Anchored objects wrap their own story as their anchor lines are set, as in InDesign: the layout no longer depends on where an earlier composition put them (a long story whose pictures moved between frames from pass to pass never settled). No readiness percentage change. - **2026-10-15** · Color Theme: the toolbar tool previews up to five colours from a placed raster and adds them as one undoable swatch group. Local deterministic extraction; transparent padding is ignored. No readiness percentage change. - **2026-10-15** · Applying text styles removes matching local formatting so capture sources inherit later edits; real overrides and named character styles remain. @@ -575,6 +572,8 @@ Newest first. - **2026-10-15** · Flex Layout (InDesign 2026): Object › Flex Layout (Add to, Convert to, Remove) and a Flex Layout panel laid out as InDesign's, also in Properties. A container lays out its objects by direction, wrap, justification, the alignment grid, Fixed/Auto width and height, padding and spacing, after every edit; Add detects aligned objects (direction, Auto size, Space Between at their spacing); resizing a container reflows its objects instead of scaling them; dragging an object in one reorders it. Montilla Design's tutorial replays step for step. No readiness percentage change. - **2026-10-15** · IDML: the CJK leading model InDesign writes on [No paragraph style] of every document (Aki Below) only applies when the document uses a Japanese composer, as in InDesign; a Western document's paragraphs after a size change no longer sit lower than in InDesign (Montilla Design's Flex Layout file: its captions came out overset). No readiness percentage change. - **2026-10-15** · IDML: linked images in a subfolder beside the package load when the package is opened on another machine (a link to `/images/logo.png` finds `images/logo.png` next to the .idml); they showed as missing. No readiness percentage change. +- **2026-10-14** · Numbering & Section Options opened on a page that doesn't start a section is New Section, as in InDesign: Start Section on, 1 offered, and the start numbers stay editable (typing one chooses it). No readiness percentage change. +- **2026-10-14** · The Direct Selection tool pressed on a corner or anchor of an object that isn't selected selects it and pulls just that point, as in InDesign (it moved the whole object). No readiness percentage change. - **2026-10-14** · Overset text is marked on every frame that ends an overset story, selected or not and inside groups too (the red mark at the out port, with frame edges shown); a selected group shows the in/out ports of the text frames inside it. InDesign shows it only on the selected frame, which hides overset labels in grouped objects until Preflight. No readiness percentage change. - **2026-10-14** · Load Swatches adds the colors of an `.ase` file that repeat a name or have none: a repeated name gets a number (`Red 2`), and a color without a name is named by its value (`C=0 M=0 Y=0 K=55`). Before, a repeated name kept one of its colors, and only the first unnamed color was added, with an empty name. No readiness percentage change. - **2026-10-14** · Objects snap to the bleed guide's four sides while moving, resizing or drawing; snapping and the canvas share one bleed rectangle (`Spread::bleed_rect`). No readiness percentage change. From 14eb9f0be6a7311d6da9a663346a9fb959afad1e Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 30 Oct 2026 21:25:23 +0000 Subject: [PATCH 203/207] Integration: #1285/#1286 ui-parity: TOC dialog notes in the body, revision-history row, header --- docs/ui-parity.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b52c7e15a..199bfd45c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Story panel; text Control panel Character/Paragraph modes; Properties ••• expands Character and Paragraph; live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Table of Contents dialog: title field keeps its width, More Options toggles without losing hidden settings; Story panel; text Control panel Character/Paragraph modes; Properties ••• expands Character and Paragraph; live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) Tools, handles, snapping, nudging, modifiers, shortcuts, panels, menus, context menus and feel, against InDesign 2026. The look was measured from the running app @@ -68,11 +68,14 @@ focused text field, and does not interrupt an existing tool drag. Synthetic canv cover the cursors, click anchors, scrub/release ownership, restoration, typing guards and undo history (DC-1). -## Remaining effort - The Table of Contents title field reserves its edit width inside the title/style row. The default Contents title is fully visible instead of being clipped to its first few characters. +The Table of Contents dialog can collapse to its entry-style controls or expand to show page +number, separator, sorting and level controls. Toggling visibility preserves their settings. + +## Remaining effort + Applying a paragraph or character style now removes local values equal to the applied style, so text used to capture a style follows later edits to that style. Different local formatting and named character styles stay intact. Paragraph-style application treats selection endpoints @@ -125,12 +128,10 @@ covers checking the sampled and redefined settings in the styles tutorial (2:45 | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Dialogs: Table of Contents title field keeps its edit width; More Options toggles without losing hidden settings | | 2026-10-24 | minor | Panels row: Story panel (Optical Margin Alignment and its size) | | 2026-10-21 | minor | Chrome row: the text Control panel's A/¶ modes; Properties row: ••• expands Character and Paragraph in place | | 2026-10-11 | minor | Live corners row to ✓: widget, diamonds, drag, Shift-drag, Alt-click shape cycling (#186) | | 2026-10-11 | minor | Added held Cmd+Space / Cmd+Alt+Space Zoom navigation and canvas input regression tests | | 2026-10-10 | minor | Threading row corrected to ✗ after checking the code (no out-port interaction, no thread command) | | 2026-10-10 | major | Created: measured menu coverage, 27-row interaction checklist from the code and issues | - -The Table of Contents dialog can collapse to its entry-style controls or expand to show page -number, separator, sorting and level controls. Toggling visibility preserves their settings. From 0ca30e1408f54aeaa27aa9549fac2228ec4c5e82 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 30 Oct 2026 21:29:34 +0000 Subject: [PATCH 204/207] Integration: #1496 Find/Change: a null attribute with an empty Change formats the found text instead of deleting it --- crates/engine/src/cmd/find.rs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index e356bcac8..473b2d622 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -355,6 +355,9 @@ fn change(s: &mut Session, p: &Value) -> Result { let first_only = bool_or(p, "first", false); let attrs = p.get("attrs").cloned(); let over = char_overrides(attrs.as_ref())?; + // A null attribute clears that override on the found text. + let cleared: Vec = + attrs.as_ref().and_then(Value::as_object).map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default(); super::text::validate_char_fill(&s.doc()?.doc, &over, "find.change")?; let char_style = str_param(p, "charStyle").map(str::to_string); let para_style = str_param(p, "paraStyle").map(str::to_string); @@ -366,7 +369,7 @@ fn change(s: &mut Session, p: &Value) -> Result { return Err(bad("find.change", format!("no paragraph style `{n}`"))); } // InDesign: with a Change Format and nothing in Change to, the found text is only formatted. - let format_only = str_param(p, "change").unwrap_or("").is_empty() && (char_style.is_some() || para_style.is_some() || !over.is_empty()); + let format_only = str_param(p, "change").unwrap_or("").is_empty() && (char_style.is_some() || para_style.is_some() || !over.is_empty() || !cleared.is_empty()); let targets = scope(s, p)?.targets; s.edit(|d, sel| { let styles = d.styles.clone(); @@ -401,8 +404,13 @@ fn change(s: &mut Session, p: &Value) -> Result { if let Some(n) = &char_style { super::style::apply_char_style(st, &styles, n, &changed); } - if !over.is_empty() { - st.format_chars(changed, |f| f.over.merge(&over)); + if !over.is_empty() || !cleared.is_empty() { + st.format_chars(changed, |f| { + f.over.merge(&over); + for k in &cleared { + let _ = f.over.set_json(k, &Value::Null); + } + }); } } count += 1; @@ -582,6 +590,12 @@ Three" assert!(s.execute("find.change", &json!({"find": "eggs", "change": "", "charStyle": "Nope"})).is_err()); assert!(s.execute("find.change", &json!({"find": "eggs", "change": "", "paraStyle": "Nope"})).is_err()); assert_eq!(s.doc().unwrap().doc, before); + // A null attribute is a Change Format too: the found text stays and loses that override. + let before = s.doc().unwrap().doc.story(sid).unwrap().clone(); + s.execute("find.change", &json!({"find": "cook", "change": "", "attrs": {"underline": null}})).unwrap(); + let story = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert_eq!(story.text, before.text, "the words stay"); + assert_eq!(story.format_after(25).over.underline, None); // With no Change Format, an empty Change to still deletes the found text. s.execute("find.change", &json!({"find": "Break ", "change": ""})).unwrap(); assert!(!s.doc().unwrap().doc.story(sid).unwrap().text.contains("Break")); From 2ecb85ff97928902a92dca25d62f975a5d0f9d0b Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 30 Oct 2026 21:29:34 +0000 Subject: [PATCH 205/207] Integration: #1674 text.move leaves the selected cells, so typing inserts at the caret --- crates/engine/src/cmd/text/caret.rs | 2 ++ crates/engine/src/tests/table.rs | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/crates/engine/src/cmd/text/caret.rs b/crates/engine/src/cmd/text/caret.rs index 670de4049..0621e30de 100644 --- a/crates/engine/src/cmd/text/caret.rs +++ b/crates/engine/src/cmd/text/caret.rs @@ -380,6 +380,8 @@ pub(super) fn move_caret(s: &mut Session, p: &Value) -> Result { s.typing_format = None; let stm = s.doc_mut()?; stm.selection.text = Some(if extend { TextSel { focus: new, ..t } } else { TextSel { anchor: new, focus: new, ..t } }); + // Moving the caret leaves the selected cells, so typing inserts at the caret. + stm.selection.cells = None; stm.bump_revision(); Ok(json!({"pos": new})) } diff --git a/crates/engine/src/tests/table.rs b/crates/engine/src/tests/table.rs index 45e4b8db8..770581fa6 100644 --- a/crates/engine/src/tests/table.rs +++ b/crates/engine/src/tests/table.rs @@ -176,6 +176,12 @@ fn escape_toggles_text_and_cell_selection() { let sel = s.doc().unwrap().selection.clone(); assert!(sel.cells.is_none()); assert_eq!(sel.text.unwrap().range(), 3..3); + // Moving the caret leaves the cell selection: typing inserts at the caret, not over the cell. + s.execute("table.toggleSelection", &json!({})).unwrap(); + s.execute("text.move", &json!({"dir": "right"})).unwrap(); + assert!(s.doc().unwrap().selection.cells.is_none()); + s.execute("text.insert", &json!({"text": "!"})).unwrap(); + assert_eq!(s.execute("table.get", &json!({"table": tid})).unwrap()["cells"][1]["text"], "F!ax"); // A selection that starts inside a merged cell goes back to the merged cell's text. s.execute("table.select", &json!({"rows": [0, 1], "cols": [0, 0]})).unwrap(); s.execute("table.merge", &json!({})).unwrap(); From 7bad69c14a77f0b8e37de04d0617a04ebe034c68 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 30 Oct 2026 21:33:58 +0000 Subject: [PATCH 206/207] Integration: cargo fmt --- crates/engine/src/cmd/find.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index 473b2d622..aa6475f26 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -356,8 +356,11 @@ fn change(s: &mut Session, p: &Value) -> Result { let attrs = p.get("attrs").cloned(); let over = char_overrides(attrs.as_ref())?; // A null attribute clears that override on the found text. - let cleared: Vec = - attrs.as_ref().and_then(Value::as_object).map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default(); + let cleared: Vec = attrs + .as_ref() + .and_then(Value::as_object) + .map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()) + .unwrap_or_default(); super::text::validate_char_fill(&s.doc()?.doc, &over, "find.change")?; let char_style = str_param(p, "charStyle").map(str::to_string); let para_style = str_param(p, "paraStyle").map(str::to_string); @@ -369,7 +372,8 @@ fn change(s: &mut Session, p: &Value) -> Result { return Err(bad("find.change", format!("no paragraph style `{n}`"))); } // InDesign: with a Change Format and nothing in Change to, the found text is only formatted. - let format_only = str_param(p, "change").unwrap_or("").is_empty() && (char_style.is_some() || para_style.is_some() || !over.is_empty() || !cleared.is_empty()); + let format_only = + str_param(p, "change").unwrap_or("").is_empty() && (char_style.is_some() || para_style.is_some() || !over.is_empty() || !cleared.is_empty()); let targets = scope(s, p)?.targets; s.edit(|d, sel| { let styles = d.styles.clone(); From 75797612cd546afdd2c356b7f83442bf5f9b0e27 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 30 Oct 2026 20:12:11 -0400 Subject: [PATCH 207/207] fix(flatpak): allow reading launcher version pins (#2059) * fix(flatpak): allow reading launcher version pins * fix(flatpak): allow reading launcher version pins --- packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml | 1 + packaging/linux/flatpak/ai.storyteller.designcraft.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml index 09ac37d73..a18f2c306 100644 --- a/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml @@ -17,6 +17,7 @@ runtime-version: '25.08' sdk: org.freedesktop.Sdk command: designcraft finish-args: + - --filesystem=xdg-data/ArtCraft/versions:ro # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland - --socket=fallback-x11 diff --git a/packaging/linux/flatpak/ai.storyteller.designcraft.yml b/packaging/linux/flatpak/ai.storyteller.designcraft.yml index bd456579f..fd56910ce 100644 --- a/packaging/linux/flatpak/ai.storyteller.designcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.designcraft.yml @@ -18,6 +18,7 @@ sdk-extensions: - org.freedesktop.Sdk.Extension.rust-stable command: designcraft finish-args: + - --filesystem=xdg-data/ArtCraft/versions:ro # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland - --socket=fallback-x11