feat(ui): Pathfinder section in Properties

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 <noreply@anthropic.com>
This commit is contained in:
moppeeer 2026-10-11 20:16:44 +02:00 committed by paaspaas00
parent d020b4e85d
commit 0022b3a89b
4 changed files with 107 additions and 1 deletions

View File

@ -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.

View File

@ -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);

View File

@ -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::<bool>(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:?}");
}
}

View File

@ -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 {