From 44cbf0b214603e0ddabc45f62dd95d8e22177ae7 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 18:27:07 +0200 Subject: [PATCH 1/5] Properties: the arrow beside Opacity opens a slider, as in InDesign Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/panels/properties.rs | 84 ++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..a4a902e2f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-20** · Properties › Appearance: the arrow beside Opacity opens a slider under the field, as in InDesign, instead of the Effects panel; a drag shows the opacity live and is one undo step (the Opacity link still opens Effects). No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 44acf6e2c..fbdb417d8 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -856,9 +856,8 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { let mresp = ui.interact(more, ui.id().with("opmore"), Sense::click()); ui.painter().rect(more, 0.0, if mresp.hovered() { t.hover } else { t.input }, Stroke::new(1.0, t.field_border), StrokeKind::Inside); icons::paint(ui.painter(), more.shrink(4.0), "chevron-right", t.icon); - if mresp.clicked() { - show_panel(app, "effects"); - } + mresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(&app.ui.language, "Opacity"))); + opacity_slider(app, &mresp, i.opacity); ui.add_space(6.0); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 10.0; @@ -871,6 +870,36 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { }); } +/// The Opacity slider that the arrow beside the field opens, as in InDesign: a drag shows the +/// new opacity live and is one undo step when let go. +fn opacity_slider(app: &mut DesignApp, resp: &egui::Response, opacity: f64) { + let (mut slid, mut held) = (None, false); + egui::Popup::menu(resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + // A thin rail and a round knob, as InDesign draws it. + ui.spacing_mut().slider_width = 130.0; + ui.spacing_mut().slider_rail_height = 2.0; + let mut v = (opacity * 100.0).round(); + let slider = ui.add( + egui::Slider::new(&mut v, 0.0..=100.0).integer().show_value(false).handle_shape(egui::style::HandleShape::Circle).trailing_fill(false), + ); + let pressed = slider.is_pointer_button_down_on() || slider.dragged(); + if slider.changed() { + slid = Some((v, pressed)); + } + held = pressed; + }); + match slid { + Some((v, true)) => app.live_preview("Opacity", "object.opacity", json!({"opacity": v / 100.0})), + Some((v, false)) => { + // The keyboard (arrow keys on the focused slider): applied at once. + app.end_live_preview(); + let _ = app.run("object.opacity", json!({"opacity": v / 100.0})); + } + None if held => app.hold_live_preview(), + None => {} + } +} + /// The Fill (or Stroke) popup of Properties › Appearance, as InDesign's: Apply To (a text frame's /// frame or its text), the tint, then the swatches. `cur` is the frame's swatch. With `text_only` /// (text selected) it colours the selected text. @@ -3343,6 +3372,55 @@ mod tests { assert!((r.x0 - 200.0).abs() < 0.5 && (r.y1 - 200.0).abs() < 0.5 && (r.x1 - 300.0).abs() < 0.5, "{r:?}"); } + /// The arrow beside Opacity opens a slider, as in InDesign: a drag shows the opacity live and + /// is one undo step; the Opacity link still opens the Effects panel. + #[test] + fn the_opacity_arrow_opens_a_slider_and_a_drag_is_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, 72, 300, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let opacity = |app: &DesignApp| app.session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().opacity; + let steps = undo_steps(&app); + 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; + } + app.settle_live_preview(); + show(app, ui); + }, + app, + ); + h.run_steps(3); + assert!(h.query_by_role(egui::accesskit::Role::Slider).is_none()); + h.get_by(|n| n.role() == egui::accesskit::Role::Button && n.label().as_deref() == Some("Opacity")).click(); + h.run_steps(3); + let track = h.get_by_role(egui::accesskit::Role::Slider).rect(); + let y = track.center().y; + h.hover_at(egui::pos2(track.right() - 1.0, y)); + h.run_steps(1); + h.drag_at(egui::pos2(track.right() - 1.0, y)); + h.run_steps(1); + for k in 1..=6 { + h.hover_at(egui::pos2(track.right() - track.width() * 0.05 * k as f32, y)); + h.run_steps(1); + assert_eq!(undo_steps(h.state()), steps, "no undo step while dragging"); + } + let previewed = opacity(h.state()); + assert!(previewed < 0.9 && previewed > 0.5, "the drag shows the opacity live: {previewed}"); + h.drop_at(egui::pos2(track.right() - track.width() * 0.3, y)); + h.run_steps(3); + assert_eq!(undo_steps(h.state()), steps + 1, "the whole drag is one undo step"); + assert!(opacity(h.state()) < 0.9); + assert_ne!(h.state().ui.open_panel.as_deref(), Some("effects"), "the arrow doesn't open Effects"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(opacity(h.state()), 1.0); + } + const CYAN: &str = "C=100 M=0 Y=0 K=0"; #[test] From be9ee05a58a99dafcce72b8432c999019d92318f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 20:31:38 +0200 Subject: [PATCH 2/5] PR-1389: name the Opacity slider for assistive technology; keep the release frame in the drag Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index fbdb417d8..b781ed4b3 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -882,7 +882,13 @@ fn opacity_slider(app: &mut DesignApp, resp: &egui::Response, opacity: f64) { let slider = ui.add( egui::Slider::new(&mut v, 0.0..=100.0).integer().show_value(false).handle_shape(egui::style::HandleShape::Circle).trailing_fill(false), ); - let pressed = slider.is_pointer_button_down_on() || slider.dragged(); + // The slider has no visible caption (the field beside the arrow is the label), so it's + // named for assistive technology only. + let label = crate::i18n::tr(&app.ui.language, "Opacity"); + slider.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), v, label)); + // The release frame can move the value too; it stays part of the drag's preview, which + // the next frame commits as the one undo step. + let pressed = slider.is_pointer_button_down_on() || slider.dragged() || slider.drag_stopped(); if slider.changed() { slid = Some((v, pressed)); } @@ -3399,23 +3405,28 @@ mod tests { assert!(h.query_by_role(egui::accesskit::Role::Slider).is_none()); h.get_by(|n| n.role() == egui::accesskit::Role::Button && n.label().as_deref() == Some("Opacity")).click(); h.run_steps(3); + assert!( + h.query_by(|n| n.role() == egui::accesskit::Role::Slider && n.label().as_deref() == Some("Opacity")).is_some(), + "assistive technology names the slider" + ); let track = h.get_by_role(egui::accesskit::Role::Slider).rect(); let y = track.center().y; h.hover_at(egui::pos2(track.right() - 1.0, y)); h.run_steps(1); h.drag_at(egui::pos2(track.right() - 1.0, y)); h.run_steps(1); - for k in 1..=6 { + for k in 1..=5 { h.hover_at(egui::pos2(track.right() - track.width() * 0.05 * k as f32, y)); h.run_steps(1); assert_eq!(undo_steps(h.state()), steps, "no undo step while dragging"); } let previewed = opacity(h.state()); assert!(previewed < 0.9 && previewed > 0.5, "the drag shows the opacity live: {previewed}"); + // Let go further along than the last move: the release frame changes the value too. h.drop_at(egui::pos2(track.right() - track.width() * 0.3, y)); h.run_steps(3); assert_eq!(undo_steps(h.state()), steps + 1, "the whole drag is one undo step"); - assert!(opacity(h.state()) < 0.9); + assert!(opacity(h.state()) < previewed, "the release position is applied: {previewed} → {}", opacity(h.state())); assert_ne!(h.state().ui.open_panel.as_deref(), Some("effects"), "the arrow doesn't open Effects"); h.state_mut().run("edit.undo", json!({})).unwrap(); assert_eq!(opacity(h.state()), 1.0); From 1ffa37aaa53928ba825159943a28187e3151c878 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:11:47 +0200 Subject: [PATCH 3/5] Opacity slider: shown without a change, it commits the drag's preview at once Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index b781ed4b3..216f2ecc3 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -873,8 +873,9 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { /// The Opacity slider that the arrow beside the field opens, as in InDesign: a drag shows the /// new opacity live and is one undo step when let go. fn opacity_slider(app: &mut DesignApp, resp: &egui::Response, opacity: f64) { - let (mut slid, mut held) = (None, false); + let (mut slid, mut held, mut shown) = (None, false, false); egui::Popup::menu(resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + shown = true; // A thin rail and a round knob, as InDesign draws it. ui.spacing_mut().slider_width = 130.0; ui.spacing_mut().slider_rail_height = 2.0; @@ -902,6 +903,9 @@ fn opacity_slider(app: &mut DesignApp, resp: &egui::Response, opacity: f64) { let _ = app.run("object.opacity", json!({"opacity": v / 100.0})); } None if held => app.hold_live_preview(), + // Shown but let go without a change (a click outside, Escape): the drag's preview is + // committed now, before whatever that click does, so that keeps its own undo step. + None if shown => app.end_live_preview(), None => {} } } From f1f7ed1fab6985ada5877f74f69d41cc90f06543 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 14:15:30 +0100 Subject: [PATCH 4/5] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/tests.rs | 102 +++++++++--------- 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 2af713820..2858392f0 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -710,59 +710,59 @@ fn several_shapes_show_pathfinder_and_intersect_combines_them() { assert!((r.x0 - 200.0).abs() < 0.5 && (r.y1 - 200.0).abs() < 0.5 && (r.x1 - 300.0).abs() < 0.5, "{r:?}"); } - /// The arrow beside Opacity opens a slider, as in InDesign: a drag shows the opacity live and - /// is one undo step; the Opacity link still opens the Effects panel. - #[test] - fn the_opacity_arrow_opens_a_slider_and_a_drag_is_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, 72, 300, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); - app.run("selection.set", json!({"ids": [id]})).unwrap(); - let opacity = |app: &DesignApp| app.session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().opacity; - let steps = undo_steps(&app); - 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; - } - app.settle_live_preview(); - show(app, ui); - }, - app, - ); - h.run_steps(3); - assert!(h.query_by_role(egui::accesskit::Role::Slider).is_none()); - h.get_by(|n| n.role() == egui::accesskit::Role::Button && n.label().as_deref() == Some("Opacity")).click(); - h.run_steps(3); - assert!( - h.query_by(|n| n.role() == egui::accesskit::Role::Slider && n.label().as_deref() == Some("Opacity")).is_some(), - "assistive technology names the slider" - ); - let track = h.get_by_role(egui::accesskit::Role::Slider).rect(); - let y = track.center().y; - h.hover_at(egui::pos2(track.right() - 1.0, y)); +/// The arrow beside Opacity opens a slider, as in InDesign: a drag shows the opacity live and +/// is one undo step; the Opacity link still opens the Effects panel. +#[test] +fn the_opacity_arrow_opens_a_slider_and_a_drag_is_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, 72, 300, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let opacity = |app: &DesignApp| app.session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().opacity; + let steps = undo_steps(&app); + 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; + } + app.settle_live_preview(); + show(app, ui); + }, + app, + ); + h.run_steps(3); + assert!(h.query_by_role(egui::accesskit::Role::Slider).is_none()); + h.get_by(|n| n.role() == egui::accesskit::Role::Button && n.label().as_deref() == Some("Opacity")).click(); + h.run_steps(3); + assert!( + h.query_by(|n| n.role() == egui::accesskit::Role::Slider && n.label().as_deref() == Some("Opacity")).is_some(), + "assistive technology names the slider" + ); + let track = h.get_by_role(egui::accesskit::Role::Slider).rect(); + let y = track.center().y; + h.hover_at(egui::pos2(track.right() - 1.0, y)); + h.run_steps(1); + h.drag_at(egui::pos2(track.right() - 1.0, y)); + h.run_steps(1); + for k in 1..=5 { + h.hover_at(egui::pos2(track.right() - track.width() * 0.05 * k as f32, y)); h.run_steps(1); - h.drag_at(egui::pos2(track.right() - 1.0, y)); - h.run_steps(1); - for k in 1..=5 { - h.hover_at(egui::pos2(track.right() - track.width() * 0.05 * k as f32, y)); - h.run_steps(1); - assert_eq!(undo_steps(h.state()), steps, "no undo step while dragging"); - } - let previewed = opacity(h.state()); - assert!(previewed < 0.9 && previewed > 0.5, "the drag shows the opacity live: {previewed}"); - // Let go further along than the last move: the release frame changes the value too. - h.drop_at(egui::pos2(track.right() - track.width() * 0.3, y)); - h.run_steps(3); - assert_eq!(undo_steps(h.state()), steps + 1, "the whole drag is one undo step"); - assert!(opacity(h.state()) < previewed, "the release position is applied: {previewed} → {}", opacity(h.state())); - assert_ne!(h.state().ui.open_panel.as_deref(), Some("effects"), "the arrow doesn't open Effects"); - h.state_mut().run("edit.undo", json!({})).unwrap(); - assert_eq!(opacity(h.state()), 1.0); + assert_eq!(undo_steps(h.state()), steps, "no undo step while dragging"); } + let previewed = opacity(h.state()); + assert!(previewed < 0.9 && previewed > 0.5, "the drag shows the opacity live: {previewed}"); + // Let go further along than the last move: the release frame changes the value too. + h.drop_at(egui::pos2(track.right() - track.width() * 0.3, y)); + h.run_steps(3); + assert_eq!(undo_steps(h.state()), steps + 1, "the whole drag is one undo step"); + assert!(opacity(h.state()) < previewed, "the release position is applied: {previewed} → {}", opacity(h.state())); + assert_ne!(h.state().ui.open_panel.as_deref(), Some("effects"), "the arrow doesn't open Effects"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(opacity(h.state()), 1.0); +} const CYAN: &str = "C=100 M=0 Y=0 K=0"; From 0dab4137eecd8e821c65d36de6a4f8cd0c57e1f4 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 17:49:56 +0100 Subject: [PATCH 5/5] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/object.rs | 2 +- crates/ui-egui/src/panels/properties/tests.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/properties/object.rs b/crates/ui-egui/src/panels/properties/object.rs index 3d5ce2ab8..36a9fecaa 100644 --- a/crates/ui-egui/src/panels/properties/object.rs +++ b/crates/ui-egui/src/panels/properties/object.rs @@ -355,7 +355,7 @@ pub(super) fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) let mresp = ui.interact(more, ui.scope_id().with("opmore"), Sense::click()); ui.painter().rect(more, 0.0, if mresp.hovered() { t.hover } else { t.input }, Stroke::new(1.0, t.field_border), StrokeKind::Inside); icons::paint(ui.painter(), more.shrink(4.0), "chevron-right", t.icon); - mresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(&app.ui.language, "Opacity"))); + mresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::i18n::tr(&app.ui.language, "Opacity"))); opacity_slider(app, &mresp, i.opacity); ui.add_space(6.0); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 2858392f0..06d462abe 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -722,7 +722,7 @@ fn the_opacity_arrow_opens_a_slider_and_a_drag_is_one_undo_step() { let steps = undo_steps(&app); 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"); + let id = egui::Id::unique("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));