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:
parent
d020b4e85d
commit
0022b3a89b
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue