From b21111f232223e333d372eab9c77a83895fe326d Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 14:21:36 +0000 Subject: [PATCH 01/48] Toolbox: Shift+X, D, X and / shortcuts for the Fill/Stroke proxy The proxy's tooltips named keys that did nothing. color.defaultFillStroke (D), color.swapFillStroke (Shift+X), color.toggleFillStroke (X) and color.applyNone (/) are UI commands; the proxy's buttons run the same commands. Swap keeps the fill and stroke tints. Which of fill and stroke is in front lives in UiState, shared by the Tools panel proxy, the Color panel and the commands. The single-key shortcuts stay out of text being typed, like the tool shortcuts. --- ROADMAP.md | 1 + crates/ui-egui/src/i18n.rs | 9 ++++ crates/ui-egui/src/i18n/ar.rs | 3 ++ crates/ui-egui/src/i18n/it.rs | 3 ++ crates/ui-egui/src/i18n/ja.rs | 3 ++ crates/ui-egui/src/i18n/pt_br.rs | 3 ++ crates/ui-egui/src/i18n/uk.rs | 3 ++ crates/ui-egui/src/lib.rs | 5 ++ crates/ui-egui/src/menus.rs | 9 ++++ crates/ui-egui/src/panels/swatches.rs | 9 ++-- crates/ui-egui/src/toolbar.rs | 78 ++++++++++++++++++++++++--- docs/target-app-parity.md | 2 +- 12 files changed, 113 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d33353caa..29984b279 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · The Tools panel's Fill/Stroke keys work: D (default fill and stroke), X (fill or stroke in front), Shift+X (swap, tints included) and / (Apply None), as `color.*` commands; the Color panel follows the proxy's fill/stroke choice. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index e5e8a8d08..d9e9d07cb 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -646,6 +646,15 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Fill (X)", ["Fill (X)", "Fill (X)", "Fill (X)", "Fill (X)", "填色 (X)"]), ("Stroke (X)", ["Stroke (X)", "Stroke (X)", "Stroke (X)", "Stroke (X)", "描边 (X)"]), ("Apply None (/)", ["Apply None (/)", "Apply None (/)", "Apply None (/)", "Apply None (/)", "应用无 (/)"]), + ( + "Default Fill and Stroke", + ["Default Fill and Stroke", "Default Fill and Stroke", "Default Fill and Stroke", "Default Fill and Stroke", "默认填色和描边"], + ), + ("Swap Fill and Stroke", ["Swap Fill and Stroke", "Swap Fill and Stroke", "Swap Fill and Stroke", "Swap Fill and Stroke", "交换填色和描边"]), + ( + "Toggle Fill and Stroke", + ["Toggle Fill and Stroke", "Toggle Fill and Stroke", "Toggle Fill and Stroke", "Toggle Fill and Stroke", "切换填色和描边"], + ), ("Reverse the gradient", ["Reverse the gradient", "Reverse the gradient", "Reverse the gradient", "Reverse the gradient", "反转渐变"]), ("Style source", ["Style source", "Style source", "Style source", "Style source", "样式源"]), ("Open", ["Open", "Open", "Open", "Open", "打开"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 10e393cd2..e9f8981cb 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -520,6 +520,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Superscript", "النص المرتفع"), ("Tag the selection", "إضافة وسم للتحديد"), ("The structure is valid.", "البنية صالحة."), + ("Toggle Fill and Stroke", "التبديل بين التعبئة والحد"), ("Toggle double column", "تبديل التخطيط بعمودين"), ("Type:", "النوع:"), ("Underline", "تسطير"), @@ -844,6 +845,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Crop to:", "القص إلى:"), ("Cross-Reference Format", "تنسيق المرجع المتقاطع"), ("Cursor Key:", "مفتاح المؤشر:"), + ("Default Fill and Stroke", "التعبئة والحد الافتراضيان"), ("Default Fill and Stroke (D)", "التعبئة والحد الافتراضيان (D)"), ("Default View:", "العرض الافتراضي:"), ("Delete Column", "حذف العمود"), @@ -985,6 +987,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Style Name Conflicts", "تعارض أسماء الأنماط"), ("Stylistic Sets", "المجموعات الأسلوبية"), ("Subdivisions:", "التقسيمات الفرعية:"), + ("Swap Fill and Stroke", "تبديل التعبئة والحد"), ("Swap Fill and Stroke (Shift+X)", "تبديل التعبئة والحد (Shift+X)"), ("Synchronize", "مزامنة"), ("System default", "افتراضي النظام"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index c91afd6ba..9fe80ca10 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -376,6 +376,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Fill (X)", "Riempimento (X)"), ("Stroke (X)", "Traccia (X)"), ("Apply None (/)", "Applica nessuno (/)"), + ("Default Fill and Stroke", "Riempimento e traccia predefiniti"), + ("Swap Fill and Stroke", "Scambia riempimento e traccia"), + ("Toggle Fill and Stroke", "Alterna riempimento e traccia"), ("Reverse the gradient", "Inverti la sfumatura"), ("Style source", "Origine stile"), ("Open", "Apri"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index be6e2c4da..9db79b854 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -378,6 +378,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Fill (X)", "塗り (X)"), ("Stroke (X)", "線 (X)"), ("Apply None (/)", "カラーなしを適用 (/)"), + ("Default Fill and Stroke", "塗りと線をデフォルトに戻す"), + ("Swap Fill and Stroke", "塗りと線を入れ替え"), + ("Toggle Fill and Stroke", "塗りと線を切り替え"), ("Reverse the gradient", "グラデーションを反転"), ("Style source", "スタイルの同期元"), ("Open", "開く"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index d25717dc6..e1634665c 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -519,6 +519,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Superscript", "Sobrescrito"), ("Tag the selection", "Marcar a seleção"), ("The structure is valid.", "A estrutura é válida."), + ("Toggle Fill and Stroke", "Alternar entre preenchimento e traçado"), ("Toggle double column", "Alternar coluna dupla"), ("Type:", "Tipo:"), ("Underline", "Sublinhado"), @@ -846,6 +847,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Crop to:", "Cortar para:"), ("Cross-Reference Format", "Formato de referência cruzada"), ("Cursor Key:", "Tecla do cursor:"), + ("Default Fill and Stroke", "Preenchimento e traçado padrão"), ("Default Fill and Stroke (D)", "Preenchimento e traçado padrão (D)"), ("Default View:", "Exibição padrão:"), ("Delete Column", "Excluir coluna"), @@ -992,6 +994,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Style Name Conflicts", "Conflitos de nomes de estilos"), ("Stylistic Sets", "Conjuntos estilísticos"), ("Subdivisions:", "Subdivisões:"), + ("Swap Fill and Stroke", "Alternar preenchimento e traçado"), ("Swap Fill and Stroke (Shift+X)", "Alternar preenchimento e traçado (Shift+X)"), ("Synchronize", "Sincronizar"), ("System default", "Padrão do sistema"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9a9fb89ba..b4434a2f5 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -379,6 +379,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Fill (X)", "Заливка (X)"), ("Stroke (X)", "Обведення (X)"), ("Apply None (/)", "Без кольору (/)"), + ("Default Fill and Stroke", "Заливка та обведення за замовчуванням"), + ("Swap Fill and Stroke", "Змінити заливку та обведення"), + ("Toggle Fill and Stroke", "Перемкнути заливку та обведення"), ("Reverse the gradient", "Змінити напрямок градієнта"), ("Style source", "Джерело стилів"), ("Open", "Відкрити"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3d94bdb03..ce8e439e3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -287,6 +287,10 @@ pub struct UiState { pub palette: Option, #[serde(skip)] pub flyout: Option, + /// Fill/Stroke proxy (Tools and Color panels): the stroke is in front, so Apply None and the + /// Color panel act on it (X toggles). + #[serde(skip)] + pub stroke_front: bool, } impl Default for UiState { @@ -362,6 +366,7 @@ impl Default for UiState { dialog: None, palette: None, flyout: None, + stroke_front: false, } } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d123ec4eb..0d3f8c172 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -85,6 +85,10 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.zoom", "Zoom To", None, "{zoom: 1.0 = 100%}"), ("view.screenMode", "Screen Mode", None, "{mode: normal|preview|bleed|slug|presentation}"), ("view.togglePreview", "Toggle Normal/Preview", Some("W"), "{}"), + ("color.defaultFillStroke", "Default Fill and Stroke", Some("D"), "{} — the selection gets no fill and a black stroke"), + ("color.swapFillStroke", "Swap Fill and Stroke", Some("Shift+X"), "{} — the selection's fill and stroke colours (and tints) trade places"), + ("color.toggleFillStroke", "Toggle Fill and Stroke", Some("X"), "{} → {strokeFront} — which of fill and stroke the proxy has in front"), + ("color.applyNone", "Apply None", Some("/"), "{} — [None] for the fill or stroke in front of the proxy"), ("view.frameEdges", "Show/Hide Frame Edges", Some("Cmd+H"), "{}"), ("view.rulers", "Show/Hide Rulers", Some("Cmd+R"), "{}"), ("view.guides", "Show/Hide Guides", Some("Cmd+;"), "{}"), @@ -1109,6 +1113,10 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option crate::toolbar::default_fill_stroke(app), + "color.swapFillStroke" => crate::toolbar::swap_fill_stroke(app), + "color.toggleFillStroke" => crate::toolbar::toggle_fill_stroke(app), + "color.applyNone" => crate::toolbar::apply_none(app), "view.frameEdges" => flag(&mut app.ui.frame_edges), "view.overprintPreview" => { app.canvas.shown = None; @@ -2097,6 +2105,7 @@ fn parse_shortcut(sc: &str) -> Option<(egui::Modifiers, egui::Key)> { "," => key = Some(egui::Key::Comma), "." => key = Some(egui::Key::Period), "\\" => key = Some(egui::Key::Backslash), + "/" => key = Some(egui::Key::Slash), "Delete" => key = Some(egui::Key::Delete), k => key = egui::Key::from_name(k), } diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 1aa11ddb3..7544347a9 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -209,9 +209,8 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { return; }; let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; - let stroke_id = egui::Id::new("color_panel_stroke"); let mode_id = egui::Id::new("color_panel_mode"); - let mut stroke: bool = ui.data(|d| d.get_temp(stroke_id)).unwrap_or(false); + let mut stroke = app.ui.stroke_front; let default_mode = if doc.settings.intent == designcraft_doc::Intent::Print { ColorMode::Cmyk } else { ColorMode::Rgb }; let mut mode: ColorMode = ui.data(|d| d.get_temp(mode_id)).unwrap_or(default_mode); let target = if stroke { "stroke" } else { "fill" }; @@ -253,10 +252,8 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }); }); }); - ui.data_mut(|d| { - d.insert_temp(stroke_id, stroke); - d.insert_temp(mode_id, mode); - }); + app.ui.stroke_front = stroke; + ui.data_mut(|d| d.insert_temp(mode_id, mode)); let sw = doc.swatch(&name).cloned(); let Some(c) = doc.resolve_color(&name, 1.0) else { ui.label(crate::rtl::widget( diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 5a5e1f08a..ba825505e 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -196,9 +196,8 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { let t = Tokens::get(ui.ctx()); let info = crate::panels::sel_info(app); let doc = app.session.active().map(|d| d.doc.clone()); - let stroke_front_id = egui::Id::new("proxy_stroke_front"); let text_id = egui::Id::new("proxy_text"); - let stroke_front: bool = ui.data(|d| d.get_temp(stroke_front_id)).unwrap_or(false); + let stroke_front = app.ui.stroke_front; let text_mode: bool = ui.data(|d| d.get_temp(text_id)).unwrap_or(false); let (fill_sw, stroke_sw) = match &info { Some(i) => (i.fill.clone(), i.stroke.clone()), @@ -216,8 +215,7 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { }) .clicked() { - let _ = app.run("object.fill", json!({"swatch": "[None]"})); - let _ = app.run("object.stroke", json!({"swatch": "[Black]"})); + let _ = app.run("color.defaultFillStroke", json!({})); } let swap = egui::Rect::from_min_size(egui::pos2(x0 + 19.0, row.min.y), vec2(11.0, 11.0)); icons::paint(ui.painter(), swap, "swap", t.icon); @@ -228,8 +226,7 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { }) .clicked() { - let _ = app.run("object.fill", json!({"swatch": stroke_sw})); - let _ = app.run("object.stroke", json!({"swatch": fill_sw})); + let _ = app.run("color.swapFillStroke", json!({})); } ui.add_space(2.0); // Proxy: 19.5 pt squares, fill top-left, stroke bottom-right. @@ -278,7 +275,7 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { if fresp.clicked() || sresp.clicked() { let front = if stroke_front { !fresp.clicked() } else { sresp.clicked() && !fill_r.contains(sresp.interact_pointer_pos().unwrap_or_default()) }; - ui.data_mut(|d| d.insert_temp(stroke_front_id, front)); + app.ui.stroke_front = front; } if fresp.double_clicked() || sresp.double_clicked() { let _ = app.run("window.panel", json!({"panel": "swatches"})); @@ -318,7 +315,7 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { flyout_triangle(ui.painter(), well, t.icon); let target = if stroke_front { "object.stroke" } else { "object.fill" }; if resp.clicked() { - let _ = app.run(target, json!({"swatch": "[None]"})); + let _ = app.run("color.applyNone", json!({})); } egui::Popup::context_menu(&resp).show(|ui| { for (label, sw) in [("Apply Color", "[Black]"), ("Apply None", "[None]")] { @@ -411,6 +408,33 @@ fn tool_button(ui: &mut egui::Ui, icon: &str, active: bool, tip: &str) -> egui:: }) } +/// Default Fill and Stroke (D): no fill, a black stroke. +pub(crate) fn default_fill_stroke(app: &mut DesignApp) -> Result { + app.run("object.fill", json!({"swatch": "[None]", "tint": 1.0}))?; + app.run("object.stroke", json!({"swatch": "[Black]", "tint": 1.0})) +} + +/// Swap Fill and Stroke (Shift+X): the selection's fill and stroke colours trade places, tints +/// included. +pub(crate) fn swap_fill_stroke(app: &mut DesignApp) -> Result { + let Some(info) = crate::panels::sel_info(app) else { return Err("nothing selected".into()) }; + let (fill_tint, stroke_tint) = (crate::panels::sel_tint(app, false), crate::panels::sel_tint(app, true)); + app.run("object.fill", json!({"swatch": info.stroke, "tint": stroke_tint}))?; + app.run("object.stroke", json!({"swatch": info.fill, "tint": fill_tint})) +} + +/// Fill/Stroke toggle (X): brings the other of fill and stroke to the front of the proxy. +pub(crate) fn toggle_fill_stroke(app: &mut DesignApp) -> Result { + app.ui.stroke_front = !app.ui.stroke_front; + Ok(json!({"strokeFront": app.ui.stroke_front})) +} + +/// Apply None (/): to the fill or the stroke, whichever is in front of the proxy. +pub(crate) fn apply_none(app: &mut DesignApp) -> Result { + let target = if app.ui.stroke_front { "object.stroke" } else { "object.fill" }; + app.run(target, json!({"swatch": "[None]"})) +} + #[cfg(test)] mod tests { use egui::{pos2, vec2}; @@ -482,4 +506,42 @@ mod tests { wheel(&mut h, panel.center(), vec2(0.0, -2000.0)); assert_eq!((rect(&h, LAST_TOOL), rect(&h, FILL)), before); } + /// The Fill/Stroke proxy's keys run on the selection and stay out of text being typed. + #[test] + fn fill_stroke_proxy_keys() { + use egui::{Event, Key, Modifiers}; + use serde_json::json; + fn press(app: &mut crate::DesignApp, ctx: &egui::Context, key: Key, modifiers: Modifiers) { + let events = [true, false].map(|pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec(); + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| crate::menus::shortcuts(app, ui.ctx())).textures_delta.clear(); + } + fn colors(app: &crate::DesignApp) -> (String, f32, String, f32) { + let st = app.session.active().unwrap(); + let it = st.doc.item(st.selection.items[0]).unwrap(); + (it.fill.swatch.clone(), it.fill.tint, it.stroke.swatch.clone(), it.stroke.tint) + } + let mut app = app(false); + let ctx = egui::Context::default(); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hello"})).unwrap(); + app.run("selection.set", json!({"ids": [f["id"]]})).unwrap(); + app.run("object.fill", json!({"swatch": "[Paper]", "tint": 0.5})).unwrap(); + app.run("object.stroke", json!({"swatch": "[Black]", "tint": 1.0})).unwrap(); + + press(&mut app, &ctx, Key::X, Modifiers::SHIFT); + assert_eq!(colors(&app), ("[Black]".into(), 1.0, "[Paper]".into(), 0.5), "Shift+X swaps, tints included"); + press(&mut app, &ctx, Key::D, Modifiers::NONE); + assert_eq!(colors(&app), ("[None]".into(), 1.0, "[Black]".into(), 1.0), "D: no fill, black stroke"); + press(&mut app, &ctx, Key::X, Modifiers::NONE); + assert!(app.ui.stroke_front, "X brings the stroke to the front"); + press(&mut app, &ctx, Key::Slash, Modifiers::NONE); + assert_eq!(colors(&app).2, "[None]", "/ applies None to the stroke in front"); + + // Typing in a story: the keys are text, not shortcuts. + app.session.execute("text.select", &json!({"story": f["story"], "anchor": 5})).unwrap(); + app.select_tool("type"); + assert!(app.session.wants_text()); + press(&mut app, &ctx, Key::X, Modifiers::NONE); + assert!(app.ui.stroke_front, "X while typing leaves the proxy alone"); + } } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e9c27a9f8..ee95a4905 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -268,7 +268,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together, widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing. - FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends. - Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text. -- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts, document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. +- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts and the Fill/Stroke proxy (D, X, Shift+X, / as `color.*` commands), document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. - Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows. - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. From 4ac6e9a54060e52c264ab1c6f21900a04479977b Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 14:28:58 +0000 Subject: [PATCH 02/48] Engine: swap and default fill and stroke in one undo step object.swapFillStroke and object.defaultFillStroke change every unlocked target in one edit, so one undo restores them all. Swap works per object and keeps each object's tints; a stroke that gets a colour at zero weight gets the default 1 pt. Objects that are locked, or on a locked layer, stay as they are. color.swapFillStroke and color.defaultFillStroke run these commands. --- ROADMAP.md | 2 +- crates/engine/src/cmd/object.rs | 110 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 14 +++- crates/ui-egui/src/toolbar.rs | 11 +--- 4 files changed, 126 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 29984b279..4dd17e470 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,7 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. -- **2026-10-12** · The Tools panel's Fill/Stroke keys work: D (default fill and stroke), X (fill or stroke in front), Shift+X (swap, tints included) and / (Apply None), as `color.*` commands; the Color panel follows the proxy's fill/stroke choice. No readiness percentage change. +- **2026-10-12** · The Tools panel's Fill/Stroke keys work: D (default fill and stroke), X (fill or stroke in front), Shift+X (swap per object, tints included) and / (Apply None), as `color.*` commands; swap and default are one undo step (`object.swapFillStroke`, `object.defaultFillStroke`); the Color panel follows the proxy's fill/stroke choice. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index cd13be117..699247291 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -185,6 +185,24 @@ pub fn specs() -> Vec { let tint = f64_or(p, "tint", 1.0) as f32; set_flag(s, p, move |i| i.fill = Fill { swatch: sw.clone(), tint, ..i.fill.clone() }, false) }), + cmd!( + "object.swapFillStroke", + "Swap Fill and Stroke", + [], + None, + "{ids?} — each object's fill and stroke colours (and tints) trade places; locked objects stay as they are", + has_editable_selection, + |s, p| edit_unlocked(s, p, swap_fill_stroke) + ), + cmd!( + "object.defaultFillStroke", + "Default Fill and Stroke", + [], + None, + "{ids?} — no fill and a black stroke; locked objects stay as they are", + has_editable_selection, + |s, p| edit_unlocked(s, p, default_fill_stroke) + ), cmd!( "object.stroke", "Stroke", @@ -1404,6 +1422,60 @@ fn set_flag(s: &mut Session, p: &Value, f: impl Fn(&mut Item), deselect: bool) - }) } +/// An object a colour command may change: neither it nor its layer is locked. +fn unlocked(d: &Document, it: &Item) -> bool { + !it.locked && d.layer(it.layer).is_none_or(|l| !l.locked) +} + +fn has_editable_selection(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + let st = s.active().ok_or_else(|| super::NOTHING_SELECTED.to_string())?; + if st.selection.items.iter().any(|id| st.doc.item(*id).is_some_and(|it| unlocked(&st.doc, it))) { + Ok(()) + } else { + Err("the selected objects are locked".into()) + } +} + +/// Runs `f` on each unlocked target in one edit (one undo step). +fn edit_unlocked(s: &mut Session, p: &Value, f: impl Fn(&mut Item)) -> Result { + let ids = targets(s, p)?; + s.edit(|d, _| { + let mut changed = 0; + for id in &ids { + if d.item(*id).is_some_and(|it| unlocked(d, it)) + && let Some(it) = d.item_mut(*id) + { + f(it); + changed += 1; + } + } + Ok(json!({"changed": changed})) + }) +} + +/// A stroke that gets a colour needs a weight to show it. +fn visible_weight(st: &mut Stroke) { + if st.swatch != designcraft_color::swatch::NONE && st.weight <= 0.0 { + st.weight = Stroke::default().weight; + } +} + +fn swap_fill_stroke(it: &mut Item) { + let fill = std::mem::take(&mut it.fill); + it.fill = Fill { swatch: std::mem::take(&mut it.stroke.swatch), tint: it.stroke.tint, ..fill.clone() }; + it.stroke.swatch = fill.swatch; + it.stroke.tint = fill.tint; + visible_weight(&mut it.stroke); +} + +fn default_fill_stroke(it: &mut Item) { + it.fill = Fill { swatch: designcraft_color::swatch::NONE.into(), tint: 1.0, ..it.fill.clone() }; + it.stroke.swatch = designcraft_color::swatch::BLACK.into(); + it.stroke.tint = 1.0; + visible_weight(&mut it.stroke); +} + fn all_flag(s: &mut Session, f: impl Fn(&mut Item)) -> Result { s.edit(|d, _| { for i in 0..d.spreads.len() { @@ -2125,6 +2197,44 @@ fn step_and_repeat(s: &mut Session, p: &Value) -> Result { mod step_repeat_tests { use super::*; + #[test] + fn swap_and_default_fill_stroke_are_one_undo_step_per_object() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = |v: Value| ItemId(v["id"].as_u64().unwrap()); + let a = id(s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap()); + let b = id(s.execute("frame.create", &json!({"rect": [20, 0, 30, 10]})).unwrap()); + let locked = id(s.execute("frame.create", &json!({"rect": [40, 0, 50, 10]})).unwrap()); + s.execute("object.fill", &json!({"ids": [a.0], "swatch": "[Paper]", "tint": 0.5})).unwrap(); + s.execute("object.stroke", &json!({"ids": [a.0], "swatch": "[Black]", "weight": 2})).unwrap(); + s.execute("object.fill", &json!({"ids": [b.0, locked.0], "swatch": "[Black]"})).unwrap(); + s.execute("object.stroke", &json!({"ids": [b.0, locked.0], "swatch": "[None]"})).unwrap(); + s.execute("object.lock", &json!({"ids": [locked.0]})).unwrap(); + s.execute("selection.set", &json!({"ids": [a.0, b.0, locked.0]})).unwrap(); + let colors = |s: &Session, i: ItemId| { + let it = s.doc().unwrap().doc.item(i).unwrap().clone(); + (it.fill.swatch, it.fill.tint, it.stroke.swatch, it.stroke.tint) + }; + let before = [colors(&s, a), colors(&s, b), colors(&s, locked)]; + + assert_eq!(s.execute("object.swapFillStroke", &json!({})).unwrap()["changed"], 2); + assert_eq!(colors(&s, a), ("[Black]".into(), 1.0, "[Paper]".into(), 0.5)); + assert_eq!(colors(&s, b), ("[None]".into(), 1.0, "[Black]".into(), 1.0)); + assert_eq!(colors(&s, locked), before[2], "a locked object keeps its colours"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!([colors(&s, a), colors(&s, b), colors(&s, locked)], before, "one undo restores both"); + + s.execute("object.defaultFillStroke", &json!({})).unwrap(); + for i in [a, b] { + assert_eq!(colors(&s, i), ("[None]".into(), 1.0, "[Black]".into(), 1.0)); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!([colors(&s, a), colors(&s, b), colors(&s, locked)], before); + + s.execute("selection.set", &json!({"ids": [locked.0]})).unwrap(); + assert!(s.execute("object.swapFillStroke", &json!({})).is_err(), "nothing unlocked to change"); + } + #[test] fn step_repeat_validates_counts_and_offsets() { assert_eq!(step_repeat_offsets(&json!({"count": MAX_STEP_REPEAT_COPIES})).unwrap().len(), MAX_STEP_REPEAT_COPIES as usize); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 0d3f8c172..b614467e4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -85,8 +85,18 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.zoom", "Zoom To", None, "{zoom: 1.0 = 100%}"), ("view.screenMode", "Screen Mode", None, "{mode: normal|preview|bleed|slug|presentation}"), ("view.togglePreview", "Toggle Normal/Preview", Some("W"), "{}"), - ("color.defaultFillStroke", "Default Fill and Stroke", Some("D"), "{} — the selection gets no fill and a black stroke"), - ("color.swapFillStroke", "Swap Fill and Stroke", Some("Shift+X"), "{} — the selection's fill and stroke colours (and tints) trade places"), + ( + "color.defaultFillStroke", + "Default Fill and Stroke", + Some("D"), + "{} — the selection gets no fill and a black stroke (object.defaultFillStroke)", + ), + ( + "color.swapFillStroke", + "Swap Fill and Stroke", + Some("Shift+X"), + "{} — each selected object's fill and stroke colours (and tints) trade places (object.swapFillStroke)", + ), ("color.toggleFillStroke", "Toggle Fill and Stroke", Some("X"), "{} → {strokeFront} — which of fill and stroke the proxy has in front"), ("color.applyNone", "Apply None", Some("/"), "{} — [None] for the fill or stroke in front of the proxy"), ("view.frameEdges", "Show/Hide Frame Edges", Some("Cmd+H"), "{}"), diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index ba825505e..2a6744d25 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -410,17 +410,12 @@ fn tool_button(ui: &mut egui::Ui, icon: &str, active: bool, tip: &str) -> egui:: /// Default Fill and Stroke (D): no fill, a black stroke. pub(crate) fn default_fill_stroke(app: &mut DesignApp) -> Result { - app.run("object.fill", json!({"swatch": "[None]", "tint": 1.0}))?; - app.run("object.stroke", json!({"swatch": "[Black]", "tint": 1.0})) + app.run("object.defaultFillStroke", json!({})) } -/// Swap Fill and Stroke (Shift+X): the selection's fill and stroke colours trade places, tints -/// included. +/// Swap Fill and Stroke (Shift+X): each selected object's fill and stroke trade places. pub(crate) fn swap_fill_stroke(app: &mut DesignApp) -> Result { - let Some(info) = crate::panels::sel_info(app) else { return Err("nothing selected".into()) }; - let (fill_tint, stroke_tint) = (crate::panels::sel_tint(app, false), crate::panels::sel_tint(app, true)); - app.run("object.fill", json!({"swatch": info.stroke, "tint": stroke_tint}))?; - app.run("object.stroke", json!({"swatch": info.fill, "tint": fill_tint})) + app.run("object.swapFillStroke", json!({})) } /// Fill/Stroke toggle (X): brings the other of fill and stroke to the front of the proxy. From d7d6f27069fe62a347fb67baab0dfe92c3933d7c Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 10:48:39 +0200 Subject: [PATCH 03/48] feat(eyedropper): Sample a placed image's colour into the selection The Eyedropper on a placed image now does what InDesign does: with text selected, clicking the photo colours the text with the image's colour under the pointer; with objects selected, it fills them. The colour is an unnamed colour (reused when the same colour is sampled again). Images on locked layers can be sampled, and unfilled frames over the image let it show through. - color.sample {at, spread?, target?: fill|stroke, ids?} samples the frontmost visible raster image at a spread point; one undo step; a caret or a point with no image is refused without an edit. - object.color shares its swatch lookup with it. - The Eyedropper tool sends color.sample over an image, matchAttributes elsewhere as before. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/style.rs | 261 +++++++++++++++++++++++++++---- crates/tools/src/tests.rs | 41 +++++ crates/tools/src/xform.rs | 41 ++++- crates/ui-egui/src/i18n.rs | 4 + crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + 10 files changed, 320 insertions(+), 33 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..02437c82d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Newest first. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. +- **2026-10-12** · The Eyedropper samples a placed image: clicking the photo with text (or objects) selected fills them with the image's colour under the pointer, as an unnamed colour (`color.sample`), as in InDesign. Images on locked layers can be sampled. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 4bf69ec41..65052d56a 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -184,6 +184,15 @@ pub fn specs() -> Vec { super::has_selection, apply_color ), + cmd!( + "color.sample", + "Sample Color (Eyedropper)", + [], + None, + "{at: [x,y] (spread coords), spread?, target?: fill|stroke, ids?} → {color: \"#rrggbb\", swatch, from: image id} — the Eyedropper on a placed image: the image's colour at the point (an unnamed colour) fills the selected text, else the selected objects", + has_text_or_selection, + sample_color + ), cmd!( "swatch.addToSwatches", "Add to Swatches", @@ -957,41 +966,149 @@ fn apply_color(s: &mut Session, p: &Value) -> Result { let stroke = str_param(p, "target") == Some("stroke"); let ids = super::targets(s, p)?; s.edit(|d, _| { - // Reuse a swatch holding exactly this colour, else add an unnamed one. - let existing = d - .swatches - .iter() - .find(|w| matches!(&w.value, SwatchValue::Color { color: c, color_type: designcraft_color::ColorType::Process } if *c == color)) - .map(|w| w.name.clone()); - let name = match existing { - Some(n) => n, - None => { - let name = Styles::unique_name(|n| d.swatch(n).is_some(), &value_name(color)); - d.swatches.push(Swatch { - name: name.clone(), - value: SwatchValue::Color { color, color_type: designcraft_color::ColorType::Process }, - locked: false, - named: false, - hidden: true, - }); - name - } - }; - for id in &ids { - if let Some(it) = d.item_mut(*id) { - if stroke { - it.stroke.swatch = name.clone(); - it.stroke.tint = 1.0; - } else { - it.fill.swatch = name.clone(); - it.fill.tint = 1.0; - } - } - } + let name = color_swatch(d, color); + color_items(d, &ids, &name, stroke); Ok(json!({"swatch": name})) }) } +/// A process swatch holding exactly `color`, else a new unnamed one (hidden from the Swatches +/// panel until Add to Swatches). +fn color_swatch(d: &mut designcraft_doc::Document, color: designcraft_color::Color) -> String { + let existing = d + .swatches + .iter() + .find(|w| matches!(&w.value, SwatchValue::Color { color: c, color_type: designcraft_color::ColorType::Process } if *c == color)) + .map(|w| w.name.clone()); + if let Some(n) = existing { + return n; + } + let name = Styles::unique_name(|n| d.swatch(n).is_some(), &value_name(color)); + d.swatches.push(Swatch { + name: name.clone(), + value: SwatchValue::Color { color, color_type: designcraft_color::ColorType::Process }, + locked: false, + named: false, + hidden: true, + }); + name +} + +fn color_items(d: &mut designcraft_doc::Document, ids: &[designcraft_doc::ItemId], name: &str, stroke: bool) { + for id in ids { + if let Some(it) = d.item_mut(*id) { + if stroke { + it.stroke.swatch = name.to_string(); + it.stroke.tint = 1.0; + } else { + it.fill.swatch = name.to_string(); + it.fill.tint = 1.0; + } + } + } +} + +fn has_text_or_selection(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + if s.active().is_some_and(|d| d.selection.text.is_some() || d.selection.cells.is_some()) { Ok(()) } else { super::has_selection(s) } +} + +/// The Eyedropper on a placed image (InDesign samples the pixel under the pointer): that colour, +/// as an unnamed colour, fills the selected text (characters), or the selected objects. +fn sample_color(s: &mut Session, p: &Value) -> Result { + const ID: &str = "color.sample"; + let at = super::point_param(p, "at").ok_or_else(|| bad(ID, "missing at"))?; + let sr = super::spread_param(p, "spread"); + let stroke = str_param(p, "target") == Some("stroke"); + let named = p.get("ids").and_then(Value::as_array).is_some_and(|a| !a.is_empty()) || p.get("id").is_some(); + let st = s.doc()?; + let (from, color) = image_color_at(&st.doc, sr, at).ok_or_else(|| bad(ID, "no placed image under the point"))?; + let hex = color.to_hex(); + if named || (st.selection.text.is_none() && st.selection.cells.is_none()) { + let ids = super::targets(s, p)?; + return s.edit(|d, _| { + let name = color_swatch(d, color); + color_items(d, &ids, &name, stroke); + Ok(json!({"color": hex, "swatch": name, "from": from.0})) + }); + } + // A caret colours nothing (as with a swatch click): say so instead of an empty undo step. + let targets: Vec<_> = super::text::format_targets(s).into_iter().filter(|t| !t.range.is_empty()).collect(); + if targets.is_empty() { + return Err(bad(ID, "select the text to colour")); + } + s.edit(|d, _| { + let name = color_swatch(d, color); + for t in &targets { + let Some(story) = d.text_story_mut(t.story, t.cell) else { continue }; + story.format_chars(t.range.clone(), |f| { + if stroke { + f.over.stroke = Some(name.clone()); + f.over.stroke_tint = Some(1.0); + } else { + f.over.fill = Some(name.clone()); + f.over.fill_tint = Some(1.0); + } + }); + } + Ok(json!({"color": hex, "swatch": name, "from": from.0})) + }) +} + +/// The colour of the placed raster image at `at` on spread `sr`: the frontmost visible image whose +/// frame shows that point (locked ones too, as sampling changes nothing). (image frame, colour); +/// None over no image, a transparent pixel, or a graphic that is not a raster (PDF, EPS). +pub(crate) fn image_color_at( + d: &designcraft_doc::Document, + sr: designcraft_doc::SpreadRef, + at: designcraft_geom::Point, +) -> Option<(designcraft_doc::ItemId, designcraft_color::Color)> { + let sp = d.spread(sr)?; + let mut items: Vec<&designcraft_doc::Item> = sp.items.iter().map(|i| &**i).collect(); + items.sort_by_key(|i| d.layer_rank(i.layer)); + items + .into_iter() + .rev() + .filter(|it| d.layer(it.layer).is_none_or(|l| l.visible)) + .find_map(|it| sample_item(d, it, designcraft_geom::Affine::IDENTITY, at, 0)) +} + +fn sample_item( + d: &designcraft_doc::Document, + it: &designcraft_doc::Item, + outer: designcraft_geom::Affine, + at: designcraft_geom::Point, + depth: usize, +) -> Option<(designcraft_doc::ItemId, designcraft_color::Color)> { + use designcraft_doc::Content; + if it.hidden || depth > 64 { + return None; + } + let xf = outer * it.xf; + match &it.content { + Content::Group { items } => items.iter().rev().find_map(|c| sample_item(d, c, xf, at, depth + 1)), + Content::Graphic(g) => { + let inner = xf.inverse() * at; + if !designcraft_geom::hit::fill_contains(&it.path.to_bezpath(), designcraft_geom::FillRule::NonZero, inner) { + return None; + } + let gp = g.xf.inverse() * inner; + let (w, h) = g.size; + // Written so that NaN (a degenerate transform) is outside too. + if !(w > 0.0 && h > 0.0 && gp.x >= 0.0 && gp.y >= 0.0 && gp.x < w && gp.y < h) { + return None; + } + let img = designcraft_images::decode_rgba(&d.assets.get(&g.asset)?.data)?; + let (pw, ph) = img.dimensions(); + let px = ((gp.x / w * f64::from(pw)) as u32).min(pw.checked_sub(1)?); + let py = ((gp.y / h * f64::from(ph)) as u32).min(ph.checked_sub(1)?); + let [r, gr, b, a] = img.get_pixel_checked(px, py)?.0; + (a > 0).then(|| (it.id, designcraft_color::Color::rgb8(r, gr, b))) + } + _ => None, + } +} + fn add_to_swatches(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let name = match str_param(p, "swatch") { @@ -1493,3 +1610,85 @@ mod cjk_tests { assert!(s.execute("style.compositeFont.set", &definition(-1.0)).is_err()); } } + +#[cfg(test)] +mod sample_color_tests { + use designcraft_color::{Color, SwatchValue}; + use designcraft_doc::ItemId; + use serde_json::json; + + use crate::Session; + + /// A 2×1 image, red then blue, placed 144 pt wide at (72, 72): each pixel is 72 pt. + fn session_with_image() -> (Session, u64) { + let png = designcraft_render::Rendered { width: 2, height: 1, pixels: vec![255, 0, 0, 255, 0, 0, 255, 255] }.to_png(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let b64 = super::super::file::base64_encode(&png); + let r = s.execute("file.place", &json!({"base64": b64, "name": "rb.png", "x": 72, "y": 72, "width": 144})).unwrap(); + (s, r["id"].as_u64().unwrap()) + } + + fn swatch_color(s: &Session, name: &str) -> Color { + match &s.doc().unwrap().doc.swatch(name).unwrap().value { + SwatchValue::Color { color, .. } => *color, + other => panic!("{other:?}"), + } + } + + #[test] + fn the_eyedropper_colours_selected_text_with_the_image_under_it() { + let (mut s, image) = session_with_image(); + let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text", "text": "Elevate."})).unwrap(); + let fid = ItemId(f["id"].as_u64().unwrap()); + let story = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 7})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("color.sample", &json!({"at": [180, 100]})).unwrap(); + assert_eq!((r["color"].as_str(), r["from"].as_u64()), (Some("#0000ff"), Some(image)), "{r}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + let name = r["swatch"].as_str().unwrap().to_string(); + assert_eq!(swatch_color(&s, &name), Color::rgb8(0, 0, 255)); + assert!(s.doc().unwrap().doc.swatch(&name).unwrap().hidden, "an unnamed colour"); + let st = s.doc().unwrap().doc.story(story).unwrap().clone(); + assert_eq!(st.format_after(0).over.fill.as_deref(), Some(name.as_str())); + assert_eq!(st.format_after(7).over.fill, None, "the full stop keeps its colour"); + // The frame itself is not coloured. + assert!(s.doc().unwrap().doc.item(fid).unwrap().fill.is_none()); + } + + #[test] + fn the_eyedropper_fills_selected_objects_and_samples_locked_images() { + let (mut s, _) = session_with_image(); + let lid = s.doc().unwrap().doc.layers[0].id.0; + s.execute("layer.set", &json!({"id": lid, "locked": true})).unwrap(); + s.execute("layer.new", &json!({"name": "TEXT"})).unwrap(); + let rect = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); + let r = s.execute("color.sample", &json!({"at": [100, 100], "target": "stroke"})).unwrap(); + assert_eq!(r["color"], "#ff0000", "{r}"); + let it = s.doc().unwrap().doc.item(ItemId(rect)).unwrap().clone(); + assert_eq!(swatch_color(&s, &it.stroke.swatch), Color::rgb8(255, 0, 0)); + // Sampling the same colour again reuses its swatch. + let again = s.execute("color.sample", &json!({"at": [90, 90]})).unwrap(); + assert_eq!(again["swatch"], r["swatch"]); + } + + #[test] + fn nothing_to_sample_or_nothing_to_colour_is_refused_without_an_edit() { + let (mut s, _) = session_with_image(); + let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text", "text": "Hi"})).unwrap(); + let fid = ItemId(f["id"].as_u64().unwrap()); + let story = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": story.0, "anchor": 1, "focus": 1})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let caret = s.execute("color.sample", &json!({"at": [100, 100]})).unwrap_err().to_string(); + assert!(caret.contains("select the text"), "{caret}"); + s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 2})).unwrap(); + let none = s.execute("color.sample", &json!({"at": [600, 600]})).unwrap_err().to_string(); + assert!(none.contains("no placed image"), "{none}"); + // Just past the image's right edge. + assert!(s.execute("color.sample", &json!({"at": [216.5, 100]})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + } +} diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 6c75b0da6..00806302c 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -705,3 +705,44 @@ fn pen_click_snaps_to_a_guide() { let x = p["anchors"][0]["p"][0].as_f64().unwrap(); assert!((x - 100.0).abs() < 1e-6, "anchor x {x}"); } + +#[test] +fn eyedropper_samples_a_placed_image_and_matches_other_objects() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let photo = ItemId(d.alloc()); + let mut it = Item::new(photo, lid, Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0))); + it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + asset: designcraft_doc::AssetId(1), + size: (100.0, 100.0), + xf: designcraft_geom::Affine::translate((0.0, 0.0)), + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + }); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + // An unfilled text frame over part of the photo lets it show through. + d.add_text_frame(SpreadRef::Doc(0), Rect::new(150.0, 150.0, 250.0, 250.0), lid, "x", ParaFormat::default()).unwrap(); + let swatch = add_rect(&mut d, Rect::new(300.0, 100.0, 350.0, 150.0)); + d.item_mut(swatch).unwrap().fill.swatch = "[Black]".into(); + let target = add_rect(&mut d, Rect::new(400.0, 100.0, 450.0, 150.0)); + // The photo's layer is locked: sampling it still works. + d.layers.iter_mut().for_each(|l| l.locked = true); + let s = Selection { items: vec![target], ..Default::default() }; + let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); + let off = l.xf(SpreadRef::Doc(0)).translation(); + let cx = ctx(&d, &s, &c, &l); + let mut t = create("eyedropper"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y)); + let sample = |x: f64, y: f64| Action::Exec("color.sample".into(), serde_json::json!({"at": [x, y], "spread": {"kind": "doc", "index": 0}})); + assert_eq!(a, vec![sample(120.0, 130.0)]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 180.0 + off.x, 180.0 + off.y)); + assert_eq!(a, vec![sample(180.0, 180.0)], "through the text frame"); + // A filled object is matched as before (here on a locked layer: not reachable, so nothing). + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 320.0 + off.x, 120.0 + off.y)).is_empty()); + let mut d2 = d.clone(); + d2.layers.iter_mut().for_each(|l| l.locked = false); + let cx = ctx(&d2, &s, &c, &l); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 320.0 + off.x, 120.0 + off.y)); + assert_eq!(a, vec![Action::Exec("object.matchAttributes".into(), serde_json::json!({"from": swatch.0}))]); +} diff --git a/crates/tools/src/xform.rs b/crates/tools/src/xform.rs index fbb1df9b8..4ca55ffa2 100644 --- a/crates/tools/src/xform.rs +++ b/crates/tools/src/xform.rs @@ -1,6 +1,7 @@ //! Rotate (R), Scale (S) and Shear (O) tools: drag around the selection centre (Alt-click sets //! the reference point elsewhere in a later iteration); Eyedropper (I): click an object to apply -//! its attributes to the selection. +//! its attributes to the selection, or a placed image to colour the selection (text or objects) +//! with the image's colour under the pointer. use designcraft_doc::SpreadRef; use designcraft_geom::{Point, Rect}; @@ -247,6 +248,37 @@ fn positive_pad(v: f64) -> f64 { if v.is_finite() && v > 0.0 { v } else { 0.0 } } +/// The spread and spread point under `p` when a placed graphic shows there: the frontmost visible +/// filled or graphic item at the point (locked included) is a graphic frame. +fn image_under(cx: &ToolContext, p: Point) -> Option<(SpreadRef, Point)> { + use designcraft_doc::{Content, Item}; + fn shows(it: &Item, outer: designcraft_geom::Affine, at: Point, depth: usize) -> Option { + if it.hidden || depth > 64 { + return None; + } + let xf = outer * it.xf; + if let Content::Group { items } = &it.content { + return items.iter().rev().find_map(|c| shows(c, xf, at, depth + 1)); + } + let graphic = matches!(it.content, Content::Graphic(_)); + // Unfilled frames (a text frame over the photo) let the image show through. + if !graphic && it.fill.is_none() { + return None; + } + designcraft_doc::edit_hit(it, outer.inverse() * at, 0.0).then_some(graphic) + } + let (sr, at) = cx.layout.spread_at(p)?; + let sp = cx.doc.spread(sr)?; + let mut items: Vec<&Item> = sp.items.iter().map(|i| &**i).collect(); + items.sort_by_key(|i| cx.doc.layer_rank(i.layer)); + let image = items + .into_iter() + .rev() + .filter(|it| cx.doc.layer(it.layer).is_none_or(|l| l.visible)) + .find_map(|it| shows(it, designcraft_geom::Affine::IDENTITY, at, 0))?; + image.then_some((sr, at)) +} + #[derive(Default)] pub struct EyedropperTool; @@ -258,8 +290,13 @@ impl Tool for EyedropperTool { if ev.kind != PointerKind::Down { return vec![]; } + let selected = !cx.selection.items.is_empty() || cx.selection.text.is_some() || cx.selection.cells.is_some(); + // A placed image under the pointer is sampled (its layer may be locked: sampling changes nothing). + if selected && let Some((sr, at)) = image_under(cx, ev.pos) { + return vec![Action::Exec("color.sample".into(), json!({"at": [at.x, at.y], "spread": sr}))]; + } let Some((_, id)) = cx.hit(ev.pos) else { return vec![] }; - if cx.selection.items.is_empty() && cx.selection.text.is_none() { + if !selected { return vec![Action::Exec("selection.set".into(), json!({"ids": [id.0]}))]; } vec![Action::Exec("object.matchAttributes".into(), json!({"from": id.0}))] diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5976d7d07..29e5eca8e 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -739,6 +739,10 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ("Apply Cell Style", ["Apply Cell Style", "Apply Cell Style", "Apply Cell Style", "Apply Cell Style", "应用单元格样式"]), ("Apply Color", ["Apply Color", "Apply Color", "Apply Color", "Apply Color", "应用颜色"]), + ( + "Sample Color (Eyedropper)", + ["Farbe aufnehmen (Pipette)", "Prélever la couleur (Pipette)", "Tomar color (Cuentagotas)", "カラーを抽出 (スポイト)", "取样颜色 (吸管)"], + ), ("Apply Condition", ["Apply Condition", "Apply Condition", "Apply Condition", "Apply Condition", "应用条件"]), ( "Apply Parent to Pages…", diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index c3373486e..fb3b7731d 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1041,6 +1041,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Formatting affects container (J)", "تطبيق التنسيق على الحاوية (J)"), ("Formatting affects text (J)", "تطبيق التنسيق على النص (J)"), ("Apply Color", "تطبيق اللون"), + ("Sample Color (Eyedropper)", "أخذ عينة من اللون (القطارة)"), ("Apply None", "إزالة اللون"), ("Height Only", "الارتفاع فقط"), ("Width Only", "العرض فقط"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 34766cf22..95fbcb44d 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -412,6 +412,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Apply Attributes (Eyedropper)", "Applica attributi (Contagocce)"), ("Apply Cell Style", "Applica stile di cella"), ("Apply Color", "Applica colore"), + ("Sample Color (Eyedropper)", "Campiona colore (Contagocce)"), ("Apply Condition", "Applica condizione"), ("Apply Parent to Pages…", "Applica pagina mastro alle pagine…"), ("ArtCraft App Page…", "Pagina dell'app ArtCraft…"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index de57db5e9..935174345 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -414,6 +414,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Apply Attributes (Eyedropper)", "属性を適用 (スポイト)"), ("Apply Cell Style", "セルスタイルを適用"), ("Apply Color", "カラーを適用"), + ("Sample Color (Eyedropper)", "カラーを抽出 (スポイト)"), ("Apply Condition", "条件を適用"), ("Apply Parent to Pages…", "ページに親ページを適用…"), ("ArtCraft App Page…", "ArtCraft アプリページ…"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index a4d06bbe9..6c383cc8c 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1045,6 +1045,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Formatting affects container (J)", "Formatação afeta o contêiner (J)"), ("Formatting affects text (J)", "Formatação afeta o texto (J)"), ("Apply Color", "Aplicar cor"), + ("Sample Color (Eyedropper)", "Amostrar cor (Conta-gotas)"), ("Apply None", "Aplicar nenhum"), ("Height Only", "Somente altura"), ("Width Only", "Somente largura"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9b70204a4..6ea43f398 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -415,6 +415,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Apply Attributes (Eyedropper)", "Застосувати атрибути (піпетка)"), ("Apply Cell Style", "Застосувати стиль клітинки"), ("Apply Color", "Застосувати колір"), + ("Sample Color (Eyedropper)", "Узяти колір (піпетка)"), ("Apply Condition", "Застосувати умову"), ("Apply Parent to Pages…", "Застосувати шаблон до сторінок…"), ("ArtCraft App Page…", "Сторінка програми ArtCraft…"), From 9ab226d741b90b9bf2d1143bc871100a9d37070c Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 17:56:05 +0200 Subject: [PATCH 04/48] fix(eyedropper): Sample rasters only; colour text through object.color A placed PDF, EPS or SVG under the Eyedropper matches attributes again, as before, instead of failing with "no placed image under the point" (Asset::is_raster, shared by the tool and color.sample). color.sample hands its colour to object.color, which now colours selected text, the text of selected frames ({text: true}), or the typing colour of an empty story; a caret in existing text is refused before a swatch or undo step. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/lib.rs | 10 +++ crates/engine/src/cmd/style.rs | 108 ++++++++++++++++++++++----------- crates/tools/src/tests.rs | 23 +++++++ crates/tools/src/xform.rs | 18 ++++-- 4 files changed, 119 insertions(+), 40 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 2c90d8b95..a643cc08d 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -452,6 +452,16 @@ pub struct Asset { pub page: u32, } +impl Asset { + /// A raster image (PNG, JPEG, GIF, WebP, TIFF, BMP, PSD), as opposed to a PDF, EPS or SVG. + pub fn is_raster(&self) -> bool { + matches!( + self.mime.as_str(), + "image/png" | "image/jpeg" | "image/gif" | "image/webp" | "image/tiff" | "image/bmp" | "image/vnd.adobe.photoshop" + ) + } +} + fn is_zero(v: &u32) -> bool { *v == 0 } diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 65052d56a..b09e78503 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -180,8 +180,8 @@ pub fn specs() -> Vec { "Apply Color", [], None, - "{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255), target?: fill|stroke, ids?} — an unnamed colour (not in the Swatches panel until Add to Swatches) on the selection", - super::has_selection, + "{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255), target?: fill|stroke, text?: bool, ids?} — an unnamed colour on the selected text or objects; `text` also targets the text of selected frames", + has_text_or_selection, apply_color ), cmd!( @@ -964,6 +964,44 @@ fn apply_gradient(s: &mut Session, p: &Value) -> Result { fn apply_color(s: &mut Session, p: &Value) -> Result { let color = p.get("color").and_then(parse_color).ok_or_else(|| bad("object.color", "missing or bad color"))?; let stroke = str_param(p, "target") == Some("stroke"); + let named = p.get("ids").is_some() || p.get("id").is_some(); + let text = !named + && (p.get("text").and_then(Value::as_bool).unwrap_or(false) || s.doc()?.selection.text.is_some() || s.doc()?.selection.cells.is_some()); + if text { + let targets = super::text::format_targets(s); + // A caret in existing text has no editable range. Refuse it before creating a swatch + // or history; an empty story has a format run that the next insertion inherits. + if !targets + .iter() + .any(|t| !t.range.is_empty() || s.active().is_some_and(|d| d.doc.text_story(t.story, t.cell).is_some_and(|st| st.is_empty()))) + { + return Err(bad("object.color", "select the text to colour")); + } + return s.edit(|d, _| { + let name = color_swatch(d, color); + for t in &targets { + let Some(story) = d.text_story_mut(t.story, t.cell) else { continue }; + let set = |f: &mut designcraft_doc::CharFormat| { + if stroke { + (f.over.stroke, f.over.stroke_tint) = (Some(name.clone()), Some(1.0)); + } else { + (f.over.fill, f.over.fill_tint) = (Some(name.clone()), Some(1.0)); + } + }; + if t.range.is_empty() { + if story.is_empty() + && let Some(run) = story.chars.first_mut() + { + set(&mut run.format); + story.rev = story.rev.wrapping_add(1); + } + } else { + story.format_chars(t.range.clone(), set); + } + } + Ok(json!({"swatch": name})) + }); + } let ids = super::targets(s, p)?; s.edit(|d, _| { let name = color_swatch(d, color); @@ -1019,40 +1057,15 @@ fn sample_color(s: &mut Session, p: &Value) -> Result { const ID: &str = "color.sample"; let at = super::point_param(p, "at").ok_or_else(|| bad(ID, "missing at"))?; let sr = super::spread_param(p, "spread"); - let stroke = str_param(p, "target") == Some("stroke"); - let named = p.get("ids").and_then(Value::as_array).is_some_and(|a| !a.is_empty()) || p.get("id").is_some(); let st = s.doc()?; let (from, color) = image_color_at(&st.doc, sr, at).ok_or_else(|| bad(ID, "no placed image under the point"))?; let hex = color.to_hex(); - if named || (st.selection.text.is_none() && st.selection.cells.is_none()) { - let ids = super::targets(s, p)?; - return s.edit(|d, _| { - let name = color_swatch(d, color); - color_items(d, &ids, &name, stroke); - Ok(json!({"color": hex, "swatch": name, "from": from.0})) - }); - } - // A caret colours nothing (as with a swatch click): say so instead of an empty undo step. - let targets: Vec<_> = super::text::format_targets(s).into_iter().filter(|t| !t.range.is_empty()).collect(); - if targets.is_empty() { - return Err(bad(ID, "select the text to colour")); - } - s.edit(|d, _| { - let name = color_swatch(d, color); - for t in &targets { - let Some(story) = d.text_story_mut(t.story, t.cell) else { continue }; - story.format_chars(t.range.clone(), |f| { - if stroke { - f.over.stroke = Some(name.clone()); - f.over.stroke_tint = Some(1.0); - } else { - f.over.fill = Some(name.clone()); - f.over.fill_tint = Some(1.0); - } - }); - } - Ok(json!({"color": hex, "swatch": name, "from": from.0})) - }) + let mut params = p.clone(); + params["color"] = json!(hex); + let mut result = apply_color(s, ¶ms)?; + result["color"] = json!(hex); + result["from"] = json!(from.0); + Ok(result) } /// The colour of the placed raster image at `at` on spread `sr`: the frontmost visible image whose @@ -1098,7 +1111,8 @@ fn sample_item( if !(w > 0.0 && h > 0.0 && gp.x >= 0.0 && gp.y >= 0.0 && gp.x < w && gp.y < h) { return None; } - let img = designcraft_images::decode_rgba(&d.assets.get(&g.asset)?.data)?; + let asset = d.assets.get(&g.asset).filter(|a| a.is_raster())?; + let img = designcraft_images::decode_rgba(&asset.data)?; let (pw, ph) = img.dimensions(); let px = ((gp.x / w * f64::from(pw)) as u32).min(pw.checked_sub(1)?); let py = ((gp.y / h * f64::from(ph)) as u32).min(ph.checked_sub(1)?); @@ -1691,4 +1705,30 @@ mod sample_color_tests { assert!(s.execute("color.sample", &json!({"at": [216.5, 100]})).is_err()); assert_eq!(s.doc().unwrap().history.undo.len(), undo); } + + #[test] + fn sampling_and_explicit_colour_use_the_same_text_application_path() { + for command in ["color.sample", "object.color"] { + let (mut s, _) = session_with_image(); + let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text"})).unwrap(); + let story = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); + s.execute("text.select", &json!({"story": story.0, "anchor": 0})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute(command, &json!({"at": [100, 100], "color": "#ff0000"})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{command}"); + s.execute("text.insert", &json!({"text": "Hi"})).unwrap(); + let text = s.doc().unwrap().doc.story(story).unwrap(); + assert_eq!(text.format_after(0).over.fill.as_deref(), r["swatch"].as_str(), "empty-story typing: {command}"); + // Explicit ids override text targeting, including with a text selection active. + let fid = ItemId(f["id"].as_u64().unwrap()); + s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 2})).unwrap(); + let r = s.execute(command, &json!({"at": [180, 100], "color": "#0000ff", "id": fid.0})).unwrap(); + assert_eq!(s.doc().unwrap().doc.item(fid).unwrap().fill.swatch, r["swatch"].as_str().unwrap()); + let text = s.doc().unwrap().doc.story(story).unwrap(); + assert_ne!(text.format_after(0).over.fill.as_deref(), r["swatch"].as_str(), "text unchanged: {command}"); + let r = s.execute(command, &json!({"at": [180, 100], "color": "#0000ff", "target": "stroke"})).unwrap(); + let text = s.doc().unwrap().doc.story(story).unwrap(); + assert_eq!(text.format_after(0).over.stroke.as_deref(), r["swatch"].as_str(), "selected text stroke: {command}"); + } + } } diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 00806302c..0d3981904 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -720,6 +720,18 @@ fn eyedropper_samples_a_placed_image_and_matches_other_objects() { fit_align: 4, crop: [0.0; 4], }); + d.assets.insert( + designcraft_doc::AssetId(1), + std::sync::Arc::new(designcraft_doc::Asset { + id: designcraft_doc::AssetId(1), + name: "photo.png".into(), + mime: "image/png".into(), + link: None, + data: std::sync::Arc::new(vec![]), + pixels: Some((100, 100)), + page: 0, + }), + ); d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); // An unfilled text frame over part of the photo lets it show through. d.add_text_frame(SpreadRef::Doc(0), Rect::new(150.0, 150.0, 250.0, 250.0), lid, "x", ParaFormat::default()).unwrap(); @@ -745,4 +757,15 @@ fn eyedropper_samples_a_placed_image_and_matches_other_objects() { let cx = ctx(&d2, &s, &c, &l); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 320.0 + off.x, 120.0 + off.y)); assert_eq!(a, vec![Action::Exec("object.matchAttributes".into(), serde_json::json!({"from": swatch.0}))]); + // Vector graphics and missing assets must retain the ordinary attribute-matching path. + for mime in ["application/pdf", "application/postscript", "image/svg+xml", "application/octet-stream"] { + std::sync::Arc::make_mut(d2.assets.get_mut(&designcraft_doc::AssetId(1)).unwrap()).mime = mime.into(); + let cx = ctx(&d2, &s, &c, &l); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y)); + assert_eq!(a, vec![Action::Exec("object.matchAttributes".into(), serde_json::json!({"from": photo.0}))], "{mime}"); + } + d2.assets.clear(); + let cx = ctx(&d2, &s, &c, &l); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y)); + assert_eq!(a, vec![Action::Exec("object.matchAttributes".into(), serde_json::json!({"from": photo.0}))]); } diff --git a/crates/tools/src/xform.rs b/crates/tools/src/xform.rs index 4ca55ffa2..e0bcd24f3 100644 --- a/crates/tools/src/xform.rs +++ b/crates/tools/src/xform.rs @@ -248,24 +248,30 @@ fn positive_pad(v: f64) -> f64 { if v.is_finite() && v > 0.0 { v } else { 0.0 } } -/// The spread and spread point under `p` when a placed graphic shows there: the frontmost visible -/// filled or graphic item at the point (locked included) is a graphic frame. +/// The spread and spread point under `p` when a placed raster shows there. Other graphic formats +/// still block an image behind them, but use attribute matching instead of pixel sampling. fn image_under(cx: &ToolContext, p: Point) -> Option<(SpreadRef, Point)> { use designcraft_doc::{Content, Item}; - fn shows(it: &Item, outer: designcraft_geom::Affine, at: Point, depth: usize) -> Option { + fn shows(d: &designcraft_doc::Document, it: &Item, outer: designcraft_geom::Affine, at: Point, depth: usize) -> Option { if it.hidden || depth > 64 { return None; } let xf = outer * it.xf; if let Content::Group { items } = &it.content { - return items.iter().rev().find_map(|c| shows(c, xf, at, depth + 1)); + return items.iter().rev().find_map(|c| shows(d, c, xf, at, depth + 1)); } let graphic = matches!(it.content, Content::Graphic(_)); // Unfilled frames (a text frame over the photo) let the image show through. if !graphic && it.fill.is_none() { return None; } - designcraft_doc::edit_hit(it, outer.inverse() * at, 0.0).then_some(graphic) + if !designcraft_doc::edit_hit(it, outer.inverse() * at, 0.0) { + return None; + } + Some(match &it.content { + Content::Graphic(g) => d.assets.get(&g.asset).is_some_and(|a| a.is_raster()), + _ => false, + }) } let (sr, at) = cx.layout.spread_at(p)?; let sp = cx.doc.spread(sr)?; @@ -275,7 +281,7 @@ fn image_under(cx: &ToolContext, p: Point) -> Option<(SpreadRef, Point)> { .into_iter() .rev() .filter(|it| cx.doc.layer(it.layer).is_none_or(|l| l.visible)) - .find_map(|it| shows(it, designcraft_geom::Affine::IDENTITY, at, 0))?; + .find_map(|it| shows(cx.doc, it, designcraft_geom::Affine::IDENTITY, at, 0))?; image.then_some((sr, at)) } From 32597369315c1ff274b7135ef583a2827ef60c1f Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:02:07 -0600 Subject: [PATCH 05/48] MCP: reconnect closed sockets before sending the next request Probe reused connections before writing so an app restart does not lose the next call. Report connection failures as not reachable with nothing sent, while retaining uncertain outcomes and no replay after sending begins. Add same-port restart and unavailable-app loopback regressions, and retain the lost-reply guard. --- crates/mcp/src/backend.rs | 115 +++++++++++++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 2 deletions(-) diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index c4930cc99..debdbb124 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -55,10 +55,27 @@ impl Remote { Err(last) } - fn roundtrip(&mut self, line: &str) -> std::io::Result { + fn prepare_connection(&mut self) -> std::io::Result<()> { + if let Some((reader, writer)) = self.conn.take() { + writer.set_nonblocking(true)?; + let probe = writer.peek(&mut [0]); + writer.set_nonblocking(false)?; + let live = match probe { + Ok(0) => false, + Ok(_) => true, + Err(e) => e.kind() == std::io::ErrorKind::WouldBlock, + }; + if live { + self.conn = Some((reader, writer)); + } + } if self.conn.is_none() { self.reconnect()?; } + Ok(()) + } + + fn roundtrip(&mut self, line: &str) -> std::io::Result { let Some((reader, writer)) = self.conn.as_mut() else { return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected")); }; @@ -78,6 +95,10 @@ impl Backend for Remote { let id = self.next_id; self.next_id += 1; let line = json!({"id": id, "method": method, "params": params}).to_string(); + if let Err(e) = self.prepare_connection() { + self.conn = None; + return Err(format!("DesignCraft app at {} is not reachable: {e}; nothing was sent", self.addr)); + } let reply = match self.roundtrip(&line) { Ok(r) => r, Err(e) => { @@ -107,6 +128,96 @@ mod tests { use super::*; use std::net::TcpListener; + fn closed_remote() -> (Remote, String) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let mut remote = Remote::connect(&addr).unwrap(); + let (mut stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + stream.set_write_timeout(Some(Duration::from_secs(2))).unwrap(); + let server = std::thread::spawn(move || { + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + assert!(reader.read_line(&mut line).unwrap() > 0); + let request: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["method"], "document.inspect"); + writeln!(stream, "{}", json!({"id": request["id"], "ok": true, "result": null})).unwrap(); + stream.shutdown(std::net::Shutdown::Both).unwrap(); + drop(listener); + }); + assert_eq!(remote.call("document.inspect", json!({})), Ok(Value::Null)); + server.join().unwrap(); + let stream = &remote.conn.as_ref().unwrap().1; + stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + assert_eq!(stream.peek(&mut [0]).unwrap(), 0, "FIN must be observable before the next call"); + (remote, addr) + } + + #[test] + fn remote_executes_once_when_app_restarts_between_calls() { + let (mut remote, addr) = closed_remote(); + let listener = TcpListener::bind(&addr).unwrap(); + listener.set_nonblocking(true).unwrap(); + let server = std::thread::spawn(move || { + let deadline = std::time::Instant::now() + Duration::from_secs(2); + let mut stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + if std::time::Instant::now() >= deadline { + return 0; + } + std::thread::yield_now(); + } + Err(e) => panic!("accept failed: {e}"), + } + }; + stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + stream.set_write_timeout(Some(Duration::from_secs(2))).unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut applied = 0; + loop { + let mut line = String::new(); + assert!(reader.read_line(&mut line).unwrap() > 0); + let request: Value = serde_json::from_str(&line).unwrap(); + match request["method"].as_str().unwrap() { + "engine.execute" => { + assert_eq!(request["params"]["command"], "frame.create"); + applied += 1; + } + "document.inspect" => {} + method => panic!("unexpected method: {method}"), + } + writeln!(stream, "{}", json!({"id": request["id"], "ok": true, "result": {"applied": applied}})).unwrap(); + if request["method"] == "document.inspect" { + return applied; + } + } + }); + + let result = remote.call("engine.execute", json!({"command": "frame.create", "params": {"rect": [0, 0, 100, 100]}})); + let inspected = match &result { + Ok(_) => remote.call("document.inspect", json!({})), + Err(_) => Err("edit failed".to_string()), + }; + let applied = server.join().unwrap(); + + assert_eq!(result, Ok(json!({"applied": 1})), "the first edit after restart must succeed"); + assert_eq!(inspected, Ok(json!({"applied": 1}))); + assert_eq!(applied, 1); + } + + #[test] + fn remote_reports_nothing_sent_when_reconnect_has_no_listener() { + let (mut remote, _addr) = closed_remote(); + + let error = remote.call("engine.execute", json!({"command": "frame.create", "params": {}})).unwrap_err(); + + assert!(error.contains("not reachable"), "{error}"); + assert!(error.contains("nothing was sent"), "{error}"); + assert!(!error.contains("operation may have completed"), "{error}"); + } + #[test] fn remote_does_not_replay_when_reply_is_lost() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); @@ -149,7 +260,7 @@ mod tests { let applied = server.join().unwrap(); assert_eq!(applied, 1, "lost reply must not replay the applied edit; client returned {result:?}"); - assert!(result.is_err(), "lost reply must report an uncertain outcome: {result:?}"); + assert!(result.unwrap_err().contains("operation may have completed"), "lost reply must report an uncertain outcome"); assert_eq!(next, Ok(json!({"applied": 1})), "the next explicit request must reconnect successfully"); } } From 45a228426a9191322cb875e62ff2fa986f689060 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 11:03:34 +0200 Subject: [PATCH 06/48] feat(ui): Effects panel shows the level, Isolate Blending and Knockout Group The Effects panel had the blend mode, opacity and Drop Shadow only. Like InDesign's it now names the level the settings apply to with its blend mode and opacity (Group: Color Dodge 100%, Object: Normal 100%), and has Isolate Blending and Knockout Group, enabled for a selected group, through object.transparencyGroup. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/i18n.rs | 4 ++ crates/ui-egui/src/i18n/ar.rs | 4 ++ crates/ui-egui/src/i18n/it.rs | 4 ++ crates/ui-egui/src/i18n/ja.rs | 4 ++ crates/ui-egui/src/i18n/pt_br.rs | 4 ++ crates/ui-egui/src/i18n/uk.rs | 4 ++ crates/ui-egui/src/panels/properties.rs | 86 +++++++++++++++++++++++++ 8 files changed, 111 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..2dbfe0ef0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Newest first. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. +- **2026-10-12** · The Effects panel shows the level its settings apply to ("Group: Color Dodge 100%") and, as in InDesign, Isolate Blending and Knockout Group for a selected group. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5976d7d07..62c92553a 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -739,6 +739,10 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ("Apply Cell Style", ["Apply Cell Style", "Apply Cell Style", "Apply Cell Style", "Apply Cell Style", "应用单元格样式"]), ("Apply Color", ["Apply Color", "Apply Color", "Apply Color", "Apply Color", "应用颜色"]), + ("Isolate Blending", ["Füllmethode isolieren", "Isoler le mode de fusion", "Aislar fusión", "描画モードを分離", "分离混合"]), + ("Knockout Group", ["Aussparungsgruppe", "Groupe de masquage", "Grupo de exclusión", "グループの抜き", "挖空组"]), + ("Object:", ["Objekt:", "Objet :", "Objeto:", "オブジェクト:", "对象:"]), + ("Group:", ["Gruppe:", "Groupe :", "Grupo:", "グループ:", "编组:"]), ("Apply Condition", ["Apply Condition", "Apply Condition", "Apply Condition", "Apply Condition", "应用条件"]), ( "Apply Parent to Pages…", diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index c3373486e..9e37dfdb1 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1041,6 +1041,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Formatting affects container (J)", "تطبيق التنسيق على الحاوية (J)"), ("Formatting affects text (J)", "تطبيق التنسيق على النص (J)"), ("Apply Color", "تطبيق اللون"), + ("Isolate Blending", "عزل المزج"), + ("Knockout Group", "مجموعة مفرغة"), + ("Object:", "الكائن:"), + ("Group:", "المجموعة:"), ("Apply None", "إزالة اللون"), ("Height Only", "الارتفاع فقط"), ("Width Only", "العرض فقط"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 34766cf22..b14398d16 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -412,6 +412,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Apply Attributes (Eyedropper)", "Applica attributi (Contagocce)"), ("Apply Cell Style", "Applica stile di cella"), ("Apply Color", "Applica colore"), + ("Isolate Blending", "Isola fusione"), + ("Knockout Group", "Gruppo di foratura"), + ("Object:", "Oggetto:"), + ("Group:", "Gruppo:"), ("Apply Condition", "Applica condizione"), ("Apply Parent to Pages…", "Applica pagina mastro alle pagine…"), ("ArtCraft App Page…", "Pagina dell'app ArtCraft…"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index de57db5e9..a90da290f 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -414,6 +414,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Apply Attributes (Eyedropper)", "属性を適用 (スポイト)"), ("Apply Cell Style", "セルスタイルを適用"), ("Apply Color", "カラーを適用"), + ("Isolate Blending", "描画モードを分離"), + ("Knockout Group", "グループの抜き"), + ("Object:", "オブジェクト:"), + ("Group:", "グループ:"), ("Apply Condition", "条件を適用"), ("Apply Parent to Pages…", "ページに親ページを適用…"), ("ArtCraft App Page…", "ArtCraft アプリページ…"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index a4d06bbe9..ee51a3916 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1045,6 +1045,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Formatting affects container (J)", "Formatação afeta o contêiner (J)"), ("Formatting affects text (J)", "Formatação afeta o texto (J)"), ("Apply Color", "Aplicar cor"), + ("Isolate Blending", "Isolar mesclagem"), + ("Knockout Group", "Grupo de vazamento"), + ("Object:", "Objeto:"), + ("Group:", "Grupo:"), ("Apply None", "Aplicar nenhum"), ("Height Only", "Somente altura"), ("Width Only", "Somente largura"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9b70204a4..0314ee403 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -415,6 +415,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Apply Attributes (Eyedropper)", "Застосувати атрибути (піпетка)"), ("Apply Cell Style", "Застосувати стиль клітинки"), ("Apply Color", "Застосувати колір"), + ("Isolate Blending", "Ізолювати змішування"), + ("Knockout Group", "Група вибивання"), + ("Object:", "Об’єкт:"), + ("Group:", "Група:"), ("Apply Condition", "Застосувати умову"), ("Apply Parent to Pages…", "Застосувати шаблон до сторінок…"), ("ArtCraft App Page…", "Сторінка програми ArtCraft…"), diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 49cb920f2..695bb149a 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1988,6 +1988,13 @@ pub fn effects_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let _ = app.run("object.opacity", json!({"opacity": (v / 100.0).clamp(0.0, 1.0)})); } }); + // The level the settings apply to, with its blend mode and opacity ("Group: Color Dodge 100%"). + let group = matches!(it.content, designcraft_doc::Content::Group { .. }); + ui.horizontal(|ui| { + let level = crate::i18n::tr(&app.ui.language, if group { "Group:" } else { "Object:" }); + crate::rtl::label(ui, egui::RichText::new(level).font(crate::theme::semibold(12.0))); + crate::rtl::label(ui, format!("{} {}%", crate::i18n::tr(&app.ui.language, it.blend.label()), (it.opacity * 100.0).round())); + }); divider(ui); let ds = it.effects.drop_shadow.clone(); let mut on = ds.on; @@ -2030,6 +2037,20 @@ pub fn effects_panel(app: &mut DesignApp, ui: &mut egui::Ui) { ui.end_row(); }); } + divider(ui); + // Groups only, as in InDesign (greyed for other objects). + ui.horizontal(|ui| { + let mut iso = it.isolate; + let label = crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Isolate Blending")); + if ui.add_enabled(group, egui::Checkbox::new(&mut iso, label)).changed() { + let _ = app.run("object.transparencyGroup", json!({"isolate": iso})); + } + let mut ko = it.knockout; + let label = crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Knockout Group")); + if ui.add_enabled(group, egui::Checkbox::new(&mut ko, label)).changed() { + let _ = app.run("object.transparencyGroup", json!({"knockout": ko})); + } + }); } pub fn preflight_panel(app: &mut DesignApp, ui: &mut egui::Ui) { @@ -2208,3 +2229,68 @@ mod tests { assert_eq!(after, name, "clicking the swatch chip applies it"); } } + +#[cfg(test)] +mod effects_panel_tests { + use super::*; + + /// One frame of the Effects panel: the text painted and where. + fn frame(app: &mut DesignApp, ctx: &egui::Context, events: Vec) -> Vec<(String, egui::Rect)> { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(320.0, 400.0)); + let time = ctx.input(|i| i.time) + 1.0 / 60.0; + let mut out = + ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), events, ..Default::default() }, |ui| effects_panel(app, ui)); + out.textures_delta.clear(); + let mut v = Vec::new(); + for s in out.shapes { + if let egui::Shape::Text(t) = s.shape { + v.push((t.galley.job.text.clone(), t.visual_bounding_rect())); + } + } + v + } + + fn click(app: &mut DesignApp, ctx: &egui::Context, label: &str) { + let labels = frame(app, ctx, vec![]); + let pos = labels.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("{label} is painted")).1.center(); + for pressed in [true, false] { + frame( + app, + ctx, + vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }, + ], + ); + } + } + + #[test] + fn the_effects_panel_shows_the_level_and_isolate_blending_and_knockout_group_for_groups() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [72, 72, 144, 144]})).unwrap()["id"].as_u64().unwrap(); + let b = app.run("frame.create", json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let g = app.run("object.group", json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [g]})).unwrap(); + app.run("object.opacity", json!({"opacity": 1.0, "blend": "colorDodge"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); + crate::theme::apply(&ctx, &Tokens::for_brightness(crate::theme::Brightness::default())); + let texts: Vec = frame(&mut app, &ctx, vec![]).into_iter().map(|(t, _)| t).collect(); + for expected in ["Group:", "Color Dodge 100%", "Isolate Blending", "Knockout Group"] { + assert!(texts.iter().any(|t| t == expected), "{expected} in {texts:?}"); + } + let item = |app: &DesignApp, id: u64| app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).cloned().unwrap(); + click(&mut app, &ctx, "Isolate Blending"); + assert!(item(&app, g).isolate); + click(&mut app, &ctx, "Knockout Group"); + assert!(item(&app, g).knockout); + // Another object: the level is Object, and the group options are greyed. + app.run("selection.set", json!({"ids": [a]})).unwrap(); + let texts: Vec = frame(&mut app, &ctx, vec![]).into_iter().map(|(t, _)| t).collect(); + assert!(texts.iter().any(|t| t == "Object:"), "{texts:?}"); + click(&mut app, &ctx, "Knockout Group"); + assert!(!item(&app, a).knockout); + } +} From b71d469b79d2e6c89ea5cf38ea3a9163164171a2 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 18:17:34 +0200 Subject: [PATCH 07/48] fix(ui): A frame holding pasted-in content stays at the Object level Paste Into gives a frame nested content, but it isn't a group: the Effects panel showed "Group:" and enabled Isolate Blending and Knockout Group for it. It now asks Item::is_group, so such a frame shows "Object:" with the group options disabled. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties.rs | 28 ++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 695bb149a..6ef2de8a7 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1989,7 +1989,7 @@ pub fn effects_panel(app: &mut DesignApp, ui: &mut egui::Ui) { } }); // The level the settings apply to, with its blend mode and opacity ("Group: Color Dodge 100%"). - let group = matches!(it.content, designcraft_doc::Content::Group { .. }); + let group = it.is_group(); ui.horizontal(|ui| { let level = crate::i18n::tr(&app.ui.language, if group { "Group:" } else { "Object:" }); crate::rtl::label(ui, egui::RichText::new(level).font(crate::theme::semibold(12.0))); @@ -2293,4 +2293,30 @@ mod effects_panel_tests { click(&mut app, &ctx, "Knockout Group"); assert!(!item(&app, a).knockout); } + + #[test] + fn pasted_into_frames_keep_object_level_and_disable_group_blending() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + let child = app.run("frame.create", json!({"rect": [80, 80, 120, 120]})).unwrap()["id"].clone(); + app.run("selection.set", json!({"ids": [child]})).unwrap(); + app.run("edit.copy", json!({})).unwrap(); + let container = app.run("frame.create", json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [container]})).unwrap(); + app.run("edit.pasteInto", json!({})).unwrap(); + app.run("selection.set", json!({"ids": [container]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); + crate::theme::apply(&ctx, &Tokens::for_brightness(crate::theme::Brightness::default())); + let texts: Vec = frame(&mut app, &ctx, vec![]).into_iter().map(|(t, _)| t).collect(); + assert!(texts.iter().any(|t| t == "Object:"), "a frame remains an object after Paste Into: {texts:?}"); + let undo = app.session.doc().unwrap().history.undo.len(); + click(&mut app, &ctx, "Isolate Blending"); + click(&mut app, &ctx, "Knockout Group"); + let st = app.session.doc().unwrap(); + let it = st.doc.item(designcraft_doc::ItemId(container)).unwrap(); + assert!(it.has_nested_items()); + assert!(!it.isolate && !it.knockout, "group-only options stay disabled for a frame holding content"); + assert_eq!(st.history.undo.len(), undo, "disabled controls do not create undo steps"); + } } From 03a8d4b95525ab14a3ea69f47fd44af65f3cc90f Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:41:01 +0200 Subject: [PATCH 08/48] feat(fonts): Font format, classification and PANOSE for the font menus Each face reports what InDesign's font menu shows and filters by, read once from its tables: its format (TrueType, OpenType/CFF, variable, colour), its class (serif, slab serif, sans serif, script, monospaced, decorative, symbol) from PANOSE, else the OS/2 IBM family class, else the family name, else the shape of its "l" (serifs widen the foot), its PANOSE numbers (with a distance for similar fonts) and when its file was added. Co-Authored-By: Claude Opus 5.5 --- crates/fonts/src/fontdb.rs | 76 +++++++++++++++ crates/fonts/src/kind.rs | 191 +++++++++++++++++++++++++++++++++++++ crates/fonts/src/lib.rs | 2 + 3 files changed, 269 insertions(+) create mode 100644 crates/fonts/src/kind.rs diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 14441e144..079c0f1b1 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -121,6 +121,8 @@ pub struct FontFace { em: std::sync::OnceLock<(f64, f64)>, /// The font menu group and native family name, read on first use. group: std::sync::OnceLock<(FontGroup, Option)>, + /// Format, class and PANOSE numbers (the font menus), read on first use. + kind: std::sync::OnceLock, } /// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of @@ -188,6 +190,32 @@ impl FontFace { pub fn skrifa(&self) -> Option> { skrifa::FontRef::from_index(self.data(), self.index).ok() } + /// The face's format, class and PANOSE numbers, as the font menus show and filter them. + pub fn kind(&self) -> crate::kind::FaceKind { + *self.kind.get_or_init(|| { + let mut kind = self.skrifa().map(|f| crate::kind::read_kind(&f, &self.family)).unwrap_or_default(); + // Nothing in the tables or the name: look at the letters. + if kind.class == crate::kind::FontClass::Other + && let Some(serifs) = has_serifs(self) + { + kind.class = if serifs { crate::kind::FontClass::Serif } else { crate::kind::FontClass::SansSerif }; + } + kind + }) + } + /// When the font file was added (created, else last modified): the font menus' Show Recently + /// Added. `None` for fonts without a file. + pub fn added(&self) -> Option { + #[cfg(not(target_arch = "wasm32"))] + { + let meta = std::fs::metadata(self.source.path()?).ok()?; + meta.created().or_else(|_| meta.modified()).ok() + } + #[cfg(target_arch = "wasm32")] + { + None + } + } pub fn hb(&self) -> Option> { harfrust::FontRef::from_index(self.data(), self.index).ok() } @@ -709,6 +737,7 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s bmp: std::sync::OnceLock::new(), em: std::sync::OnceLock::new(), group: std::sync::OnceLock::new(), + kind: std::sync::OnceLock::new(), }) } @@ -1432,6 +1461,53 @@ fn over_total() -> String { format!("over the {} MB the folder's fonts may take in all", MAX_DOCUMENT_FONTS_TOTAL >> 20) } +/// Does the face's "l" (else "I") have serifs: much wider at its foot than mid-stem? `None` when +/// it has neither letter. +fn has_serifs(face: &FontFace) -> Option { + let gid = ['l', 'I'].into_iter().map(|c| face.glyph_for(c)).find(|g| *g != 0)?; + let path = font_outline(face, gid); + let bbox = kurbo::Shape::bounding_box(&path); + // An empty or degenerate outline (NaN included) says nothing. + if bbox.height().is_nan() || bbox.height() <= 1.0 { + return None; + } + let mut edges = Vec::new(); + let mut last = None; + let mut start = None; + kurbo::flatten(&path, 1.0, |el| match el { + kurbo::PathEl::MoveTo(p) => { + last = Some(p); + start = Some(p); + } + kurbo::PathEl::LineTo(p) => { + if let Some(a) = last { + edges.push((a, p)); + } + last = Some(p); + } + kurbo::PathEl::ClosePath => { + if let (Some(a), Some(b)) = (last, start) { + edges.push((a, b)); + } + last = start; + } + _ => {} + }); + // The outline is y-down: the foot is near bbox.y1 (the baseline). + let width_at = |y: f64| { + let xs: Vec = edges + .iter() + .filter(|(a, b)| (a.y <= y && y < b.y) || (b.y <= y && y < a.y)) + .map(|(a, b)| a.x + (y - a.y) * (b.x - a.x) / (b.y - a.y)) + .collect(); + let (lo, hi) = xs.iter().fold((f64::MAX, f64::MIN), |(lo, hi), x| (lo.min(*x), hi.max(*x))); + (hi > lo).then_some(hi - lo) + }; + let foot = width_at(bbox.y1 - bbox.height() * 0.02)?; + let stem = width_at(bbox.y0 + bbox.height() * 0.5)?; + Some(foot > stem * 1.6) +} + /// Glyph `gid`'s outline as the font draws it, in font units, y-down (flipped). fn font_outline(face: &FontFace, gid: u32) -> BezPath { let mut pen = FlipPen(BezPath::new()); diff --git a/crates/fonts/src/kind.rs b/crates/fonts/src/kind.rs new file mode 100644 index 000000000..6a224bc00 --- /dev/null +++ b/crates/fonts/src/kind.rs @@ -0,0 +1,191 @@ +//! What the font menus say about a face: its file format (the icon after the sample), its +//! classification (the Filters menu) and its PANOSE numbers (Show Similar Fonts). + +use skrifa::raw::TableProvider; +use skrifa::raw::types::Tag; + +/// A face's font format, as the font menus show it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum FontFormat { + /// TrueType outlines (`glyf`). + TrueType, + /// PostScript (CFF) outlines. + OpenType, + /// A variable font (`fvar`). + Variable, + /// Colour glyphs (COLR, SVG, bitmaps): emoji and colour fonts. + Color, +} + +/// The classes the font menus filter by. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum FontClass { + Serif, + SlabSerif, + SansSerif, + Script, + Monospaced, + Decorative, + Symbol, + /// Nothing in the font says. + Other, +} + +impl FontClass { + /// The classes, in the Filters menu's order. + pub const ALL: [FontClass; 8] = [ + FontClass::Serif, + FontClass::SlabSerif, + FontClass::SansSerif, + FontClass::Script, + FontClass::Monospaced, + FontClass::Decorative, + FontClass::Symbol, + FontClass::Other, + ]; + + /// The name the Filters menu shows (English; the UI translates it). + pub fn label(self) -> &'static str { + match self { + FontClass::Serif => "Serif", + FontClass::SlabSerif => "Slab Serif", + FontClass::SansSerif => "Sans Serif", + FontClass::Script => "Script", + FontClass::Monospaced => "Monospaced", + FontClass::Decorative => "Decorative", + FontClass::Symbol => "Symbol", + FontClass::Other => "Other", + } + } +} + +/// A face's format, class and PANOSE numbers. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FaceKind { + pub format: FontFormat, + pub class: FontClass, + /// The OS/2 table's PANOSE classification (all zero when the font has none). + pub panose: [u8; 10], +} + +impl Default for FaceKind { + fn default() -> Self { + FaceKind { format: FontFormat::TrueType, class: FontClass::Other, panose: [0; 10] } + } +} + +/// Read a face's kind from its tables; `family` helps classify fonts whose tables don't say. +pub fn read_kind(f: &skrifa::FontRef<'_>, family: &str) -> FaceKind { + let has = |tag: &[u8; 4]| f.table_data(Tag::new(tag)).is_some(); + let format = if [b"COLR", b"SVG ", b"CBDT", b"sbix"].iter().any(|t| has(t)) { + FontFormat::Color + } else if has(b"fvar") { + FontFormat::Variable + } else if has(b"CFF ") || has(b"CFF2") { + FontFormat::OpenType + } else { + FontFormat::TrueType + }; + let os2 = f.os2().ok(); + let panose: [u8; 10] = os2.as_ref().and_then(|t| t.panose_10().try_into().ok()).unwrap_or([0; 10]); + let ibm = os2.as_ref().map_or(0, |t| (t.s_family_class() >> 8) as u8); + let fixed = f.post().ok().is_some_and(|p| p.is_fixed_pitch() != 0); + FaceKind { format, class: classify(&panose, ibm, fixed, family), panose } +} + +/// The class from PANOSE (family kind, serif style, proportion), else the IBM family class in OS/2, +/// else the family name. +pub fn classify(panose: &[u8; 10], ibm_class: u8, fixed_pitch: bool, family: &str) -> FontClass { + let name = family.to_lowercase(); + let named = |words: &[&str]| words.iter().any(|w| name.contains(w)); + // PANOSE proportion 9 is monospaced (Latin text). + if fixed_pitch || (panose[0] == 2 && panose[3] == 9) || named(&["mono", "courier", " code"]) { + return FontClass::Monospaced; + } + match panose[0] { + 3 => return FontClass::Script, + 4 => return FontClass::Decorative, + 5 => return FontClass::Symbol, + 2 => match panose[1] { + // Square serifs are slabs; cove, bone, triangle… serifs are serifs. + 6 => return FontClass::SlabSerif, + 2..=10 => return FontClass::Serif, + 11..=15 => return FontClass::SansSerif, + _ => {} + }, + _ => {} + } + match ibm_class { + 1 | 2 | 3 | 7 => FontClass::Serif, + 4 | 5 => FontClass::SlabSerif, + 8 => FontClass::SansSerif, + 9 => FontClass::Decorative, + 10 => FontClass::Script, + 12 => FontClass::Symbol, + _ if named(&["slab"]) => FontClass::SlabSerif, + _ if named(&["sans", "grotesk", "grotesque", "gothic", "helvetica", "arial"]) => FontClass::SansSerif, + _ if named(&["script", "hand", "brush"]) => FontClass::Script, + _ if named(&["serif"]) => FontClass::Serif, + _ => FontClass::Other, + } +} + +/// How unlike two faces look by their PANOSE numbers (0 = alike), counting only the digits both +/// fonts set; `None` when they share none. +pub fn panose_distance(a: &[u8; 10], b: &[u8; 10]) -> Option { + let pairs: Vec<(u8, u8)> = a.iter().zip(b).skip(1).filter(|(x, y)| **x > 1 && **y > 1).map(|(x, y)| (*x, *y)).collect(); + if pairs.is_empty() { + return None; + } + Some(pairs.iter().map(|(x, y)| u32::from(x.abs_diff(*y))).sum::() * 9 / pairs.len() as u32) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classes_come_from_panose_then_the_ibm_class_then_the_name() { + let latin = |serif: u8, proportion: u8| [2, serif, 5, proportion, 5, 0, 0, 0, 0, 0]; + assert_eq!(classify(&latin(2, 3), 0, false, "Garamond"), FontClass::Serif); + assert_eq!(classify(&latin(6, 3), 0, false, "Rockwell"), FontClass::SlabSerif); + assert_eq!(classify(&latin(11, 3), 0, false, "Inter"), FontClass::SansSerif); + assert_eq!(classify(&latin(11, 9), 0, false, "Inter"), FontClass::Monospaced, "proportion 9"); + assert_eq!(classify(&[3, 0, 0, 0, 0, 0, 0, 0, 0, 0], 0, false, "Brush"), FontClass::Script); + assert_eq!(classify(&[0; 10], 8, false, "Untitled"), FontClass::SansSerif, "IBM class 8"); + assert_eq!(classify(&[0; 10], 0, true, "Untitled"), FontClass::Monospaced, "post.isFixedPitch"); + assert_eq!(classify(&[0; 10], 0, false, "JetBrains Mono"), FontClass::Monospaced); + assert_eq!(classify(&[0; 10], 0, false, "Roboto Slab"), FontClass::SlabSerif); + assert_eq!(classify(&[0; 10], 0, false, "Plain"), FontClass::Other); + } + + #[test] + fn bundled_fonts_are_classified() { + let db = crate::FontDb::global(); + for (family, class) in [ + ("Source Serif 4", FontClass::Serif), + ("Source Sans 3", FontClass::SansSerif), + ("Inter", FontClass::SansSerif), + ("JetBrains Mono", FontClass::Monospaced), + ] { + let kind = db.face(family, "Regular").kind(); + assert_eq!(kind.class, class, "{family}"); + assert_eq!(kind.format, FontFormat::TrueType, "{family}"); + } + let serif = db.face("Source Serif 4", "Regular").kind().panose; + let sans = db.face("Source Sans 3", "Regular").kind().panose; + let serif_bold = db.face("Source Serif 4", "Bold").kind().panose; + if let (Some(near), Some(far)) = (panose_distance(&serif, &serif_bold), panose_distance(&serif, &sans)) { + assert!(near < far, "the bold serif is nearer the serif than the sans is ({near} vs {far})"); + } + } + + #[test] + fn panose_distance_counts_shared_digits() { + let a = [2, 2, 5, 3, 5, 4, 5, 2, 2, 4]; + assert_eq!(panose_distance(&a, &a), Some(0)); + let b = [2, 11, 5, 3, 5, 4, 5, 2, 2, 4]; + assert!(panose_distance(&a, &b).is_some_and(|d| d > 0)); + assert_eq!(panose_distance(&a, &[0; 10]), None, "no PANOSE: nothing to compare"); + } +} diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index c1d744780..dd4583701 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -9,6 +9,7 @@ mod fontdb; mod group; +mod kind; #[cfg(any(test, feature = "testing"))] pub mod testing; mod vertical; @@ -20,6 +21,7 @@ pub use fontdb::{ pub use group::{FamilyInfo, FontGroup, sort_for_menu}; pub use harfrust::Feature; use harfrust::{Direction, Language, ShapeOptions, Tag, UnicodeBuffer}; +pub use kind::{FaceKind, FontClass, FontFormat, classify, panose_distance}; pub use kurbo::BezPath; use skrifa::MetadataProvider; use skrifa::instance::Size; From 6baa57b503009b10927c563f5d454a1438dfbe06 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 00:13:57 +0200 Subject: [PATCH 09/48] feat(fonts): A family's kind without keeping it loaded ScopedFonts::family_kind reads a family's format, class, PANOSE and file date from its Regular face. A loaded face is asked; an installed family that isn't loaded is read from its file, which is let go afterwards, instead of loading every family the font menu's filters look at for the rest of the session. Files over MAX_DOCUMENT_FONT_BYTES are skipped. Co-Authored-By: Claude Opus 5.5 --- crates/fonts/src/fontdb.rs | 37 ++++++++++++++++++++++++++++++ crates/fonts/src/tests_sysfonts.rs | 17 ++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 079c0f1b1..1ccc8d076 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -1306,6 +1306,43 @@ impl ScopedFonts<'_> { None } + /// `family`'s kind (format, class, PANOSE) and when its file was added, read from its Regular + /// face (else its first), for the font menus' filters, which look at every family. A face + /// already loaded is asked; an installed family that isn't loaded is read from its file, which + /// is then let go rather than kept loaded. None for an unknown family or an unreadable file. + pub fn family_kind(&self, family: &str) -> Option<(crate::kind::FaceKind, Option)> { + let styles = self.styles(family); + let style = styles.iter().find(|s| *s == "Regular").or(styles.first())?.clone(); + if self.own().iter().chain(self.db.read_faces().iter()).any(|f| f.family.eq_ignore_ascii_case(family)) { + let f = self.find(family, &style)?; + return Some((f.kind(), f.added())); + } + #[cfg(not(target_arch = "wasm32"))] + { + let path = { + let cat = self.db.read_catalog(); + let of_family = |c: &&CatalogEntry| c.family.eq_ignore_ascii_case(family); + let entry = cat.iter().filter(of_family).find(|c| norm(&c.style) == norm(&style)).or_else(|| cat.iter().find(of_family)); + entry.map(|c| c.path.clone())? + }; + if std::fs::metadata(&path).ok()?.len() > MAX_DOCUMENT_FONT_BYTES { + return None; + } + let data = Arc::new(std::fs::read(&path).ok()?); + let found = enumerate_faces(&data); + let pick = found + .iter() + .find(|(_, f, st, _)| f.eq_ignore_ascii_case(family) && norm(st) == norm(&style)) + .or_else(|| found.iter().find(|(_, f, _, _)| f.eq_ignore_ascii_case(family)))? + .clone(); + let (index, fam, st, coords) = pick; + let face = make_face(FontBytes::Owned(data.clone()), FontSource::Installed(path), index, fam, st, coords)?; + Some((face.kind(), face.added())) + } + #[cfg(target_arch = "wasm32")] + None + } + /// Is `family` available (the document's, loaded, or installed on the system), as named or /// without a format suffix (see [`ScopedFonts::face`])? pub fn has_family(&self, family: &str) -> bool { diff --git a/crates/fonts/src/tests_sysfonts.rs b/crates/fonts/src/tests_sysfonts.rs index a9cda7cdc..ca82ad4d7 100644 --- a/crates/fonts/src/tests_sysfonts.rs +++ b/crates/fonts/src/tests_sysfonts.rs @@ -126,6 +126,23 @@ fn a_link_back_to_a_parent_folder_ends_the_scan() { assert_eq!(db.load_system_fonts(), 2); } +#[test] +fn a_familys_kind_is_read_without_keeping_it_loaded() { + let dir = font_dir("kind"); + let db = FontDb::with_font_dirs(vec![dir]); + db.load_system_fonts(); + let fonts = db.scoped(0); + let (kind, added) = fonts.family_kind(FAMILY).expect("an installed family"); + assert_eq!(kind.class, crate::kind::FontClass::SansSerif); + assert!(added.is_some(), "the file's date"); + assert!(!db.is_loaded(FAMILY), "the file was read and let go"); + // A loaded family is asked directly, with the same answer. + let _ = fonts.face(FAMILY, "Regular"); + assert!(db.is_loaded(FAMILY)); + assert_eq!(fonts.family_kind(FAMILY).map(|(k, _)| k.class), Some(crate::kind::FontClass::SansSerif)); + assert!(fonts.family_kind("No Such Family").is_none()); +} + #[test] fn the_scan_reads_names_without_loading_the_font() { let dir = font_dir("names"); From ef874284815cbebecce851319b3a8b7a28fc93ad Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:41:01 +0200 Subject: [PATCH 10/48] feat(engine): Report the selected text; remember recently used fonts type.selectionAttrs reports the start of the selected text on one line (`selectedText`, up to 60 characters), for previews such as the font menu's Selected Text sample. The preferences keep `recentFonts`, the fonts last picked in the font menus. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/text.rs | 36 +++++++++++++++++++++++++++++++++-- crates/engine/src/lib.rs | 3 +++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 71d390d02..1e3272fe3 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -253,7 +253,7 @@ pub fn specs() -> Vec { has_text_or_frames, break_link ), - cmd!(query "type.selectionAttrs", "Selection Attributes", [], None, "{} → resolved character/paragraph attributes at the text selection", has_doc, selection_attrs), + cmd!(query "type.selectionAttrs", "Selection Attributes", [], None, "{} → resolved character/paragraph attributes at the text selection, and its start as `selectedText` (one line, up to 60 characters)", has_doc, selection_attrs), ] } @@ -970,6 +970,9 @@ fn open_type(s: &mut Session, p: &Value) -> Result { Ok(json!({"features": list, "figures": otf::figures(&list)})) } +/// How much of the selected text `type.selectionAttrs` reports. +const SELECTED_TEXT_CHARS: usize = 60; + fn selection_attrs(s: &mut Session, _p: &Value) -> Result { let st = s.doc()?; let targets = format_targets(s); @@ -980,8 +983,17 @@ fn selection_attrs(s: &mut Session, _p: &Value) -> Result { let (pp, base) = st.doc.styles.resolve_para(pf); let cf = if r.is_empty() { story.char_format_at(r.start) } else { story.format_after(r.start) }; let cp = st.doc.styles.resolve_char(&base, cf); + // The selected text (its start, on one line), for previews such as the font menu's. + let line: String = story + .text + .get(r.start..r.end) + .unwrap_or_default() + .chars() + .map(|c| if c.is_control() || c == designcraft_doc::OBJECT_MARK { ' ' } else { c }) + .collect(); + let selected: String = line.trim_start().chars().take(SELECTED_TEXT_CHARS).collect(); Ok(json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp, - "paraOverrides": pf.para.count(), "charOverrides": cf.over.count()})) + "paraOverrides": pf.para.count(), "charOverrides": cf.over.count(), "selectedText": selected.trim()})) } /// Our own filler text (not Adobe's). @@ -1154,6 +1166,26 @@ mod open_type_tests { s.execute("text.move", &json!({"dir": "right"})).unwrap(); } + #[test] + fn selection_attrs_report_the_selected_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s + .execute( + "frame.create", + &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Spring issue +What's new in typography this season, and why it matters to every designer"}), + ) + .unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 13})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"], "Spring issue", "on one line, trimmed"); + s.execute("text.select", &json!({"story": r["story"], "anchor": 13, "focus": 200})).unwrap(); + let long = s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"].as_str().unwrap().to_string(); + assert!(long.starts_with("What's new") && (55..=60).contains(&long.chars().count()), "the line break skipped, capped: {long}"); + s.execute("text.select", &json!({"story": r["story"], "anchor": 3, "focus": 3})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"], "", "a caret selects nothing"); + } + #[test] fn open_type_toggles_reach_the_text() { let mut s = Session::new(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 164719305..061165cb4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -179,6 +179,8 @@ pub struct Prefs { pub rich_black_output: bool, /// Font menu favourites (family names). pub favorite_fonts: Vec, + /// Fonts picked in the font menus, most recent first (shown at the menus' top). + pub recent_fonts: Vec, /// Preferences › Type › Show Font Names in English: the font menus show CJK families by their /// English names instead of their native ones. Documents store the English name either way. pub show_font_names_in_english: bool, @@ -235,6 +237,7 @@ impl Default for Prefs { highlight_substituted_fonts: true, rich_black_output: false, favorite_fonts: Vec::new(), + recent_fonts: Vec::new(), show_font_names_in_english: false, smart_text_reflow: true, autocorrect: false, From d6635f180a9e8e879514d4b7d9ed21b0256619f2 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 18:22:03 +0200 Subject: [PATCH 11/48] fix(engine): The selected-text preview reads only the 60 characters it reports type.selectionAttrs copied the whole selection to a string before taking the first 60 characters, so selecting a long story copied all of it on every query. It now skips leading blanks and stops after 60 characters. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/text.rs | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 1e3272fe3..334dfad03 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -973,6 +973,14 @@ fn open_type(s: &mut Session, p: &Value) -> Result { /// How much of the selected text `type.selectionAttrs` reports. const SELECTED_TEXT_CHARS: usize = 60; +fn selected_text_preview(chars: impl Iterator) -> String { + chars + .map(|c| if c.is_control() || c == designcraft_doc::OBJECT_MARK { ' ' } else { c }) + .skip_while(|c| c.is_whitespace()) + .take(SELECTED_TEXT_CHARS) + .collect() +} + fn selection_attrs(s: &mut Session, _p: &Value) -> Result { let st = s.doc()?; let targets = format_targets(s); @@ -984,14 +992,7 @@ fn selection_attrs(s: &mut Session, _p: &Value) -> Result { let cf = if r.is_empty() { story.char_format_at(r.start) } else { story.format_after(r.start) }; let cp = st.doc.styles.resolve_char(&base, cf); // The selected text (its start, on one line), for previews such as the font menu's. - let line: String = story - .text - .get(r.start..r.end) - .unwrap_or_default() - .chars() - .map(|c| if c.is_control() || c == designcraft_doc::OBJECT_MARK { ' ' } else { c }) - .collect(); - let selected: String = line.trim_start().chars().take(SELECTED_TEXT_CHARS).collect(); + 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()})) } @@ -1166,6 +1167,17 @@ mod open_type_tests { s.execute("text.move", &json!({"dir": "right"})).unwrap(); } + #[test] + fn selected_text_preview_stops_at_its_character_budget() { + let read = std::cell::Cell::new(0usize); + let prefix = ['\t', '\u{2003}', designcraft_doc::OBJECT_MARK]; + let chars = prefix.into_iter().chain(std::iter::repeat_n('字', 100_000)).inspect(|_| read.set(read.get() + 1)); + let preview = super::selected_text_preview(chars); + assert_eq!(preview, "字".repeat(super::SELECTED_TEXT_CHARS)); + assert_eq!(read.get(), prefix.len() + super::SELECTED_TEXT_CHARS, "the rest of a large selection is never copied or scanned"); + assert_eq!(super::selected_text_preview("A\nB".chars()), "A B"); + } + #[test] fn selection_attrs_report_the_selected_text() { let mut s = Session::new(); From 0ac04151c9a0ab63fa02bbe5b58a7fece07de20b Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sun, 11 Oct 2026 20:12:02 +0200 Subject: [PATCH 12/48] fix(engine): Every command that writes story text reads CR as a paragraph return frame.create and text.insert turned \r and \r\n into paragraph returns, but story.replaceRange, story.setText, note.new, footnote and endnote text, type on a path and table cells kept the CR in the text, where it drew as a missing glyph and joined the paragraphs. They all use text_param now. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 3 +++ crates/engine/src/cmd/editorial.rs | 2 +- crates/engine/src/cmd/endnotes.rs | 6 +++--- crates/engine/src/cmd/notes.rs | 6 +++--- crates/engine/src/cmd/object.rs | 5 +++++ crates/engine/src/cmd/path_type.rs | 2 +- crates/engine/src/cmd/table.rs | 2 +- crates/engine/src/cmd/text.rs | 4 ++-- 8 files changed, 19 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..242413657 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,9 @@ Newest first. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. +- **2026-10-11** · `story.replaceRange`, `story.setText`, notes, footnotes, endnotes, type on a path and table cells read ` +` and ` +` as paragraph returns, as `frame.create` and `text.insert` already did. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/engine/src/cmd/editorial.rs b/crates/engine/src/cmd/editorial.rs index 6cee420ed..451c96110 100644 --- a/crates/engine/src/cmd/editorial.rs +++ b/crates/engine/src/cmd/editorial.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { if ts.cell.is_some() { return Err(bad("note.new", "notes go in story text")); } - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); let author = str_param(p, "author").unwrap_or("").to_string(); let at = ts.anchor.max(ts.focus); s.edit(|d, sel| { diff --git a/crates/engine/src/cmd/endnotes.rs b/crates/engine/src/cmd/endnotes.rs index bdf9ca695..664b5c10f 100644 --- a/crates/engine/src/cmd/endnotes.rs +++ b/crates/engine/src/cmd/endnotes.rs @@ -4,7 +4,7 @@ use designcraft_doc::{ParaFormat, SpreadRef, StoryId}; use serde_json::{Value, json}; -use super::{CommandSpec, bad, cmd, has_doc, has_text, ok, str_param}; +use super::{CommandSpec, bad, cmd, has_doc, has_text, ok}; use crate::{Result, Session}; pub fn specs() -> Vec { @@ -20,7 +20,7 @@ pub fn specs() -> Vec { ), cmd!("endnote.edit", "Edit Endnote", [], None, "{story, id, text}", has_doc, |s, p| { let (sid, id) = target(p)?; - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); s.edit(|d, _| { let style = d.endnote_options.para_style.clone(); let st = d.story_mut(sid).ok_or_else(|| bad("endnote.edit", "no such story"))?; @@ -103,7 +103,7 @@ fn insert(s: &mut Session, p: &Value) -> Result { if st.doc.endnote_story == Some(ts.story) { return Err(bad(ID, "the endnote frame lists the endnotes")); } - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); let at = ts.anchor.max(ts.focus); s.edit(|d, sel| { // The endnote frame: a text frame in the margins of a new last page. diff --git a/crates/engine/src/cmd/notes.rs b/crates/engine/src/cmd/notes.rs index 4d7b0f13f..c17dc6cdb 100644 --- a/crates/engine/src/cmd/notes.rs +++ b/crates/engine/src/cmd/notes.rs @@ -3,7 +3,7 @@ use designcraft_doc::{CellAddr, FootnoteOptions, ParaFormat, StoryId, TextSel}; use serde_json::{Value, json}; -use super::{CommandSpec, bad, cmd, has_doc, str_param}; +use super::{CommandSpec, bad, cmd, has_doc}; use crate::{Result, Session}; pub fn specs() -> Vec { @@ -58,7 +58,7 @@ fn in_note(s: &Session) -> std::result::Result<(), String> { } fn insert(s: &mut Session, p: &Value) -> Result { - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); s.edit(|d, sel| { let t = sel.text.ok_or_else(|| bad("footnote.insert", "no insertion point"))?; let para = ParaFormat { style: d.footnote_options.para_style.clone(), ..Default::default() }; @@ -130,7 +130,7 @@ fn list(s: &mut Session, p: &Value) -> Result { fn set_text(s: &mut Session, p: &Value) -> Result { let sid = story_id(p, "footnote.setText")?; let id = note_id(p, "footnote.setText")?; - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); s.edit(|d, _| { let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; let n = st.note_mut(id).ok_or_else(|| bad("footnote.setText", format!("no footnote {id}")))?; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index cd13be117..62f52acaa 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -2604,6 +2604,11 @@ mod line_end_tests { s.execute("text.insert", &json!({"text": "\rFour", "raw": true})).unwrap(); assert_eq!(story(&s).text, "One\nTwo\nThree\nFour"); assert_eq!(story(&s).paras.len(), 4); + // Editing a range or the whole story (scripts written for InDesign end paragraphs with \r). + s.execute("story.replaceRange", &json!({"story": sid.0, "start": 0, "end": 3, "text": "Zero\rOne"})).unwrap(); + assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("Zero\nOne\nTwo\nThree\nFour", 5)); + s.execute("story.setText", &json!({"story": sid.0, "text": "A\r\nB\rC"})).unwrap(); + assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("A\nB\nC", 3)); } } diff --git a/crates/engine/src/cmd/path_type.rs b/crates/engine/src/cmd/path_type.rs index 77bbf87ab..6d0da227d 100644 --- a/crates/engine/src/cmd/path_type.rs +++ b/crates/engine/src/cmd/path_type.rs @@ -36,7 +36,7 @@ fn align_param(p: &Value) -> Result> { fn on_path(s: &mut Session, p: &Value) -> Result { let id = super::id_param(p, "id").ok_or_else(|| bad("type.onPath", "missing id"))?; - let text = super::str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); let mut pt = PathType { start: p.get("start").and_then(Value::as_f64).unwrap_or(0.0).max(0.0), flip: p.get("flip").and_then(Value::as_bool).unwrap_or(false), diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index c2313170a..9a1cffc10 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -645,7 +645,7 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { Some(o) => return Err(bad("table.setCell", format!("unknown vj `{o}`"))), None => None, }; - let text = str_param(p, "text").map(str::to_string); + let text = str_param(p, "text").map(|_| super::text_param(p, "text")); let stroke = p.get("stroke").cloned(); edit_table(s, &g, "table.setCell", |t| { let owners = t.owners(); diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 71d390d02..6eafa2333 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -76,7 +76,7 @@ pub fn specs() -> Vec { ), cmd!("story.setText", "Set Story Text", [], None, "{story, text} — replace a story's whole text", has_doc, |s, p| { let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.setText", "missing story"))?); - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); s.edit(|d, sel| { d.set_story_text(sid, &text)?; if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid) { @@ -97,7 +97,7 @@ pub fn specs() -> Vec { let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.replaceRange", "missing story"))?); let a = p.get("start").and_then(Value::as_u64).unwrap_or(0) as usize; let b = p.get("end").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(a); - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); s.edit(|d, sel| { let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; let (a, b) = (a.min(st.len()), b.min(st.len()).max(a.min(st.len()))); From e0cf54ec2b06414c88b30f6d29ea7d2498347ec9 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 00:00:30 +0200 Subject: [PATCH 13/48] fix(engine): note.edit reads CR as a paragraph return too note.edit was the one story-text command still reading `text` without text_param. The progress-log line held real CR and CRLF characters where it meant to show the escapes; it reads `\r` and `\r\n` now. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/editorial.rs | 2 +- crates/engine/src/cmd/object.rs | 7 +++++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 242413657..6e41e1727 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Newest first. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. +- **2026-10-11** · `story.replaceRange`, `story.setText`, notes, footnotes, endnotes, type on a path and table cells read `\r` and `\r\n` as paragraph returns, as `frame.create` and `text.insert` already did. No readiness percentage change. - **2026-10-11** · `story.replaceRange`, `story.setText`, notes, footnotes, endnotes, type on a path and table cells read ` ` and ` ` as paragraph returns, as `frame.create` and `text.insert` already did. No readiness percentage change. diff --git a/crates/engine/src/cmd/editorial.rs b/crates/engine/src/cmd/editorial.rs index 451c96110..6bb73176b 100644 --- a/crates/engine/src/cmd/editorial.rs +++ b/crates/engine/src/cmd/editorial.rs @@ -36,7 +36,7 @@ pub fn specs() -> Vec { }), cmd!("note.edit", "Edit Note", [], None, "{story, id, text?, author?}", has_doc, |s, p| { let (sid, id) = target(p)?; - let (text, author) = (str_param(p, "text").map(str::to_string), str_param(p, "author").map(str::to_string)); + let (text, author) = (str_param(p, "text").map(|_| super::text_param(p, "text")), str_param(p, "author").map(str::to_string)); s.edit(|d, _| { let st = d.story_mut(sid).ok_or_else(|| bad("note.edit", "no such story"))?; let n = st.editorial.iter_mut().find(|n| n.id == id).ok_or_else(|| bad("note.edit", format!("no note {id}")))?; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 62f52acaa..51699514a 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -2609,6 +2609,13 @@ mod line_end_tests { assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("Zero\nOne\nTwo\nThree\nFour", 5)); s.execute("story.setText", &json!({"story": sid.0, "text": "A\r\nB\rC"})).unwrap(); assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("A\nB\nC", 3)); + // Notes, made and edited. + s.execute("text.select", &json!({"story": sid.0, "anchor": 1})).unwrap(); + let n = s.execute("note.new", &json!({"text": "One\rTwo"})).unwrap(); + let note = |s: &mut Session| s.execute("note.list", &json!({})).unwrap()[0]["text"].clone(); + assert_eq!(note(&mut s), "One\nTwo"); + s.execute("note.edit", &json!({"story": n["story"], "id": n["id"], "text": "Three\r\nFour"})).unwrap(); + assert_eq!(note(&mut s), "Three\nFour"); } } From 3651bcb10b54900c6b022d4c1b8ec85d854993f8 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 18:26:47 +0200 Subject: [PATCH 14/48] docs: Drop a duplicate progress-log line with raw line breaks Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6e41e1727..8aee91dc9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -133,9 +133,6 @@ Newest first. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. - **2026-10-11** · `story.replaceRange`, `story.setText`, notes, footnotes, endnotes, type on a path and table cells read `\r` and `\r\n` as paragraph returns, as `frame.create` and `text.insert` already did. No readiness percentage change. -- **2026-10-11** · `story.replaceRange`, `story.setText`, notes, footnotes, endnotes, type on a path and table cells read ` -` and ` -` as paragraph returns, as `frame.create` and `text.insert` already did. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. From 6880042c6a32dde23c3d9c560f1b36c204184ded Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 14:57:09 +0200 Subject: [PATCH 15/48] fix(compose): Hyphenation Zone applies to the Single-line Composer only InDesign's Hyphenation Zone ("the white space allowed at the end of a line of unjustified text before hyphenation begins") applies only with the Single-line Composer. The Paragraph Composer, the default, weighs every break of the paragraph instead. DesignCraft applied the default 3p0 zone to both, so ragged paragraphs under the Paragraph Composer refused hyphens InDesign makes. In LYH Studio's poster tutorial the placeholder text breaks "aper-natem" and "mosticsia-tur"; ours ran a line longer. The breaker now gets the zone only for unjustified Single-line Composer paragraphs. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 4 +++- crates/compose/src/tests.rs | 15 ++++++++++----- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..fa779dd7d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Newest first. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. +- **2026-10-12** · Hyphenation Zone applies to the Single-line Composer only, as in InDesign. With the default Paragraph Composer, ragged text hyphenates where InDesign does (aper-natem), giving the same line breaks. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3dd7c25b3..24004d2a9 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1477,7 +1477,9 @@ fn spacing_for(pp: &ParaProps, base_size: f64) -> Spacing { hyphen_penalty: 50.0 + 450.0 * pp.hyph_weight, hyphen_limit: pp.hyph_limit, ragged_stretch: base_size * 2.0, - hyph_zone: if pp.align.is_justified() { 0.0 } else { pp.hyph_zone }, + // The zone is the Single-line Composer's (unjustified text only), as in InDesign: the + // Paragraph Composer weighs every break of the paragraph instead. + hyph_zone: if pp.align.is_justified() || pp.composer != Composer::SingleLine { 0.0 } else { pp.hyph_zone }, optical: pp.optical_margin, korean_char_breaks: pp.korean_char_breaks, } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index f5d0113c1..a7a981447 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -288,11 +288,16 @@ fn hyphenation_zone_limits_ragged_hyphens() { let cs = compose_story(&d, sid, &ComposeOptions::default()); all_lines(&cs).iter().filter(|l| l.hyphenated).count() }; - for c in [designcraft_doc::Composer::Paragraph, designcraft_doc::Composer::SingleLine] { - let (free, zoned, huge) = (count(0.0, c), count(36.0, c), count(1000.0, c)); - // A huge zone leaves only words that start a line (no space to break at) hyphenable. - assert!(free > 0 && zoned <= free && huge * 4 <= free, "{c:?}: {free} {zoned} {huge}"); - } + // Single-line Composer: a huge zone leaves only words that start a line (no space to break + // at) hyphenable. + let c = designcraft_doc::Composer::SingleLine; + let (free, zoned, huge) = (count(0.0, c), count(36.0, c), count(1000.0, c)); + assert!(free > 0 && zoned <= free && huge * 4 <= free, "{c:?}: {free} {zoned} {huge}"); + // The zone is the Single-line Composer's only (InDesign): the Paragraph Composer hyphenates + // the same whatever it is. + let c = designcraft_doc::Composer::Paragraph; + let (free, zoned, huge) = (count(0.0, c), count(36.0, c), count(1000.0, c)); + assert!(free > 0 && zoned == free && huge == free, "{c:?}: {free} {zoned} {huge}"); } #[test] From 7ee66df165588af2db643128498a62be58852c0f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 10:58:17 +0200 Subject: [PATCH 16/48] feat(ui): Step and Repeat dialog with Create as a grid and Preview Edit > Step and Repeat opened the generic command form (Count 1..1000, Dx, Dy, Rows, Columns, Ids). It now has InDesign's dialog: Repeat (Count, or Rows and Columns when Create as a grid is on), Offset (Vertical, Horizontal, in the document's ruler units or any unit typed) and Preview, which shows the copies on the page while the dialog is open. Cancel, Esc and ui.dialog.cancel take the preview away; OK makes the copies once, as one undo step. A value that isn't a number keeps the dialog open and says so. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/control.rs | 3 +- crates/ui-egui/src/dialogs.rs | 218 ++++++++++++++++++++++++++++++- crates/ui-egui/src/i18n.rs | 8 ++ crates/ui-egui/src/i18n/ar.rs | 8 ++ crates/ui-egui/src/i18n/it.rs | 8 ++ crates/ui-egui/src/i18n/ja.rs | 5 + crates/ui-egui/src/i18n/pt_br.rs | 8 ++ crates/ui-egui/src/i18n/uk.rs | 8 ++ docs/target-app-parity.md | 2 +- 10 files changed, 262 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..9622e24cc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Newest first. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. +- **2026-10-12** · Edit › Step and Repeat has its InDesign dialog: Repeat (Count, or Rows and Columns with Create as a grid), Offset (Vertical, Horizontal, in ruler units) and Preview, which shows the copies while the dialog is open; Cancel takes them away and OK makes them as one undo step. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 12df640da..378bb5cba 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -302,8 +302,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> } "ui.dialog.confirm" => wrap(crate::dialogs::confirm(app)), "ui.dialog.cancel" => { - app.cancel_pdf_import(); - app.ui.dialog = None; + crate::dialogs::cancel(app); ok(Value::Null) } "ui.resize" => { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 14d2552e8..695a66af1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1656,6 +1656,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { ui.end_row(); }); } + STEP_AND_REPEAT => step_and_repeat_dialog(app, ui, &mut d), id if id.starts_with("cmd:") => command_form(app, ui, &mut d), "goToPage" => { ui.horizontal(|ui| { @@ -1733,14 +1734,20 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { Some(true) => { let _ = confirm(app); } - Some(false) => { - app.ui.dialog = None; - app.cancel_pdf_import(); - } + Some(false) => cancel(app), None => {} } } +/// 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) { + if app.ui.dialog.take().is_some_and(|d| d.fields.contains_key(PREVIEWED)) { + app.session.cancel_interaction(); + } + app.cancel_pdf_import(); +} + /// 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()) }; @@ -2167,6 +2174,20 @@ pub fn confirm(app: &mut DesignApp) -> Result { "alert" => Ok(Value::Null), "findChange" if d.b("objectMode") => Ok(Value::Null), "findChange" => app.run("find.change", json!({"find": d.s("find"), "change": d.s("change"), "grep": d.b("grep"), "caseSensitive": d.b("caseSensitive"), "wholeWord": d.b("wholeWord"), "scope": d.s("scope")})), + STEP_AND_REPEAT => { + // The preview goes; the copies are made once, as one undo step. + if d.fields.contains_key(PREVIEWED) { + app.session.cancel_interaction(); + } + let r = step_and_repeat_params(app, &d).and_then(|p| app.run("edit.stepAndRepeat", p)); + if let Err(e) = &r { + let mut d = d.clone(); + d.fields.remove(PREVIEWED); + d.fields.insert("status".into(), json!(e)); + app.ui.dialog = Some(d); + } + r + } id if id.starts_with("cmd:") => { let cid = &id[4..]; let doc = designcraft_engine::find_command(cid).map_or("", |c| c.params); @@ -3558,6 +3579,120 @@ fn command_form(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } } +const STEP_AND_REPEAT: &str = "cmd:edit.stepAndRepeat"; +/// The params a previewing dialog shows on the page (its interaction is open while it is set). +const PREVIEWED: &str = "_previewed"; + +/// Edit › Step and Repeat, as in InDesign: Repeat (Count, or Rows and Columns with Create as a +/// grid), Offset (Vertical, Horizontal, in the document's ruler units) and Preview, which shows +/// the copies on the page while the dialog is open. +fn step_and_repeat_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let (hu, vu) = app.session.active().map_or((Unit::Picas, Unit::Picas), |s| (s.doc.settings.horizontal_units, s.doc.settings.vertical_units)); + for (k, v) in [ + ("count", json!("1")), + ("rows", json!("1")), + ("columns", json!("1")), + ("vertical", json!(format_measure(12.0, vu))), + ("horizontal", json!(format_measure(12.0, hu))), + ] { + d.fields.entry(k).or_insert(v); + } + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + crate::rtl::label(ui, egui::RichText::new(tr("Repeat")).font(semibold(12.0))); + egui::Grid::new("sr_repeat").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + let grid = d.b("grid"); + let fields: &[(&str, &str)] = if grid { &[("rows", "Rows:"), ("columns", "Columns:")] } else { &[("count", "Count:")] }; + for (k, l) in fields { + crate::rtl::label(ui, tr(l)); + text_field(ui, d, k, 70.0); + ui.end_row(); + } + crate::rtl::label(ui, ""); + check(ui, d, "grid", &tr("Create as a grid")); + ui.end_row(); + }); + ui.add_space(10.0); + crate::rtl::label(ui, egui::RichText::new(tr("Offset")).font(semibold(12.0))); + ui.horizontal(|ui| { + for (k, l) in [("vertical", "Vertical:"), ("horizontal", "Horizontal:")] { + crate::rtl::label(ui, tr(l)); + text_field(ui, d, k, 80.0); + ui.add_space(12.0); + } + }); + }); + ui.add_space(32.0); + check(ui, d, "preview", &tr("Preview")); + }); + step_and_repeat_preview(app, d); + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + ui.add_space(6.0); + crate::rtl::label(ui, egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + } +} + +/// `edit.stepAndRepeat`'s params from the dialog: whole counts, offsets in the ruler units +/// (or any unit typed: "-0.75 in", "3p"). Empty fields are 1 copy and 1p0. +fn step_and_repeat_params(app: &DesignApp, d: &Dialog) -> Result { + let (hu, vu) = app.session.active().map_or((Unit::Picas, Unit::Picas), |s| (s.doc.settings.horizontal_units, s.doc.settings.vertical_units)); + let invalid = |s: &str| format!("{}: {}", crate::i18n::tr(&app.ui.language, "Invalid value"), s.trim()); + // Fields not shown yet (a dialog confirmed over the control channel) take the defaults. + let whole = |k: &str| { + let s = d.s(k); + if s.trim().is_empty() { Ok(1) } else { s.trim().parse::().map_err(|_| invalid(&s)) } + }; + let measure = |k: &str, u: Unit| { + let s = d.s(k); + if s.trim().is_empty() { + return Ok(12.0); + } + parse_measure(&s, u).ok().filter(|v| v.is_finite()).ok_or_else(|| invalid(&s)) + }; + let (dx, dy) = (measure("horizontal", hu)?, measure("vertical", vu)?); + Ok(if d.b("grid") { + json!({"rows": whole("rows")?, "columns": whole("columns")?, "dx": dx, "dy": dy}) + } else { + json!({"count": whole("count")?, "dx": dx, "dy": dy}) + }) +} + +/// Preview on: the copies show on the page (redone when a field changes); off: they go. +fn step_and_repeat_preview(app: &mut DesignApp, d: &mut Dialog) { + let shown = d.fields.get(PREVIEWED).cloned(); + if !d.b("preview") { + if shown.is_some() { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + d.fields.remove("status"); + } + return; + } + let params = match step_and_repeat_params(app, d) { + Ok(p) => p, + Err(e) => { + d.fields.insert("status".into(), json!(e)); + return; + } + }; + if shown.as_ref() == Some(¶ms) { + return; + } + if shown.is_none() + && let Err(e) = app.session.begin_interaction("Step and Repeat") + { + d.fields.insert("status".into(), json!(e.to_string())); + return; + } + match app.session.preview("edit.stepAndRepeat", params.clone()) { + Ok(()) => d.fields.remove("status"), + Err(e) => d.fields.insert("status".into(), json!(e.to_string())), + }; + d.fields.insert(PREVIEWED.into(), params); +} + /// New Cross-Reference: link to a paragraph (paragraph styles on the left, their paragraphs on /// the right) or a text anchor, with a cross-reference format. fn insert_xref(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { @@ -3744,6 +3879,81 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod tests { use super::*; + #[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()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 600, 540, 640]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let open = |app: &mut DesignApp| { + crate::menus::activate(app, "edit.stepAndRepeat", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some(STEP_AND_REPEAT)); + }; + let set = |app: &mut DesignApp, k: &str, v: Value| { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + }; + let items = |app: &DesignApp| app.session.doc().unwrap().doc.spreads[0].items.len(); + let undo = |app: &DesignApp| app.session.doc().unwrap().history.undo.len(); + let (n0, u0) = (items(&app), undo(&app)); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); + open(&mut app); + dialog_frame(&mut app, &ctx, screen, vec![]); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for expected in ["Step and Repeat", "Repeat", "Count:", "Create as a grid", "Offset", "Vertical:", "Horizontal:", "Preview", "OK", "Cancel"] { + visible_label(&labels, screen, expected); + } + // Preview: the copies show, and change with the fields, without an undo step. + set(&mut app, "count", json!("5")); + set(&mut app, "vertical", json!("-0.75 in")); + set(&mut app, "horizontal", json!("0")); + set(&mut app, "preview", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!((items(&app), undo(&app)), (n0 + 5, u0)); + set(&mut app, "count", json!("3")); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(items(&app), n0 + 3); + set(&mut app, "preview", json!(false)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(items(&app), n0, "Preview off takes them away"); + set(&mut app, "preview", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(items(&app), n0 + 3); + // Cancel puts the document back. + cancel(&mut app); + assert!(app.ui.dialog.is_none()); + assert_eq!((items(&app), undo(&app)), (n0, u0)); + // OK after a preview makes the copies once, as one undo step, 0.75 in apart. + app.run("selection.set", json!({"ids": [id]})).unwrap(); + open(&mut app); + set(&mut app, "count", json!("2")); + set(&mut app, "vertical", json!("-0.75 in")); + set(&mut app, "horizontal", json!("0")); + set(&mut app, "preview", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + let r = confirm(&mut app).unwrap(); + assert_eq!((items(&app), undo(&app)), (n0 + 2, u0 + 1)); + let last = r["ids"][1].as_u64().unwrap(); + let top = app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(last)).unwrap().bounds().min_y(); + assert!((top - (600.0 - 108.0)).abs() < 1e-6, "{top}"); + // Create as a grid: Rows × Columns cells, the original being one. + app.run("selection.set", json!({"ids": [id]})).unwrap(); + open(&mut app); + set(&mut app, "grid", json!(true)); + set(&mut app, "rows", json!("2")); + set(&mut app, "columns", json!("3")); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "Rows:"); + visible_label(&labels, screen, "Columns:"); + assert_eq!(confirm(&mut app).unwrap()["created"], 5); + // A value that isn't a measure keeps the dialog open and says so. + open(&mut app); + set(&mut app, "vertical", json!("up")); + assert!(confirm(&mut app).is_err()); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.s("status"), "Invalid value: up"); + } + fn dialog_context(language: &str) -> egui::Context { let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx, language); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5976d7d07..473f02205 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -739,6 +739,14 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ("Apply Cell Style", ["Apply Cell Style", "Apply Cell Style", "Apply Cell Style", "Apply Cell Style", "应用单元格样式"]), ("Apply Color", ["Apply Color", "Apply Color", "Apply Color", "Apply Color", "应用颜色"]), + ("Repeat", ["Wiederholen", "Répéter", "Repetir", "繰り返し", "重复"]), + ("Count:", ["Anzahl:", "Nombre :", "Recuento:", "繰り返し数:", "计数:"]), + ("Create as a grid", ["Als Raster erstellen", "Créer en tant que grille", "Crear como cuadrícula", "グリッドとして作成", "创建为网格"]), + ("Rows:", ["Zeilen:", "Rangées :", "Filas:", "行:", "行:"]), + ("Columns:", ["Spalten:", "Colonnes :", "Columnas:", "列:", "列:"]), + ("Vertical:", ["Vertikal:", "Vertical :", "Vertical:", "垂直方向:", "垂直:"]), + ("Horizontal:", ["Horizontal:", "Horizontal :", "Horizontal:", "水平方向:", "水平:"]), + ("Invalid value", ["Ungültiger Wert", "Valeur non valide", "Valor no válido", "無効な値", "无效值"]), ("Apply Condition", ["Apply Condition", "Apply Condition", "Apply Condition", "Apply Condition", "应用条件"]), ( "Apply Parent to Pages…", diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index c3373486e..0382b3302 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1041,6 +1041,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Formatting affects container (J)", "تطبيق التنسيق على الحاوية (J)"), ("Formatting affects text (J)", "تطبيق التنسيق على النص (J)"), ("Apply Color", "تطبيق اللون"), + ("Repeat", "تكرار"), + ("Count:", "العدد:"), + ("Create as a grid", "إنشاء كشبكة"), + ("Rows:", "الصفوف:"), + ("Columns:", "الأعمدة:"), + ("Vertical:", "رأسي:"), + ("Horizontal:", "أفقي:"), + ("Invalid value", "قيمة غير صالحة"), ("Apply None", "إزالة اللون"), ("Height Only", "الارتفاع فقط"), ("Width Only", "العرض فقط"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 34766cf22..562afe5d2 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -412,6 +412,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Apply Attributes (Eyedropper)", "Applica attributi (Contagocce)"), ("Apply Cell Style", "Applica stile di cella"), ("Apply Color", "Applica colore"), + ("Repeat", "Ripeti"), + ("Count:", "Conteggio:"), + ("Create as a grid", "Crea come griglia"), + ("Rows:", "Righe:"), + ("Columns:", "Colonne:"), + ("Vertical:", "Verticale:"), + ("Horizontal:", "Orizzontale:"), + ("Invalid value", "Valore non valido"), ("Apply Condition", "Applica condizione"), ("Apply Parent to Pages…", "Applica pagina mastro alle pagine…"), ("ArtCraft App Page…", "Pagina dell'app ArtCraft…"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index de57db5e9..9a1aff485 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -414,6 +414,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Apply Attributes (Eyedropper)", "属性を適用 (スポイト)"), ("Apply Cell Style", "セルスタイルを適用"), ("Apply Color", "カラーを適用"), + ("Repeat", "繰り返し"), + ("Count:", "繰り返し数:"), + ("Create as a grid", "グリッドとして作成"), + ("Rows:", "行:"), + ("Invalid value", "無効な値"), ("Apply Condition", "条件を適用"), ("Apply Parent to Pages…", "ページに親ページを適用…"), ("ArtCraft App Page…", "ArtCraft アプリページ…"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index a4d06bbe9..465bbd627 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1045,6 +1045,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Formatting affects container (J)", "Formatação afeta o contêiner (J)"), ("Formatting affects text (J)", "Formatação afeta o texto (J)"), ("Apply Color", "Aplicar cor"), + ("Repeat", "Repetir"), + ("Count:", "Contagem:"), + ("Create as a grid", "Criar como grade"), + ("Rows:", "Linhas:"), + ("Columns:", "Colunas:"), + ("Vertical:", "Vertical:"), + ("Horizontal:", "Horizontal:"), + ("Invalid value", "Valor inválido"), ("Apply None", "Aplicar nenhum"), ("Height Only", "Somente altura"), ("Width Only", "Somente largura"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9b70204a4..68c438edb 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -415,6 +415,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Apply Attributes (Eyedropper)", "Застосувати атрибути (піпетка)"), ("Apply Cell Style", "Застосувати стиль клітинки"), ("Apply Color", "Застосувати колір"), + ("Repeat", "Повторення"), + ("Count:", "Кількість:"), + ("Create as a grid", "Створити як сітку"), + ("Rows:", "Рядки:"), + ("Columns:", "Стовпці:"), + ("Vertical:", "Вертикально:"), + ("Horizontal:", "Горизонтально:"), + ("Invalid value", "Неприпустиме значення"), ("Apply Condition", "Застосувати умову"), ("Apply Parent to Pages…", "Застосувати шаблон до сторінок…"), ("ArtCraft App Page…", "Сторінка програми ArtCraft…"), diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5705ee738..83b277507 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -276,7 +276,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; `table.*` commands. - Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline. - EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`). -- Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid), snippets, object styles, Numbering & Section Options. +- Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid, with a previewing dialog), snippets, object styles, Numbering & Section Options. - Footnotes: Type › Insert Footnote (caret moves into the note), Document Footnote Options (numbering style incl. symbols, start/restart per page/spread/section, prefix/suffix, reference position/character style, paragraph style, separator, spacing, first baseline, rule above); notes composed at the bottom of the referencing column with the body text making room; edited in place on the canvas; rendered, exported to PDF (real text) and IDML (InDesign's structure, verified opening in InDesign 2026) and imported from IDML; `footnote.*` commands. - Paragraph Rules: Rule Above / Rule Below (on, weight, colour, tint, column or text width, offset, left/right indent) set in Type › Paragraph Rules…, the Paragraph panel menu and Paragraph Style Options › Paragraph Rules; inherited style values shown, and `type.para` / `style.paragraph.edit` change only the rule fields named. - Cross-references and text anchors: Insert Cross-Reference (paragraph by style, or text anchor), 11 formats with InDesign's building blocks (full/partial paragraph, paragraph text/number, page number, anchor name, chapter, file name) plus user formats; resolved live at composition — never out of date, no Update step; unresolved destinations show `??`; PDF link annotations to the destination page; IDML export/import as InDesign cross-reference sources, hyperlinks, text destinations and formats (verified in InDesign 2026); `xref.*` / `anchor.*` commands. From 03d8b1e61b23b455c04aa2b51adcb28ad22bb557 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 05:08:30 +0200 Subject: [PATCH 17/48] fix(ui): Double-clicking the Fill or Stroke box opens the Color Picker In InDesign a double-click on the Tools panel's Fill or Stroke box opens the Color Picker; DesignCraft opened the Swatches panel. It now opens the Color Picker for that box, with the selection's colour (the selected text's when text is selected). The Stroke box also reports its name to screen readers. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/toolbar.rs | 49 +++++++++++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..b72b1f25c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Newest first. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. +- **2026-10-12** · Double-clicking the Fill or Stroke box in the Tools panel opens the Color Picker with the selection's colour, as in InDesign (it opened the Swatches panel). No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 5a5e1f08a..ac2729695 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -272,16 +272,31 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { crate::rtl::label(ui, fill_tip); }); fresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, fill_tip)); + let stroke_tip = crate::i18n::tr(&app.ui.language, "Stroke (X)"); let sresp = ui.interact(stroke_r.translate(vec2(0.0, 0.0)), ui.id().with("proxy_stroke"), Sense::click()).on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Stroke (X)")); + crate::rtl::label(ui, stroke_tip); }); + sresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, stroke_tip)); if fresp.clicked() || sresp.clicked() { let front = if stroke_front { !fresp.clicked() } else { sresp.clicked() && !fill_r.contains(sresp.interact_pointer_pos().unwrap_or_default()) }; ui.data_mut(|d| d.insert_temp(stroke_front_id, front)); } + // A double-click opens the Color Picker for that box (as InDesign's), with the selection's + // colour: the selected text's when text is selected. if fresp.double_clicked() || sresp.double_clicked() { - let _ = app.run("window.panel", json!({"panel": "swatches"})); + let on_fill = fresp.double_clicked() && (!stroke_front || !sresp.double_clicked()); + let stroke = !on_fill; + let text = crate::panels::text_attrs(app).filter(|_| app.session.active().is_some_and(|d| d.selection.text.is_some())); + let current = match text { + Some(a) => a["chars"][if stroke { "stroke" } else { "fill" }].as_str().unwrap_or("[None]").to_string(), + None if stroke => stroke_sw.clone(), + None => fill_sw.clone(), + }; + let rgb = doc.as_ref().and_then(|d| d.resolve_color(¤t, 1.0)).map_or([0.0; 3], |c| c.to_rgb()); + let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + let hex = format!("#{:02x}{:02x}{:02x}", byte(rgb[0]), byte(rgb[1]), byte(rgb[2])); + app.ui.dialog = Some(crate::dialogs::Dialog::new("colorPicker", json!({"target": if stroke { "stroke" } else { "fill" }, "hex": hex}))); } ui.add_space(3.0); // Formatting affects container / text. @@ -472,6 +487,36 @@ mod tests { assert!(!h.state().app.ui.tools_double_column); } + #[test] + fn double_clicking_the_fill_box_opens_the_color_picker_with_the_selections_colour() { + let mut app = app(false); + app.run("file.new", serde_json::json!({})).unwrap(); + let id = app.run("frame.create", serde_json::json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].clone(); + app.run("object.color", serde_json::json!({"color": "#456daf", "ids": [id]})).unwrap(); + app.run("selection.set", serde_json::json!({"ids": [id]})).unwrap(); + // Frames 1 ms apart, so two clicks make a double-click. + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).with_step_dt(0.001).build_ui_state( + |ui, app: &mut crate::DesignApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(6); + let at = h.get_by_label(FILL).rect().center(); + h.hover_at(at); + h.step(); + for pressed in [true, false, true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + h.step(); + } + h.run_steps(3); + let d = h.state().ui.dialog.clone().expect("the Color Picker opened"); + assert_eq!(d.id, "colorPicker"); + assert_eq!((d.fields["target"].as_str(), d.fields["hex"].as_str()), (Some("fill"), Some("#456daf")), "{:?}", d.fields); + } + #[test] fn a_tall_window_keeps_one_column_and_does_not_scroll() { let mut h = test_window::open(app(false), vec2(1400.0, 900.0)); From 8fd67c8674cb17acdef2eb5f6b4b2b915ee578b5 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 00:34:51 +0200 Subject: [PATCH 18/48] =?UTF-8?q?feat(ui):=20Rotation,=20shear,=20scale,?= =?UTF-8?q?=20rotate=20and=20flip=20in=20Properties=20=E2=80=BA=20Transfor?= =?UTF-8?q?m?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InDesign's Properties › Transform shows, under X/Y/W/H, the rotation and shear angles beside the X and Y scale, then buttons to rotate a quarter turn either way and to flip horizontally or vertically. DesignCraft's showed only X/Y/W/H; the rest was in the Control bar and Object › Transform. The fields set totals through transform.set around the reference point; the buttons run transform.rotate and transform.flip. Rotate and flip icons are original drawings. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/icons.rs | 23 +++++++ crates/ui-egui/src/panels/properties.rs | 91 +++++++++++++++++++++++++ 3 files changed, 115 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..d5db7e85a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Newest first. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. +- **2026-10-12** · Properties › Transform shows rotation, shear and X/Y scale, and Rotate 90° and Flip Horizontal/Vertical buttons, as InDesign's does. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 69415e9b3..dba5a7a7a 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -178,6 +178,29 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { pen.line(&pts); pen.line(&[(13.0, 4.0), (15.5, 6.0), (16.5, 2.8)]); } + // Properties › Transform: rotate a quarter turn (an arc over a square), flip (a shape and its + // mirror image across a dashed axis). + "rotate-cw" | "rotate-ccw" => { + let m = |x: f32| if name == "rotate-ccw" { 20.0 - x } else { x }; + pen.rect(m(3.0).min(m(11.0)), 9.0, m(3.0).max(m(11.0)), 17.0); + let pts: Vec<(f32, f32)> = (0..12) + .map(|i| { + let a = std::f32::consts::PI * (1.0 - i as f32 / 11.0); + (m(11.0 + 5.0 * a.cos()), 9.0 - 5.0 * a.sin()) + }) + .collect(); + pen.line(&pts); + pen.line(&[(m(14.0), 7.0), (m(16.0), 9.5), (m(18.0), 7.0)]); + } + "flip-h" | "flip-v" => { + let t = |x: f32, y: f32| if name == "flip-v" { (y, x) } else { (x, y) }; + pen.fill(&[t(3.0, 16.0), t(8.5, 4.0), t(8.5, 16.0)]); + pen.closed(&[t(11.5, 16.0), t(11.5, 4.0), t(17.0, 16.0)]); + for k in 0..4 { + let y0 = 2.5 + k as f32 * 4.0; + pen.line(&[t(10.0, y0), t(10.0, y0 + 2.0)]); + } + } "tool-gradient" | "tool-gradient-feather" => { for i in 0..6 { let a = 30 + i * 35; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 49cb920f2..17473fba0 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -460,11 +460,62 @@ fn transform_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { set.insert("ref".into(), json!(rp)); let _ = app.run("transform.set", Value::Object(set)); } + transform_angles(app, ui, rp); if more_options(ui, &app.ui.language).clicked() { app.ui.open_panel = Some("info".into()); } } +/// Properties › Transform, below X/Y/W/H as in InDesign: rotation and shear angles beside the +/// X and Y scales (totals, as Transformations are Totals), then rotate a quarter turn and flip. +fn transform_angles(app: &mut DesignApp, ui: &mut Ui, rp: u8) { + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language.clone(); + let tv = app.session.execute("transform.info", &json!({})).ok(); + let tv_get = |k: &str| tv.as_ref().and_then(|v| v[k].as_f64()); + ui.add_space(4.0); + let b = block(ui, 2.0 * FIELD_H + 4.0); + let mut set = serde_json::Map::new(); + let rows = [ + [("tool-rotate", "Rotation Angle", "rotation", "°", "p_rot"), ("tool-scale", "Scale X Percentage", "scaleX", "%", "p_sx")], + [("tool-shear", "Shear X Angle", "shear", "°", "p_shear"), ("tool-scale", "Scale Y Percentage", "scaleY", "%", "p_sy")], + ]; + for (row, cells) in rows.into_iter().enumerate() { + let yy = row as f32 * 25.0; + for (col, (icon, tip, key, suffix, id)) in cells.into_iter().enumerate() { + let x = if col == 0 { 40.0 } else { 120.0 }; + let ir = sub(b, x, yy + 2.0, 17.0, 17.0); + icons::paint(ui.painter(), ir, icon, t.icon); + ui.interact(ir, ui.id().with(id), Sense::hover()).on_hover_text(crate::i18n::tr(&lang, tip)); + if let Some(v) = place(ui, sub(b, x + 19.0, yy, 54.0, FIELD_H), |ui| widgets::number(ui, id, tv_get(key), suffix, 54.0, 1)) { + set.insert(key.into(), json!(v)); + } + } + } + if !set.is_empty() { + set.insert("ref".into(), json!(rp)); + let _ = app.run("transform.set", Value::Object(set)); + } + ui.add_space(2.0); + ui.horizontal(|ui| { + ui.add_space(36.0); + ui.spacing_mut().item_spacing.x = 10.0; + for (icon, tip, cmd, params) in [ + ("rotate-cw", "Rotate 90° Clockwise", "transform.rotate", json!({"angle": -90})), + ("rotate-ccw", "Rotate 90° Counterclockwise", "transform.rotate", json!({"angle": 90})), + ("flip-h", "Flip Horizontal", "transform.flip", json!({"axis": "horizontal"})), + ("flip-v", "Flip Vertical", "transform.flip", json!({"axis": "vertical"})), + ] { + let label = crate::i18n::tr(&lang, tip); + let resp = icon_toggle(ui, icon, false, label); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + if resp.clicked() { + let _ = app.run(cmd, params); + } + } + }); +} + /// A stroke-type preview dropdown (light box with the line style). fn stroke_type_dropdown(app: &mut DesignApp, ui: &mut Ui, cur: &str, w: f32) { const TYPES: &[(&str, &str)] = &[ @@ -2207,4 +2258,44 @@ mod tests { let after = h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().fill.swatch.clone(); assert_eq!(after, name, "clicking the swatch chip applies it"); } + + #[test] + fn transform_flips_and_rotates_from_properties() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + let xf = |h: &Harness<'_, DesignApp>| h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().xf; + // Flip Horizontal mirrors the frame (a negative determinant); Flip Vertical as well brings it + // back to a half turn, as in the tutorial's petal. + h.get_by_label("Flip Horizontal").click(); + h.run_steps(3); + assert!(xf(&h).determinant() < 0.0, "{:?}", xf(&h)); + h.get_by_label("Flip Vertical").click(); + h.run_steps(3); + let c = xf(&h).as_coeffs(); + assert!((c[0] + 1.0).abs() < 1e-9 && (c[3] + 1.0).abs() < 1e-9, "a half turn: {c:?}"); + let info = h.state_mut().session.execute("transform.info", &json!({})).unwrap(); + assert!((info["rotation"].as_f64().unwrap().abs() - 180.0).abs() < 1e-6, "{info}"); + // A quarter turn clockwise from there. + h.get_by_label("Rotate 90° Clockwise").click(); + h.run_steps(3); + let info = h.state_mut().session.execute("transform.info", &json!({})).unwrap(); + assert!((info["rotation"].as_f64().unwrap() - 90.0).abs() < 1e-6, "{info}"); + // The angle and scale fields are there. + assert!(h.query_by(|n| n.role() == egui::accesskit::Role::TextInput && n.value().as_deref() == Some("90°")).is_some()); + } } From 1dc33dd280d057f694b1d3661886069ddddead64 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 00:29:17 +0200 Subject: [PATCH 19/48] fix(render): One underline bar per stretch of text, no seams The canvas drew an underline (and a strikethrough) as one rectangle per glyph; where two anti-aliased edges met at a fractional pixel they left a lighter seam between the letters. Contiguous bars of the same colour and position are merged into one, as the PDF export already does. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/tests/text_decorations.rs | 39 +++++++++++++++++++++++++ crates/render/src/text.rs | 20 +++++++++++-- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..66083a13e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Newest first. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. +- **2026-10-12** · Underlines and strikethroughs are drawn as one bar per stretch of text on screen, as in PDF export: a bar per letter left faint seams between the letters. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/engine/tests/text_decorations.rs b/crates/engine/tests/text_decorations.rs index cd3388f76..c49c1d2cb 100644 --- a/crates/engine/tests/text_decorations.rs +++ b/crates/engine/tests/text_decorations.rs @@ -163,3 +163,42 @@ fn decoration_offsets_are_centers_for_explicit_and_automatic_rules() { } } } + +/// An underline is one bar per stretch of text: a bar per glyph left lighter seams between the +/// letters where the anti-aliased edges met (at fractional positions). +#[test] +fn an_underline_has_no_seams_between_letters() { + let mut d = Document::new(&NewDocument { width: 400.0, height: 120.0, facing_pages: false, ..Default::default() }); + let chars = CharAttrs { + font_family: Some("Source Sans 3".into()), + size: Some(48.0), + fill: Some("[Black]".into()), + underline: Some(true), + underline_weight: Some(Some(6.0)), + ..Default::default() + }; + d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 390.0, 110.0), d.default_layer(), "FERRUM", ParaFormat { chars, ..Default::default() }) + .unwrap(); + let mut renderer = Renderer::new(); + renderer.threads = 0; + let placed = [Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }]; + let options = RenderOptions { paper: false, background: Some([255; 4]), greek_below_px: 0.0, ..Default::default() }; + // A fractional scale puts the letters' edges between pixels. + let (w, h) = (543u32, 163u32); + let img = renderer.render(&d, &Cache::new(), &placed, w, h, Affine::scale(1.3579), &options); + let px = |x: u32, y: u32| { + let i = ((y * w + x) * 4) as usize; + [img.pixels[i], img.pixels[i + 1], img.pixels[i + 2], img.pixels[i + 3]] + }; + // The underline's middle row: the row with the longest run of dark pixels ([Black] shows as + // about 52 grey), then nothing lighter than its darkest pixel along it. + let row = (0..h).max_by_key(|&y| (0..w).filter(|&x| px(x, y)[0] < 110).count()).unwrap(); + let dark: Vec = (0..w).filter(|&x| px(x, row)[0] < 110).collect(); + let (x0, x1) = (dark[0], *dark.last().unwrap()); + assert!(x1 - x0 > 150, "the underline spans the word: {x0}..{x1}"); + // The bar's own anti-aliased ends aside. + let inside = x0 + 2..=x1 - 2; + let ink = inside.clone().map(|x| px(x, row)[0]).min().unwrap(); + let light: Vec<(u32, u8)> = inside.map(|x| (x, px(x, row)[0])).filter(|&(_, v)| v > ink + 12).collect(); + assert!(light.is_empty(), "seams in the underline at {light:?}"); +} diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 734ec275e..cc46ad729 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -117,8 +117,8 @@ fn union(r: Option, b: Rect) -> Option { fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> LineGlyphs { let mut runs: Vec<(u32, BezPath)> = Vec::new(); - let mut underlines = Vec::new(); - let mut strikes = Vec::new(); + let mut underlines: Vec<(String, f32, Rect)> = Vec::new(); + let mut strikes: Vec<(String, f32, Rect)> = Vec::new(); let mut glyphs = 0; for g in &l.glyphs { if !g.visible { @@ -127,7 +127,21 @@ fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> Line let style = &cs.styles[g.style as usize]; for (on, rule, bars) in [(style.underline, &style.underline_rule, &mut underlines), (style.strikethrough, &style.strike_rule, &mut strikes)] { if on { - bars.push((rule.color.clone(), rule.tint, rule.rect(g.x, g.x + g.adv, designcraft_compose::rule_baseline(style, l, g)))); + let r = rule.rect(g.x, g.x + g.adv, designcraft_compose::rule_baseline(style, l, g)); + // One bar per stretch, as the PDF export draws them: a bar per glyph left + // anti-aliased seams between the letters. + match bars.last_mut() { + Some((color, tint, last)) + if *color == rule.color + && *tint == rule.tint + && (last.y0 - r.y0).abs() < 1e-6 + && (last.y1 - r.y1).abs() < 1e-6 + && (r.x0 - last.x1).abs() < 0.5 => + { + last.x1 = last.x1.max(r.x1); + } + _ => bars.push((rule.color.clone(), rule.tint, r)), + } } } let outline = db.outline(&g.face, g.gid); From fb296eacac425d643e0fc655f9a4e32b73266568 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sun, 11 Oct 2026 20:25:49 +0200 Subject: [PATCH 20/48] fix(ui): View items in the View menu, laid out like InDesign's Rotate Spread, Proof Setup, Proof Colors, Flattener Preview, Show Tag Markers, Separations Preview and Show/Hide Tagged Frames were at the end of the Type menu. They are in View now, which follows InDesign's order: Overprint Preview and proofing, zoom, Rotate Spread, Screen Mode, Display Performance, rulers, Extras (frame edges, text threads), Grids & Guides and Structure (tagged frames, tag markers). Show Hidden Characters stays in Type, as in InDesign; W still toggles Preview. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/i18n.rs | 3 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/menus.rs | 104 +++++++++++++++++++++++----------- 4 files changed, 77 insertions(+), 32 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..6dcb644fb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Newest first. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. +- **2026-10-11** · The View menu follows InDesign's: proofing first, then zoom, Rotate Spread, Screen Mode, Display Performance, rulers, Extras, Grids & Guides and Structure; the view items that sat at the end of the Type menu moved there. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5976d7d07..b6898c7f4 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2811,6 +2811,9 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Paragraph Rules", ["Absatzlinien", "Filets de paragraphe", "Filetes de párrafo", "段落境界線", "段落线"]), ("Rule Below", ["Linie unterhalb", "Filet après", "Filete después", "後境界線", "下方横线"]), ("Tint:", ["Farbton:", "Teinte:", "Matiz:", "濃淡:", "色调:"]), + ("Slug", ["Infobereich", "Ligne-bloc", "Indicaciones", "印刷可能領域", "辅助信息区"]), + ("Presentation", ["Präsentation", "Présentation", "Presentación", "プレゼンテーション", "演示文稿"]), + ("Extras", ["Extras", "Options supplémentaires", "Extras", "エクストラ", "附加"]), ("Column", ["Spalte", "Colonne", "Columna", "段", "栏"]), ]; diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 34766cf22..7747e87ec 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1493,4 +1493,5 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paragraph Rules", "Filetti paragrafo"), ("Rule Below", "Filetto sotto"), ("Tint:", "Tinta:"), + ("Presentation", "Presentazione"), ]; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index cb1422394..b0049865d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -403,34 +403,6 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:type.sizeDown", "-", "ui:view.hiddenCharacters", - "ui:view.taggedFrames", - ">Rotate Spread", - "ui:view.rotateSpread|90° CW|{\"angle\": 90}", - "ui:view.rotateSpread|90° CCW|{\"angle\": -90}", - "ui:view.rotateSpread|180°|{\"angle\": 180}", - "ui:view.rotateSpread|Clear Rotation|{\"angle\": 0}", - "<", - ">Proof Setup", - "ui:view.proofSetup|Working CMYK|{\"target\": \"workingCmyk\"}", - "ui:view.proofSetup|Internet Standard RGB (sRGB)|{\"target\": \"srgb\"}", - "ui:view.proofSetup|Legacy Macintosh RGB|{\"target\": \"legacyMacRgb\"}", - "ui:view.proofSetup|Monitor RGB|{\"target\": \"monitorRgb\"}", - "ui:view.proofSetup|Color Blindness – Protanopia|{\"target\": \"protanopia\"}", - "ui:view.proofSetup|Color Blindness – Deuteranopia|{\"target\": \"deuteranopia\"}", - "-", - "ui:view.proofSetup|Simulate Paper Color|{\"simulatePaper\": \"toggle\"}", - "<", - "ui:view.proofColors", - "ui:view.flattenerPreview", - "ui:view.tagMarkers", - ">Separations Preview", - "ui:view.separations|Off|{\"plate\": null, \"inkLimit\": null}", - "ui:view.separations|Cyan|{\"plate\": \"cyan\"}", - "ui:view.separations|Magenta|{\"plate\": \"magenta\"}", - "ui:view.separations|Yellow|{\"plate\": \"yellow\"}", - "ui:view.separations|Black|{\"plate\": \"black\"}", - "ui:view.separations|Ink Limit 300%|{\"inkLimit\": 300}", - "<", "-", ">Hyperlinks & Cross-References", "cmd:hyperlink.create", @@ -620,6 +592,28 @@ pub const MENUS: &[(&str, &[&str])] = &[ ( "View", &[ + "ui:view.overprintPreview", + ">Proof Setup", + "ui:view.proofSetup|Working CMYK|{\"target\": \"workingCmyk\"}", + "ui:view.proofSetup|Internet Standard RGB (sRGB)|{\"target\": \"srgb\"}", + "ui:view.proofSetup|Legacy Macintosh RGB|{\"target\": \"legacyMacRgb\"}", + "ui:view.proofSetup|Monitor RGB|{\"target\": \"monitorRgb\"}", + "ui:view.proofSetup|Color Blindness – Protanopia|{\"target\": \"protanopia\"}", + "ui:view.proofSetup|Color Blindness – Deuteranopia|{\"target\": \"deuteranopia\"}", + "-", + "ui:view.proofSetup|Simulate Paper Color|{\"simulatePaper\": \"toggle\"}", + "<", + "ui:view.proofColors", + ">Separations Preview", + "ui:view.separations|Off|{\"plate\": null, \"inkLimit\": null}", + "ui:view.separations|Cyan|{\"plate\": \"cyan\"}", + "ui:view.separations|Magenta|{\"plate\": \"magenta\"}", + "ui:view.separations|Yellow|{\"plate\": \"yellow\"}", + "ui:view.separations|Black|{\"plate\": \"black\"}", + "ui:view.separations|Ink Limit 300%|{\"inkLimit\": 300}", + "<", + "ui:view.flattenerPreview", + "-", "ui:view.zoomIn", "ui:view.zoomOut", "ui:view.fitPage", @@ -628,8 +622,20 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:view.actualSize", "ui:view.entirePasteboard", "-", - "ui:view.overprintPreview", - "ui:view.togglePreview", + ">Rotate Spread", + "ui:view.rotateSpread|90° CW|{\"angle\": 90}", + "ui:view.rotateSpread|90° CCW|{\"angle\": -90}", + "ui:view.rotateSpread|180°|{\"angle\": 180}", + "ui:view.rotateSpread|Clear Rotation|{\"angle\": 0}", + "<", + "-", + ">Screen Mode", + "ui:view.screenMode|Normal|{\"mode\": \"normal\"}", + "ui:view.screenMode|Preview|{\"mode\": \"preview\"}", + "ui:view.screenMode|Bleed|{\"mode\": \"bleed\"}", + "ui:view.screenMode|Slug|{\"mode\": \"slug\"}", + "ui:view.screenMode|Presentation|{\"mode\": \"presentation\"}", + "<", ">Display Performance", "ui:view.fastDisplay", "ui:view.typicalDisplay", @@ -637,10 +643,11 @@ pub const MENUS: &[(&str, &[&str])] = &[ "<", "-", "ui:view.rulers", + "-", + ">Extras", "ui:view.frameEdges", "ui:view.textThreads", - "ui:view.hiddenCharacters", - "-", + "<", ">Grids & Guides", "ui:view.guides", "ui:view.snapToGuides", @@ -650,6 +657,11 @@ pub const MENUS: &[(&str, &[&str])] = &[ "-", "ui:app.deleteAllGuides", "<", + "-", + ">Structure", + "ui:view.taggedFrames", + "ui:view.tagMarkers", + "<", ], ), ( @@ -2279,6 +2291,34 @@ pub fn quick_apply_items(session: &designcraft_engine::Session, query: &str) -> mod tests { use super::*; + /// The entries (command ids) of a menu bar menu, submenus included. + fn menu_ids(title: &str) -> Vec<&'static str> { + let items = MENUS.iter().find(|(t, _)| *t == title).map(|(_, items)| *items).unwrap_or_default(); + items.iter().filter_map(|e| e.split_once(':').map(|(_, rest)| rest.split('|').next().unwrap_or(rest))).collect() + } + + #[test] + fn view_items_are_in_the_view_menu_as_in_indesign() { + let (view, typ) = (menu_ids("View"), menu_ids("Type")); + for id in [ + "view.rotateSpread", + "view.proofSetup", + "view.proofColors", + "view.flattenerPreview", + "view.separations", + "view.taggedFrames", + "view.tagMarkers", + ] { + assert!(view.contains(&id), "{id} is in View"); + assert!(!typ.contains(&id), "{id} isn't in Type"); + } + // Screen Mode and Extras are View submenus; Show Hidden Characters stays in Type. + assert!(view.contains(&"view.screenMode") && view.contains(&"view.frameEdges")); + assert!(typ.contains(&"view.hiddenCharacters") && !view.contains(&"view.hiddenCharacters")); + // W still toggles Normal / Preview without a menu entry. + assert_eq!(ui_label("view.togglePreview").and_then(|c| c.1), Some("W")); + } + fn walk(items: &[Item], out: &mut Vec<(String, String, Value)>) { for it in items { match it { From 9ac6f522bb31d4a139af56f4f97f32b981bdfd19 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 00:09:46 +0200 Subject: [PATCH 21/48] fix(ui): Screen Mode checks the current mode; Japanese View submenu paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit View › Screen Mode showed no check mark (checked() had no view.screenMode arm), so with the Normal/Preview row gone nothing showed the current mode. ja.rs still translated the old Type/Proof Setup, Type/Rotate Spread and Type/Separations Preview paths; it now has the View/… paths, Screen Mode, Extras and Structure included. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ja.rs | 9 +++++--- crates/ui-egui/src/menus.rs | 41 +++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index de57db5e9..1c220bdd6 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1550,15 +1550,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Type/Insert Special Character/Quotation Marks", "書式/特殊文字を挿入/引用符"), ("Type/Insert Special Character/Symbols", "書式/特殊文字を挿入/記号"), ("Type/Insert White Space", "書式/空白文字を挿入"), - ("Type/Proof Setup", "書式/カラー校正設定"), - ("Type/Rotate Spread", "書式/スプレッドを回転"), - ("Type/Separations Preview", "書式/分版プレビュー"), ("Type/Story Direction", "書式/組み方向"), ("Type/Text Variables", "書式/テキスト変数"), ("Type/Track Changes", "書式/変更を記録"), ("Type/Type on a Path", "書式/パス上のテキスト"), ("View/Display Performance", "表示/表示品質"), + ("View/Extras", "表示/表示補助"), ("View/Grids & Guides", "表示/グリッドとガイド"), + ("View/Proof Setup", "表示/カラー校正設定"), + ("View/Rotate Spread", "表示/スプレッドを回転"), + ("View/Screen Mode", "表示/スクリーンモード"), + ("View/Separations Preview", "表示/分版プレビュー"), + ("View/Structure", "表示/構造"), ("Window/Arrange", "ウィンドウ/配置"), ("Window/Interactive", "ウィンドウ/インタラクティブ"), ("Window/Interface Color Theme", "ウィンドウ/インターフェイスのカラーテーマ"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b0049865d..4de5d0d0d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1793,6 +1793,17 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option { "view.proofColors" => app.ui.proof_colors, "view.flattenerPreview" => app.ui.flattener_preview, "view.tagMarkers" => app.ui.tag_markers, + "view.screenMode" => { + let mode = match params.get("mode").and_then(Value::as_str) { + Some("normal") => crate::ScreenMode::Normal, + Some("preview") => crate::ScreenMode::Preview, + Some("bleed") => crate::ScreenMode::Bleed, + Some("slug") => crate::ScreenMode::Slug, + Some("presentation") => crate::ScreenMode::Presentation, + _ => return None, + }; + app.ui.screen_mode == mode + } "view.rotateSpread" => match params.get("angle").and_then(Value::as_i64) { Some(0) => app.session.active().is_some_and(|st| st.doc.spreads.iter().all(|sp| sp.pages.first().is_none_or(|p| p.view_rotation == 0))), _ => return None, @@ -2319,6 +2330,36 @@ mod tests { assert_eq!(ui_label("view.togglePreview").and_then(|c| c.1), Some("W")); } + /// The submenu paths of a menu bar menu ("View/Screen Mode"), as Edit › Menus keys them. + fn submenu_paths(title: &str) -> Vec { + let items = MENUS.iter().find(|(t, _)| *t == title).map(|(_, items)| *items).unwrap_or_default(); + let mut stack = vec![title.to_string()]; + let mut out = Vec::new(); + for e in items { + if let Some(name) = e.strip_prefix('>') { + let path = format!("{}/{name}", stack.last().map_or("", String::as_str)); + out.push(path.clone()); + stack.push(path); + } else if *e == "<" { + stack.pop(); + } + } + out + } + + #[test] + fn screen_mode_shows_the_current_mode_and_view_submenus_are_translated() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let mode = |app: &DesignApp, m: &str| checked(app, "view.screenMode", &json!({ "mode": m })); + assert_eq!((mode(&app, "normal"), mode(&app, "preview")), (Some(true), Some(false))); + app.ui.screen_mode = crate::ScreenMode::Preview; + assert_eq!((mode(&app, "normal"), mode(&app, "preview")), (Some(false), Some(true))); + // Every View submenu has its Japanese path (Edit › Menus shows them). + for path in submenu_paths("View") { + assert_ne!(crate::i18n::tr("ja", &path), path, "{path} has no Japanese row"); + } + } + fn walk(items: &[Item], out: &mut Vec<(String, String, Value)>) { for it in items { match it { From 0022b3a89bdff6ad99a050d73152225281a46df3 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sun, 11 Oct 2026 20:16:44 +0200 Subject: [PATCH 22/48] feat(ui): Pathfinder section in Properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With several objects selected, InDesign's Properties panel shows Pathfinder: Add, Subtract, Intersect, Exclude Overlap and Minus Back. DesignCraft had them only in Object › Pathfinder and the Pathfinder panel. The section sits after Align, with code-drawn icons. Section titles and icon buttons in Properties now have accessible names (the title; the button's tooltip), so screen readers can tell them apart. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/icons.rs | 32 ++++++++++++ crates/ui-egui/src/panels/properties.rs | 69 +++++++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 6 ++- 4 files changed, 107 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..add862906 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Newest first. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. +- **2026-10-11** · Properties gains InDesign's Pathfinder section (Add, Subtract, Intersect, Exclude Overlap, Minus Back) with several objects selected; section titles and icon buttons have accessible names. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 69415e9b3..3021e7d3b 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -495,6 +495,38 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { _ => {} } } + // Pathfinder: a back square (top left) and a front one (bottom right), the result filled. + "pf-add" | "pf-subtract" | "pf-intersect" | "pf-exclude" | "pf-minus-back" => { + let (back, front) = ((3.0, 3.0, 13.0, 13.0), (7.0, 7.0, 17.0, 17.0)); + match name { + "pf-add" => { + pen.frect(back.0, back.1, back.2, back.3); + pen.frect(front.0, front.1, front.2, front.3); + } + "pf-subtract" => { + pen.frect(3.0, 3.0, 13.0, 7.0); + pen.frect(3.0, 7.0, 7.0, 13.0); + pen.rect(front.0, front.1, front.2, front.3); + } + "pf-intersect" => { + pen.rect(back.0, back.1, back.2, back.3); + pen.rect(front.0, front.1, front.2, front.3); + pen.frect(7.0, 7.0, 13.0, 13.0); + } + "pf-exclude" => { + pen.frect(3.0, 3.0, 13.0, 7.0); + pen.frect(3.0, 7.0, 7.0, 13.0); + pen.frect(13.0, 7.0, 17.0, 17.0); + pen.frect(7.0, 13.0, 13.0, 17.0); + pen.rect(7.0, 7.0, 13.0, 13.0); + } + _ => { + pen.rect(back.0, back.1, back.2, back.3); + pen.frect(13.0, 7.0, 17.0, 17.0); + pen.frect(7.0, 13.0, 13.0, 17.0); + } + } + } // ---- Properties panel glyphs (original drawings) "orient-portrait" => { pen.rect(5.5, 3.0, 14.5, 17.0); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 49cb920f2..baf167447 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -121,6 +121,11 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { section(ui, crate::i18n::tr(&app.ui.language, "Align"), true); align_row(app, ui); divider(ui); + if i.count > 1 { + section(ui, crate::i18n::tr(&app.ui.language, "Pathfinder"), true); + pathfinder_row(app, ui); + divider(ui); + } if i.is_text && i.count == 1 { section(ui, crate::i18n::tr(&app.ui.language, "Text Frame"), true); text_frame_section(app, ui); @@ -1724,6 +1729,27 @@ pub fn wrap_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }); } +/// The Pathfinder operations, in the order of InDesign's Pathfinder buttons. +const PATHFINDER: [(&str, &str, &str); 5] = [ + ("pf-add", "add", "Add"), + ("pf-subtract", "subtract", "Subtract"), + ("pf-intersect", "intersect", "Intersect"), + ("pf-exclude", "exclude", "Exclude Overlap"), + ("pf-minus-back", "minusBack", "Minus Back"), +]; + +/// Properties › Pathfinder (several objects selected): combine them into one shape. +fn pathfinder_row(app: &mut DesignApp, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 8.0; + for (icon, op, tip) in PATHFINDER { + if icon_toggle(ui, icon, false, crate::i18n::tr(&app.ui.language, tip)).clicked() { + let _ = app.run("object.pathfinder", json!({"op": op})); + } + } + }); +} + /// Pathfinder panel: combine the selected shapes; make or release compound paths. pub fn pathfinder_panel(app: &mut DesignApp, ui: &mut egui::Ui) { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Pathfinder")).strong()); @@ -2207,4 +2233,47 @@ mod tests { let after = h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().fill.swatch.clone(); assert_eq!(after, name, "clicking the swatch chip applies it"); } + + fn panel(app: DesignApp) -> Harness<'static, DesignApp> { + let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + h + } + + #[test] + fn several_shapes_show_pathfinder_and_intersect_combines_them() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [100, 100, 300, 300], "shape": "ellipse"})).unwrap()["id"].clone(); + let b = app.run("frame.create", json!({"rect": [200, 100, 300, 200]})).unwrap()["id"].clone(); + // One shape: no Pathfinder section. + app.run("selection.set", json!({"ids": [a]})).unwrap(); + let mut h = panel(app); + assert!(h.query_by_label("Pathfinder").is_none()); + // Two: the section and its five buttons; Intersect leaves one shape, the ellipse's quarter. + h.state_mut().run("selection.set", json!({"ids": [a, b]})).unwrap(); + h.run_steps(3); + assert!(h.query_by_label("Pathfinder").is_some()); + for op in ["Add", "Subtract", "Intersect", "Exclude Overlap", "Minus Back"] { + assert!(h.query_by_label(op).is_some(), "{op}"); + } + h.get_by_label("Intersect").click(); + h.run_steps(3); + let doc = &h.state().session.active().unwrap().doc; + let items = &doc.spreads[0].items; + assert_eq!(items.len(), 1, "one shape left"); + let r = items[0].bounds(); + assert!((r.x0 - 200.0).abs() < 0.5 && (r.y1 - 200.0).abs() < 0.5 && (r.x1 - 300.0).abs() < 0.5, "{r:?}"); + } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index b13627c9c..bcb27ddf9 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -408,8 +408,10 @@ pub fn selection_band(ui: &mut Ui, title: &str) { pub fn section(ui: &mut Ui, title: &str, _default_open: bool) -> bool { let t = Tokens::get(ui.ctx()); ui.add_space(7.0); - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 16.0), Sense::hover()); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 16.0), Sense::hover()); crate::rtl::paint(ui.painter(), r.left_center(), egui::Align2::LEFT_CENTER, title, egui::FontId::proportional(11.5), t.text_strong); + // Screen readers hear the section's title. + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, title)); ui.add_space(3.0); true } @@ -525,6 +527,8 @@ pub fn icon_toggle_sized(ui: &mut Ui, icon: &str, on: bool, tip: &str, size: egu } let s = (size.y - 4.0).min(17.0); crate::icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(s, s)), icon, if on { t.text_strong } else { t.icon }); + // Named by its tooltip, for screen readers. + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, tip)); if tip.is_empty() { resp } else { From a175245f95fcb946d793d967d3f1a96b096e053f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sun, 11 Oct 2026 20:00:52 +0200 Subject: [PATCH 23/48] fix(geom): Corner Options keep curves and round every corner point Corners were applied only where two straight segments meet, and every other segment was redrawn as a straight line: a Pathfinder quarter circle with rounded corners rendered as a triangle with one round corner. Corner points are now where the path turns; the sides next to a corner are trimmed along their length, curves included, and stay curves. Smooth points are left alone; rectangles are unchanged. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/geom/src/corners.rs | 152 +++++++++++++++++++++++++++---------- 2 files changed, 112 insertions(+), 41 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..ecadb88fa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Newest first. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. +- **2026-10-11** · Corner Options keep curved sides curved and round every corner point, including where an arc meets a line (a Pathfinder quarter circle with rounded corners stayed a triangle). No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/geom/src/corners.rs b/crates/geom/src/corners.rs index df1fd0fa9..6e8ea32e8 100644 --- a/crates/geom/src/corners.rs +++ b/crates/geom/src/corners.rs @@ -1,9 +1,10 @@ //! Live corner options (Object → Corner Options): rounded, inverse rounded, inset, bevel, fancy. //! -//! Corners are applied at render time to the straight-segment corners of a closed path, so the -//! frame keeps its simple editable geometry (as in InDesign). +//! Corners are applied at render time to the corner points of a closed path (where its direction +//! turns), so the frame keeps its simple editable geometry (as in InDesign). Curved sides stay +//! curved: a corner where an arc meets a line trims the arc along its length. -use kurbo::{BezPath, Point, Vec2}; +use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen, Point, Vec2}; use serde::{Deserialize, Serialize}; use crate::path::{PathData, SubPath}; @@ -63,8 +64,9 @@ impl CornerOptions { } } -/// Apply corner options to `path`. Corners are applied to anchors joining two straight segments of -/// closed subpaths; other anchors are kept. Anchor `i` of every subpath uses `opts.corners[i % 4]`. +/// Apply corner options to `path`. Corners are applied to the corner points of closed subpaths +/// (where the incoming and outgoing directions differ, curved sides included); smooth points are +/// kept. Anchor `i` of every subpath uses `opts.corners[i % 4]`. pub fn apply(path: &PathData, opts: &CornerOptions) -> BezPath { if opts.is_none() { return path.to_bezpath(); @@ -76,50 +78,76 @@ pub fn apply(path: &PathData, opts: &CornerOptions) -> BezPath { out } +/// Arc length accuracy (points) when trimming curved sides. +const ARC_ACCURACY: f64 = 1e-3; +/// Directions closer than this (cosine, about 1°) are a smooth point, not a corner. +const SMOOTH: f64 = 0.9998; + +/// The unit direction a segment leaves its start in (None for a point). +fn start_dir(c: &CubicBez) -> Option { + [c.p1, c.p2, c.p3].into_iter().map(|q| q - c.p0).find(|d| d.hypot() > 1e-9).map(|d| d / d.hypot()) +} + +/// The unit direction a segment arrives at its end in (None for a point). +fn end_dir(c: &CubicBez) -> Option { + [c.p2, c.p1, c.p0].into_iter().map(|q| c.p3 - q).find(|d| d.hypot() > 1e-9).map(|d| d / d.hypot()) +} + +/// Does the path turn where `prev` ends and `next` starts? +fn is_corner(prev: &CubicBez, next: &CubicBez) -> bool { + matches!((end_dir(prev), start_dir(next)), (Some(a), Some(b)) if a.dot(b) < SMOOTH) +} + fn apply_subpath(sp: &SubPath, opts: &CornerOptions, out: &mut BezPath) { let n = sp.anchors.len(); - let straight_corner = |i: usize| { - let a = &sp.anchors[i]; - sp.closed && !a.has_in() && !a.has_out() - }; - if !sp.closed || n < 3 || !(0..n).any(straight_corner) { + if !sp.closed || n < 2 { sp.to_bezpath_into(out); return; } - let pts: Vec = sp.anchors.iter().map(|a| a.p).collect(); - let mut first = true; - for i in 0..n { - let c = opts.corners[i % 4]; - let p = pts[i]; - let prev = pts[(i + n - 1) % n]; - let next = pts[(i + 1) % n]; - if c.is_none() || !straight_corner(i) || !straight_corner((i + n - 1) % n) && sp.anchors[(i + n - 1) % n].has_out() { - if first { - out.move_to(p); - first = false; - } else { - out.line_to(p); + // Segment `i` runs from anchor `i` to anchor `i + 1`; the corner at anchor `i` joins segments + // `i - 1` and `i`, and takes at most half of each. + let segs: Vec = (0..n).map(|i| sp.segment(i)).collect(); + let lens: Vec = segs.iter().map(|c| c.arclen(ARC_ACCURACY)).collect(); + let size: Vec = (0..n) + .map(|i| { + let c = opts.corners[i % 4]; + let prev = (i + n - 1) % n; + if c.is_none() || !is_corner(&segs[prev], &segs[i]) { + return 0.0; } - continue; - } - let din = prev - p; - let dout = next - p; - let (lin, lout) = (din.hypot(), dout.hypot()); - if lin < 1e-9 || lout < 1e-9 { - continue; - } - let s = c.size.min(lin / 2.0).min(lout / 2.0); - let ui = din / lin; - let uo = dout / lout; - let a = p + ui * s; // entry point on incoming edge - let b = p + uo * s; // exit point on outgoing edge - if first { - out.move_to(a); - first = false; + c.size.min(lens[prev] / 2.0).min(lens[i] / 2.0).max(0.0) + }) + .collect(); + if size.iter().all(|s| *s <= 0.0) { + sp.to_bezpath_into(out); + return; + } + // Each segment without the parts its corners take. + let trimmed: Vec = (0..n) + .map(|i| { + let (s0, s1) = (size[i], size[(i + 1) % n]); + let t0 = if s0 > 0.0 { segs[i].inv_arclen(s0, ARC_ACCURACY) } else { 0.0 }; + let t1 = if s1 > 0.0 { segs[i].inv_arclen(lens[i] - s1, ARC_ACCURACY) } else { 1.0 }; + segs[i].subsegment(t0..t1.max(t0)) + }) + .collect(); + out.move_to(trimmed[0].p0); + for i in 0..n { + let seg = &trimmed[i]; + if sp.segment_is_line(i) { + out.line_to(seg.p3); } else { - out.line_to(a); + out.curve_to(seg.p1, seg.p2, seg.p3); + } + // The corner at the segment's end, from where this segment stops to where the next starts. + let j = (i + 1) % n; + let next = &trimmed[j]; + if size[j] > 0.0 { + match (end_dir(seg).or_else(|| end_dir(&segs[i])), start_dir(next).or_else(|| start_dir(&segs[j]))) { + (Some(ta), Some(tb)) => corner_shape(out, opts.corners[j % 4].shape, sp.anchors[j].p, seg.p3, next.p0, -ta, tb, size[j]), + _ => out.line_to(next.p0), + } } - corner_shape(out, c.shape, p, a, b, ui, uo, s); } out.close_path(); } @@ -185,6 +213,48 @@ mod tests { assert!((a - std::f64::consts::PI * 100.0).abs() < 1.0, "{a}"); } + fn nearest(bp: &BezPath, p: Point) -> f64 { + use kurbo::ParamCurveNearest; + bp.segments().map(|s| s.nearest(p, 1e-6).distance_sq.sqrt()).fold(f64::INFINITY, f64::min) + } + + /// A quarter disc of radius 100 centred on (0, 100): its arc from (0, 0) to (100, 100). + fn quarter_disc() -> PathData { + let k = 100.0 * crate::shapes::KAPPA; + let mut a0 = crate::path::Anchor::corner(Point::new(0.0, 0.0)); + a0.h_out = Point::new(k, 0.0); + let mut a1 = crate::path::Anchor::corner(Point::new(100.0, 100.0)); + a1.h_in = Point::new(100.0, 100.0 - k); + let a2 = crate::path::Anchor::corner(Point::new(0.0, 100.0)); + PathData::new(vec![SubPath { anchors: vec![a0, a1, a2], closed: true }]) + } + + #[test] + fn rounded_corners_keep_curved_sides_and_round_where_an_arc_meets_a_line() { + let p = quarter_disc(); + let bp = apply(&p, &CornerOptions::uniform(CornerShape::Rounded, 20.0)); + // Still a quarter disc (area pi/4 * 100^2 = 7854), less three small rounded corners; a + // triangle would be 5000. + let area = bp.area().abs(); + assert!(area > 7300.0 && area < 7854.0, "area {area}"); + // The middle of the arc is kept. + let mid = Point::new(100.0 * std::f64::consts::FRAC_1_SQRT_2, 100.0 - 100.0 * std::f64::consts::FRAC_1_SQRT_2); + assert!(nearest(&bp, mid) < 0.05, "{}", nearest(&bp, mid)); + // All three corners are rounded off: the line-line one and both where the arc meets a line. + for corner in [Point::new(0.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)] { + assert!(nearest(&bp, corner) > 3.0, "{corner:?} is still sharp"); + } + } + + #[test] + fn smooth_points_are_not_corners() { + let e = crate::shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 60.0)); + for shape in CornerShape::ALL.into_iter().skip(1) { + let bp = apply(&e, &CornerOptions::uniform(shape, 20.0)); + assert!((bp.area().abs() - e.to_bezpath().area().abs()).abs() < 1e-6, "{shape:?}"); + } + } + #[test] fn size_is_clamped_to_half_edge() { let r = rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)); From 5eda7cebbcb8479d58bf26e95517bad43d7a89db Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sun, 11 Oct 2026 23:57:34 +0200 Subject: [PATCH 24/48] fix(geom): Trim straight sides exactly for Corner Options Straight sides were trimmed through inv_arclen at 1e-3 accuracy, so a rectangle's corner points landed up to about 1e-4 pt off. Lines are now measured and trimmed along the chord, exactly; curves still use arc length. Co-Authored-By: Claude Opus 5.5 --- crates/geom/src/corners.rs | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/geom/src/corners.rs b/crates/geom/src/corners.rs index 6e8ea32e8..6b0c9cb0a 100644 --- a/crates/geom/src/corners.rs +++ b/crates/geom/src/corners.rs @@ -107,7 +107,9 @@ fn apply_subpath(sp: &SubPath, opts: &CornerOptions, out: &mut BezPath) { // Segment `i` runs from anchor `i` to anchor `i + 1`; the corner at anchor `i` joins segments // `i - 1` and `i`, and takes at most half of each. let segs: Vec = (0..n).map(|i| sp.segment(i)).collect(); - let lens: Vec = segs.iter().map(|c| c.arclen(ARC_ACCURACY)).collect(); + let lines: Vec = (0..n).map(|i| sp.segment_is_line(i)).collect(); + // Straight sides are measured (and trimmed below) exactly; curves by arc length. + let lens: Vec = segs.iter().zip(&lines).map(|(c, line)| if *line { (c.p3 - c.p0).hypot() } else { c.arclen(ARC_ACCURACY) }).collect(); let size: Vec = (0..n) .map(|i| { let c = opts.corners[i % 4]; @@ -126,6 +128,12 @@ fn apply_subpath(sp: &SubPath, opts: &CornerOptions, out: &mut BezPath) { let trimmed: Vec = (0..n) .map(|i| { let (s0, s1) = (size[i], size[(i + 1) % n]); + if lines[i] { + let c = &segs[i]; + let u = if lens[i] > 1e-12 { (c.p3 - c.p0) / lens[i] } else { Vec2::ZERO }; + let (a, b) = (c.p0 + u * s0, c.p3 - u * s1); + return CubicBez::new(a, a, b, b); + } let t0 = if s0 > 0.0 { segs[i].inv_arclen(s0, ARC_ACCURACY) } else { 0.0 }; let t1 = if s1 > 0.0 { segs[i].inv_arclen(lens[i] - s1, ARC_ACCURACY) } else { 1.0 }; segs[i].subsegment(t0..t1.max(t0)) @@ -134,7 +142,7 @@ fn apply_subpath(sp: &SubPath, opts: &CornerOptions, out: &mut BezPath) { out.move_to(trimmed[0].p0); for i in 0..n { let seg = &trimmed[i]; - if sp.segment_is_line(i) { + if lines[i] { out.line_to(seg.p3); } else { out.curve_to(seg.p1, seg.p2, seg.p3); @@ -246,6 +254,25 @@ mod tests { } } + #[test] + fn straight_sides_are_trimmed_exactly() { + let r = rectangle(Rect::new(0.0, 0.0, 100.0, 60.0)); + let bp = apply(&r, &CornerOptions::uniform(CornerShape::Bevel, 10.0)); + // Every point a bevelled rectangle goes through sits exactly 10 pt from a corner. + let pts: Vec = bp + .elements() + .iter() + .filter_map(|e| match e { + kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) => Some(*p), + _ => None, + }) + .collect(); + assert_eq!(pts.len(), 9, "{pts:?}"); + for p in pts { + assert!([0.0, 10.0, 90.0, 100.0].contains(&p.x) && [0.0, 10.0, 50.0, 60.0].contains(&p.y), "{p:?}"); + } + } + #[test] fn smooth_points_are_not_corners() { let e = crate::shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 60.0)); From 84158e36d34003a84206eeba695289e2342f1186 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 19:32:28 +0000 Subject: [PATCH 25/48] Document tabs: long names are shortened so the close button stays visible A tab is at most 260 px wide. A longer document name ends in an ellipsis (cut at a grapheme boundary, RTL names included), the dirty marker and zoom stay readable, and the full name shows in the tab's tooltip. Tabs and their close buttons carry accessible labels. Fixes #603 --- crates/ui-egui/src/chrome.rs | 55 +++++++++++++++++++++++++++++++++--- crates/ui-egui/src/rtl.rs | 8 ++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 2896fcdc4..c2f1cb48e 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -489,9 +489,17 @@ fn control_text(app: &mut DesignApp, ui: &mut egui::Ui) { }); } +/// The widest a document tab grows: a longer name is shortened so its close button and the +/// tabs after it stay in view. +const DOC_TAB_MAX_W: f32 = 260.0; +/// Close button and padding around a tab's title. +const DOC_TAB_PAD: f32 = 44.0; + pub fn doc_tabs(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::hover()); + let max_w = DOC_TAB_MAX_W.min(bar.width()); + let close_label = crate::i18n::tr(&app.ui.language, "Close"); ui.painter().rect_filled(bar, 0.0, t.tab_strip); ui.painter().line_segment([bar.left_bottom(), bar.right_bottom()], Stroke::new(1.0, t.border)); let mut x = bar.min.x; @@ -504,14 +512,28 @@ pub fn doc_tabs(app: &mut DesignApp, ui: &mut egui::Ui) { let is_active = Some(i) == active; // `*` = unsaved; the view-mode suffix only in Preview (InDesign shows its GPU mode there). let suffix = if is_active && app.ui.screen_mode == crate::ScreenMode::Preview { " [Preview]" } else { "" }; - let label = format!("{}{} @ {:.0}%{suffix}", if *dirty { "*" } else { "" }, crate::rtl::isolate(title), zoom * 100.0); - let galley = crate::rtl::plain(ui.ctx(), &label, semibold(11.5), if is_active { t.text_strong } else { t.text_dim }); - let w = (galley.size().x + 44.0).max(if is_active { 210.0 } else { 150.0 }); + let color = if is_active { t.text_strong } else { t.text_dim }; + // The name gives way first; the dirty marker and zoom stay readable. + let zoom_label = crate::rtl::plain(ui.ctx(), &format!(" @ {:.0}%{suffix}", zoom * 100.0), semibold(11.5), color); + let name = format!("{}{}", if *dirty { "*" } else { "" }, crate::rtl::isolate(title)); + let name_max = max_w - DOC_TAB_PAD - zoom_label.size().x; + let galley = crate::rtl::truncated(ui.ctx(), &name, semibold(11.5), color, name_max); + let elided = galley.elided; + let w = (galley.size().x + zoom_label.size().x + DOC_TAB_PAD).max(if is_active { 210.0 } else { 150.0 }).min(max_w); let r = egui::Rect::from_min_size(egui::pos2(x, bar.min.y), vec2(w, 28.0)); let resp = ui.interact(r, ui.id().with(("doctab", i)), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, title)); + let resp = if elided { + resp.on_hover_ui(|ui| { + let _ = crate::rtl::label(ui, title); + }) + } else { + resp + }; ui.painter().rect_filled(r, 0.0, if is_active { t.panel } else { t.tab_strip }); let cr = egui::Rect::from_center_size(egui::pos2(r.min.x + 18.0, r.center().y), vec2(14.0, 14.0)); let cresp = ui.interact(cr, ui.id().with(("docclose", i)), Sense::click()); + cresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{close_label} {title}"))); ui.painter().text( cr.center(), egui::Align2::CENTER_CENTER, @@ -519,7 +541,11 @@ pub fn doc_tabs(app: &mut DesignApp, ui: &mut egui::Ui) { egui::FontId::proportional(14.0), if cresp.hovered() { t.text_strong } else { t.text_dim }, ); - ui.painter().galley(egui::pos2(r.min.x + 32.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE); + let name_w = galley.size().x; + // Clipped too, for a window narrower than the zoom label. + let text = ui.painter().with_clip_rect(r); + text.galley(egui::pos2(r.min.x + 32.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE); + text.galley(egui::pos2(r.min.x + 32.0 + name_w, r.center().y - zoom_label.size().y / 2.0), zoom_label, Color32::WHITE); ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border)); if cresp.clicked() { close = Some(i); @@ -783,6 +809,27 @@ mod tests { h.get_by_label(label).rect() } + /// A very long document name made its tab grow past the window, hiding the close button + /// and the tabs after it (#603). + #[test] + fn long_document_names_keep_tabs_short_and_close_buttons_visible() { + let latin = "Quarterly report with a very long file name ".repeat(6); + let hebrew = "דוח רבעוני עם שם קובץ ארוך מאוד ".repeat(6); + let mut app = app(); + for title in [latin.as_str(), hebrew.as_str(), "Short"] { + app.run("file.new", serde_json::json!({ "title": title })).unwrap(); + } + let h = test_window::open(app, vec2(1200.0, 600.0)); + for title in [latin.as_str(), hebrew.as_str()] { + let tab = rect(&h, title); + assert!(tab.width() <= super::DOC_TAB_MAX_W, "{title:?} tab is {} wide", tab.width()); + let close = rect(&h, &format!("Close {title}")); + assert!(tab.contains_rect(close), "close button {close:?} outside its tab {tab:?}"); + } + let (first, short) = (rect(&h, &latin), rect(&h, "Short")); + assert!(short.min.x - first.min.x <= 2.0 * super::DOC_TAB_MAX_W, "the last tab {short:?} starts too far right"); + } + #[test] fn a_narrow_window_scrolls_the_control_bar() { let w = 700.0; diff --git a/crates/ui-egui/src/rtl.rs b/crates/ui-egui/src/rtl.rs index 2521aae20..a554bc64a 100644 --- a/crates/ui-egui/src/rtl.rs +++ b/crates/ui-egui/src/rtl.rs @@ -76,6 +76,14 @@ pub fn plain(ctx: &egui::Context, text: &str, font: FontId, color: Color32) -> A layout(ctx, LayoutJob::simple_singleline(text.to_owned(), font, color)) } +/// One line no wider than `max_width`: longer text ends in an ellipsis, cut at a grapheme +/// boundary in logical order (`Galley::elided` tells whether it was cut). +pub fn truncated(ctx: &egui::Context, text: &str, font: FontId, color: Color32, max_width: f32) -> Arc { + let mut job = LayoutJob::simple_singleline(text.to_owned(), font, color); + job.wrap = TextWrapping::truncate_at_width(max_width.max(0.0)); + layout(ctx, job) +} + pub fn paint(p: &egui::Painter, pos: Pos2, anchor: Align2, text: &str, font: FontId, color: Color32) { let galley = plain(p.ctx(), text, font, color); let rect = anchor.anchor_rect(Rect::from_min_size(pos, galley.size())); From 17cf5b4e91ebf0db0314e90fc4c4d8b4d7826c3d Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 13:57:37 -0600 Subject: [PATCH 26/48] test(recovery): make failed republishing fixtures deterministic Force actual failed recovery publication at the reserved destination so privileged runners exercise the intended retention path. Keep the normal writer, publication order and existing assertions; enable these fixtures across platforms. Validation: all six local gates and the FreeBSD root VM workspace build/tests. PR #627. --- crates/engine/src/recovery.rs | 48 ++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index 85308be3b..33b9a2a33 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -656,6 +656,10 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> { /// Entries belonging to documents already open in this session's recovery folder are left alone. /// Returns the opened documents' indices. pub fn open(s: &mut Session, dir: &Path) -> Result> { + open_with_publisher(s, dir, |path, bytes| publish_recovery_file(path, bytes, false)) +} + +fn open_with_publisher(s: &mut Session, dir: &Path, mut publish: impl FnMut(&Path, &[u8]) -> Result<()>) -> Result> { #[cfg(not(target_arch = "wasm32"))] { validate_recovery_dir(dir)?; @@ -712,14 +716,14 @@ pub fn open(s: &mut Session, dir: &Path) -> Result> { // clean up every snapshot this document actually owns rather than an unrelated same uid. if let Some(state) = s.docs.get_mut(index) { let (document, metadata) = files(&destination, state.uid); - let publish = designcraft_format::save(state.doc.as_ref()) + let published = designcraft_format::save(state.doc.as_ref()) .map_err(|e| EngineError::Other(e.to_string())) - .and_then(|bytes| publish_recovery_file(&document, &bytes, false)); - match publish { + .and_then(|bytes| publish(&document, &bytes)); + match published { Ok(()) => { state.recovery_entries.push((destination.clone(), state.uid)); let meta = json!({"path": state.path, "title": state.title(), "saved": designcraft_doc::vars::now()}); - match publish_recovery_file(&metadata, meta.to_string().as_bytes(), false) { + match publish(&metadata, meta.to_string().as_bytes()) { Ok(()) => { discard(dir, uid); state.recovery_entries = vec![(destination.clone(), state.uid)]; @@ -801,20 +805,32 @@ mod tests { assert_eq!(list(dir.path()).len(), 1); } - #[cfg(unix)] + /// Directory collisions fail even for privileged runners. Creating the blocker in the + /// publisher occurs after UID reservation, which would otherwise skip an occupied path. + fn open_with_failed_republication(s: &mut Session, dir: &Path) -> Result> { + let mut attempts = 0; + let opened = open_with_publisher(s, dir, |path, bytes| { + assert_eq!(path.extension().and_then(|extension| extension.to_str()), Some("designcraft")); + std::fs::create_dir(path).unwrap(); + let result = publish_recovery_file(path, bytes, false); + std::fs::remove_dir(path).unwrap(); + assert!(result.is_err(), "a directory must obstruct actual recovery publication"); + attempts += 1; + result + }); + assert_eq!(attempts, 1, "the fixture must exercise the actual failed publication"); + opened + } + #[test] fn a_failed_recovery_rewrite_preserves_the_original() { - use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().unwrap(); assert_eq!(save(&unsaved("keep the original"), dir.path()).unwrap(), 1); let entries = list(dir.path()); let original = files(dir.path(), entries[0].0).0; let bytes = std::fs::read(&original).unwrap(); - std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap(); let mut reopened = Session::new(); - let result = open(&mut reopened, dir.path()); - // Restore before assertions so a failing test does not leave an unremovable fixture. - std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let result = open_with_failed_republication(&mut reopened, dir.path()); assert_eq!(result.unwrap().len(), 1); assert_eq!(texts(&reopened), ["keep the original"]); assert_eq!(std::fs::read(original).unwrap(), bytes); @@ -918,9 +934,7 @@ mod tests { } } - #[cfg(unix)] fn failed_republication_session() -> (Session, tempfile::TempDir, u64) { - use std::os::unix::fs::PermissionsExt; let root = tempfile::tempdir().unwrap(); let mut session = Session::new(); session.execute("file.new", &json!({})).unwrap(); @@ -929,15 +943,12 @@ mod tests { original.docs[0].uid = live_uid; save(&original, root.path()).unwrap(); session.recovery_dir = Some(root.path().to_path_buf()); - std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o500)).unwrap(); - let recovered = open(&mut session, root.path()); - std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let recovered = open_with_failed_republication(&mut session, root.path()); assert_eq!(recovered.unwrap().len(), 1); assert_ne!(session.docs[1].uid, live_uid); (session, root, live_uid) } - #[cfg(unix)] #[test] fn retained_source_survives_a_coincident_live_uid_checkpoint_failure() { let (mut session, root, live_uid) = failed_republication_session(); @@ -950,7 +961,6 @@ mod tests { assert_eq!(std::fs::read(files(root.path(), live_uid).0).unwrap(), original); } - #[cfg(unix)] #[test] fn clean_autosave_preserves_another_documents_retained_source() { let (mut session, root, live_uid) = failed_republication_session(); @@ -962,7 +972,6 @@ mod tests { assert_eq!(std::fs::read(checkpoint).unwrap(), original); } - #[cfg(unix)] #[test] fn closing_a_coincident_document_preserves_another_documents_retained_source() { let (mut session, root, live_uid) = failed_republication_session(); @@ -972,7 +981,6 @@ mod tests { assert_eq!(std::fs::read(checkpoint).unwrap(), original); } - #[cfg(unix)] #[test] fn saving_a_coincident_document_preserves_another_documents_retained_source() { let (mut session, root, live_uid) = failed_republication_session(); @@ -983,7 +991,6 @@ mod tests { assert_eq!(std::fs::read(checkpoint).unwrap(), original); } - #[cfg(unix)] #[test] fn reverting_a_coincident_document_preserves_another_documents_retained_source() { let (mut session, root, live_uid) = failed_republication_session(); @@ -998,7 +1005,6 @@ mod tests { assert_eq!(std::fs::read(checkpoint).unwrap(), original); } - #[cfg(unix)] #[test] fn retained_source_provenance_is_consumed_before_its_uid_is_reused() { let (mut session, root, live_uid) = failed_republication_session(); From c35515c54daf282005faa41352a1688db8752874 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 22:18:14 +0200 Subject: [PATCH 27/48] feat(tools): The Pen continues an open path from either end point As in InDesign, the Pen over an end point of an open path (selected, or under the pointer) shows a continue cursor; clicking it carries on drawing that path, new anchors going on its subpath, and clicking its other end closes it. Dragging from the end point first reshapes its direction lines. From the first anchor the subpath is reversed first. - engine: path.reverse (Object > Paths > Reverse Path), path.setAnchor; path.appendAnchor and path.close take a subpath, and appendAnchor refuses a closed subpath - tools: Cursor::PenContinue Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/object.rs | 194 ++++++++++++++++++++++++++++++- crates/tools/src/lib.rs | 2 + crates/tools/src/pen.rs | 108 ++++++++++++++++- crates/tools/src/tests.rs | 59 ++++++++++ crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/i18n.rs | 2 + crates/ui-egui/src/i18n/ar.rs | 2 + crates/ui-egui/src/i18n/it.rs | 2 + crates/ui-egui/src/i18n/ja.rs | 2 + crates/ui-egui/src/i18n/pt_br.rs | 2 + crates/ui-egui/src/i18n/uk.rs | 2 + crates/ui-egui/src/menus.rs | 2 + 13 files changed, 368 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..4f42aa6ba 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · The Pen tool continues an open path from either end point, as in InDesign: the cursor shows it over an end point, a click there carries on drawing that path, a drag first reshapes the end point's direction lines, and the other end closes it. Object › Paths › Reverse Path (`path.reverse`); `path.setAnchor`; `path.appendAnchor` and `path.close` take a subpath. No readiness percentage change. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index cd13be117..e79009e7e 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -595,8 +595,34 @@ pub fn specs() -> Vec { step_and_repeat ), cmd!("path.create", "Create Path", [], None, "{spread?, anchors: [{p:[x,y], in?:[x,y], out?:[x,y]}], closed?: bool}", has_doc, path_create), - cmd!("path.appendAnchor", "Add Anchor", [], None, "{id, anchor: {p, in?, out?}} (spread coords)", has_doc, path_append), - cmd!("path.close", "Close Path", ["Object", "Paths"], None, "{id}", has_doc, path_close), + cmd!( + "path.appendAnchor", + "Add Anchor", + [], + None, + "{id, anchor: {p, in?, out?} (spread coords), subpath?: index (default the last)} — after the subpath's last anchor", + has_doc, + path_append + ), + cmd!( + "path.setAnchor", + "Set Anchor", + [], + None, + "{id, subpath?: index (default the last), anchor: index|\"first\"|\"last\", p?, in?, out? (spread coords)} — moves an anchor or its direction lines; with both lines given and collinear it becomes smooth", + has_doc, + path_set_anchor + ), + cmd!( + "path.reverse", + "Reverse Path", + ["Object", "Paths"], + None, + "{ids?, subpath?: index (only that subpath of the first target)} — the anchors run the other way (the end point becomes the start)", + has_selection, + path_reverse + ), + cmd!("path.close", "Close Path", ["Object", "Paths"], None, "{id, subpath?: index (default every subpath)}", has_doc, path_close), cmd!("path.moveAnchors", "Move Anchors", [], None, "{id, anchors: [[subpath, index]], dx, dy, handle?: in|out}", has_doc, path_move_anchors), cmd!( "object.align", @@ -1935,11 +1961,20 @@ fn path_create(s: &mut Session, p: &Value) -> Result { fn path_append(s: &mut Session, p: &Value) -> Result { let id = super::id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?; let a = p.get("anchor").and_then(anchor_from).ok_or_else(|| bad("path.appendAnchor", "missing anchor"))?; + let subpath = p.get("subpath").and_then(Value::as_u64); s.edit(|d, _| { let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse(); let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; - let sp = it.path.subpaths.last_mut().ok_or_else(|| bad("path.appendAnchor", "empty path"))?; + let sp = match subpath { + Some(i) => { + usize::try_from(i).ok().and_then(|i| it.path.subpaths.get_mut(i)).ok_or_else(|| bad("path.appendAnchor", "no such subpath"))? + } + None => it.path.subpaths.last_mut().ok_or_else(|| bad("path.appendAnchor", "empty path"))?, + }; + if sp.closed { + return Err(bad("path.appendAnchor", "the subpath is closed")); + } sp.anchors.push(a.transform(inv)); if it.shape == Shape::GraphicLine { it.shape = Shape::Path; @@ -1948,12 +1983,103 @@ fn path_append(s: &mut Session, p: &Value) -> Result { }) } +fn path_set_anchor(s: &mut Session, p: &Value) -> Result { + const ID: &str = "path.setAnchor"; + let id = super::id_param(p, "id").ok_or_else(|| bad(ID, "missing id"))?; + let pt = |k: &str| -> Option { + let a = p.get(k)?.as_array()?; + let q = Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?); + (q.x.is_finite() && q.y.is_finite()).then_some(q) + }; + let (new_p, new_in, new_out) = (pt("p"), pt("in"), pt("out")); + if new_p.is_none() && new_in.is_none() && new_out.is_none() { + return Err(bad(ID, "give p, in or out")); + } + let subpath = p.get("subpath").and_then(Value::as_u64); + let which = p.get("anchor").cloned().unwrap_or(Value::Null); + s.edit(|d, _| { + let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse(); + let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + if it.locked { + return Err(bad(ID, "the path is locked")); + } + let sp = match subpath { + Some(i) => usize::try_from(i).ok().and_then(|i| it.path.subpaths.get_mut(i)), + None => it.path.subpaths.last_mut(), + } + .ok_or_else(|| bad(ID, "no such subpath"))?; + let n = sp.anchors.len(); + let ai = match &which { + Value::String(w) if w == "first" => 0, + Value::String(w) if w == "last" => n.saturating_sub(1), + v => v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(ID, "anchor: an index, \"first\" or \"last\""))?, + }; + let a = sp.anchors.get_mut(ai).ok_or_else(|| bad(ID, "no such anchor"))?; + if let Some(q) = new_p { + let v = inv * q - a.p; + a.p += v; + a.h_in += v; + a.h_out += v; + } + if let Some(q) = new_in { + a.h_in = inv * q; + } + if let Some(q) = new_out { + a.h_out = inv * q; + } + if new_in.is_some() && new_out.is_some() { + let (i, o) = (a.h_in - a.p, a.h_out - a.p); + let collinear = (i.x * o.y - i.y * o.x).abs() <= 1e-6 * i.hypot().max(1.0) * o.hypot().max(1.0) && i.dot(o) <= 0.0; + a.kind = if collinear { designcraft_geom::AnchorKind::Smooth } else { designcraft_geom::AnchorKind::Corner }; + } + ok() + }) +} + +fn path_reverse(s: &mut Session, p: &Value) -> Result { + let ids = targets(s, p)?; + let only = p.get("subpath").and_then(Value::as_u64).map(|i| usize::try_from(i).unwrap_or(usize::MAX)); + if let (Some(i), Some(id)) = (only, ids.first()) + && s.doc()?.doc.item(*id).is_none_or(|it| it.path.subpaths.get(i).is_none()) + { + return Err(bad("path.reverse", "no such subpath")); + } + let ids = if only.is_some() { ids.into_iter().take(1).collect() } else { ids }; + s.edit(|d, _| { + let mut reversed = 0; + for id in &ids { + let Some(it) = d.item_mut(*id) else { continue }; + if it.locked || it.is_group() { + continue; + } + for (i, sp) in it.path.subpaths.iter_mut().enumerate() { + if only.is_some_and(|o| o != i) { + continue; + } + sp.anchors.reverse(); + for a in &mut sp.anchors { + std::mem::swap(&mut a.h_in, &mut a.h_out); + } + } + reversed += 1; + } + Ok(json!({"reversed": reversed})) + }) +} + fn path_close(s: &mut Session, p: &Value) -> Result { let id = super::id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?; + let only = p.get("subpath").and_then(Value::as_u64).map(|i| usize::try_from(i).unwrap_or(usize::MAX)); s.edit(|d, _| { let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; - for sp in &mut it.path.subpaths { - sp.closed = true; + if only.is_some_and(|i| i >= it.path.subpaths.len()) { + return Err(bad("path.close", "no such subpath")); + } + for (i, sp) in it.path.subpaths.iter_mut().enumerate() { + if only.is_none_or(|o| o == i) { + sp.closed = true; + } } if it.shape == Shape::GraphicLine { it.shape = Shape::Path; @@ -2813,3 +2939,61 @@ mod swatch_name_tests { } } } + +#[cfg(test)] +mod path_edit_tests { + use designcraft_doc::ItemId; + use designcraft_geom::Point; + use serde_json::json; + + fn session_with_path() -> (crate::Session, u64) { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s + .execute( + "path.create", + &json!({"anchors": [{"p": [100, 100]}, {"p": [200, 100], "in": [170, 80], "out": [230, 120]}, {"p": [300, 200]}]}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(); + (s, id) + } + + fn anchors(s: &crate::Session, id: u64) -> Vec { + let d = &s.doc().unwrap().doc; + let it = d.item(ItemId(id)).unwrap(); + let xf = d.parent_xf(&d.find(ItemId(id)).unwrap()) * it.xf; + it.path.subpaths[0].anchors.iter().map(|a| a.transform(xf)).collect() + } + + #[test] + fn reverse_path_swaps_ends_and_direction_lines() { + let (mut s, id) = session_with_path(); + s.execute("path.reverse", &json!({"ids": [id]})).unwrap(); + let a = anchors(&s, id); + assert_eq!(a.iter().map(|a| a.p).collect::>(), [Point::new(300.0, 200.0), Point::new(200.0, 100.0), Point::new(100.0, 100.0)]); + assert_eq!((a[1].h_in, a[1].h_out), (Point::new(230.0, 120.0), Point::new(170.0, 80.0))); + assert!(s.execute("path.reverse", &json!({"ids": [id], "subpath": 3})).is_err()); + } + + #[test] + fn anchors_go_on_the_named_open_subpath_and_set_anchor_reshapes_one() { + let (mut s, id) = session_with_path(); + s.execute("path.appendAnchor", &json!({"id": id, "subpath": 0, "anchor": {"p": [400, 250]}})).unwrap(); + assert_eq!(anchors(&s, id).last().unwrap().p, Point::new(400.0, 250.0)); + assert!(s.execute("path.appendAnchor", &json!({"id": id, "subpath": 1, "anchor": {"p": [0, 0]}})).is_err()); + // Direction lines on the last anchor: collinear and opposite make it smooth. + s.execute("path.setAnchor", &json!({"id": id, "anchor": "last", "in": [380, 230], "out": [420, 270]})).unwrap(); + let last = *anchors(&s, id).last().unwrap(); + assert_eq!((last.h_in, last.h_out, last.kind), (Point::new(380.0, 230.0), Point::new(420.0, 270.0), designcraft_geom::AnchorKind::Smooth)); + s.execute("path.setAnchor", &json!({"id": id, "anchor": 0, "p": [90, 110]})).unwrap(); + assert_eq!(anchors(&s, id)[0].p, Point::new(90.0, 110.0)); + assert!(s.execute("path.setAnchor", &json!({"id": id, "anchor": 9, "p": [0, 0]})).is_err()); + assert!(s.execute("path.setAnchor", &json!({"id": id, "anchor": "last"})).is_err(), "nothing to set"); + // Closing one subpath; then it takes no more anchors. + s.execute("path.close", &json!({"id": id, "subpath": 0})).unwrap(); + assert!(s.execute("path.appendAnchor", &json!({"id": id, "anchor": {"p": [0, 0]}})).is_err()); + assert!(s.execute("path.close", &json!({"id": id, "subpath": 4})).is_err()); + } +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 247494f84..4dad3be41 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -335,6 +335,8 @@ pub enum Cursor { ResizeNeSw, Rotate, Pen, + /// The Pen over an open path's end point: a click continues the path. + PenContinue, Text, Hand, HandGrab, diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index 3c75e51cd..21e90f734 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -1,8 +1,9 @@ //! Pen tool (P): click for corner points, drag for smooth points, click the first point to close, //! Enter/Esc (or switching tools) to finish an open path. Each anchor is its own undo step, as in -//! InDesign. +//! InDesign. Clicking an end point of an open path (the cursor shows it) continues that path from +//! there; dragging from it first reshapes its direction lines. -use designcraft_doc::SpreadRef; +use designcraft_doc::{ItemId, SpreadRef}; use designcraft_geom::{BezPath, Point}; use serde_json::{Value, json}; @@ -19,6 +20,45 @@ pub struct PenTool { hover: Point, /// Smart guides in canvas coordinates, as `snap_point` returned them. guides: Vec, + /// Continuing an existing path: the subpath the new anchors go on. + subpath: Option, + /// The press was on the continued path's end point: a drag reshapes its direction lines + /// instead of adding an anchor. + editing_end: bool, + /// The press on the end point has been dragged. + end_moved: bool, +} + +/// An end point of an open path under canvas point `p`: (item, subpath, whether it is the last +/// anchor). The selection is checked first, then the frontmost item under the pointer. +fn endpoint_at(cx: &ToolContext, p: Point) -> Option<(ItemId, usize, bool)> { + let tol = cx.tol(6.0); + let mut ids = cx.selection.items.clone(); + if let Some((_, id)) = cx.hit(p) + && !ids.contains(&id) + { + ids.push(id); + } + for id in ids { + let Some(it) = cx.doc.item(id) else { continue }; + if it.locked || it.is_group() || !matches!(it.content, designcraft_doc::Content::Unassigned) { + continue; + } + let Some(xf) = cx.item_canvas_xf(id).map(|m| m * it.xf) else { continue }; + for (si, sp) in it.path.subpaths.iter().enumerate() { + if sp.closed { + continue; + } + let (Some(first), Some(last)) = (sp.anchors.first(), sp.anchors.last()) else { continue }; + if (xf * last.p - p).hypot() <= tol { + return Some((id, si, true)); + } + if (xf * first.p - p).hypot() <= tol { + return Some((id, si, false)); + } + } + } + None } fn anchor_json(a: &(Point, Point, Point)) -> Value { @@ -32,9 +72,35 @@ impl PenTool { self.anchors.clear(); self.dragging = false; self.guides.clear(); + self.subpath = None; + self.editing_end = false; + self.end_moved = false; vec![] } + /// Start continuing subpath `si` of `id` from its last (`at_end`) or first anchor: the anchors + /// so far are its other end and the end point, in spread coordinates. + fn continue_from(&mut self, cx: &ToolContext, id: ItemId, si: usize, at_end: bool) -> Vec { + let (Some(it), Some(loc)) = (cx.doc.item(id), cx.doc.find(id)) else { return vec![] }; + let Some(sp) = it.path.subpaths.get(si) else { return vec![] }; + let (Some(first), Some(last)) = (sp.anchors.first(), sp.anchors.last()) else { return vec![] }; + let xf = cx.doc.parent_xf(&loc) * it.xf; + let at = |a: &designcraft_geom::Anchor, flip: bool| { + let (i, o) = if flip { (a.h_out, a.h_in) } else { (a.h_in, a.h_out) }; + (xf * a.p, xf * i, xf * o) + }; + // From the first anchor the path is reversed first, so the new anchors follow on. + let (other, end) = if at_end { (at(first, false), at(last, false)) } else { (at(last, true), at(first, true)) }; + self.path = Some(id.0); + self.subpath = Some(si); + self.spread = Some(loc.spread); + self.anchors = vec![other, end]; + self.editing_end = true; + self.end_moved = false; + self.dragging = true; + if at_end { vec![] } else { vec![Action::Exec("path.reverse".into(), json!({"ids": [id.0], "subpath": si}))] } + } + /// Shift constrains first. The stored point is the snapped spread point. Guides are already canvas. fn snap_click(&mut self, cx: &ToolContext, spread: SpreadRef, mut p: Point, shift: bool) -> Point { if shift && let Some(last) = self.anchors.last() { @@ -53,7 +119,13 @@ impl PenTool { let anchors: Vec = self.anchors.iter().map(anchor_json).collect(); vec![Action::Exec("path.create".into(), json!({"spread": spread_json(sr), "anchors": anchors, "closed": false, "notifyPen": true}))] } - Some(id) => vec![Action::Exec("path.appendAnchor".into(), json!({"id": id, "anchor": anchor_json(last)}))], + Some(id) => { + let mut p = json!({"id": id, "anchor": anchor_json(last)}); + if let Some(si) = self.subpath { + p["subpath"] = json!(si); + } + vec![Action::Exec("path.appendAnchor".into(), p)] + } None => vec![], } } @@ -93,12 +165,24 @@ impl Tool for PenTool { && (first.0 - sp).hypot() <= cx.tol(6.0) { let out = match self.path { - Some(id) => vec![Action::Exec("path.close".into(), json!({"id": id}))], + Some(id) => { + let mut p = json!({"id": id}); + if let Some(si) = self.subpath { + p["subpath"] = json!(si); + } + vec![Action::Exec("path.close".into(), p)] + } None => vec![], }; self.finish(); return out; } + // On an open path's end point: continue that path. + if self.anchors.is_empty() + && let Some((id, si, at_end)) = endpoint_at(cx, ev.pos) + { + return self.continue_from(cx, id, si, at_end); + } let p = self.snap_click(cx, snap_spread, sp, ev.mods.shift); if self.anchors.is_empty() { self.spread = Some(sr); @@ -108,6 +192,7 @@ impl Tool for PenTool { vec![] } PointerKind::Drag => { + self.end_moved |= self.editing_end; if let Some(a) = self.anchors.last_mut() { // Smooth point: out handle follows the pointer, in handle mirrors it. a.2 = sp; @@ -120,6 +205,17 @@ impl Tool for PenTool { return vec![]; } self.dragging = false; + // The continued path's end point: a drag set its direction lines. + if std::mem::take(&mut self.editing_end) { + let (Some(id), Some(si), Some(&(p, h_in, h_out))) = (self.path, self.subpath, self.anchors.last()) else { return vec![] }; + if !std::mem::take(&mut self.end_moved) || (h_out - p).hypot() < 1e-9 { + return vec![]; + } + return vec![Action::Exec( + "path.setAnchor".into(), + json!({"id": id, "subpath": si, "anchor": "last", "in": [h_in.x, h_in.y], "out": [h_out.x, h_out.y]}), + )]; + } self.commit_anchor() } _ => vec![], @@ -165,8 +261,8 @@ impl Tool for PenTool { out } - fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - Cursor::Pen + fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + if self.anchors.is_empty() && endpoint_at(cx, p).is_some() { Cursor::PenContinue } else { Cursor::Pen } } fn busy(&self) -> bool { diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 6c75b0da6..f8f58c7d3 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -705,3 +705,62 @@ fn pen_click_snaps_to_a_guide() { let x = p["anchors"][0]["p"][0].as_f64().unwrap(); assert!((x - 100.0).abs() < 1e-6, "anchor x {x}"); } + +#[test] +fn pen_continues_an_open_path_from_either_end_point() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let id = ItemId(d.alloc()); + let anchors = vec![ + designcraft_geom::Anchor::corner(Point::new(100.0, 100.0)), + designcraft_geom::Anchor { + p: Point::new(200.0, 100.0), + h_in: Point::new(170.0, 80.0), + h_out: Point::new(230.0, 120.0), + kind: designcraft_geom::AnchorKind::Smooth, + }, + ]; + let mut it = Item::new(id, lid, Shape::Path, designcraft_geom::PathData::single(designcraft_geom::SubPath::new(anchors, false))); + it.stroke = Stroke::default(); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let off = l.xf(SpreadRef::Doc(0)).translation(); + let at = |x: f64, y: f64| (x + off.x, y + off.y); + let ev = |k: PointerKind, (x, y): (f64, f64)| PointerEvent::new(k, x, y); + let mut t = create("pen"); + // The cursor says the end point continues the path; elsewhere it's the plain pen. + assert_eq!(t.cursor(&cx, Point::new(at(200.0, 100.0).0, at(200.0, 100.0).1), Mods::default()), Cursor::PenContinue); + assert_eq!(t.cursor(&cx, Point::new(at(150.0, 300.0).0, at(150.0, 300.0).1), Mods::default()), Cursor::Pen); + // Clicking the last anchor adds nothing yet; the next click appends to that path. + assert!(t.pointer(&cx, &ev(PointerKind::Down, at(200.0, 100.0))).is_empty()); + assert!(t.pointer(&cx, &ev(PointerKind::Up, at(200.0, 100.0))).is_empty(), "a click on the end point changes nothing"); + t.pointer(&cx, &ev(PointerKind::Down, at(300.0, 200.0))); + let a = t.pointer(&cx, &ev(PointerKind::Up, at(300.0, 200.0))); + let [Action::Exec(cmd, p)] = a.as_slice() else { panic!("{a:?}") }; + assert_eq!((cmd.as_str(), p["id"].as_u64(), p["subpath"].as_u64()), ("path.appendAnchor", Some(id.0), Some(0))); + assert_eq!((p["anchor"]["p"][0].as_f64(), p["anchor"]["p"][1].as_f64()), (Some(300.0), Some(200.0))); + // Clicking the first anchor of the other end closes it. + let a = t.pointer(&cx, &ev(PointerKind::Down, at(100.0, 100.0))); + assert_eq!(a, vec![Action::Exec("path.close".into(), serde_json::json!({"id": id.0, "subpath": 0}))]); + assert!(t.pointer(&cx, &ev(PointerKind::Up, at(100.0, 100.0))).is_empty()); + // From the first anchor, the path is reversed first; a drag there sets its direction lines. + let mut t = create("pen"); + let a = t.pointer(&cx, &ev(PointerKind::Down, at(100.0, 100.0))); + assert_eq!(a, vec![Action::Exec("path.reverse".into(), serde_json::json!({"ids": [id.0], "subpath": 0}))]); + t.pointer(&cx, &ev(PointerKind::Drag, at(80.0, 140.0))); + let a = t.pointer(&cx, &ev(PointerKind::Up, at(80.0, 140.0))); + assert_eq!( + a, + vec![Action::Exec( + "path.setAnchor".into(), + serde_json::json!({"id": id.0, "subpath": 0, "anchor": "last", "in": [120.0, 60.0], "out": [80.0, 140.0]}) + )] + ); + // Closed paths and locked paths aren't continued. + let mut d2 = d.clone(); + d2.item_mut(id).unwrap().locked = true; + let cx = ctx(&d2, &s, &c, &l); + let t = create("pen"); + assert_eq!(t.cursor(&cx, Point::new(at(200.0, 100.0).0, at(200.0, 100.0).1), Mods::default()), Cursor::Pen); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4c7313e61..176b8a229 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -352,7 +352,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon { Cursor::ResizeNwSe => C::ResizeNwSe, Cursor::ResizeNeSw => C::ResizeNeSw, Cursor::Rotate => C::Alias, - Cursor::Pen => C::Crosshair, + Cursor::Pen | Cursor::PenContinue => C::Crosshair, Cursor::Text => C::Text, Cursor::Hand => C::Grab, Cursor::HandGrab => C::Grabbing, diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5976d7d07..39b31ca0c 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -55,6 +55,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("Reverse Path", ["Pfadrichtung umkehren", "Inverser le tracé", "Invertir trazado", "パスの方向反転", "反转路径"]), + ("Set Anchor", ["Ankerpunkt festlegen", "Définir le point d’ancrage", "Definir punto de ancla", "アンカーポイントを設定", "设置锚点"]), ("Clear", ["Löschen", "Effacer", "Borrar", "消去", "清除"]), ("Duplicate", ["Duplizieren", "Dupliquer", "Duplicar", "複製", "重复"]), ("Select All", ["Alles auswählen", "Tout sélectionner", "Seleccionar todo", "すべてを選択", "全选"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index c3373486e..d02370d52 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", "لصق"), + ("Reverse Path", "عكس المسار"), + ("Set Anchor", "تعيين نقطة الارتساء"), ("Clear", "مسح"), ("Delete", "حذف"), ("Duplicate", "تكرار"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 34766cf22..e61d461a0 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"), + ("Reverse Path", "Inverti tracciato"), + ("Set Anchor", "Imposta ancoraggio"), ("Clear", "Cancella"), ("Duplicate", "Duplica"), ("Select All", "Seleziona tutto"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index de57db5e9..7dd06af9c 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", "ペースト"), + ("Reverse Path", "パスの方向反転"), + ("Set Anchor", "アンカーポイントを設定"), ("Clear", "消去"), ("Duplicate", "複製"), ("Select All", "すべてを選択"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index a4d06bbe9..ea7f5291c 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"), + ("Reverse Path", "Inverter demarcador"), + ("Set Anchor", "Definir ponto de ancoragem"), ("Clear", "Limpar"), ("Delete", "Excluir"), ("Duplicate", "Duplicar"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9b70204a4..96bd9bb3e 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", "Вставити"), + ("Reverse Path", "Розвернути контур"), + ("Set Anchor", "Задати прив’язку"), ("Clear", "Очистити"), ("Duplicate", "Дублювати"), ("Select All", "Виділити все"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index cb1422394..f2b3d892f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -486,6 +486,8 @@ pub const MENUS: &[(&str, &[&str])] = &[ ">Paths", "cmd:object.makeCompoundPath", "cmd:object.releaseCompoundPath", + "-", + "cmd:path.reverse", "<", ">Pathfinder", "cmd:object.pathfinder|Add|{\"op\": \"add\"}", From cded7d997e961e47f2920703059753f43c57c5ac Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 22:32:56 +0200 Subject: [PATCH 28/48] feat(tools): Drag type-on-a-path start and end brackets Selected type on a path shows its start and end brackets across the path, as in InDesign, and the Selection and Direct Selection tools drag them along it (Move Bracket, one undo step): the text starts and ends where they are, and text past the end bracket is overset. - doc: PathType.end (None: the path's end) - geom: PathWarp::nearest, the arc length of the nearest point - engine: type.pathOptions {end}; brackets stay on the path and can't cross - compose: the line runs from the start to the end bracket - idml: EndBracket read and written - tools: Cursor::PathBracket Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 3 +- crates/doc/src/item.rs | 6 +- crates/engine/src/cmd/path_type.rs | 62 +++++++++++++++++-- crates/geom/src/warp.rs | 21 +++++++ crates/idml/src/export.rs | 2 +- crates/idml/src/import.rs | 5 ++ crates/idml/src/tests.rs | 24 ++++++++ crates/tools/src/lib.rs | 2 + crates/tools/src/select.rs | 95 +++++++++++++++++++++++++++++- crates/tools/src/tests.rs | 48 +++++++++++++++ crates/ui-egui/src/canvas.rs | 1 + 12 files changed, 261 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..a213adda0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Type on a path has start and end brackets, as in InDesign: selected path type shows them across the path, and dragging either with the Selection or Direct Selection tool moves where the text starts or ends (text past the end bracket is overset). `type.pathOptions {end}`; IDML reads and writes EndBracket. No readiness percentage change. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3dd7c25b3..4aa16fd73 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -417,7 +417,8 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { path: None, ..tf.options.clone() }; - (Rect::new(0.0, 0.0, (len - pt.start).max(1.0), size * 1.6), opts) + let end = pt.end.map_or(len, |e| e.clamp(0.0, len)); + (Rect::new(0.0, 0.0, (end - pt.start).max(1.0), size * 1.6), opts) } None => (item.text_area(), tf.options.clone()), }; diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 5cfe05fe2..0a701998e 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -345,8 +345,12 @@ pub struct TextFrameOptions { #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct PathType { - /// Where the text starts, as a distance along the path. + /// Where the text starts, as a distance along the path (the start bracket). pub start: f64, + /// Where the text ends, as a distance along the path (the end bracket); `None` is the path's + /// end. Text past it is overset. + #[serde(skip_serializing_if = "Option::is_none")] + pub end: Option, /// Run the other way (the text sits on the other side). pub flip: bool, pub align: PathAlign, diff --git a/crates/engine/src/cmd/path_type.rs b/crates/engine/src/cmd/path_type.rs index 77bbf87ab..c136cb8d1 100644 --- a/crates/engine/src/cmd/path_type.rs +++ b/crates/engine/src/cmd/path_type.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "Type on a Path Options…", ["Type", "Type on a Path"], None, - "{id?, start?, flip?, align?: baseline|ascender|descender|center, delete?: true (Delete Type from Path)}", + "{id?, start?, end?: distance along the path or null (the path's end), flip?, align?: baseline|ascender|descender|center, delete?: true (Delete Type from Path)} — start and end are the brackets: start ≤ end, both on the path", has_doc, path_options ), @@ -86,6 +86,7 @@ fn path_options(s: &mut Session, p: &Value) -> Result { let p = p.clone(); s.edit(|d, sel| { let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + let len = designcraft_geom::warp::PathWarp::new(&it.path.to_bezpath(), false).length(); let Content::Text(tf) = &mut it.content else { return Err(bad("type.pathOptions", "not type on a path")) }; let Some(pt) = tf.options.path.as_mut() else { return Err(bad("type.pathOptions", "not type on a path")) }; if delete { @@ -95,8 +96,26 @@ fn path_options(s: &mut Session, p: &Value) -> Result { sel.text = None; return Ok(json!({"deleted": true})); } - if let Some(v) = p.get("start").and_then(Value::as_f64) { - pt.start = v.max(0.0); + if let Some(v) = p.get("start").and_then(Value::as_f64).filter(|v| v.is_finite()) { + pt.start = v.clamp(0.0, len); + } + match p.get("end") { + Some(Value::Null) => pt.end = None, + Some(v) => { + let e = v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad("type.pathOptions", "end: a distance or null"))?; + pt.end = (e < len - 0.01).then_some(e.max(0.0)); + } + None => {} + } + // The brackets can't cross: the one being moved stops at the other. + if let Some(e) = pt.end + && pt.start > e + { + if p.get("end").is_some() { + pt.end = Some(pt.start); + } else { + pt.start = e; + } } if let Some(v) = p.get("flip").and_then(Value::as_bool) { pt.flip = v; @@ -104,7 +123,7 @@ fn path_options(s: &mut Session, p: &Value) -> Result { if let Some(a) = align { pt.align = a; } - Ok(json!({"start": pt.start, "flip": pt.flip, "align": pt.align})) + Ok(json!({"start": pt.start, "end": pt.end, "flip": pt.flip, "align": pt.align})) }) } @@ -160,4 +179,39 @@ mod tests { let pdf = designcraft_pdf::export_pdf(&d, &s2.cache, &Default::default()).unwrap(); assert!(pdf.len() > 1000); } + + #[test] + fn the_brackets_set_where_the_text_starts_and_ends() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("line.create", &json!({"a": [100, 300], "b": [500, 300]})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("type.onPath", &json!({"id": id, "text": "Sixteen letters!"})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let overset = |s: &Session| designcraft_compose::compose_story(&s.doc().unwrap().doc, sid, &Default::default()).overset_at.is_some(); + let undo = s.doc().unwrap().history.undo.len(); + assert!(!overset(&s)); + // The end bracket close to the start: the text no longer fits, and is overset. + let r = s.execute("type.pathOptions", &json!({"id": id, "start": 50, "end": 70})).unwrap(); + assert_eq!((r["start"].as_f64(), r["end"].as_f64()), (Some(50.0), Some(70.0))); + assert!(overset(&s)); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + // Brackets can't cross: the start stops at the end, the end at the start. + let r = s.execute("type.pathOptions", &json!({"id": id, "start": 90})).unwrap(); + assert_eq!(r["start"].as_f64(), Some(70.0)); + let r = s.execute("type.pathOptions", &json!({"id": id, "start": 10, "end": 5})).unwrap(); + assert_eq!((r["start"].as_f64(), r["end"].as_f64()), (Some(10.0), Some(10.0))); + // Past the path, or null: the path's end again; the start stays on the path. + let r = s.execute("type.pathOptions", &json!({"id": id, "end": 1000})).unwrap(); + assert!(r["end"].is_null()); + let r = s.execute("type.pathOptions", &json!({"id": id, "end": 200})).unwrap(); + assert_eq!(r["end"].as_f64(), Some(200.0)); + let r = s.execute("type.pathOptions", &json!({"id": id, "end": null, "start": 5000})).unwrap(); + assert_eq!((r["start"].as_f64(), r["end"].is_null()), (Some(400.0), true)); + assert!(s.execute("type.pathOptions", &json!({"id": id, "end": "far"})).is_err()); + // Saved with the document. + let d = s.doc().unwrap().doc.clone(); + let back = crate::cmd::file_from(&crate::cmd::file_bytes(&d)).unwrap(); + let path = |d: &designcraft_doc::Document| d.item(designcraft_doc::ItemId(id)).unwrap().text_frame().unwrap().options.path.clone(); + assert_eq!(path(&back), path(&d)); + } } diff --git a/crates/geom/src/warp.rs b/crates/geom/src/warp.rs index fe6b89078..34041d280 100644 --- a/crates/geom/src/warp.rs +++ b/crates/geom/src/warp.rs @@ -47,6 +47,22 @@ impl PathWarp { None } + /// The arc length (as `at` measures it, so flipped paths count from their end) of the point + /// of the path nearest `p`. + pub fn nearest(&self, p: Point) -> f64 { + use kurbo::ParamCurveNearest; + let mut best = (f64::INFINITY, 0.0); + let mut before = 0.0; + for (seg, len) in &self.segs { + let n = seg.nearest(p, 1e-6); + if n.distance_sq < best.0 { + best = (n.distance_sq, before + seg.subsegment(0.0..n.t).arclen(1e-4)); + } + before += len; + } + if self.flip { self.total - best.1 } else { best.1 } + } + /// Transform for something laid out on a straight line (x along the line, y down from the /// line) whose horizontal centre is `mid`: it lands centred at arc length `mid`, turned with /// the path, its line at `baseline` on the path. @@ -76,5 +92,10 @@ mod tests { assert!((g * Point::new(50.0, 10.0) - Point::new(50.0, 0.0)).hypot() < 1e-9); let flipped = PathWarp::new(&bp, true); assert!((flipped.at(0.0).unwrap().0 - Point::new(100.0, 100.0)).hypot() < 1e-9); + // The nearest point by arc length, off the path and on it; flipped counts from the end. + assert!((w.nearest(Point::new(120.0, 50.0)) - 150.0).abs() < 1e-6); + assert!((w.nearest(Point::new(120.0, 150.0)) - 200.0).abs() < 1e-6, "past the end: the end"); + assert!((w.nearest(Point::new(30.0, -8.0)) - 30.0).abs() < 1e-6); + assert!((flipped.nearest(Point::new(30.0, -8.0)) - 170.0).abs() < 1e-6); } } diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 9d73562b9..b9fa7aaae 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1629,7 +1629,7 @@ impl<'a> Ex<'a> { .attr("TextAlignment", names::path_align_out(pt.align)) .attr("FlipPathEffect", if pt.flip { "Flipped" } else { "NotFlipped" }) .attr("StartBracket", num(pt.start)) - .attr("EndBracket", num(len)), + .attr("EndBracket", num(pt.end.map_or(len, |e| e.min(len)))), ); } else if let Content::Text(t) = &it.content { let (prev, next) = self.threads.get(&it.id.0).copied().unwrap_or((None, None)); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 46c3253b6..31b321519 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2181,6 +2181,11 @@ impl<'r> Importer<'r> { let options = TextFrameOptions { path: Some(designcraft_doc::PathType { start: tp.num("StartBracket").unwrap_or(0.0).max(0.0), + // At (or past) the path's length it is the path's end: no end bracket. + end: tp + .num("EndBracket") + .filter(|e| e.is_finite() && *e >= 0.0) + .filter(|e| *e < designcraft_geom::warp::PathWarp::new(&it.path.to_bezpath(), false).length() - 0.01), flip: tp.get("FlipPathEffect") == Some("Flipped"), align: names::path_align_in(tp.get("TextAlignment").unwrap_or("")), }), diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 23ec99bdf..8d27074e9 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1454,3 +1454,27 @@ fn list_numbering_format_expression_and_bullet_import_and_round_trip() { check(&d); check(&import_idml(&export_idml(&d)).unwrap()); } + +#[test] +fn type_on_a_path_keeps_its_start_and_end_brackets() { + use designcraft_geom::{Anchor, PathData, Point, SubPath}; + for end in [Some(250.0), None] { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let id = designcraft_doc::ItemId(d.alloc()); + let line = PathData::single(SubPath::new(vec![Anchor::corner(Point::new(100.0, 300.0)), Anchor::corner(Point::new(500.0, 300.0))], false)); + let mut it = designcraft_doc::Item::new(id, lid, Shape::GraphicLine, line); + let sid = designcraft_doc::StoryId(d.alloc()); + let mut tf = designcraft_doc::TextFrame { story: sid, options: Default::default() }; + tf.options.path = Some(designcraft_doc::PathType { start: 30.0, end, ..Default::default() }); + it.content = designcraft_doc::Content::Text(tf); + let mut story = designcraft_doc::Story::with_text(sid, "Along", Default::default()); + story.frames = vec![id]; + d.stories.insert(sid, std::sync::Arc::new(story)); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let back = import_idml(&export_idml(&d)).unwrap(); + let pt = back.spreads[0].items[0].text_frame().unwrap().options.path.clone().unwrap(); + assert!((pt.start - 30.0).abs() < 1e-6, "{pt:?}"); + assert_eq!(pt.end.map(|e| e.round()), end, "an end bracket at the path's end is no end bracket"); + } +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 247494f84..02b790d55 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -335,6 +335,8 @@ pub enum Cursor { ResizeNeSw, Rotate, Pen, + /// Over a type-on-a-path bracket: dragging it moves where the text starts or ends. + PathBracket, Text, Hand, HandGrab, diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 8f59c3503..fc9a8c316 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -38,6 +38,11 @@ enum Drag { start: Point, cur: Point, }, + /// Dragging a type-on-a-path bracket along its path (`end`: the end bracket). + Bracket { + id: ItemId, + end: bool, + }, Anchor { id: u64, si: usize, @@ -51,6 +56,50 @@ enum Drag { }, } +/// Half the length of a path-type bracket, in screen pixels. +const BRACKET_PX: f64 = 9.0; + +/// A selected type-on-a-path item's path (inner space), its inner → canvas transform and its +/// brackets' arc lengths (start, end). +fn path_type_of(cx: &ToolContext, id: ItemId) -> Option<(designcraft_geom::warp::PathWarp, designcraft_geom::Affine, f64, f64)> { + let it = cx.doc.item(id)?; + let pt = it.text_frame()?.options.path.as_ref()?; + let warp = designcraft_geom::warp::PathWarp::new(&it.path.to_bezpath(), pt.flip); + let end = pt.end.unwrap_or(warp.length()).min(warp.length()); + Some((warp, cx.item_canvas_xf(id)? * it.xf, pt.start, end)) +} + +/// The bracket line at arc length `s`: across the path, `BRACKET_PX` either side (canvas). +fn bracket_line(cx: &ToolContext, warp: &designcraft_geom::warp::PathWarp, xf: designcraft_geom::Affine, s: f64) -> Option<(Point, Point)> { + let (p, a) = warp.at(s)?; + let c = xf * p; + let t = xf * (p + designcraft_geom::Vec2::new(a.cos(), a.sin())) - c; + let n = designcraft_geom::Vec2::new(-t.y, t.x); + let n = n / n.hypot().max(1e-9) * cx.tol(BRACKET_PX); + Some((c - n, c + n)) +} + +/// The bracket of a selected type-on-a-path item under `p` (canvas): (item, whether it is the end +/// bracket). +fn bracket_at(cx: &ToolContext, p: Point) -> Option<(ItemId, bool)> { + let tol = cx.tol(BRACKET_PX); + cx.selection.items.iter().find_map(|id| { + let (warp, xf, start, end) = path_type_of(cx, *id)?; + if cx.doc.item(*id).is_some_and(|it| it.locked) { + return None; + } + let near = |s: f64| warp.at(s).is_some_and(|(q, _)| (xf * q - p).hypot() <= tol); + // The end bracket first: with both brackets at one point, it can still be pulled back. + if near(end) { + Some((*id, true)) + } else if near(start) { + Some((*id, false)) + } else { + None + } + }) +} + /// Anchor or handle of a selected item under `p` (canvas): (item, subpath, anchor, handle). pub fn anchor_at(cx: &ToolContext, p: Point) -> Option<(u64, usize, usize, Option<&'static str>)> { anchor_at_in(cx, &cx.selection.items, p) @@ -378,6 +427,13 @@ impl Tool for SelectionTool { } PointerKind::Down => { self.shift_release = None; + // Type on a path: a bracket moves where the text starts or ends. + if !ev.mods.shift + && let Some((id, end)) = bracket_at(cx, p) + { + self.drag = Drag::Bracket { id, end }; + return vec![Action::Begin("Move Bracket".into())]; + } if self.direct && let Some((id, si, ai, handle)) = anchor_at(cx, p) { @@ -437,6 +493,14 @@ impl Tool for SelectionTool { } } PointerKind::Drag => match self.drag.clone() { + Drag::Bracket { id, end } => { + let Some((warp, xf, _, _)) = path_type_of(cx, id) else { return vec![] }; + if xf.determinant().abs() < 1e-12 { + return vec![]; + } + let s = warp.nearest(xf.inverse() * p); + vec![Action::Preview("type.pathOptions".into(), json!({"id": id.0, if end { "end" } else { "start" }: s}))] + } Drag::Pending { start, hit } => { if (p - start).hypot() < cx.tol(3.0) { return vec![]; @@ -682,7 +746,9 @@ impl Tool for SelectionTool { let release = self.shift_release.take(); let d = std::mem::replace(&mut self.drag, Drag::None); match d { - Drag::Move { .. } | Drag::Resize { .. } | Drag::Anchor { .. } | Drag::Rotate { .. } => vec![Action::Commit], + Drag::Move { .. } | Drag::Resize { .. } | Drag::Anchor { .. } | Drag::Rotate { .. } | Drag::Bracket { .. } => { + vec![Action::Commit] + } Drag::Pending { .. } => match release { Some(id) => vec![Action::Exec("selection.toggle".into(), json!({"id": id}))], None => vec![], @@ -739,15 +805,19 @@ impl Tool for SelectionTool { } } - fn overlays(&self, _cx: &ToolContext) -> Vec { + fn overlays(&self, cx: &ToolContext) -> Vec { match &self.drag { Drag::Marquee { start, cur } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))], Drag::Move { .. } | Drag::Resize { .. } | Drag::Rotate { .. } | Drag::Anchor { .. } => self.guides.clone(), + Drag::None | Drag::Bracket { .. } => brackets(cx), _ => vec![], } } fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + if matches!(self.drag, Drag::Bracket { .. }) || (matches!(self.drag, Drag::None) && bracket_at(cx, p).is_some()) { + return Cursor::PathBracket; + } if self.direct { return Cursor::ArrowHollow; } @@ -769,6 +839,27 @@ impl Tool for SelectionTool { } } +/// The start and end brackets of the selected type-on-a-path items, in the selection colour: a +/// line across the path with a short tick pointing at the text between them. +fn brackets(cx: &ToolContext) -> Vec { + let mut out = Vec::new(); + for id in &cx.selection.items { + let Some((warp, xf, start, end)) = path_type_of(cx, *id) else { continue }; + let color = cx.doc.item(*id).and_then(|it| cx.doc.layer(it.layer)).map_or([43, 155, 255], |l| l.color); + for (s, towards) in [(start, end.max(start + 1.0)), (end, start.min(end - 1.0))] { + let Some((a, b)) = bracket_line(cx, &warp, xf, s) else { continue }; + out.push(Overlay::Line { a, b, color, dashed: false }); + // The tick at the outer end points along the path towards the text. + if let (Some((p0, _)), Some((p1, _))) = (warp.at(s), warp.at(towards)) { + let dir = xf * p1 - xf * p0; + let dir = dir / dir.hypot().max(1e-9) * cx.tol(BRACKET_PX * 0.6); + out.push(Overlay::Line { a: b, b: b + dir, color, dashed: false }); + } + } + } + out +} + fn handle_cursor(h: usize) -> Cursor { match h { 0 | 4 => Cursor::ResizeNwSe, diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 6c75b0da6..e39f8d4ee 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -705,3 +705,51 @@ fn pen_click_snaps_to_a_guide() { let x = p["anchors"][0]["p"][0].as_f64().unwrap(); assert!((x - 100.0).abs() < 1e-6, "anchor x {x}"); } + +#[test] +fn the_selection_tool_drags_a_path_type_bracket_along_its_path() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let id = ItemId(d.alloc()); + let line = designcraft_geom::PathData::single(designcraft_geom::SubPath::new( + vec![designcraft_geom::Anchor::corner(Point::new(100.0, 300.0)), designcraft_geom::Anchor::corner(Point::new(500.0, 300.0))], + false, + )); + let mut it = Item::new(id, lid, Shape::GraphicLine, line); + let sid = designcraft_doc::StoryId(d.alloc()); + let mut tf = designcraft_doc::TextFrame { story: sid, options: Default::default() }; + tf.options.path = Some(designcraft_doc::PathType { start: 40.0, end: Some(300.0), ..Default::default() }); + it.content = designcraft_doc::Content::Text(tf); + let mut st = designcraft_doc::Story::with_text(sid, "On the line", Default::default()); + st.frames = vec![id]; + d.stories.insert(sid, Arc::new(st)); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); + let off = l.xf(SpreadRef::Doc(0)).translation(); + let at = |x: f64, y: f64| Point::new(x + off.x, y + off.y); + let ev = |k: PointerKind, p: Point| PointerEvent::new(k, p.x, p.y); + // Not selected: no brackets. + let none = Selection::default(); + assert!(create("selection").overlays(&ctx(&d, &none, &c, &l)).is_empty()); + let s = Selection { items: vec![id], ..Default::default() }; + let cx = ctx(&d, &s, &c, &l); + let mut t = create("selection"); + // Both brackets drawn across the path at 140 and 400 (a line and a tick each). + let o = t.overlays(&cx); + let xs: Vec = o.iter().filter_map(|o| if let Overlay::Line { a, b, .. } = o { (a.x == b.x).then_some(a.x - off.x) } else { None }).collect(); + assert_eq!(xs, [140.0, 400.0], "{o:?}"); + assert_eq!(t.cursor(&cx, at(140.0, 300.0), Mods::default()), Cursor::PathBracket); + assert_ne!(t.cursor(&cx, at(250.0, 300.0), Mods::default()), Cursor::PathBracket); + // Drag the start bracket: its distance along the path follows the pointer, off the path too. + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, at(140.0, 301.0))), vec![Action::Begin("Move Bracket".into())]); + let a = t.pointer(&cx, &ev(PointerKind::Drag, at(220.0, 330.0))); + let [Action::Preview(cmd, p)] = a.as_slice() else { panic!("{a:?}") }; + assert_eq!((cmd.as_str(), p["id"].as_u64()), ("type.pathOptions", Some(id.0))); + assert!((p["start"].as_f64().unwrap() - 120.0).abs() < 1e-6, "{p}"); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, at(220.0, 330.0))), vec![Action::Commit]); + // The end bracket. + t.pointer(&cx, &ev(PointerKind::Down, at(400.0, 300.0))); + let a = t.pointer(&cx, &ev(PointerKind::Drag, at(450.0, 290.0))); + let [Action::Preview(_, p)] = a.as_slice() else { panic!("{a:?}") }; + assert!((p["end"].as_f64().unwrap() - 350.0).abs() < 1e-6, "{p}"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4c7313e61..2f30386c8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -353,6 +353,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon { Cursor::ResizeNeSw => C::ResizeNeSw, Cursor::Rotate => C::Alias, Cursor::Pen => C::Crosshair, + Cursor::PathBracket => C::ResizeColumn, Cursor::Text => C::Text, Cursor::Hand => C::Grab, Cursor::HandGrab => C::Grabbing, From 64f0ca8fa6a38c484504871f9af9e142ad6dcf3d Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 14:52:12 -0600 Subject: [PATCH 29/48] fix(macos): make Dock Quit cancelable Route AppKit termination through the existing unsaved-document close transaction using the immutable winit patch. Restore pending prompts from hidden or minimized windows and preserve answers across repeated requests. Validation: all six local gates, native cancellation/retry/save smoke, and hosted platform checks. Fixes #544. PR #623. --- Cargo.lock | 4 +- Cargo.toml | 4 ++ ROADMAP.md | 3 +- apps/designcraft/Cargo.toml | 2 + apps/designcraft/src/main.rs | 9 +++ crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/lib.rs | 104 +++++++++++++++++++++++++++++++++++ docs/architecture.md | 11 +++- docs/target-app-parity.md | 4 +- 9 files changed, 134 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5e4dc8975..ef525c84e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1156,6 +1156,7 @@ dependencies = [ "regex", "serde", "serde_json", + "tempfile", "toml", "unicode-bidi", "unicode-bidi-mirroring", @@ -5516,8 +5517,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/skylartaylor/winit.git?rev=3d51077fd1a966f84df9ef287839dc84fd5b4ca7#3d51077fd1a966f84df9ef287839dc84fd5b4ca7" dependencies = [ "ahash", "android-activity", diff --git a/Cargo.toml b/Cargo.toml index c2126ade8..b08a82fc2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,3 +97,7 @@ opt-level = 3 lto = "thin" codegen-units = 16 debug = 0 + +# Opt-in cancelable macOS termination on the pinned winit 0.30 line. +[patch.crates-io] +winit = { git = "https://github.com/skylartaylor/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" } diff --git a/ROADMAP.md b/ROADMAP.md index f826a3767..91a951616 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (orderly native exit recovery) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. - **2026-10-12** · Book numbering and style updates prepare every chapter before saving. Only closed native chapters are changed; imported IDML remains a read-only style/PDF source. If a later disk write fails, the error names the completed chapters; the batch is not a whole-book transaction. No readiness percentage change. diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml index 13254e11e..b65efbe48 100644 --- a/apps/designcraft/Cargo.toml +++ b/apps/designcraft/Cargo.toml @@ -31,4 +31,6 @@ rfd = { workspace = true } winit = { version = "0.30", default-features = false, features = ["x11"] } [target.'cfg(target_os = "macos")'.dependencies] +# The same winit as eframe; only the native termination opt-in is used. +winit = { version = "0.30", default-features = false } muda = { version = "0.21", default-features = false } diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index f46812579..26ce2ee4f 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -299,6 +299,15 @@ fn main() -> eframe::Result { b.with_x11(); })); } + // AppKit Dock Quit otherwise terminates before egui can cancel the close. Route it through + // ordinary CloseRequested events, preserving the same transactional Save/Don't Save flow. + #[cfg(target_os = "macos")] + { + options.event_loop_builder = Some(Box::new(|builder| { + use winit::platform::macos::EventLoopBuilderExtMacOS; + builder.with_close_requested_on_terminate(true); + })); + } eframe::run_native( "DesignCraft", options, diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 850902196..76b5f89b2 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -29,6 +29,7 @@ serde_json = { workspace = true } log = { workspace = true } [dev-dependencies] +tempfile = { workspace = true } designcraft-idml = { workspace = true } zip = { workspace = true } # Headless (offscreen wgpu) rendering of the whole window for UI screenshots: `examples/ui_shot.rs`. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e0fa1dff0..8c6189935 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -852,6 +852,11 @@ impl DesignApp { return; } ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + // Dock Quit can arrive while the app is hidden or minimized. Make its question + // visible through the normal safe window commands before asking about documents. + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); if self.quitting.is_none() { self.quitting = Some(QuitState { original_active: self.session.active().map(|d| d.uid), accepted: HashMap::new(), pending: None }); } @@ -1427,6 +1432,105 @@ mod tests { dialogs::confirm(app).unwrap(); } + #[test] + fn native_quit_prompts_restore_window_visibility_without_focusing_completed_quits() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + let commands = quit_frame(&mut app, &ctx, true); + assert!(commands.contains(&egui::ViewportCommand::CancelClose)); + for command in [egui::ViewportCommand::Visible(true), egui::ViewportCommand::Minimized(false), egui::ViewportCommand::Focus] { + assert!(commands.contains(&command), "unsaved prompt must restore the window: {commands:?}"); + } + discard_quit_dialog(&mut app); + let approved = quit_frame(&mut app, &ctx, false); + assert!(approved.contains(&egui::ViewportCommand::Close)); + let completed = quit_frame(&mut app, &ctx, true); + assert!(app.session.documents().is_empty()); + + let mut clean = DesignApp::new(designcraft_engine::Session::new(), Services::default()); + clean.run("file.new", json!({})).unwrap(); + let clean_close = quit_frame(&mut clean, &ctx, true); + let mut control = app_with_unsaved_document(); + control.quit_without_asking = true; + let control_close = quit_frame(&mut control, &ctx, true); + for commands in [&approved, &completed, &clean_close, &control_close] { + assert!( + !commands.iter().any(|command| matches!( + command, + egui::ViewportCommand::Visible(true) | egui::ViewportCommand::Minimized(false) | egui::ViewportCommand::Focus + )), + "completed and immediate quits must not activate the window: {commands:?}" + ); + } + } + + /// Dock Quit may arrive again while the first request's dialogs are still open. + #[test] + fn repeated_native_quit_requests_preserve_partial_approvals_and_cancel() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + second_unsaved_document(&mut app); + let original_active = app.session.active().unwrap().uid; + let uids: Vec<_> = app.session.documents().iter().map(|d| d.uid).collect(); + let docs: Vec<_> = app.session.documents().iter().map(|d| d.doc.clone()).collect(); + let directory = tempfile::tempdir().unwrap(); + app.session.recovery_dir = Some(directory.path().to_path_buf()); + app.run("file.recovery.save", json!({})).unwrap(); + assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose)); + assert_eq!(dialog_uid(&app), Some(uids[0])); + discard_quit_dialog(&mut app); + quit_frame(&mut app, &ctx, false); + assert_eq!(dialog_uid(&app), Some(uids[1])); + for _ in 0..3 { + assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose)); + assert_eq!(dialog_uid(&app), Some(uids[1])); + } + app.ui.dialog = None; // Cancel the second document, retaining the first Don't Save answer. + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.active().unwrap().uid, original_active); + assert_eq!(app.session.documents().iter().map(|d| d.uid).collect::>(), uids); + for (document, original) in app.session.documents().iter().zip(docs) { + assert!(std::sync::Arc::ptr_eq(&document.doc, &original)); + assert!(document.is_dirty()); + assert!(!document.history.undo.is_empty()); + assert!(directory.path().join(format!("{}.designcraft", document.uid)).exists()); + } + // A fresh Dock Quit must ask about the first document again after cancellation. + assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose)); + assert_eq!(dialog_uid(&app), Some(uids[0])); + } + + #[test] + fn repeated_native_quit_requests_save_and_discard_only_after_all_answers() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + second_unsaved_document(&mut app); + let uids: Vec<_> = app.session.documents().iter().map(|d| d.uid).collect(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("saved.designcraft"); + let chosen = path.to_string_lossy().to_string(); + app.services.pick_save = Some(Box::new(move |_| Some(chosen.clone()))); + app.session.recovery_dir = Some(directory.path().join("recovery")); + app.run("file.recovery.save", json!({})).unwrap(); + quit_frame(&mut app, &ctx, true); + assert_eq!(dialog_uid(&app), Some(uids[0])); + dialogs::confirm(&mut app).unwrap(); // Save the first document. + assert!(!app.session.documents()[0].is_dirty()); + quit_frame(&mut app, &ctx, false); + assert_eq!(dialog_uid(&app), Some(uids[1])); + assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose)); + assert_eq!(dialog_uid(&app), Some(uids[1])); + discard_quit_dialog(&mut app); + assert!(quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.documents().len(), 2); + assert!(app.session.recovery_dir.as_ref().unwrap().join(format!("{}.designcraft", uids[1])).exists()); + assert!(quit_frame(&mut app, &ctx, true).is_empty()); + assert!(app.session.documents().is_empty()); + assert!(path.exists()); + assert_eq!(designcraft_engine::Session::new().execute("file.open", &json!({"path": path})).unwrap()["index"], 0); + assert_eq!(std::fs::read_dir(app.session.recovery_dir.as_ref().unwrap()).unwrap().count(), 0); + } + #[test] fn quitting_first_cancel_restores_original_clean_active_document() { let ctx = egui::Context::default(); diff --git a/docs/architecture.md b/docs/architecture.md index be3274b24..39a9039dc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # DesignCraft architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (orderly native exit recovery) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026 How DesignCraft is built today. The design notes behind it are in `plan/architecture.md` (local, gitignored); this file describes what the code on `main` does. Agents: AGENTS.md has the @@ -60,8 +60,12 @@ command is replayable as a script. Escaped panics during a command or an import/ error (`engine/src/guard.rs`), never a lost document; native recovery is written periodically into each running app's locked folder. Orderly native shutdown finalizes complete, current quit approvals and writes the remaining documents' latest recovery state, including clean-document -cleanup after Undo. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit -remains open (#544). +cleanup after Undo. Unavailable or failed recovery is reported. The desktop opts into a pinned +winit 0.30 patch that cancels AppKit termination and queues ordinary window close requests, +including Quit from the macOS Dock. The existing unsaved-document transaction can cancel +those requests or exit after current approvals are complete. A pending prompt restores and +focuses a hidden or minimized window; completed and immediate quits do not activate it. The +AppKit callback never runs the confirmation dialogs inside a modal termination loop. ## Composition and rendering @@ -98,5 +102,6 @@ builds (on manifest changes), packaging lint and the release workflow; `xtask ci | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Documented cancelable Dock Quit through the native event-loop boundary | | 2026-10-12 | minor | Documented orderly native exit recovery and the remaining cancelable Dock Quit gap | | 2026-10-10 | major | Created from the code on main (92649ff) and `plan/architecture.md` | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9095faf50..78e968dcf 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DesignCraft parity with Adobe InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (recovery checkpoint retention) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature @@ -291,7 +291,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Native engine saves, Save a Copy, snippets, books, libraries, swatches and engine/MCP exports use same-folder atomic replacement, as do desktop Services writes and CLI raster exports. Failed writes before replacement retain the original; existing Unix permissions apply before staging document bytes, and already-open readers retain the old bytes. Parent-folder flushing is best effort, so this is not a power-loss durability guarantee. - Save As prepares destination-relative data-source paths before writing and installs them only after a successful save. Failed Save As retains the document, saved state and history; Save a Copy rebases only the saved copy. Library and book collection edits likewise update live values only after persistence succeeds. - Crash recovery: unsaved documents are written every 30 s into a locked folder owned by that running app and reopened (unsaved, remembering their files) after a crash. Startup takes entries from ended runs and leaves live runs alone; writes and cleanup validate current folder and lock ownership. Failed isolation disables recovery with a warning. Recovered checkpoints remain available through another crash before autosave; restarting IDs and explicit recovery from another folder preserve unopened snapshots. Successful Save, Close or Revert clears the document's checkpoints, including manual recovery while autosave is disabled; failed republishing keeps the original. Concurrent older versions still use unlocked root entries; mixed-version isolation remains open. File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`. -- Window close and application/menu Quit ask about every unsaved document. Cancel or a cancelled/failed save keeps all tabs open; Don't Save approvals discard nothing until the complete plan is current. Later edits and new documents are checked before closing. Control-channel quit remains immediate; a cancelable macOS Dock Quit prompt is still missing (#544). +- Window close, application/menu Quit and macOS Dock Quit ask about every unsaved document. Cancel or a cancelled/failed save keeps all tabs open; Don't Save approvals discard nothing until the complete plan is current. Later edits and new documents are checked before closing. Repeated native requests preserve an active dialog and partial approvals. Control-channel quit remains immediate. - Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present. - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. From de31bd5647f86d0a2c7c45ec1d08bbc7a3eeef73 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 23:03:15 +0200 Subject: [PATCH 30/48] fix(fonts): fall back to a font made for Malayalam (part of #628) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Malayalam took the first installed file mapping it: on macOS Arial Unicode MS, which has no Malayalam shaping, so conjuncts like ക്ഷ fell apart. Fonts made for Malayalam are tried first, loaded on demand through the CJK chain lookup. --- crates/compose/src/tests.rs | 17 +++++++++++++++ crates/fonts/src/fontdb.rs | 33 +++++++++++++++++++++++++----- crates/fonts/src/tests_fallback.rs | 30 +++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 5 deletions(-) diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index f5d0113c1..dd67c6855 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1835,6 +1835,23 @@ fn cjk_fallback_follows_the_language() { assert_eq!(Some(han.family.clone()), db.fallback_for('直', primary.id(), None).map(|f| f.family.clone())); } +#[test] +fn malayalam_falls_back_to_a_font_that_forms_its_conjuncts() { + // Run against the installed fonts: on macOS the first file mapping Malayalam is Arial + // Unicode MS, which leaves ക്ഷ as three glyphs. + let db = designcraft_fonts::FontDb::global(); + if !["Malayalam Sangam MN", "Malayalam MN", "Nirmala UI", "Noto Sans Malayalam"].iter().any(|f| db.has_family(f)) { + return; + } + let text = "ക്ഷ"; + let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default()); + d.settings.glyph_fallback = true; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let glyphs: Vec<_> = all_lines(&cs).iter().flat_map(|l| l.glyphs.iter()).map(|g| (g.face.family.clone(), g.gid)).collect(); + assert_eq!(glyphs.len(), 1, "one conjunct: {glyphs:?}"); + assert_ne!(glyphs[0].1, 0); +} + #[test] fn missing_glyphs_are_the_fonts_box_unless_fallback_is_on() { use designcraft_fonts::testing::font_with; diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 14441e144..294cf34f3 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -435,6 +435,29 @@ const KOREAN_FALLBACKS: &[&str] = &[ "Source Han Sans K", ]; +/// Fonts made for Malayalam (macOS, Windows, Noto, Linux). A font that only maps the characters +/// (Arial Unicode MS, first by path on macOS) sets them without conjuncts or vowel signs. +const MALAYALAM_FALLBACKS: &[&str] = &[ + "Malayalam Sangam MN", + "Malayalam MN", + "Nirmala UI", + "Kartika", + "Noto Sans Malayalam", + "Noto Serif Malayalam", + "Manjari", + "Rachana", + "Meera", + "Lohit Malayalam", +]; + +/// The fallback chain of a script that only fonts made for it set well, whatever the language. +fn script_chain(c: char) -> Option<&'static [&'static str]> { + match c as u32 { + 0x0D00..=0x0D7F => Some(MALAYALAM_FALLBACKS), + _ => None, + } +} + /// The Japanese fallback chain: the craft-fonts Japanese faces' families in document order /// (Mincho first; none without craft-fonts), then [`JAPANESE_FALLBACKS`]. fn japanese_chain() -> &'static [&'static str] { @@ -1375,12 +1398,12 @@ impl ScopedFonts<'_> { } /// A face other than `exclude` that covers `c` in `language` (a BCP 47 tag, `None` when - /// unknown). CJK characters try their language's chain first (see [`cjk_chain`]); then the - /// first face (fallback family first, then load order, the document's fonts last) that covers - /// `c`. On native, system fonts are cataloged and loaded lazily the first time no loaded face - /// covers a character. + /// unknown). CJK characters try their language's chain first (see [`cjk_chain`]), Malayalam + /// the fonts made for it (see [`script_chain`]); then the first face (fallback family first, + /// then load order, the document's fonts last) that covers `c`. On native, system fonts are + /// cataloged and loaded lazily the first time no loaded face covers a character. pub fn fallback_for(&self, c: char, exclude: u32, language: Option<&str>) -> Option> { - if let Some(chain) = cjk_chain(c, language) + if let Some(chain) = cjk_chain(c, language).or_else(|| script_chain(c)) && let Some(f) = chain.iter().find_map(|family| self.chain_face(family, c, exclude)) { return Some(f); diff --git a/crates/fonts/src/tests_fallback.rs b/crates/fonts/src/tests_fallback.rs index 4d81f9a5b..d9e27ac62 100644 --- a/crates/fonts/src/tests_fallback.rs +++ b/crates/fonts/src/tests_fallback.rs @@ -127,3 +127,33 @@ fn chain_fonts_load_from_the_font_folders_when_first_needed() { assert_eq!(family(&db, HAN, Some("ko")).as_deref(), Some(first_loaded("Other CJK"))); assert!(!db.is_loaded("AppleMyungjo")); } + +const KA: char = 'ക'; +const VIRAMA: char = '\u{0D4D}'; +const AA: char = '\u{0D3E}'; + +#[test] +fn malayalam_takes_a_font_made_for_it() { + // Arial Unicode MS maps Malayalam but can't shape it (no conjuncts); loaded first, it won. + let db = db_with(&[("Arial Unicode MS", &[KA, VIRAMA, AA]), ("Malayalam Sangam MN", &[KA, VIRAMA, AA])]); + for c in [KA, VIRAMA, AA] { + for language in [None, Some("en-US"), Some("ml")] { + assert_eq!(family(&db, c, language).as_deref(), Some("Malayalam Sangam MN"), "{c:?} {language:?}"); + } + } + // Past the Malayalam fonts, the language-blind search as before. + let sangam = db.face("Malayalam Sangam MN", "Regular"); + assert_eq!(db.fallback_for(KA, sangam.id(), None).map(|f| f.family.clone()).as_deref(), Some("Arial Unicode MS")); +} + +#[test] +fn malayalam_fonts_load_from_the_font_folders_when_first_needed() { + let dir = std::env::temp_dir().join(format!("dc-malayalam-fallback-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("noto.ttf"), font_with("Noto Sans Malayalam", &[KA]).unwrap()).unwrap(); + let db = FontDb::with_font_dirs(vec![dir]); + db.add_font(font_with("Arial Unicode MS", &[KA]).unwrap()); + assert!(!db.is_loaded("Noto Sans Malayalam")); + assert_eq!(family(&db, KA, None).as_deref(), Some("Noto Sans Malayalam")); +} From f96895f400d54a984c2809a079417b44f9c3d77b Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 23:35:58 +0200 Subject: [PATCH 31/48] feat(text): Edit type on a path along the curve The Type tool's clicks, drags and double-clicks on type on a path went to the character at the same spot of the straightened line, and the caret and highlight were drawn straight. A click now finds the character where its nearest point along the path is (compose::PathTextMap, from the line to the path and back, as path_glyphs places the glyphs); the caret stands across the path there and the highlight bends with the text. A click on the letters, above the path, hits the frame. Depends on #636 (PathWarp::nearest). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 45 +++++++++++++++++++++++++ crates/doc/src/edit.rs | 8 ++++- crates/engine/src/cmd/path_type.rs | 54 ++++++++++++++++++++++++++++++ crates/engine/src/cmd/text.rs | 2 ++ crates/ui-egui/src/canvas.rs | 28 ++++++++++++++-- 6 files changed, 135 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a213adda0..35bc92d16 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Editing type on a path: the Type tool clicks, drags and double-clicks along the curve (the character under the pointer, not one on the straightened line), a click on the letters themselves hits the frame, and the caret and highlight bend with the path. No readiness percentage change. - **2026-10-12** · Type on a path has start and end brackets, as in InDesign: selected path type shows them across the path, and dragging either with the Selection or Direct Selection tool moves where the text starts or ends (text past the end bracket is overset). `type.pathOptions {end}`; IDML reads and writes EndBracket. No readiness percentage change. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 4aa16fd73..4efd32bf5 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -3166,6 +3166,51 @@ pub fn adjacent_row(l: &Line, x: f64, caret_y: f64, up: bool) -> Option { #[cfg(test)] mod tests; +/// Type on a path: between the composed line (x along it, y as composed) and the frame's path +/// space, as `path_glyphs` places the glyphs. +pub struct PathTextMap { + warp: designcraft_geom::warp::PathWarp, + start: f64, + /// Line y that sits on the path (the alignment), and the line's baseline. + on: f64, + baseline: f64, +} + +impl PathTextMap { + /// The map of `item` (type on a path) for its composed frame `ft`; None for other frames. + pub fn new(item: &designcraft_doc::Item, ft: &FrameText) -> Option { + let pt = item.text_frame()?.options.path.as_ref()?; + let warp = designcraft_geom::warp::PathWarp::new(&item.path.to_bezpath(), pt.flip); + let (on, baseline) = match ft.lines.first() { + Some(l) => ( + match pt.align { + designcraft_doc::PathAlign::Baseline => l.baseline, + designcraft_doc::PathAlign::Ascender => l.baseline - l.ascent, + designcraft_doc::PathAlign::Descender => l.baseline + l.descent, + designcraft_doc::PathAlign::Center => l.baseline - (l.ascent - l.descent) / 2.0, + }, + l.baseline, + ), + None => (0.0, 0.0), + }; + Some(PathTextMap { warp, start: pt.start, on, baseline }) + } + + /// A point of the composed line, on the path: along it by x, across it by y. + pub fn to_path(&self, p: Point) -> Point { + match self.warp.at(self.start + p.x) { + Some((q, a)) => q + designcraft_geom::Vec2::new(-a.sin(), a.cos()) * (p.y - self.on), + None => p, + } + } + + /// A point of the path space, on the composed line: how far along the path its nearest point + /// is, at the baseline (for hit testing). + pub fn to_line(&self, p: Point) -> Point { + Point::new(self.warp.nearest(p) - self.start, self.baseline) + } +} + /// Each visible glyph of a type-on-a-path frame placed on the path: its outline in frame space /// (grouped by run style). pub fn path_glyphs( diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 3bb827135..38de38ac7 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -416,5 +416,11 @@ pub fn item_hit(it: &Item, p: Point, tol: f64) -> bool { if area && it.path.is_closed() && designcraft_geom::hit::fill_contains(&bp, designcraft_geom::FillRule::NonZero, inner) { return true; } - designcraft_geom::hit::stroke_contains(&bp, it.stroke.weight.max(1.0), tol, inner) + // Type on a path: the letters stand over the path, so a click on them hits it too. + let reach = if it.text_frame().is_some_and(|t| t.options.path.is_some()) { PATH_TYPE_REACH } else { 0.0 }; + designcraft_geom::hit::stroke_contains(&bp, it.stroke.weight.max(1.0), tol + reach, inner) } + +/// How far from its path type on a path is hit (points): about a cap height of 12–18 pt type, +/// either side (the type may sit on or under the path). +const PATH_TYPE_REACH: f64 = 12.0; diff --git a/crates/engine/src/cmd/path_type.rs b/crates/engine/src/cmd/path_type.rs index c136cb8d1..9ce091a7e 100644 --- a/crates/engine/src/cmd/path_type.rs +++ b/crates/engine/src/cmd/path_type.rs @@ -214,4 +214,58 @@ mod tests { let path = |d: &designcraft_doc::Document| d.item(designcraft_doc::ItemId(id)).unwrap().text_frame().unwrap().options.path.clone(); assert_eq!(path(&back), path(&d)); } + + #[test] + fn the_type_tool_clicks_and_selects_along_a_curved_path() { + for flip in [false, true] { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + // An arch: up from (100, 400) over (300, 200) and down to (500, 400). + let id = s + .execute("path.create", &json!({"anchors": [{"p": [100, 400], "out": [100, 250]}, {"p": [300, 200], "in": [200, 200], "out": [400, 200]}, {"p": [500, 400], "in": [500, 250]}]})) + .unwrap()["id"] + .as_u64() + .unwrap(); + let r = s.execute("type.onPath", &json!({"id": id, "text": "AAAA BBBB CCCC DDDD EEEE FFFF GGGG", "flip": flip})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + // Where each glyph sits on the path, from the composed line. + let d = s.doc().unwrap().doc.clone(); + let it = d.item(designcraft_doc::ItemId(id)).unwrap(); + let cs = designcraft_compose::compose_story(&d, sid, &Default::default()); + let ft = &cs.frames[0]; + let map = designcraft_compose::PathTextMap::new(it, ft).unwrap(); + let l = &ft.lines[0]; + let glyph = |byte: usize| l.glyphs.iter().find(|g| g.byte == byte).unwrap(); + let on_path = |byte: usize| { + let g = glyph(byte); + // A little outside the path, as a click near the letter would be. + map.to_path(designcraft_geom::Point::new(g.x + g.adv * 0.3, l.baseline - 4.0)) + }; + // A click by the first C (byte 10), on the curve: the caret goes there. + let p = on_path(10); + let r = s.execute("text.placeCaret", &json!({"frame": id, "point": [p.x, p.y]})).unwrap(); + assert_eq!(r["pos"], 10, "flip={flip}: {r}"); + // Dragging to the first E (byte 20) selects C to D. + let q = on_path(20); + s.execute("text.extendTo", &json!({"frame": id, "point": [q.x, q.y]})).unwrap(); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!(t.range(), 10..20, "flip={flip}"); + // A double-click on a word (its first click places the caret) selects it. + let w = on_path(16); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0})).unwrap(); + s.execute("text.selectWord", &json!({"frame": id, "point": [w.x, w.y]})).unwrap(); + assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 15..19, "flip={flip}"); + // A click on a letter, above the path, hits the frame (the type tool's hit test). + let above = map.to_path(designcraft_geom::Point::new(glyph(10).x, l.baseline - l.ascent * 0.7)); + let sp = s.doc().unwrap().doc.clone(); + assert_eq!( + sp.hit_item(0, sp.parent_xf(&sp.find(designcraft_doc::ItemId(id)).unwrap()) * above, 1.0), + Some(designcraft_doc::ItemId(id)), + "flip={flip}" + ); + // The map runs both ways along the path. + let back = map.to_line(map.to_path(designcraft_geom::Point::new(123.0, l.baseline))); + assert!((back.x - 123.0).abs() < 0.01, "{back:?}"); + } + } } diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 71d390d02..a1a17ccc8 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -279,6 +279,8 @@ fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize, Op let inner = xf.inverse() * pt; let cs = s.cache.get(&st.doc, sid, None); let fi = cs.frames.iter().position(|f| f.frame == frame)?; + // Type on a path: the click lands on the line where it is along the path. + let inner = cs.frames.get(fi).and_then(|ft| compose::PathTextMap::new(it, ft)).map_or(inner, |m| m.to_line(inner)); if let Some((table, row, col, b)) = compose::hit_cell(&cs, fi, inner) { return Some((sid, b, Some(CellAddr { table, row, col }))); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2f30386c8..5bc9146ed 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -979,6 +979,28 @@ fn draw_text_selection( if range.is_empty() { continue; } + // Type on a path: the highlight bends with the text. + if let Some(map) = compose::PathTextMap::new(it, ft) { + for l in &ft.lines { + let (s, e) = (range.start.max(l.range.start), range.end.min(l.range.end)); + if s >= e { + continue; + } + for q in compose::highlight_quads(l, s, e, false) { + let (x0, x1) = (q[0].x.min(q[1].x), q[0].x.max(q[1].x)); + let (y0, y1) = (q[0].y.min(q[2].y), q[0].y.max(q[2].y)); + // Short slices, each turned with the path. + let n = ((x1 - x0) / 2.0).ceil().clamp(1.0, 2000.0) as usize; + for k in 0..n { + let (a, b) = (x0 + (x1 - x0) * k as f64 / n as f64, x0 + (x1 - x0) * (k + 1) as f64 / n as f64); + let poly = + [Point::new(a, y0), Point::new(b, y0), Point::new(b, y1), Point::new(a, y1)].map(|p| xf.to_screen(m * map.to_path(p))); + painter.add(egui::Shape::convex_polygon(poly.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 110), Stroke::NONE)); + } + } + } + continue; + } let mut quads = Vec::new(); let mut glyphs = Vec::new(); for (li, l) in ft.lines.iter().enumerate() { @@ -1010,8 +1032,10 @@ fn draw_text_selection( let m = a * doc.text_xf(it); let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0; if blink { - let p0 = xf.to_screen(m * Point::new(x, bl - asc)); - let p1 = xf.to_screen(m * Point::new(x, bl + desc)); + // Type on a path: the caret stands across the path where it is along it. + let on = |p: Point| compose::PathTextMap::new(it, ft).map_or(p, |map| map.to_path(p)); + let p0 = xf.to_screen(m * on(Point::new(x, bl - asc))); + let p1 = xf.to_screen(m * on(Point::new(x, bl + desc))); painter.line_segment([p0, p1], Stroke::new(1.0, Color32::BLACK)); } painter.ctx().request_repaint_after(std::time::Duration::from_millis(330)); From fc01666ac21856fc5b1263e237b6c72870d58e46 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 23:58:11 +0200 Subject: [PATCH 32/48] feat(text): select sentence, paragraph and story on 3, 4 and 5 clicks (fixes #622) --- crates/engine/src/cmd/text.rs | 106 ++++++++++++++++++++++++++++++ crates/mcp/src/headless.rs | 2 +- crates/tools/src/lib.rs | 7 +- crates/tools/src/text.rs | 18 +++--- crates/ui-egui/src/canvas.rs | 118 +++++++++++++++++++++++++++++----- crates/ui-egui/src/control.rs | 2 +- 6 files changed, 227 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 71d390d02..351fc37d6 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -31,6 +31,15 @@ pub fn specs() -> Vec { st.selection.text = Some(TextSel { anchor: a, focus: b, ..t }); ok() }), + cmd!(noundo "text.selectSentence", "Select Sentence", [], None, "{frame, point} — the sentence under the point (triple click)", has_doc, |s, p| { + select_around(s, p, "text.selectSentence", sentence_bounds) + }), + cmd!(noundo "text.selectParagraph", "Select Paragraph", [], None, "{frame, point} — the paragraph under the point (quadruple click)", has_doc, |s, p| { + select_around(s, p, "text.selectParagraph", para_bounds) + }), + cmd!(noundo "text.selectStory", "Select Story", [], None, "{frame, point} — all the text of the story under the point, threaded frames included (quintuple click)", has_doc, |s, p| { + select_around(s, p, "text.selectStory", |t, _| (0, t.len())) + }), cmd!(noundo "text.select", "Select Text", [], None, "{story, anchor, focus} (UTF-8 byte offsets into the story text, as find.find reports them: á or — counts 2 or 3)", has_doc, |s, p| { let sid = StoryId(p.get("story").and_then(Value::as_u64).unwrap_or(0)); let st = s.doc_mut()?; @@ -627,6 +636,62 @@ fn word_bounds(s: &str, i: usize) -> (usize, usize) { (a, b) } +/// Selects `bounds(text, byte)` around the byte under `point` in `frame`. Unlike a caret +/// placement, a press inside the current selection is no start of a text drag here: it is the +/// third or later click of a row, which lands in the word or sentence just selected. +fn select_around(s: &mut Session, p: &Value, id: &str, bounds: fn(&str, usize) -> (usize, usize)) -> Result { + let frame = id_param(p, "frame").ok_or_else(|| bad(id, "missing frame"))?; + let pt = point_param(p, "point").unwrap_or(Point::ZERO); + let (sid, b, cell) = hit_byte(s, frame, pt).ok_or_else(|| bad(id, "not a text frame"))?; + let st = s.doc_mut()?; + let text = st.doc.text_story(sid, cell).map(|x| x.text.clone()).unwrap_or_default(); + let (anchor, focus) = bounds(&text, floor_char_boundary(&text, b)); + st.selection = Selection::text(TextSel { story: sid, anchor, focus, frame: Some(frame), cell }); + st.revision += 1; + Ok(json!({"story": sid.0, "anchor": anchor, "focus": focus})) +} + +/// The paragraph around byte `i`, without its `\n`. +fn para_bounds(s: &str, i: usize) -> (usize, usize) { + let i = i.min(s.len()); + let a = s.get(..i).and_then(|t| t.rfind('\n')).map_or(0, |k| k + 1); + let b = s.get(i..).and_then(|t| t.find('\n')).map_or(s.len(), |k| i + k); + (a, b) +} + +/// The sentence around byte `i`, within its paragraph: it ends after `.`, `!`, `?` or `…` (and +/// any closing quotes or brackets) followed by a space or the paragraph end, or after a CJK full +/// stop, and takes the spaces after it, as deleting a sentence should leave none behind. +fn sentence_bounds(s: &str, i: usize) -> (usize, usize) { + let (pa, pb) = para_bounds(s, i); + let para = s.get(pa..pb).unwrap_or(""); + let mut start = 0; + let mut it = para.char_indices().peekable(); + while let Some((_, c)) = it.next() { + let cjk = matches!(c, '。' | '!' | '?'); + if !cjk && !matches!(c, '.' | '!' | '?' | '…') { + continue; + } + while it.peek().is_some_and(|&(_, c)| { + matches!(c, '.' | '!' | '?' | '…' | '。' | '!' | '?' | '"' | '\'' | '”' | '’' | '»' | '›' | ')' | ']' | '」' | '』' | ')') + }) { + it.next(); + } + if !cjk && it.peek().is_some_and(|&(_, c)| !c.is_whitespace()) { + continue; + } + while it.peek().is_some_and(|&(_, c)| c.is_whitespace()) { + it.next(); + } + let end = it.peek().map_or(para.len(), |&(k, _)| k); + if pa + end > i || end == para.len() { + return (pa + start, pa + end); + } + start = end; + } + (pa + start, pb) +} + /// Type ▸ Story Direction. fn story_direction(s: &mut Session, p: &Value) -> Result { let vertical = p.get("vertical").and_then(Value::as_bool).ok_or_else(|| bad("type.storyDirection", "missing vertical"))?; @@ -1896,3 +1961,44 @@ mod story_query_contract_tests { assert_eq!(s.execute("story.get", &json!({})).unwrap()["text"], ""); } } + +#[cfg(test)] +mod multi_click_tests { + use super::{para_bounds, sentence_bounds}; + + fn sentence(s: &str, at: &str) -> String { + let i = s.find(at).unwrap(); + let (a, b) = sentence_bounds(s, i); + s[a..b].to_string() + } + + #[test] + fn sentences_end_at_a_stop_before_a_space_or_the_paragraph_end() { + let s = "It costs 3.5 euros. \u{201C}Really?\u{201D} (Twice!) Yes\u{2026} fine.\nNext one. Last"; + assert_eq!(sentence(s, "costs"), "It costs 3.5 euros. "); + assert_eq!(sentence(s, "Really"), "\u{201C}Really?\u{201D} "); + assert_eq!(sentence(s, "Twice"), "(Twice!) "); + assert_eq!(sentence(s, "Yes"), "Yes\u{2026} "); + assert_eq!(sentence(s, "fine"), "fine."); + assert_eq!(sentence(s, "one"), "Next one. "); + assert_eq!(sentence(s, "Last"), "Last"); + // The caret at the very end of a paragraph is in its last sentence. + let end = s.find('\n').unwrap(); + assert_eq!(sentence_bounds(s, end), (s.find("fine").unwrap(), end)); + } + + #[test] + fn cjk_full_stops_end_sentences_without_a_space() { + let s = "これは文です。次の文!最後"; + assert_eq!(sentence(s, "次"), "次の文!"); + assert_eq!(sentence(s, "最後"), "最後"); + } + + #[test] + fn paragraphs_stop_at_returns() { + let s = "One.\nTwo two.\n"; + assert_eq!(para_bounds(s, 6), (5, 13)); + assert_eq!(para_bounds(s, 0), (0, 4)); + assert_eq!(para_bounds(s, s.len()), (s.len(), s.len())); + } +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index eea81ddd3..de0353575 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -123,7 +123,7 @@ impl Headless { let x = e.get("x").and_then(Value::as_f64).ok_or("pointer event needs numeric `x`")?; let y = e.get("y").and_then(Value::as_f64).ok_or("pointer event needs numeric `y`")?; let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base); - self.session.pointer(&PointerEvent { kind, pos: Point::new(x, y), mods }, VIEW).map_err(|e| e.to_string())?; + self.session.pointer(&PointerEvent { kind, pos: Point::new(x, y), mods, clicks: 0 }, VIEW).map_err(|e| e.to_string())?; } let requests: Vec = self.session.ui_requests.drain(..).map(|r| serde_json::to_value(r).unwrap_or_default()).collect(); Ok(json!({"selection": self.selection(), "tool": self.session.tool_id(), "requests": requests})) diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 247494f84..0bda9247d 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -63,11 +63,16 @@ pub struct PointerEvent { pub pos: Point, #[serde(default)] pub mods: Mods, + /// Presses in quick succession at about the same spot, this one included: 2 on a + /// `DoubleClick`, 3 or more on a `Down` (the Type tool selects a sentence, paragraph, story). + /// 0 or 1 is a single press. + #[serde(default)] + pub clicks: u8, } impl PointerEvent { pub fn new(kind: PointerKind, x: f64, y: f64) -> Self { - Self { kind, pos: Point::new(x, y), mods: Mods::default() } + Self { kind, pos: Point::new(x, y), mods: Mods::default(), clicks: 0 } } pub fn with_mods(mut self, m: Mods) -> Self { self.mods = m; diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 13a93f23a..f166cf8d6 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -50,19 +50,21 @@ impl Tool for TypeTool { && !matches!(it.content, designcraft_doc::Content::Graphic(_) | designcraft_doc::Content::Group { .. }) { let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos); + // Clicks in a row select a word, a sentence, a paragraph, then the whole story. + let unit = match (ev.kind, ev.clicks) { + (PointerKind::DoubleClick, _) => Some("text.selectWord"), + (_, 3) => Some("text.selectSentence"), + (_, 4) => Some("text.selectParagraph"), + (_, 5..) => Some("text.selectStory"), + _ => None, + }; // Whole rows/columns selected: wait to see whether this is a drag. - if ev.kind == PointerKind::Down && !ev.mods.shift && whole_rows_or_cols(cx) { + if unit.is_none() && !ev.mods.shift && whole_rows_or_cols(cx) { self.cell_drag = Some((id.0, sp)); return vec![]; } self.selecting = Some(id.0); - let cmd = if ev.kind == PointerKind::DoubleClick { - "text.selectWord" - } else if ev.mods.shift { - "text.extendTo" - } else { - "text.placeCaret" - }; + let cmd = unit.unwrap_or(if ev.mods.shift { "text.extendTo" } else { "text.placeCaret" }); return vec![Action::Exec(cmd.into(), json!({"frame": id.0, "point": [sp.x, sp.y]}))]; } vec![] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4c7313e61..0ab1d6ebb 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1341,6 +1341,19 @@ fn fmt_tick(v: f64) -> String { if (v - r).abs() < 1e-6 { format!("{}", r as i64) } else { format!("{v:.1}") } } +/// Presses in a row, this one included: each within egui's double-click delay and distance of the +/// one before. Counted on the press, as text selection wants (egui counts releases, up to three). +fn count_click(ui: &egui::Ui, id: egui::Id, at: Pos2, now: f64) -> u8 { + let (delay, dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist)); + let last: Option<(f64, Pos2, u8)> = ui.data(|d| d.get_temp(id)); + let n = match last { + Some((t, p, n)) if now - t < delay && p.distance(at) < dist => n.saturating_add(1), + _ => 1, + }; + ui.data_mut(|d| d.insert_temp(id, (now, at, n))); + n +} + fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rect: Rect) { let Some(v) = app.view().copied() else { return }; let xf = Xf::new(rect, &v); @@ -1383,20 +1396,15 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r let mut events: Vec = Vec::new(); let down_id = egui::Id::new(("canvas_pointer_down", app.pane)); let mut down: bool = ui.data(|d| d.get_temp(down_id)).unwrap_or(false); - let (pressed, released, origin, latest, dbl) = ui.input(|i| { - ( - i.pointer.primary_pressed(), - i.pointer.primary_released(), - i.pointer.press_origin(), - i.pointer.latest_pos(), - i.pointer.button_double_clicked(egui::PointerButton::Primary), - ) - }); + let (pressed, released, origin, latest, now) = + ui.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_released(), i.pointer.press_origin(), i.pointer.latest_pos(), i.time)); if pressed && !space && let Some(o) = origin.filter(|o| (if xf.rot == 0 { rect.contains(*o) } else { resp.rect.contains(*o) }) && resp.hovered()) { - events.push(PointerEvent { kind: if dbl { PointerKind::DoubleClick } else { PointerKind::Down }, pos: pos(o), mods: m }); + let clicks = count_click(ui, egui::Id::new(("canvas_clicks", app.pane)), o, now); + let kind = if clicks == 2 { PointerKind::DoubleClick } else { PointerKind::Down }; + events.push(PointerEvent { kind, pos: pos(o), mods: m, clicks }); down = true; } if down @@ -1404,7 +1412,7 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r && resp.drag_delta() != egui::Vec2::ZERO && let Some(p) = latest { - events.push(PointerEvent { kind: PointerKind::Drag, pos: pos(p), mods: m }); + events.push(PointerEvent { kind: PointerKind::Drag, pos: pos(p), mods: m, clicks: 0 }); } // Power Zoom: holding the Hand tool still for half a second turns the press into one. let hold_id = egui::Id::new(("canvas_hold", app.pane)); @@ -1414,8 +1422,8 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r let still = origin_pos.zip(latest).is_some_and(|(o, l)| (o - l).length() < 3.0); if !fired && still && now - start >= 0.5 { let p = pos(latest.unwrap_or(rect.center())); - events.push(PointerEvent { kind: PointerKind::Up, pos: p, mods: m }); - events.push(PointerEvent { kind: PointerKind::Down, pos: p, mods: Mods { alt: true, ..m } }); + events.push(PointerEvent { kind: PointerKind::Up, pos: p, mods: m, clicks: 0 }); + events.push(PointerEvent { kind: PointerKind::Down, pos: p, mods: Mods { alt: true, ..m }, clicks: 1 }); ui.data_mut(|d| d.insert_temp(hold_id, (start, true))); } else { ui.data_mut(|d| d.insert_temp(hold_id, (start, fired))); @@ -1428,10 +1436,10 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r } if down && released { let p = latest.unwrap_or(rect.center()); - events.push(PointerEvent { kind: PointerKind::Up, pos: pos(p), mods: m }); + events.push(PointerEvent { kind: PointerKind::Up, pos: pos(p), mods: m, clicks: 0 }); down = false; } else if !down && let Some(p) = resp.hover_pos() { - events.push(PointerEvent { kind: PointerKind::Move, pos: pos(p), mods: m }); + events.push(PointerEvent { kind: PointerKind::Move, pos: pos(p), mods: m, clicks: 0 }); } ui.data_mut(|d| d.insert_temp(down_id, down)); for e in events { @@ -1798,6 +1806,86 @@ mod tests { } } + /// A text frame holding `text`, the Type tool, and a harness stepping at 60 fps (clicks in a + /// row must land within the double-click delay). + fn typing(text: &str) -> (Harness<'static, DesignApp>, u64) { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let r = app.run("frame.create", json!({"rect": [72, 72, 400, 400], "content": "text", "text": text})).unwrap(); + let id = r["id"].as_u64().unwrap(); + app.run("tool.select", json!({"tool": "type"})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut DesignApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + (h, id) + } + + /// The screen point over the first line of frame `id` where a click puts the caret at a byte + /// in `bytes`. + fn screen_at(h: &mut Harness<'_, DesignApp>, id: u64, bytes: std::ops::Range) -> Pos2 { + let mut x = 74.0; + let p = loop { + assert!(x < 400.0, "no point on the first line hits {bytes:?}"); + h.state_mut().run("text.placeCaret", json!({"frame": id, "point": [x, 80.0]})).unwrap(); + if h.state().session.doc().unwrap().selection.text.is_some_and(|t| bytes.contains(&t.focus)) { + break Point::new(x, 80.0); + } + x += 1.0; + }; + h.state_mut().run("edit.deselectAll", json!({})).unwrap(); + let app = h.state(); + let c = app.session.layout().to_canvas(SpreadRef::Doc(0), p); + Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(c) + } + + fn click(h: &mut Harness<'_, DesignApp>, p: Pos2) { + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + h.step(); + } + } + + fn selected(h: &Harness<'_, DesignApp>) -> std::ops::Range { + h.state().session.doc().unwrap().selection.text.map(|t| t.range()).unwrap_or_default() + } + + #[test] + fn clicks_in_a_row_select_word_sentence_paragraph_and_story() { + let text = "One two. Three four! Five six.\nSecond para here."; + let (mut h, id) = typing(text); + // Inside "Three", the second sentence: its start is not the paragraph's. + let p = screen_at(&mut h, id, 10..14); + h.hover_at(p); + h.step(); + let mut got = vec![]; + for _ in 0..5 { + click(&mut h, p); + let r = selected(&h); + got.push(text[r].to_string()); + } + assert_eq!(got[1..], ["Three", "Three four! ", "One two. Three four! Five six.", text], "{got:?}"); + assert!(got[0].is_empty(), "one click places the caret: {got:?}"); + } + + #[test] + fn slow_clicks_do_not_add_up() { + let (mut h, id) = typing("One two. Three four!"); + let p = screen_at(&mut h, id, 10..14); + h.hover_at(p); + h.step(); + for _ in 0..3 { + click(&mut h, p); + h.run_steps(30); + } + assert!(selected(&h).is_empty(), "three clicks half a second apart leave a caret: {:?}", selected(&h)); + } + #[test] fn rulers_open_no_units_menu_without_a_document() { let mut h = harness(DesignApp::new(designcraft_engine::Session::new(), crate::Services::default())); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 12df640da..68e92104e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -147,7 +147,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> designcraft_geom::Point::new(x, y) }; let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods); - if let Err(e) = app.session.pointer(&PointerEvent { kind, pos, mods }, view) { + if let Err(e) = app.session.pointer(&PointerEvent { kind, pos, mods, clicks: 0 }, view) { return err(e); } app.after_engine(); From 3d59dd5d9757d248b8e5a531b633e8df09c20e11 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 00:38:39 +0200 Subject: [PATCH 33/48] fix(cmd): reject unknown spread values instead of falling back to page 1 (part of #651) --- crates/engine/src/cmd/file.rs | 8 +++---- crates/engine/src/cmd/guides.rs | 27 +++++------------------ crates/engine/src/cmd/linked.rs | 2 +- crates/engine/src/cmd/liquid.rs | 2 +- crates/engine/src/cmd/mathexpr.rs | 2 +- crates/engine/src/cmd/media.rs | 2 +- crates/engine/src/cmd/mod.rs | 23 ++++++++++++++------ crates/engine/src/cmd/object.rs | 36 ++++++++++++++++++++++++++----- crates/engine/src/cmd/qr.rs | 2 +- docs/mcp.md | 4 +++- 10 files changed, 65 insertions(+), 43 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 692c363e8..a1919b89e 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -335,7 +335,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result { let st = s.active()?; st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Graphic(_)))) }); - let spread = super::spread_param(p, "spread"); + let spread = super::spread_param(p, "file.place")?; let lid = s.doc()?.active_layer; let px = p.get("x").and_then(Value::as_f64); let py = p.get("y").and_then(Value::as_f64); @@ -458,7 +458,7 @@ fn place_load(s: &mut Session, p: &Value) -> Result { fn place_drop(s: &mut Session, p: &Value) -> Result { let (aid, (nw, nh)) = s.loaded.ok_or_else(|| bad("place.drop", "nothing loaded in the place cursor"))?; - let sr = super::spread_param(p, "spread"); + let sr = super::spread_param(p, "place.drop")?; let lid = s.doc()?.active_layer; let frame = super::id_param(p, "frame"); let rect = super::rect_param(p, "rect"); @@ -550,7 +550,7 @@ fn place_layout_page(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -> Re if ids.is_empty() { return Err(bad(ID, format!("page {page} has no objects"))); } - let to = super::spread_param(p, "spread"); + let to = super::spread_param(p, ID)?; let off = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { (Some(x), Some(y)) => designcraft_geom::Vec2::new(x - pr.x0, y - pr.y0), _ => designcraft_geom::Vec2::new(-pr.x0, -pr.y0), @@ -586,7 +586,7 @@ pub(crate) fn snippet_place(s: &mut Session, p: &Value) -> Result { if ids.is_empty() { return Err(bad("snippet.place", "the snippet is empty")); } - let sr = super::spread_param(p, "spread"); + let sr = super::spread_param(p, "snippet.place")?; let bounds = ids.iter().filter_map(|i| src.item(*i).map(|it| it.bounds())).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO); let off = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { (Some(x), Some(y)) => designcraft_geom::Vec2::new(x - bounds.x0, y - bounds.y0), diff --git a/crates/engine/src/cmd/guides.rs b/crates/engine/src/cmd/guides.rs index 47aacdc86..1111cb3b0 100644 --- a/crates/engine/src/cmd/guides.rs +++ b/crates/engine/src/cmd/guides.rs @@ -28,7 +28,7 @@ pub fn specs() -> Vec { cmd!("guide.move", "Move Guide", [], None, "{spread?, page, index, position}", has_doc, move_guide), cmd!("guide.delete", "Delete Guide", [], None, "{spread?, page, index}", has_doc, delete), cmd!("guide.deleteAll", "Delete All Guides on Spread", ["View", "Grids & Guides"], None, "{spread?}", has_doc, |s, p| { - let r = guide_spread(p, "guide.deleteAll")?; + let r = super::spread_param(p, "guide.deleteAll")?; s.edit(|d, _| { let sp = d.spread_mut(r).ok_or_else(|| bad("guide.deleteAll", "no such spread"))?; let mut n = 0; @@ -41,7 +41,7 @@ pub fn specs() -> Vec { }), cmd!(query "guide.list", "Guides", [], None, "{spread?} → [{page, index, orientation, position, spread}]", has_doc, |s, p| { let d = &s.doc()?.doc; - let sp = d.spread(guide_spread(p, "guide.list")?).ok_or_else(|| bad("guide.list", "no such spread"))?; + let sp = d.spread(super::spread_param(p, "guide.list")?).ok_or_else(|| bad("guide.list", "no such spread"))?; let mut out = Vec::new(); for (pi, pg) in sp.pages.iter().enumerate() { for (i, g) in pg.guides.iter().enumerate() { @@ -71,23 +71,6 @@ fn optional_usize(p: &Value, key: &str, command: &str) -> Result> p.get(key).map(|value| checked_usize(value, key, command)).transpose() } -/// Numeric spread shorthand or the serialized `SpreadRef` form, without lossy/defaulting casts. -fn guide_spread(p: &Value, command: &str) -> Result { - let Some(value) = p.get("spread") else { return Ok(SpreadRef::Doc(0)) }; - if value.is_number() { - return Ok(SpreadRef::Doc(checked_usize(value, "spread", command)?)); - } - let object = value.as_object().ok_or_else(|| bad(command, "`spread` must be a non-negative integer or a spread reference"))?; - let kind = object.get("kind").and_then(Value::as_str).ok_or_else(|| bad(command, "`spread.kind` must be `doc` or `parent`"))?; - let index = object.get("index").ok_or_else(|| bad(command, "missing `spread.index`"))?; - let index = checked_usize(index, "spread.index", command)?; - match kind { - "doc" => Ok(SpreadRef::Doc(index)), - "parent" => Ok(SpreadRef::Parent(index)), - _ => Err(bad(command, "`spread.kind` must be `doc` or `parent`")), - } -} - fn finite_number(p: &Value, key: &str, command: &str) -> Result> { let Some(value) = p.get(key) else { return Ok(None) }; let number = value.as_f64().ok_or_else(|| bad(command, format!("`{key}` must be a finite number")))?; @@ -113,7 +96,7 @@ fn page_at(d: &Document, r: SpreadRef, x: f64) -> usize { fn add(s: &mut Session, p: &Value) -> Result { let o = orientation(p)?; let pos = finite_number(p, "position", "guide.add")?.ok_or_else(|| bad("guide.add", "missing position"))?; - let r = guide_spread(p, "guide.add")?; + let r = super::spread_param(p, "guide.add")?; let spread_guide = p.get("spreadGuide").and_then(Value::as_bool).unwrap_or(false); let d = &s.doc()?.doc; let pi = match optional_usize(p, "page", "guide.add")? { @@ -139,7 +122,7 @@ fn add(s: &mut Session, p: &Value) -> Result { fn guide_ref(p: &Value, cmd: &str) -> Result<(SpreadRef, usize, usize)> { let pi = optional_usize(p, "page", cmd)?.ok_or_else(|| bad(cmd, "missing page"))?; let i = optional_usize(p, "index", cmd)?.ok_or_else(|| bad(cmd, "missing index"))?; - Ok((guide_spread(p, cmd)?, pi, i)) + Ok((super::spread_param(p, cmd)?, pi, i)) } /// Commands must use the same guide/layer protection as pointer editing. @@ -237,7 +220,7 @@ fn create_guides(s: &mut Session, p: &Value) -> Result { let cg = guide_gutter(p, "columnGutter")?; let to_page = str_param(p, "fitTo") == Some("page"); let remove = p.get("removeExisting").and_then(Value::as_bool).unwrap_or(false); - let r = guide_spread(p, "layout.createGuides")?; + let r = super::spread_param(p, "layout.createGuides")?; let only = optional_usize(p, "page", "layout.createGuides")?; let page_count = { let sp = s.doc()?.doc.spread(r).ok_or_else(|| bad("layout.createGuides", "no such spread"))?; diff --git a/crates/engine/src/cmd/linked.rs b/crates/engine/src/cmd/linked.rs index ec07a24b2..f504fe661 100644 --- a/crates/engine/src/cmd/linked.rs +++ b/crates/engine/src/cmd/linked.rs @@ -28,7 +28,7 @@ pub fn specs() -> Vec { |s, p| { let parent = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.placeAndLink", "missing story"))?); let rect = rect_param(p, "rect").ok_or_else(|| bad("story.placeAndLink", "missing rect"))?; - let sr = spread_param(p, "spread"); + let sr = spread_param(p, "story.placeAndLink")?; let layer = s.doc()?.active_layer; s.edit(|d, sel| { if d.story(parent).is_none() { diff --git a/crates/engine/src/cmd/liquid.rs b/crates/engine/src/cmd/liquid.rs index 604369df6..879fdb368 100644 --- a/crates/engine/src/cmd/liquid.rs +++ b/crates/engine/src/cmd/liquid.rs @@ -168,7 +168,7 @@ pub fn specs() -> Vec { } ), cmd!("guide.liquid", "Convert to Liquid Guide", [], None, "{spread?, page, index, on?: bool (default true)}", has_doc, |s, p| { - let sr = super::spread_param(p, "spread"); + let sr = super::spread_param(p, "guide.liquid")?; let pi = p.get("page").and_then(Value::as_u64).unwrap_or(0) as usize; let gi = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("guide.liquid", "`index` required"))? as usize; let on = p.get("on").and_then(Value::as_bool).unwrap_or(true); diff --git a/crates/engine/src/cmd/mathexpr.rs b/crates/engine/src/cmd/mathexpr.rs index ec90d52e3..d9ce51148 100644 --- a/crates/engine/src/cmd/mathexpr.rs +++ b/crates/engine/src/cmd/mathexpr.rs @@ -70,7 +70,7 @@ fn insert(s: &mut Session, p: &Value) -> Result { }); } let text = s.doc()?.selection.text.filter(|t| t.cell.is_none()); - let spread = super::spread_param(p, "spread"); + let spread = super::spread_param(p, "math.insert")?; let (px, py) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)); s.edit(|d, sel| { if let Some(t) = text { diff --git a/crates/engine/src/cmd/media.rs b/crates/engine/src/cmd/media.rs index b6e4cd4b8..f6d13ec0b 100644 --- a/crates/engine/src/cmd/media.rs +++ b/crates/engine/src/cmd/media.rs @@ -20,7 +20,7 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str, let st = s.active()?; st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Graphic(_)))) }); - let spread = super::spread_param(p, "spread"); + let spread = super::spread_param(p, "file.place")?; let lid = s.doc()?.active_layer; let (px, py) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)); let mime = mime.to_string(); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 68061af0a..31e4544f4 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -254,12 +254,23 @@ pub(crate) fn rect_param(p: &Value, key: &str) -> Option { let f = |i: usize| a.get(i).and_then(Value::as_f64); Some(Rect::new(f(0)?, f(1)?, f(2)?, f(3)?)) } -/// `{"kind":"doc","index":0}`, `0` or absent (= spread 0). -pub(crate) fn spread_param(p: &Value, key: &str) -> SpreadRef { - match p.get(key) { - Some(Value::Number(n)) => SpreadRef::Doc(n.as_u64().unwrap_or(0) as usize), - Some(v) => serde_json::from_value(v.clone()).unwrap_or(SpreadRef::Doc(0)), - None => SpreadRef::Doc(0), +/// The `spread` param: a body spread index (`0`), a spread reference (`{"kind":"doc"|"parent","index":0}`) +/// or absent (= spread 0). Anything else is an error: read as spread 0, a script aiming at a parent +/// spread got its items on the first page without a word. +pub(crate) fn spread_param(p: &Value, command: &str) -> Result { + let index = |v: &Value, key: &str| { + v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(command, format!("`{key}` must be a non-negative integer"))) + }; + let Some(value) = p.get("spread") else { return Ok(SpreadRef::Doc(0)) }; + if value.is_number() { + return Ok(SpreadRef::Doc(index(value, "spread")?)); + } + let object = value.as_object().ok_or_else(|| bad(command, "`spread` must be a non-negative integer or {kind: doc|parent, index}"))?; + let i = index(object.get("index").ok_or_else(|| bad(command, "missing `spread.index`"))?, "spread.index")?; + match object.get("kind").and_then(Value::as_str) { + Some("doc") => Ok(SpreadRef::Doc(i)), + Some("parent") => Ok(SpreadRef::Parent(i)), + _ => Err(bad(command, "`spread.kind` must be `doc` or `parent`")), } } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index cd13be117..21362ef9a 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Create Frame", [], None, - "{spread?, rect: [x0,y0,x1,y1] (spread coords), shape?: rectangle|ellipse|polygon, content?: graphic|text|unassigned, sides?: 6, text?: string, caret?: bool, vertical?: bool (text: a new vertical story)}", + "{spread?: index | {kind: doc|parent, index} (a parent spread: index into `parents` of document.inspect), rect: [x0,y0,x1,y1] (spread coords), shape?: rectangle|ellipse|polygon, content?: graphic|text|unassigned, sides?: 6, text?: string, caret?: bool, vertical?: bool (text: a new vertical story)}", has_doc, frame_create ), @@ -828,7 +828,7 @@ fn frame_create(s: &mut Session, p: &Value) -> Result { let rect = rect_param(p, "rect").ok_or_else(|| bad("frame.create", "missing rect"))?; let shape = str_param(p, "shape").unwrap_or("rectangle"); let content = str_param(p, "content").unwrap_or("graphic"); - let sr = spread_param(p, "spread"); + let sr = spread_param(p, "frame.create")?; let lid = s.doc()?.active_layer; let text = super::text_param(p, "text"); let caret = bool_or(p, "caret", content == "text"); @@ -891,7 +891,7 @@ pub(crate) fn polygon_in(r: Rect, sides: u32, inset: f64) -> designcraft_geom::P fn line_create(s: &mut Session, p: &Value) -> Result { let a = point_param(p, "a").ok_or_else(|| bad("line.create", "missing a"))?; let b = point_param(p, "b").ok_or_else(|| bad("line.create", "missing b"))?; - let sr = spread_param(p, "spread"); + let sr = spread_param(p, "line.create")?; let lid = s.doc()?.active_layer; s.edit(|d, sel| { let id = ItemId(d.alloc()); @@ -1592,7 +1592,7 @@ fn gap_move(s: &mut Session, p: &Value) -> Result { .and_then(Value::as_array) .and_then(|a| Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))) .ok_or_else(|| bad(ID, "missing at"))?; - let sr = spread_param(p, "spread"); + let sr = spread_param(p, ID)?; let (vertical, a, z, before, after) = find_gap(&s.doc()?.doc, sr, at).ok_or_else(|| bad(ID, "no gap there"))?; // `delta` along the gap's axis, or a drag (dx, dy) of which the axis part counts. let delta = @@ -1919,7 +1919,7 @@ fn path_create(s: &mut Session, p: &Value) -> Result { return Err(bad("path.create", "a path needs at least two anchors")); } let closed = bool_or(p, "closed", false); - let sr = spread_param(p, "spread"); + let sr = spread_param(p, "path.create")?; let lid = s.doc()?.active_layer; s.edit(|d, sel| { let id = ItemId(d.alloc()); @@ -2813,3 +2813,29 @@ mod swatch_name_tests { } } } + +#[cfg(test)] +mod spread_target_tests { + use super::*; + + #[test] + fn creating_on_a_parent_spread_and_rejecting_unknown_spreads() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + let parent = json!({"kind": "parent", "index": 0}); + let r = s.execute("frame.create", &json!({"spread": parent, "rect": [0, 0, 100, 100]})).unwrap(); + let id = ItemId(r["id"].as_u64().unwrap()); + assert_eq!(s.doc().unwrap().doc.find(id).unwrap().spread, SpreadRef::Parent(0)); + s.execute("line.create", &json!({"spread": parent, "a": [0, 0], "b": [50, 50]})).unwrap(); + assert_eq!(s.doc().unwrap().doc.parents[0].items.len(), 2); + + // Each of these used to land on the first body page. + for bad in + [json!("A-Parent"), json!("0"), json!(-1), json!(1.5), json!({"kind": "master", "index": 0}), json!({"kind": "parent"}), json!([0])] + { + assert!(s.execute("frame.create", &json!({"spread": bad, "rect": [0, 0, 100, 100]})).is_err(), "frame.create spread {bad}"); + assert!(s.execute("line.create", &json!({"spread": bad, "a": [0, 0], "b": [50, 50]})).is_err(), "line.create spread {bad}"); + } + assert!(s.doc().unwrap().doc.spreads[0].items.is_empty()); + } +} diff --git a/crates/engine/src/cmd/qr.rs b/crates/engine/src/cmd/qr.rs index c201de4ee..d68f5e696 100644 --- a/crates/engine/src/cmd/qr.rs +++ b/crates/engine/src/cmd/qr.rs @@ -58,7 +58,7 @@ fn qr_code(s: &mut Session, p: &Value) -> Result { let text = encode(p).map_err(|e| bad(ID, e))?; let color = str_param(p, "color").unwrap_or(designcraft_color::swatch::BLACK).to_string(); let rect = rect_param(p, "rect"); - let sr = spread_param(p, "spread"); + let sr = spread_param(p, ID)?; let st = s.doc()?; let lid = st.active_layer; let sel = st.selection.items.clone(); diff --git a/docs/mcp.md b/docs/mcp.md index 17c03a3df..f35585746 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -58,7 +58,9 @@ so inspect the document before retrying. The next request re-establishes the con Points (1/72 in), y down, in **spread** space: on a single-page spread the page's top-left is (0, 0); on a facing spread the right page starts at x = page width. `inspect_document` lists page bounds per spread. Page indices in -`render_page` / `export_png` are 0-based. `pointer` events in connected mode may use `"space": "screen"` (egui points, +`render_page` / `export_png` are 0-based. Commands that create items take `spread`: a body spread index, or +`{"kind": "parent", "index": 0}` for a parent spread (its index in `parents` of `inspect_document`); any other +value is an error. `pointer` events in connected mode may use `"space": "screen"` (egui points, see `ui_inspect` → `canvasRect`). ## Text offsets From 9e15d5a17eb9ebaf153e17075d9a3e4f6c23bb98 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 00:41:46 +0200 Subject: [PATCH 34/48] fix(cli): accept a UTF-8 BOM in scripts and document the syntax in script --help (fixes #654) --- README.md | 1 + apps/designcraft-cli/src/main.rs | 35 +++++++++++++++++++++++++++++++ apps/designcraft-cli/tests/cli.rs | 15 +++++++++++++ crates/engine/src/script.rs | 18 ++++++++++++++-- docs/agents.md | 8 +++++-- 5 files changed, 73 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 39fbe7100..0ec6540ba 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ cargo run --release -p designcraft -- --sample # open the sample mag cargo run --release -p designcraft -- --sample --control 7979 # + JSON control channel cargo run --release -p designcraft-cli -- run --sample --all-pages out/ # headless: render every page to PNG cargo run --release -p designcraft-cli -- commands # list every command +cargo run --release -p designcraft-cli -- script --help # command script syntax (docs/agents.md) cargo xtask ci # fmt, clippy, tests, assets, layering, wasm ``` diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index c3808f24b..dd62cd91d 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -38,6 +38,37 @@ use serde_json::{Value, json}; /// The usage block. Shared by `--help` (stdout, success) and an unknown command (stderr, failure). const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample]\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version"; +/// `script --help`: the script syntax, otherwise only in `describe script.run` and docs/agents.md. +const SCRIPT_HELP: &str = r#"usage: designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going] + +Runs a command script from FILE (stdin when FILE is `-` or missing), headless or, with +--connect PORT, in the running app (designcraft --control PORT). + +Script text, one step per line: + command.id {"param": 1} also `command.id={json}`, or `command.id` alone + {"command": "command.id", "params": {...}} JSON lines, or a JSON array of them + # comment, // comment skipped, like blank lines + UTF-8 with or without a byte order mark; LF or CRLF line endings. + +References to earlier results (in string parameters): + "$N.path" the value at `path` in the result of step N. N is the 0-based step index: + comments and blank lines are not steps. `$last` is the previous step. + Paths go through result fields and array indices: "$1.story", "$3.ids.0". + "${N.path}" inside a longer string, replaced by the value's text: "Frame ${0.id}". + +Text parameters: "\n" starts a new paragraph, "\u2028" is a forced line break, "\t" a tab. +Most commands ignore parameters they do not know instead of rejecting them: check names +with `designcraft-cli describe COMMAND`. + +Options: + --in FILE / --sample open a document (.designcraft or .idml) or the sample first + --save OUT save as .designcraft after the script succeeds + --export OUT export after the script succeeds (.pdf, .idml, .epub, .png, .jpg); repeatable + --keep-going run every step, recording failures as {"error": ...} results + +Prints {"completed": n, "results": [...]}, plus failedIndex / failedCommand / error and a +non-zero exit when a step fails."#; + /// The links line under the usage block. fn usage_footer() -> String { format!( @@ -335,6 +366,10 @@ fn script(args: &[String]) -> Result<(), String> { "--save" => save = Some(val()?), "--export" => export.push(val()?), "--keep-going" => keep_going = true, + "--help" | "-h" => { + outln!("{SCRIPT_HELP}"); + return Ok(()); + } f if !f.starts_with("--") && file.is_none() => file = Some(f.to_string()), other => return Err(format!("unknown script option `{other}`")), } diff --git a/apps/designcraft-cli/tests/cli.rs b/apps/designcraft-cli/tests/cli.rs index c2804be94..92f68ab56 100644 --- a/apps/designcraft-cli/tests/cli.rs +++ b/apps/designcraft-cli/tests/cli.rs @@ -87,6 +87,21 @@ fn help_goes_to_stdout_and_unknown_args_to_stderr() { assert!(String::from_utf8_lossy(&out.stderr).starts_with("usage: designcraft-cli")); } +/// `script --help` was "unknown script option `--help`"; the line syntax was only in +/// `describe script.run` (#654). +#[test] +fn script_help_documents_the_syntax() { + for flag in ["--help", "-h"] { + let out = cli().args(["script", flag]).output().unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(out.status.success(), "{flag}: {:?} {}", out.status, String::from_utf8_lossy(&out.stderr)); + assert!(stdout.starts_with("usage: designcraft-cli script"), "{flag}: {stdout:?}"); + for needle in ["0-based", "$1.story", "$3.ids.0", "\\u2028", "ignore"] { + assert!(stdout.contains(needle), "{flag}: no {needle:?} in {stdout:?}"); + } + } +} + /// `designcraft-cli commands | head -1` panicked with "failed printing to /// stdout: Broken pipe (os error 32)" and exit status 101. #[test] diff --git a/crates/engine/src/script.rs b/crates/engine/src/script.rs index a447cb6bb..d2304e651 100644 --- a/crates/engine/src/script.rs +++ b/crates/engine/src/script.rs @@ -9,7 +9,8 @@ //! //! **Script text** ([`parse`]): a JSON array of steps, JSON lines (`{"command": …, "params": …}`), //! or compact lines `command.id {json}` / `command.id={json}` / `command.id`; blank lines and -//! lines starting with `#` or `//` are ignored. +//! lines starting with `#` or `//` are ignored. A leading byte order mark and CRLF line endings are +//! accepted. use serde_json::{Value, json}; @@ -22,7 +23,9 @@ pub struct Step { /// Parse script text (see the module docs). pub fn parse(text: &str) -> Result, String> { - let t = text.trim(); + // Windows tools save "UTF-8 with BOM"; `trim` keeps U+FEFF, which then became part of the + // first command id. + let t = text.trim_start_matches('\u{feff}').trim(); if t.starts_with('[') { let v: Value = serde_json::from_str(t).map_err(|e| format!("script: {e}"))?; return v @@ -174,6 +177,17 @@ mod tests { assert!(parse("frame.create {bad").unwrap_err().contains("line 1")); } + /// Windows editors and PowerShell 5.1 save "UTF-8 with BOM" with CRLF line endings; the BOM + /// became part of the first command id (#654). + #[test] + fn a_leading_bom_and_crlf_parse_like_plain_text() { + let plain = "file.new {\"width\": 100}\nframe.create {\"rect\": [0, 0, 10, 10]}\n"; + let windows = format!("\u{feff}{}", plain.replace('\n', "\r\n")); + assert_eq!(parse(&windows).unwrap(), parse(plain).unwrap()); + let array = r#"[{"command": "file.new"}]"#; + assert_eq!(parse(&format!("\u{feff}{array}")).unwrap(), parse(array).unwrap()); + } + #[test] fn resolves_references() { let results = vec![json!({"id": 8, "story": 9, "ids": [4, 5]}), json!({"name": "A"})]; diff --git a/docs/agents.md b/docs/agents.md index 0dd23f062..5115654a2 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -21,8 +21,12 @@ Parameters are documented on each command (`params`), e.g. `{anchor? | paragraph ## Scripts: several commands, later ones using earlier results A script is one command per line (`command.id {json params}`; `#` comments), JSON lines, or a JSON array of -`{command, params}`. A parameter string `"$N.path"` becomes that value of step N's result (0-based, `$last` for the -previous step); `"${N.path}"` interpolates inside longer strings. +`{command, params}`, in UTF-8 with or without a byte order mark. A parameter string `"$N.path"` becomes that value +of step N's result: N is the 0-based step index (comments and blank lines are not steps, `$last` is the previous +step) and the path goes through result fields and array indices (`"$1.story"`, `"$3.ids.0"`); `"${N.path}"` +interpolates inside longer strings. In text parameters `\n` starts a new paragraph and `\u2028` is a forced line +break. Most commands ignore parameters they don't know instead of rejecting them, so check names with `describe`. +`designcraft-cli script --help` prints this reference. ```sh cat > page.dcs <<'DCS' From cc5b4a1f0f9dac7fc509191746ec048d6c8ffb1a Mon Sep 17 00:00:00 2001 From: Jason Brashear <115739170+webdevtodayjason@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:20:54 -0500 Subject: [PATCH 35/48] fix(color): mirror gradient midpoints when reversing Move and mirror segment midpoints when reversing gradient stops so reversed gradients preserve color and opacity interpolation. Validated locally on macOS with all six cargo xtask ci gates (1,277 tests passed, four existing tests ignored, 15 WASM compilation checks), plus independent regressions, undo/redo, native and IDML round trips, and PNG/PDF rendering. Refs #638 --- crates/color/src/gradient.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index c5e30a98d..4da4f2d84 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -104,9 +104,12 @@ impl Gradient { out } pub fn reverse(&mut self) { + // A midpoint belongs to the segment after its stop, so it moves with that segment and mirrors. + let mids: Vec<_> = self.stops.iter().rev().skip(1).map(|s| 1.0 - s.midpoint).collect(); self.stops.reverse(); - for s in &mut self.stops { + for (s, m) in self.stops.iter_mut().zip(mids.into_iter().chain([0.5])) { s.offset = 1.0 - s.offset; + s.midpoint = m; } } pub fn sort(&mut self) { @@ -219,8 +222,24 @@ mod tests { #[test] fn reverse_stops() { let mut g = Gradient::default(); + g.stops[0].midpoint = 0.25; g.reverse(); assert_eq!(g.stops[0].color.to_hex(), "#000000"); assert_eq!(g.stops[0].offset, 0.0); + // The midpoint moves to its reversed segment and mirrors. + assert!((g.sample(0.75).0.to_rgb()[0] - 0.5).abs() < 1e-5); + assert_eq!(g.stops.iter().map(|s| s.midpoint).collect::>(), [0.75, 0.5]); + + // With several stops, the reversed gradient is the original mirrored. + let mut g = Gradient::default(); + g.stops[0].midpoint = 0.3; + g.stops.insert(1, GradientStop { offset: 0.4, color: Color::rgb(1.0, 0.0, 0.0), opacity: 0.5, midpoint: 0.8 }); + let original = g.clone(); + g.reverse(); + for t in [0.1, 0.3, 0.5, 0.7, 0.9] { + let ((c, o), (want_c, want_o)) = (g.sample(t), original.sample(1.0 - t)); + assert!(c.to_rgb().iter().zip(want_c.to_rgb()).all(|(a, b)| (a - b).abs() < 1e-5), "colour at {t}"); + assert!((o - want_o).abs() < 1e-5, "opacity at {t}"); + } } } From 91263c912f1c1f2c61c087541d446e88edf43401 Mon Sep 17 00:00:00 2001 From: Jason Brashear <115739170+webdevtodayjason@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:21:52 -0500 Subject: [PATCH 36/48] fix(textimport): preserve leading empty Tagged Text paragraphs Preserve paragraph breaks after a ParaStyle tag starts the story so leading empty paragraphs and their styles survive Tagged Text import. Validated individually and after #638 with all six cargo xtask ci gates (1,278 tests passed, four existing tests ignored, 15 WASM compilation checks), six independent paragraph/header/Unicode probes, and an actual export-to-placement command smoke test. Refs #641 --- crates/textimport/src/tagged.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/textimport/src/tagged.rs b/crates/textimport/src/tagged.rs index cfd08eea7..b84577eae 100644 --- a/crates/textimport/src/tagged.rs +++ b/crates/textimport/src/tagged.rs @@ -99,6 +99,7 @@ pub fn import(bytes: &[u8]) -> Result { let chars: Vec = text.chars().collect(); // Paragraphs: (style, runs of (text, format)). let mut paras: Vec<(String, Vec<(String, CharFormat)>)> = vec![(String::new(), Vec::new())]; + let mut started = false; let mut fmt = CharFormat::default(); let mut para_styles: Vec = Vec::new(); let mut char_styles: Vec = Vec::new(); @@ -151,6 +152,7 @@ pub fn import(bytes: &[u8]) -> Result { } } "ParaStyle" => { + started = true; let v = value.trim().to_string(); if !v.is_empty() && !para_styles.contains(&v) { para_styles.push(v.clone()); @@ -187,7 +189,7 @@ pub fn import(bytes: &[u8]) -> Result { } let style = paras.last().map(|p| p.0.clone()).unwrap_or_default(); // Header lines before any text don't start paragraphs. - if paras.len() == 1 && paras[0].1.is_empty() { + if !started && paras.len() == 1 && paras.first().is_some_and(|p| p.1.is_empty()) { i += 1; continue; } @@ -243,6 +245,18 @@ mod tests { assert_eq!(import("\r\nA<0xD83D>B<0xDE00>C".as_bytes()).unwrap().story.text, "ABC"); } + #[test] + fn tagged_text_keeps_a_leading_empty_paragraph() { + let doc = Document::new(&designcraft_doc::build::NewDocument::default()); + let mut story = Story::new(StoryId(0)); + story.insert(0, "\nText"); + story.paras[0].style = "Head".into(); + story.paras[1].style = "Body".into(); + let back = import(tagged_text(&doc, &story).as_bytes()).unwrap(); + assert_eq!(back.story.text, story.text); + assert_eq!(back.story.paras, story.paras); + } + #[test] fn tagged_text_round_trips() { let src = "\r\n\r\nCaf<0x00E9> \\<1\\>\r\nA word and big text"; From 7abcbc745f33daf175af8b09e52ae12400ce5ec8 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:44:20 +1000 Subject: [PATCH 37/48] PARITY-DATA-SOURCE: honor quoted delimiters and record boundaries Co-Authored-By: Codex --- crates/engine/src/cmd/datamerge/parse.rs | 106 +++++++-- crates/engine/src/cmd/datamerge/tests.rs | 276 +++++++++++++++++++++++ 2 files changed, 369 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/datamerge/parse.rs b/crates/engine/src/cmd/datamerge/parse.rs index e65493dfc..6ab10f3d9 100644 --- a/crates/engine/src/cmd/datamerge/parse.rs +++ b/crates/engine/src/cmd/datamerge/parse.rs @@ -367,10 +367,16 @@ fn sniff(name: &str, text: &str) -> Delimiter { if ext == "csv" { return Delimiter::Comma; } - let header = first_physical_line(text); - if header.contains('\t') { + let tab = header_delimiter_candidate(text, '\t'); + let semicolon = header_delimiter_candidate(text, ';'); + let comma = header_delimiter_candidate(text, ','); + // Prefer formats with coherent quoted-field boundaries. If none separates fields, + // retain the legacy permissive choice; this is detection, not record validation. + let prefer_coherent = [&tab, &semicolon, &comma].iter().any(|c| c.found && c.coherent); + let eligible = |c: &HeaderDelimiterCandidate| c.found && (!prefer_coherent || c.coherent); + if eligible(&tab) { Delimiter::Tab - } else if header.contains(';') && !header.contains(',') { + } else if eligible(&semicolon) && !eligible(&comma) { Delimiter::Semicolon } else { Delimiter::Comma @@ -381,8 +387,66 @@ pub fn extension(name: &str) -> String { Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase() } -fn first_physical_line(text: &str) -> &str { - text.split('\n').next().unwrap_or("").trim_end_matches('\r') +struct HeaderDelimiterCandidate { + found: bool, + coherent: bool, +} + +/// A candidate separator outside quotes in its first nonempty logical record. +/// Only that candidate starts a new field. Quotes in an unquoted field stay literal; +/// Non-whitespace text after a quoted field closes lowers coherence, not validity. +fn header_delimiter_candidate(text: &str, delimiter: char) -> HeaderDelimiterCandidate { + let mut found = false; + let mut coherent = true; + let mut quoted = false; + let mut after_quote = false; + let mut field_start = true; + let mut nonempty = false; + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + if quoted { + if c == '"' { + if chars.peek() == Some(&'"') { + chars.next(); + nonempty = true; + } else { + quoted = false; + after_quote = true; + } + } else if !c.is_whitespace() { + nonempty = true; + } + continue; + } + match c { + '"' if field_start => { + quoted = true; + field_start = false; + } + separator if separator == delimiter => { + found = true; + field_start = true; + after_quote = false; + } + '\r' | '\n' => { + if nonempty { + break; + } + found = false; + coherent = true; + field_start = true; + after_quote = false; + } + _ => { + // Whitespace may follow a closing quote, but does not start a new field + // or allow a later non-whitespace suffix to regain coherence. + coherent &= !after_quote || c.is_whitespace(); + nonempty |= !c.is_whitespace(); + field_start = false; + } + } + } + HeaderDelimiterCandidate { found, coherent: coherent && !quoted } } /// Header cell: trim, drop one leading apostrophe, then `@` is image and `#` is qr. @@ -452,6 +516,7 @@ fn parse_delimited(s: &str, delim: char) -> Result, String> { let mut row: Vec = Vec::new(); let mut field = String::new(); let mut quoted = false; + let mut field_start = true; let mut line = 1usize; let mut row_line = 1usize; let mut data_rows = 0usize; @@ -501,21 +566,25 @@ fn parse_delimited(s: &str, delim: char) -> Result, String> { }; while let Some(c) = chars.next() { - if c == '\n' { + if c == '\n' || (c == '\r' && chars.peek() != Some(&'\n')) { line = line.saturating_add(1); } if quoted { match c { '"' if chars.peek() == Some(&'"') => { - field.push('"'); + if !discarding { + field.push('"'); + } chars.next(); } '"' => quoted = false, - _ => field.push(c), + _ if !discarding => field.push(c), + _ => {} } continue; } if c == delim { + field_start = true; if discarding { field.clear(); continue; @@ -531,14 +600,25 @@ fn parse_delimited(s: &str, delim: char) -> Result, String> { continue; } match c { - '"' if field.is_empty() && !discarding => quoted = true, - '\r' => {} - '\n' => { + '"' if field_start => { + quoted = true; + field_start = false; + } + '\r' | '\n' => { + if c == '\r' && chars.peek() == Some(&'\n') { + chars.next(); + line = line.saturating_add(1); + } finish(&mut row, &mut field, &mut rows, &mut data_rows, &mut header_width, row_line, &mut discarding)?; row_line = line; + field_start = true; + } + _ => { + field_start = false; + if !discarding { + field.push(c); + } } - _ if discarding => {} - _ => field.push(c), } } if !field.is_empty() || !row.is_empty() || discarding { diff --git a/crates/engine/src/cmd/datamerge/tests.rs b/crates/engine/src/cmd/datamerge/tests.rs index e4636d109..f4c2004e4 100644 --- a/crates/engine/src/cmd/datamerge/tests.rs +++ b/crates/engine/src/cmd/datamerge/tests.rs @@ -85,6 +85,282 @@ fn csv_parsing() { assert_eq!(quoted[1][0], "Ada\nGrace"); } +#[test] +fn data_source_sniffs_quoted_logical_headers() { + for (body, delimiter, names) in [ + ("\"Last, First\";City\nAda;London\n", Delimiter::Semicolon, ["Last, First", "City"]), + ("\"Name\tLabel\",City\nAda,London\n", Delimiter::Comma, ["Name\tLabel", "City"]), + ("\"Name\nLabel\";City\nAda;London\n", Delimiter::Semicolon, ["Name\nLabel", "City"]), + ("\r\n \r\nName;City\r\nAda;London\r\n", Delimiter::Semicolon, ["Name", "City"]), + ("\"Name \"\"label,\"\"\";City\nAda;London\n", Delimiter::Semicolon, ["Name \"label,\"", "City"]), + ] { + let mut s = Session::new(); + new_doc(&mut s, 1); + let result = exec(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(body.as_bytes()), "name": "people.txt"})); + assert_eq!(result["records"], 1, "{body:?}"); + let source = &s.documents()[0].doc.data_merge.sources[0]; + assert_eq!(source.delimiter, delimiter, "{body:?}"); + assert_eq!(source.fields.iter().map(|f| f.name.as_str()).collect::>(), names, "{body:?}"); + assert_eq!(source.rows, vec![vec!["Ada", "London"]], "{body:?}"); + } +} + +#[test] +fn data_source_sniff_quote_state_belongs_to_the_candidate_delimiter() { + for ending in ["\n", "\r\n", "\r"] { + for (body, delimiter, names) in [ + (format!("Name,\"preferred\tCity\"{ending}Ada\tLondon{ending}"), Delimiter::Tab, ["Name,\"preferred", "City\""]), + (format!("\"Last, First\";City{ending}Ada;London{ending}"), Delimiter::Semicolon, ["Last, First", "City"]), + (format!("\"Name\tLabel\",City{ending}Ada,London{ending}"), Delimiter::Comma, ["Name\tLabel", "City"]), + (format!("\"\";\"\"{ending} \t{ending}\"Last, First\";City{ending}Ada;London{ending}"), Delimiter::Semicolon, ["Last, First", "City"]), + ] { + let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap(); + let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(delimiter)).unwrap(); + let fields: Vec<_> = names.into_iter().map(|name| DataField { name: name.into(), kind: DataFieldKind::Text }).collect(); + assert_eq!(automatic.delimiter, delimiter, "{body:?}"); + assert_eq!(automatic.fields, fields, "{body:?}"); + assert_eq!(automatic.rows, vec![vec!["Ada", "London"]], "{body:?}"); + assert!(automatic.warnings.is_empty(), "{body:?}: {:?}", automatic.warnings); + assert_eq!(automatic, explicit, "{body:?}"); + } + } +} + +#[test] +fn data_source_sniff_prefers_coherent_quoted_field_boundaries() { + for ending in ["\n", "\r\n", "\r"] { + for (header, first, second, delimiter, names, rows, warnings) in [ + ( + "\"Name\";\"Last, First\"", + "\"Ada\";\"Lovelace, Countess\"", + "\"Grace\";\"Hopper, Admiral\"", + Delimiter::Semicolon, + vec!["Name", "Last, First"], + vec![vec!["Ada", "Lovelace, Countess"], vec!["Grace", "Hopper, Admiral"]], + vec![], + ), + ( + "\"Name\",\"Region\tCity\"", + "\"Ada\",\"London\"", + "\"Grace\",\"Paris\"", + Delimiter::Comma, + vec!["Name", "Region\tCity"], + vec![vec!["Ada", "London"], vec!["Grace", "Paris"]], + vec![], + ), + ( + "\"Name\";\"City\";\"Last, \"\"First\"\"\";\"Title\"", + "\"Ada\";\"London\";\"Lovelace, \"\"Countess\"\"\";\"Analyst\"", + "\"Grace\";\"Paris\";\"Hopper, \"\"Admiral\"\"\";\"Engineer\";\"ignored\"", + Delimiter::Semicolon, + vec!["Name", "City", "Last, \"First\"", "Title"], + vec![vec!["Ada", "London", "Lovelace, \"Countess\"", "Analyst"], vec!["Grace", "Paris", "Hopper, \"Admiral\"", "Engineer"]], + vec!["Row 3 has cells past the header. Those cells were ignored."], + ), + ( + "\"Name\",\"Role\",\"Region\t\"\"City\"\"\",\"Title\"", + "\"Ada\",\"Analyst\",\"London\t\"\"West End\"\"\",\"Countess\"", + "\"Grace\",\"Engineer\",\"Paris\t\"\"Centre\"\"\",\"Admiral\",\"ignored\"", + Delimiter::Comma, + vec!["Name", "Role", "Region\t\"City\"", "Title"], + vec![vec!["Ada", "Analyst", "London\t\"West End\"", "Countess"], vec!["Grace", "Engineer", "Paris\t\"Centre\"", "Admiral"]], + vec!["Row 3 has cells past the header. Those cells were ignored."], + ), + ] { + let body = format!("{header}{ending}{first}{ending}{second}{ending}"); + let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap(); + let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(delimiter)).unwrap(); + let fields: Vec<_> = names.into_iter().map(|name| DataField { name: name.into(), kind: DataFieldKind::Text }).collect(); + assert_eq!(automatic.delimiter, delimiter, "{body:?}"); + assert_eq!(automatic.fields, fields, "{body:?}"); + assert_eq!(automatic.rows, rows, "{body:?}"); + assert_eq!(automatic.warnings, warnings, "{body:?}"); + assert_eq!(automatic, explicit, "{body:?}"); + } + } +} + +#[test] +fn data_source_sniff_retains_priority_and_permissive_fallback() { + for ending in ["\n", "\r\n", "\r"] { + for (header, row, delimiter, names, cells) in [ + // Equally coherent candidates retain Tab, then Comma, then Semicolon priority. + ("Name,Title\tCity", "Ada,Analyst\tLondon\tignored", Delimiter::Tab, ["Name,Title", "City"], ["Ada,Analyst", "London"]), + ("Name;Title,City", "Ada;Analyst,London,ignored", Delimiter::Comma, ["Name;Title", "City"], ["Ada;Analyst", "London"]), + // No coherent candidate separates fields here. Keep parsing the trailing text + // after a quoted field exactly as before, using the same delimiter priority. + ( + "\"Name\"suffix,Title\tCity", + "\"Ada\"suffix,Analyst\tLondon\tignored", + Delimiter::Tab, + ["Namesuffix,Title", "City"], + ["Adasuffix,Analyst", "London"], + ), + ( + "\"Name\"suffix;Title,City", + "\"Ada\"suffix;Analyst,London,ignored", + Delimiter::Comma, + ["Namesuffix;Title", "City"], + ["Adasuffix;Analyst", "London"], + ), + ("\"Name\"suffix;City", "\"Ada\"suffix;London;ignored", Delimiter::Semicolon, ["Namesuffix", "City"], ["Adasuffix", "London"]), + ] { + let body = format!("{header}{ending}{row}{ending}"); + let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap(); + let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(delimiter)).unwrap(); + let fields: Vec<_> = names.into_iter().map(|name| DataField { name: name.into(), kind: DataFieldKind::Text }).collect(); + assert_eq!(automatic.delimiter, delimiter, "{body:?}"); + assert_eq!(automatic.fields, fields, "{body:?}"); + assert_eq!(automatic.rows, vec![cells.to_vec()], "{body:?}"); + assert_eq!(automatic.warnings, vec!["Row 2 has cells past the header. Those cells were ignored."], "{body:?}"); + assert_eq!(automatic, explicit, "{body:?}"); + } + } +} + +#[test] +fn data_source_sniff_allows_post_quote_whitespace() { + for ending in ["\n", "\r\n", "\r"] { + for space in [" ", "\u{00A0}", "\u{2003}"] { + for (header, separator, delimiter, names) in [ + (format!("\"Name\"{space};\"Last, First\""), ';', Delimiter::Semicolon, ["Name", "Last, First"]), + (format!("\"Name\"{space},\"Region\tCity\"{space}"), ',', Delimiter::Comma, ["Name", "Region\tCity"]), + (format!("\"Name\"{space}\t\"Last, First; City\"{space}"), '\t', Delimiter::Tab, ["Name", "Last, First; City"]), + ] { + let body = format!( + "{header}{ending}\"Ada\"{space}{separator}\"London\"{space}{ending}\"Grace\"{space}{separator}\"Paris\"{space}{separator}ignored{ending}" + ); + let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap(); + let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(delimiter)).unwrap(); + let fields: Vec<_> = names.into_iter().map(|name| DataField { name: name.into(), kind: DataFieldKind::Text }).collect(); + assert_eq!(automatic.delimiter, delimiter, "{body:?}"); + assert_eq!(automatic.fields, fields, "{body:?}"); + // Header classification trims; record values must keep their whitespace. + assert_eq!( + automatic.rows, + vec![vec![format!("Ada{space}"), format!("London{space}")], vec![format!("Grace{space}"), format!("Paris{space}")]], + "{body:?}" + ); + assert_eq!(automatic.warnings, vec!["Row 3 has cells past the header. Those cells were ignored."], "{body:?}"); + assert_eq!(automatic, explicit, "{body:?}"); + } + } + } +} + +#[test] +fn data_source_sniff_post_quote_whitespace_keeps_closed_field_state() { + for ending in ["\n", "\r\n", "\r"] { + for space in [" ", "\u{00A0}", "\u{2003}"] { + for suffix in ["suffix", "\"suffix\""] { + let body = format!("\"Name\"{space}{suffix};\"Last, First\"{ending}Ada,London,ignored{ending}"); + // Both separated formats have incompatible post-quote suffixes. Keep + // the permissive Comma fallback, including literal quotes in the name. + let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap(); + let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(Delimiter::Comma)).unwrap(); + assert_eq!(automatic.delimiter, Delimiter::Comma, "{body:?}"); + assert_eq!( + automatic.fields, + vec![ + DataField { name: format!("Name{space}{suffix};\"Last"), kind: DataFieldKind::Text }, + DataField { name: "First\"".into(), kind: DataFieldKind::Text }, + ], + "{body:?}" + ); + assert_eq!(automatic.rows, vec![vec!["Ada", "London"]], "{body:?}"); + assert_eq!(automatic.warnings, vec!["Row 2 has cells past the header. Those cells were ignored."], "{body:?}"); + assert_eq!(automatic, explicit, "{body:?}"); + } + } + } +} + +#[test] +fn data_source_literal_quote_header_merges_the_detected_column() { + let mut s = Session::new(); + new_doc(&mut s, 1); + text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "", None); + let body = "Name,\"preferred\tCity\"\nAda\tLondon\nGrace\tParis\n"; + let selected = exec(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(body.as_bytes()), "name": "people.txt"})); + assert_eq!(selected["records"], 2); + let source = &s.documents()[0].doc.data_merge.sources[0]; + assert_eq!(source.delimiter, Delimiter::Tab); + assert_eq!(source.rows, vec![vec!["Ada", "London"], vec!["Grace", "Paris"]]); + exec(&mut s, "data.placeholder.add", json!({"field": "City\""})); + let merged = exec(&mut s, "data.merge", json!({})); + assert_eq!(merged["records"], 2); + assert_eq!(merged["pages"], 2); + assert_eq!(texts(&s), vec!["London", "Paris"]); +} + +#[test] +fn data_source_preserves_quoted_newlines_and_counts_record_lines() { + for ending in ["\n", "\r\n", "\r"] { + let body = format!("Name,City{ending}\"Ada{ending}Lovelace\",London{ending}Grace,Paris,extra{ending}"); + let mut s = Session::new(); + new_doc(&mut s, 1); + let result = select_csv(&mut s, &body); + assert_eq!(result["records"], 2, "{ending:?}"); + let source = &s.documents()[0].doc.data_merge.sources[0]; + assert_eq!(source.rows, vec![vec![format!("Ada{ending}Lovelace"), "London".into()], vec!["Grace".into(), "Paris".into()]]); + assert_eq!(source.warnings.len(), 1); + assert!(source.warnings[0].contains("Row 4"), "{:?}", source.warnings); + } +} + +#[test] +fn data_source_quoted_header_and_cr_records_merge_independently() { + let mut s = Session::new(); + new_doc(&mut s, 1); + text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "", None); + let body = "\u{FEFF}\"Last, First\";City\rAda;London\rGrace;Paris\r"; + exec(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(body.as_bytes()), "name": "people.txt"})); + exec(&mut s, "data.placeholder.add", json!({"field": "Last, First"})); + let template = s.documents()[0].doc.clone(); + let result = exec(&mut s, "data.merge", json!({})); + assert_eq!(result["records"], 2); + assert_eq!(result["pages"], 2); + assert_eq!(texts(&s), vec!["Ada", "Grace"]); + assert_eq!(serde_json::to_value(&s.documents()[0].doc).unwrap(), serde_json::to_value(&template).unwrap()); +} + +#[test] +fn data_source_discarded_quoted_fields_keep_records_and_warning_lines() { + for ending in ["\n", "\r\n", "\r"] { + for (discarded, following_line) in [(format!("\"Address \"\"line 1\"\"{ending}Address line 2\""), 4), ("literal\"quote".into(), 3)] { + let mut s = Session::new(); + new_doc(&mut s, 1); + text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "", None); + let body = format!("Name{ending}Ada,,,{discarded}{ending}Grace,extra{ending}"); + let selected = select_csv(&mut s, &body); + assert_eq!(selected["records"], 2, "{body:?}"); + let source = &s.documents()[0].doc.data_merge.sources[0]; + assert_eq!(source.rows, vec![vec!["Ada"], vec!["Grace"]], "{body:?}"); + assert_eq!(source.warnings.len(), 2, "{:?}", source.warnings); + assert!(source.warnings[0].contains("Row 2"), "{:?}", source.warnings); + assert!(source.warnings[1].contains(&format!("Row {following_line}")), "{:?}", source.warnings); + exec(&mut s, "data.placeholder.add", json!({"field": "Name"})); + let merged = exec(&mut s, "data.merge", json!({})); + assert_eq!(merged["records"], 2); + assert_eq!(merged["pages"], 2); + assert_eq!(texts(&s), vec!["Ada", "Grace"]); + } + } +} + +#[test] +fn data_source_delimiter_override_and_known_extensions_win() { + let body = "Name;City\nAda;London\n"; + let csv = parse::table_from_bytes(body.as_bytes(), "people.csv", None).unwrap(); + assert_eq!(csv.delimiter, Delimiter::Comma); + assert_eq!(csv.rows, vec![vec!["Ada;London"]]); + let explicit = parse::table_from_bytes(body.as_bytes(), "people.csv", Some(Delimiter::Semicolon)).unwrap(); + assert_eq!(explicit.rows, vec![vec!["Ada", "London"]]); + let tab = parse::table_from_bytes(b"Name,Title\tCity\nAda,Analyst\tLondon", "people.tsv", None).unwrap(); + assert_eq!(tab.delimiter, Delimiter::Tab); + assert_eq!(tab.rows, vec![vec!["Ada,Analyst", "London"]]); +} + #[test] fn text_sniff_padding_skips_and_warnings() { let dir = scratch("sniff"); From f7adb11cdc31b8997389c0fc7cb4fafab8cb4e7f Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:46:10 +1000 Subject: [PATCH 38/48] PARITY-CHARACTER-STYLE: capture selected character formatting Co-Authored-By: Codex --- crates/engine/src/cmd/style.rs | 23 +- .../tests/character_style_from_selection.rs | 274 ++++++++++++++++++ crates/ui-egui/src/panels/styles.rs | 41 +++ 3 files changed, 336 insertions(+), 2 deletions(-) create mode 100644 crates/engine/tests/character_style_from_selection.rs diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 8a520b685..aa1cc4a5f 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -105,7 +105,7 @@ pub fn specs() -> Vec { "New Character Style…", [], None, - "{name, basedOn?: style | \"[None]\", chars?: {…}} — the style sets only `chars` → {name} (made unique)", + "{name, basedOn?: style | \"[None]\", chars?: {…}, fromSelection?: bool (default false)} — if true, capture the first selected character's style/local overrides (caret: typing format), then overlay chars (null unsets); only explicit fill names are validated; paragraph formatting stays inherited; no text target: chars only → {name} (made unique)", has_doc, create_char ), @@ -622,8 +622,27 @@ fn delete_para(s: &mut Session, p: &Value) -> Result { fn create_char(s: &mut Session, p: &Value) -> Result { let base = str_param(p, "name").unwrap_or("Character Style 1").to_string(); - let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + let mut chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + // Validate caller-supplied fills; captured formatting may retain a deleted swatch name. super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.create")?; + if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false) + && let Some(target) = super::text::format_targets_pub(s).first() + { + let st = s.doc()?; + let story = st.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; + // Match paragraph fromSelection's first-character/caret policy, but keep paragraph + // attributes inherited instead of freezing the fully resolved character properties. + let format = if target.range.is_empty() { story.char_format_at(target.range.start) } else { story.format_after(target.range.start) }; + let mut captured = st.doc.styles.char_style_attrs(&format.style); + captured.merge(&format.over); + // Replay the explicit fields so null clears a captured value rather than looking absent. + if let Some(overrides) = p.get("chars").and_then(Value::as_object) { + for (key, value) in overrides { + captured.set_json(key, value).map_err(|e| bad("style", e))?; + } + } + chars = captured; + } // Based on [None] is based on nothing. let based_on = str_param(p, "basedOn").filter(|b| *b != designcraft_doc::NO_CHAR_STYLE).map(str::to_string); s.edit(|d, _| { diff --git a/crates/engine/tests/character_style_from_selection.rs b/crates/engine/tests/character_style_from_selection.rs new file mode 100644 index 000000000..0c9d80831 --- /dev/null +++ b/crates/engine/tests/character_style_from_selection.rs @@ -0,0 +1,274 @@ +//! Character Styles panel's existing `fromSelection` command contract. +use designcraft_doc::{CharAttrs, StoryId}; +use designcraft_engine::Session; +use serde_json::{Value, json}; + +fn story(text: &str) -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let frame = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": text})).unwrap(); + (s, frame["story"].as_u64().unwrap()) +} + +fn select(s: &mut Session, sid: u64, anchor: usize, focus: usize) { + s.execute("text.select", &json!({"story": sid, "anchor": anchor, "focus": focus})).unwrap(); +} + +fn chars(s: &Session, name: &str) -> CharAttrs { + s.doc().unwrap().doc.styles.char_style(name).unwrap().chars.clone() +} + +fn document(s: &Session) -> Value { + serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap() +} + +#[test] +fn character_capture_overlays_style_chain_local_and_explicit_attrs_but_inherits_paragraph() { + let (mut s, sid) = story("alpha\nbeta"); + s.execute("style.paragraph.create", &json!({"name": "Source paragraph", "chars": {"size": 18}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Destination", "chars": {"size": 30, "underline": true}})).unwrap(); + s.execute("style.character.create", &json!({"name": "Base", "chars": {"underline": true, "tracking": 10, "strikethrough": true}})).unwrap(); + s.execute("style.character.create", &json!({"name": "Emphasis", "basedOn": "Base", "chars": {"fontStyle": "Italic"}})).unwrap(); + select(&mut s, sid, 0, 5); + s.execute("style.paragraph.apply", &json!({"name": "Source paragraph"})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap(); + s.execute("type.char", &json!({"tracking": 20, "underline": false})).unwrap(); + s.execute("style.character.create", &json!({"name": "Captured", "fromSelection": true, "chars": {"tracking": 7}})).unwrap(); + assert_eq!( + chars(&s, "Captured"), + CharAttrs { font_style: Some("Italic".into()), underline: Some(false), tracking: Some(7.0), strikethrough: Some(true), ..Default::default() } + ); + select(&mut s, sid, 6, 10); + s.execute("style.paragraph.apply", &json!({"name": "Destination"})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Captured"})).unwrap(); + let resolved = s.execute("type.selectionAttrs", &json!({})).unwrap(); + assert_eq!(resolved["chars"]["fontStyle"], "Italic"); + assert_eq!(resolved["chars"]["strikethrough"], true); + assert_eq!(resolved["chars"]["underline"], false); + assert_eq!(resolved["chars"]["tracking"], 7.0); + assert_eq!(resolved["chars"]["size"], 30.0); + s.execute("style.paragraph.edit", &json!({"name": "Destination", "chars": {"size": 34}})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 34.0); + // The captured attributes are a snapshot, independent of future source-style edits. + s.execute("style.character.edit", &json!({"name": "Emphasis", "chars": {"fontStyle": "Bold"}})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontStyle"], "Italic"); +} + +#[test] +fn character_capture_reproducer_is_one_undo_step_and_survives_native_reopen() { + let (mut s, sid) = story("alpha beta"); + select(&mut s, sid, 0, 5); + s.execute("type.char", &json!({"underline": true})).unwrap(); + let before = document(&s); + let selection = s.doc().unwrap().selection.clone(); + let undo_len = s.doc().unwrap().history.undo.len(); + let source = serde_json::to_value(s.doc().unwrap().doc.story(StoryId(sid)).unwrap()).unwrap(); + s.execute("style.character.create", &json!({"name": "Captured", "fromSelection": true})).unwrap(); + assert_eq!(chars(&s, "Captured"), CharAttrs { underline: Some(true), ..Default::default() }); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1); + assert_eq!(s.doc().unwrap().selection, selection); + assert_eq!(serde_json::to_value(s.doc().unwrap().doc.story(StoryId(sid)).unwrap()).unwrap(), source); + let after = document(&s); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(document(&s), before); + assert_eq!(s.doc().unwrap().selection, selection); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(document(&s), after); + assert_eq!(s.doc().unwrap().selection, selection); + + // Both commands use the native ZIP format, without a filesystem fixture. + let saved = s.execute("file.serialize", &json!({})).unwrap(); + let mut reopened = Session::new(); + reopened.execute("file.openBytes", &json!({"name": "capture.designcraft", "base64": saved["base64"]})).unwrap(); + assert_eq!(chars(&reopened, "Captured"), chars(&s, "Captured")); + select(&mut reopened, sid, 6, 10); + assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["underline"], false); + let before_apply = document(&reopened); + reopened.execute("style.character.apply", &json!({"name": "Captured"})).unwrap(); + assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["underline"], true); + assert_eq!(reopened.doc().unwrap().doc.story(StoryId(sid)).unwrap().text, "alpha beta"); + let after_apply = document(&reopened); + reopened.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(document(&reopened), before_apply); + reopened.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(document(&reopened), after_apply); +} + +#[test] +fn character_capture_matches_paragraph_selection_and_caret_boundary_policy() { + let (mut s, sid) = story("éX"); + select(&mut s, sid, 0, 2); + s.execute("type.char", &json!({"underline": true})).unwrap(); + select(&mut s, sid, 2, 3); + s.execute("type.char", &json!({"underline": false})).unwrap(); + // A selected character takes the run on the right; a caret takes the typing run on the left. + // Reversed and mixed selections start at the lowest byte offset, just like paragraph capture. + for (anchor, focus, underlined) in [(3, 2, false), (2, 2, true), (3, 0, true)] { + select(&mut s, sid, anchor, focus); + let p = s.execute("style.paragraph.create", &json!({"name": "Paragraph", "fromSelection": true})).unwrap(); + let c = s.execute("style.character.create", &json!({"name": "Character", "fromSelection": true})).unwrap(); + let p = s.doc().unwrap().doc.styles.para(p["name"].as_str().unwrap()).unwrap(); + let c = chars(&s, c["name"].as_str().unwrap()); + assert_eq!(c.underline, Some(underlined)); + assert_eq!(c.underline, p.chars.underline); + assert_eq!(c.size, None, "unlike paragraph capture, character capture must not freeze paragraph defaults"); + } +} + +#[test] +fn character_capture_is_opt_in_and_no_text_uses_only_explicit_chars() { + let (mut s, sid) = story("alpha"); + select(&mut s, sid, 0, 5); + s.execute("type.char", &json!({"underline": true})).unwrap(); + for options in [json!({"name": "Plain"}), json!({"name": "Plain", "fromSelection": false})] { + let created = s.execute("style.character.create", &options).unwrap(); + assert!(chars(&s, created["name"].as_str().unwrap()).is_empty()); + } + assert!(s.doc().unwrap().doc.styles.char_style("Plain 2").is_some()); + s.execute("selection.set", &json!({"ids": []})).unwrap(); + s.execute("style.character.create", &json!({"name": "No target", "fromSelection": true, "basedOn": "Plain", "chars": {"underline": false}})) + .unwrap(); + assert_eq!(chars(&s, "No target"), CharAttrs { underline: Some(false), ..Default::default() }); + assert_eq!(s.doc().unwrap().doc.styles.char_style("No target").unwrap().based_on.as_deref(), Some("Plain")); + let (mut empty, empty_sid) = story(""); + select(&mut empty, empty_sid, 0, 0); + empty.execute("style.character.create", &json!({"name": "Empty", "fromSelection": true})).unwrap(); + assert!(chars(&empty, "Empty").is_empty()); +} + +#[test] +fn character_capture_retains_deleted_fill_and_reuses_it_after_native_reopen() { + for named_source in [false, true] { + let (mut s, sid) = story("alpha beta gamma"); + s.execute("swatch.create", &json!({"name": "Ink", "color": "#ff0000"})).unwrap(); + select(&mut s, sid, 0, 5); + if named_source { + s.execute("style.character.create", &json!({"name": "Base", "chars": {"fill": "Ink"}})).unwrap(); + s.execute("style.character.create", &json!({"name": "Source", "basedOn": "Base"})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Source"})).unwrap(); + } else { + s.execute("type.char", &json!({"fill": "Ink"})).unwrap(); + } + s.execute("swatch.delete", &json!({"name": "Ink"})).unwrap(); + s.doc().unwrap().doc.check().unwrap(); + let before = document(&s); + let selection = s.doc().unwrap().selection.clone(); + let undo_len = s.doc().unwrap().history.undo.len(); + s.execute("style.character.create", &json!({"name": "Captured", "fromSelection": true})).unwrap(); + assert_eq!(chars(&s, "Captured"), CharAttrs { fill: Some("Ink".into()), ..Default::default() }); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1); + assert_eq!(s.doc().unwrap().selection, selection); + let after = document(&s); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(document(&s), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(document(&s), after); + // Non-fill overrides do not turn an existing captured reference into explicit input. + s.execute("style.character.create", &json!({"name": "Tweaked", "fromSelection": true, "chars": {"underline": false, "tracking": 0}})) + .unwrap(); + assert_eq!(chars(&s, "Tweaked"), CharAttrs { fill: Some("Ink".into()), underline: Some(false), tracking: Some(0.0), ..Default::default() }); + s.execute("style.character.create", &json!({"name": "Paper", "fromSelection": true, "chars": {"fill": "[Paper]"}})).unwrap(); + assert_eq!(chars(&s, "Paper"), CharAttrs { fill: Some("[Paper]".into()), ..Default::default() }); + s.execute("style.character.create", &json!({"name": "Cleared fill", "fromSelection": true, "chars": {"fill": null}})).unwrap(); + assert!(chars(&s, "Cleared fill").is_empty()); + select(&mut s, sid, 6, 10); + s.execute("style.character.apply", &json!({"name": "Captured"})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "Ink"); + s.doc().unwrap().doc.check().unwrap(); + + let saved = s.execute("file.serialize", &json!({})).unwrap(); + let mut reopened = Session::new(); + reopened.execute("file.openBytes", &json!({"name": "deleted-fill.designcraft", "base64": saved["base64"]})).unwrap(); + reopened.doc().unwrap().doc.check().unwrap(); + for name in ["Captured", "Tweaked", "Paper", "Cleared fill"] { + assert_eq!(chars(&reopened, name), chars(&s, name)); + } + select(&mut reopened, sid, 6, 10); + assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "Ink"); + select(&mut reopened, sid, 11, 16); + assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "[Black]"); + reopened.execute("style.character.apply", &json!({"name": "Captured"})).unwrap(); + assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "Ink"); + reopened.execute("style.character.apply", &json!({"name": "Cleared fill"})).unwrap(); + assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "[Black]"); + assert_eq!(reopened.doc().unwrap().doc.story(StoryId(sid)).unwrap().text, "alpha beta gamma"); + } +} + +#[test] +fn character_capture_rejects_explicit_missing_fills_without_losing_redo() { + let (mut s, sid) = story("alpha"); + s.execute("swatch.create", &json!({"name": "Ink", "color": "#ff0000"})).unwrap(); + select(&mut s, sid, 0, 5); + s.execute("type.char", &json!({"fill": "Ink"})).unwrap(); + s.execute("swatch.delete", &json!({"name": "Ink"})).unwrap(); + s.execute("style.character.create", &json!({"name": "Redo sentinel"})).unwrap(); + let redo_doc = s.doc().unwrap().doc.clone(); + let redo_selection = s.doc().unwrap().selection.clone(); + s.execute("edit.undo", &json!({})).unwrap(); + let before = s.doc().unwrap().clone(); + let journal = s.journal.clone(); + for (capture, fill) in [(true, "Missing"), (true, "Ink"), (false, "Missing")] { + let error = s.execute("style.character.create", &json!({"name": "Invalid", "fromSelection": capture, "chars": {"fill": fill}})).unwrap_err(); + assert!(error.to_string().contains(fill)); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before.doc)); + assert_eq!(s.doc().unwrap().selection, before.selection); + assert_eq!(s.doc().unwrap().revision, before.revision); + assert_eq!(format!("{:?}", s.doc().unwrap().history), format!("{:?}", before.history)); + assert_eq!(s.journal, journal); + } + s.execute("edit.redo", &json!({})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &redo_doc)); + assert_eq!(s.doc().unwrap().selection, redo_selection); + assert!(s.doc().unwrap().doc.styles.char_style("Redo sentinel").is_some()); +} + +#[test] +fn character_capture_explicit_null_restores_live_paragraph_inheritance() { + for named_source in [false, true] { + let (mut s, sid) = story("alpha\nbeta"); + select(&mut s, sid, 0, 5); + let source = json!({"tracking": 20, "underline": true, "underlineWeight": 1.5, "fontStyle": "Italic"}); + if named_source { + s.execute("style.character.create", &json!({"name": "Base", "chars": source})).unwrap(); + s.execute("style.character.create", &json!({"name": "Source", "basedOn": "Base"})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Source"})).unwrap(); + } else { + s.execute("type.char", &source).unwrap(); + } + // Omitting chars still captures values, including a nullable attribute's numeric value. + s.execute("style.character.create", &json!({"name": "Unchanged", "fromSelection": true})).unwrap(); + assert_eq!(chars(&s, "Unchanged").tracking, Some(20.0)); + assert_eq!(chars(&s, "Unchanged").underline_weight, Some(Some(1.5))); + let before = document(&s); + let undo_len = s.doc().unwrap().history.undo.len(); + s.execute( + "style.character.create", + &json!({"name": "Cleared", "fromSelection": true, + "chars": {"tracking": null, "underlineWeight": null, "underline": false, "baselineShift": 0}}), + ) + .unwrap(); + assert_eq!( + chars(&s, "Cleared"), + CharAttrs { font_style: Some("Italic".into()), underline: Some(false), baseline_shift: Some(0.0), ..Default::default() } + ); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1); + let after = document(&s); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(document(&s), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(document(&s), after); + s.execute("style.paragraph.create", &json!({"name": "Destination", "chars": {"tracking": 30, "underlineWeight": 2}})).unwrap(); + select(&mut s, sid, 6, 10); + s.execute("style.paragraph.apply", &json!({"name": "Destination"})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Cleared"})).unwrap(); + let effective = s.execute("type.selectionAttrs", &json!({})).unwrap(); + assert_eq!(effective["chars"]["tracking"], 30.0); + assert_eq!(effective["chars"]["underlineWeight"], 2.0); + assert_eq!(effective["chars"]["underline"], false); + s.execute("style.paragraph.edit", &json!({"name": "Destination", "chars": {"tracking": 40, "underlineWeight": 3}})).unwrap(); + let effective = s.execute("type.selectionAttrs", &json!({})).unwrap(); + assert_eq!(effective["chars"]["tracking"], 40.0); + assert_eq!(effective["chars"]["underlineWeight"], 3.0); + } +} diff --git a/crates/ui-egui/src/panels/styles.rs b/crates/ui-egui/src/panels/styles.rs index 25a596644..d64cc1bd5 100644 --- a/crates/ui-egui/src/panels/styles.rs +++ b/crates/ui-egui/src/panels/styles.rs @@ -150,3 +150,44 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren } }); } + +#[cfg(test)] +mod tests { + use egui_kittest::{Harness, kittest::Queryable}; + + use super::*; + + #[test] + fn character_style_plus_captures_selection_for_reuse() { + 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": "alpha beta"})).unwrap(); + app.run("text.select", json!({"story": frame["story"], "anchor": 0, "focus": 5})).unwrap(); + app.run("type.char", json!({"underline": true})).unwrap(); + let selection = app.session.doc().unwrap().selection.clone(); + let undo_len = app.session.doc().unwrap().history.undo.len(); + let mut h = Harness::builder().with_size(vec2(360.0, 240.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + character(app, ui); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Create New Style from Selection").click(); + h.run_steps(3); + let app = h.state_mut(); + let style = app.session.doc().unwrap().doc.styles.char_style("Character Style 1").unwrap(); + assert_eq!(style.chars, designcraft_doc::CharAttrs { underline: Some(true), ..Default::default() }); + assert_eq!(app.session.doc().unwrap().selection, selection); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo_len + 1); + app.run("text.select", json!({"story": frame["story"], "anchor": 6, "focus": 10})).unwrap(); + app.run("style.character.apply", json!({"name": "Character Style 1"})).unwrap(); + assert_eq!(app.run("type.selectionAttrs", json!({})).unwrap()["chars"]["underline"], true); + } +} From b4db4286085db3abb2c98d0e97f54233852b1c55 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:48:56 +1000 Subject: [PATCH 39/48] PARITY-ARRANGE: step past same-layer siblings in Arrange Co-Authored-By: Codex --- crates/engine/src/cmd/object.rs | 180 +++++++++++++++++++++++++++++++- 1 file changed, 179 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index cd13be117..bc01aabb7 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1318,6 +1318,43 @@ fn arrange(s: &mut Session, p: &Value) -> Result { let ids = targets(s, p)?; let to = str_param(p, "to").unwrap_or("front").to_string(); s.edit(|d, _| { + if matches!(to.as_str(), "forward" | "backward") { + let selected: HashSet = ids.iter().copied().collect(); + let mut positions: Vec<_> = selected + .iter() + .filter_map(|id| { + let loc = d.find(*id)?; + match loc.path.as_slice() { + [i] => Some((loc.spread, *i)), + _ => None, + } + }) + .collect(); + // Move the leading selected items first, independently of selection/click order. + // Swapping only with unselected siblings leaves the other selected indices valid. + positions.sort_by_key(|(_, i)| *i); + let forward = to == "forward"; + if forward { + positions.reverse(); + } + for (spread, i) in positions { + let Some(sp) = d.spread_mut(spread) else { continue }; + let Some(it) = sp.items.get(i) else { continue }; + let layer = it.layer; + let sibling = if forward { + sp.items.iter().enumerate().skip(i.saturating_add(1)).find(|(_, it)| it.layer == layer) + } else { + sp.items.iter().enumerate().take(i).rev().find(|(_, it)| it.layer == layer) + }; + if let Some((j, it)) = sibling + && !selected.contains(&it.id) + { + // Keep every other layer's slots and relative stacking order untouched. + sp.items.swap(i, j); + } + } + return ok(); + } for id in &ids { let Some(loc) = d.find(*id) else { continue }; if loc.path.len() != 1 { @@ -1330,7 +1367,6 @@ fn arrange(s: &mut Session, p: &Value) -> Result { let j = match to.as_str() { "front" => n - 1, "back" => 0, - "forward" => (i + 1).min(n - 1), _ => i.saturating_sub(1), }; sp.items.insert(j, it); @@ -2121,6 +2157,148 @@ fn step_and_repeat(s: &mut Session, p: &Value) -> Result { }) } +#[cfg(test)] +mod arrange_tests { + use super::*; + + fn fixture(layers: [usize; N]) -> (Session, [ItemId; N]) { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + let first = s.doc().unwrap().doc.default_layer().0; + let second = s.execute("layer.new", &json!({"name": "Other layer"})).unwrap()["id"].as_u64().unwrap(); + let layer_ids = [first, second]; + let mut ids = [ItemId(0); N]; + for (id, layer) in ids.iter_mut().zip(layers) { + // Overlap objects within each layer; separate layers spatially for hit testing. + let x = if layer == 0 { 0 } else { 200 }; + *id = ItemId(s.execute("frame.create", &json!({"rect": [x, 0, x + 100, 100]})).unwrap()["id"].as_u64().unwrap()); + s.execute("object.setLayer", &json!({"ids": [id.0], "layer": layer_ids[layer]})).unwrap(); + } + (s, ids) + } + + fn stack(s: &Session) -> Vec { + s.doc().unwrap().doc.spreads[0].items.iter().map(|it| it.id).collect() + } + + fn snapshot(s: &Session) -> Value { + let st = s.doc().unwrap(); + json!({"document": &*st.doc, "selection": &st.selection}) + } + + fn assert_items_unchanged(before: &Document, after: &Document) { + assert_eq!(serde_json::to_value(&before.layers).unwrap(), serde_json::to_value(&after.layers).unwrap()); + for it in &before.spreads[0].items { + assert_eq!(serde_json::to_value(it).unwrap(), serde_json::to_value(after.item(it.id).unwrap()).unwrap()); + } + } + + #[test] + fn arrange_steps_past_other_layers_change_hit_order_and_undo() { + for direction in ["forward", "backward"] { + let (mut s, [a, x, y, b]) = fixture([0, 1, 1, 0]); + let selected = if direction == "forward" { a } else { b }; + s.execute("selection.set", &json!({"ids": [selected.0]})).unwrap(); + let before = snapshot(&s); + let doc = s.doc().unwrap().doc.clone(); + let undo_len = s.doc().unwrap().history.undo.len(); + let hit = |s: &Session| s.doc().unwrap().doc.hit_item(0, Point::new(50.0, 50.0), 0.0); + assert_eq!(hit(&s), Some(b)); + + s.execute("object.arrange", &json!({"to": direction})).unwrap(); + assert_eq!(stack(&s), [b, x, y, a], "{direction}"); + assert_eq!(hit(&s), Some(a), "the visible topmost object must change"); + assert_items_unchanged(&doc, &s.doc().unwrap().doc); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1); + assert_eq!(s.doc().unwrap().selection.items, [selected]); + let after = snapshot(&s); + + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(snapshot(&s), before); + assert_eq!(hit(&s), Some(b)); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(snapshot(&s), after); + assert_eq!(hit(&s), Some(a)); + } + } + + #[test] + fn arrange_multiple_selection_preserves_order_independent_of_input_order() { + // Layer 0: a, b, c, d. Layer 1: x, y. Arrays name the original stack indices. + let cases = [ + ("forward", [0, 2], [4, 1, 0, 3, 2, 5]), // a,b cross c as a block + ("backward", [2, 4], [2, 1, 4, 3, 0, 5]), // b,c cross a as a block + ("forward", [0, 4], [2, 1, 0, 3, 5, 4]), // separate selected blocks + ("backward", [2, 5], [2, 1, 0, 3, 5, 4]), + ("forward", [4, 5], [0, 1, 2, 3, 4, 5]), // front block is already at its endpoint + ("backward", [0, 2], [0, 1, 2, 3, 4, 5]), // back block is already at its endpoint + ]; + for (direction, selected, expected) in cases { + for reverse in [false, true] { + let (mut s, ids) = fixture([0, 1, 0, 1, 0, 0]); + let doc = s.doc().unwrap().doc.clone(); + let mut selected = selected.map(|i| ids[i].0).to_vec(); + if reverse { + selected.reverse(); + } + // Explicit command targets may contain duplicates: an item still moves once. + selected.push(selected[0]); + s.execute("object.arrange", &json!({"to": direction, "ids": selected})).unwrap(); + assert_eq!(stack(&s), expected.map(|i| ids[i]), "{direction}, reverse={reverse}"); + assert_items_unchanged(&doc, &s.doc().unwrap().doc); + } + } + } + + #[test] + fn arrange_same_layer_steps_stop_at_layer_endpoints() { + let cases = [ + ("forward", vec![1], [0, 2, 1, 3, 4]), + ("backward", vec![3], [0, 1, 3, 2, 4]), + ("forward", vec![3], [0, 1, 2, 3, 4]), + ("backward", vec![1], [0, 1, 2, 3, 4]), + ("forward", vec![3, 1, 2], [0, 1, 2, 3, 4]), + ("backward", vec![3, 1, 2], [0, 1, 2, 3, 4]), + ]; + for (direction, selected, expected) in cases { + let (mut s, ids) = fixture([1, 0, 0, 0, 1]); + let selected: Vec<_> = selected.into_iter().map(|i| ids[i].0).collect(); + s.execute("object.arrange", &json!({"to": direction, "ids": selected})).unwrap(); + assert_eq!(stack(&s), expected.map(|i| ids[i])); + } + for direction in ["forward", "backward"] { + let (mut s, [x, a, y]) = fixture([1, 0, 1]); + s.execute("object.arrange", &json!({"to": direction, "ids": [a.0]})).unwrap(); + assert_eq!(stack(&s), [x, a, y], "only object on its layer"); + } + } + + #[test] + fn arrange_selection_on_two_layers_moves_each_layer_independently() { + for direction in ["forward", "backward"] { + let (mut s, [a, x, b, y]) = fixture([0, 1, 0, 1]); + let selected = if direction == "forward" { [x.0, a.0] } else { [b.0, y.0] }; + s.execute("object.arrange", &json!({"to": direction, "ids": selected})).unwrap(); + assert_eq!(stack(&s), [b, y, a, x]); + let doc = &s.doc().unwrap().doc; + assert_eq!(doc.hit_item(0, Point::new(50.0, 50.0), 0.0), Some(a)); + assert_eq!(doc.hit_item(0, Point::new(250.0, 50.0), 0.0), Some(x)); + } + } + + #[test] + fn arrange_menu_steps_use_same_layer_order() { + for command in ["object.bringForward", "object.sendBackward"] { + let (mut s, [a, x, b]) = fixture([0, 1, 0]); + let selected = if command == "object.bringForward" { a } else { b }; + s.execute("selection.set", &json!({"ids": [selected.0]})).unwrap(); + s.execute(command, &json!({})).unwrap(); + assert_eq!(stack(&s), [b, x, a]); + assert_eq!(s.doc().unwrap().doc.hit_item(0, Point::new(50.0, 50.0), 0.0), Some(a)); + } + } +} + #[cfg(test)] mod step_repeat_tests { use super::*; From e507b986014d5913ac841f22ee432d14b8736814 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:51:50 +1000 Subject: [PATCH 40/48] PARITY-TAB-LEADERS: draw right-indent tab leaders Co-Authored-By: Codex --- crates/compose/src/lib.rs | 12 ++++ crates/compose/src/tests.rs | 119 ++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3dd7c25b3..ee1231503 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -2229,6 +2229,18 @@ fn layout_line( // A default tab stop is a left tab. last_tab = Some((i, stop.as_ref().is_none_or(|t| t.align == TabAlign::Left))); } else if line[i].ch == story::RIGHT_INDENT_TAB { + // Use the first stop past the right margin, or the last stop at/before it. + // Choose the stop before checking its leader: an empty leader takes precedence too. + let right = x1 - tab_origin; + let stop = pp + .tabs + .iter() + .filter(|t| t.position > right) + .min_by(|a, b| a.position.total_cmp(&b.position)) + .or_else(|| pp.tabs.iter().filter(|t| t.position <= right).max_by(|a, b| a.position.total_cmp(&b.position))); + if let Some(t) = stop.filter(|t| !t.leader.is_empty()) { + leaders.push((i, t.leader.clone())); + } let rest: f64 = line[i + 1..].iter().map(|g| g.adv).sum(); line[i].adv = (measure - x - rest).max(0.0); last_tab = Some((i, false)); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index f5d0113c1..55bf3c015 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -789,6 +789,125 @@ fn tab_leaders_fill_the_gap() { assert!(last_dot < num_x, "leaders stop before the page number"); } +#[test] +fn right_indent_tab_leaders_follow_stop_precedence_without_moving_text() { + let text = "Intro\u{e006}12"; + let cases = [ + ("last interior", 0.0, vec![(200.0, "."), (80.0, "-")], Some('.')), + ("first exterior", 0.0, vec![(380.0, "+"), (200.0, "-"), (320.0, ".")], Some('.')), + ("at the margin", 0.0, vec![(300.0, "."), (80.0, "-")], Some('.')), + ("no stops", 0.0, vec![], None), + ("empty exterior", 0.0, vec![(200.0, "."), (320.0, ""), (350.0, "+")], None), + ("empty interior", 0.0, vec![(80.0, "."), (200.0, "")], None), + ("indented margin", 36.0, vec![(260.0, "-"), (280.0, "."), (340.0, "+")], Some('.')), + ]; + for (name, right_indent, stops, expected) in cases { + let tabs = stops + .into_iter() + .map(|(position, leader)| designcraft_doc::TabStop { position, align: TabAlign::Right, leader: leader.into(), align_on: String::new() }) + .collect(); + let para = ParaAttrs { tabs: Some(tabs), left_indent: Some(12.0), right_indent: Some(right_indent), ..Default::default() }; + // Translation and left/right indents distinguish the tab origin from line/absolute x. + let (mut d, sid, _) = doc_with(text, Rect::new(72.0, 36.0, 372.0, 136.0), para); + let cs = compose_story(&d, sid, &Default::default()); + assert!(!cs.is_overset(), "{name}"); + assert_eq!(cs.frames[0].lines.len(), 1, "{name}"); + let line = &cs.frames[0].lines[0]; + let tab = line.glyphs.iter().find(|g| g.byte == 5 && g.len > 0).unwrap(); + let leaders: Vec<_> = line.glyphs.iter().filter(|g| g.visible && g.len == 0).collect(); + assert_eq!(!leaders.is_empty(), expected.is_some(), "{name}"); + for g in leaders { + assert_eq!(g.gid, g.face.get().glyph_for(expected.unwrap()), "{name}"); + let width = g.face.get().advance(g.gid) * g.sx; + assert!(g.x >= tab.x - 0.01 && g.x + width <= tab.x + tab.adv + 0.01, "{name}: leader outside tab gap"); + assert_eq!(g.byte, tab.byte, "{name}"); + } + assert!((line.end_x - (372.0 - right_indent)).abs() < 0.01, "{name}: number stays at the right margin"); + // Removing only the leaders must leave every source glyph and the line itself unchanged. + for t in d.story_mut(sid).unwrap().paras[0].para.tabs.as_mut().unwrap() { + t.leader.clear(); + } + let plain = compose_story(&d, sid, &Default::default()); + assert_eq!(plain.frames[0].lines.len(), 1, "{name}"); + let plain_line = &plain.frames[0].lines[0]; + assert_eq!((&line.range, line.baseline, line.end_x), (&plain_line.range, plain_line.baseline, plain_line.end_x), "{name}"); + let source_positions = |l: &Line| { + l.glyphs.iter().filter(|g| g.len > 0).map(|g| (g.byte, g.len, g.gid, g.x, g.y, g.adv, g.sx, g.sy, g.style)).collect::>() + }; + assert_eq!(source_positions(line), source_positions(plain_line), "{name}"); + assert_eq!(d.stories[&sid].text, text, "{name}: source text is not expanded"); + } +} + +#[test] +fn right_indent_tab_leaders_use_the_tab_format_and_regular_tab_drawing() { + let mut rendered = Vec::new(); + for ch in [story::RIGHT_INDENT_TAB, '\t'] { + let text = format!("Intro{ch}12"); + let para = ParaAttrs { + tabs: Some(vec![designcraft_doc::TabStop { position: 300.0, align: TabAlign::Right, leader: ".-".into(), align_on: String::new() }]), + ..Default::default() + }; + let (mut d, sid, _) = doc_with(&text, Rect::new(72.0, 36.0, 372.0, 136.0), para); + d.story_mut(sid).unwrap().format_chars(5..5 + ch.len_utf8(), |f| { + f.over.font_family = Some("Inter".into()); + f.over.size = Some(18.0); + f.over.h_scale = Some(1.3); + f.over.v_scale = Some(0.8); + f.over.baseline_shift = Some(4.0); + f.over.fill_tint = Some(0.4); + }); + let cs = compose_story(&d, sid, &Default::default()); + assert!(!cs.is_overset()); + assert_eq!(cs.frames[0].lines.len(), 1); + let line = &cs.frames[0].lines[0]; + let tab = line.glyphs.iter().find(|g| g.byte == 5 && g.len > 0).unwrap(); + let prefix = &line.glyphs[0]; + assert_ne!(tab.face.id(), prefix.face.id(), "tab uses its own font"); + assert_ne!(tab.style, prefix.style, "tab uses its own character formatting"); + assert_eq!(cs.styles[tab.style as usize].size, 18.0); + assert_eq!(cs.styles[tab.style as usize].fill_tint, 0.4); + let leaders: Vec<_> = line.glyphs.iter().filter(|g| g.visible && g.len == 0).collect(); + assert!(leaders.len() > 10); + assert_eq!(leaders.len() % 2, 0, "only complete patterns are drawn"); + for (i, g) in leaders.iter().enumerate() { + assert_eq!(g.gid, tab.face.get().glyph_for(if i % 2 == 0 { '.' } else { '-' })); + assert_eq!(g.face.id(), tab.face.id()); + assert_eq!((g.sx, g.sy, g.y, g.style, g.byte), (tab.sx, tab.sy, -4.0, tab.style, tab.byte)); + let width = g.face.get().advance(g.gid) * g.sx; + assert!(g.x >= tab.x - 0.01 && g.x + width <= tab.x + tab.adv + 0.01); + } + rendered.push(leaders.iter().map(|g| (g.gid, g.x, g.y, g.sx, g.sy, g.style)).collect::>()); + } + assert_eq!(rendered[0], rendered[1], "right-indent and equivalent regular right tabs use the same leader drawing"); +} + +#[test] +fn right_indent_tab_leaders_do_not_draw_when_the_gap_cannot_fit_a_pattern() { + let text = "A\u{e006}B"; + let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default()); + let cs = compose_story(&d, sid, &Default::default()); + let line = &cs.frames[0].lines[0]; + let natural: f64 = line.glyphs.iter().filter(|g| g.visible).map(|g| g.adv).sum(); + let first = &line.glyphs[0]; + let pattern_width = first.face.get().advance(first.face.get().glyph_for('-')) * first.sx; + assert!(pattern_width > 0.01); + for gap in [0.0, pattern_width / 2.0] { + let para = ParaAttrs { + tabs: Some(vec![designcraft_doc::TabStop { position: 1.0, align: TabAlign::Left, leader: "-".into(), align_on: String::new() }]), + ..Default::default() + }; + let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, natural + gap, 100.0), para); + let cs = compose_story(&d, sid, &Default::default()); + assert!(!cs.is_overset()); + assert_eq!(cs.frames[0].lines.len(), 1); + let line = &cs.frames[0].lines[0]; + let tab = line.glyphs.iter().find(|g| g.byte == 1 && g.len > 0).unwrap(); + assert!((tab.adv - gap).abs() < 1e-6); + assert!(line.glyphs.iter().all(|g| g.len > 0 || !g.visible), "a partial leader pattern must not overlap adjacent text"); + } +} + #[test] fn footnotes_sit_at_the_column_bottom_and_push_text() { // A short frame: without footnotes the text fills it; with two footnotes the body text From b3ec8dc908bebba44812cce36ccca80a8983bc42 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:53:54 +1000 Subject: [PATCH 41/48] PARITY-EPUB-STYLE: match CSS to exported style mappings Co-Authored-By: Codex --- crates/epub/src/lib.rs | 62 ++++++++--- crates/epub/tests/export_tag_styles.rs | 138 +++++++++++++++++++++++++ 2 files changed, 184 insertions(+), 16 deletions(-) create mode 100644 crates/epub/tests/export_tag_styles.rs diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 643f59341..77b509d0b 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -322,7 +322,10 @@ pub fn stylesheet(doc: &Document) -> String { let mut rule = String::new(); para_css(&pp, base, lead, &mut rule); char_css(doc, &cp, base, &mut rule); - let _ = writeln!(css, "p.{} {{ {rule}}}", pc[ps.name.as_str()]); + let (tag, class) = export_mapping(st, &ps.name, false, &pc); + if let Some(selector) = class_selector(tag, &class) { + let _ = writeln!(css, "{selector} {{ {rule}}}"); + } } for cs in &st.character { if cs.name == story::NO_CHAR_STYLE { @@ -347,7 +350,10 @@ pub fn stylesheet(doc: &Document) -> String { if let Some(sz) = c.size { let _ = write!(rule, "font-size: {:.3}em; ", sz / base); } - let _ = writeln!(css, "span.{} {{ {rule}}}", cc[cs.name.as_str()]); + let (tag, class) = export_mapping(st, &cs.name, true, &cc); + if let Some(selector) = class_selector(tag, &class) { + let _ = writeln!(css, "{selector} {{ {rule}}}"); + } } css } @@ -386,6 +392,39 @@ fn is_tag(t: &str, character: bool) -> bool { } } +/// Resolve Export Tagging once for both markup and its stylesheet. Keep the generated, +/// collision-free class when no custom class was supplied, and the existing tag fallback. +fn export_mapping<'a>(st: &'a Styles, name: &str, character: bool, classes: &BTreeMap<&str, String>) -> (&'a str, String) { + let et = st.export_tag(name, character); + let tag = et.map(|e| e.tag.as_str()).filter(|t| is_tag(t, character)).unwrap_or(if character { "span" } else { "p" }); + let class = et.map(|e| e.class.clone()).filter(|c| !c.is_empty()).or_else(|| classes.get(name).cloned()).unwrap_or_else(|| slug(name)); + // Match the characters xml_attr actually emits, before escaping either output context. + let class = class.chars().filter(|c| valid_xml_char(*c) && !('\u{E000}'..='\u{E1FF}').contains(c)).collect(); + (tag, class) +} + +/// Each class token must match on the same element. Escape CSS identifier punctuation and +/// leading digits without changing the class written into XHTML. +fn class_selector(tag: &str, class: &str) -> Option { + let mut tokens = class.split_ascii_whitespace().peekable(); + // A nonempty but whitespace-only custom class has no token to select. Never emit a bare + // element selector, which would apply this style to unrelated paragraphs or character runs. + tokens.peek()?; + let mut selector = tag.to_string(); + for token in tokens { + selector.push('.'); + for (i, c) in token.chars().enumerate() { + let leading_digit = c.is_ascii_digit() && (i == 0 || i == 1 && token.starts_with('-')); + if !leading_digit && token != "-" && (c.is_ascii_alphanumeric() || matches!(c, '-' | '_') || !c.is_ascii()) { + selector.push(c); + } else { + let _ = write!(selector, "\\{:x} ", c as u32); + } + } + } + Some(selector) +} + /// One story as XHTML paragraphs. pub fn story_html(doc: &Document, sid: StoryId) -> String { let Some(st) = doc.story(sid) else { return String::new() }; @@ -394,10 +433,7 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String { for (pi, r) in st.para_ranges().iter().enumerate() { let pf = &st.paras[pi]; // Export Tagging: the style's element and class. - let et = doc.styles.export_tag(&pf.style, false); - let tag = et.map(|e| e.tag.as_str()).filter(|t| is_tag(t, false)).unwrap_or("p"); - let class = - et.map(|e| e.class.clone()).filter(|c| !c.is_empty()).or_else(|| pc.get(pf.style.as_str()).cloned()).unwrap_or_else(|| slug(&pf.style)); + let (tag, class) = export_mapping(&doc.styles, &pf.style, false, &pc); let text_empty = st.text[r.clone()].trim().is_empty(); let rtl = doc.styles.resolve_para(pf).0.direction == designcraft_doc::TextDirection::RightToLeft; let _ = write!(out, "<{tag} class=\"{}\"{}>", xml_attr(&class), if rtl { " dir=\"rtl\"" } else { "" }); @@ -427,17 +463,11 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String { } else { story_markup(raw) }; - let et = (f.style != story::NO_CHAR_STYLE).then(|| doc.styles.export_tag(&f.style, true)).flatten(); - let ctag = et.map(|e| e.tag.as_str()).filter(|t| is_tag(t, true)).unwrap_or("span"); - let cls = if f.style != story::NO_CHAR_STYLE { - let class = et - .map(|e| e.class.clone()) - .filter(|c| !c.is_empty()) - .or_else(|| cc.get(f.style.as_str()).cloned()) - .unwrap_or_else(|| slug(&f.style)); - format!(" class=\"{}\"", xml_attr(&class)) + let (ctag, cls) = if f.style != story::NO_CHAR_STYLE { + let (tag, class) = export_mapping(&doc.styles, &f.style, true, &cc); + (tag, format!(" class=\"{}\"", xml_attr(&class))) } else { - String::new() + ("span", String::new()) }; let style = override_css(doc, &f.over); let style_attr = if style.is_empty() { String::new() } else { format!(" style=\"{}\"", xml_attr(&style)) }; diff --git a/crates/epub/tests/export_tag_styles.rs b/crates/epub/tests/export_tag_styles.rs new file mode 100644 index 000000000..47485b532 --- /dev/null +++ b/crates/epub/tests/export_tag_styles.rs @@ -0,0 +1,138 @@ +use std::io::{Cursor, Read}; + +use designcraft_doc::build::NewDocument; +use designcraft_doc::geom::Rect; +use designcraft_doc::{Align, CharAttrs, CharacterStyle, Document, ExportTag, ParaAttrs, ParaFormat, ParagraphStyle, SpreadRef, story}; +use designcraft_epub::{EpubOptions, HtmlOptions, export_epub, export_html}; + +fn fixture() -> Document { + let mut doc = Document::new(&NewDocument::default()); + let styles = doc.styles_mut(); + styles.paragraph.iter_mut().find(|p| p.name == story::BASIC_PARAGRAPH).unwrap().chars.size = Some(12.0); + styles.paragraph.push(ParagraphStyle { + name: "Chapter Title".into(), + based_on: None, + next_style: None, + para: ParaAttrs { align: Some(Align::Center), ..Default::default() }, + chars: CharAttrs { size: Some(24.0), ..Default::default() }, + shortcut: String::new(), + }); + styles.character.push(CharacterStyle { + name: "Key Term".into(), + based_on: None, + chars: CharAttrs { size: Some(18.0), ..Default::default() }, + shortcut: String::new(), + }); + let layer = doc.default_layer(); + doc.add_text_frame( + SpreadRef::Doc(0), + Rect::new(36.0, 36.0, 300.0, 100.0), + layer, + "Heading", + ParaFormat { style: "Chapter Title".into(), ..Default::default() }, + ) + .unwrap(); + let (_, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 120.0, 300.0, 200.0), layer, "Accent plain", ParaFormat::default()).unwrap(); + doc.story_mut(sid).unwrap().format_chars(0..6, |f| f.style = "Key Term".into()); + doc +} + +fn map(doc: &mut Document, paragraph_tag: &str, paragraph_class: &str, character_tag: &str, character_class: &str) { + let tags = &mut doc.styles_mut().export_tags; + tags.insert("p:Chapter Title".into(), ExportTag { tag: paragraph_tag.into(), class: paragraph_class.into() }); + tags.insert("c:Key Term".into(), ExportTag { tag: character_tag.into(), class: character_class.into() }); +} + +fn package(doc: &Document) -> (String, String) { + let mut zip = zip::ZipArchive::new(Cursor::new(export_epub(doc, &EpubOptions::default()).unwrap())).unwrap(); + let mut read = |path: &str| { + let mut text = String::new(); + zip.by_name(path).unwrap().read_to_string(&mut text).unwrap(); + text + }; + let xhtml = read("OEBPS/content.xhtml"); + let css = read("OEBPS/style.css"); + let opf = read("OEBPS/content.opf"); + assert!(xhtml.contains("")); + assert!(opf.contains("href=\"style.css\" media-type=\"text/css\"")); + (xhtml, css) +} + +fn rule<'a>(css: &'a str, selector: &str) -> &'a str { + css.lines().find_map(|line| line.strip_prefix(&format!("{selector} {{ "))).unwrap_or_else(|| panic!("missing {selector} in {css}")) +} + +#[test] +fn mapped_tags_and_classes_keep_their_packaged_style_declarations() { + let mut doc = fixture(); + map(&mut doc, "h1", "chapter", "em", "term"); + let (xhtml, css) = package(&doc); + assert!(xhtml.contains("

Heading

"), "{xhtml}"); + assert!(xhtml.contains("Accent plain"), "{xhtml}"); + assert!(rule(&css, "h1.chapter").contains("font-size: 2.000em;")); + assert!(rule(&css, "h1.chapter").contains("text-align: center;")); + assert!(rule(&css, "em.term").contains("font-size: 1.500em;")); + assert!(!css.contains("p.chapter-title {") && !css.contains("span.key-term {")); + assert!(rule(&css, "p.basic-paragraph").contains("font-size: 1.000em;")); + // HTML uses the same mapped stylesheet inline, while EPUB links its packaged CSS. + let html = export_html(&doc, &HtmlOptions::default()); + assert!(html.contains(&css)); + assert!(html.contains("

Heading

")); +} + +#[test] +fn tag_only_class_only_and_unsupported_tags_keep_existing_fallbacks() { + for (pt, pc, ct, cc, paragraph, character) in [ + ("h2", "", "em", "", "h2.chapter-title", "em.key-term"), + ("", "chapter", "", "term", "p.chapter", "span.term"), + ("section", "", "h2", "", "p.chapter-title", "span.key-term"), + ("", "", "", "", "p.chapter-title", "span.key-term"), + ] { + let mut doc = fixture(); + map(&mut doc, pt, pc, ct, cc); + let (xhtml, css) = package(&doc); + for (selector, text, size) in [(paragraph, "Heading", "2.000em"), (character, "Accent", "1.500em")] { + let (tag, class) = selector.split_once('.').unwrap(); + assert!(xhtml.contains(&format!("<{tag} class=\"{class}\">{text}")), "{xhtml}"); + assert!(rule(&css, selector).contains(&format!("font-size: {size};"))); + } + } + let (xhtml, css) = package(&fixture()); + assert!(xhtml.contains("

Heading

")); + assert!(rule(&css, "p.chapter-title").contains("font-size: 2.000em;")); + assert!(rule(&css, "span.key-term").contains("font-size: 1.500em;")); +} + +#[test] +fn custom_class_tokens_match_without_renaming_them() { + for (class, attribute, selector) in [ + ("lead wide", "lead wide", ".lead.wide"), + ("lead\twide\n", "lead\twide\n", ".lead.wide"), + ("chapter:lead", "chapter:lead", r".chapter\3a lead"), + ("2nd-edition", "2nd-edition", r".\32 nd-edition"), + ("-2nd", "-2nd", r".-\32 nd"), + ("-", "-", r".\2d "), + ("見出し.café", "見出し.café", r".見出し\2e café"), + ("edition \"wide\"", "edition "wide"", r".edition.\22 wide\22 "), + ] { + let mut doc = fixture(); + map(&mut doc, "h2", class, "em", class); + let (xhtml, css) = package(&doc); + assert!(xhtml.contains(&format!("

Heading

")), "{xhtml}"); + assert!(xhtml.contains(&format!("Accent")), "{xhtml}"); + assert!(rule(&css, &format!("h2{selector}")).contains("font-size: 2.000em;")); + assert!(rule(&css, &format!("em{selector}")).contains("font-size: 1.500em;")); + } +} + +#[test] +fn whitespace_only_custom_classes_do_not_apply_rules_to_every_element() { + let mut doc = fixture(); + map(&mut doc, "p", " \t", "span", " \n"); + let (xhtml, css) = package(&doc); + assert!(xhtml.contains("

Heading

")); + assert!(xhtml.contains("Accent")); + assert!(!css.lines().any(|line| line.starts_with("p {") || line.starts_with("span {"))); + assert!(!css.contains("font-size: 2.000em;") && !css.contains("font-size: 1.500em;")); + assert!(rule(&css, "p.basic-paragraph").contains("font-size: 1.000em;")); +} From 8f48869e589f1eb1adddd11d72bb374312437edb Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:56:09 +1000 Subject: [PATCH 42/48] PARITY-IDML-INDEX: retain index reference source sort keys Co-Authored-By: Codex --- crates/idml/src/export.rs | 25 ++- crates/idml/src/import.rs | 55 ++++--- crates/idml/src/tests.rs | 1 + crates/idml/src/tests/index_sort.rs | 240 ++++++++++++++++++++++++++++ 4 files changed, 295 insertions(+), 26 deletions(-) create mode 100644 crates/idml/src/tests/index_sort.rs diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 9d73562b9..6c5b00a4f 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -33,10 +33,17 @@ impl Default for ExportOptions { } /// Export a document as an IDML package (ZIP bytes). +/// +/// IDML has one sort key per index topic path. If native references disagree, the first +/// reference serialized for that path supplies its key, including an empty/default key. +/// Later references cannot overwrite it; no synthetic topic names or sort keys are created. +/// Consistent references retain their keys. Conflicting per-reference keys cannot all survive +/// this topic-level representation. This policy also applies to [`export_idml_with`]. pub fn export_idml(doc: &Document) -> Vec { export_idml_with(doc, &ExportOptions::default()) } +/// Export with options, using the index sort-key conflict policy of [`export_idml`]. pub fn export_idml_with(doc: &Document, opts: &ExportOptions) -> Vec { let mut ex = Ex::new(doc, opts); let parts = ex.build(); @@ -133,8 +140,8 @@ struct Ex<'a> { threads: HashMap, Option)>, /// Cross-reference sources written so far: (Self, target anchor). xref_sources: Vec<(String, u64)>, - /// Index topics (paths) and their See / See also cross-references. - index_topics: BTreeSet>, + /// Index topic path → first serialized sort key; See / See also cross-references. + index_topics: BTreeMap, String>, index_xrefs: Vec<(Vec, &'static str, String)>, } @@ -148,7 +155,7 @@ impl<'a> Ex<'a> { fonts: BTreeMap::new(), threads: HashMap::new(), xref_sources: Vec::new(), - index_topics: BTreeSet::new(), + index_topics: BTreeMap::new(), index_xrefs: Vec::new(), }; ex.assign_swatch_ids(); @@ -1809,12 +1816,12 @@ impl<'a> Ex<'a> { fn index_el(&mut self) -> El { let mut topics = self.index_topics.clone(); for (_, _, target) in &self.index_xrefs { - topics.insert(vec![target.clone()]); + topics.entry(vec![target.clone()]).or_default(); } - fn build(ex: &mut Ex, topics: &BTreeSet>, prefix: &[String]) -> Vec { + fn build(ex: &mut Ex, topics: &BTreeMap, String>, prefix: &[String]) -> Vec { let mut out = Vec::new(); - for t in topics.iter().filter(|t| t.len() == prefix.len() + 1 && t.starts_with(prefix)) { - let mut el = El::new("Topic").attr("Self", topic_self(t)).attr("SortOrder", "").attr("Name", t.last().map_or("", String::as_str)); + for (t, sort) in topics.iter().filter(|(t, _)| t.len() == prefix.len() + 1 && t.starts_with(prefix)) { + let mut el = El::new("Topic").attr("Self", topic_self(t)).attr("SortOrder", sort).attr("Name", t.last().map_or("", String::as_str)); for (from, kind, target) in ex.index_xrefs.clone() { if from == *t { el.push( @@ -2069,7 +2076,9 @@ impl<'a> Ex<'a> { if let Some(r) = s.index_refs.get(k).filter(|r| !r.topics.is_empty()) { use designcraft_doc::index::IndexRange as R; for n in 1..=r.topics.len() { - self.index_topics.insert(r.topics[..n].to_vec()); + // A shared topic has one key: keep the first reference's + // value (even empty), rather than inventing a merged key. + self.index_topics.entry(r.topics[..n].to_vec()).or_insert_with(|| r.sort.get(n - 1).cloned().unwrap_or_default()); } let (kind, limit) = match &r.range { R::CurrentPage => (Some("CurrentPage"), None), diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 46c3253b6..1b1cb7d8e 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -206,11 +206,32 @@ struct Importer<'r> { /// Cross-reference format Self → name. xref_format_names: HashMap, xref_formats: Vec, - /// Index topic Self → topic path; topic See / See also cross-references. - index_topics: HashMap>, + /// Index topic Self → topic path and per-level sort keys; topic cross-references. + index_topics: HashMap, index_xrefs: Vec, } +/// IDML stores a sort key on each topic, rather than on its page references. +#[derive(Clone, Default)] +struct IndexTopic { + path: Vec, + sort: Vec, +} + +impl IndexTopic { + fn child(&self, topic: &El) -> Self { + let mut path = self.path.clone(); + path.push(topic.get("Name").unwrap_or("").to_string()); + let mut sort = self.sort.clone(); + if let Some(key) = topic.get("SortOrder").filter(|key| !key.is_empty()) { + // Keep empty positions before a child's key, but omit trailing defaults. + sort.resize(self.path.len(), String::new()); + sort.push(key.to_string()); + } + Self { path, sort } + } +} + fn lab_to_color(l: f32, a: f32, b: f32) -> Color { let rgb = lab::xyz_to_srgb(lab::lab_to_xyz(lab::Lab::new(l, a, b))); Color::rgb(rgb[0], rgb[1], rgb[2]) @@ -453,7 +474,7 @@ impl<'r> Importer<'r> { self.xref_format(e); } if let Some(ix) = top.iter().find(|e| e.local() == "Index") { - self.index_topics_of(ix, &[]); + self.index_topics_of(ix, &IndexTopic::default()); } // Stories (ids first so frames can reference them). for e in top.iter().filter(|e| e.local() == "Story") { @@ -1010,33 +1031,31 @@ impl<'r> Importer<'r> { } /// Index topics (recursively); at the root, their See / See also cross-references too. - fn index_topics_of(&mut self, e: &El, prefix: &[String]) { + fn index_topics_of(&mut self, e: &El, prefix: &IndexTopic) { for t in e.find_all("Topic") { - let mut path = prefix.to_vec(); - path.push(t.get("Name").unwrap_or("").to_string()); + let topic = prefix.child(t); if let Some(me) = t.get("Self") { - self.index_topics.insert(me.to_string(), path.clone()); + self.index_topics.insert(me.to_string(), topic.clone()); } - self.index_topics_of(t, &path); + self.index_topics_of(t, &topic); } - if !prefix.is_empty() { + if !prefix.path.is_empty() { return; } - let mut stack = vec![(e, Vec::::new())]; - while let Some((el, path)) = stack.pop() { + let mut stack = vec![(e, IndexTopic::default())]; + while let Some((el, prefix)) = stack.pop() { for t in el.find_all("Topic") { - let mut p = path.clone(); - p.push(t.get("Name").unwrap_or("").to_string()); + let topic = prefix.child(t); for x in t.find_all("CrossReference") { - let target = x.get("ReferencedTopic").and_then(|r| self.index_topics.get(r)).map(|p| p.join(": ")).unwrap_or_default(); + let target = x.get("ReferencedTopic").and_then(|r| self.index_topics.get(r)).map(|t| t.path.join(": ")).unwrap_or_default(); use designcraft_doc::index::IndexRange as R; let range = match x.get("CrossReferenceType") { Some("SeeAlso") | Some("SeeAlsoHerein") => R::SeeAlso(target), _ => R::See(target), }; - self.index_xrefs.push(designcraft_doc::IndexRef { topics: p.clone(), sort: vec![], range }); + self.index_xrefs.push(designcraft_doc::IndexRef { topics: topic.path.clone(), sort: topic.sort.clone(), range }); } - stack.push((t, p)); + stack.push((t, topic)); } } } @@ -1796,14 +1815,14 @@ impl<'r> Importer<'r> { } "PageReference" => { use designcraft_doc::index::IndexRange as R; - let topics = c.get("ReferencedTopic").and_then(|r| self.index_topics.get(r)).cloned().unwrap_or_default(); + let topic = c.get("ReferencedTopic").and_then(|r| self.index_topics.get(r)).cloned().unwrap_or_default(); let range = match c.get("PageReferenceType") { Some("ToEndOfStory") | Some("ToEndOfDocument") | Some("ToEndOfSection") => R::ToEndOfStory, Some("ForNextNParagraphs") => R::NextParagraphs(c.num("PageReferenceLimit").unwrap_or(1.0).max(1.0) as u32), Some("SuppressPageNumbers") => R::SuppressPageRange, _ => R::CurrentPage, }; - b.index_refs.push(designcraft_doc::IndexRef { topics, sort: vec![], range }); + b.index_refs.push(designcraft_doc::IndexRef { topics: topic.path, sort: topic.sort, range }); b.push(&designcraft_doc::INDEX_MARK.to_string(), cf); } "CrossReferenceSource" => { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 23ec99bdf..087a2b5d7 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -9,6 +9,7 @@ use super::*; mod assets; mod decorations; +mod index_sort; fn zip_files(files: &[(&str, &str)]) -> Vec { use zip::write::SimpleFileOptions; diff --git a/crates/idml/src/tests/index_sort.rs b/crates/idml/src/tests/index_sort.rs new file mode 100644 index 000000000..e35e7fed9 --- /dev/null +++ b/crates/idml/src/tests/index_sort.rs @@ -0,0 +1,240 @@ +//! Original synthetic fixtures: no Adobe-generated files or implementation details. +//! Behavioral reference: https://developer.adobe.com/indesign/uxp/dom/api/t/topic/ + +use std::io::Read; + +use designcraft_doc::IndexRef; +use designcraft_doc::index::{self, IndexRange, IndexSpec, Resolved}; + +use super::*; +use crate::xml::El; + +fn strings(values: &[&str]) -> Vec { + values.iter().map(|v| (*v).to_string()).collect() +} + +fn reference(topics: &[&str], sort: &[&str], range: IndexRange) -> IndexRef { + IndexRef { topics: strings(topics), sort: strings(sort), range } +} + +fn references(doc: &Document) -> Vec { + doc.stories.values().flat_map(|s| s.index_refs.iter().map(|r| r.as_ref().clone())).collect() +} + +fn native(refs: Vec) -> Document { + let mut doc = Document::new(&NewDocument::default()); + let layer = doc.default_layer(); + let (_, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 400.0, 400.0), layer, "Source", ParaFormat::default()).unwrap(); + let story = doc.story_mut(sid).unwrap(); + for r in refs { + story.insert_index_ref(story.len(), r); + } + doc.check().unwrap(); + doc +} + +fn import_xml(index: &str, refs: &str) -> Document { + let map = format!( + r#" + {index} + + + + "# + ); + let story = format!( + r#" + + Source{refs} + + "# + ); + let spread = r#" + + + + + + + + + "#; + import_idml(&zip_files(&[("designmap.xml", &map), ("Stories/s.xml", &story), ("Spreads/s.xml", spread)])).unwrap() +} + +/// Inspect the exported XML directly, without calling the IDML importer. +fn exported_index(doc: &Document) -> El { + let bytes = export_idml(doc); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut map = String::new(); + zip.by_name("designmap.xml").unwrap().read_to_string(&mut map).unwrap(); + crate::xml::parse(map.as_bytes()).unwrap().find("Index").unwrap().clone() +} + +fn topic<'a>(index: &'a El, path: &[&str]) -> &'a El { + let mut el = index; + for name in path { + el = el.find_all("Topic").find(|t| t.get("Name") == Some(*name)).unwrap(); + } + el +} + +#[test] +fn imports_synthetic_sort_keys_for_nested_topics_and_cross_references() { + let doc = import_xml( + r#" + + + + + + + + + + + + + "#, + r#" + + + + + "#, + ); + doc.check().unwrap(); + let refs = references(&doc); + assert_eq!(refs.len(), 8); + for r in [ + reference(&["Zulu"], &["Alpha"], IndexRange::CurrentPage), + reference(&["Zulu"], &["Alpha"], IndexRange::NextParagraphs(4)), + reference(&["Beta"], &[], IndexRange::CurrentPage), + reference(&["Empty"], &[], IndexRange::SuppressPageRange), + reference(&["Parent", "É & "], &["", " A&<é> "], IndexRange::CurrentPage), + reference(&["Parent", "É & "], &["", " A&<é> "], IndexRange::See("Beta".into())), + reference(&["Parent", "É & "], &["", " A&<é> "], IndexRange::SeeAlso("Zulu".into())), + reference(&["Zoo", "Leaf"], &["Aardvark"], IndexRange::CurrentPage), + ] { + assert!(refs.contains(&r), "missing {r:?} in {refs:?}"); + } + let story = doc.stories.values().next().unwrap(); + assert_eq!(story.text.replace(designcraft_doc::INDEX_MARK, ""), "Source"); +} + +#[test] +fn exports_sort_keys_as_topic_attributes_without_changing_names() { + let doc = native(vec![ + reference(&["Zulu"], &["Alpha"], IndexRange::CurrentPage), + reference(&["Zulu"], &["Alpha"], IndexRange::NextParagraphs(2)), + reference(&["Beta"], &[], IndexRange::CurrentPage), + reference(&["Empty"], &[""], IndexRange::CurrentPage), + reference(&["Parent", "É & "], &["", " A&<é> "], IndexRange::CurrentPage), + reference(&["Alias"], &["Able"], IndexRange::See("Zulu".into())), + reference(&["Related"], &["Azure"], IndexRange::SeeAlso("Zulu".into())), + reference(&["Parent", "Alias"], &["", "Able"], IndexRange::See("Zulu".into())), + reference(&["Parent", "Related"], &["", "Azure"], IndexRange::SeeAlso("Zulu".into())), + ]); + let index = exported_index(&doc); + assert_eq!(index.find_all("Topic").count(), 6, "repeated references and cross-reference targets share their topic"); + assert_eq!(topic(&index, &["Zulu"]).get("SortOrder"), Some("Alpha")); + assert_eq!(topic(&index, &["Beta"]).get("SortOrder"), Some("")); + assert_eq!(topic(&index, &["Empty"]).get("SortOrder"), Some("")); + assert_eq!(topic(&index, &["Parent"]).get("SortOrder"), Some("")); + assert_eq!(topic(&index, &["Parent", "É & "]).get("SortOrder"), Some(" A&<é> ")); + for (name, key, kind) in [("Alias", "Able", "See"), ("Related", "Azure", "SeeAlso")] { + for path in [&[name][..], &["Parent", name][..]] { + let t = topic(&index, path); + assert_eq!(t.get("SortOrder"), Some(key)); + let cross_ref = t.find("CrossReference").unwrap(); + assert_eq!(cross_ref.get("CrossReferenceType"), Some(kind)); + assert_eq!(cross_ref.get("ReferencedTopic"), topic(&index, &["Zulu"]).get("Self")); + } + } +} + +/// These references all belong to the same page. Fix the page resolution to isolate index +/// ordering from fonts/composition, and use the real index generator for its text and headings. +fn generated_index(doc: &Document) -> Vec<(String, String)> { + let refs: Vec<_> = references(doc).into_iter().map(|r| Resolved { r, pages: Some((0, 0)) }).collect(); + index::build(&IndexSpec { title: String::new(), ..Default::default() }, &refs, &|p| (p + 1).to_string()) +} + +#[test] +fn round_trip_preserves_generated_index_order_and_headings() { + let doc = native(vec![ + reference(&["Zulu"], &["Alpha"], IndexRange::CurrentPage), + reference(&["Zulu"], &["Alpha"], IndexRange::CurrentPage), + reference(&["Beta"], &[], IndexRange::CurrentPage), + reference(&["Parent", "Zulu"], &["", "Alpha"], IndexRange::CurrentPage), + reference(&["Parent", "Beta"], &[], IndexRange::CurrentPage), + reference(&["Alias"], &["Able"], IndexRange::See("Zulu".into())), + reference(&["Related"], &["Delta"], IndexRange::SeeAlso("Beta".into())), + reference(&["Parent", "Alias"], &["", "Able"], IndexRange::See("Zulu".into())), + reference(&["Parent", "Related"], &["", "Azure"], IndexRange::SeeAlso("Beta".into())), + ]); + let before = generated_index(&doc); + let expected: Vec<_> = [ + ("Index Section Head", "A"), + ("Index Level 1", "Alias See Zulu"), + ("Index Level 1", "Zulu 1"), + ("Index Section Head", "B"), + ("Index Level 1", "Beta 1"), + ("Index Section Head", "D"), + ("Index Level 1", "Related 1. See also Beta"), + ("Index Section Head", "P"), + ("Index Level 1", "Parent"), + ("Index Level 2", "Alias See Zulu"), + ("Index Level 2", "Zulu 1"), + ("Index Level 2", "Related 1. See also Beta"), + ("Index Level 2", "Beta 1"), + ] + .into_iter() + .map(|(style, text)| (style.to_string(), text.to_string())) + .collect(); + assert_eq!(before, expected); + let back = import_idml(&export_idml(&doc)).unwrap(); + back.check().unwrap(); + assert_eq!(generated_index(&back), before); + assert_eq!(back.stories.values().next().unwrap().text.replace(designcraft_doc::INDEX_MARK, ""), "Source"); +} + +#[test] +fn conflicting_sort_keys_keep_the_first_serialized_value_including_defaults() { + fn check(first: &[&str], second: &[&str], expected: &str) { + let doc = native(vec![reference(&["Shared"], first, IndexRange::CurrentPage), reference(&["Shared"], second, IndexRange::CurrentPage)]); + let index = exported_index(&doc); + assert_eq!(index.find_all("Topic").count(), 1, "conflicts do not manufacture topics"); + assert_eq!(topic(&index, &["Shared"]).get("SortOrder"), Some(expected)); + let back = import_idml(&export_idml(&doc)).unwrap(); + let refs = references(&back); + assert_eq!(refs.len(), 2); + for r in refs { + assert_eq!(r.sort.first().map_or("", String::as_str), expected); + } + assert_eq!(references(&doc)[0].sort, strings(first), "export does not edit native references"); + assert_eq!(references(&doc)[1].sort, strings(second)); + } + check(&["Alpha"], &["Omega"], "Alpha"); + check(&["Omega"], &["Alpha"], "Omega"); + check(&[], &["Alpha"], ""); + check(&[""], &["Alpha"], ""); + check(&["Alpha"], &[], "Alpha"); + check(&["Alpha"], &[""], "Alpha"); +} + +#[test] +fn shared_ancestors_and_identical_leaf_names_keep_keys_by_full_path() { + let doc = native(vec![ + reference(&["One", "Zulu"], &["Alpha", "First"], IndexRange::CurrentPage), + reference(&["One", "Zulu"], &["Beta", "Second"], IndexRange::CurrentPage), + reference(&["One", "Beta"], &["Gamma", "Third"], IndexRange::CurrentPage), + reference(&["Two", "Zulu"], &["Delta", "Fourth"], IndexRange::CurrentPage), + ]); + let index = exported_index(&doc); + assert_eq!(topic(&index, &["One"]).get("SortOrder"), Some("Alpha")); + assert_eq!(topic(&index, &["One", "Zulu"]).get("SortOrder"), Some("First")); + assert_eq!(topic(&index, &["One", "Beta"]).get("SortOrder"), Some("Third")); + assert_eq!(topic(&index, &["Two"]).get("SortOrder"), Some("Delta")); + assert_eq!(topic(&index, &["Two", "Zulu"]).get("SortOrder"), Some("Fourth")); +} From 5ca6a474b93c35c1a42ba2e9755501144d782b1f Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:59:32 +1000 Subject: [PATCH 43/48] PARITY-TABLE-SPLIT: split each original selected cell once Co-Authored-By: Codex --- crates/doc/src/table.rs | 165 ++++++++++++++++++++++++++++++- crates/engine/src/cmd/table.rs | 6 +- crates/engine/src/tests_table.rs | 122 +++++++++++++++++++++++ 3 files changed, 287 insertions(+), 6 deletions(-) diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 7fe385e98..9449be93c 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -695,6 +695,79 @@ impl Table { self.set_regions(®ions); } + /// Split each original owner touched by `range` once. Remember merged-cell cuts in the + /// original grid: a neighboring split may refine that grid before we reach the owner. + pub fn split_cells(&mut self, range: CellRange, horizontal: bool) -> Result<(), String> { + if range.r0 > range.r1 || range.c0 > range.c1 || range.r1 >= self.nrows() || range.c1 >= self.ncols() { + return Err("cell range out of bounds".into()); + } + let owners = self.owners(); + let mut selected = std::collections::BTreeSet::new(); + for r in range.r0..=range.r1 { + for c in range.c0..=range.c1 { + let owner = owners.get(r * self.ncols() + c).ok_or("missing cell owner")?; + selected.insert(*owner); + } + } + let mut targets = Vec::new(); + for (r, c) in selected { + let cell = self.cell(r, c).ok_or("missing split cell")?; + let span = if horizontal { cell.row_span } else { cell.col_span } as usize; + let start = if horizontal { r } else { c }; + let cut = if span > 1 { Some(start.checked_add(span.div_ceil(2)).ok_or("invalid split boundary")?) } else { None }; + targets.push((r, c, cut)); + } + for i in 0..targets.len() { + let (r, c, cut) = *targets.get(i).ok_or("missing split target")?; + let cell = self.cell(r, c).ok_or("missing split cell")?; + let (rs, cs) = (cell.row_span as usize, cell.col_span as usize); + let mut blank = cell.blank_like(); + // Splitting creates an empty half, not a duplicate of graphic-cell content. + blank.graphic = None; + let before = if horizontal { self.nrows() } else { self.ncols() }; + if let Some(cut) = cut { + let (start, span) = if horizontal { (r, rs) } else { (c, cs) }; + let first = cut.checked_sub(start).filter(|n| *n > 0 && *n < span).ok_or("invalid split boundary")?; + let mut regions: Vec<_> = self.regions().into_iter().filter(|&(rr, cc, _, _)| (rr, cc) != (r, c)).collect(); + if horizontal { + regions.push((r, c, first, cs)); + regions.push((cut, c, rs - first, cs)); + } else { + regions.push((r, c, rs, first)); + regions.push((r, cut, rs, cs - first)); + } + self.set_regions(®ions); + } else { + // An originally single-track cell may now span two tracks inserted for its + // neighbor. split_cell divides that span instead of inserting another track. + self.split_cell(r, c, horizontal); + } + let first = self.cell(r, c).ok_or("missing split half")?; + let second = if horizontal { (r + first.row_span as usize, c) } else { (r, c + first.col_span as usize) }; + let second = self.cell_mut(second.0, second.1).ok_or("missing split half")?; + blank.row_span = second.row_span; + blank.col_span = second.col_span; + *second = blank; + let after = if horizontal { self.nrows() } else { self.ncols() }; + if after > before { + let at = if horizontal { r + 1 } else { c + 1 }; + let added = after - before; + for (row, col, cut) in targets.iter_mut().skip(i + 1) { + let axis = if horizontal { row } else { col }; + if *axis >= at { + *axis += added; + } + if let Some(cut) = cut + && *cut >= at + { + *cut += added; + } + } + } + } + Ok(()) + } + /// Split the cell covering (r, c) in two: `horizontal` stacks the halves (Split Cell /// Horizontally), otherwise side by side. A merged cell splits its span; a single cell gets a /// new row (column) that the cells beside it span, the row height (column width) shared. @@ -719,10 +792,11 @@ impl Table { return; } if horizontal { - let h = self.rows[or].height; + let row = self.rows[or].clone(); self.insert_rows(or + 1, 1); - self.rows[or].height = h / 2.0; - self.rows[or + 1].height = h / 2.0; + self.rows[or].height = row.height / 2.0; + // Both halves keep the split row's height mode and header/body/footer role. + self.rows[or + 1] = Row { height: row.height / 2.0, ..row }; // insert_rows doesn't grow regions that end at the split row; everything else on it // spans the new row now. let mut regions: Vec<_> = self.regions(); @@ -1255,4 +1329,89 @@ mod split_tests { assert_eq!(m.nrows(), 4); assert_eq!((m.cell(0, 0).unwrap().row_span, m.cell(2, 0).unwrap().row_span), (2, 2)); } + + #[test] + fn split_range_keeps_original_merged_cut_when_neighbor_tracks_are_refined() { + for horizontal in [true, false] { + let mut t = Table::new(1, 4, 4, 0, 0, 280.0); + for (i, size) in [40.0, 60.0, 80.0, 100.0].into_iter().enumerate() { + t.rows[i].mode = RowHeightMode::Exactly; + t.rows[i].height = if horizontal { size } else { 70.0 }; + t.columns[i].width = if horizontal { 70.0 } else { size }; + } + for r in 0..4 { + for c in 0..4 { + let cell = t.cell_mut(r, c).unwrap(); + cell.text.insert(0, &format!("{r},{c}")); + cell.style = format!("style-{r}-{c}"); + cell.insets = [r as f64 + c as f64 + 1.0; 4]; + } + } + let (merged, selected, owner, lower, neighbor, moved_neighbor) = if horizontal { + (CellRange::new(0, 1, 2, 1), CellRange::new(0, 0, 2, 1), (0, 1), (4, 1), (3, 3), (6, 3)) + } else { + (CellRange::new(1, 0, 1, 2), CellRange::new(0, 0, 1, 2), (1, 0), (1, 4), (3, 3), (3, 6)) + }; + t.merge(merged).unwrap(); + let original = t.clone(); + t.split_cells(selected, horizontal).unwrap(); + t.check().unwrap(); + assert_eq!((t.nrows(), t.ncols()), if horizontal { (7, 4) } else { (4, 7) }); + let sizes: Vec<_> = if horizontal { t.rows.iter().map(|r| r.height).collect() } else { t.columns.iter().map(|c| c.width).collect() }; + assert_eq!(sizes, [20.0, 20.0, 30.0, 30.0, 40.0, 40.0, 100.0]); + let first = t.cell(owner.0, owner.1).unwrap(); + let second = t.cell(lower.0, lower.1).unwrap(); + // The original three-track merge divides after its second original track, + // regardless of which of those tracks a neighboring selected cell subdivides. + assert_eq!(if horizontal { first.row_span } else { first.col_span }, 4); + assert_eq!(if horizontal { second.row_span } else { second.col_span }, 2); + assert_eq!(first.text, original.cell(owner.0, owner.1).unwrap().text); + assert!(second.text.is_empty()); + assert_eq!(second.style, first.style); + assert_eq!(second.insets, first.insets); + assert_eq!(t.cell(moved_neighbor.0, moved_neighbor.1), original.cell(neighbor.0, neighbor.1)); + let owners = t.owners(); + assert_eq!(owners.iter().copied().collect::>().len(), 18, "four original selected owners split once each"); + let mut before_text: Vec<_> = original.cells.iter().filter(|c| !c.text.is_empty()).map(|c| c.text.text.clone()).collect(); + let mut after_text: Vec<_> = t.cells.iter().filter(|c| !c.text.is_empty()).map(|c| c.text.text.clone()).collect(); + before_text.sort(); + after_text.sort(); + assert_eq!(after_text, before_text, "no original text is copied or lost"); + } + } + + #[test] + fn split_range_resolves_covered_positions_without_expanding_selection() { + for horizontal in [true, false] { + let mut t = Table::new(1, 3, 3, 0, 0, 180.0); + t.merge(CellRange::new(0, 0, 1, 1)).unwrap(); + let selected = if horizontal { CellRange::new(1, 1, 1, 2) } else { CellRange::new(1, 1, 2, 1) }; + t.split_cells(selected, horizontal).unwrap(); + t.check().unwrap(); + let owners = t.owners(); + assert_eq!(owners.iter().copied().collect::>().len(), 8, "only the two touched owners split"); + assert_eq!((t.nrows(), t.ncols()), if horizontal { (4, 3) } else { (3, 4) }); + assert_eq!(t.owner(0, 0), (0, 0)); + assert_eq!(if horizontal { t.owner(1, 0) } else { t.owner(0, 1) }, if horizontal { (1, 0) } else { (0, 1) }); + } + } + + #[test] + fn split_header_row_keeps_its_height_mode_and_role() { + let mut t = Table::new(1, 1, 2, 1, 1, 180.0); + t.rows[0].height = 60.0; + t.rows[0].mode = RowHeightMode::Exactly; + let body = t.rows[1].clone(); + let footer = t.rows[2].clone(); + t.split_cells(CellRange::new(0, 0, 0, 1), true).unwrap(); + t.check().unwrap(); + assert_eq!(t.header_rows(), 2); + for row in &t.rows[..2] { + assert_eq!(row.height, 30.0); + assert_eq!(row.mode, RowHeightMode::Exactly); + assert_eq!(row.kind, RowKind::Header); + } + assert_eq!(t.rows[2], body); + assert_eq!(t.rows[3], footer); + } } diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index c2313170a..6c6b1ff9d 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -178,7 +178,7 @@ fn table_specs() -> Vec { "Split Cell Horizontally", ["Table"], None, - "{} — the target cell becomes two, one above the other", + "{} — each target cell becomes two, one above the other", in_table, |s, p| split(s, p, true) ), @@ -187,7 +187,7 @@ fn table_specs() -> Vec { "Split Cell Vertically", ["Table"], None, - "{} — the target cell becomes two, side by side", + "{} — each target cell becomes two, side by side", in_table, |s, p| split(s, p, false) ), @@ -589,7 +589,7 @@ fn merge(s: &mut Session, p: &Value) -> Result { fn split(s: &mut Session, p: &Value, horizontal: bool) -> Result { let g = target(s, p, "table.split")?; edit_table(s, &g, "table.split", |t| { - t.split_cell(g.range.r0, g.range.c0, horizontal); + t.split_cells(g.range, horizontal).map_err(|e| bad("table.split", e))?; Ok(json!({"rows": t.nrows(), "cols": t.ncols()})) }) } diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 45861347d..3934aabec 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -399,3 +399,125 @@ fn editing_beside_a_merged_cell_only_changes_the_selected_edge_segment() { } } } + +#[test] +fn split_selected_row_or_column_splits_both_cells() { + for horizontal in [true, false] { + let (mut s, sid, _) = session_with_frame(); + let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + let params = if horizontal { json!({"rows": [0, 0], "cols": [0, 1]}) } else { json!({"rows": [0, 1], "cols": [0, 0]}) }; + s.execute("table.select", ¶ms).unwrap(); + let command = if horizontal { "table.splitHorizontally" } else { "table.splitVertically" }; + s.execute(command, &json!({})).unwrap(); + let table = table_for(&s, sid, tid); + assert_eq!((table.nrows(), table.ncols()), if horizontal { (3, 2) } else { (2, 3) }); + for r in 0..2 { + for c in 0..2 { + assert_eq!(table.owner(r, c), (r, c), "both selected cells have independent halves"); + } + } + check(&s); + } +} + +#[test] +fn split_selected_block_and_merged_cells_preserves_geometry_and_history() { + for horizontal in [true, false] { + for merged in [false, true] { + let (mut s, sid, _) = session_with_frame(); + let tid = s.execute("table.insert", &json!({"rows": 4, "cols": 4, "width": 240})).unwrap()["table"].as_u64().unwrap(); + s.execute("table.setRowHeight", &json!({"table": tid, "height": 60, "mode": "exactly"})).unwrap(); + s.execute("style.cell.create", &json!({"name": "Neighbor", "fill": "[Paper]", "insets": 3})).unwrap(); + s.execute("style.cell.create", &json!({"name": "Chosen", "fill": "[Black]", "tint": 0.15, "insets": 2})).unwrap(); + s.execute("style.cell.apply", &json!({"table": tid, "name": "Neighbor"})).unwrap(); + for r in 0..4 { + for c in 0..4 { + s.execute("table.setCell", &json!({"row": r, "col": c, "text": format!("{r},{c}")})).unwrap(); + } + } + let range = if !merged { + doc::CellRange::new(1, 1, 2, 2) + } else if horizontal { + doc::CellRange::new(0, 0, 2, 1) + } else { + doc::CellRange::new(0, 0, 1, 2) + }; + let selection = json!({"rows": [range.r0, range.r1], "cols": [range.c0, range.c1]}); + s.execute("table.select", &selection).unwrap(); + s.execute("style.cell.apply", &json!({"name": "Chosen"})).unwrap(); + if merged { + let merge = if horizontal { json!({"rows": [0, 2], "cols": [1, 1]}) } else { json!({"rows": [1, 1], "cols": [0, 2]}) }; + s.execute("table.merge", &merge).unwrap(); + s.execute("table.select", &selection).unwrap(); + } + let before_doc = s.doc().unwrap().doc.clone(); + let before_selection = s.doc().unwrap().selection.clone(); + let before = table_for(&s, sid, tid).clone(); + let before_layout = s.cache.get(&before_doc, doc::StoryId(sid), None); + let before_fragment = before_layout.frames.iter().flat_map(|f| &f.tables).find(|t| t.table == tid).unwrap(); + let undo_len = s.doc().unwrap().history.undo.len(); + let command = if horizontal { "table.splitHorizontally" } else { "table.splitVertically" }; + s.execute(command, &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1, "one split command creates one undo entry"); + let after_doc = s.doc().unwrap().doc.clone(); + let after_selection = s.doc().unwrap().selection.clone(); + let after = table_for(&s, sid, tid).clone(); + let after_layout = s.cache.get(&after_doc, doc::StoryId(sid), None); + let after_fragment = after_layout.frames.iter().flat_map(|f| &f.tables).find(|t| t.table == tid).unwrap(); + assert_eq!(after_fragment.rect, before_fragment.rect, "splitting does not resize the table"); + assert_eq!(after_fragment.cells.len(), before_fragment.cells.len() + 4, "four original owners split once"); + for placed in &before_fragment.cells { + let original = before.cell(placed.row, placed.col).unwrap(); + let matches: Vec<_> = after_fragment.cells.iter().filter(|c| c.source == placed.source).collect(); + assert_eq!(matches.len(), 1, "original cell text survives once: {}", placed.source); + let surviving = matches[0]; + let cell = after.cell(surviving.row, surviving.col).unwrap(); + let mut normalized = cell.clone(); + normalized.row_span = original.row_span; + normalized.col_span = original.col_span; + assert_eq!(&normalized, original, "existing cell text and formatting are preserved"); + if !range.contains(placed.row, placed.col) { + assert_eq!(surviving.rect, placed.rect, "unselected neighbor geometry is unchanged"); + continue; + } + let second_addr = if horizontal { + (surviving.row + cell.row_span as usize, surviving.col) + } else { + (surviving.row, surviving.col + cell.col_span as usize) + }; + let second = after.cell(second_addr.0, second_addr.1).unwrap(); + assert_eq!(after.owner(second_addr.0, second_addr.1), second_addr); + assert!(second.text.is_empty()); + assert_eq!(second.style, original.style); + assert_eq!(second.insets, original.insets); + assert_eq!(second.fill, original.fill); + assert_eq!(second.fill_tint, original.fill_tint); + let second_rect = after_fragment.cell(second_addr.0, second_addr.1).unwrap().rect; + assert_eq!(surviving.rect.union(second_rect), placed.rect, "the halves exactly cover the original cell"); + if horizontal { + assert_eq!(surviving.rect.y1, second_rect.y0); + } else { + assert_eq!(surviving.rect.x1, second_rect.x0); + } + } + let text = s.doc().unwrap().selection.text.unwrap(); + let address = text.cell.unwrap(); + assert_eq!(after.owner(address.row, address.col), (address.row, address.col)); + assert!(text.focus <= after.cell(address.row, address.col).unwrap().text.len()); + let cells = s.doc().unwrap().selection.cells.unwrap(); + assert!(cells.range.r1 < after.nrows() && cells.range.c1 < after.ncols()); + check(&s); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before_doc)); + assert_eq!(s.doc().unwrap().selection, before_selection); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &after_doc)); + assert_eq!(s.doc().unwrap().selection, after_selection); + let bytes = crate::cmd::file_bytes(&s.doc().unwrap().doc); + let reopened = crate::cmd::file_from(&bytes).unwrap(); + reopened.check().unwrap(); + assert_eq!(reopened.story(doc::StoryId(sid)).unwrap().tables.get(&tid).unwrap().as_ref(), &after); + } + } +} From 966b637796bc9a7cf175c066ded41521b7c72e0f Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 04:27:41 +0200 Subject: [PATCH 44/48] fix(ui): open the dock's panel menu from its three-line button (part of #676) --- crates/ui-egui/src/dock.rs | 12 +++++ crates/ui-egui/src/panel_docking.rs | 54 ++++++++++++----------- crates/ui-egui/src/panel_docking_tests.rs | 25 +++++++++++ crates/ui-egui/src/panels/pages.rs | 9 ++++ 4 files changed, 75 insertions(+), 25 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 72810e151..0d581e992 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -90,7 +90,19 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { x += w; } let menu_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 24.0, strip.min.y + 4.0), vec2(18.0, 18.0)); + let menu = ui.interact(menu_r, ui.id().with("dock-menu"), Sense::click()); + if menu.hovered() { + ui.painter().rect_filled(menu_r, 3.0, t.hover); + } icons::paint(ui.painter(), menu_r, "menu", t.icon); + let tab = app.ui.dock_tab.clone(); + egui::Popup::menu(&menu).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + if tab == "pages" { + panels::pages::panel_menu(app, ui); + ui.separator(); + } + crate::panel_docking::panel_menu(app, ui, &tab); + }); egui::Frame::NONE.inner_margin(egui::Margin { left: 10, right: 10, top: 4, bottom: 6 }).show(ui, |ui| { egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| match app.ui.dock_tab.as_str() { "pages" => panels::pages::show(app, ui), diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index e53986c5c..05fc1150c 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -418,31 +418,7 @@ fn icon_rail(app: &mut DesignApp, ui: &mut egui::Ui) { pub(crate) fn legacy_tab(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response) { let Some(panel) = normalize(panel) else { return }; - let language = app.ui.language.clone(); - response.context_menu(|ui| { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Float Panel"))).clicked() { - if let Err(error) = app.run("window.panel.float", json!({"panel": panel})) { - app.status(error); - } - ui.close(); - } - ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Group with")), |ui| { - egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - for (anchor, label, _) in crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS) { - if *anchor != panel && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, label))).clicked() { - // A closed icon panel is made visible before becoming the destination. - let result = app - .run("window.panel.activate", json!({"panel": anchor})) - .and_then(|_| app.run("window.panel.move", json!({"panel":panel,"anchor":anchor}))); - if let Err(error) = result { - app.status(error); - } - ui.close(); - } - } - }); - }); - }); + response.context_menu(|ui| panel_menu(app, ui, panel)); if response.drag_started() && let Some(id) = normalize(panel) { @@ -472,6 +448,34 @@ pub(crate) fn legacy_tab(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str, re } } +/// The docking commands of a panel: its tab's context menu and the end of its panel menu. +pub(crate) fn panel_menu(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str) { + let Some(panel) = normalize(panel) else { return }; + let language = app.ui.language.clone(); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Float Panel"))).clicked() { + if let Err(error) = app.run("window.panel.float", json!({"panel": panel})) { + app.status(error); + } + ui.close(); + } + ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Group with")), |ui| { + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + for (anchor, label, _) in crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS) { + if *anchor != panel && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, label))).clicked() { + // A closed icon panel is made visible before becoming the destination. + let result = app + .run("window.panel.activate", json!({"panel": anchor})) + .and_then(|_| app.run("window.panel.move", json!({"panel":panel,"anchor":anchor}))); + if let Err(error) = result { + app.status(error); + } + ui.close(); + } + } + }); + }); +} + struct PanelContent<'a>(&'a mut DesignApp); impl craft_ui::docking::DockContent for PanelContent<'_> { fn body(&mut self, ui: &mut egui::Ui, id: &String) { diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index 210ae0ce0..de08e3cf5 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -597,3 +597,28 @@ fn capture_legacy_panel_menu_visual_fixture() { assert_eq!(serde_json::to_value(&h.state().session.active().unwrap().doc).unwrap(), doc); h.render().unwrap().save(directory.join("designcraft-legacy-last-target-selected-dark-ar.png")).unwrap(); } + +#[test] +fn legacy_dock_menu_button_opens_the_active_panel_menu() { + let mut app = app(); + app.run("file.new", json!({})).unwrap(); + app.ui.dock_tab = "pages".into(); + let pages = app.session.active().unwrap().doc.page_count(); + let mut h = harness(app, vec2(1280.0, 800.0)); + assert!(h.state().ui.docking.is_none()); + let tab = h.query_all_by_label("Pages").find(|node| node.rect().height() <= 45.0).unwrap().rect(); + let dock = egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("dock")).unwrap().outer_rect; + let at = egui::pos2(dock.right() - 15.0, tab.center().y); + h.event(Event::PointerMoved(at)); + h.run_steps(1); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(2); + assert!(egui::Popup::is_any_open(&h.ctx), "the three-line button must open a menu"); + h.get_by_label("Insert Page After").click(); + h.run_steps(3); + assert_eq!(h.state().session.active().unwrap().doc.page_count(), pages + 1); + assert!(!egui::Popup::is_any_open(&h.ctx)); +} diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 6bbad9066..ae323225b 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -216,6 +216,15 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } } +/// The panel menu's page commands, acting on the current page and its spread. +pub fn panel_menu(app: &mut DesignApp, ui: &mut egui::Ui) { + let Some(st) = app.session.active() else { return }; + let doc = st.doc.clone(); + let cur = crate::canvas::current_page(app).unwrap_or(0); + let Some((si, _)) = doc.page_loc(cur) else { return }; + page_menu(app, ui, &doc, cur, si); +} + fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Document, abs: usize, si: usize) { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Insert Page After"))).clicked() { let _ = app.run("layout.pages.insert", json!({"after": abs, "count": 1})); From fa9c7b08b255bef19577c4830da546f285e44387 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:43:50 +1000 Subject: [PATCH 45/48] PARITY-DISTRIBUTE-LOCKS: preserve locked objects during distribution Co-Authored-By: Codex --- crates/engine/src/cmd/object.rs | 4 + crates/engine/tests/distribute_locks.rs | 128 ++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 crates/engine/tests/distribute_locks.rs diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index cd13be117..729b955b1 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1887,6 +1887,10 @@ fn distribute(s: &mut Session, p: &Value) -> Result { for ((id, r), t) in items.iter().zip(targets) { let delta = t - lo(r); if let Some(it) = d.item_mut(*id) { + // Keep locked items in the spacing calculation, as align does for its reference. + if it.locked { + continue; + } let v = if horizontal { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) }; it.xf = Affine::translate(v) * it.xf; } diff --git a/crates/engine/tests/distribute_locks.rs b/crates/engine/tests/distribute_locks.rs new file mode 100644 index 000000000..4935e9946 --- /dev/null +++ b/crates/engine/tests/distribute_locks.rs @@ -0,0 +1,128 @@ +//! Distribution preserves explicit item locks without changing reference/spacing calculations. +use std::sync::Arc; + +use designcraft_doc::ItemId; +use designcraft_engine::Session; +use designcraft_geom::Affine; +use serde_json::{Value, json}; + +const STARTS: [f64; 4] = [10.0, 70.0, 150.0, 310.0]; +const SIZES: [f64; 4] = [20.0, 30.0, 40.0, 50.0]; +// An original, synthetic 1x1 RGBA PNG; used by both a locked and an unlocked frame. +const PIXEL: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGPYYqPxHwAE2AIYBVnQbwAAAABJRU5ErkJggg=="; + +fn fixture(horizontal: bool, locks: [bool; 4]) -> (Session, Vec) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let mut ids = Vec::new(); + for (i, (&start, &size)) in STARTS.iter().zip(&SIZES).enumerate() { + let cross = 40.0 + i as f64 * 35.0; + let rect = if horizontal { [start, cross, start + size, cross + 25.0] } else { [cross, start, cross + 25.0, start + size] }; + let graphic = i == 1 || i == 2; + let created = + s.execute("frame.create", &json!({"rect": rect, "content": if graphic {"unassigned"} else {"text"}, "text": "Keep this text"})).unwrap(); + let id = created["id"].as_u64().unwrap(); + ids.push(id); + if graphic { + s.execute("file.place", &json!({"frame": id, "base64": PIXEL, "name": "pixel.png"})).unwrap(); + } + } + // An unselected control must retain all geometry and content too. + s.execute("frame.create", &json!({"rect": [400, 400, 450, 450], "content": "text", "text": "Outside selection"})).unwrap(); + s.execute("object.fill", &json!({"ids": ids, "swatch": "[Black]", "tint": 0.5})).unwrap(); + let locked: Vec = ids.iter().zip(locks).filter_map(|(&id, lock)| lock.then_some(id)).collect(); + if !locked.is_empty() { + s.execute("object.lock", &json!({"ids": locked})).unwrap(); + } + // Layers selection may include locked items. Deliberately avoid geometric order. + s.execute("selection.set", &json!({"ids": [ids[3], ids[1], ids[0], ids[2]]})).unwrap(); + assert!(!s.doc().unwrap().doc.assets.is_empty()); + s.doc().unwrap().doc.check().unwrap(); + (s, ids) +} + +fn modes() -> [(Value, [f64; 4]); 3] { + [ + // Endpoint centers 20 and 335 give a center step of 105. + (json!({"by": "centers"}), [10.0, 110.0, 210.0, 310.0]), + // Outer span 350 minus total widths 140 gives three gaps of 70. + (json!({"by": "spacing"}), [10.0, 100.0, 200.0, 310.0]), + // An explicit spacing value selects fixed-gap mode without requiring `by`. + (json!({"spacing": 15}), [10.0, 45.0, 90.0, 145.0]), + ] +} + +fn check_distribution(horizontal: bool, mut params: Value, positions: [f64; 4], locks: [bool; 4], explicit_ids: bool) { + let (mut s, ids) = fixture(horizontal, locks); + params["axis"] = json!(if horizontal { "horizontal" } else { "vertical" }); + if explicit_ids { + params["ids"] = json!([ids[2], ids[0], ids[3], ids[1]]); + } + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let undo_len = s.doc().unwrap().history.undo.len(); + let mut expected = (*before).clone(); + for (i, &id) in ids.iter().enumerate() { + if !locks[i] { + let delta = positions[i] - STARTS[i]; + let translation = if horizontal { (delta, 0.0) } else { (0.0, delta) }; + let item = expected.item_mut(ItemId(id)).unwrap(); + item.xf = Affine::translate(translation) * item.xf; + } + } + s.execute("object.distribute", ¶ms).unwrap(); + let after = s.doc().unwrap().doc.clone(); + for (i, &id) in ids.iter().enumerate() { + let item = after.item(ItemId(id)).unwrap(); + let b = item.bounds(); + let start = if horizontal { b.x0 } else { b.y0 }; + assert_eq!(start, if locks[i] { STARTS[i] } else { positions[i] }, "item {i}: {params}, locks={locks:?}"); + if locks[i] { + assert_eq!(item, before.item(ItemId(id)).unwrap(), "locked item's transform, path, content and flags are unchanged"); + } + } + // Only the specified translations may change: all paths, sizes, other-axis coordinates, + // paint, content, stories, layer membership, item order and embedded asset bytes are exact. + assert_eq!(*after, expected, "{params}, locks={locks:?}"); + assert_eq!(s.doc().unwrap().selection, selection); + after.check().unwrap(); + if expected != *before { + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &before)); + assert_eq!(s.doc().unwrap().selection, selection); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &after)); + assert_eq!(s.doc().unwrap().selection, selection); + } +} + +#[test] +fn distribute_preserves_locked_items_and_moves_unlocked_controls() { + for horizontal in [true, false] { + for (params, positions) in modes() { + // Locked interior object still participates in the numeric reference calculation. + check_distribution(horizontal, params.clone(), positions, [false, true, false, false], false); + // Locked endpoints and an interior object; only the unlocked interior control moves. + check_distribution(horizontal, params, positions, [true, true, false, true], true); + } + } +} + +#[test] +fn distribute_unlocked_spacing_modes_are_unchanged() { + for horizontal in [true, false] { + for (params, positions) in modes() { + check_distribution(horizontal, params, positions, [false; 4], true); + } + } +} + +#[test] +fn distribute_all_locked_leaves_document_and_selection_unchanged() { + for horizontal in [true, false] { + for (params, positions) in modes() { + check_distribution(horizontal, params, positions, [true; 4], false); + } + } +} From 481b7d2f3b4caa756b11a4b893c656398b9a5d74 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:17:58 +1000 Subject: [PATCH 46/48] PARITY-EFFECTIVE-PPI: check transformed image resolution Co-Authored-By: Codex --- crates/engine/src/cmd/preflight.rs | 87 ++++---- crates/engine/tests/preflight_resolution.rs | 217 ++++++++++++++++++++ 2 files changed, 264 insertions(+), 40 deletions(-) create mode 100644 crates/engine/tests/preflight_resolution.rs diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index 3fc061d7d..f5ee8dfdf 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -5,6 +5,7 @@ use designcraft_compose::ComposedStory; use designcraft_doc::{Content, Document, Intent, Item, SpreadRef}; use designcraft_fonts::FaceRef; +use designcraft_geom::Affine; use serde::Serialize; use serde_json::{Value, json}; @@ -29,10 +30,12 @@ pub fn specs() -> Vec { "{minPpi?: 150} → issues [{severity, kind, message, item?, page?}]", has_doc, run)] } -fn page_of(d: &Document, sr: SpreadRef, it: &Item) -> Option { +fn page_of(d: &Document, sr: SpreadRef, it: &Item, parent: Affine) -> Option { let SpreadRef::Doc(si) = sr else { return None }; let sp = d.spreads.get(si)?; - Some(d.first_page_of_spread(si) + sp.page_at_x(it.bounds().center().x).unwrap_or(0)) + // Item bounds include its own transform; ancestors bring the frame into spread space. + let bounds = parent.transform_rect_bbox(it.bounds()); + Some(d.first_page_of_spread(si) + sp.page_at_x(bounds.center().x).unwrap_or(0)) } /// Characters a missing-glyph report names before "and N more". @@ -85,7 +88,7 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { missing_glyphs(&cs, &story.text, 0, &mut missing); if cs.is_overset() { let last = story.frames.last().copied(); - let page = last.and_then(|f| d.find(f).zip(d.item(f))).and_then(|(loc, it)| page_of(d, loc.spread, it)); + let page = last.and_then(|f| d.find(f).zip(d.item(f))).and_then(|(loc, it)| page_of(d, loc.spread, it, Affine::IDENTITY)); let n = story.text[cs.overset_at.unwrap_or(0).min(story.len())..].chars().count(); out.push(Issue { severity: "error", kind: "overset", message: format!("Overset text: {n} characters"), item: last.map(|i| i.0), page }); } @@ -161,48 +164,52 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { page: None, }); } - // Items. + // Carry the ancestor transform in the same order as rendering, retaining pre-order. for (si, sp) in d.spreads.iter().enumerate() { - for top in &sp.items { - top.walk(&mut |it| { - let page = page_of(d, SpreadRef::Doc(si), it); - match &it.content { - Content::Graphic(g) => match d.assets.get(&g.asset) { - None => { - out.push(Issue { severity: "error", kind: "missingLink", message: "Missing graphic".into(), item: Some(it.id.0), page }) - } - Some(a) if a.data.is_empty() => out.push(Issue { - severity: "error", - kind: "missingLink", - message: format!("Missing link: {}", a.link.as_deref().unwrap_or(&a.name)), - item: Some(it.id.0), - page, - }), - Some(a) => { - // Effective ppi: pixels per inch at the placed size. Placed PDF and - // SVG pages export as vectors; their `pixels` is only the preview's. - let vector = designcraft_images::is_pdf(&a.data) || designcraft_images::is_svg(&a.data); - if let Some((pw, _)) = a.pixels.filter(|_| !vector) { - let placed_w = (g.xf * it.xf).as_coeffs()[0].hypot((g.xf * it.xf).as_coeffs()[1]) * g.size.0; - let ppi = pw as f64 / (placed_w / 72.0).max(1e-6); - if d.settings.intent == Intent::Print && ppi < min_ppi { - out.push(Issue { - severity: "warning", - kind: "lowResolution", - message: format!("{}: effective {ppi:.0} ppi (< {min_ppi:.0})", a.name), - item: Some(it.id.0), - page, - }); - } + let mut pending: Vec<_> = sp.items.iter().rev().map(|it| (it.as_ref(), Affine::IDENTITY)).collect(); + while let Some((it, parent)) = pending.pop() { + let xf = parent * it.xf; + pending.extend(it.children().iter().rev().map(|child| (child.as_ref(), xf))); + let page = page_of(d, SpreadRef::Doc(si), it, parent); + match &it.content { + Content::Graphic(g) => match d.assets.get(&g.asset) { + None => out.push(Issue { severity: "error", kind: "missingLink", message: "Missing graphic".into(), item: Some(it.id.0), page }), + Some(a) if a.data.is_empty() => out.push(Issue { + severity: "error", + kind: "missingLink", + message: format!("Missing link: {}", a.link.as_deref().unwrap_or(&a.name)), + item: Some(it.id.0), + page, + }), + Some(a) => { + // Placed PDF and SVG pages export as vectors; `pixels` is only the preview's. + let vector = designcraft_images::is_pdf(&a.data) || designcraft_images::is_svg(&a.data); + if let Some((pw, ph)) = a.pixels.filter(|_| !vector) { + // Measure each image axis in points, not its rotated bounding box or + // cropped frame. Either axis can fall below the minimum resolution. + let [xx, yx, xy, yy, _, _] = (xf * g.xf).as_coeffs(); + let placed_w = xx.hypot(yx) * g.size.0; + let placed_h = xy.hypot(yy) * g.size.1; + let ppi_x = pw as f64 / (placed_w / 72.0).max(1e-6); + let ppi_y = ph as f64 / (placed_h / 72.0).max(1e-6); + let ppi = ppi_x.min(ppi_y); + if d.settings.intent == Intent::Print && ppi < min_ppi { + out.push(Issue { + severity: "warning", + kind: "lowResolution", + message: format!("{}: effective {ppi:.0} ppi (< {min_ppi:.0})", a.name), + item: Some(it.id.0), + page, + }); } } - }, - Content::Text(tf) if d.story(tf.story).is_some_and(|s| s.is_empty()) => { - out.push(Issue { severity: "warning", kind: "emptyText", message: "Empty text frame".into(), item: Some(it.id.0), page }); } - _ => {} + }, + Content::Text(tf) if d.story(tf.story).is_some_and(|s| s.is_empty()) => { + out.push(Issue { severity: "warning", kind: "emptyText", message: "Empty text frame".into(), item: Some(it.id.0), page }); } - }); + _ => {} + } } } // RGB swatches in use in a print document. diff --git a/crates/engine/tests/preflight_resolution.rs b/crates/engine/tests/preflight_resolution.rs new file mode 100644 index 000000000..f4562c569 --- /dev/null +++ b/crates/engine/tests/preflight_resolution.rs @@ -0,0 +1,217 @@ +//! Effective raster resolution follows image, frame and ancestor transforms in spread space. +use std::sync::Arc; + +use designcraft_doc::{Content, Intent, ItemId}; +use designcraft_engine::{Session, cmd::base64_encode}; +use designcraft_geom::Affine; +use serde_json::{Value, json}; + +fn place(s: &mut Session, name: &str, bytes: &[u8]) -> ItemId { + s.execute("selection.set", &json!({"ids": []})).unwrap(); + let placed = s.execute("file.place", &json!({"base64": base64_encode(bytes), "name": name, "x": 0, "y": 0, "width": 144})).unwrap(); + ItemId(placed["id"].as_u64().unwrap()) +} + +fn raster() -> (Session, ItemId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 1000, "height": 1000, "facingPages": false})).unwrap(); + // 600 × 300 pixels at 144 × 72 points: 300 ppi in both directions. + let png = designcraft_render::Rendered { width: 600, height: 300, pixels: vec![200; 600 * 300 * 4] }.to_png(); + let id = place(&mut s, "photo.png", &png); + assert_eq!(s.doc().unwrap().doc.item(id).unwrap().inner_bounds().size(), designcraft_geom::Size::new(144.0, 72.0)); + (s, id) +} + +fn facing_raster() -> (Session, ItemId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 612, "height": 792, "pages": 3, "facingPages": true})).unwrap(); + // Page 0 is the cover; spread 1 contains absolute pages 1 (left) and 2 (right). + let d = &s.doc().unwrap().doc; + assert_eq!(d.spreads.len(), 2); + assert_eq!(d.spreads[0].pages.len(), 1); + assert_eq!(d.spreads[1].pages.len(), 2); + assert_eq!(d.first_page_of_spread(1), 1); + assert_eq!(d.spreads[1].pages[0].x, 0.0); + assert_eq!(d.spreads[1].pages[1].x, 612.0); + let png = designcraft_render::Rendered { width: 600, height: 300, pixels: vec![200; 600 * 300 * 4] }.to_png(); + let placed = s + .execute( + "file.place", + &json!({ + "base64": base64_encode(&png), "name": "photo.png", "spread": 1, + "x": 0, "y": 0, "width": 144 + }), + ) + .unwrap(); + let id = ItemId(placed["id"].as_u64().unwrap()); + let d = &s.doc().unwrap().doc; + assert_eq!(d.find(id).unwrap().spread, designcraft_doc::SpreadRef::Doc(1)); + assert_eq!(d.item(id).unwrap().inner_bounds().size(), designcraft_geom::Size::new(144.0, 72.0)); + (s, id) +} + +fn transform(s: &mut Session, id: ItemId, frame: Affine, image: Affine) { + s.edit(|d, _| { + let it = d.item_mut(id).unwrap(); + it.xf = frame; + let Content::Graphic(g) = &mut it.content else { panic!("graphic fixture") }; + g.xf = image * g.xf; + Ok(()) + }) + .unwrap(); +} + +fn group(s: &mut Session, child: ItemId, xf: Affine) -> ItemId { + let spread = s.doc().unwrap().doc.find(child).unwrap().spread; + let marker = s.execute("frame.create", &json!({"spread": spread, "rect": [0, 0, 1, 1]})).unwrap()["id"].clone(); + let group = s.execute("object.group", &json!({"ids": [child.0, marker]})).unwrap(); + let id = ItemId(group["id"].as_u64().unwrap()); + s.edit(|d, _| { + d.item_mut(id).unwrap().xf = xf; + Ok(()) + }) + .unwrap(); + id +} + +fn resolutions(s: &mut Session, params: Value) -> Vec { + let before = s.doc().unwrap(); + let doc = before.doc.clone(); + let selection = before.selection.clone(); + let state = (before.revision, before.history.undo.len(), before.history.redo.len(), s.journal.len()); + let result = s.execute("preflight.run", ¶ms).unwrap(); + let after = s.doc().unwrap(); + assert!(Arc::ptr_eq(&doc, &after.doc), "preflight must leave the document and assets untouched"); + assert_eq!(selection, after.selection, "preflight must leave the selection untouched"); + assert_eq!(state, (after.revision, after.history.undo.len(), after.history.redo.len(), s.journal.len())); + result["issues"].as_array().unwrap().iter().filter(|i| i["kind"] == "lowResolution").cloned().collect() +} + +fn warning(s: &mut Session, id: ItemId, params: Value, ppi: u32, threshold: u32) { + warning_on_page(s, id, params, ppi, threshold, 0); +} + +fn warning_on_page(s: &mut Session, id: ItemId, params: Value, ppi: u32, threshold: u32, page: usize) { + assert_eq!( + resolutions(s, params), + vec![json!({ + "severity": "warning", "kind": "lowResolution", "item": id.0, "page": page, + "message": format!("photo.png: effective {ppi} ppi (< {threshold})") + })] + ); +} + +#[test] +fn either_raster_axis_can_fail_after_image_or_frame_scaling() { + for scale in [Affine::scale_non_uniform(4.0, 1.0), Affine::scale_non_uniform(1.0, 4.0)] { + for image_only in [false, true] { + let (mut s, id) = raster(); + let (frame, image) = if image_only { (Affine::IDENTITY, scale) } else { (scale, Affine::IDENTITY) }; + transform(&mut s, id, frame, image); + // One axis remains 300 ppi, the other is 75 ppi: report the worse axis once. + warning(&mut s, id, json!({}), 75, 150); + } + } +} + +#[test] +fn nested_groups_accumulate_scale_and_keep_image_frame_parent_order() { + let (mut s, id) = raster(); + let inner = group(&mut s, id, Affine::scale(2.0)); + group(&mut s, inner, Affine::scale(2.0)); + warning(&mut s, id, json!({}), 75, 150); + + let (mut s, id) = raster(); + // Double image X, then turn the frame 90 degrees, then triple spread X. + // The image axes become 288 and 216 pt, giving 150 and 100 ppi respectively. + transform(&mut s, id, Affine::new([0.0, 1.0, -1.0, 0.0, 400.0, 20.0]), Affine::scale_non_uniform(2.0, 1.0)); + group(&mut s, id, Affine::scale_non_uniform(3.0, 1.0)); + warning(&mut s, id, json!({}), 100, 150); +} + +#[test] +fn translated_scaled_nested_raster_reports_right_facing_page() { + let (mut s, id) = facing_raster(); + let inner = group(&mut s, id, Affine::scale(2.0)); + group(&mut s, inner, Affine::translate((640.0, 0.0)) * Affine::scale(2.0)); + // Local frame center x=72 is on the left page. Ancestors move its bounds to + // x=640..1216 on the right page, while scaling 300 ppi down to 75 ppi. + warning_on_page(&mut s, id, json!({}), 75, 150, 2); +} + +#[test] +fn translating_group_changes_warning_page_without_changing_resolution() { + let (mut s, id) = facing_raster(); + // A nonidentity frame transform also detects applying the frame transform twice. + transform(&mut s, id, Affine::translate((-240.0, 0.0)), Affine::IDENTITY); + warning_on_page(&mut s, id, json!({"minPpi": 301}), 300, 301, 1); + group(&mut s, id, Affine::translate((880.0, 0.0))); + // Translation moves the center to x=712, but does not change either image axis. + assert!(resolutions(&mut s, json!({})).is_empty()); + warning_on_page(&mut s, id, json!({"minPpi": 301}), 300, 301, 2); +} + +#[test] +fn rotation_translation_and_reflection_do_not_change_resolution() { + for xf in [ + Affine::translate((250.0, 300.0)) * Affine::rotate(std::f64::consts::FRAC_PI_4), + Affine::translate((250.0, 300.0)) * Affine::scale_non_uniform(-1.0, 1.0), + ] { + let (mut s, id) = raster(); + transform(&mut s, id, xf, Affine::IDENTITY); + // Measuring the rotated bounding box instead would incorrectly warn here. + assert!(resolutions(&mut s, json!({"minPpi": 250})).is_empty()); + warning(&mut s, id, json!({"minPpi": 301}), 300, 301); + } +} + +#[test] +fn cropping_keeps_resolution_and_fitting_changes_it_with_content_scale() { + let (mut s, id) = raster(); + s.execute("transform.resize", &json!({"ids": [id.0], "from": [0, 0, 144, 72], "to": [0, 0, 36, 18]})).unwrap(); + warning(&mut s, id, json!({"minPpi": 301}), 300, 301); + s.execute("transform.resize", &json!({"ids": [id.0], "from": [0, 0, 36, 18], "to": [0, 0, 144, 216]})).unwrap(); + warning(&mut s, id, json!({"minPpi": 301}), 300, 301); + + s.execute("object.fit", &json!({"ids": [id.0], "mode": "fitProportionally"})).unwrap(); + warning(&mut s, id, json!({"minPpi": 301}), 300, 301); + // Stretching just Y to 216 pt leaves X at 300 ppi but reduces Y to 100 ppi. + s.execute("object.fit", &json!({"ids": [id.0], "mode": "fitContentToFrame"})).unwrap(); + warning(&mut s, id, json!({}), 100, 150); + // Proportional fill is 432 × 216 pt: 100 ppi in both directions. + s.execute("object.fit", &json!({"ids": [id.0], "mode": "fillProportionally"})).unwrap(); + warning(&mut s, id, json!({}), 100, 150); + s.execute("object.fit", &json!({"ids": [id.0], "mode": "centerContent"})).unwrap(); + warning(&mut s, id, json!({}), 100, 150); +} + +#[test] +fn threshold_is_strict_and_resolution_remains_print_only() { + let (mut s, id) = raster(); + transform(&mut s, id, Affine::scale_non_uniform(1.0, 2.0), Affine::IDENTITY); + assert!(resolutions(&mut s, json!({})).is_empty(), "150 ppi meets the default threshold"); + assert!(resolutions(&mut s, json!({"minPpi": 149})).is_empty()); + warning(&mut s, id, json!({"minPpi": 151}), 150, 151); + for intent in [Intent::Web, Intent::Mobile] { + s.edit(|d, _| { + d.settings.intent = intent; + Ok(()) + }) + .unwrap(); + assert!(resolutions(&mut s, json!({"minPpi": 151})).is_empty()); + } +} + +#[test] +fn transformed_vector_previews_are_excluded_beside_rasters() { + let (mut s, id) = raster(); + let svg = br#""#; + let vector = place(&mut s, "vector.svg", svg); + let parent = s.execute("object.group", &json!({"ids": [id.0, vector.0]})).unwrap()["id"].as_u64().unwrap(); + s.edit(|d, _| { + d.item_mut(ItemId(parent)).unwrap().xf = Affine::scale_non_uniform(1.0, 4.0); + Ok(()) + }) + .unwrap(); + warning(&mut s, id, json!({}), 75, 150); +} From b70b586a16bc0759ca274db67e91ffca9e54179d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 02:22:34 -0400 Subject: [PATCH 47/48] fix(path-type): NaN-safe bracket clamping (f64::clamp panics on a NaN path length) Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/lib.rs | 2 +- crates/engine/src/cmd/path_type.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 4efd32bf5..14a97be2b 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -417,7 +417,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { path: None, ..tf.options.clone() }; - let end = pt.end.map_or(len, |e| e.clamp(0.0, len)); + let end = pt.end.map_or(len, |e| e.max(0.0).min(len)); (Rect::new(0.0, 0.0, (end - pt.start).max(1.0), size * 1.6), opts) } None => (item.text_area(), tf.options.clone()), diff --git a/crates/engine/src/cmd/path_type.rs b/crates/engine/src/cmd/path_type.rs index 9ce091a7e..02e5a1901 100644 --- a/crates/engine/src/cmd/path_type.rs +++ b/crates/engine/src/cmd/path_type.rs @@ -97,7 +97,7 @@ fn path_options(s: &mut Session, p: &Value) -> Result { return Ok(json!({"deleted": true})); } if let Some(v) = p.get("start").and_then(Value::as_f64).filter(|v| v.is_finite()) { - pt.start = v.clamp(0.0, len); + pt.start = v.max(0.0).min(len); } match p.get("end") { Some(Value::Null) => pt.end = None, From e862e0a613c0793c6fea7cfd37ad4145ed6cf686 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 02:28:50 -0400 Subject: [PATCH 48/48] fix: integrate batched community PRs (#572 with #662's fallible spread_param; #524's test helper back in its module) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/style.rs | 2 +- crates/ui-egui/src/panels/properties.rs | 86 ++++++++++++------------- 2 files changed, 44 insertions(+), 44 deletions(-) diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 4fced4c67..c4d20f099 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -1075,7 +1075,7 @@ fn has_text_or_selection(s: &Session) -> std::result::Result<(), String> { fn sample_color(s: &mut Session, p: &Value) -> Result { const ID: &str = "color.sample"; let at = super::point_param(p, "at").ok_or_else(|| bad(ID, "missing at"))?; - let sr = super::spread_param(p, "spread"); + let sr = super::spread_param(p, "spread")?; let st = s.doc()?; let (from, color) = image_color_at(&st.doc, sr, at).ok_or_else(|| bad(ID, "no placed image under the point"))?; let hex = color.to_hex(); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 76ddf6b42..d9c74e40d 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -2345,6 +2345,49 @@ mod tests { // The angle and scale fields are there. assert!(h.query_by(|n| n.role() == egui::accesskit::Role::TextInput && n.value().as_deref() == Some("90°")).is_some()); } + + fn panel(app: DesignApp) -> Harness<'static, DesignApp> { + let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + h + } + + #[test] + fn several_shapes_show_pathfinder_and_intersect_combines_them() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [100, 100, 300, 300], "shape": "ellipse"})).unwrap()["id"].clone(); + let b = app.run("frame.create", json!({"rect": [200, 100, 300, 200]})).unwrap()["id"].clone(); + // One shape: no Pathfinder section. + app.run("selection.set", json!({"ids": [a]})).unwrap(); + let mut h = panel(app); + assert!(h.query_by_label("Pathfinder").is_none()); + // Two: the section and its five buttons; Intersect leaves one shape, the ellipse's quarter. + h.state_mut().run("selection.set", json!({"ids": [a, b]})).unwrap(); + h.run_steps(3); + assert!(h.query_by_label("Pathfinder").is_some()); + for op in ["Add", "Subtract", "Intersect", "Exclude Overlap", "Minus Back"] { + assert!(h.query_by_label(op).is_some(), "{op}"); + } + h.get_by_label("Intersect").click(); + h.run_steps(3); + let doc = &h.state().session.active().unwrap().doc; + let items = &doc.spreads[0].items; + assert_eq!(items.len(), 1, "one shape left"); + let r = items[0].bounds(); + assert!((r.x0 - 200.0).abs() < 0.5 && (r.y1 - 200.0).abs() < 0.5 && (r.x1 - 300.0).abs() < 0.5, "{r:?}"); + } } #[cfg(test)] @@ -2436,47 +2479,4 @@ mod effects_panel_tests { assert!(!it.isolate && !it.knockout, "group-only options stay disabled for a frame holding content"); assert_eq!(st.history.undo.len(), undo, "disabled controls do not create undo steps"); } - - fn panel(app: DesignApp) -> Harness<'static, DesignApp> { - let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( - |ui, app: &mut DesignApp| { - let id = egui::Id::new("test_fonts"); - if ui.data(|d| d.get_temp::(id)).is_none() { - crate::theme::install_fonts(ui.ctx(), ""); - ui.data_mut(|d| d.insert_temp(id, true)); - return; - } - show(app, ui); - }, - app, - ); - h.run_steps(3); - h - } - - #[test] - fn several_shapes_show_pathfinder_and_intersect_combines_them() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let a = app.run("frame.create", json!({"rect": [100, 100, 300, 300], "shape": "ellipse"})).unwrap()["id"].clone(); - let b = app.run("frame.create", json!({"rect": [200, 100, 300, 200]})).unwrap()["id"].clone(); - // One shape: no Pathfinder section. - app.run("selection.set", json!({"ids": [a]})).unwrap(); - let mut h = panel(app); - assert!(h.query_by_label("Pathfinder").is_none()); - // Two: the section and its five buttons; Intersect leaves one shape, the ellipse's quarter. - h.state_mut().run("selection.set", json!({"ids": [a, b]})).unwrap(); - h.run_steps(3); - assert!(h.query_by_label("Pathfinder").is_some()); - for op in ["Add", "Subtract", "Intersect", "Exclude Overlap", "Minus Back"] { - assert!(h.query_by_label(op).is_some(), "{op}"); - } - h.get_by_label("Intersect").click(); - h.run_steps(3); - let doc = &h.state().session.active().unwrap().doc; - let items = &doc.spreads[0].items; - assert_eq!(items.len(), 1, "one shape left"); - let r = items[0].bounds(); - assert!((r.x0 - 200.0).abs() < 0.5 && (r.y1 - 200.0).abs() < 0.5 && (r.x1 - 300.0).abs() < 0.5, "{r:?}"); - } }