diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c..add8629 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 69415e9..3021e7d 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 49cb920..baf1674 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 b13627c..bcb27dd 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 {