From 080534b09af1e40e322c73b2df21b8b72456aafc Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 13:53:19 +0200 Subject: [PATCH 01/41] Panel tabs: InDesign's Close, Close Tab Group, Minimize and Collapse to Icons Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/i18n.rs | 6 ++ crates/ui-egui/src/i18n/ar.rs | 3 + crates/ui-egui/src/i18n/de.rs | 3 + crates/ui-egui/src/i18n/it.rs | 3 + crates/ui-egui/src/i18n/ja.rs | 3 + crates/ui-egui/src/i18n/ka.rs | 3 + crates/ui-egui/src/i18n/pt_br.rs | 3 + crates/ui-egui/src/i18n/uk.rs | 3 + crates/ui-egui/src/menus.rs | 7 ++ crates/ui-egui/src/panel_docking.rs | 106 ++++++++++++++++++++++ crates/ui-egui/src/panel_docking_tests.rs | 53 +++++++++++ 12 files changed, 194 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf4..8398c9d0 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** · A panel tab's context menu (and the end of its panel menu) has InDesign's Close, Close Tab Group, Minimize and Collapse to Icons, before Float Panel and Group with; `window.panel.closeGroup` and `window.collapseToIcons` are commands. 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/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 245a6e28..49757b51 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -58,6 +58,12 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ( + "Close Tab Group", + ["Registerkartengruppe schließen", "Fermer le groupe d'onglets", "Cerrar grupo de fichas", "タブグループを閉じる", "关闭选项卡组"], + ), + ("Minimize", ["Minimieren", "Réduire", "Minimizar", "最小化", "最小化"]), + ("Collapse to Icons", ["Auf Symbole minimieren", "Réduire en icônes", "Contraer a iconos", "アイコンパネルに折りたたむ", "折叠为图标"]), ( "Bulleted & Numbered Lists", [ diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index d1dcb7ac..60e8d9ce 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", "لصق"), + ("Close Tab Group", "إغلاق مجموعة علامات التبويب"), + ("Minimize", "تصغير"), + ("Collapse to Icons", "طي إلى أيقونات"), ("Bulleted & Numbered Lists", "القوائم ذات التعداد النقطي والرقمي"), ("Current Page Number", "رقم الصفحة الحالية"), ("Apply Color (,)", "تطبيق اللون (,)"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 0f55578b..a035bf4a 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -30,6 +30,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("Close Tab Group", "Registerkartengruppe schließen"), + ("Minimize", "Minimieren"), + ("Collapse to Icons", "Auf Symbole minimieren"), ("Clear", "Löschen"), ("Duplicate", "Duplizieren"), ("Select All", "Alles auswählen"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 00328ac9..4755d573 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"), + ("Close Tab Group", "Chiudi gruppo schede"), + ("Minimize", "Minimizza"), + ("Collapse to Icons", "Riduci a icone"), ("Bulleted & Numbered Lists", "Elenchi puntati e numerati"), ("Current Page Number", "Numero di pagina corrente"), ("Apply Color (,)", "Applica colore (,)"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index fe9e213e..9f6ce92d 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Close Tab Group", "タブグループを閉じる"), + ("Minimize", "最小化"), + ("Collapse to Icons", "アイコンパネルに折りたたむ"), ("Bulleted & Numbered Lists", "箇条書き"), ("Apply Color (,)", "カラーを適用 (,)"), ("Apply Gradient (.)", "グラデーションを適用 (.)"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index b3379c6d..40c50236 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", "ჩასმა"), + ("Close Tab Group", "ჩანართების ჯგუფის დახურვა"), + ("Minimize", "ჩაკეცვა"), + ("Collapse to Icons", "ხატულებად ჩაკეცვა"), ("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 0675a2f6..5fd7aaa6 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"), + ("Close Tab Group", "Fechar grupo de guias"), + ("Minimize", "Minimizar"), + ("Collapse to Icons", "Recolher para ícones"), ("Bulleted & Numbered Lists", "Listas com marcadores e numeradas"), ("Show Hidden Characters", "Mostrar caracteres ocultos"), ("Current Page Number", "Número da página atual"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 8f1d27f8..42274d83 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,6 +31,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Close Tab Group", "Закрити групу вкладок"), + ("Minimize", "Згорнути"), + ("Collapse to Icons", "Згорнути до піктограм"), ("Bulleted & Numbered Lists", "Марковані й нумеровані списки"), ("Current Page Number", "Номер поточної сторінки"), ("Apply Color (,)", "Застосувати колір (,)"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a35261d5..855b25be 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -181,6 +181,8 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("window.panel.dock", "Dock Panel", None, "{panel, anchor?, zone?, before?} — return a floating panel to a dock group"), ("window.panel.activate", "Activate Panel", None, "{panel} — show and select a panel in its current group"), ("window.panel.close", "Close Panel", None, "{panel} — hide a panel"), + ("window.panel.closeGroup", "Close Tab Group", None, "{panel} — hide the panel and the panels tabbed with it"), + ("window.collapseToIcons", "Collapse to Icons", None, "{} — the panel dock shrinks to its column of icons"), ("window.dockPanel", "Dock Panel", None, "{panel} — back into the dock's icon column"), ("window.controlBar", "Control", Some("Cmd+Alt+6"), "{}"), // Object › Fitting with InDesign's shortcuts (one `object.fit` mode each). @@ -1557,6 +1559,11 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option flag(&mut app.ui.tools_double_column), + "window.collapseToIcons" => { + app.ui.dock_expanded = false; + app.ui.open_panel = None; + Ok(Value::Null) + } "window.newWorkspace" => { let Some(name) = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()) else { app.ui.dialog = Some(crate::dialogs::Dialog::new("newWorkspace", json!({"name": ""}))); diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 05fc1150..e7adb631 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -69,6 +69,56 @@ fn legacy(app: &DesignApp) -> Layout { layout } +/// The panels tabbed with `panel`: its floating group, or its tab group in the dock. A panel in +/// an accordion, or not in the layout, is a group of its own. +fn tab_group(layout: &Layout, panel: &str) -> Vec { + if let Some(group) = layout.floating.iter().find(|g| g.panels.iter().any(|p| p == panel)) { + return group.panels.clone(); + } + let mut visit: Vec<&Node> = layout.root.iter().collect(); + let mut seen = 0usize; + while let Some(node) = visit.pop() { + seen = seen.saturating_add(1); + if seen > 2 * craft_ui::docking::MAX_PANELS { + break; + } + match node { + Node::Split { first, second, .. } => { + visit.push(first); + visit.push(second); + } + Node::Tabs { panels, .. } if panels.iter().any(|p| p == panel) => return panels.clone(), + _ => {} + } + } + vec![panel.to_string()] +} + +/// Is `panel` an open entry of an accordion (which Minimize closes)? +fn open_in_stack(layout: &Layout, panel: &str) -> bool { + let mut visit: Vec<&Node> = layout.root.iter().collect(); + let mut seen = 0usize; + while let Some(node) = visit.pop() { + seen = seen.saturating_add(1); + if seen > 2 * craft_ui::docking::MAX_PANELS { + break; + } + match node { + Node::Split { first, second, .. } => { + visit.push(first); + visit.push(second); + } + Node::Stack { entries } => { + if let Some(e) = entries.iter().find(|e| e.panel == panel) { + return e.open; + } + } + Node::Tabs { .. } => {} + } + } + false +} + fn first_root(layout: &Layout, except: &str) -> Option { let root = Layout { root: layout.root.clone(), floating: Vec::new() }; root.panels().into_iter().find(|p| p.as_str() != except).cloned() @@ -116,6 +166,9 @@ pub(crate) fn command(app: &mut DesignApp, id: &str, params: &Value) -> Option for PanelContent<'_> { .show(ui, |ui| crate::dock::panel_body(self.0, ui, id)); }); } + fn panel_menu(&mut self, ui: &mut egui::Ui, id: &String) { + // The docking surface adds Float, Group with and Close after these. + if let Some(panel) = normalize(id) { + tab_commands(self.0, ui, panel, false); + ui.separator(); + } + } fn label(&mut self, ui: &egui::Ui, _: &String, logical: &str, font: &egui::FontId, color: egui::Color32) -> egui::WidgetText { egui::WidgetText::Galley(crate::rtl::plain(ui.ctx(), logical, font.clone(), color)) } @@ -494,6 +556,50 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { } } +/// Close Tab Group: `panel` and the panels tabbed with it, each restorable from Window as one +/// closed with Close. +fn close_group(app: &mut DesignApp, params: &Value) -> Result { + let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?; + let layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); + let group = tab_group(&layout, panel); + for p in &group { + change(app, "close", &json!({"panel": p}))?; + } + Ok(json!({"closed": group})) +} + +/// InDesign's commands on a panel tab (and at the end of its panel menu): Close, Close Tab Group, +/// Minimize (an open accordion entry) and Collapse to Icons. `close` is false where the docking +/// surface adds its own Close. +fn tab_commands(app: &mut DesignApp, ui: &mut egui::Ui, panel: &'static str, close: bool) { + let language = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&language, s).to_owned(); + let run = |app: &mut DesignApp, ui: &mut egui::Ui, id: &str, p: Value| { + if let Err(error) = app.run(id, p) { + app.status(error); + } + ui.close(); + }; + if close && ui.button(crate::rtl::widget(ui, tr("Close"))).clicked() { + run(app, ui, "window.panel.close", json!({"panel": panel})); + } + if ui.button(crate::rtl::widget(ui, tr("Close Tab Group"))).clicked() { + run(app, ui, "window.panel.closeGroup", json!({"panel": panel})); + } + let layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); + let minimizable = open_in_stack(&layout, panel); + if ui.add_enabled(minimizable, egui::Button::new(crate::rtl::widget(ui, tr("Minimize")))).clicked() { + if let Err(error) = apply_action(app, Action::SetStackOpen { panel: panel.to_string(), open: false }) { + app.status(error); + } + ui.close(); + } + ui.separator(); + if ui.add_enabled(app.ui.dock_expanded, egui::Button::new(crate::rtl::widget(ui, tr("Collapse to Icons")))).clicked() { + run(app, ui, "window.collapseToIcons", json!({})); + } +} + /// Close the last explicitly opened panel through the same validated layout transaction. pub(crate) fn close_visible(app: &mut DesignApp) -> Result<(), String> { if app.ui.docking.is_some() { diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index fa1b3a8f..05f09127 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -650,3 +650,56 @@ fn legacy_dock_menu_button_opens_the_active_panel_menu() { assert_eq!(h.state().session.active().unwrap().doc.page_count(), pages + 1); assert!(!egui::Popup::is_any_open(&h.ctx)); } + +/// InDesign's panel tab menu (Learn Skills Daily's InDesign course, 6:20): Close, Close Tab Group, +/// Minimize and Collapse to Icons. Close Tab Group hides the panels tabbed together. +#[test] +fn close_tab_group_hides_the_panels_tabbed_together() { + let mut app = app(); + // The default dock: Properties, Pages and Layers are one tab group. + let r = app.run("window.panel.closeGroup", json!({"panel":"pages"})).unwrap(); + assert_eq!(r["closed"], json!(["properties", "pages", "layers"])); + let layout = app.ui.docking.clone().unwrap(); + assert!(["properties", "pages", "layers"].iter().all(|p| !layout.contains(&p.to_string()))); + // Window brings one back. + app.run("window.panel.activate", json!({"panel":"pages"})).unwrap(); + assert!(app.ui.docking.as_ref().unwrap().contains(&"pages".to_string())); + // A floating group goes as a whole; the dock keeps its own. + app.run("window.panel.float", json!({"panel":"swatches","x":40,"y":60})).unwrap(); + app.run("window.panel.activate", json!({"panel":"color"})).unwrap(); + app.run("window.panel.move", json!({"panel":"color", "anchor":"swatches"})).unwrap(); + app.run("window.panel.closeGroup", json!({"panel":"color"})).unwrap(); + let layout = app.ui.docking.clone().unwrap(); + assert!(!layout.contains(&"color".to_string()) && !layout.contains(&"swatches".to_string())); + assert!(layout.contains(&"pages".to_string())); + assert!(app.run("window.panel.closeGroup", json!({"panel":"nope"})).is_err()); +} + +#[test] +fn collapse_to_icons_shrinks_the_dock_to_its_icons() { + let mut app = app(); + assert!(app.ui.dock_expanded); + app.run("window.collapseToIcons", json!({})).unwrap(); + assert!(!app.ui.dock_expanded); + // A panel chosen from Window opens the dock again. + app.run("window.panel", json!({"panel":"pages"})).unwrap(); + assert!(app.ui.dock_expanded); +} + +#[test] +fn a_tabs_context_menu_lists_indesigns_tab_commands_and_close_works() { + let mut h = harness(app(), vec2(1280.0, 800.0)); + let tab = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); + h.event(Event::PointerMoved(tab)); + h.run_steps(1); + h.event(Event::PointerButton { pos: tab, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(Event::PointerButton { pos: tab, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + for item in ["Close", "Close Tab Group", "Minimize", "Collapse to Icons", "Float Panel"] { + assert!(h.query_by_label(item).is_some(), "{item}"); + } + h.get_by_label("Close").click(); + h.run_steps(2); + assert!(!h.state().ui.docking.as_ref().unwrap().contains(&"layers".to_string())); +} From 4e7c49b36b9054074a0d5101a58858e65550ede9 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 14:04:38 +0200 Subject: [PATCH 02/41] Window > Paragraph Styles opens the floating Character Styles | Paragraph Styles group Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/menus.rs | 4 +- crates/ui-egui/src/panel_docking.rs | 93 +++++++++++++++++++++++ crates/ui-egui/src/panel_docking_tests.rs | 25 ++++++ 4 files changed, 122 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf4..45bd6671 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** · Window › Styles › Paragraph Styles (or Character Styles) opens InDesign's floating Character Styles | Paragraph Styles group, which stays open while other panels open; it was a flyout that the next panel replaced. 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/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a35261d5..a5261a15 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -3908,8 +3908,10 @@ mod tests { app.run("file.new", json!({})).unwrap(); activate(&mut app, "app.characterPanel", &Value::Null); assert_eq!(app.ui.open_panel.as_deref(), Some("character")); + // Paragraph Styles opens floating, grouped with Character Styles, as in InDesign. activate(&mut app, "app.paragraphStylesPanel", &Value::Null); - assert_eq!(app.ui.open_panel.as_deref(), Some("paragraphStyles")); + let layout = app.ui.docking.as_ref().unwrap(); + assert!(layout.floating.iter().any(|g| g.panels == ["characterStyles", "paragraphStyles"] && g.active == 1)); let story = app.run("frame.create", json!({"rect": [36, 36, 200, 100], "content": "text", "text": "Page "})).unwrap()["story"].clone(); app.run("text.select", json!({"story": story, "anchor": 5, "focus": 5})).unwrap(); activate(&mut app, "app.insertCurrentPageNumber", &Value::Null); diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 05fc1150..040669b9 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -116,6 +116,11 @@ pub(crate) fn command(app: &mut DesignApp, id: &str, params: &Value) -> Option) -> Result) -> Vec { + let mut out = Vec::new(); + let mut visit: Vec<&Node> = layout.root.iter().collect(); + while let Some(node) = visit.pop() { + if out.len() > craft_ui::docking::MAX_PANELS { + break; + } + match node { + Node::Split { first, second, .. } => { + visit.push(first); + visit.push(second); + } + Node::Tabs { panels, active } => out.extend(panels.get(*active).cloned()), + Node::Stack { .. } => {} + } + } + out +} + +/// Bring each of `front` back in front of the dock tab group that holds it. +fn restore_front_tabs(layout: &mut Layout, front: &[String]) { + let mut visit: Vec<&mut Node> = layout.root.iter_mut().collect(); + let mut seen = 0usize; + while let Some(node) = visit.pop() { + seen = seen.saturating_add(1); + if seen > 2 * craft_ui::docking::MAX_PANELS { + break; + } + match node { + Node::Split { first, second, .. } => { + visit.push(first); + visit.push(second); + } + Node::Tabs { panels, active } => { + if let Some(i) = panels.iter().position(|p| front.contains(p)) { + *active = i; + } + } + Node::Stack { .. } => {} + } + } +} + +/// Panels InDesign opens together as one floating group from the Window menu, in tab order: +/// Window › Styles › Paragraph Styles (or Character Styles) brings both. +const FLOATING_GROUPS: &[&[&str]] = &[&["characterStyles", "paragraphStyles"]]; + +/// Window › a panel of a [`FLOATING_GROUPS`] group none of whose panels is showing: the group +/// opens floating over the right of the canvas, with that panel in front. None otherwise (the +/// panel opens as before). +fn open_floating_group(app: &mut DesignApp, params: &Value) -> Option> { + let panel = params.get("panel").and_then(Value::as_str).and_then(normalize)?; + let group = FLOATING_GROUPS.iter().find(|g| g.contains(&panel))?; + // Panels in the workspace's icon groups open from there. + if app.ui.icon_groups.as_ref().is_some_and(|g| g.iter().flatten().any(|p| group.contains(&p.as_str()))) { + return None; + } + let layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); + if group.iter().any(|p| layout.contains(&p.to_string())) { + return None; + } + let (w, h) = (290.0, 340.0); + let (x, y) = app.canvas_rect.map_or((400.0, 140.0), |r| ((r.right() - w - 24.0).max(r.left()), r.top() + 96.0)); + let before = (app.ui.docking.clone(), app.ui.docking_hidden.clone(), app.ui.floating.clone(), app.ui.open_panel.clone()); + // The dock's tab in front stays in front (opening a panel passes through the dock). + let front = front_tabs(&layout); + let open = |app: &mut DesignApp| -> Result { + let first = group.first().ok_or("empty panel group")?; + change(app, "float", &json!({"panel": first, "x": x, "y": y, "width": w, "height": h}))?; + for p in group.get(1..).unwrap_or_default() { + change(app, "move", &json!({"panel": p, "anchor": first}))?; + } + change(app, "activate", &json!({"panel": panel})) + }; + let result = open(app); + if result.is_ok() + && let Some(layout) = app.ui.docking.as_mut() + { + restore_front_tabs(layout, &front); + } + if result.is_err() { + // All or nothing. + (app.ui.docking, app.ui.docking_hidden, app.ui.floating, app.ui.open_panel) = before; + } + Some(result) +} + fn change(app: &mut DesignApp, operation: &str, params: &Value) -> Result { let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?; if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) { diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index fa1b3a8f..3666345f 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -650,3 +650,28 @@ fn legacy_dock_menu_button_opens_the_active_panel_menu() { assert_eq!(h.state().session.active().unwrap().doc.page_count(), pages + 1); assert!(!egui::Popup::is_any_open(&h.ctx)); } + +/// Window › Styles › Paragraph Styles opens InDesign's floating Character Styles | Paragraph Styles +/// group, which stays open while other panels open (Learn Skills Daily's InDesign course, 7:55). +#[test] +fn paragraph_styles_open_floating_with_character_styles() { + let mut app = app(); + app.run("window.panel", json!({"panel":"paragraphStyles"})).unwrap(); + let layout = app.ui.docking.clone().unwrap(); + let group = layout.floating.iter().find(|g| g.panels.iter().any(|p| p == "paragraphStyles")).expect("a floating group"); + assert_eq!(group.panels, ["characterStyles", "paragraphStyles"]); + assert_eq!(group.panels[group.active], "paragraphStyles", "the one chosen is in front"); + // The dock still shows Properties in front. + assert!(matches!(&layout.root, Some(craft_ui::docking::Node::Tabs { panels, active }) if panels[*active] == "properties"), "{:?}", layout.root); + // Other panels from Window leave it open. + app.run("window.panel", json!({"panel":"color"})).unwrap(); + app.run("window.panel", json!({"panel":"links"})).unwrap(); + let layout = app.ui.docking.clone().unwrap(); + assert!(layout.floating.iter().any(|g| g.panels == ["characterStyles", "paragraphStyles"])); + // Once showing, Window › Character Styles brings that tab forward. + app.run("window.panel", json!({"panel":"characterStyles"})).unwrap(); + let layout = app.ui.docking.clone().unwrap(); + let group = layout.floating.iter().find(|g| g.panels.iter().any(|p| p == "characterStyles")).unwrap(); + assert_eq!(group.panels[group.active], "characterStyles"); + assert_eq!(layout.panels().iter().filter(|p| p.as_str() == "characterStyles").count(), 1); +} From 45a6cc54b96cfcbfbbcf69bd789873833b935cf7 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 14:12:42 +0200 Subject: [PATCH 03/41] New Workspace: Capture Panel Locations and Menu Customization, as InDesign's Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/dialogs.rs | 15 +++++---- crates/ui-egui/src/i18n.rs | 2 ++ crates/ui-egui/src/i18n/ar.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/pt_br.rs | 2 ++ crates/ui-egui/src/i18n/uk.rs | 2 ++ crates/ui-egui/src/lib.rs | 7 +++++ crates/ui-egui/src/menus.rs | 54 ++++++++++++++++++++++++++++++-- 12 files changed, 85 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf4..7433483e 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** · New Workspace is InDesign's: Name, and Capture › Panel Locations and Menu Customization (the items hidden with Edit › Menus), both on by default; a workspace saved without panel locations leaves the panels where they are. 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/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7d02776a..f7e53087 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1537,7 +1537,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let available = (ctx.content_rect().size() - frame_margin - egui::vec2(16.0, 16.0)).max(egui::Vec2::splat(1.0)); let preferred_width: f32 = match d.id.as_str() { "alert" => 440.0, - "closeDocument" | "updateLink" => 380.0, + "closeDocument" | "updateLink" | "newWorkspace" => 380.0, "newDocument" if crate::i18n::is_rtl(&app.ui.language) => 380.0, _ => 640.0, }; @@ -2036,10 +2036,10 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Name:")); text_field(ui, &mut d, "name", 220.0); }); - crate::rtl::label( - ui, - egui::RichText::new(crate::i18n::tr(&app.ui.language, "Saves which bars show and where the panels are.")).size(10.5), - ); + ui.add_space(6.0); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Capture")).font(semibold(12.0))); + check(ui, &mut d, "panelLocations", crate::i18n::tr(&app.ui.language, "Panel Locations")); + check(ui, &mut d, "menuCustomization", crate::i18n::tr(&app.ui.language, "Menu Customization")); } "fittingOptions" => { check(ui, &mut d, "autoFit", crate::i18n::tr(&app.ui.language, "Auto-Fit")); @@ -2635,7 +2635,10 @@ pub fn confirm(app: &mut DesignApp) -> Result { doc["glyphFallback"] = json!(d.b("glyphFallback")); app.run("document.preferences", doc) } - "newWorkspace" => app.run("window.newWorkspace", json!({"name": d.s("name")})), + "newWorkspace" => app.run( + "window.newWorkspace", + json!({"name": d.s("name"), "panelLocations": d.b("panelLocations"), "menuCustomization": d.b("menuCustomization")}), + ), "updateLink" => crate::panels::properties::run_update_links(app, d.fields.get("target").cloned().unwrap_or_else(|| json!({}))), "closeDocument" => { // The document may have moved in the tab row (or gone) while the dialog was open. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 245a6e28..8f033ccd 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -58,6 +58,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("Capture", ["Erfassen", "Capturer", "Capturar", "取り込み", "捕捉"]), + ("Panel Locations", ["Bedienfeldpositionen", "Emplacements des panneaux", "Ubicaciones de paneles", "パネルの位置", "面板位置"]), ( "Bulleted & Numbered Lists", [ diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index d1dcb7ac..0910eba5 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", "لصق"), + ("Capture", "التقاط"), + ("Panel Locations", "مواضع اللوحات"), ("Bulleted & Numbered Lists", "القوائم ذات التعداد النقطي والرقمي"), ("Current Page Number", "رقم الصفحة الحالية"), ("Apply Color (,)", "تطبيق اللون (,)"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 0f55578b..88216fa0 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("Capture", "Erfassen"), + ("Panel Locations", "Bedienfeldpositionen"), ("Clear", "Löschen"), ("Duplicate", "Duplizieren"), ("Select All", "Alles auswählen"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 00328ac9..8a4cceeb 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"), + ("Capture", "Acquisisci"), + ("Panel Locations", "Posizioni pannelli"), ("Bulleted & Numbered Lists", "Elenchi puntati e numerati"), ("Current Page Number", "Numero di pagina corrente"), ("Apply Color (,)", "Applica colore (,)"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index fe9e213e..4ec960cc 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", "ペースト"), + ("Capture", "取り込み"), + ("Panel Locations", "パネルの位置"), ("Bulleted & Numbered Lists", "箇条書き"), ("Apply Color (,)", "カラーを適用 (,)"), ("Apply Gradient (.)", "グラデーションを適用 (.)"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index b3379c6d..24160aeb 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", "ჩასმა"), + ("Capture", "შენახვა"), + ("Panel Locations", "პანელების მდებარეობა"), ("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 0675a2f6..4958606d 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"), + ("Capture", "Capturar"), + ("Panel Locations", "Locais dos painéis"), ("Bulleted & Numbered Lists", "Listas com marcadores e numeradas"), ("Show Hidden Characters", "Mostrar caracteres ocultos"), ("Current Page Number", "Número da página atual"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 8f1d27f8..6b173ca3 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Capture", "Зберегти"), + ("Panel Locations", "Розташування панелей"), ("Bulleted & Numbered Lists", "Марковані й нумеровані списки"), ("Current Page Number", "Номер поточної сторінки"), ("Apply Color (,)", "Застосувати колір (,)"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 59c7ebb5..7d4ed4c6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -113,6 +113,13 @@ pub struct SavedWorkspace { /// The workspace's own icon column (see [`UiState::icon_groups`]). #[serde(default, skip_serializing_if = "Option::is_none")] pub icon_groups: Option>>, + /// Saved with New Workspace › Capture › Panel Locations off: choosing the workspace leaves the + /// bars and panels where they are. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub keep_panels: bool, + /// New Workspace › Capture › Menu Customization: the menu items hidden with Edit › Menus. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hidden_menu_items: Option>, } /// A missing on-by-default bool stays on. `bool::default` is false. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a35261d5..6f2aff4e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -218,7 +218,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ None, "{name: Advanced|Book|Digital Publishing|Essentials|Essentials Classic|Interactive for PDF|Printing and Proofing|Typography or a saved workspace}", ), - ("window.newWorkspace", "New Workspace…", None, "{name} — saves the current bars and panel arrangement"), + ( + "window.newWorkspace", + "New Workspace…", + None, + "{name, panelLocations?: bool (default true: the bars and panels), menuCustomization?: bool (default true: the menu items hidden with Edit › Menus)} — saves the workspace", + ), ("window.deleteWorkspace", "Delete Workspace…", None, "{name}"), ("window.resetWorkspace", "Reset Workspace", None, "{} — back to the current workspace as saved (or its defaults)"), ("window.brightness", "Interface Color Theme", None, "{brightness: dark|mediumDark|mediumLight|light|highContrast}"), @@ -1559,7 +1564,8 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option flag(&mut app.ui.tools_double_column), "window.newWorkspace" => { let Some(name) = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()) else { - app.ui.dialog = Some(crate::dialogs::Dialog::new("newWorkspace", json!({"name": ""}))); + app.ui.dialog = + Some(crate::dialogs::Dialog::new("newWorkspace", json!({"name": "", "panelLocations": true, "menuCustomization": true}))); return Some(Ok(Value::Null)); }; let u = &app.ui; @@ -1576,6 +1582,8 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option Option Date: Wed, 14 Oct 2026 03:16:00 +0200 Subject: [PATCH 04/41] fix(ui): the status bar's error count opens the Preflight panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "N errors ▾" text in the status bar was a plain label. As in InDesign it is now a menu; its Preflight Panel… entry shows the panel (`window.panel {panel: "preflight"}`). Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 533ba782643314e5841fb8bb58cd3af4bf2dd9ae) --- crates/ui-egui/src/chrome.rs | 47 ++++++++++++++++++++++++------------ crates/ui-egui/src/menus.rs | 2 +- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 439fb393..5855b3f9 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -709,22 +709,21 @@ pub fn status_bar(app: &mut DesignApp, ui: &mut egui::Ui) { 4.0, if errors == 0 { Color32::from_rgb(60, 200, 90) } else { Color32::from_rgb(235, 50, 50) }, ); - crate::rtl::label( - ui, - egui::RichText::new(if errors == 0 { - format!("{} ▾", crate::i18n::tr(&app.ui.language, "No errors")) - } else { - format!( - "{} ▾", - crate::i18n::count_label( - &app.ui.language, - if errors == 1 && app.ui.language != "uk" { "error" } else { "errors" }, - errors - ) - ) - }) - .font(small.clone()), - ); + let count = if errors == 0 { + format!("{} ▾", crate::i18n::tr(&app.ui.language, "No errors")) + } else { + format!( + "{} ▾", + crate::i18n::count_label(&app.ui.language, if errors == 1 && app.ui.language != "uk" { "error" } else { "errors" }, errors) + ) + }; + // As in InDesign, the count opens a menu that leads to the Preflight panel. + crate::menus::menu_button(ui, egui::RichText::new(count).font(small.clone()), |ui| { + if ui.button(crate::i18n::tr(&app.ui.language, "Preflight Panel…")).clicked() { + let _ = app.run("window.panel", json!({"panel": "preflight"})); + ui.close(); + } + }); ui.add_space(12.0); ui.label(egui::RichText::new(&app.ui.status).font(small).color(t.text_dim)); }); @@ -899,6 +898,22 @@ mod tests { h.get_by_label(label).rect() } + /// The status bar's preflight count is a menu; its Preflight Panel entry shows the panel. + #[test] + fn status_bar_error_count_opens_the_preflight_panel() { + let mut app = app(); + app.run("file.new", serde_json::json!({})).unwrap(); + app.ui.dock_tab = "properties".into(); + let mut h = test_window::open(app, vec2(1200.0, 700.0)); + h.get_by_label("No errors ▾").click(); + h.run_steps(3); + h.get_by_label("Preflight Panel…").click(); + h.run_steps(3); + // Preflight is not a dock tab: `window.panel` shows it as its own panel. + assert_eq!(h.state().app.ui.open_panel.as_deref(), Some("preflight")); + h.get_by_label("Preflight"); + } + /// A very long document name made its tab grow past the window, hiding the close button /// and the tabs after it (#603). #[test] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a35261d5..229ced6e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -162,7 +162,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "window.panel", "Show Panel", None, - "{panel: properties|pages|layers|swatches|paragraphStyles|characterStyles|stroke|character|paragraph|textWrap|links|table}", + "{panel: properties|pages|layers|swatches|paragraphStyles|characterStyles|stroke|character|paragraph|textWrap|links|table|preflight}", ), ("window.floatPanel", "Float Panel", None, "{panel, x?, y?} — the panel in its own movable window"), ( From e001b4122101043109b1ab24811a27b7a18ec19b Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 16:25:51 +0200 Subject: [PATCH 05/41] Layout: First/Previous/Next/Last Page and Next/Previous Spread with InDesign's shortcuts Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 14 +++++ crates/ui-egui/src/i18n.rs | 4 ++ crates/ui-egui/src/menus.rs | 106 +++++++++++++++++++++++++++++++++++ 4 files changed, 125 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf4..b656875c 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: Layout › First Page, Previous Page, Next Page, Last Page, Next Spread and Previous Spread, with InDesign's shortcuts (Shift+Page Up/Down, Shift+Cmd+Page Up/Down, Alt+Page Up/Down). - **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/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 40b146f5..1c550a19 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -221,6 +221,20 @@ pub fn go_to_page(app: &mut DesignApp, abs: usize) { } } +/// Centre the spread `delta` spreads after (or before) the one in view (keeps zoom); stops at the +/// first and last spread. Parent spreads while they are being edited. +pub fn step_spread(app: &mut DesignApp, delta: isize) { + let Some(st) = app.session.active() else { return }; + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let Some(cur) = current_slot(app, &layout) else { return }; + let to = cur.saturating_add_signed(delta).min(layout.slots.len().saturating_sub(1)); + let Some(b) = layout.slots.get(to).map(|s| s.bounds) else { return }; + let Some(rect) = app.canvas_rect else { return }; + if let Some(v) = app.view_mut() { + v.origin = Point::new(b.center().x - rect.width() as f64 / 2.0 / v.zoom, b.center().y - rect.height() as f64 / 2.0 / v.zoom); + } +} + pub fn zoom_at(app: &mut DesignApp, screen: Pos2, factor: f64) { let Some(rect) = app.canvas_rect else { return }; let Some(v) = app.view_mut() else { return }; diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 245a6e28..a20b30dc 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -58,6 +58,10 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("First Page", ["Erste Seite", "Première page", "Primera página", "最初のページ", "第一页"]), + ("Previous Page", ["Vorherige Seite", "Page précédente", "Página anterior", "前のページ", "上一页"]), + ("Next Page", ["Nächste Seite", "Page suivante", "Página siguiente", "次のページ", "下一页"]), + ("Last Page", ["Letzte Seite", "Dernière page", "Última página", "最後のページ", "最后一页"]), ( "Bulleted & Numbered Lists", [ diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a35261d5..423b72be 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -157,6 +157,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.fastDisplay", "Fast Display", Some("Cmd+Alt+Shift+Z"), "{} — placed graphics as grey boxes, no effects"), ("view.typicalDisplay", "Typical Display", Some("Cmd+Alt+Z"), "{} — low-resolution image proxies"), ("view.highQualityDisplay", "High Quality Display", Some("Cmd+Alt+H"), "{} — full-resolution images"), + ("view.firstPage", "First Page", Some("Cmd+Shift+PageUp"), "{} — scroll to the document's first page (zoom kept)"), + ("view.previousPage", "Previous Page", Some("Shift+PageUp"), "{} — scroll to the page before the one in view"), + ("view.nextPage", "Next Page", Some("Shift+PageDown"), "{} — scroll to the page after the one in view"), + ("view.lastPage", "Last Page", Some("Cmd+Shift+PageDown"), "{} — scroll to the document's last page"), + ("view.nextSpread", "Next Spread", Some("Alt+PageDown"), "{} — centre the spread after the one in view"), + ("view.previousSpread", "Previous Spread", Some("Alt+PageUp"), "{} — centre the spread before the one in view"), ("view.goToPage", "Go to Page…", Some("Cmd+J"), "{page: number (position) | \"name\" (section page name, or \"+n\" for a position)}"), ( "window.panel", @@ -338,6 +344,12 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:layout.createGuides", "cmd:layout.createAlternate", "-", + "ui:view.firstPage", + "ui:view.previousPage", + "ui:view.nextPage", + "ui:view.lastPage", + "ui:view.nextSpread", + "ui:view.previousSpread", "ui:view.goToPage", "-", "cmd:layout.section", @@ -1358,6 +1370,24 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { + let n = app.session.active().map_or(0, |d| d.doc.page_count()); + if n > 0 { + let cur = crate::canvas::current_page(app).unwrap_or(0); + let to = match id { + "view.firstPage" => 0, + "view.previousPage" => cur.saturating_sub(1), + "view.nextPage" => cur.saturating_add(1).min(n - 1), + _ => n - 1, + }; + crate::canvas::go_to_page(app, to); + } + Ok(Value::Null) + } + "view.nextSpread" | "view.previousSpread" => { + crate::canvas::step_spread(app, if id == "view.nextSpread" { 1 } else { -1 }); + Ok(Value::Null) + } "view.goToPage" => { match p.get("page") { Some(Value::Number(n)) => crate::canvas::go_to_page(app, (n.as_u64().unwrap_or(1) as usize).saturating_sub(1)), @@ -3296,6 +3326,82 @@ mod tests { assert_eq!(items(&app), items0, "the tool saw nothing"); } + #[test] + fn layout_menu_goes_through_pages_and_spreads_with_their_shortcuts() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap(); + let ctx = egui::Context::default(); + let frame = |app: &mut crate::DesignApp, events: Vec| { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + let key = |k: egui::Key, modifiers: egui::Modifiers| { + vec![ + egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers }, + egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers }, + ] + }; + let page = |app: &crate::DesignApp| crate::canvas::current_page(app).unwrap(); + let spread = |app: &crate::DesignApp| { + let st = app.session.active().unwrap(); + crate::canvas::current_slot(app, &designcraft_tools::layout::CanvasLayout::new(&st.doc, false)).unwrap() + }; + frame(&mut app, vec![]); + frame(&mut app, vec![]); + // The Layout menu lists them, as InDesign's does, before Go to Page. + let layout = MENUS.iter().find(|m| m.0 == "Layout").unwrap().1; + let at = |id: &str| layout.iter().position(|e| e.starts_with(&format!("ui:{id}"))).unwrap(); + assert!(at("view.firstPage") < at("view.previousPage")); + assert!(at("view.previousPage") < at("view.nextPage")); + assert!(at("view.nextPage") < at("view.lastPage")); + assert!(at("view.lastPage") < at("view.nextSpread")); + assert!(at("view.nextSpread") < at("view.previousSpread")); + assert!(at("view.previousSpread") < at("view.goToPage")); + let shift = egui::Modifiers::SHIFT; + let cmd_shift = egui::Modifiers { shift: true, command: true, ctrl: true, ..Default::default() }; + let alt = egui::Modifiers::ALT; + // Pages 1 | 2-3 | 4-5 | 6-7. + frame(&mut app, key(egui::Key::PageDown, shift)); + assert_eq!(page(&app), 1, "Next Page"); + frame(&mut app, key(egui::Key::PageDown, shift)); + assert_eq!(page(&app), 2, "Next Page goes to the right page of the spread"); + frame(&mut app, key(egui::Key::PageUp, shift)); + assert_eq!(page(&app), 1, "Previous Page"); + frame(&mut app, key(egui::Key::PageDown, cmd_shift)); + assert_eq!(page(&app), 6, "Last Page"); + frame(&mut app, key(egui::Key::PageDown, shift)); + assert_eq!(page(&app), 6, "Next Page stops at the last page"); + frame(&mut app, key(egui::Key::PageUp, cmd_shift)); + assert_eq!(page(&app), 0, "First Page"); + frame(&mut app, key(egui::Key::PageUp, shift)); + assert_eq!(page(&app), 0, "Previous Page stops at the first page"); + frame(&mut app, key(egui::Key::PageDown, alt)); + assert_eq!(spread(&app), 1, "Next Spread"); + frame(&mut app, key(egui::Key::PageDown, alt)); + frame(&mut app, key(egui::Key::PageDown, alt)); + frame(&mut app, key(egui::Key::PageDown, alt)); + assert_eq!(spread(&app), 3, "Next Spread stops at the last spread"); + frame(&mut app, key(egui::Key::PageUp, alt)); + assert_eq!(spread(&app), 2, "Previous Spread"); + // The spread is centred, the zoom kept. + let zoom = app.view().unwrap().zoom; + app.run("view.previousSpread", json!({})).unwrap(); + let st = app.session.active().unwrap(); + let slot = designcraft_tools::layout::CanvasLayout::new(&st.doc, false).slots[1]; + let (v, r) = (*app.view().unwrap(), app.canvas_rect.unwrap()); + let centre = (v.origin.x + r.width() as f64 / 2.0 / v.zoom, v.origin.y + r.height() as f64 / 2.0 / v.zoom); + assert!((centre.0 - slot.bounds.center().x).abs() < 1e-6 && (centre.1 - slot.bounds.center().y).abs() < 1e-6); + assert_eq!(v.zoom, zoom); + } + #[test] fn tool_shortcuts_work_after_drawing_on_the_canvas() { // Clicking the canvas gives it keyboard focus; single-key tool shortcuts must still switch From 75d3e528ff9d0be3226c07400d3b5c3307ae0916 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 16:32:52 +0200 Subject: [PATCH 06/41] View: Presentation shows one spread fitted to the screen and goes through spreads with arrow keys and clicks Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 45 +++++++++++++-- crates/ui-egui/src/menus.rs | 108 +++++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b656875c..17f35c1c 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: Presentation shows one spread at a time, as large as the screen allows; the arrow keys, Page Up/Down, Home, End, a click and Shift-click go through the spreads. - 2026-10-15: Layout › First Page, Previous Page, Next Page, Last Page, Next Spread and Previous Spread, with InDesign's shortcuts (Shift+Page Up/Down, Shift+Cmd+Page Up/Down, Alt+Page Up/Down). - **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/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1c550a19..8cb5859e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -221,6 +221,22 @@ pub fn go_to_page(app: &mut DesignApp, abs: usize) { } } +/// Presentation: the spread in view, centred and as large as `rect` allows. +fn present_fit(app: &mut DesignApp, rect: Rect) { + let Some(st) = app.session.active() else { return }; + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let Some(b) = current_slot(app, &layout).and_then(|i| layout.slots.get(i)).map(|s| s.bounds) else { return }; + if b.width() <= 0.0 || b.height() <= 0.0 { + return; + } + let zoom = (rect.width() as f64 / b.width()).min(rect.height() as f64 / b.height()).clamp(0.05, 40.0); + let origin = Point::new(b.center().x - rect.width() as f64 / 2.0 / zoom, b.center().y - rect.height() as f64 / 2.0 / zoom); + if let Some(v) = app.view_mut() { + v.zoom = zoom; + v.origin = origin; + } +} + /// Centre the spread `delta` spreads after (or before) the one in view (keeps zoom); stops at the /// first and last spread. Parent spreads while they are being edited. pub fn step_spread(app: &mut DesignApp, delta: isize) { @@ -337,10 +353,25 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let rect = view_rect(screen, rot); app.canvas_rect = Some(rect); let resp = ui.allocate_rect(full, Sense::click_and_drag()); - if !app.view().is_some_and(|v| v.fitted) { - fit(app, rect, "spread"); + if presenting { + // One spread at a time, as large as the screen allows; a click shows the next one, + // Shift-click the previous one. Tools and selection don't see the input. + present_fit(app, rect); + if resp.clicked() { + // The release that made the click says whether Shift was down. + let shift = ui.input(|i| { + i.modifiers.shift + || i.events.iter().any(|e| matches!(e, egui::Event::PointerButton { pressed: false, modifiers, .. } if modifiers.shift)) + }); + step_spread(app, if shift { -1 } else { 1 }); + present_fit(app, rect); + } + } else { + if !app.view().is_some_and(|v| v.fitted) { + fit(app, rect, "spread"); + } + handle_input(app, ui, &resp, rect); } - handle_input(app, ui, &resp, rect); if bars { scrollbars(app, ui, outer, screen, rect, &t); } @@ -348,7 +379,10 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let Some(&v) = app.view() else { return }; let xf = Xf::new(rect, &v); let doc = st.doc.clone(); - let layout = CanvasLayout::new(&doc, st.editing_parents); + let mut layout = CanvasLayout::new(&doc, st.editing_parents); + if presenting && let Some(cur) = current_slot(app, &layout) { + layout.slots = layout.slots.get(cur).copied().into_iter().collect(); + } let painter = ui.painter_at(screen); let preview = matches!(app.ui.screen_mode, ScreenMode::Preview | ScreenMode::Presentation); let bg = if app.ui.screen_mode == ScreenMode::Presentation { Color32::BLACK } else { t.pasteboard }; @@ -419,6 +453,9 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { draw_dynamic_spelling(app, ui.ctx(), &painter, &xf, &doc, &layout); } } + if presenting { + return; + } let sel_rect = draw_selection(app, &painter, &xf, &doc, &layout); draw_tool_overlays(app, &painter, &xf); if rulers { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 423b72be..27d89a5d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2392,6 +2392,21 @@ pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) { let events = ctx.input(|i| i.events.clone()); for e in events { let egui::Event::Key { key, pressed: true, modifiers, repeat: false, .. } = e else { continue }; + // Presentation goes through the spreads with the arrow keys, Page Up/Down, Home and End. + if app.ui.screen_mode == ScreenMode::Presentation && !modifiers.command && !modifiers.alt { + let step = match key { + egui::Key::ArrowRight | egui::Key::ArrowDown | egui::Key::PageDown => Some(1), + egui::Key::ArrowLeft | egui::Key::ArrowUp | egui::Key::PageUp => Some(-1), + egui::Key::Home => Some(isize::MIN), + egui::Key::End => Some(isize::MAX), + _ => None, + }; + if let Some(step) = step { + ctx.input_mut(|i| i.consume_key(modifiers, key)); + crate::canvas::step_spread(app, step); + continue; + } + } // Command shortcuts. let mut fired: Option = None; let all = effective_shortcuts(app); @@ -3402,6 +3417,99 @@ mod tests { assert_eq!(v.zoom, zoom); } + #[test] + fn presentation_shows_one_spread_and_goes_through_them_with_keys_and_clicks() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap(); + app.session.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap(); + let id = app.session.active().unwrap().selection.items[0]; + let ctx = egui::Context::default(); + let frame = |app: &mut crate::DesignApp, events: Vec| { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + let key = |k: egui::Key| { + vec![ + egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }, + egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() }, + ] + }; + let click = |pos: egui::Pos2, modifiers: egui::Modifiers| { + vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers }, + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers }, + ] + }; + let spread = |app: &crate::DesignApp| { + let st = app.session.active().unwrap(); + crate::canvas::current_slot(app, &designcraft_tools::layout::CanvasLayout::new(&st.doc, false)).unwrap() + }; + // The spread fills the screen: as wide or as tall as it, centred. + let fills = |app: &crate::DesignApp| { + let st = app.session.active().unwrap(); + let b = designcraft_tools::layout::CanvasLayout::new(&st.doc, false).slots[spread(app)].bounds; + let (v, r) = (*app.view().unwrap(), app.canvas_rect.unwrap()); + let (w, h) = (b.width() * v.zoom, b.height() * v.zoom); + let x0 = (b.x0 - v.origin.x) * v.zoom; + let y0 = (b.y0 - v.origin.y) * v.zoom; + ((w - r.width() as f64).abs() < 0.01 || (h - r.height() as f64).abs() < 0.01) + && w <= r.width() as f64 + 0.01 + && h <= r.height() as f64 + 0.01 + && (x0 * 2.0 + w - r.width() as f64).abs() < 0.01 + && (y0 * 2.0 + h - r.height() as f64).abs() < 0.01 + }; + frame(&mut app, vec![]); + frame(&mut app, vec![]); + app.run("view.screenMode", json!({"mode": "presentation"})).unwrap(); + frame(&mut app, vec![]); + frame(&mut app, vec![]); + assert_eq!(app.canvas_rect.unwrap().size(), egui::vec2(1400.0, 900.0), "the whole window"); + assert_eq!(spread(&app), 0); + assert!(fills(&app)); + let bounds = app.session.active().unwrap().doc.item(id).unwrap().bounds(); + frame(&mut app, key(egui::Key::ArrowRight)); + frame(&mut app, vec![]); + assert_eq!(spread(&app), 1, "Right Arrow shows the next spread"); + assert!(fills(&app)); + assert_eq!(app.session.active().unwrap().doc.item(id).unwrap().bounds(), bounds, "the arrow doesn't nudge the selection"); + frame(&mut app, key(egui::Key::PageDown)); + assert_eq!(spread(&app), 2, "Page Down"); + frame(&mut app, key(egui::Key::ArrowLeft)); + assert_eq!(spread(&app), 1, "Left Arrow"); + frame(&mut app, key(egui::Key::End)); + assert_eq!(spread(&app), 3, "End"); + frame(&mut app, key(egui::Key::ArrowRight)); + assert_eq!(spread(&app), 3, "the last spread stays"); + frame(&mut app, key(egui::Key::Home)); + assert_eq!(spread(&app), 0, "Home"); + frame(&mut app, key(egui::Key::PageUp)); + assert_eq!(spread(&app), 0, "the first spread stays"); + let c = app.canvas_rect.unwrap().center(); + frame(&mut app, click(c, egui::Modifiers::NONE)); + frame(&mut app, vec![]); + assert_eq!(spread(&app), 1, "a click shows the next spread"); + assert!(fills(&app)); + frame(&mut app, click(c, egui::Modifiers::SHIFT)); + frame(&mut app, vec![]); + assert_eq!(spread(&app), 0, "Shift-click the previous one"); + assert_eq!(app.session.active().unwrap().selection.items, vec![id], "clicks don't select"); + assert_eq!(app.session.tool_id(), "selection"); + // Out of Presentation, the arrow keys nudge again. + app.run("view.screenMode", json!({"mode": "normal"})).unwrap(); + frame(&mut app, vec![]); + frame(&mut app, key(egui::Key::ArrowRight)); + assert_ne!(app.session.active().unwrap().doc.item(id).unwrap().bounds(), bounds); + } + #[test] fn tool_shortcuts_work_after_drawing_on_the_canvas() { // Clicking the canvas gives it keyboard focus; single-key tool shortcuts must still switch From 32146348b8894201fd3fdabee8d8cd8db72705e2 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 16:43:38 +0200 Subject: [PATCH 07/41] UI: Tab, Shift+Tab and Presentation hide flyout and floating panels too Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/lib.rs | 7 +++-- crates/ui-egui/src/menus.rs | 51 +++++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf4..db6a6319 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: Tab, Shift+Tab and Presentation hide the icon column's panel flyout and the classic floating panels too, as InDesign hides floating panels. - **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/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 59c7ebb5..326214ce 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1366,8 +1366,11 @@ impl DesignApp { ); self.switch_pane(self.focus_pane); } - dock::flyout(self, &ctx); - dock::floating(self, &ctx); + // Tab, Shift+Tab and Presentation hide the floating panels with the docked ones. + if !presenting && self.ui.hidden_panels == 0 { + dock::flyout(self, &ctx); + dock::floating(self, &ctx); + } story_editor::show(self, &ctx); dialogs::show(self, &ctx); about::show(self, &ctx); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a35261d5..1ce66d2a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -3164,6 +3164,57 @@ mod tests { assert!((width(&app) - 200.0).abs() < 1e-6, "fitted: {}", width(&app)); } + #[test] + fn tab_and_presentation_hide_floating_panels_too() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({})).unwrap(); + let ctx = egui::Context::default(); + let frame = |app: &mut crate::DesignApp| { + for _ in 0..2 { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + }; + // The icon column's flyout (the classic dock) and a floating panel group (the docking layout). + let flyout = egui::LayerId::new(egui::Order::Foreground, egui::Id::new("panel_flyout")); + let group = egui::LayerId::new(egui::Order::Middle, egui::Id::new("designcraft-panel-docking").with(("floating", "preflight"))); + let shown = |layer: egui::LayerId| ctx.memory(|m| m.areas().is_visible(&layer)); + let toggles = [ + ("window.hidePanels", json!({}), json!({})), + ("window.hidePanelsExceptTools", json!({}), json!({})), + ("view.screenMode", json!({"mode": "presentation"}), json!({"mode": "normal"})), + ]; + app.run("window.panel", json!({"panel": "preflight"})).unwrap(); + frame(&mut app); + assert!(app.ui.docking.is_none() && shown(flyout)); + for (id, hide, show) in &toggles { + app.run(id, hide.clone()).unwrap(); + frame(&mut app); + assert!(!shown(flyout), "{id} hides the flyout"); + app.run(id, show.clone()).unwrap(); + frame(&mut app); + assert!(shown(flyout), "{id} again brings it back"); + } + app.run("window.floatPanel", json!({"panel": "preflight", "x": 400, "y": 200})).unwrap(); + frame(&mut app); + assert!(app.ui.docking.is_some() && shown(group)); + for (id, hide, show) in &toggles { + app.run(id, hide.clone()).unwrap(); + frame(&mut app); + assert!(!shown(group), "{id} hides the floating panel"); + app.run(id, show.clone()).unwrap(); + frame(&mut app); + assert!(shown(group), "{id} again brings it back"); + } + } + #[test] fn tab_hides_panels_and_documents_cycle() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From e2ad5cafa202a98dbdd2622d723389a620159986 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 16:57:42 +0200 Subject: [PATCH 08/41] UI: double-clicking the status bar's preflight count shows the Preflight panel Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/chrome.rs | 25 +++++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 33 ++++++++++++++++++++------------- 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf4..5df96b8e 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: Double-clicking the status bar's preflight count or its light shows the Preflight panel, as in InDesign. - **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/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 5855b3f9..9b59f183 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -703,7 +703,7 @@ pub fn status_bar(app: &mut DesignApp, ui: &mut egui::Ui) { ui.add_space(8.0); // Preflight: overset text is an error. let errors = panels::preflight_errors(app); - let (r, _) = ui.allocate_exact_size(vec2(9.0, 9.0), Sense::hover()); + let (r, dot) = ui.allocate_exact_size(vec2(9.0, 9.0), Sense::click()); ui.painter().circle_filled( r.center(), 4.0, @@ -718,12 +718,17 @@ pub fn status_bar(app: &mut DesignApp, ui: &mut egui::Ui) { ) }; // As in InDesign, the count opens a menu that leads to the Preflight panel. - crate::menus::menu_button(ui, egui::RichText::new(count).font(small.clone()), |ui| { + let menu = crate::menus::menu_button(ui, egui::RichText::new(count).font(small.clone()), |ui| { if ui.button(crate::i18n::tr(&app.ui.language, "Preflight Panel…")).clicked() { let _ = app.run("window.panel", json!({"panel": "preflight"})); ui.close(); } }); + // Double-clicking the light or the count shows the panel straight away (the + // second click has closed the menu the first one opened). + if dot.double_clicked() || menu.response.double_clicked() { + let _ = app.run("window.panel", json!({"panel": "preflight"})); + } ui.add_space(12.0); ui.label(egui::RichText::new(&app.ui.status).font(small).color(t.text_dim)); }); @@ -914,6 +919,22 @@ mod tests { h.get_by_label("Preflight"); } + /// 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() { + let mut app = app(); + app.run("file.new", serde_json::json!({})).unwrap(); + app.ui.dock_tab = "properties".into(); + let mut h = test_window::open_stepped(app, vec2(1200.0, 700.0), 1.0, 1.0 / 60.0); + let count = h.get_by_label("No errors ▾"); + count.click(); + count.click(); + h.run_steps(3); + assert_eq!(h.state().app.ui.open_panel.as_deref(), Some("preflight")); + h.get_by_label("Preflight"); + assert!(h.query_by_label("Preflight Panel…").is_none(), "the menu isn't left open"); + } + /// A very long document name made its tab grow past the window, hiding the close button /// and the tabs after it (#603). #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 59c7ebb5..30dfd78a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2300,22 +2300,29 @@ pub(crate) mod test_window { } /// The window at a size in points and a scale, with a new document open. - pub fn open_scaled(mut app: crate::DesignApp, size: egui::Vec2, pixels_per_point: f32) -> Harness<'static, Window> { + pub fn open_scaled(app: crate::DesignApp, size: egui::Vec2, pixels_per_point: f32) -> Harness<'static, Window> { + open_stepped(app, size, pixels_per_point, 0.25) + } + + /// As `open_scaled`, with `step_dt` seconds between frames (a double-click needs its two + /// clicks closer together than the default quarter second). + pub fn open_stepped(mut app: crate::DesignApp, size: egui::Vec2, pixels_per_point: f32, step_dt: f32) -> Harness<'static, Window> { if app.session.active().is_none() { app.run("file.new", serde_json::json!({})).unwrap(); } - let mut h = Harness::builder().with_size(size).with_pixels_per_point(pixels_per_point).with_max_steps(1000).build_ui_state( - |ui, w: &mut Window| { - // The builder runs a frame before the texture size can be raised: skip it. - if !w.ready { - return; - } - let ctx = ui.ctx().clone(); - w.app.logic(&ctx); - w.app.ui(ui); - }, - Window { app, ready: false }, - ); + let mut h = + Harness::builder().with_size(size).with_pixels_per_point(pixels_per_point).with_max_steps(1000).with_step_dt(step_dt).build_ui_state( + |ui, w: &mut Window| { + // The builder runs a frame before the texture size can be raised: skip it. + if !w.ready { + return; + } + let ctx = ui.ctx().clone(); + w.app.logic(&ctx); + w.app.ui(ui); + }, + Window { app, ready: false }, + ); h.input_mut().max_texture_side = Some(8192); h.state_mut().ready = true; h.run_steps(6); From 29f02d54581af5657c4e841381c20885eb1e4020 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 17:49:01 +0200 Subject: [PATCH 09/41] Canvas: pages show their paper while their render is on its way Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 42 ++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf4..671a7cff 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: A page scrolled or jumped to shows its paper while its render is on its way, as in InDesign, instead of the black of its shadow. - **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/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 40b146f5..cb630d6b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -344,6 +344,15 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let r = xf.rect(slot.bounds); painter.rect_filled(r.translate(vec2(1.5, 1.5)), 0.0, Color32::BLACK); } + // Paper under the rendered content: a page scrolled or jumped to shows its paper colour, + // not the shadow's black, until its render arrives. + let [r, g, b, _] = doc.resolve_color(designcraft_color::swatch::PAPER, 1.0).unwrap_or(designcraft_color::Color::WHITE).to_rgba8(1.0); + let paper = Color32::from_rgb(r, g, b); + for slot in &layout.slots { + for page in doc.spread(slot.spread).map(|sp| sp.pages.as_slice()).unwrap_or_default() { + painter.rect_filled(xf.rect(slot.xf.transform_rect_bbox(page.bounds())), 0.0, paper); + } + } // Rendered content. render_texture(app, ui.ctx(), rect, &xf, &layout, preview); if let (Some(tex), Some(sh)) = (&app.canvas.texture, app.canvas.shown) { @@ -2233,6 +2242,39 @@ mod tests { assert_eq!(press(Some(up), 0.0, 1.0), 1); } + /// While a page's render is on its way the page shows paper, not the black of its shadow. + #[test] + fn pages_show_paper_under_their_render() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", serde_json::json!({"pages": 3, "facingPages": true})).unwrap(); + let ctx = egui::Context::default(); + let mut shapes = Vec::new(); + for _ in 0..2 { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(&mut app, ui)); + out.textures_delta.clear(); + shapes = out.shapes; + } + let st = app.session.active().unwrap(); + let layout = CanvasLayout::new(&st.doc, false); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + // The paper swatch on screen (white, as the colour settings show it). + let [r, g, b, _] = st.doc.resolve_color(designcraft_color::swatch::PAPER, 1.0).unwrap().to_rgba8(1.0); + let paper: Vec = shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Rect(s) if s.fill == Color32::from_rgb(r, g, b) => Some(s.rect), + _ => None, + }) + .collect(); + // Each page on its own (a spread's pages may differ in size). + assert_eq!(paper.len(), st.doc.page_count()); + for abs in 0..st.doc.page_count() { + let want = xf.rect(layout.page_rect(&st.doc, abs).unwrap()); + assert!(paper.iter().any(|r| (r.min - want.min).length() < 0.01 && (r.max - want.max).length() < 0.01), "page {abs}"); + } + } + fn harness(app: DesignApp) -> Harness<'static, DesignApp> { let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( |ui, app: &mut DesignApp| { From 5ab0abaf6f6b29829bf22e9a8ba86e9acc32f4c9 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 18:02:04 +0200 Subject: [PATCH 10/41] Pages: deleting pages that contain objects asks first, with Don't show again Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/build.rs | 8 ++++ crates/ui-egui/src/dialogs.rs | 36 +++++++++++++- crates/ui-egui/src/i18n.rs | 22 +++++++++ crates/ui-egui/src/i18n/ar.rs | 4 ++ crates/ui-egui/src/i18n/de.rs | 4 ++ crates/ui-egui/src/i18n/it.rs | 4 ++ crates/ui-egui/src/i18n/ja.rs | 4 ++ crates/ui-egui/src/i18n/ka.rs | 4 ++ crates/ui-egui/src/i18n/pt_br.rs | 4 ++ crates/ui-egui/src/i18n/uk.rs | 4 ++ crates/ui-egui/src/lib.rs | 3 ++ crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels/pages.rs | 62 +++++++++++++++++++++++-- crates/ui-egui/src/panels/properties.rs | 2 +- 15 files changed, 158 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf4..a9102718 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: Deleting pages that contain objects asks first, as InDesign does ("The affected page contains objects. Delete the page anyway?", with Don't show again), from the Pages panel, Layout › Pages › Delete Page and Properties. - **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/doc/src/build.rs b/crates/doc/src/build.rs index 9fb07555..c673d68b 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -442,6 +442,14 @@ impl Document { } /// Delete absolute pages (items whose centre is on a deleted page are deleted too). + /// Whether absolute page `abs` holds page items: the ones Delete Pages removes with it + /// (an item belongs to the page its centre is over). + pub fn page_has_items(&self, abs: usize) -> bool { + let Some((si, pi)) = self.page_loc(abs) else { return false }; + let Some(sp) = self.spreads.get(si) else { return false }; + sp.items.iter().any(|it| sp.page_at_x(it.bounds().center().x) == Some(pi)) + } + pub fn delete_pages(&mut self, pages: &[usize]) -> Result<()> { if pages.len() >= self.page_count() { return Err(DocError::Invalid("a document needs at least one page".into())); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7d02776a..01c78f96 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1512,6 +1512,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "userDictionary" => crate::i18n::tr(&app.ui.language, "User Dictionary"), "newWorkspace" => crate::i18n::tr(&app.ui.language, "New Workspace"), "closeDocument" => crate::i18n::tr(&app.ui.language, "Unsaved Changes"), + "deletePages" => crate::i18n::tr(&app.ui.language, "Warning"), "updateLink" => crate::i18n::tr(&app.ui.language, "Update Link"), "menus" => crate::i18n::tr(&app.ui.language, "Menu Customization"), "importOptions" => crate::i18n::tr(&app.ui.language, "Import Options"), @@ -1537,7 +1538,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let available = (ctx.content_rect().size() - frame_margin - egui::vec2(16.0, 16.0)).max(egui::Vec2::splat(1.0)); let preferred_width: f32 = match d.id.as_str() { "alert" => 440.0, - "closeDocument" | "updateLink" => 380.0, + "closeDocument" | "updateLink" | "deletePages" => 380.0, "newDocument" if crate::i18n::is_rtl(&app.ui.language) => 380.0, _ => 640.0, }; @@ -2023,6 +2024,22 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { .replace("{}", &d.s("title")); crate::rtl::label(ui, text); } + "deletePages" => { + let several = d.fields.get("pages").and_then(Value::as_array).is_some_and(|p| p.len() > 1); + crate::rtl::label( + ui, + crate::i18n::tr( + &app.ui.language, + if several { + "The affected pages contain objects. Delete the pages anyway?" + } else { + "The affected page contains objects. Delete the page anyway?" + }, + ), + ); + ui.add_space(8.0); + check(ui, &mut d, "dontShowAgain", crate::i18n::tr(&app.ui.language, "Don't show again")); + } "updateLink" => { let text = crate::i18n::tr( &app.ui.language, @@ -2244,6 +2261,17 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } } +/// Delete absolute pages `pages` as the Pages panel and Layout › Pages › Delete Page do: when +/// they hold objects, InDesign's warning asks first, unless it was told not to. +pub fn delete_pages(app: &mut DesignApp, pages: Vec) -> Result { + let has_items = app.session.active().is_some_and(|st| pages.iter().any(|&abs| st.doc.page_has_items(abs))); + if has_items && !app.ui.skip_delete_pages_warning { + app.ui.dialog = Some(Dialog::new("deletePages", json!({"pages": pages}))); + return Ok(Value::Null); + } + app.run("layout.pages.delete", json!({"pages": pages})) +} + /// Close the open dialog without applying it (Cancel, Esc, the control channel): a previewing /// dialog puts the document back as it was. pub fn cancel(app: &mut DesignApp) { @@ -2636,6 +2664,12 @@ pub fn confirm(app: &mut DesignApp) -> Result { app.run("document.preferences", doc) } "newWorkspace" => app.run("window.newWorkspace", json!({"name": d.s("name")})), + "deletePages" => { + if d.b("dontShowAgain") { + app.ui.skip_delete_pages_warning = true; + } + app.run("layout.pages.delete", json!({"pages": d.fields.get("pages").cloned().unwrap_or_else(|| json!([]))})) + } "updateLink" => crate::panels::properties::run_update_links(app, d.fields.get("target").cloned().unwrap_or_else(|| json!({}))), "closeDocument" => { // The document may have moved in the tab row (or gone) while the dialog was open. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 245a6e28..51890012 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -58,6 +58,28 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("Warning", ["Warnung", "Avertissement", "Advertencia", "警告", "警告"]), + ("Don't show again", ["Nicht mehr anzeigen", "Ne plus afficher", "No volver a mostrar", "今後表示しない", "不再显示"]), + ( + "The affected page contains objects. Delete the page anyway?", + [ + "Die betroffene Seite enthält Objekte. Seite trotzdem löschen?", + "La page concernée contient des objets. Supprimer quand même la page ?", + "La página afectada contiene objetos. ¿Desea eliminarla de todos modos?", + "対象のページにはオブジェクトが含まれています。ページを削除しますか?", + "受影响的页面包含对象。仍要删除该页面吗?", + ], + ), + ( + "The affected pages contain objects. Delete the pages anyway?", + [ + "Die betroffenen Seiten enthalten Objekte. Seiten trotzdem löschen?", + "Les pages concernées contiennent des objets. Supprimer quand même les pages ?", + "Las páginas afectadas contienen objetos. ¿Desea eliminarlas de todos modos?", + "対象のページにはオブジェクトが含まれています。ページを削除しますか?", + "受影响的页面包含对象。仍要删除这些页面吗?", + ], + ), ( "Bulleted & Numbered Lists", [ diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index d1dcb7ac..30f449d1 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", "لصق"), + ("Warning", "تحذير"), + ("Don't show again", "عدم الإظهار مرة أخرى"), + ("The affected page contains objects. Delete the page anyway?", "تحتوي الصفحة المعنية على كائنات. هل تريد حذف الصفحة على أي حال؟"), + ("The affected pages contain objects. Delete the pages anyway?", "تحتوي الصفحات المعنية على كائنات. هل تريد حذف الصفحات على أي حال؟"), ("Bulleted & Numbered Lists", "القوائم ذات التعداد النقطي والرقمي"), ("Current Page Number", "رقم الصفحة الحالية"), ("Apply Color (,)", "تطبيق اللون (,)"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 0f55578b..973aa202 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -30,6 +30,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("Warning", "Warnung"), + ("Don't show again", "Nicht mehr anzeigen"), + ("The affected page contains objects. Delete the page anyway?", "Die betroffene Seite enthält Objekte. Seite trotzdem löschen?"), + ("The affected pages contain objects. Delete the pages anyway?", "Die betroffenen Seiten enthalten Objekte. Seiten trotzdem löschen?"), ("Clear", "Löschen"), ("Duplicate", "Duplizieren"), ("Select All", "Alles auswählen"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 00328ac9..19c89f41 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"), + ("Warning", "Avviso"), + ("Don't show again", "Non mostrare più"), + ("The affected page contains objects. Delete the page anyway?", "La pagina interessata contiene oggetti. Eliminarla comunque?"), + ("The affected pages contain objects. Delete the pages anyway?", "Le pagine interessate contengono oggetti. Eliminarle comunque?"), ("Bulleted & Numbered Lists", "Elenchi puntati e numerati"), ("Current Page Number", "Numero di pagina corrente"), ("Apply Color (,)", "Applica colore (,)"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index fe9e213e..0f6ee87d 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Warning", "警告"), + ("Don't show again", "今後表示しない"), + ("The affected page contains objects. Delete the page anyway?", "対象のページにはオブジェクトが含まれています。ページを削除しますか?"), + ("The affected pages contain objects. Delete the pages anyway?", "対象のページにはオブジェクトが含まれています。ページを削除しますか?"), ("Bulleted & Numbered Lists", "箇条書き"), ("Apply Color (,)", "カラーを適用 (,)"), ("Apply Gradient (.)", "グラデーションを適用 (.)"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index b3379c6d..6bac0e4f 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", "ჩასმა"), + ("Warning", "გაფრთხილება"), + ("Don't show again", "აღარ აჩვენო"), + ("The affected page contains objects. Delete the page anyway?", "გვერდი შეიცავს ობიექტებს. მაინც წავშალო გვერდი?"), + ("The affected pages contain objects. Delete the pages anyway?", "გვერდები შეიცავს ობიექტებს. მაინც წავშალო გვერდები?"), ("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 0675a2f6..639a811a 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"), + ("Warning", "Aviso"), + ("Don't show again", "Não mostrar novamente"), + ("The affected page contains objects. Delete the page anyway?", "A página afetada contém objetos. Excluir a página mesmo assim?"), + ("The affected pages contain objects. Delete the pages anyway?", "As páginas afetadas contêm objetos. Excluir as páginas mesmo assim?"), ("Bulleted & Numbered Lists", "Listas com marcadores e numeradas"), ("Show Hidden Characters", "Mostrar caracteres ocultos"), ("Current Page Number", "Número da página atual"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 8f1d27f8..ef80e1f2 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,6 +31,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Warning", "Попередження"), + ("Don't show again", "Більше не показувати"), + ("The affected page contains objects. Delete the page anyway?", "Сторінка містить об’єкти. Усе одно видалити сторінку?"), + ("The affected pages contain objects. Delete the pages anyway?", "Сторінки містять об’єкти. Усе одно видалити сторінки?"), ("Bulleted & Numbered Lists", "Марковані й нумеровані списки"), ("Current Page Number", "Номер поточної сторінки"), ("Apply Color (,)", "Застосувати колір (,)"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 59c7ebb5..36d94695 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -195,6 +195,8 @@ pub struct UiState { pub flattener_preview: bool, /// Tab / Shift+Tab: 1 = every panel hidden, 2 = all but the Tools panel (0 = shown). pub hidden_panels: u8, + /// "Don't show again" in the warning before deleting pages that contain objects. + pub skip_delete_pages_warning: bool, /// Edit › Color Settings (re-applied at start). pub color_settings: Option, /// View › Proof Colors and View › Proof Setup. @@ -339,6 +341,7 @@ impl Default for UiState { tag_markers: false, flattener_preview: false, hidden_panels: 0, + skip_delete_pages_warning: false, color_settings: None, proof_colors: false, proof_setup: Default::default(), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a35261d5..2e826076 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1123,7 +1123,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option 1 && item(ui, "Delete Spread") { let first = doc.first_page_of_spread(si); - let _ = app.run("layout.pages.delete", json!({"pages": (first..first.saturating_add(pages_in_spread)).collect::>()})); + let _ = crate::dialogs::delete_pages(app, (first..first.saturating_add(pages_in_spread)).collect()); ui.close(); } ui.separator(); @@ -438,6 +438,62 @@ mod tests { assert_eq!(h.state().session.doc().unwrap().doc.page_count(), 4, "only the clicked page"); } + /// As in InDesign: deleting a page with objects asks first; an empty page goes at once, and + /// "Don't show again" stops the question. + #[test] + fn deleting_a_page_with_objects_asks_first() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 5, "facingPages": true})).unwrap(); + let doc = app.session.doc().unwrap().doc.clone(); + let (si, pi) = doc.page_loc(2).unwrap(); + let x = doc.spreads[si].pages[pi].x; + app.run("frame.create", json!({"spread": si, "rect": [x + 36.0, 36.0, x + 200.0, 200.0]})).unwrap(); + let count = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.page_count(); + let mut h = menu_harness(app, 2, si); + h.get_by_label("Delete Page").click(); + h.run_steps(3); + assert_eq!(count(&h), 5, "nothing deleted yet"); + let d = h.state().ui.dialog.clone().unwrap(); + assert_eq!((d.id.as_str(), d.fields["pages"].clone()), ("deletePages", json!([2]))); + // Cancel keeps the page. + crate::dialogs::cancel(h.state_mut()); + assert_eq!(count(&h), 5); + // The whole spread: one question for both pages. + h.get_by_label("Delete Spread").click(); + h.run_steps(3); + assert_eq!(h.state().ui.dialog.as_ref().unwrap().fields["pages"], json!([1, 2])); + crate::dialogs::cancel(h.state_mut()); + // OK deletes it. + h.get_by_label("Delete Page").click(); + h.run_steps(3); + crate::dialogs::confirm(h.state_mut()).unwrap(); + assert_eq!(count(&h), 4); + assert!(!h.state().ui.skip_delete_pages_warning); + // An empty page goes without a question. + let app = h.state_mut(); + crate::dialogs::delete_pages(app, vec![0]).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.doc().unwrap().doc.page_count(), 3); + // "Don't show again". + let doc = app.session.doc().unwrap().doc.clone(); + let (si, pi) = doc.page_loc(1).unwrap(); + let x = doc.spreads[si].pages[pi].x; + app.run("frame.create", json!({"spread": si, "rect": [x + 36.0, 36.0, x + 200.0, 200.0]})).unwrap(); + app.run("frame.create", json!({"spread": si, "rect": [x + 36.0, 300.0, x + 200.0, 400.0]})).unwrap(); + crate::dialogs::delete_pages(app, vec![1]).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("dontShowAgain".into(), json!(true)); + crate::dialogs::confirm(app).unwrap(); + assert!(app.ui.skip_delete_pages_warning); + assert_eq!(app.session.doc().unwrap().doc.page_count(), 2); + let doc = app.session.doc().unwrap().doc.clone(); + let (si, pi) = doc.page_loc(0).unwrap(); + let x = doc.spreads[si].pages[pi].x; + app.run("frame.create", json!({"spread": si, "rect": [x + 36.0, 36.0, x + 200.0, 200.0]})).unwrap(); + crate::dialogs::delete_pages(app, vec![0]).unwrap(); + assert!(app.ui.dialog.is_none(), "no more questions"); + assert_eq!(app.session.doc().unwrap().doc.page_count(), 1); + } + #[test] fn the_panel_menu_follows_indesigns_order() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 52ce7e68..e37d4eb2 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -350,7 +350,7 @@ fn document_sections(app: &mut DesignApp, ui: &mut Ui) { let _ = app.run("layout.pages.insert", json!({"after": cur, "count": 1})); } if del { - let _ = app.run("layout.pages.delete", json!({"pages": [cur]})); + let _ = crate::dialogs::delete_pages(app, vec![cur]); } ui.add_space(3.0); if outline_button(ui, crate::i18n::tr(&app.ui.language, "Edit Page"), fw).clicked() { From 09f80899431366d6338aa02629f67fa625bda956 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 18:13:05 +0200 Subject: [PATCH 11/41] Properties: Edit Page takes the Page tool to the page and shows its own size, as InDesign's Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/i18n.rs | 2 + crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/it.rs | 2 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 2 + crates/ui-egui/src/i18n/uk.rs | 2 + crates/ui-egui/src/panels/properties.rs | 144 +++++++++++++++++++++++- crates/ui-egui/src/widgets.rs | 2 + 11 files changed, 157 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf4..491fa37b 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: Properties › Edit Page works as InDesign's: the Page tool on the page in view, and Properties shows that page alone (Page Size: page, preset, orientation, W/H; Margins and Columns; Quick Actions) with Back. It opened Document Setup, which changes every page. - **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/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 245a6e28..bf7b07ad 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -58,6 +58,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("Back", ["Zurück", "Retour", "Atrás", "戻る", "返回"]), + ("Margins and Columns", ["Ränder und Spalten", "Marges et colonnes", "Márgenes y columnas", "マージン・段組", "边距和分栏"]), ( "Bulleted & Numbered Lists", [ diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index d1dcb7ac..b99ab0af 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", "لصق"), + ("Back", "رجوع"), ("Bulleted & Numbered Lists", "القوائم ذات التعداد النقطي والرقمي"), ("Current Page Number", "رقم الصفحة الحالية"), ("Apply Color (,)", "تطبيق اللون (,)"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 0f55578b..a5f0c115 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -30,6 +30,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("Back", "Zurück"), ("Clear", "Löschen"), ("Duplicate", "Duplizieren"), ("Select All", "Alles auswählen"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 00328ac9..9c83a251 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"), + ("Back", "Indietro"), + ("Margins and Columns", "Margini e colonne"), ("Bulleted & Numbered Lists", "Elenchi puntati e numerati"), ("Current Page Number", "Numero di pagina corrente"), ("Apply Color (,)", "Applica colore (,)"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index fe9e213e..4ad3b361 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", "ペースト"), + ("Back", "戻る"), ("Bulleted & Numbered Lists", "箇条書き"), ("Apply Color (,)", "カラーを適用 (,)"), ("Apply Gradient (.)", "グラデーションを適用 (.)"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index b3379c6d..2cb9d785 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", "ჩასმა"), + ("Back", "უკან"), ("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 0675a2f6..a3eb30c2 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"), + ("Back", "Voltar"), + ("Margins and Columns", "Margens e colunas"), ("Bulleted & Numbered Lists", "Listas com marcadores e numeradas"), ("Show Hidden Characters", "Mostrar caracteres ocultos"), ("Current Page Number", "Número da página atual"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 8f1d27f8..a92229fd 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Back", "Назад"), + ("Margins and Columns", "Поля та колонки"), ("Bulleted & Numbered Lists", "Марковані й нумеровані списки"), ("Current Page Number", "Номер поточної сторінки"), ("Apply Color (,)", "Застосувати колір (,)"), diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 52ce7e68..dff9d1ab 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -84,7 +84,9 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.spacing_mut().item_spacing = vec2(6.0, 5.0); let info = sel_info(app); let has_text = app.session.active().is_some_and(|s| s.selection.text.is_some()); - selection_band(ui, &selection_title(app, &info)); + if !(info.is_none() && !has_text && app.session.tool_id() == "page") { + selection_band(ui, &selection_title(app, &info)); + } if has_text { text_style_section(app, ui); divider(ui); @@ -104,6 +106,14 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { quick_actions_text(app, ui); return; } + // The Page tool with no objects selected: the page's own settings, as InDesign's. + if info.is_none() && app.session.tool_id() == "page" { + let page = app.session.active().and_then(|st| st.selection.page).or_else(|| crate::canvas::current_page(app)); + if let Some(page) = page { + page_sections(app, ui, page); + return; + } + } let Some(i) = info else { document_sections(app, ui); return; @@ -190,6 +200,104 @@ pub(crate) fn preset_name(w: f64, h: f64) -> &'static str { PAGE_PRESETS.iter().find(|p| (p.1 - a).abs() < 0.6 && (p.2 - b).abs() < 0.6).map(|p| p.0).unwrap_or("Custom") } +/// Properties with the Page tool (Properties › Edit Page), as InDesign's: a "Page" band with Back, +/// Page Size for this page alone (which page, preset, orientation, W/H), Margins and Columns and +/// Quick Actions. Back returns to the Selection tool and the document's properties. +fn page_sections(app: &mut DesignApp, ui: &mut Ui, page: usize) { + let u = units(app); + let lang = app.ui.language.clone(); + let Some(st) = app.session.active() else { return }; + let d = st.doc.clone(); + let Some((si, pi)) = d.page_loc(page) else { return }; + let Some(pg) = d.spreads.get(si).and_then(|s| s.pages.get(pi)).cloned() else { return }; + let n = page + 1; + let t = Tokens::get(ui.ctx()); + // The band: "Page", and Back at its end. + let band = block(ui, 33.0); + crate::rtl::paint( + ui.painter(), + pos2(band.min.x, band.center().y + 1.0), + egui::Align2::LEFT_CENTER, + crate::i18n::tr(&lang, "Page"), + crate::theme::semibold(11.5), + t.text_strong, + ); + let back = place(ui, Rect::from_min_size(pos2(band.max.x - 58.0, band.center().y - 10.0), vec2(54.0, 20.0)), |ui| { + outline_button(ui, crate::i18n::tr(&lang, "Back"), 54.0).clicked() + }); + rule(ui); + if back { + let _ = app.run("layout.selectPage", json!({"page": null})); + app.select_tool("selection"); + return; + } + section(ui, crate::i18n::tr(&lang, "Page Size"), true); + // Which page. + 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.get(page).map_or("", String::as_str), 93.0, &names, Some(page)) + }); + if let Some(p) = picked { + let _ = app.run("layout.selectPage", json!({"page": p + 1})); + crate::canvas::go_to_page(app, p); + return; + } + ui.add_space(3.0); + // Preset and orientation: this page alone. + let (w, h) = (pg.width, pg.height); + let landscape = w > h; + let b = block(ui, FIELD_H); + let mut size = None; + place(ui, sub(b, 0.0, 0.0, 93.0, FIELD_H), |ui| { + let resp = dropdown(ui, crate::i18n::tr(&lang, preset_name(w, h)), 93.0); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(140.0); + for p in PAGE_PRESETS { + if ui.selectable_label(preset_name(w, h) == p.0, crate::i18n::tr(&lang, p.0)).clicked() { + size = Some(if landscape { (p.2, p.1) } else { (p.1, p.2) }); + ui.close(); + } + } + }); + }); + place(ui, sub(b, 126.0, 0.0, 52.0, FIELD_H), |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + if icon_toggle(ui, "orient-portrait", !landscape, crate::i18n::tr(&lang, "Portrait")).clicked() && landscape { + size = Some((h, w)); + } + if icon_toggle(ui, "orient-landscape", landscape, crate::i18n::tr(&lang, "Landscape")).clicked() && !landscape { + size = Some((h, w)); + } + }); + ui.add_space(3.0); + let b = block(ui, 2.0 * FIELD_H + 5.0); + for (row, (label, v, key)) in [("W:", w, "width"), ("H:", h, "height")].into_iter().enumerate() { + let y = row as f32 * 26.0; + place(ui, sub(b, 0.0, y, 20.0, FIELD_H), |ui| widgets::caption_w(ui, label, 20.0)); + let nv = place(ui, sub(b, 21.0, y, 72.0, FIELD_H), |ui| { + NumField::measure(&format!("page_{key}"), Some(v), u).width(72.0).spinner().range(1.0, 15552.0).show(ui) + }); + if let Some(nv) = nv { + size = Some(if key == "width" { (nv, h) } else { (w, nv) }); + } + } + if let Some((nw, nh)) = size { + let _ = app.run("layout.pageSize", json!({"pages": [n], "width": nw, "height": nh, "anchor": app.ui.ref_point.min(8)})); + } + ui.add_space(6.0); + let fw = full_width(ui); + if outline_button(ui, crate::i18n::tr(&lang, "Margins and Columns"), fw).clicked() { + crate::canvas::go_to_page(app, page); + crate::menus::activate(app, "layout.marginsAndColumns", &Value::Null); + } + divider(ui); + section(ui, crate::i18n::tr(&lang, "Quick Actions"), true); + if outline_button(ui, crate::i18n::tr(&lang, "Import File"), fw).clicked() { + let _ = app.run("app.placeDialog", json!({})); + } +} + fn document_sections(app: &mut DesignApp, ui: &mut Ui) { let u = units(app); let Some(st) = app.session.active() else { return }; @@ -354,7 +462,9 @@ fn document_sections(app: &mut DesignApp, ui: &mut Ui) { } ui.add_space(3.0); if outline_button(ui, crate::i18n::tr(&app.ui.language, "Edit Page"), fw).clicked() { - crate::dialogs::open_document_setup(app); + // As in InDesign: the Page tool on this page; Properties shows the page's own settings. + app.select_tool("page"); + let _ = app.run("layout.selectPage", json!({"page": cur + 1})); } divider(ui); // Rulers & Grids / Guides: icon toggle rows. @@ -2542,6 +2652,36 @@ mod tests { h } + /// As InDesign's: Edit Page takes the Page tool to the page in view, and Properties sets that + /// page alone; Back returns to the document. + #[test] + fn edit_page_shows_the_page_and_sets_its_size_alone() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 3, "facingPages": false})).unwrap(); + app.ui.dock_tab = "properties".into(); + let mut h = crate::test_window::open(app, vec2(1400.0, 900.0)); + crate::canvas::go_to_page(&mut h.state_mut().app, 1); + h.run_steps(3); + h.get_by_label("Edit Page").click(); + h.run_steps(3); + let st = h.state().app.session.active().unwrap(); + assert_eq!((h.state().app.session.tool_id(), st.selection.page), ("page", Some(1))); + assert!(h.query_by_label("Document Setup").is_none(), "no dialog"); + let _ = h.get_by_label("Page Size"); + let _ = h.get_by_label("Margins and Columns"); + h.get_by_label("Landscape").click(); + h.run_steps(3); + let doc = &h.state().app.session.active().unwrap().doc; + let size = |abs: usize| doc.page(abs).map(|p| (p.width, p.height)).unwrap(); + assert_eq!(size(1), (792.0, 612.0), "the page turns"); + assert_eq!((size(0), size(2)), ((612.0, 792.0), (612.0, 792.0)), "the others don't"); + h.get_by_label("Back").click(); + h.run_steps(3); + let st = h.state().app.session.active().unwrap(); + assert_eq!((h.state().app.session.tool_id(), st.selection.page), ("selection", None)); + let _ = h.get_by_label("Edit Page"); + } + #[test] fn links_panel_distinguishes_same_named_sources_and_keeps_link_actions() { let first = "/project/assets/studio-a/photo.svg"; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 44316fda..b4e0c4ab 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -334,6 +334,8 @@ pub fn outline_button(ui: &mut Ui, label: &str, width: f32) -> Response { } p.rect_stroke(r, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); crate::rtl::paint(p, r.center(), egui::Align2::CENTER_CENTER, label, label_font(), t.text); + // Screen readers (and tests) find it by its label. + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); resp } From 7313bdfc93241b3fc3a98b40cc2aa7c304e349c8 Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:00:08 +0200 Subject: [PATCH 12/41] TUT4.1: show and clear text style overrides in Styles panels --- ROADMAP.md | 3 + crates/engine/src/cmd/text.rs | 11 +++- crates/ui-egui/src/panels/styles.rs | 96 +++++++++++++++++++++++++---- docs/ui-parity.md | 10 ++- 4 files changed, 105 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf4..9c1ab281 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -343,6 +343,9 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-10-15** · Paragraph Styles marks local character overrides and offers Clear Overrides + in the style row menu; named character styles and equal local values are not false overrides. + - **2026-10-13** · Switching between document and parent pages refreshes the canvas immediately, preventing black parent pages and stale document objects when the zoom stays the same. Ordinary edits retain partial repainting. No readiness percentage change. Newest first. diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 432297f3..8d7abaa6 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -1279,9 +1279,18 @@ fn selection_attrs(s: &mut Session, _p: &Value) -> Result { let (pp, base) = st.doc.styles.resolve_para(pf); let cf = effective_char_format(s, &Target { story: sid, cell, range: r.clone() })?; let cp = st.doc.styles.resolve_char(&base, &cf); + let (style_para, style_chars) = st.doc.styles.resolve_para_style(&pf.style); + let mut character_base = base.clone(); + let character_style = st.doc.styles.char_style_attrs(&cf.style); + character_base.apply(&character_style); + 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(); let selected = selected_text_preview(story.text.get(r.start..r.end).unwrap_or_default().chars()); Ok(json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp, - "paraOverrides": pf.para.count(), "charOverrides": cf.over.count(), "selectedText": selected.trim()})) + "paraOverrides": style_para.delta(&pp).count() + style_chars.delta(&base).count(), + "charOverrides": char_delta.count(), "characterStyleOverrides": character_style_overrides, + "selectedText": selected.trim()})) } /// The first target character, with any pending caret overrides. Keep character-style diff --git a/crates/ui-egui/src/panels/styles.rs b/crates/ui-egui/src/panels/styles.rs index fb080c79..dd793ef5 100644 --- a/crates/ui-egui/src/panels/styles.rs +++ b/crates/ui-egui/src/panels/styles.rs @@ -16,7 +16,16 @@ fn list(app: &mut DesignApp, ui: &mut egui::Ui, para: bool) { }; let attrs = super::text_attrs(app); let current = attrs.as_ref().and_then(|a| a[if para { "paragraphStyle" } else { "characterStyle" }].as_str().map(str::to_string)); - let overrides = attrs.as_ref().map(|a| a[if para { "paraOverrides" } else { "charOverrides" }].as_u64().unwrap_or(0)).unwrap_or(0); + let overrides = attrs + .as_ref() + .map(|a| { + if para { + a["charOverrides"].as_u64().unwrap_or(0) + a["paraOverrides"].as_u64().unwrap_or(0) + } else { + a["characterStyleOverrides"].as_u64().unwrap_or(0) + } + }) + .unwrap_or(0); crate::rtl::label( ui, egui::RichText::new(match ¤t { @@ -37,12 +46,12 @@ fn list(app: &mut DesignApp, ui: &mut egui::Ui, para: bool) { if para && n == designcraft_doc::NO_PARA_STYLE { continue; } - style_row(app, ui, para, n, current.as_deref(), &groups, &t); + style_row(app, ui, para, n, current.as_deref(), overrides > 0, &groups, &t); } for g in &groups { egui::CollapsingHeader::new(g.as_str()).id_salt(("style_group", para, g)).default_open(true).show(ui, |ui| { for n in names.iter().filter(|n| n.rsplit_once('/').is_some_and(|(x, _)| x == g)) { - style_row(app, ui, para, n, current.as_deref(), &groups, &t); + style_row(app, ui, para, n, current.as_deref(), overrides > 0, &groups, &t); } }); } @@ -50,7 +59,12 @@ fn list(app: &mut DesignApp, ui: &mut egui::Ui, para: bool) { ui.horizontal(|ui| { ui.label(crate::rtl::widget( ui, - egui::RichText::new(crate::i18n::tr(&app.ui.language, "⌥-click clears overrides")).size(10.5).color(t.text_disabled), + egui::RichText::new(crate::i18n::tr( + &app.ui.language, + if cfg!(target_os = "macos") { "⌥-click clears overrides" } else { "Alt-click clears overrides" }, + )) + .size(10.5) + .color(t.text_disabled), )); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if icons::button( @@ -80,7 +94,7 @@ pub fn character(app: &mut DesignApp, ui: &mut egui::Ui) { /// One style in the list: click applies (⌥ clears overrides), double-click edits, /// right-click: edit, apply, break link, move to group. -fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, current: Option<&str>, groups: &[String], t: &Tokens) { +fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, current: Option<&str>, overridden: bool, groups: &[String], t: &Tokens) { let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); if current == Some(n) { ui.painter().rect_filled(row, 0.0, t.row_selected); @@ -88,14 +102,10 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren ui.painter().rect_filled(row, 0.0, t.hover); } let shown = n.rsplit('/').next().unwrap_or(n); - crate::rtl::paint( - ui.painter(), - row.min + vec2(8.0, 11.0), - egui::Align2::LEFT_CENTER, - if n.contains('/') { shown } else { crate::i18n::style_name(&app.ui.language, shown) }, - egui::FontId::proportional(12.5), - t.text, - ); + let label = if n.contains('/') { shown } else { crate::i18n::style_name(&app.ui.language, shown) }; + let row_label = format!("{label}{}", if current == Some(n) && overridden { "+" } else { "" }); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, current == Some(n), &row_label)); + crate::rtl::paint(ui.painter(), row.min + vec2(8.0, 11.0), egui::Align2::LEFT_CENTER, &row_label, egui::FontId::proportional(12.5), t.text); let editable = n != if para { designcraft_doc::NO_PARA_STYLE } else { designcraft_doc::NO_CHAR_STYLE }; if resp.double_clicked() && editable { crate::dialogs::open_style_options(app, para, n); @@ -116,6 +126,14 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren let _ = app.run(cmd, json!({"name": n})); ui.close(); } + if para + && ui + .button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Apply \"{name}\", Clear Overrides").replace("{name}", label))) + .clicked() + { + let _ = app.run("style.paragraph.apply", json!({"name": n, "clearOverrides": true})); + ui.close(); + } if current == Some(n) && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Break Link to Style"))).clicked() { let _ = app.run("style.breakLink", json!({"kind": kind})); ui.close(); @@ -171,6 +189,58 @@ mod tests { use super::*; + #[test] + fn paragraph_color_override_is_marked_and_cleared_from_the_row_menu() { + 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,300,200], "content": "text", "text": "Header"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 6})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Header", "chars": {"fill": "[Black]"}})).unwrap(); + app.run("style.paragraph.apply", json!({"name": "Header"})).unwrap(); + app.run("style.character.create", json!({"name": "Emphasis", "chars": {"underline": true}})).unwrap(); + app.run("style.character.apply", json!({"name": "Emphasis"})).unwrap(); + app.run("type.char", json!({"fill": "[Paper]"})).unwrap(); + let mut h = Harness::builder().with_size(vec2(460.0, 300.0)).build_ui_state(|ui, app: &mut DesignApp| paragraph(app, ui), app); + h.run_steps(3); + let at = h.get_all_by_label("Header+").next_back().unwrap().rect().center(); + h.event(egui::Event::PointerMoved(at)); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }); + h.run_steps(2); + } + h.get_by_label("Apply \"Header\", Clear Overrides").click(); + h.run_steps(3); + let app = h.state_mut(); + let a = app.run("type.selectionAttrs", json!({})).unwrap(); + assert_eq!(a["chars"]["fill"], "[Black]"); + assert_eq!(a["chars"]["underline"], true); + assert_eq!(a["characterStyle"], "Emphasis"); + assert_eq!(a["charOverrides"], 0); + assert_eq!(a["paraOverrides"], 0); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.run("type.selectionAttrs", json!({})).unwrap()["chars"]["fill"], "[Paper]"); + } + + #[test] + fn named_character_styles_and_redundant_local_values_are_not_paragraph_overrides() { + 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,300,200], "content": "text", "text": "Body text"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 4})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Body", "para": {"spaceAfter": 6}, "chars": {"size": 10}})).unwrap(); + app.run("style.paragraph.apply", json!({"name": "Body"})).unwrap(); + app.run("style.character.create", json!({"name": "Italic", "chars": {"fontStyle": "Italic"}})).unwrap(); + app.run("style.character.apply", json!({"name": "Italic"})).unwrap(); + app.run("type.char", json!({"size": 10, "fontStyle": "Italic"})).unwrap(); + app.run("type.para", json!({"spaceAfter": 6})).unwrap(); + let a = app.run("type.selectionAttrs", json!({})).unwrap(); + assert_eq!((a["paraOverrides"].as_u64(), a["charOverrides"].as_u64()), (Some(0), Some(0))); + let mut h = Harness::builder().with_size(vec2(360.0, 240.0)).build_ui_state(|ui, app: &mut DesignApp| paragraph(app, ui), app); + h.run_steps(3); + assert!(h.query_by_label("Body+").is_none()); + assert!(h.query_all_by_label("Body").next().is_some()); + } + #[test] fn character_style_plus_captures_selection_for_reuse() { 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 30512bdf..b50d6a65 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-11 · **Change:** minor (document views row: scrollbars, Alt pans while editing text) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (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 @@ -61,6 +61,14 @@ context menus, typing at a caret) are things a layout artist does every minute, ## Remaining effort +The Paragraph Styles panel now includes character formatting in its `+` indicator, on both +the current-style summary and the selected row. The row menu offers Apply "name", Clear +Overrides through the existing command, retaining named character styles. Selection attribute +counts compare effective values with their style definitions, so equal local values and named +character styles alone do not report overrides. This covers the color-override/clear workflow +at 4:25–5:20 in [Jessica Garrity's styles tutorial](https://www.youtube.com/watch?v=pG9JkAPLgs8). +Redefine Style and style-dialog Preview remain separate gaps. + | Work | Opus 5.5 h | |---|---| | Copy/paste, context menus, threading clicks, caret formatting on every platform, with control-channel regression tests | 30–50 | From 4f3a55a190e1191f5625b302d86825bf3161ec2a Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:01:13 +0200 Subject: [PATCH 13/41] TUT3.1: let captured text inherit later style edits --- ROADMAP.md | 3 + crates/doc/src/attrs.rs | 4 + crates/engine/src/cmd/style.rs | 142 +++++++++++++++++++++++++++++++-- docs/ui-parity.md | 7 ++ 4 files changed, 151 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf4..6766495a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -343,6 +343,9 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-10-15** · Applying text styles removes matching local formatting so capture sources + inherit later edits; real overrides and named character styles remain. + - **2026-10-13** · Switching between document and parent pages refreshes the canvas immediately, preventing black parent pages and stale document objects when the zoom stays the same. Ordinary edits retain partial repainting. No readiness percentage change. Newest first. diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index b100e19a..64e26415 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -43,6 +43,10 @@ macro_rules! attr_set { pub fn count(&self) -> usize { 0 $( + self.$field.is_some() as usize )* } + /// Remove local values already supplied by the applied style, keeping real differences. + pub fn retain_differences(&mut self, base: &$props) { + $( if self.$field.as_ref() == Some(&base.$field) { self.$field = None; } )* + } /// Field names that are set (camelCase). pub fn keys(&self) -> Vec<&'static str> { let mut v = Vec::new(); diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 1cccc7fd..81b9af5e 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -1,5 +1,104 @@ //! Paragraph / character styles and swatches. +#[cfg(test)] +mod application_inheritance_tests { + use super::*; + + fn setup(text: &str) -> (Session, Value) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [36,36,300,300], "content": "text", "text": text})).unwrap(); + (s, f["story"].clone()) + } + fn select(s: &mut Session, story: &Value, start: usize, end: usize) { + s.execute("text.select", &json!({"story": story, "anchor": start, "focus": end})).unwrap(); + } + fn attrs(s: &mut Session) -> Value { + s.execute("type.selectionAttrs", &json!({})).unwrap() + } + + #[test] + fn paragraph_capture_source_follows_later_style_edits_and_undo() { + let (mut s, story) = setup("First\nSecond"); + select(&mut s, &story, 0, 5); + s.execute("type.char", &json!({"size": 18, "fill": "[Paper]"})).unwrap(); + s.execute("type.para", &json!({"spaceAfter": 6})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Header", "fromSelection": true})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("style.paragraph.apply", &json!({"name": "Header"})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + s.execute("edit.redo", &json!({})).unwrap(); + select(&mut s, &story, 6, 6); + s.execute("style.paragraph.apply", &json!({"name": "Header"})).unwrap(); + s.execute("style.paragraph.edit", &json!({"name": "Header", "chars": {"size": 20, "fill": "[Black]"}, "para": {"spaceAfter": 12}})).unwrap(); + for pos in [0, 6] { + select(&mut s, &story, pos, pos + 1); + let a = attrs(&mut s); + assert_eq!(a["chars"]["size"], 20.0); + assert_eq!(a["chars"]["fill"], "[Black]"); + assert_eq!(a["para"]["spaceAfter"], 12.0); + } + } + + #[test] + fn applying_styles_keeps_different_overrides_and_named_character_styles() { + let (mut s, story) = setup("First\nSecond"); + select(&mut s, &story, 0, 5); + s.execute("type.char", &json!({"size": 14})).unwrap(); + s.execute("type.para", &json!({"spaceAfter": 9})).unwrap(); + s.execute("style.character.create", &json!({"name": "Emphasis", "chars": {"underline": true}})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Body", "chars": {"size": 10}, "para": {"spaceAfter": 6}})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Body"})).unwrap(); + let a = attrs(&mut s); + assert_eq!(a["chars"]["size"], 14.0); + assert_eq!(a["para"]["spaceAfter"], 9.0); + assert_eq!(a["characterStyle"], "Emphasis"); + assert_eq!(a["chars"]["underline"], true); + } + + #[test] + fn character_capture_source_remains_sparse_and_follows_style_edits() { + let (mut s, story) = setup("First\nSecond"); + select(&mut s, &story, 0, 5); + s.execute("type.char", &json!({"underline": true})).unwrap(); + s.execute("style.character.create", &json!({"name": "Emphasis", "fromSelection": true})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap(); + s.execute("style.character.edit", &json!({"name": "Emphasis", "chars": {"underline": false}})).unwrap(); + assert_eq!(attrs(&mut s)["chars"]["underline"], false); + assert!(s.doc().unwrap().doc.styles.char_style("Emphasis").unwrap().chars.size.is_none()); + s.execute("style.paragraph.edit", &json!({"name": designcraft_doc::BASIC_PARAGRAPH, "chars": {"size": 20}})).unwrap(); + assert_eq!(attrs(&mut s)["chars"]["size"], 20.0); + } + + #[test] + fn paragraph_selection_excludes_the_next_paragraph_at_its_start() { + let (mut s, story) = setup("é\nNext"); + select(&mut s, &story, 0, 3); + s.execute("style.paragraph.create", &json!({"name": "Header"})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Header", "clearOverrides": true})).unwrap(); + select(&mut s, &story, 3, 4); + assert_eq!(attrs(&mut s)["paragraphStyle"], designcraft_doc::BASIC_PARAGRAPH); + select(&mut s, &story, 0, 2); + assert_eq!(attrs(&mut s)["paragraphStyle"], "Header"); + } + + #[test] + fn clearing_a_partial_selection_preserves_character_overrides_outside_it() { + let (mut s, story) = setup("First Second"); + select(&mut s, &story, 0, 12); + s.execute("type.char", &json!({"size": 18})).unwrap(); + select(&mut s, &story, 0, 5); + s.execute("style.paragraph.apply", &json!({"name": designcraft_doc::BASIC_PARAGRAPH, "clearOverrides": true})).unwrap(); + assert_ne!(attrs(&mut s)["chars"]["size"], 18.0); + select(&mut s, &story, 6, 12); + assert_eq!(attrs(&mut s)["chars"]["size"], 18.0); + } +} + use designcraft_color::{Swatch, SwatchValue}; use designcraft_doc::{CharAttrs, CharacterStyle, ParaAttrs, ParagraphStyle, Styles}; use serde_json::{Value, json}; @@ -405,19 +504,39 @@ 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) { - st.format_paras(r.clone(), |f| { + // 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); } }); - if clear { - let (a, b) = (st.para_ranges()[st.para_at(r.start)].start, st.para_ranges()[st.para_at(r.end)].end); - st.format_chars(a..b, |f| f.over = CharAttrs::default()); + 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); + } + }); } } } @@ -432,9 +551,22 @@ fn apply_char(s: &mut Session, p: &Value) -> Result { } let targets = super::text::format_targets_pub(s); s.edit(|d, _| { + let styles = d.styles.clone(); for t in &targets { if let Some(st) = d.text_story_mut(t.story, t.cell) { - st.format_chars(t.range.clone(), |f| f.style = name.clone()); + 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); + }); + } } } ok() diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 30512bdf..c5522396 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -61,6 +61,13 @@ context menus, typing at a caret) are things a layout artist does every minute, ## 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 +as exclusive; Clear Overrides removes character formatting only inside a nonempty selection. +Regressions cover the capture/edit workflow at 2:16 and 6:02 in +[Jessica Garrity's styles tutorial](https://www.youtube.com/watch?v=pG9JkAPLgs8). + | Work | Opus 5.5 h | |---|---| | Copy/paste, context menus, threading clicks, caret formatting on every platform, with control-channel regression tests | 30–50 | From e298455174a5c4f2001a86f2a5256aefaa7c9702 Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:05:55 +0200 Subject: [PATCH 14/41] TUT4.2: preview edits in text style options --- ROADMAP.md | 3 + crates/ui-egui/src/dialogs.rs | 145 ++++++++++++++++++++++++++++++++++ docs/ui-parity.md | 7 ++ 3 files changed, 155 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf4..50f3cf8c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -343,6 +343,9 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-10-15** · Existing paragraph and character styles preview their attribute edits in + Style Options; Cancel restores the document and OK records one edit. + - **2026-10-13** · Switching between document and parent pages refreshes the canvas immediately, preventing black parent pages and stale document objects when the zoom stays the same. Ordinary edits retain partial repainting. No readiness percentage change. Newest first. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7d02776a..ab83f176 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2208,6 +2208,10 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { _ => " OK ", }; ui.horizontal(|ui| { + if matches!(d.id.as_str(), "paragraphStyleOptions" | "characterStyleOptions") && !d.b("new") { + check(ui, &mut d, "preview", crate::i18n::tr(&app.ui.language, "Preview")); + style_options_preview(app, &mut d); + } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui .add( @@ -2270,6 +2274,10 @@ fn find_change_params(d: &Dialog) -> Value { /// 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()) }; + if matches!(d.id.as_str(), "paragraphStyleOptions" | "characterStyleOptions") && d.fields.contains_key(PREVIEWED) { + // Restore the original before the ordinary command commits one undoable edit. + app.session.cancel_interaction(); + } if d.id != "pdfImport" || !d.b("async") { app.cancel_pdf_import(); } @@ -2799,6 +2807,62 @@ fn regex_ok(p: &str) -> Result<(), ()> { regex::Regex::new(p).map(|_| ()).map_err(|_| ()) } +/// Preview the changed style attributes through the same engine commands as OK. Renaming and +/// export tagging have no visual effect and wait for OK, so the dialog can keep looking up the +/// original style while the preview is visible. The interaction restores its starting document +/// before each update, including after a field is cleared or Preview is switched off. +fn style_options_preview(app: &mut DesignApp, d: &mut Dialog) { + let shown = d.fields.get(PREVIEWED).cloned(); + if !d.b("preview") || d.b("new") { + if shown.is_some() { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + d.fields.remove("status"); + } + return; + } + let paragraph = d.id == "paragraphStyleOptions"; + let mut params = json!({"name": d.s("name"), "chars": {}}); + if paragraph { + params["para"] = json!({}); + } + for (key, value) in &d.fields { + if let Some(attr) = key.strip_prefix("c.") { + params["chars"][attr] = value.clone(); + } else if paragraph && let Some(attr) = key.strip_prefix("p.") { + if let Some((attr, field)) = attr.split_once('.') { + if params["para"][attr].is_null() { + params["para"][attr] = json!({}); + } + params["para"][attr][field] = value.clone(); + } else { + params["para"][attr] = value.clone(); + } + } + } + if let Some(based) = d.fields.get("basedOn") { + params["basedOn"] = match based.as_str() { + Some(designcraft_doc::NO_PARA_STYLE | designcraft_doc::NO_CHAR_STYLE) => Value::Null, + _ => based.clone(), + }; + } + if shown.as_ref() == Some(¶ms) { + return; + } + if shown.is_none() + && let Err(error) = app.session.begin_interaction("Style Options") + { + d.fields.insert("status".into(), json!(error.to_string())); + return; + } + let command = if paragraph { "style.paragraph.edit" } else { "style.character.edit" }; + match app.session.preview(command, params.clone()) { + Ok(()) => d.fields.remove("status"), + Err(error) => d.fields.insert("status".into(), json!(error.to_string())), + }; + d.fields.insert(PREVIEWED.into(), params); +} + fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let name = d.s("name"); let new = d.b("new"); @@ -4809,6 +4873,87 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod tests { use super::*; + #[test] + fn style_options_preview_updates_all_uses_cancels_and_commits_once() { + use egui_kittest::{Harness, kittest::Queryable}; + for paragraph in [true, false] { + 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,300,300],"content":"text","text":"First\nSecond"})).unwrap(); + app.run("text.select", json!({"story":f["story"],"anchor":0,"focus":12})).unwrap(); + let kind = if paragraph { "paragraph" } else { "character" }; + app.run(&format!("style.{kind}.create"), json!({"name":"Tutorial","chars":{"size":10}})).unwrap(); + app.run(&format!("style.{kind}.apply"), json!({"name":"Tutorial"})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + let undo = app.session.doc().unwrap().history.undo.len(); + let journal = app.session.journal.len(); + open_style_options(&mut app, paragraph, "Tutorial"); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("style_preview_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("Preview").click(); + h.run_steps(3); + h.state_mut().ui.dialog.as_mut().unwrap().fields.insert("c.size".into(), json!(18)); + h.run_steps(3); + let app = h.state_mut(); + assert_eq!(app.run("type.selectionAttrs", json!({})).unwrap()["chars"]["size"], 18.0); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo); + assert_eq!(app.session.journal.len(), journal); + // Repeated previews replace each other, never accumulate undo entries. + app.ui.dialog.as_mut().unwrap().fields.insert("c.size".into(), json!(20)); + h.run_steps(3); + assert_eq!(h.state_mut().run("type.selectionAttrs", json!({})).unwrap()["chars"]["size"], 20.0); + h.get_by_label("Preview").click(); + h.run_steps(3); + assert_eq!(h.state().session.doc().unwrap().doc, before); + h.get_by_label("Preview").click(); + h.run_steps(3); + cancel(h.state_mut()); + assert_eq!(h.state().session.doc().unwrap().doc, before); + assert_eq!(h.state().session.doc().unwrap().history.undo.len(), undo); + open_style_options(h.state_mut(), paragraph, "Tutorial"); + let d = h.state_mut().ui.dialog.as_mut().unwrap(); + d.fields.insert("preview".into(), json!(true)); + d.fields.insert("c.size".into(), json!(24)); + h.run_steps(3); + confirm(h.state_mut()).unwrap(); + assert_eq!(h.state().session.doc().unwrap().history.undo.len(), undo + 1); + // Both paragraphs/character runs still use the globally edited definition. + for pos in [0, 6] { + h.state_mut().run("text.select", json!({"story":f["story"],"anchor":pos,"focus":pos+1})).unwrap(); + assert_eq!(h.state_mut().run("type.selectionAttrs", json!({})).unwrap()["chars"]["size"], 24.0); + } + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(h.state().session.doc().unwrap().doc, before); + } + } + + #[test] + fn invalid_style_preview_leaves_the_starting_document_and_can_be_cancelled() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("style.character.create", json!({"name":"Emphasis"})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + let mut d = Dialog::new("characterStyleOptions", json!({"name":"Emphasis","preview":true,"basedOn":"Emphasis"})); + style_options_preview(&mut app, &mut d); + assert!(d.fields.contains_key("status")); + assert_eq!(app.session.doc().unwrap().doc, before); + app.ui.dialog = Some(d); + cancel(&mut app); + assert_eq!(app.session.doc().unwrap().doc, before); + assert!(app.session.doc().unwrap().interaction.is_none()); + } + #[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/docs/ui-parity.md b/docs/ui-parity.md index 30512bdf..0bf86ec2 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -61,6 +61,13 @@ context menus, typing at a caret) are things a layout artist does every minute, ## Remaining effort +Paragraph and Character Style Options now preview edits to existing styles. Preview updates +every use, remains outside undo history, and restores the starting document when disabled or +cancelled. OK applies the final edit once. The color-preview step at about 4:00 in +[Jessica Garrity's styles tutorial](https://www.youtube.com/watch?v=pG9JkAPLgs8) is reproducible +offscreen. New-style creation, renaming and export tagging are committed on OK; they are not +included in the visual preview. + | Work | Opus 5.5 h | |---|---| | Copy/paste, context menus, threading clicks, caret formatting on every platform, with control-channel regression tests | 30–50 | From 4ea23d4ccd8bc3452a51e6a4598845780f2d6370 Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:07:12 +0200 Subject: [PATCH 15/41] TUT1.1: restore the Typography workspace panel groups --- ROADMAP.md | 3 +++ crates/ui-egui/src/menus.rs | 48 +++++++++++++++++++++++++++++++++---- docs/ui-parity.md | 7 ++++++ 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf4..a4d99dca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -343,6 +343,9 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-10-15** · Typography uses named panel groups with the Control panel and restores that + arrangement on Reset Workspace, leaving the page clear until a panel is opened. + - **2026-10-13** · Switching between document and parent pages refreshes the canvas immediately, preventing black parent pages and stale document objects when the zoom stays the same. Ordinary edits retain partial repainting. No readiness percentage change. Newest first. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a35261d5..b093c649 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -844,6 +844,17 @@ pub const CLASSIC_GROUPS: &[&[&str]] = &[ &["gradient"], ]; +/// The Typography preset's available panels in the order observed in InDesign's workspace. +/// CC Libraries and the separate Story panel have no corresponding DesignCraft panel. +pub const TYPOGRAPHY_GROUPS: &[&[&str]] = &[ + &["pages", "layers", "swatches"], + &["glyphs", "textWrap"], + &["stroke", "gradient"], + &["hyperlinks", "effects"], + &["paragraph", "paragraphStyles"], + &["character", "characterStyles"], +]; + pub fn ui_label(id: &str) -> Option<(&'static str, Option<&'static str>)> { UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| (c.1, c.2)) } @@ -1627,11 +1638,14 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option Some(CLASSIC_GROUPS), + "Typography" => Some(TYPOGRAPHY_GROUPS), + _ => None, + }; + app.ui.icon_groups = groups.map(|groups| groups.iter().map(|g| g.iter().map(|p| p.to_string()).collect()).collect()); + app.ui.dock_expanded = groups.is_none(); let dock_tab = match name { - "Typography" => Some("properties"), "Interactive for PDF" | "Digital Publishing" => Some("pages"), _ => None, }; @@ -1640,7 +1654,6 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option Some("paragraphStyles".into()), "Printing and Proofing" => Some("swatches".into()), "Interactive for PDF" => Some("buttons".into()), "Digital Publishing" => Some("liquid".into()), @@ -3956,6 +3969,31 @@ mod tests { assert_eq!(app.ui.icon_groups, Some(groups)); } + #[test] + fn typography_workspace_opens_its_panels_from_named_icon_groups_and_resets() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + run_ui(&mut app, "window.workspace", &json!({"name":"Typography"})).unwrap().unwrap(); + assert!(app.ui.control_bar); + assert!(!app.ui.dock_expanded); + assert!(app.ui.open_panel.is_none()); + let groups = app.ui.icon_groups.clone().unwrap(); + assert_eq!(groups[0], ["pages", "layers", "swatches"]); + assert_eq!(groups[4], ["paragraph", "paragraphStyles"]); + assert_eq!(groups[5], ["character", "characterStyles"]); + for panel in ["pages", "paragraphStyles", "characterStyles"] { + run_ui(&mut app, "window.panel", &json!({"panel":panel})).unwrap().unwrap(); + assert_eq!(app.ui.open_panel.as_deref(), Some(panel)); + assert!(!app.ui.dock_expanded); + } + run_ui(&mut app, "window.resetWorkspace", &json!({})).unwrap().unwrap(); + assert_eq!(app.ui.icon_groups, Some(groups)); + assert!(app.ui.open_panel.is_none()); + assert!(!app.ui.dock_expanded); + run_ui(&mut app, "window.workspace", &json!({"name":"Essentials"})).unwrap().unwrap(); + assert!(app.ui.icon_groups.is_none()); + assert!(app.ui.dock_expanded); + } + #[test] fn the_window_menu_is_in_indesigns_order_and_reaches_every_panel() { let window = menu_tree().into_iter().find(|(m, _)| *m == "Window").unwrap().1; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 30512bdf..4686426b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -61,6 +61,13 @@ context menus, typing at a caret) are things a layout artist does every minute, ## Remaining effort +Typography now starts and resets with the Control panel and named panel groups along the right +edge, including Paragraph/Paragraph Styles and Character/Character Styles. It does not open +Properties or a Styles flyout over the new page. This matches the available panels at 0:05–0:15 +in [Jessica Garrity's styles tutorial](https://www.youtube.com/watch?v=pG9JkAPLgs8). +CC Libraries and a separate Story panel are absent; these are not needed for the tutorial's +style operations. The flyout tabs and detailed controls remain separate UI parity work. + | Work | Opus 5.5 h | |---|---| | Copy/paste, context menus, threading clicks, caret formatting on every platform, with control-channel regression tests | 30–50 | From 8fb944fe06ea9ffba02569d6a1ccd1821b442531 Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:12:09 +0200 Subject: [PATCH 16/41] TUT4.3: redefine text styles from local formatting --- ROADMAP.md | 3 + crates/engine/src/cmd/style.rs | 168 ++++++++++++++++++++++++++++ crates/ui-egui/src/panels/styles.rs | 33 ++++++ docs/ui-parity.md | 7 ++ 4 files changed, 211 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf4..e7ca95e7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -343,6 +343,9 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-10-15** · Redefine paragraph and character styles from local formatting, including the + Styles panel context menu, global updates and one-step undo. + - **2026-10-13** · Switching between document and parent pages refreshes the canvas immediately, preventing black parent pages and stale document objects when the zoom stays the same. Ordinary edits retain partial repainting. No readiness percentage change. Newest first. diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 1cccc7fd..41fefef1 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -1,5 +1,82 @@ //! Paragraph / character styles and swatches. +#[cfg(test)] +mod redefine_tests { + use super::*; + + fn sample() -> (Session, Value, Value) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect":[36,36,300,200],"content":"text","text":"First header"})).unwrap(); + let b = s.execute("frame.create", &json!({"rect":[36,240,300,400],"content":"text","text":"Second header"})).unwrap(); + s.execute("style.paragraph.create", &json!({"name":"Header","chars":{"size":18,"fill":"[Paper]"}})).unwrap(); + for story in [&a["story"], &b["story"]] { + s.execute("text.select", &json!({"story":story,"anchor":0,"focus":5})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name":"Header"})).unwrap(); + } + s.execute("text.select", &json!({"story":a["story"],"anchor":0,"focus":5})).unwrap(); + (s, a["story"].clone(), b["story"].clone()) + } + + #[test] + fn redefine_paragraph_updates_other_stories_and_clears_the_source_overrides() { + let (mut s, a, b) = sample(); + s.execute("style.character.create", &json!({"name":"Emphasis","chars":{"underline":true}})).unwrap(); + s.execute("style.character.apply", &json!({"name":"Emphasis"})).unwrap(); + s.execute("type.char", &json!({"size":20,"fill":"[Black]"})).unwrap(); + s.execute("type.para", &json!({"spaceAfter":9})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("style.paragraph.redefine", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + assert!(s.doc().unwrap().doc.styles.para("Header").unwrap().chars.underline.is_none()); + for story in [&a, &b] { + s.execute("text.select", &json!({"story":story,"anchor":0,"focus":5})).unwrap(); + let attrs = s.execute("type.selectionAttrs", &json!({})).unwrap(); + assert_eq!(attrs["chars"]["size"], 20.0); + assert_eq!(attrs["chars"]["fill"], "[Black]"); + assert_eq!(attrs["para"]["spaceAfter"], 9.0); + assert_eq!(attrs["chars"]["underline"], story == &a); + } + s.execute("style.paragraph.edit", &json!({"name":"Header","chars":{"size":22}})).unwrap(); + s.execute("text.select", &json!({"story":a,"anchor":0,"focus":5})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 22.0); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + } + + #[test] + fn redefine_character_does_not_capture_paragraph_font_size() { + let (mut s, a, b) = sample(); + s.execute("style.character.create", &json!({"name":"Underline","chars":{"underline":true}})).unwrap(); + for story in [&a, &b] { + s.execute("text.select", &json!({"story":story,"anchor":0,"focus":5})).unwrap(); + s.execute("style.character.apply", &json!({"name":"Underline"})).unwrap(); + } + s.execute("type.char", &json!({"fill":"[Black]"})).unwrap(); + s.execute("style.character.redefine", &json!({"name":"Underline"})).unwrap(); + let style = s.doc().unwrap().doc.styles.char_style("Underline").unwrap(); + assert!(style.chars.size.is_none()); + assert_eq!(style.chars.underline, Some(true)); + for story in [&a, &b] { + s.execute("text.select", &json!({"story":story,"anchor":0,"focus":5})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "[Black]"); + } + } + + #[test] + fn redefine_rejects_the_wrong_style_and_the_none_entry_atomically() { + let (mut s, _, _) = sample(); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + assert!(s.execute("style.paragraph.redefine", &json!({"name":"Other"})).is_err()); + assert!(s.execute("style.character.redefine", &json!({})).is_err()); + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + } +} + use designcraft_color::{Swatch, SwatchValue}; use designcraft_doc::{CharAttrs, CharacterStyle, ParaAttrs, ParagraphStyle, Styles}; use serde_json::{Value, json}; @@ -81,6 +158,24 @@ pub fn specs() -> Vec { ), cmd!("style.paragraph.apply", "Apply Paragraph Style", [], None, "{name, clearOverrides?: bool}", has_text_or_frames, apply_para), cmd!("style.character.apply", "Apply Character Style", [], None, "{name}", has_text_or_frames, apply_char), + cmd!( + "style.paragraph.redefine", + "Redefine Paragraph Style", + [], + None, + "{name?} — incorporate the first selected paragraph/character's local formatting into its paragraph style; retain character styles; one undo step", + has_text_or_frames, + |s, p| redefine(s, p, true) + ), + cmd!( + "style.character.redefine", + "Redefine Character Style", + [], + None, + "{name?} — incorporate the first selected character's local formatting into its character style without capturing paragraph attributes; one undo step", + has_text_or_frames, + |s, p| redefine(s, p, false) + ), cmd!( "style.paragraph.create", "New Paragraph Style…", @@ -397,6 +492,79 @@ fn attrs(cmd: &str, group: &str, p: &Value, set: impl Fn(&mut T, &st Ok(a) } +fn redefine(s: &mut Session, p: &Value, paragraph: bool) -> Result { + let command = if paragraph { "style.paragraph.redefine" } else { "style.character.redefine" }; + let targets = super::text::format_targets_pub(s); + let target = targets.first().ok_or_else(|| bad(command, "select text using the style to redefine"))?; + let state = s.doc()?; + let story = state.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; + let pf = story.paras.get(story.para_at(target.range.start)).ok_or_else(|| bad(command, "no paragraph at the selection"))?; + let cf = super::text::effective_char_format(s, target)?; + let selected = if paragraph { &pf.style } else { &cf.style }; + let name = str_param(p, "name").unwrap_or(selected).to_string(); + if name != *selected || name == if paragraph { designcraft_doc::NO_PARA_STYLE } else { designcraft_doc::NO_CHAR_STYLE } { + return Err(bad(command, "select text using the named style; the no-style entry cannot be redefined")); + } + let (base_para, base_chars) = state.doc.styles.resolve_para_style(&pf.style); + let (resolved_para, paragraph_chars) = state.doc.styles.resolve_para(pf); + let para_changes = base_para.delta(&resolved_para); + let mut current = if paragraph { + paragraph_chars.clone() + } else { + state.doc.styles.resolve_char(¶graph_chars, &designcraft_doc::CharFormat { style: name.clone(), over: CharAttrs::default() }) + }; + let inherited = if paragraph { base_chars } else { current.clone() }; + // A named character style is an intentional layer, not a paragraph override to absorb. + current.apply(&cf.over); + let char_changes = inherited.delta(¤t); + let result = s.edit(|d, _| { + if paragraph { + let style = d.styles_mut().para_mut(&name).ok_or_else(|| bad(command, "paragraph style no longer exists"))?; + style.para.merge(¶_changes); + style.chars.merge(&char_changes); + } else { + let style = + d.styles_mut().character.iter_mut().find(|st| st.name == name).ok_or_else(|| bad(command, "character style no longer exists"))?; + style.chars.merge(&char_changes); + } + let styles = d.styles.clone(); + for target in &targets { + let Some(story) = d.text_story_mut(target.story, target.cell) else { continue }; + let end = if target.range.is_empty() { target.range.start } else { target.range.end.saturating_sub(1) }; + let (first, last) = (story.para_at(target.range.start), story.para_at(end)); + for (index, range) in story.para_ranges().into_iter().enumerate().take(last.saturating_add(1)).skip(first) { + let Some(pf) = story.paras.get_mut(index) else { continue }; + if paragraph && pf.style != name { + continue; + } + if paragraph { + let (pp, cp) = styles.resolve_para(pf); + let (base_p, base_c) = styles.resolve_para_style(&name); + pf.para = base_p.delta(&pp); + pf.chars = base_c.delta(&cp); + } + let (_, base) = styles.resolve_para(pf); + let end = range.end.saturating_add(1).min(story.len()); + let range = if paragraph { range.start..end } else { range.start.max(target.range.start)..end.min(target.range.end) }; + story.format_chars(range, |format| { + if !paragraph && format.style != name { + return; + } + let mut inherited = base.clone(); + inherited.apply(&styles.char_style_attrs(&format.style)); + let resolved = styles.resolve_char(&base, format); + format.over = inherited.delta(&resolved); + }); + } + } + Ok(json!({"name":name})) + }); + if result.is_ok() { + s.typing_format = None; + } + result +} + fn apply_para(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.apply", "missing name"))?.to_string(); let clear = p.get("clearOverrides").and_then(Value::as_bool).unwrap_or(false); diff --git a/crates/ui-egui/src/panels/styles.rs b/crates/ui-egui/src/panels/styles.rs index fb080c79..55555f66 100644 --- a/crates/ui-egui/src/panels/styles.rs +++ b/crates/ui-egui/src/panels/styles.rs @@ -116,6 +116,11 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren let _ = app.run(cmd, json!({"name": n})); ui.close(); } + if editable && current == Some(n) && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Redefine Style"))).clicked() { + let command = if para { "style.paragraph.redefine" } else { "style.character.redefine" }; + let _ = app.run(command, json!({"name":n})); + ui.close(); + } if current == Some(n) && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Break Link to Style"))).clicked() { let _ = app.run("style.breakLink", json!({"kind": kind})); ui.close(); @@ -171,6 +176,34 @@ mod tests { use super::*; + #[test] + fn selected_style_context_menu_redefines_its_format() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let frame = app.run("frame.create", json!({"rect":[36,36,300,200],"content":"text","text":"Headline"})).unwrap(); + app.run("text.select", json!({"story":frame["story"],"anchor":0,"focus":8})).unwrap(); + app.run("style.paragraph.create", json!({"name":"Header","chars":{"size":18}})).unwrap(); + app.run("style.paragraph.apply", json!({"name":"Header"})).unwrap(); + app.run("type.char", json!({"size":24})).unwrap(); + let mut h = Harness::builder().with_size(vec2(360.0, 240.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let tokens = Tokens::get(ui.ctx()); + style_row(app, ui, true, "Header", Some("Header"), &[], &tokens); + }, + app, + ); + h.run_steps(3); + let at = egui::pos2(30.0, 15.0); + h.event(egui::Event::PointerMoved(at)); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }); + h.run_steps(2); + } + h.get_by_label("Redefine Style").click(); + h.run_steps(3); + assert_eq!(h.state().session.doc().unwrap().doc.styles.para("Header").unwrap().chars.size, Some(24.0)); + } + #[test] fn character_style_plus_captures_selection_for_reuse() { 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 30512bdf..76e77c8c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -61,6 +61,13 @@ context menus, typing at a caret) are things a layout artist does every minute, ## Remaining effort +The selected style's context menu now offers Redefine Style for paragraph and character styles. +It incorporates the source text's local changes, updates every use, and removes matching source +overrides in one undo step. Named character styles remain separate when redefining a paragraph +style. Commands: `style.paragraph.redefine` and `style.character.redefine` (optional `name`, +which must match the source). This implements the global color, size and spacing updates at +about 5:10–6:00 in [Jessica Garrity's styles tutorial](https://www.youtube.com/watch?v=pG9JkAPLgs8). + | Work | Opus 5.5 h | |---|---| | Copy/paste, context menus, threading clicks, caret formatting on every platform, with control-channel regression tests | 30–50 | From 70df1191495108c675821e73825461abd41fd01f Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:41:49 +0200 Subject: [PATCH 17/41] TUT3.2: show captured and pending paragraph style settings --- ROADMAP.md | 3 + crates/ui-egui/src/dialogs.rs | 108 ++++++++++++++++++++++++++++++---- docs/ui-parity.md | 5 ++ 3 files changed, 105 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf4..c48816d4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -343,6 +343,9 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-10-15** · Paragraph Style Options summarizes captured and pending color, size and + paragraph settings relative to the Based On style. + - **2026-10-13** · Switching between document and parent pages refreshes the canvas immediately, preventing black parent pages and stale document objects when the zoom stays the same. Ordinary edits retain partial repainting. No readiness percentage change. Newest first. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7d02776a..03e0659c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3271,22 +3271,61 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo ui.end_row(); }); ui.add_space(8.0); - crate::rtl::label( - ui, - egui::RichText::new(format!( - "{} {} {:.1} pt · {}", - cp.font_family, - cp.font_style, - cp.size, - crate::i18n::tr(&app.ui.language, pp.align.label()) - )) - .color(crate::theme::Tokens::get(ui.ctx()).text_dim), - ); + 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| { + ui.add(egui::Label::new(egui::RichText::new(summary).color(crate::theme::Tokens::get(ui.ctx()).text_dim)).wrap()); + }); } }); }); } +/// Show what the style changes from its parent, including edits waiting for OK. Fully captured +/// styles contain every default, so comparing resolved values keeps the summary readable. +fn paragraph_style_summary(lang: &str, d: &Dialog, style: &designcraft_doc::ParagraphStyle, styles: &designcraft_doc::Styles) -> String { + let based = d.fields.get("basedOn").and_then(Value::as_str).or(style.based_on.as_deref()).unwrap_or(designcraft_doc::NO_PARA_STYLE); + let (base_para, base_chars) = styles.resolve_para_style(based); + let (mut para, mut chars) = (base_para.clone(), base_chars.clone()); + para.apply(&style.para); + chars.apply(&style.chars); + for (key, value) in &d.fields { + if let Some(key) = key.strip_prefix("c.") { + let mut delta = designcraft_doc::CharAttrs::default(); + if delta.set_json(key, value).is_ok() { + chars.apply(&delta); + } + } else if let Some(key) = key.strip_prefix("p.") { + let (key, value) = key.split_once('.').map_or((key, value.clone()), |(attr, field)| (attr, json!({field:value}))); + let mut delta = designcraft_doc::ParaAttrs::default(); + if delta.set_json_over(key, &value, ¶).is_ok() { + para.apply(&delta); + } + } + } + let mut char_delta = base_chars.delta(&chars); + if base_chars.font_family != chars.font_family { + char_delta.font_style = Some(chars.font_style.clone()); + } + let mut parts = vec![crate::i18n::style_name(lang, based).to_string()]; + for value in [serde_json::to_value(base_para.delta(¶)), serde_json::to_value(char_delta)] { + if let Ok(Value::Object(attrs)) = value { + for (key, value) in attrs { + let suffix = if matches!( + key.as_str(), + "size" | "spaceBefore" | "spaceAfter" | "leftIndent" | "rightIndent" | "firstLineIndent" | "lastLineIndent" + ) { + " pt" + } else { + "" + }; + parts.push(format!("{}: {}{suffix}", setting_label(lang, &key), setting_text(&value))); + } + } + } + parts.join(" + ") +} + fn delete_paragraph_style(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let name = d.s("name"); ui.set_min_width(380.0); @@ -4809,6 +4848,53 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod tests { use super::*; + #[test] + fn paragraph_style_settings_show_captured_color_and_pending_spacing_without_changing_the_document() { + 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,300,200],"content":"text","text":"Heading"})).unwrap(); + app.run("text.select", json!({"story":f["story"],"anchor":0,"focus":7})).unwrap(); + app.run("type.char", json!({"fontFamily":"Source Sans 3","fontStyle":"Bold","size":16,"fill":"[Paper]"})).unwrap(); + app.run("style.paragraph.create", json!({"name":"Header","fromSelection":true})).unwrap(); + let doc = app.session.doc().unwrap().doc.clone(); + let style = doc.styles.para("Header").unwrap(); + let mut d = Dialog::new("paragraphStyleOptions", json!({"name":"Header"})); + let summary = paragraph_style_summary("", &d, style, &doc.styles); + for value in ["Source Sans 3", "Bold", "16 pt", "[Paper]"] { + assert!(summary.contains(value), "{summary}"); + } + assert!(summary.len() < 250, "default attributes do not swamp the captured settings: {summary}"); + d.fields.insert("c.fill".into(), json!("[Registration]")); + d.fields.insert("p.spaceAfter".into(), json!(4.5)); + let summary = paragraph_style_summary("", &d, style, &doc.styles); + assert!(summary.contains("[Registration]") && !summary.contains("[Paper]"), "{summary}"); + assert!(summary.contains("4.5 pt"), "{summary}"); + assert_eq!(app.session.doc().unwrap().doc, doc); + } + + #[test] + fn paragraph_style_settings_compare_with_the_chosen_parent() { + let mut styles = designcraft_doc::Styles::default(); + styles.paragraph.push(designcraft_doc::ParagraphStyle { + name: "Parent".into(), + chars: designcraft_doc::CharAttrs { size: Some(20.0), ..Default::default() }, + based_on: None, + next_style: None, + para: Default::default(), + shortcut: String::new(), + }); + let style = designcraft_doc::ParagraphStyle { + name: "Child".into(), + based_on: Some("Parent".into()), + next_style: None, + para: Default::default(), + chars: Default::default(), + shortcut: String::new(), + }; + let d = Dialog::new("paragraphStyleOptions", json!({"name":"Child"})); + assert_eq!(paragraph_style_summary("", &d, &style, &styles), "Parent"); + } + #[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/docs/ui-parity.md b/docs/ui-parity.md index 30512bdf..94fea0ae 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -61,6 +61,11 @@ context menus, typing at a caret) are things a layout artist does every minute, ## Remaining effort +Paragraph Style Options' General pane shows a Style Settings summary of differences from its +parent, including captured color, size and spacing. Pending edits appear before OK without +changing the document. Fully captured default attributes do not overwhelm the summary. This +covers checking the sampled and redefined settings in the styles tutorial (2:45 and 5:15). + | Work | Opus 5.5 h | |---|---| | Copy/paste, context menus, threading clicks, caret formatting on every platform, with control-channel regression tests | 30–50 | From 7d4ddc3679e24ef71760095c7c1ba743f77d4e50 Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:21:02 +0200 Subject: [PATCH 18/41] TUT4.2: reject malformed previews and cancel replaced dialogs --- crates/ui-egui/src/control.rs | 27 +++++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 24 +++++++++++++++++++++++- docs/ui-parity.md | 4 ++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 8ee45652..d7c25b98 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -339,6 +339,9 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest }, "ui.dialog.open" => { let Some(id) = s("id") else { return err("missing `id`") }; + // Replacing a modal cancels its transaction just like its Cancel button. + // In particular, an uncommitted style preview must not leak into the new dialog. + crate::dialogs::cancel(app); if id == "findChange" || app.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange") { app.session.reset_find_scope(); } @@ -440,6 +443,30 @@ pub(crate) fn page_index(p: &Value, default: usize, count: usize) -> Result DesignApp { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.session.execute("file.new", &json!({"pages":2})).unwrap(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ab83f176..a94bdbd6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2834,7 +2834,9 @@ fn style_options_preview(app: &mut DesignApp, d: &mut Dialog) { if params["para"][attr].is_null() { params["para"][attr] = json!({}); } - params["para"][attr][field] = value.clone(); + if let Some(fields) = params["para"][attr].as_object_mut() { + fields.insert(field.into(), value.clone()); + } } else { params["para"][attr] = value.clone(); } @@ -4954,6 +4956,26 @@ mod tests { assert!(app.session.doc().unwrap().interaction.is_none()); } + #[test] + fn style_preview_rejects_conflicting_scalar_and_nested_rule_fields_without_panicking() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + let mut dialog = Dialog::new( + "paragraphStyleOptions", + json!({ + "name": designcraft_doc::BASIC_PARAGRAPH, "preview": true, + "p.ruleAbove": false, "p.ruleAbove.weight": 2 + }), + ); + style_options_preview(&mut app, &mut dialog); + assert!(dialog.fields.contains_key("status")); + assert_eq!(app.session.doc().unwrap().doc, before); + app.ui.dialog = Some(dialog); + cancel(&mut app); + assert!(app.session.doc().unwrap().interaction.is_none()); + } + #[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/docs/ui-parity.md b/docs/ui-parity.md index 0bf86ec2..ef004bf1 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -61,6 +61,10 @@ context menus, typing at a caret) are things a layout artist does every minute, ## Remaining effort +Style Preview rejects conflicting scalar/nested automation fields without a panic. Replacing a +previewing dialog through the control channel cancels its transaction, restoring the original +document and normal undo recording before the replacement opens. + Paragraph and Character Style Options now preview edits to existing styles. Preview updates every use, remains outside undo history, and restores the starting document when disabled or cancelled. OK applies the final edit once. The color-preview step at about 4:00 in From dea0352299f8f721553ad41c30456900b8bcda03 Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:30:39 +0200 Subject: [PATCH 19/41] TUT4.3: normalize the source run when redefining at a caret --- crates/engine/src/cmd/style.rs | 44 +++++++++++++++++++++++++++++++--- docs/ui-parity.md | 3 +++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 41fefef1..d508a8be 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -75,6 +75,28 @@ mod redefine_tests { assert_eq!(s.doc().unwrap().doc, before); assert_eq!(s.doc().unwrap().history.undo.len(), undo); } + + #[test] + fn redefine_character_at_a_caret_cleans_the_source_run_but_keeps_other_overrides() { + let (mut s, story, _) = sample(); + s.execute("style.character.create", &json!({"name":"Emphasis","chars":{"underline":true}})).unwrap(); + for (start, end, size) in [(0, 5, 24), (6, 12, 32)] { + s.execute("text.select", &json!({"story":story,"anchor":start,"focus":end})).unwrap(); + s.execute("style.character.apply", &json!({"name":"Emphasis"})).unwrap(); + s.execute("type.char", &json!({"size":size})).unwrap(); + } + let before = s.doc().unwrap().doc.clone(); + s.execute("text.select", &json!({"story":story,"anchor":2,"focus":2})).unwrap(); + s.execute("style.character.redefine", &json!({"name":"Emphasis"})).unwrap(); + s.execute("style.character.edit", &json!({"name":"Emphasis","chars":{"size":30}})).unwrap(); + for (start, end, size) in [(0, 5, 30.0), (6, 12, 32.0)] { + s.execute("text.select", &json!({"story":story,"anchor":start,"focus":end})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], size); + } + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + } } use designcraft_color::{Swatch, SwatchValue}; @@ -530,8 +552,24 @@ fn redefine(s: &mut Session, p: &Value, paragraph: bool) -> Result { let styles = d.styles.clone(); for target in &targets { let Some(story) = d.text_story_mut(target.story, target.cell) else { continue }; - let end = if target.range.is_empty() { target.range.start } else { target.range.end.saturating_sub(1) }; - let (first, last) = (story.para_at(target.range.start), story.para_at(end)); + // At a character-style caret, the sampled format belongs to the containing run + // (the preceding run at a boundary). Normalize that source run too, otherwise its + // old local values would mask the next edit of the redefined style. + let mut selected_range = target.range.clone(); + if !paragraph && selected_range.is_empty() { + let caret = selected_range.start; + let mut start = 0usize; + for run in &story.chars { + let end = start.saturating_add(run.len).min(story.len()); + if caret < end || (caret == end && caret > start) { + selected_range = start..end; + break; + } + start = end; + } + } + let end = if selected_range.is_empty() { selected_range.start } else { selected_range.end.saturating_sub(1) }; + let (first, last) = (story.para_at(selected_range.start), story.para_at(end)); for (index, range) in story.para_ranges().into_iter().enumerate().take(last.saturating_add(1)).skip(first) { let Some(pf) = story.paras.get_mut(index) else { continue }; if paragraph && pf.style != name { @@ -545,7 +583,7 @@ fn redefine(s: &mut Session, p: &Value, paragraph: bool) -> Result { } let (_, base) = styles.resolve_para(pf); let end = range.end.saturating_add(1).min(story.len()); - let range = if paragraph { range.start..end } else { range.start.max(target.range.start)..end.min(target.range.end) }; + let range = if paragraph { range.start..end } else { range.start.max(selected_range.start)..end.min(selected_range.end) }; story.format_chars(range, |format| { if !paragraph && format.style != name { return; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 76e77c8c..68375ce0 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -61,6 +61,9 @@ context menus, typing at a caret) are things a layout artist does every minute, ## Remaining effort +Redefining a character style at a caret also normalizes the sampled source run, so subsequent +style edits reach that text while different local overrides in adjacent runs remain intact. + The selected style's context menu now offers Redefine Style for paragraph and character styles. It incorporates the source text's local changes, updates every use, and removes matching source overrides in one undo step. Named character styles remain separate when redefining a paragraph From a8388df9e8a5341c667cfc796612cda68ee64105 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 20:51:56 +0200 Subject: [PATCH 20/41] Units: inches show four decimals, as InDesign's fields Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/geom/src/units.rs | 16 +++++++++++++++- crates/ui-egui/src/widgets.rs | 4 ++-- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf4..999153af 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: Measures in inches show four decimals, as InDesign's fields do (A4 is 8.2677 × 11.6929 in, a 1p0 gutter 0.1667 in); other units keep three. - **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/geom/src/units.rs b/crates/geom/src/units.rs index 9ad93293..da1676d4 100644 --- a/crates/geom/src/units.rs +++ b/crates/geom/src/units.rs @@ -258,7 +258,10 @@ fn parse_single(tok: &str, default: Unit, percent_base: Option) -> Option String { - format_measure_prec(pt, unit, 3) + // 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 }; + format_measure_prec(pt, unit, decimals) } pub fn format_measure_prec(pt: f64, unit: Unit, decimals: usize) -> String { @@ -301,6 +304,17 @@ mod tests { (a - b).abs() < 1e-6 } + /// Inches show four decimals, as InDesign's fields do; other units three. + #[test] + fn inches_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"); + assert_eq!(format_measure(10.0, Unit::Points), "10 pt"); + } + #[test] fn plain_and_units() { assert!(close(p("12"), 12.0)); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 44316fda..dd95a147 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -5,7 +5,7 @@ //! section headers and 1.5 pt section rules. use designcraft_geom::Unit; -use designcraft_geom::units::{format_measure_prec, parse_measure}; +use designcraft_geom::units::{format_measure, parse_measure}; use egui::{Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use crate::theme::Tokens; @@ -35,7 +35,7 @@ pub enum NumKind<'a> { impl NumKind<'_> { pub fn format(&self, v: f64) -> String { match *self { - NumKind::Measure(u) => format_measure_prec(v, u, 3), + NumKind::Measure(u) => format_measure(v, u), NumKind::Number { suffix, decimals } => { let s = format!("{v:.decimals$}"); let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s }; From 0e5a6bd9d1b2011cb51c22b03a75d2db0d37af5c Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 21:15:15 +0200 Subject: [PATCH 21/41] Rulers: the corner menu sets both rulers' units; ruler menus as InDesign's Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 96 ++++++++++++++++++++++++++------ crates/ui-egui/src/i18n.rs | 2 + crates/ui-egui/src/i18n/ar.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/pt_br.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + 10 files changed, 90 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf4..fee2779d 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: Ruler menus as InDesign's: right-clicking where the rulers meet sets the units of both; each ruler's menu lists InDesign's units and Hide Rulers, the horizontal one Delete All Guides on Spread. - **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/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 40b146f5..91708674 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1583,33 +1583,78 @@ fn ruler_rects(full: Rect, rect: Rect) -> (Rect, Rect) { ) } -/// Right-clicking a ruler opens a menu of units for that ruler. +/// Right-clicking a ruler opens its menu, as InDesign's: units for that ruler, Hide Rulers and +/// (the horizontal ruler) Delete All Guides on Spread. Right-clicking the corner where they meet +/// opens the units for both rulers at once. fn ruler_units_menus(app: &mut DesignApp, resp: &egui::Response, full: Rect, rect: Rect, doc: &Document) { let (top, left) = ruler_rects(full, rect); + let corner = Rect::from_min_max(full.min, rect.min); let at = resp.interact_pointer_pos(); - for (ruler, key, cur) in [(top, "horizontalUnits", doc.settings.horizontal_units), (left, "verticalUnits", doc.settings.vertical_units)] { + let (h, v) = (doc.settings.horizontal_units, doc.settings.vertical_units); + // (where, which units it sets, the unit checked) + let menus = [(top, RulerMenu::Horizontal, Some(h)), (left, RulerMenu::Vertical, Some(v)), (corner, RulerMenu::Both, (h == v).then_some(h))]; + for (area, which, cur) in menus { let open = if resp.secondary_clicked() { - Some(egui::SetOpenCommand::Bool(at.is_some_and(|p| ruler.contains(p)))) + Some(egui::SetOpenCommand::Bool(at.is_some_and(|p| area.contains(p)))) } else if resp.clicked() { Some(egui::SetOpenCommand::Bool(false)) } else { None }; - egui::Popup::context_menu(resp).id(resp.id.with(("ruler_units", key))).open_memory(open).show(|ui| ruler_units_menu(app, ui, key, cur)); + egui::Popup::context_menu(resp) + .id(resp.id.with(("ruler_units", which as u8))) + .open_memory(open) + .show(|ui| ruler_units_menu(app, ui, which, cur)); } } -/// Every unit, the current one checked; picking one sets `key` (`horizontalUnits` or -/// `verticalUnits`) through `document.preferences`. -fn ruler_units_menu(app: &mut DesignApp, ui: &mut egui::Ui, key: &str, cur: designcraft_geom::Unit) { - for u in designcraft_geom::Unit::ALL { - let label = crate::i18n::tr(&app.ui.language, u.label()); - let text = if u == cur { format!("✓ {label}") } else { format!(" {label}") }; +#[derive(Clone, Copy, PartialEq)] +enum RulerMenu { + Horizontal, + Vertical, + Both, +} + +/// The units a ruler's menu offers, in InDesign's order. +const RULER_UNITS: [designcraft_geom::Unit; 9] = { + use designcraft_geom::Unit::*; + [Points, Picas, Inches, InchesDecimal, Millimeters, Centimeters, Ciceros, Agates, Pixels] +}; + +/// A ruler's menu: the units (the current one checked), then Hide Rulers and, on the horizontal +/// ruler, Delete All Guides on Spread. Picking a unit sets `horizontalUnits`, `verticalUnits` or +/// both through `document.preferences`. +fn ruler_units_menu(app: &mut DesignApp, ui: &mut egui::Ui, which: RulerMenu, cur: Option) { + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + // A document in Q or Ha keeps its unit on the list. + let extra = cur.filter(|u| !RULER_UNITS.contains(u)); + for u in RULER_UNITS.into_iter().chain(extra) { + let label = tr(u.label()); + let text = if Some(u) == cur { format!("✓ {label}") } else { format!(" {label}") }; if ui.button(crate::rtl::widget(ui, text)).clicked() { - let _ = app.run("document.preferences", json!({ key: u })); + let p = match which { + RulerMenu::Horizontal => json!({"horizontalUnits": u}), + RulerMenu::Vertical => json!({"verticalUnits": u}), + RulerMenu::Both => json!({"horizontalUnits": u, "verticalUnits": u}), + }; + let _ = app.run("document.preferences", p); ui.close(); } } + ui.separator(); + let hide = format!(" {}", tr("Hide Rulers")); + if ui + .add(egui::Button::new(crate::rtl::widget(ui, hide)).shortcut_text(crate::menus::shortcut_of(app, "view.rulers").unwrap_or_default())) + .clicked() + { + let _ = app.run("view.rulers", json!({})); + ui.close(); + } + if which == RulerMenu::Horizontal && ui.button(crate::rtl::widget(ui, format!(" {}", tr("Delete All Guides on Spread")))).clicked() { + crate::menus::activate(app, "app.deleteAllGuides", &Value::Null); + ui.close(); + } } fn fmt_tick(v: f64) -> String { @@ -2500,11 +2545,30 @@ mod tests { h.get_by_label(" Inches").click(); h.run_steps(3); assert_eq!(units(&h), (Unit::Inches, Unit::Millimeters)); - // The page and the ruler corner open no units menu. - for p in [rect.center(), rect.min - vec2(RULER / 2.0, RULER / 2.0)] { - right_click(&mut h, p); - assert!(h.query_by_label(" Points").is_none()); - } + // The corner where the rulers meet sets both, as InDesign's: nothing is checked while + // they differ. + let corner = rect.min - vec2(RULER / 2.0, RULER / 2.0); + right_click(&mut h, corner); + assert!(h.query_by_label("✓ Inches").is_none() && h.query_by_label("✓ Millimeters").is_none()); + let _ = h.get_by_label_contains("Hide Rulers"); + assert!(h.query_by_label(" Delete All Guides on Spread").is_none(), "only on the horizontal ruler"); + h.get_by_label(" Points").click(); + h.run_steps(3); + assert_eq!(units(&h), (Unit::Points, Unit::Points)); + // InDesign's list: no Q or Ha. + right_click(&mut h, corner); + h.get_by_label("✓ Points"); + assert!(h.query_by_label(" Q").is_none() && h.query_by_label(" Ha").is_none()); + let _ = h.get_by_label(" Inches Decimal"); + // The horizontal ruler's menu also deletes the spread's guides; Hide Rulers hides them. + right_click(&mut h, pos2(rect.center().x, rect.min.y - RULER / 2.0)); + let _ = h.get_by_label(" Delete All Guides on Spread"); + h.get_by_label_contains("Hide Rulers").click(); + h.run_steps(3); + assert!(!h.state().ui.rulers); + // The page opens no units menu. + right_click(&mut h, rect.center()); + assert!(h.query_by_label(" Points").is_none()); } /// A text frame holding `text`, the Type tool, and a harness stepping at 60 fps (clicks in a diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 245a6e28..bf05120d 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -58,6 +58,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("Hide Rulers", ["Lineale ausblenden", "Masquer les règles", "Ocultar reglas", "定規を隠す", "隐藏标尺"]), + ("Inches Decimal", ["Zoll dezimal", "Pouces décimaux", "Pulgadas decimales", "インチ(10 進)", "十进制英寸"]), ( "Bulleted & Numbered Lists", [ diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index d1dcb7ac..d1ba43fc 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", "لصق"), + ("Inches Decimal", "بوصات عشرية"), ("Bulleted & Numbered Lists", "القوائم ذات التعداد النقطي والرقمي"), ("Current Page Number", "رقم الصفحة الحالية"), ("Apply Color (,)", "تطبيق اللون (,)"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 0f55578b..f8ae4ea6 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -30,6 +30,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("Inches Decimal", "Zoll dezimal"), ("Clear", "Löschen"), ("Duplicate", "Duplizieren"), ("Select All", "Alles auswählen"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 00328ac9..5be9fbd0 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"), + ("Inches Decimal", "Pollici decimali"), ("Bulleted & Numbered Lists", "Elenchi puntati e numerati"), ("Current Page Number", "Numero di pagina corrente"), ("Apply Color (,)", "Applica colore (,)"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index fe9e213e..a26d9e72 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", "ペースト"), + ("Inches Decimal", "インチ(10 進)"), ("Bulleted & Numbered Lists", "箇条書き"), ("Apply Color (,)", "カラーを適用 (,)"), ("Apply Gradient (.)", "グラデーションを適用 (.)"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index b3379c6d..e9087763 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", "ჩასმა"), + ("Inches Decimal", "ინჩები (ათობითი)"), ("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 0675a2f6..25d1d832 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"), + ("Inches Decimal", "Polegadas decimais"), ("Bulleted & Numbered Lists", "Listas com marcadores e numeradas"), ("Show Hidden Characters", "Mostrar caracteres ocultos"), ("Current Page Number", "Número da página atual"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 8f1d27f8..2d147b44 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", "Вставити"), + ("Inches Decimal", "Дюйми (десяткові)"), ("Bulleted & Numbered Lists", "Марковані й нумеровані списки"), ("Current Page Number", "Номер поточної сторінки"), ("Apply Color (,)", "Застосувати колір (,)"), From 42be0c57601fc0d120c11f2674495894e107dad9 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 21:32:45 +0200 Subject: [PATCH 22/41] Tools: a tool group's flyout closes on Esc and on a click outside it Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/toolbar.rs | 50 +++++++++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf4..b6abc0d9 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: A tool group's flyout closes on Esc and on a click anywhere outside it (a panel, the Fill swatch), as InDesign's; it only closed on a click on the canvas. - **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/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 7e6ca1cd..0e617bf9 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -45,6 +45,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.add_space(2.0); let current = app.session.tool_id(); let mut chosen: Option<&'static str> = None; + let mut opened_now = false; // A slot is a group (its current tool shown, the others in its flyout) or, in two // columns, one tool of a group InDesign lays out tool by tool. let mut row_items: Vec)>> = Vec::new(); @@ -117,6 +118,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { || (resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.press_start_time().is_some_and(|s| i.time - s > 0.35)))) { + opened_now |= app.ui.flyout != Some(gi); app.ui.flyout = Some(gi); } if app.ui.flyout == Some(gi) { @@ -156,6 +158,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { t.text_dim, ); } + row.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl.label)); if row.clicked() { chosen = Some(tl.id); } @@ -177,8 +180,18 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { if let Some(id) = chosen { app.select_tool(id); } - if app.ui.flyout.is_some() && ui.input(|i| i.pointer.any_click()) && chosen.is_none() && !ui.ctx().is_pointer_over_egui() { - app.ui.flyout = None; + // As InDesign's, an open flyout closes on Esc and on a click anywhere outside it (a + // panel, a swatch, the canvas), not only on the canvas. + if let Some(gi) = app.ui.flyout + && chosen.is_none() + && !opened_now + { + let area = ui.ctx().memory(|m| m.area_rect(egui::Id::new(("flyout", gi)))); + let outside = ui.input(|i| i.pointer.any_click() && i.pointer.interact_pos().is_some_and(|p| area.is_none_or(|a| !a.contains(p)))); + let esc = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); + if outside || esc { + app.ui.flyout = None; + } } ui.add_space(8.0); fill_stroke_proxy(app, ui, width, cols); @@ -630,6 +643,39 @@ mod tests { h.get_by_label(label).rect() } + /// As InDesign's: a tool group's flyout closes on Esc and on a click outside it (the fill + /// swatch, a panel), not only on the canvas. + #[test] + fn a_tool_flyout_closes_on_escape_and_on_a_click_elsewhere() { + use egui::{Event, Modifiers, PointerButton}; + use egui_kittest::kittest::Queryable; + let app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let mut h = crate::test_window::open(app, vec2(1400.0, 900.0)); + let open = |h: &mut egui_kittest::Harness<'static, crate::test_window::Window>| { + let at = h.get_by_label("Rectangle Frame Tool (F)").rect().center(); + h.input_mut().events.push(Event::PointerMoved(at)); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + } + h.run_steps(3); + assert!(h.state().app.ui.flyout.is_some(), "a right-click opens the flyout"); + let _ = h.get_by_label("Ellipse Frame Tool"); + }; + open(&mut h); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().app.ui.flyout.is_none(), "Esc closes it"); + // A click on a panel closes it. + open(&mut h); + crate::test_window::click_at(&mut h, egui::pos2(1300.0, 700.0)); + assert!(h.state().app.ui.flyout.is_none(), "a click elsewhere closes it"); + // Choosing a tool in it closes it and selects the tool. + open(&mut h); + h.get_by_label("Ellipse Frame Tool").click(); + h.run_steps(3); + assert_eq!((h.state().app.session.tool_id(), h.state().app.ui.flyout), ("ellipseFrame", None)); + } + #[test] fn a_short_window_reaches_every_tool() { for double in [false, true] { From 5aed65ff238109960cc0b583d2e54f8b1529b338 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 23:10:05 +0200 Subject: [PATCH 23/41] Layers: a slow double-click renames an object's row; a quick one only selects, as InDesign's Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/panels/layers.rs | 64 ++++++++++++++++++++++++++--- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf4..6389380a 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: Layers panel: an object's row is renamed with a slow double-click (a second click on the selected row), as in InDesign; a quick double-click only selects it. - **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/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 143be811..b8ae73ef 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -230,6 +230,7 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) { } let (eye, lock) = eye_lock(ui, &app.ui.language, row, !it.hidden, it.locked, !l.visible, ("item", it.id.0)); let name = item_label(&doc, it); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, is_sel, &name)); let field = Rect::from_min_max(row.min + vec2(2.0 * COL_W + 28.0, 3.0), pos2(row.max.x - 28.0, row.max.y - 3.0)); let renaming = match rename_field(ui, field, it.id.0) { // An empty name goes back to the automatic label. @@ -257,13 +258,22 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) { let _ = app.run("object.setFlags", json!({"ids": [it.id.0], "hidden": !it.hidden})); } else if lock { let _ = app.run("object.setFlags", json!({"ids": [it.id.0], "locked": !it.locked})); - } else if resp.double_clicked() { - // Its own name, or the label it shows without the brackets. - let current = if it.name.is_empty() { name.trim_start_matches('<').trim_end_matches('>').to_string() } else { it.name.clone() }; - start_rename(ui, it.id.0, ¤t); } else if sq || resp.clicked() { - let add = ui.input(|i| i.modifiers.shift || i.modifiers.command); - let _ = app.run("selection.set", json!({"ids": [it.id.0], "add": add})); + // As InDesign's: a slow double-click (a second click on the selected object's row, + // after the double-click delay) renames it; a quick double-click only selects. + let (now, add) = ui.input(|i| (i.time, i.modifiers.shift || i.modifiers.command)); + let delay = ui.ctx().options(|o| o.input_options.max_double_click_delay); + let last_id = egui::Id::new(("layers_last_click", it.id.0)); + let last: Option = ui.data(|d| d.get_temp(last_id)); + ui.data_mut(|d| d.insert_temp(last_id, now)); + let slow = !sq && !add && !resp.double_clicked() && last.is_some_and(|t| now - t > delay && now - t < SLOW_RENAME_WINDOW); + if slow && shown == [it.id] { + // Its own name, or the label it shows without the brackets. + let current = if it.name.is_empty() { name.trim_start_matches('<').trim_end_matches('>').to_string() } else { it.name.clone() }; + start_rename(ui, it.id.0, ¤t); + } else { + let _ = app.run("selection.set", json!({"ids": [it.id.0], "add": add})); + } } } } @@ -291,6 +301,9 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) { }); } +/// Seconds within which a second click on a selected row renames it (after the double-click delay). +const SLOW_RENAME_WINDOW: f64 = 1.5; + /// The object whose row is being renamed. type RenameKey = u64; const RENAME: &str = "layers_rename"; @@ -371,6 +384,45 @@ mod tests { assert!(!open(&h)); } + /// As InDesign's: a slow double-click on an object's row renames it; a quick one doesn't. + #[test] + fn a_slow_double_click_renames_an_object_and_a_quick_one_does_not() { + 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 id = app.run("frame.create", json!({"rect": [72, 72, 200, 200], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": []})).unwrap(); + let mut h = + Harness::builder().with_size(egui::vec2(260.0, 400.0)).with_step_dt(0.05).build_ui_state(|ui, app: &mut DesignApp| show(app, ui), app); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(("layer_open", h.state().session.active().unwrap().active_layer.0)), true)); + h.run_steps(2); + let row = h.get_by_label("").rect().center(); + let click = |h: &mut Harness<'_, DesignApp>| { + h.input_mut().events.push(egui::Event::PointerMoved(row)); + for pressed in [true, false] { + h.input_mut().events.push(egui::Event::PointerButton { + pos: row, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + } + }; + let renaming = |h: &Harness<'_, DesignApp>| h.query_by_role(egui::accesskit::Role::TextInput).is_some(); + // A quick double-click selects it and doesn't rename. + click(&mut h); + click(&mut h); + h.run_steps(2); + assert_eq!(h.state().session.active().unwrap().selection.items, vec![ItemId(id)]); + assert!(!renaming(&h), "a double-click doesn't rename"); + // A second click a moment later renames it. + h.run_steps(12); + click(&mut h); + h.run_steps(2); + assert!(renaming(&h), "a slow double-click renames"); + } + #[test] fn rows_rename_in_place() { use egui_kittest::{Harness, kittest::Queryable}; From 6d24b64dd5709c8e95c5c2ca2d8748795f7e5916 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 03:53:30 +0000 Subject: [PATCH 24/41] Integration: #1073 the Effects dialog test reads the shadow distance with four inch decimals --- crates/ui-egui/src/effects_dialog.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/effects_dialog.rs b/crates/ui-egui/src/effects_dialog.rs index 4bdba85f..456ca4a5 100644 --- a/crates/ui-egui/src/effects_dialog.rs +++ b/crates/ui-egui/src/effects_dialog.rs @@ -699,7 +699,7 @@ mod tests { ] { assert!(texts.iter().any(|x| x.trim() == t), "{t}: {texts:?}"); } - assert!(texts.iter().any(|x| x.contains("0.097 in")), "distance in inches: {texts:?}"); + assert!(texts.iter().any(|x| x.contains("0.0972 in")), "distance in inches: {texts:?}"); // Preview: the shadow is on the page while the dialog is open, without an undo step. frame(&mut app, &ctx); assert!(fx(&app).inner_shadow.on); From ddac0751ffabc24a0f0ac5a547597799c3932b37 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 03:56:59 +0000 Subject: [PATCH 25/41] Integration: #1058 delete_pages keeps its doc comment, page_has_items has its own --- crates/doc/src/build.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index 20643ef4..2437e063 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -462,7 +462,6 @@ impl Document { Ok(ids) } - /// Delete absolute pages (items whose centre is on a deleted page are deleted too). /// Whether absolute page `abs` holds page items: the ones Delete Pages removes with it /// (an item belongs to the page its centre is over). pub fn page_has_items(&self, abs: usize) -> bool { @@ -471,6 +470,7 @@ impl Document { sp.items.iter().any(|it| sp.page_at_x(it.bounds().center().x) == Some(pi)) } + /// Delete absolute pages (items whose centre is on a deleted page are deleted too). pub fn delete_pages(&mut self, pages: &[usize]) -> Result<()> { if pages.len() >= self.page_count() { return Err(DocError::Invalid("a document needs at least one page".into())); From 3896bd2982bdececc8146c8419895dd1381896df Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 03:59:02 +0000 Subject: [PATCH 26/41] Integration: #1064 Redefine Style is translated in every UI language --- crates/ui-egui/src/i18n.rs | 8 ++++++++ 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, 18 insertions(+) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 9cf42b8d..0d75a95c 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -759,6 +759,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ "Break Link to Style", ["Break Link to Style", "Rompre le lien avec le style", "Break Link to Style", "Break Link to Style", "断开与样式的链接"], ), + ("Redefine Style", ["Format neu definieren", "Redéfinir le style", "Redefinir estilo", "スタイルを再定義", "重新定义样式"]), ("Bullets and Numbering", ["Bullets and Numbering", "Puces et numérotation", "Bullets and Numbering", "Bullets and Numbering", "项目符号和编号"]), ("Business Card", ["Business Card", "Carte de visite", "Business Card", "Business Card", "名片"]), ("CMYK", ["CMYK", "CMJN", "CMYK", "CMYK", "CMYK"]), @@ -5012,4 +5013,11 @@ mod tests { // Fixed standard names stay identical in every language. assert_eq!(tr("ja", "PDF/X-4"), "PDF/X-4"); } + + #[test] + fn redefine_style_is_translated_in_every_language() { + for (code, _) in LANGUAGES.iter().filter(|(c, _)| !c.is_empty()) { + assert_ne!(tr(code, "Redefine Style"), "Redefine Style", "{code}"); + } + } } diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 9164b597..baf1e82e 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -964,6 +964,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Bleed and Slug", "النزف ومنطقة المعلومات"), ("Body Rows", "صفوف المتن"), ("Break Link to Style", "فك الارتباط بالنمط"), + ("Redefine Style", "إعادة تعريف النمط"), ("Bullets and Numbering", "التعداد النقطي والرقمي"), ("Business Card", "بطاقة عمل"), ("Buttons act in interactive PDF export.", "تعمل الأزرار عند التصدير إلى PDF تفاعلي."), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 3aff33c8..e81156b4 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -140,6 +140,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Body Rows", "Řádky těla"), ("Bottom", "Dole"), ("Break Link to Style", "Zrušit propojení se stylem"), + ("Redefine Style", "Předefinovat styl"), ("Bullets and Numbering", "Odrážky a číslování"), ("Business Card", "Vizitka"), ("CMYK", "CMYK"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index cb69f2e7..e9def8d4 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -159,6 +159,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Body Rows", "Tabellenkörperzeilen"), ("Bottom", "Unten"), ("Break Link to Style", "Verknüpfung mit Format aufheben"), + ("Redefine Style", "Format neu definieren"), ("Bullets and Numbering", "Aufzählungszeichen und Nummerierung"), ("Business Card", "Visitenkarte"), ("CMYK", "CMYK"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index c7876de4..650f91f0 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -228,6 +228,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Body Rows", "Righe di corpo"), ("Bottom", "Basso"), ("Break Link to Style", "Interrompi collegamento allo stile"), + ("Redefine Style", "Ridefinisci stile"), ("Bullets and Numbering", "Elenchi puntati e numerati"), ("Business Card", "Biglietto da visita"), ("CMYK", "CMYK"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 4d12cfa3..72e0ecab 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -346,6 +346,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Body Rows", "本体行"), ("Bottom", "下"), ("Break Link to Style", "スタイルへのリンクを解除"), + ("Redefine Style", "スタイルを再定義"), ("Bullets and Numbering", "箇条書き記号と番号付け"), ("Business Card", "名刺"), ("CMYK", "CMYK"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d8053617..3645d39a 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -148,6 +148,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Body Rows", "ძირითადი სტრიქონები"), ("Bottom", "ქვემოთ"), ("Break Link to Style", "სტილთან კავშირის გაწყვეტა"), + ("Redefine Style", "სტილის ხელახლა განსაზღვრა"), ("Bullets and Numbering", "ბურთულები და დანომვრა"), ("Business Card", "სავიზიტო ბარათი"), ("CMYK", "CMYK"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 07f6b062..b57928b0 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -138,6 +138,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Body Rows", "Wiersze treści"), ("Bottom", "Dół"), ("Break Link to Style", "Odłącz od stylu"), + ("Redefine Style", "Przedefiniuj styl"), ("Bullets and Numbering", "Wypunktowanie i numeracja"), ("Business Card", "Wizytówka"), ("CMYK", "CMYK"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index ff2a9f56..6eeb07cc 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -965,6 +965,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Bleed and Slug", "Sangria e área de informações"), ("Body Rows", "Linhas do corpo"), ("Break Link to Style", "Romper vínculo com o estilo"), + ("Redefine Style", "Redefinir estilo"), ("Bullets and Numbering", "Marcadores e numeração"), ("Business Card", "Cartão de visita"), ("Buttons act in interactive PDF export.", "Os botões agem na exportação de PDF interativo."), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index bd26ec90..2c5fa311 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -140,6 +140,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Body Rows", "Строки тела"), ("Bottom", "Снизу"), ("Break Link to Style", "Разорвать связь со стилем"), + ("Redefine Style", "Переопределить стиль"), ("Bullets and Numbering", "Маркеры и нумерация"), ("Business Card", "Визитная карточка"), ("CMYK", "CMYK"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index aa36f75d..f975ab0f 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -231,6 +231,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Body Rows", "Рядки основного вмісту"), ("Bottom", "Знизу"), ("Break Link to Style", "Розірвати зв’язок зі стилем"), + ("Redefine Style", "Перевизначити стиль"), ("Bullets and Numbering", "Маркери та нумерація"), ("Business Card", "Візитівка"), ("CMYK", "CMYK"), From f6b74fbcd47d5de14493233cfa4f8a36fc3ea8b1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 04:00:03 +0000 Subject: [PATCH 27/41] Integration: #1060 the Alt-click hint and Apply "name", Clear Overrides are translated in every UI language --- crates/ui-egui/src/i18n.rs | 14 ++++++++++++-- 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 ++ 11 files changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 0d75a95c..bd6b9e29 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2806,6 +2806,14 @@ const TABLE: &[(&str, [&str; 5])] = &[ "⌥-click clears overrides", ["⌥-click clears overrides", "⌥-clic efface les remplacements", "⌥-click clears overrides", "⌥-click clears overrides", "⌥-单击清除覆盖"], ), + ( + "Alt-click clears overrides", + ["Alt-Klick entfernt Abweichungen", "Alt-clic efface les remplacements", "Alt-clic borra las modificaciones", "Alt を押しながらクリックするとオーバーライドを消去します", "Alt-单击清除覆盖"], + ), + ( + "Apply \"{name}\", Clear Overrides", + ["„{name}“ anwenden, Abweichungen löschen", "Appliquer « {name} », effacer les remplacements", "Aplicar \"{name}\", borrar modificaciones", "\"{name}\" を適用、オーバーライドを消去", "应用\"{name}\",清除覆盖"], + ), ("⚠ out of date", ["⚠ out of date", "⚠ obsolète", "⚠ out of date", "⚠ out of date", "⚠ 已过期"]), ("General", ["General", "Général", "General", "General", "常规"]), ( @@ -5015,9 +5023,11 @@ mod tests { } #[test] - fn redefine_style_is_translated_in_every_language() { + fn style_row_menu_strings_are_translated_in_every_language() { for (code, _) in LANGUAGES.iter().filter(|(c, _)| !c.is_empty()) { - assert_ne!(tr(code, "Redefine Style"), "Redefine Style", "{code}"); + for key in ["Redefine Style", "Alt-click clears overrides", "Apply \"{name}\", Clear Overrides"] { + assert_ne!(tr(code, key), key, "{code}: {key}"); + } } } } diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index baf1e82e..674805b9 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1177,6 +1177,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[No Group]", "[بلا مجموعة]"), ("[None] — choose a colour on the ramp.", "[بلا] — اختر لونًا على شريط التدرج."), ("⌥-click clears overrides", "النقر مع ⌥ يمسح التجاوزات"), + ("Alt-click clears overrides", "النقر مع Alt يمسح التجاوزات"), + ("Apply \"{name}\", Clear Overrides", "تطبيق \"{name}\" ومسح التجاوزات"), ("⚠ out of date", "⚠ غير محدّث"), ("Interface", "الواجهة"), ("Advanced Type", "كتابة متقدمة"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e81156b4..6b329db4 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -952,6 +952,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[None] — choose a colour on the ramp.", "[Žádný] — vyberte barvu na spektru."), ("discord.gg/artcraft — help, feedback and show-and-tell", "discord.gg/artcraft — pomoc, zpětná vazba a ukázky prací"), ("⌥-click clears overrides", "⌥-kliknutí zruší přepsání"), + ("Alt-click clears overrides", "Alt-kliknutí zruší přepsání"), + ("Apply \"{name}\", Clear Overrides", "Použít „{name}“, zrušit přepsání"), ("⚠ out of date", "⚠ zastaralé"), ("General", "Obecné"), ("Basic Character Formats", "Základní formáty znaků"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index e9def8d4..26df93b9 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -986,6 +986,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[None] — choose a colour on the ramp.", "[Ohne] – eine Farbe auf dem Farbbalken wählen."), ("discord.gg/artcraft — help, feedback and show-and-tell", "discord.gg/artcraft – Hilfe, Feedback und Arbeiten zeigen"), ("⌥-click clears overrides", "⌥-Klick entfernt Abweichungen"), + ("Alt-click clears overrides", "Alt-Klick entfernt Abweichungen"), + ("Apply \"{name}\", Clear Overrides", "„{name}“ anwenden, Abweichungen löschen"), ("⚠ out of date", "⚠ veraltet"), ("General", "Allgemein"), ("Basic Character Formats", "Grundlegende Zeichenformate"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 650f91f0..ad9ec1b7 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1068,6 +1068,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[None] — choose a colour on the ramp.", "[Nessuno] — scegli un colore sulla barra dei colori."), ("discord.gg/artcraft — help, feedback and show-and-tell", "discord.gg/artcraft — aiuto, feedback e condivisione dei lavori"), ("⌥-click clears overrides", "⌥-clic cancella le modifiche locali"), + ("Alt-click clears overrides", "Alt-clic cancella le modifiche locali"), + ("Apply \"{name}\", Clear Overrides", "Applica \"{name}\", cancella modifiche locali"), ("⚠ out of date", "⚠ non aggiornato"), ("General", "Generale"), ("Basic Character Formats", "Formati carattere di base"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 72e0ecab..6752c08e 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1184,6 +1184,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[None] — choose a colour on the ramp.", "[なし] — カラーバーでカラーを選択してください。"), ("discord.gg/artcraft — help, feedback and show-and-tell", "discord.gg/artcraft — サポート・ご意見・作品紹介"), ("⌥-click clears overrides", "⌥ を押しながらクリックするとオーバーライドを消去します"), + ("Alt-click clears overrides", "Alt を押しながらクリックするとオーバーライドを消去します"), + ("Apply \"{name}\", Clear Overrides", "\"{name}\" を適用、オーバーライドを消去"), ("⚠ out of date", "⚠ 要更新"), ("General", "一般"), ("Basic Character Formats", "文字の基本書式"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 3645d39a..b5ec7b26 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -996,6 +996,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[None] — choose a colour on the ramp.", "[არცერთი] — აირჩიეთ ფერი სკალაზე."), ("discord.gg/artcraft — help, feedback and show-and-tell", "discord.gg/artcraft — დახმარება, გამოხმაურება და ნამუშევრების ჩვენება"), ("⌥-click clears overrides", "⌥-დაწკაპუნება ასუფთავებს გადაფარვებს"), + ("Alt-click clears overrides", "Alt-დაწკაპუნება ასუფთავებს გადაფარვებს"), + ("Apply \"{name}\", Clear Overrides", "„{name}“-ის გამოყენება, გადაფარვების გასუფთავება"), ("⚠ out of date", "⚠ მოძველებულია"), ("General", "ზოგადი"), ("Basic Character Formats", "სიმბოლოს საბაზო ფორმატები"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index b57928b0..ef41e692 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -962,6 +962,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[None] — choose a colour on the ramp.", "[Brak] — wybierz kolor na pasku."), ("discord.gg/artcraft — help, feedback and show-and-tell", "discord.gg/artcraft — pomoc, opinie i prezentacje prac"), ("⌥-click clears overrides", "⌥-klik usuwa zastąpienia"), + ("Alt-click clears overrides", "Alt-klik usuwa zastąpienia"), + ("Apply \"{name}\", Clear Overrides", "Zastosuj „{name}”, usuń zastąpienia"), ("⚠ out of date", "⚠ nieaktualne"), ("General", "Ogólne"), ("Basic Character Formats", "Podstawowe formaty znaków"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 6eeb07cc..52cb2c55 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1185,6 +1185,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[No Group]", "[Sem grupo]"), ("[None] — choose a colour on the ramp.", "[Nenhum] — escolha uma cor na rampa."), ("⌥-click clears overrides", "⌥-clique limpa substituições"), + ("Alt-click clears overrides", "Alt-clique limpa substituições"), + ("Apply \"{name}\", Clear Overrides", "Aplicar \"{name}\", limpar substituições"), ("⚠ out of date", "⚠ desatualizado"), ("Interface", "Interface"), ("Advanced Type", "Tipo avançado"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 2c5fa311..dd52bb83 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -939,6 +939,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[None] — choose a colour on the ramp.", "[Нет] — выберите цвет на шкале."), ("discord.gg/artcraft — help, feedback and show-and-tell", "discord.gg/artcraft — помощь, отзывы и обмен работами"), ("⌥-click clears overrides", "⌥-щелчок сбрасывает переопределения"), + ("Alt-click clears overrides", "Alt-щелчок сбрасывает переопределения"), + ("Apply \"{name}\", Clear Overrides", "Применить «{name}», сбросить переопределения"), ("⚠ out of date", "⚠ устарело"), ("General", "Основные"), ("Basic Character Formats", "Основные атрибуты символов"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index f975ab0f..8e48e74f 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1065,6 +1065,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[None] — choose a colour on the ramp.", "[Немає] — виберіть колір на шкалі."), ("discord.gg/artcraft — help, feedback and show-and-tell", "discord.gg/artcraft — допомога, відгуки та обмін роботами"), ("⌥-click clears overrides", "⌥-клацання скидає перевизначення"), + ("Alt-click clears overrides", "Alt-клацання скидає перевизначення"), + ("Apply \"{name}\", Clear Overrides", "Застосувати «{name}», скинути перевизначення"), ("⚠ out of date", "⚠ застаріле"), ("General", "Загальні"), ("Basic Character Formats", "Основне форматування символів"), From 141c048289ac6e324fd7fd4cb3fe37b592cb5ce4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 04:01:22 +0000 Subject: [PATCH 28/41] Integration: #1062 style Preview runs the Justification spacing check OK runs, and shows its reason --- crates/ui-egui/src/dialogs.rs | 44 +++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c5c90db8..f55c3303 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3514,13 +3514,18 @@ fn grep_error(p: &str) -> Option { designcraft_compose::grep::Grep::new(p, Default::default()).err().map(|e| e.to_string()) } -fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Result { - let name = d.s("name"); - // What the Justification section showed: the style's own values, or a new style's Based On. - let shown = if d.b("new") { d.s("basedOn") } else { name.clone() }; +/// 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(); - if let Err(e) = check_justification(d, "p.", &base) { + check_justification(d, "p.", &base) +} + +fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Result { + let name = d.s("name"); + if let Err(e) = check_paragraph_style_justification(app, d) { // The caller shows the reason in the dialog's status line. d.fields.insert("section".into(), json!("justify")); return Err(e); @@ -3593,6 +3598,16 @@ fn style_options_preview(app: &mut DesignApp, d: &mut Dialog) { return; } let paragraph = d.id == "paragraphStyleOptions"; + // Values OK would refuse don't reach the page either: the page goes back and the status + // line says why. + if paragraph && let Err(e) = check_paragraph_style_justification(app, d) { + if shown.is_some() { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + } + d.fields.insert("status".into(), json!(e)); + return; + } let mut params = json!({"name": d.s("name"), "chars": {}}); if paragraph { params["para"] = json!({}); @@ -7543,6 +7558,25 @@ mod tests { } } + #[test] + fn style_preview_refuses_justification_spacing_ok_would_refuse() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + let mut d = Dialog::new( + "paragraphStyleOptions", + json!({"name": designcraft_doc::BASIC_PARAGRAPH, "preview": true, "p.wordSpaceDesired": 1.1}), + ); + style_options_preview(&mut app, &mut d); + assert_ne!(app.session.doc().unwrap().doc, before, "a valid value previews"); + // Minimum above Desired: the page goes back and the dialog says why. + d.fields.insert("p.wordSpaceMin".into(), json!(1.5)); + style_options_preview(&mut app, &mut d); + assert_eq!(app.session.doc().unwrap().doc, before); + assert!(d.s("status").contains("Word Spacing"), "{:?}", d.fields.get("status")); + assert!(app.session.doc().unwrap().interaction.is_none()); + } + #[test] fn invalid_style_preview_leaves_the_starting_document_and_can_be_cancelled() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 27ac9472f79ff536bda94656acbd05a827168e97 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 04:02:40 +0000 Subject: [PATCH 29/41] Integration: #1083 rename comments name the slow double-click that starts an object's rename --- crates/ui-egui/src/panels/layers.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index c770b13f..657e9b61 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -566,7 +566,7 @@ type RenameKey = u64; type RenameState = (RenameKey, String, String); const RENAME: &str = "layers_rename"; -/// The name field of the row being renamed (double-click), in `r`: `Some(Some(name))` when Enter +/// The name field of the row being renamed (a layer's double-click, an object's slow one), in `r`: `Some(Some(name))` when Enter /// or a click elsewhere confirms it with a changed text, `Some(None)` when Esc cancels or the text /// is the one it started with, `None` while typing or when this row isn't being renamed. fn rename_field(ui: &mut Ui, r: Rect, key: RenameKey) -> Option> { @@ -798,7 +798,7 @@ mod tests { let mut h = Harness::builder().with_size(egui::vec2(260.0, 400.0)).build_ui_state(|ui, app: &mut DesignApp| show(app, ui), app); h.run_steps(2); let rename = |h: &mut Harness<'_, DesignApp>, key: RenameKey, typed: &str| { - // As a double-click starts it (with an emptied field here, so the typing is the name). + // As a slow double-click starts it (with an emptied field here, so the typing is the name). h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(RENAME), (key, String::new(), String::new()))); h.run_steps(2); h.get_by_role(egui::accesskit::Role::TextInput).type_text(typed); @@ -826,7 +826,7 @@ mod tests { let mut h = Harness::builder().with_size(egui::vec2(260.0, 400.0)).build_ui_state(|ui, app: &mut DesignApp| show(app, ui), app); h.run_steps(2); let name = |h: &Harness<'_, DesignApp>| h.state().session.active().unwrap().doc.item(ItemId(id)).unwrap().name.clone(); - // As a double-click on the unnamed row starts it: the field shows the automatic label. + // As a slow double-click on the unnamed row starts it: the field shows the automatic label. let start = |h: &mut Harness<'_, DesignApp>| { h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(RENAME), (id, "Rectangle".to_string(), "Rectangle".to_string()))); h.run_steps(2); From 70b3ca22a0c3669e3d239d8def78611739041450 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 04:03:42 +0000 Subject: [PATCH 30/41] Integration: #1063 progress-log lines of #1060-#1065 sit under Newest first; the Typography line ends as usual and the parity doc keeps no done-work paragraph --- ROADMAP.md | 11 +++++------ docs/ui-parity.md | 7 ------- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 175e4ce9..97c90ec2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -345,6 +345,10 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-10-13** · Switching between document and parent pages refreshes the canvas immediately, preventing black parent pages and stale document objects when the zoom stays the same. Ordinary edits retain partial repainting. No readiness percentage change. + +Newest first. + - **2026-10-15** · Applying text styles removes matching local formatting so capture sources inherit later edits; real overrides and named character styles remain. - **2026-10-15** · Redefine paragraph and character styles from local formatting, including the @@ -356,12 +360,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m - **2026-10-15** · Paragraph Style Options summarizes captured and pending color, size and paragraph settings relative to the Based On style. - **2026-10-15** · Typography uses named panel groups with the Control panel and restores that - arrangement on Reset Workspace, leaving the page clear until a panel is opened. - -- **2026-10-13** · Switching between document and parent pages refreshes the canvas immediately, preventing black parent pages and stale document objects when the zoom stays the same. Ordinary edits retain partial repainting. No readiness percentage change. - -Newest first. - + arrangement on Reset Workspace, leaving the page clear until a panel is opened. No readiness percentage change. - **2026-10-14** · Threading keeps generated and linked stories, text that refers to a story, local IDs and locked frames intact; a colour chosen at a caret waits for the typing, Swap Fill and Stroke on text keeps each run's colours, control-channel input never reads or writes the system clipboard, and IDML frame preferences given as properties import with bounded column counts (#84). No readiness percentage change. - **2026-10-14** · The Liquid Layout panel follows InDesign's: Liquid Page Rule with the page number, Content › Auto-Fit, and Object Constraints › Resize with Page and Pin for the selected object on an object-based page; Create Alternate Layout moved to the Layout menu. No readiness percentage change. - **2026-10-14** · On an object-based page the Page tool shows a selected object's liquid pins and resize locks as InDesign does: a click pins a side to the page edge or locks the width or height, with InDesign's tooltips. No readiness percentage change. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 70a4732d..78c94868 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -108,13 +108,6 @@ Paragraph Style Options' General pane shows a Style Settings summary of differen parent, including captured color, size and spacing. Pending edits appear before OK without changing the document. Fully captured default attributes do not overwhelm the summary. This covers checking the sampled and redefined settings in the styles tutorial (2:45 and 5:15). -Typography now starts and resets with the Control panel and named panel groups along the right -edge, including Paragraph/Paragraph Styles and Character/Character Styles. It does not open -Properties or a Styles flyout over the new page. This matches the available panels at 0:05–0:15 -in [Jessica Garrity's styles tutorial](https://www.youtube.com/watch?v=pG9JkAPLgs8). -CC Libraries and a separate Story panel are absent; these are not needed for the tutorial's -style operations. The flyout tabs and detailed controls remain separate UI parity work. - | Work | Opus 5.5 h | |---|---| | Copy/paste, context menus, threading clicks, caret formatting on every platform, with control-channel regression tests | 30–50 | From 904c518efbda50928fde893441df984c0cb82b5a Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 04:04:57 +0000 Subject: [PATCH 31/41] Integration: #1044 leaving Presentation puts back the view it was entered from --- crates/ui-egui/src/lib.rs | 4 ++++ crates/ui-egui/src/menus.rs | 22 ++++++++++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e6e9f4e2..bd0473ba 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -189,6 +189,9 @@ pub struct UiState { pub screen_mode: ScreenMode, /// The screen mode leaving Presentation returns to: the one it was entered from. pub presentation_return: ScreenMode, + /// The view (by its `views` key) Presentation was entered from; leaving puts it back. + #[serde(skip)] + pub presentation_view: Option<(u64, View)>, pub frame_edges: bool, pub rulers: bool, pub guides: bool, @@ -350,6 +353,7 @@ impl Default for UiState { brightness: theme::Brightness::MediumDark, screen_mode: ScreenMode::Normal, presentation_return: ScreenMode::Normal, + presentation_view: None, frame_edges: true, rulers: true, guides: true, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4f7aa805..364ede52 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1936,8 +1936,18 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option Date: Fri, 16 Oct 2026 04:05:27 +0000 Subject: [PATCH 32/41] Integration: #1021 Minimize runs window.panel.layout, so scripts and the control channel reach it too --- crates/ui-egui/src/panel_docking.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index fec61f2e..202123ec 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -589,10 +589,7 @@ fn tab_commands(app: &mut DesignApp, ui: &mut egui::Ui, panel: &'static str, clo let layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); let minimizable = open_in_stack(&layout, panel); if ui.add_enabled(minimizable, egui::Button::new(crate::rtl::widget(ui, tr("Minimize")))).clicked() { - if let Err(error) = apply_action(app, Action::SetStackOpen { panel: panel.to_string(), open: false }) { - app.status(error); - } - ui.close(); + run(app, ui, "window.panel.layout", json!({"action": {"SetStackOpen": {"panel": panel, "open": false}}})); } ui.separator(); if ui.add_enabled(app.ui.dock_expanded, egui::Button::new(crate::rtl::widget(ui, tr("Collapse to Icons")))).clicked() { From be8c85705ccb7e710b55d488229bdb3ffe8d3928 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 04:06:15 +0000 Subject: [PATCH 33/41] Integration: #1022 style panels closed from the dock reopen in the dock, not floating --- crates/ui-egui/src/panel_docking.rs | 4 ++++ crates/ui-egui/src/panel_docking_tests.rs | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 32bcda7b..6f5d87c4 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -310,6 +310,10 @@ fn open_floating_group(app: &mut DesignApp, params: &Value) -> Option Date: Fri, 16 Oct 2026 04:07:14 +0000 Subject: [PATCH 34/41] =?UTF-8?q?Integration:=20#1035=20Preflight=20Panel?= =?UTF-8?q?=E2=80=A6=20shows=20the=20panel=20when=20it=20is=20already=20op?= =?UTF-8?q?en=20instead=20of=20closing=20it;=20errors=20go=20to=20the=20st?= =?UTF-8?q?atus=20bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/chrome.rs | 18 +++++++++++++++++- 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 + 9 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index da05cfef..9c83ffe6 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -735,6 +735,16 @@ pub fn doc_tabs(app: &mut DesignApp, ui: &mut egui::Ui) { } } +/// Show the Preflight panel. `window.panel` toggles a panel that is already open, so it runs +/// only when Preflight isn't showing. +fn show_preflight(app: &mut DesignApp) { + if app.ui.open_panel.as_deref() != Some("preflight") + && let Err(e) = app.run("window.panel", json!({"panel": "preflight"})) + { + app.status(e); + } +} + pub fn status_bar(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let resp = egui::Panel::bottom("status_bar") @@ -810,7 +820,7 @@ pub fn status_bar(app: &mut DesignApp, ui: &mut egui::Ui) { // As in InDesign, the count opens a menu that leads to the Preflight panel. crate::menus::menu_button(ui, egui::RichText::new(count).font(small.clone()), |ui| { if ui.button(crate::i18n::tr(&app.ui.language, "Preflight Panel…")).clicked() { - let _ = app.run("window.panel", json!({"panel": "preflight"})); + show_preflight(app); ui.close(); } }); @@ -1234,6 +1244,12 @@ mod tests { // Preflight is not a dock tab: `window.panel` shows it as its own panel. assert_eq!(h.state().app.ui.open_panel.as_deref(), Some("preflight")); h.get_by_label("Preflight"); + // With the panel already showing, the entry keeps it open. + h.get_by_label("No errors ▾").click(); + h.run_steps(3); + h.get_by_label("Preflight Panel…").click(); + h.run_steps(3); + assert_eq!(h.state().app.ui.open_panel.as_deref(), Some("preflight")); } /// A very long document name made its tab grow past the window, hiding the close button diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index c3d88b23..3d7b11e7 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1916,6 +1916,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Picas", "بيكا"), ("Place with Import Options", "وضع مع خيارات الاستيراد"), ("Preflight", "الفحص المسبق"), + ("Preflight Panel…", "لوحة الفحص المسبق…"), ("Previous", "السابق"), ("Previous Page Number", "رقم الصفحة السابقة"), ("Print Booklet", "طباعة كتيّب"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 6b329db4..e77e72a1 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -1710,6 +1710,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paragraph Symbol", "Symbol odstavce"), ("Picas", "Piky"), ("Preflight", "Předtisková kontrola"), + ("Preflight Panel…", "Panel předtiskové kontroly…"), ("Previous Page Number", "Číslo předchozí stránky"), ("Punctuation Space", "Mezera šířky interpunkce"), ("Quarter Space", "Čtvrtina čtverčíku"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index fa709879..adcd3110 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -1728,6 +1728,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paragraph Symbol", "Absatzzeichen"), ("Picas", "Pica"), ("Preflight", "Preflight"), + ("Preflight Panel…", "Preflight-Bedienfeld …"), ("Previous Page Number", "Vorherige Seitenzahl"), ("Punctuation Space", "Interpunktionsleerzeichen"), ("Quarter Space", "Viertelgeviert"), diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 9ccb87a3..46e0a1fa 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -146,6 +146,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Picas", "Picas"), ("Place with Import Options", "Importer avec les options d'importation"), ("Preflight", "Contrôle en amont"), + ("Preflight Panel…", "Panneau Contrôle en amont…"), ("Previous Page", "Page précédente"), ("Previous Page Number", "Numéro de la page précédente"), ("Print Booklet", "Imprimer le cahier"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 7eea64d7..5acaa5e3 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1926,6 +1926,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paragraph Symbol", "段落記号"), ("Picas", "パイカ"), ("Preflight", "プリフライト"), + ("Preflight Panel…", "プリフライトパネル…"), ("Previous Page Number", "前のページ番号"), ("Punctuation Space", "句読点幅スペース"), ("Quarter Space", "四分スペース"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index a1d63639..09afe1ab 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -1738,6 +1738,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paragraph Symbol", "აბზაცის სიმბოლო"), ("Picas", "პიკები"), ("Preflight", "ბეჭდვისწინა შემოწმება"), + ("Preflight Panel…", "ბეჭდვისწინა შემოწმების პანელი…"), ("Previous Page Number", "წინა გვერდის ნომერი"), ("Punctuation Space", "პუნქტუაციის შორისი"), ("Quarter Space", "მეოთხედი შორისი"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index ef41e692..68d685d9 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -1682,6 +1682,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paragraph Symbol", "Symbol akapitu"), ("Picas", "Pica"), ("Preflight", "Inspekcja wstępna"), + ("Preflight Panel…", "Panel inspekcji wstępnej…"), ("Previous Page Number", "Numer poprzedniej strony"), ("Punctuation Space", "Spacja interpunkcyjna"), ("Quarter Space", "Ćwierćfiret"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index dd52bb83..251555ca 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -1692,6 +1692,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paragraph Symbol", "Знак абзаца"), ("Picas", "Пики"), ("Preflight", "Предпечатная проверка"), + ("Preflight Panel…", "Панель предпечатной проверки…"), ("Previous Page Number", "Номер предыдущей страницы"), ("Punctuation Space", "Пунктуационный пробел"), ("Quarter Space", "Четверть шпации"), From d0caaaf34454d5c9bdeafaca1c59aacb6456eefb Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 04:07:39 +0000 Subject: [PATCH 35/41] Integration: #1046 double-clicking the preflight count keeps an open Preflight panel open (show_preflight) --- crates/ui-egui/src/chrome.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 32d9d83e..8e7f3f98 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -827,7 +827,7 @@ pub fn status_bar(app: &mut DesignApp, ui: &mut egui::Ui) { // Double-clicking the light or the count shows the panel straight away (the // second click has closed the menu the first one opened). if dot.double_clicked() || menu.response.double_clicked() { - let _ = app.run("window.panel", json!({"panel": "preflight"})); + show_preflight(app); } ui.add_space(12.0); ui.label(egui::RichText::new(&app.ui.status).font(small).color(t.text_dim)); @@ -1271,6 +1271,12 @@ mod tests { assert_eq!(h.state().app.ui.open_panel.as_deref(), Some("preflight")); h.get_by_label("Preflight"); assert!(h.query_by_label("Preflight Panel…").is_none(), "the menu isn't left open"); + // A double-click with the panel already showing keeps it open. + let count = h.get_by_label("No errors ▾"); + count.click(); + count.click(); + h.run_steps(3); + assert_eq!(h.state().app.ui.open_panel.as_deref(), Some("preflight")); } /// A very long document name made its tab grow past the window, hiding the close button From 746ccad1f30987af9fd712c80dbe33402a68979a Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 04:08:22 +0000 Subject: [PATCH 36/41] Integration: #1076 a held-open tool flyout survives its release, and Esc closes the flyout without switching tools --- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/toolbar.rs | 29 +++++++++++++++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 42222f34..b7ab03fe 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -3071,7 +3071,7 @@ pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) { eprintln!("tool shortcut {sc} ({key:?}, {modifiers:?}) -> {tool}"); } app.select_tool(tool); - } else if key == egui::Key::Escape && app.session.tool_id() != "selection" && !app.session.tool_busy() { + } else if key == egui::Key::Escape && app.session.tool_id() != "selection" && !app.session.tool_busy() && app.ui.flyout.is_none() { app.select_tool("selection"); } } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 5af6ea6a..7c8ddbd6 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -182,13 +182,14 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { app.select_tool(id); } // As InDesign's, an open flyout closes on Esc and on a click anywhere outside it (a - // panel, a swatch, the canvas), not only on the canvas. + // panel, a swatch, the canvas), not only on the canvas. A new press counts, not a + // click: the release that ends the hold which opened it is one. if let Some(gi) = app.ui.flyout && chosen.is_none() && !opened_now { let area = ui.ctx().memory(|m| m.area_rect(egui::Id::new(("flyout", gi)))); - let outside = ui.input(|i| i.pointer.any_click() && i.pointer.interact_pos().is_some_and(|p| area.is_none_or(|a| !a.contains(p)))); + let outside = ui.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| area.is_none_or(|a| !a.contains(p)))); let esc = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); if outside || esc { app.ui.flyout = None; @@ -664,6 +665,30 @@ mod tests { assert_eq!((h.state().app.session.tool_id(), h.state().app.ui.flyout), ("ellipseFrame", None)); } + /// A flyout opened by holding the group button stays open after the release, and Esc closes + /// it without switching the active tool. + #[test] + fn a_tool_flyout_opened_by_holding_stays_open_and_esc_keeps_the_tool() { + use egui::{Event, Modifiers, PointerButton}; + use egui_kittest::kittest::Queryable; + let app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let mut h = crate::test_window::open_stepped(app, vec2(1400.0, 900.0), 1.0, 1.0 / 60.0); + h.state_mut().app.select_tool("rectangleFrame"); + h.run_steps(2); + let at = h.get_by_label("Rectangle Frame Tool (F)").rect().center(); + h.input_mut().events.push(Event::PointerMoved(at)); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + // Held for half a second: shorter than egui's longest click. + h.run_steps(30); + assert!(h.state().app.ui.flyout.is_some(), "holding opens the flyout"); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + assert!(h.state().app.ui.flyout.is_some(), "the release keeps it open"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert_eq!((h.state().app.session.tool_id(), h.state().app.ui.flyout), ("rectangleFrame", None)); + } + #[test] fn a_short_window_reaches_every_tool() { for double in [false, true] { From eafd6a192e00be2b8f7e71fdb90b9e5f7d0c78ee Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 04:08:36 +0000 Subject: [PATCH 37/41] Integration: the progress log keeps one copy of the 2026-10-13 canvas refresh line --- ROADMAP.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 61a4dd94..30dd1833 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -345,8 +345,6 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log -- **2026-10-13** · Switching between document and parent pages refreshes the canvas immediately, preventing black parent pages and stale document objects when the zoom stays the same. Ordinary edits retain partial repainting. No readiness percentage change. - Newest first. - **2026-10-15** · Applying text styles removes matching local formatting so capture sources From 1e149e38e0768d1c9c08618098c1b5413424971b Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 04:11:37 +0000 Subject: [PATCH 38/41] Integration: cargo fmt --- crates/ui-egui/src/chrome.rs | 6 +++++- crates/ui-egui/src/dialogs.rs | 6 ++---- crates/ui-egui/src/i18n.rs | 16 ++++++++++++++-- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 8e7f3f98..0df5361f 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -814,7 +814,11 @@ pub fn status_bar(app: &mut DesignApp, ui: &mut egui::Ui) { } else { format!( "{} ▾", - crate::i18n::count_label(&app.ui.language, if errors == 1 && !crate::i18n::category_first(&app.ui.language) { "error" } else { "errors" }, errors) + crate::i18n::count_label( + &app.ui.language, + if errors == 1 && !crate::i18n::category_first(&app.ui.language) { "error" } else { "errors" }, + errors + ) ) }; // As in InDesign, the count opens a menu that leads to the Preflight panel. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index bf2548bf..767bea4f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -7652,10 +7652,8 @@ mod tests { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({})).unwrap(); let before = app.session.doc().unwrap().doc.clone(); - let mut d = Dialog::new( - "paragraphStyleOptions", - json!({"name": designcraft_doc::BASIC_PARAGRAPH, "preview": true, "p.wordSpaceDesired": 1.1}), - ); + let mut d = + Dialog::new("paragraphStyleOptions", json!({"name": designcraft_doc::BASIC_PARAGRAPH, "preview": true, "p.wordSpaceDesired": 1.1})); style_options_preview(&mut app, &mut d); assert_ne!(app.session.doc().unwrap().doc, before, "a valid value previews"); // Minimum above Desired: the page goes back and the dialog says why. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index b643eca9..92d4b841 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2820,11 +2820,23 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ( "Alt-click clears overrides", - ["Alt-Klick entfernt Abweichungen", "Alt-clic efface les remplacements", "Alt-clic borra las modificaciones", "Alt を押しながらクリックするとオーバーライドを消去します", "Alt-单击清除覆盖"], + [ + "Alt-Klick entfernt Abweichungen", + "Alt-clic efface les remplacements", + "Alt-clic borra las modificaciones", + "Alt を押しながらクリックするとオーバーライドを消去します", + "Alt-单击清除覆盖", + ], ), ( "Apply \"{name}\", Clear Overrides", - ["„{name}“ anwenden, Abweichungen löschen", "Appliquer « {name} », effacer les remplacements", "Aplicar \"{name}\", borrar modificaciones", "\"{name}\" を適用、オーバーライドを消去", "应用\"{name}\",清除覆盖"], + [ + "„{name}“ anwenden, Abweichungen löschen", + "Appliquer « {name} », effacer les remplacements", + "Aplicar \"{name}\", borrar modificaciones", + "\"{name}\" を適用、オーバーライドを消去", + "应用\"{name}\",清除覆盖", + ], ), ("⚠ out of date", ["⚠ out of date", "⚠ obsolète", "⚠ out of date", "⚠ out of date", "⚠ 已过期"]), ("General", ["General", "Général", "General", "General", "常规"]), From 51f790a092479e43fbd5d27f2285ed6745a38b85 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 04:35:52 +0000 Subject: [PATCH 39/41] Integration: #1059 canvas menu tests match rows in the canvas, not the Properties panel's Arrange button --- crates/ui-egui/src/canvas.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index dcc464d7..cd0e022a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3754,11 +3754,14 @@ mod tests { } /// A menu row: a button that isn't a toggle, named by its label, then its shortcut if it has - /// one. Toggle buttons with the same name (the Task Bar's icons) don't match. + /// one. Toggle buttons with the same name (the Task Bar's icons) don't match, nor do buttons + /// right of the canvas (the Properties panel's Arrange). fn menu_row<'h>(h: &'h Harness<'_, DesignApp>, label: &'h str) -> Option> { + let right = f64::from(h.state().canvas_rect.map_or(f32::INFINITY, |r| r.max.x)); h.query_by(move |n| { n.role() == egui::accesskit::Role::Button && n.toggled().is_none() + && n.bounding_box().is_none_or(|b| b.x0 < right) && n.label().is_some_and(|l| l == label || l.strip_prefix(label).and_then(|r| r.strip_prefix(' ')).is_some_and(|r| !r.contains(' '))) }) } From eb2b2f1b5fa8c9614c0eb9692e19d6d090e95693 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 04:37:17 +0000 Subject: [PATCH 40/41] Integration: #1043, #1059, #1074 French strings now in the shared table leave the French catalog --- crates/ui-egui/src/i18n/fr.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 46e0a1fa..78c5d594 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -64,7 +64,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Figure Space", "Espace chiffre"), ("File/Export", "Fichier/Exporter"), ("Find What", "Rechercher"), - ("First Page", "Première page"), ("Flush Space", "Espace sans alinéa"), ("Forced Line Break", "Saut de ligne forcé"), ("Frame Break", "Saut de bloc"), @@ -75,7 +74,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Graphic", "Graphique"), ("Hair Space", "Espace ultrafine"), ("Hide Guides", "Masquer les repères"), - ("Hide Rulers", "Masquer les règles"), ("High Contrast", "Contraste élevé"), ("High Resolution", "Haute résolution"), ("I, II, III, IV...", "I, II, III, IV..."), @@ -91,7 +89,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Join the ArtCraft Discord", "Rejoindre le Discord d'ArtCraft"), ("Join the community", "Rejoindre la communauté"), ("Kerning", "Crénage"), - ("Last Page", "Dernière page"), ("Layout/Index", "Page/Index"), ("Layout/Pages", "Page/Pages"), ("Legacy Macintosh RGB", "RVB Macintosh hérité"), @@ -99,7 +96,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Load DTD", "Charger la DTD"), ("Load Swatches", "Charger des nuances"), ("Low Resolution", "Basse résolution"), - ("Margins and Columns", "Marges et colonnes"), ("Margins:", "Marges :"), ("Medium Dark", "Moyennement foncé"), ("Medium Light", "Moyennement clair"), @@ -114,7 +110,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Page Reference", "Nouvelle référence de page"), ("New Parent", "Nouveau gabarit"), ("New Stroke Style", "Nouveau style de contour"), - ("Next Page", "Page suivante"), ("Next Page Number", "Numéro de la page suivante"), ("Nonbreaking Hyphen", "Trait d'union insécable"), ("Nonbreaking Space", "Espace insécable"), @@ -147,7 +142,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Place with Import Options", "Importer avec les options d'importation"), ("Preflight", "Contrôle en amont"), ("Preflight Panel…", "Panneau Contrôle en amont…"), - ("Previous Page", "Page précédente"), ("Previous Page Number", "Numéro de la page précédente"), ("Print Booklet", "Imprimer le cahier"), ("Punctuation Space", "Espace de ponctuation"), From 699b5da7dc15e8f79341171f8953d72a8dec45d6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 04:37:18 +0000 Subject: [PATCH 41/41] Integration: #1076 the release that ends a hold opening a tool flyout doesn't pick the tool and close it --- crates/ui-egui/src/toolbar.rs | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 7c8ddbd6..a04c45a4 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -99,9 +99,16 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { Stroke::NONE, )); } - if resp.clicked() { + // The release that ends a hold which opened the flyout isn't a click + // on the tool (egui reports releases within 0.8 s as clicks). + let hold_id = egui::Id::new(("flyout_hold", gi)); + let ended_hold = resp.clicked() && ui.data_mut(|d| d.remove_temp::(hold_id)).unwrap_or(false); + if resp.clicked() && !ended_hold { chosen = Some(shown.id); } + if !resp.is_pointer_button_down_on() { + ui.data_mut(|d| d.remove::(hold_id)); + } // Double-click a polygon tool: Polygon Settings. if resp.double_clicked() && matches!(shown.id, "polygon" | "polygonFrame") { let cur = app.session.execute("tool.polygonSettings", &json!({})).unwrap_or_default(); @@ -113,12 +120,12 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { { crate::menus::activate(app, cmd, &Value::Null); } - if g.len() > 1 - && (resp.secondary_clicked() - || resp.long_touched() - || (resp.is_pointer_button_down_on() - && ui.input(|i| i.pointer.press_start_time().is_some_and(|s| i.time - s > 0.35)))) - { + let held = + resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.press_start_time().is_some_and(|s| i.time - s > 0.35)); + if g.len() > 1 && held { + ui.data_mut(|d| d.insert_temp(hold_id, true)); + } + if g.len() > 1 && (resp.secondary_clicked() || resp.long_touched() || held) { opened_now |= app.ui.flyout != Some(gi); app.ui.flyout = Some(gi); }