From e1812231a90290385ac8f55a3c8765010b37c836 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 18:52:13 +0200 Subject: [PATCH 01/20] Place cursor: a thumbnail of the loaded graphic; a drag draws a proportional frame with its scale Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/tests.rs | 27 ++++++++++++++++++ crates/engine/src/tooling.rs | 1 + crates/tools/src/lib.rs | 2 ++ crates/tools/src/nav.rs | 40 +++++++++++++++++++++----- crates/tools/src/snap.rs | 1 + crates/tools/src/tests.rs | 1 + crates/ui-egui/src/canvas.rs | 55 ++++++++++++++++++++++++++++++++++++ 8 files changed, 121 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..e8a0494ac 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** · The loaded place cursor shows a thumbnail of the graphic, as InDesign's does; dragging with it draws the frame at the graphic's proportions from where the drag started, with its scale beside the pointer, and places exactly that frame. 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/engine/src/tests.rs b/crates/engine/src/tests.rs index 9aacf469d..ad8c5b107 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -476,6 +476,33 @@ fn place_gun_click_drag_and_into_frame() { assert_eq!(graphic_xf(&s), placed); } +/// A place-cursor drag draws the frame at the graphic's proportions from where it started, as +/// InDesign does, shows its scale, and places exactly that frame, in any direction. +#[test] +fn place_gun_drag_previews_and_places_a_proportional_frame() { + use designcraft_tools::{Cursor, Mods, Overlay, PointerEvent, PointerKind}; + let mut s = session(); + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); + s.execute("place.load", &json!({"base64": cmd::base64_encode(&png), "name": "a.png"})).unwrap(); + let v = ViewInfo::at_zoom(1.0); + let p = designcraft_geom::Point::new; + assert_eq!(s.cursor(p(300.0, 300.0), Mods::default(), v), Cursor::LoadedGraphic); + // Up and to the left: 80 × 200 holds the 40 × 20 graphic at 200 %, 80 × 40, hanging from the press. + s.pointer(&PointerEvent::new(PointerKind::Down, 300.0, 300.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 220.0, 100.0), v).unwrap(); + assert_eq!(s.cursor(p(220.0, 100.0), Mods::default(), v), Cursor::Crosshair, "the crosshair while drawing"); + let o = s.overlays(v); + assert!(o.iter().any(|o| matches!(o, Overlay::Marquee(r) if *r == designcraft_geom::Rect::new(220.0, 260.0, 300.0, 300.0))), "{o:?}"); + assert!( + o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == "X: 200.00% +Y: 200.00%")), + "{o:?}" + ); + s.pointer(&PointerEvent::new(PointerKind::Up, 220.0, 100.0), v).unwrap(); + let it = s.doc().unwrap().doc.spreads[0].items.last().unwrap().clone(); + assert_eq!(it.bounds(), designcraft_geom::Rect::new(220.0, 260.0, 300.0, 300.0), "the frame previewed"); +} + #[test] fn preflight_reports_overset_and_missing_fonts() { let mut s = session(); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index c58236090..e388aa869 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -108,6 +108,7 @@ impl Session { snap: view.snap, unit: view.unit, stroke_in_front: view.stroke_in_front, + loaded: self.loaded.map(|l| l.size), }; Some(f(self, &cx)) } diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index a92f1c132..3491401b1 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -220,6 +220,8 @@ pub struct ToolContext<'a> { pub unit: Unit, /// The toolbox's Stroke box is in front: sampled colours go to strokes (the Eyedropper). pub stroke_in_front: bool, + /// Natural size, in points, of the graphic loaded in the place cursor. + pub loaded: Option<(f64, f64)>, } impl ToolContext<'_> { diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index f77c5d0bb..db15effad 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -142,7 +142,8 @@ impl Tool for PlaceGun { { return vec![Action::Exec("place.drop".into(), json!({"frame": id.0}))]; } - let r = designcraft_geom::Rect::from_points(sa, b); + // The frame the drag drew: at the graphic's proportions from where it started. + let r = cx.loaded.map_or_else(|| designcraft_geom::Rect::from_points(sa, b), |size| proportional(sa, b, size).0); vec![Action::Exec( "place.drop".into(), json!({"spread": crate::spread_json(sr), "x": sa.x, "y": sa.y, "rect": [r.x0, r.y0, r.x1, r.y1]}), @@ -151,11 +152,24 @@ impl Tool for PlaceGun { _ => vec![], } } - fn overlays(&self, _cx: &ToolContext) -> Vec { - match self.start { - Some(a) => vec![crate::Overlay::Marquee(designcraft_geom::Rect::from_points(a, self.cur))], - None => vec![], - } + fn overlays(&self, cx: &ToolContext) -> Vec { + let Some(a) = self.start else { return vec![] }; + let (Some(size), true) = (cx.loaded, (self.cur - a).hypot() >= cx.tol(3.0)) else { + return vec![crate::Overlay::Marquee(designcraft_geom::Rect::from_points(a, self.cur))]; + }; + // As InDesign: the frame the graphic will fill, and its scale beside the pointer. + let (r, k) = proportional(a, self.cur, size); + let pct = format!("{:.2}%", k * 100.0); + vec![ + crate::Overlay::Marquee(r), + crate::Overlay::Measure { + p: self.cur, + text: format!( + "X: {pct} +Y: {pct}" + ), + }, + ] } fn key(&mut self, _cx: &ToolContext, key: crate::ToolKey, _mods: Mods) -> Vec { match key { @@ -165,10 +179,22 @@ impl Tool for PlaceGun { } } fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - Cursor::LoadedGraphic + // Drawing the frame: the crosshair (the loaded graphic's thumbnail would hide the frame). + if self.start.is_some() { Cursor::Crosshair } else { Cursor::LoadedGraphic } } } +/// The frame a place-cursor drag from `a` to `b` draws for a graphic of natural `size`: the +/// graphic's proportions, from `a` towards `b`, as large as fits the drag. With its scale. +fn proportional(a: Point, b: Point, size: (f64, f64)) -> (designcraft_geom::Rect, f64) { + let (w, h) = (size.0.max(1e-6), size.1.max(1e-6)); + let (dx, dy) = (b.x - a.x, b.y - a.y); + let k = (dx.abs() / w).min(dy.abs() / h); + let k = if k.is_finite() { k } else { 0.0 }; + let c = Point::new(a.x + (w * k).copysign(dx), a.y + (h * k).copysign(dy)); + (designcraft_geom::Rect::from_points(a, c), k) +} + /// Page tool (Shift+P): click a page to select it; drag one of its handles to preview its liquid /// page rule (the page springs back on release), Alt-drag to resize it, Shift to keep its /// proportions; double-click a page for exact sizes. diff --git a/crates/tools/src/snap.rs b/crates/tools/src/snap.rs index 0cbc9aadd..2bbb20a24 100644 --- a/crates/tools/src/snap.rs +++ b/crates/tools/src/snap.rs @@ -1416,6 +1416,7 @@ mod tests { snap: SnapView::FACTORY, unit: Unit::Points, stroke_in_front: false, + loaded: None, } } diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index ee3b362f1..86054431e 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -19,6 +19,7 @@ fn ctx<'a>(d: &'a Document, s: &'a Selection, c: &'a Cache, l: &'a CanvasLayout) snap: SnapView::OFF, unit: Unit::Points, stroke_in_front: false, + loaded: None, } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8d94f099f..66963c528 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -606,9 +606,63 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let icon = crate::cursors::show(ui.ctx(), &mut app.cursors, c, p); ui.ctx().set_cursor_icon(icon); draw_cursor_badge(&painter, p, c, &t); + if c == Cursor::LoadedGraphic { + draw_loaded_thumbnail(app, &painter, p, &t); + } } } +/// Longest side of the loaded place cursor's thumbnail, in points. +const LOADED_THUMB: f32 = 128.0; + +/// The loaded place cursor's thumbnail, as InDesign shows it: the graphic, at most 128 pt on its +/// longer side, hanging from the pointer, with an image badge in its corner. +fn draw_loaded_thumbnail(app: &DesignApp, painter: &egui::Painter, p: Pos2, t: &Tokens) { + let (Some(l), Ok(st)) = (app.session.loaded.as_ref(), app.session.doc()) else { return }; + let (w, h) = (l.size.0 as f32, l.size.1 as f32); + if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) { + return; + } + let k = LOADED_THUMB / w.max(h); + let r = Rect::from_min_size(p + vec2(2.0, 2.0), vec2(w * k, h * k)); + if let Some(tex) = loaded_thumb_texture(painter.ctx(), &st.doc, l.asset, l.size) { + painter.image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::from_white_alpha(225)); + } else { + // Not drawable here (an unreadable file): its frame only. + painter.rect_stroke(r, 0.0, Stroke::new(1.0, t.measure_text), StrokeKind::Inside); + } + let badge = Rect::from_min_size(r.min + vec2(6.0, 6.0), vec2(16.0, 12.0)); + painter.rect_filled(badge, 2.0, t.measure_bg); + let ink = Stroke::new(1.2, t.measure_text); + let inner = badge.shrink2(vec2(3.0, 2.5)); + painter.rect_stroke(inner, 0.5, ink, StrokeKind::Middle); + painter.add(egui::Shape::line( + vec![pos2(inner.min.x, inner.max.y), pos2(inner.min.x + 3.5, inner.min.y + 2.5), pos2(inner.max.x, inner.max.y)], + ink, + )); +} + +/// The loaded graphic's thumbnail texture, cached per image data. +fn loaded_thumb_texture(ctx: &egui::Context, doc: &Document, asset: designcraft_doc::AssetId, size: (f64, f64)) -> Option { + let a = doc.assets.get(&asset)?; + let key = egui::Id::new(("loaded_thumb", std::sync::Arc::as_ptr(&a.data) as usize, a.data.len(), a.page)); + if let Some(t) = ctx.data(|d| d.get_temp::(key)) { + return Some(t); + } + // Twice the thumbnail's size in pixels, for high-density screens. + let scale = (2.0 * f64::from(LOADED_THUMB) / size.0.max(size.1)).min(1.0); + let pm = designcraft_render::images::mip(&a.data, a.page, size.0, scale)?; + let (pw, ph) = (pm.width() as usize, pm.height() as usize); + if pw == 0 || ph == 0 { + return None; + } + let ci = egui::ColorImage::from_rgba_premultiplied([pw, ph], pm.data_as_u8_slice()); + let max = ctx.input(|i| i.max_texture_side).min(1024); + let t = ctx.load_texture("loaded_thumb", crate::widgets::fit_texture(ci, max), egui::TextureOptions::LINEAR); + ctx.data_mut(|d| d.insert_temp(key, t.clone())); + Some(t) +} + /// The loaded text cursor's badge beside the system cursor: lines of text, a closed chain /// (a click threads) or a broken one (a click unthreads). Drawn in code. fn draw_cursor_badge(painter: &egui::Painter, p: Pos2, c: Cursor, t: &Tokens) { @@ -4481,6 +4535,7 @@ mod tests { snap: SnapView { snap_to_document_grid: true, zone_px: 20.0, ..SnapView::OFF }, unit: Unit::Points, stroke_in_front: false, + loaded: None, }; let (snapped, _) = designcraft_tools::snap::snap_point(&cx, sr, Point::new(page.x0 + 31.3, page.y0 + 47.9)); assert_eq!((snapped.x - page.x0, snapped.y - page.y0), (27.0, 45.0)); From 53b8b0414132a051a5e56397f838cc5bd607f3b9 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 21:17:37 +0200 Subject: [PATCH 02/20] PR-1392: the place-cursor preview follows place.drop's 2 pt drag threshold Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 2 +- crates/engine/src/tests.rs | 16 ++++++++++++++++ crates/tools/src/lib.rs | 2 +- crates/tools/src/nav.rs | 18 +++++++++++++++++- 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 973262937..3fb243216 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -882,7 +882,7 @@ fn place_drop(s: &mut Session, p: &Value) -> Result { } // Drag: fit proportionally; click: actual size at the point. let (r, k) = match rect { - Some(r) if r.width() > 2.0 && r.height() > 2.0 => { + Some(r) if r.width() > designcraft_tools::PLACE_DRAG_MIN && r.height() > designcraft_tools::PLACE_DRAG_MIN => { let k = (r.width() / nw).min(r.height() / nh); (Rect::new(r.x0, r.y0, r.x0 + nw * k, r.y0 + nh * k), k) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ad8c5b107..5d0795196 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -501,6 +501,22 @@ Y: 200.00%")), s.pointer(&PointerEvent::new(PointerKind::Up, 220.0, 100.0), v).unwrap(); let it = s.doc().unwrap().doc.spreads[0].items.last().unwrap().clone(); assert_eq!(it.bounds(), designcraft_geom::Rect::new(220.0, 260.0, 300.0, 300.0), "the frame previewed"); + // 80 × 1 is past the drag tolerance, but its 2 × 1 proportional frame is too small for + // `place.drop`, which places at natural size: the preview shows that frame and 100 %. + s.execute("place.load", &json!({"base64": cmd::base64_encode(&png), "name": "a.png"})).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 400.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 180.0, 401.0), v).unwrap(); + let natural = designcraft_geom::Rect::new(100.0, 400.0, 140.0, 420.0); + let o = s.overlays(v); + assert!(o.iter().any(|o| matches!(o, Overlay::Marquee(r) if *r == natural)), "{o:?}"); + assert!( + o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == "X: 100.00% +Y: 100.00%")), + "{o:?}" + ); + s.pointer(&PointerEvent::new(PointerKind::Up, 180.0, 401.0), v).unwrap(); + let it = s.doc().unwrap().doc.spreads[0].items.last().unwrap().clone(); + assert_eq!(it.bounds(), natural, "the frame previewed"); } #[test] diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 3491401b1..5f05d01ba 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -16,7 +16,7 @@ mod frame; mod gradient; pub mod layout; mod nav; -pub use nav::{LOCK_OFFSET_PX, PIN_OFFSET_PX}; +pub use nav::{LOCK_OFFSET_PX, PIN_OFFSET_PX, PLACE_DRAG_MIN}; mod pen; mod pencil; pub mod select; diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index db15effad..4a582c385 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -158,7 +158,7 @@ impl Tool for PlaceGun { return vec![crate::Overlay::Marquee(designcraft_geom::Rect::from_points(a, self.cur))]; }; // As InDesign: the frame the graphic will fill, and its scale beside the pointer. - let (r, k) = proportional(a, self.cur, size); + let (r, k) = drawn_frame(a, self.cur, size); let pct = format!("{:.2}%", k * 100.0); vec![ crate::Overlay::Marquee(r), @@ -184,6 +184,22 @@ Y: {pct}" } } +/// A dragged frame `place.drop` takes only when it is wider and taller than this, in points; +/// a thinner one places the graphic at its natural size instead. +pub const PLACE_DRAG_MIN: f64 = 2.0; + +/// The frame `place.drop` makes for a place-cursor drag from `a` to `b`: the proportional frame +/// (see [`proportional`]), or the graphic at natural size from `a` when that frame is at most +/// [`PLACE_DRAG_MIN`] in either direction. With its scale. +fn drawn_frame(a: Point, b: Point, size: (f64, f64)) -> (designcraft_geom::Rect, f64) { + let (r, k) = proportional(a, b, size); + if r.width() > PLACE_DRAG_MIN && r.height() > PLACE_DRAG_MIN { + (r, k) + } else { + (designcraft_geom::Rect::new(a.x, a.y, a.x + size.0, a.y + size.1), 1.0) + } +} + /// The frame a place-cursor drag from `a` to `b` draws for a graphic of natural `size`: the /// graphic's proportions, from `a` towards `b`, as large as fits the drag. With its scale. fn proportional(a: Point, b: Point, size: (f64, f64)) -> (designcraft_geom::Rect, f64) { From 75d72c69b59a52acc14a1766a534a3f570bb4b87 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 21 Oct 2026 08:28:10 +0200 Subject: [PATCH 03/20] Effects: blend modes for Drop Shadow, Inner Shadow and Outer Glow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As InDesign's Effects dialog › Blending › Mode: the shadows start Multiply and the outer glow Screen in white; the renderer blends each with what's behind it, the commands take mode, IDML reads and writes the drop shadow's BlendMode. Effects saved before read Normal, as they were drawn. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/item.rs | 37 ++++++++++- crates/engine/src/cmd/object.rs | 36 ++++++++++- crates/idml/src/export.rs | 1 + crates/idml/src/import.rs | 1 + crates/render/src/fx.rs | 17 +++-- crates/render/tests/effects.rs | 28 +++++++++ crates/render/tests/golden/inner-shadow.png | Bin 1879 -> 1859 bytes crates/ui-egui/src/effects_dialog.rs | 65 +++++++++++++++----- 9 files changed, 161 insertions(+), 25 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..2e579b927 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-21** · Drop Shadow, Inner Shadow and Outer Glow have a blend mode, as InDesign's (Effects dialog › Blending › Mode; `mode` on `object.dropShadow`, `object.innerShadow`, `object.outerGlow`; IDML `BlendMode`). New shadows start Multiply and new outer glows Screen in white ([Paper]); effects saved before keep Normal, so documents look as they did. Found in the Learn Skills Daily course, chapter 14. 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/doc/src/item.rs b/crates/doc/src/item.rs index cfb13dc53..6da69b9e2 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -973,12 +973,22 @@ pub struct DropShadow { pub distance: f64, pub size: f64, pub spread: f64, + /// How the shadow blends with what's behind it (InDesign: Multiply). A document saved before + /// effects had modes reads Normal, as they were drawn then. + #[serde(default = "normal_blend")] + pub mode: BlendMode, +} + +/// The blend of an effect saved before effects had one. +fn normal_blend() -> BlendMode { + BlendMode::Normal } impl Default for DropShadow { fn default() -> Self { DropShadow { on: false, + mode: BlendMode::Multiply, color: designcraft_color::swatch::BLACK.into(), opacity: 0.75, angle: 120.0, @@ -1005,12 +1015,16 @@ pub struct InnerShadow { pub size: f64, /// Percentage (0–100) of `size` that hardens the edge instead of blurring it. pub choke: f64, + /// How the shadow blends with the object (InDesign: Multiply; Normal when saved before). + #[serde(default = "normal_blend")] + pub mode: BlendMode, } impl Default for InnerShadow { fn default() -> Self { InnerShadow { on: false, + mode: BlendMode::Multiply, color: designcraft_color::swatch::BLACK.into(), opacity: 0.75, angle: 120.0, @@ -1032,11 +1046,15 @@ pub struct OuterGlow { pub size: f64, /// Percentage (0–100) of `size` that grows the silhouette instead of blurring it. pub spread: f64, + /// How the glow blends with what's behind it (InDesign: Screen; Normal when saved before). + #[serde(default = "normal_blend")] + pub mode: BlendMode, } impl Default for OuterGlow { fn default() -> Self { - OuterGlow { on: false, color: designcraft_color::swatch::BLACK.into(), opacity: 0.75, size: 7.0, spread: 0.0 } + // InDesign starts a glow screened in white. + OuterGlow { on: false, color: designcraft_color::swatch::PAPER.into(), opacity: 0.75, size: 7.0, spread: 0.0, mode: BlendMode::Screen } } } @@ -1639,6 +1657,23 @@ mod tests { use super::*; use designcraft_geom::shapes; + /// Shadows and glows saved before they had a blend mode read Normal, as they were drawn; + /// new ones start as InDesign's: Multiply for shadows, Screen for the outer glow. + #[test] + fn effect_blend_modes_default_as_indesign_and_keep_old_documents() { + let old: DropShadow = + serde_json::from_str(r#"{"on": true, "color": "[Black]", "opacity": 0.75, "angle": 120, "distance": 7, "size": 5, "spread": 0}"#) + .unwrap(); + assert_eq!(old.mode, BlendMode::Normal); + let old: OuterGlow = serde_json::from_str(r#"{"on": true, "color": "[Black]", "opacity": 0.75, "size": 7, "spread": 0}"#).unwrap(); + assert_eq!(old.mode, BlendMode::Normal); + assert_eq!(DropShadow::default().mode, BlendMode::Multiply); + assert_eq!(InnerShadow::default().mode, BlendMode::Multiply); + assert_eq!(OuterGlow::default().mode, BlendMode::Screen); + let saved = serde_json::to_value(DropShadow { mode: BlendMode::Screen, ..Default::default() }).unwrap(); + assert_eq!(serde_json::from_value::(saved).unwrap().mode, BlendMode::Screen, "round trip"); + } + #[test] fn graphics_without_a_clipping_path_load_and_save_as_before() { let g = Graphic { diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 82f33a6d8..29358fcac 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -382,7 +382,7 @@ pub fn specs() -> Vec { "Drop Shadow", ["Object", "Effects"], Some("Cmd+Alt+M"), - "{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, spread? (%), color? (swatch), ids?}", + "{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, spread? (%), color? (swatch), mode?: blend mode (multiply…), ids?}", has_selection, |s, p| { let p2 = p.clone(); @@ -398,6 +398,9 @@ pub fn specs() -> Vec { if let Some(c) = str_param(&p2, "color") { ds.color = c.into(); } + if let Some(m) = blend_param(&p2) { + ds.mode = m; + } }) } ), @@ -406,7 +409,7 @@ pub fn specs() -> Vec { "Inner Shadow", ["Object", "Effects"], None, - "{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, choke? (%), color?, ids?}", + "{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, choke? (%), color?, mode?: blend mode, ids?}", has_selection, |s, p| { let p2 = p.clone(); @@ -422,6 +425,9 @@ pub fn specs() -> Vec { if let Some(c) = str_param(&p2, "color") { e.color = c.into(); } + if let Some(m) = blend_param(&p2) { + e.mode = m; + } }) } ), @@ -430,7 +436,7 @@ pub fn specs() -> Vec { "Outer Glow", ["Object", "Effects"], None, - "{on?: bool, opacity?, size?, spread? (%), color?, ids?}", + "{on?: bool, opacity?, size?, spread? (%), color?, mode?: blend mode (screen…), ids?}", has_selection, |s, p| { let p2 = p.clone(); @@ -443,6 +449,9 @@ pub fn specs() -> Vec { if let Some(c) = str_param(&p2, "color") { e.color = c.into(); } + if let Some(m) = blend_param(&p2) { + e.mode = m; + } }) } ), @@ -3101,6 +3110,12 @@ fn align(s: &mut Session, p: &Value) -> Result { }) } +/// An effect's `mode` parameter: a blend mode by name ("multiply", "screen", or as saved). +fn blend_param(p: &Value) -> Option { + let v = p.get("mode")?; + v.as_str().and_then(BlendMode::parse).or_else(|| serde_json::from_value(v.clone()).ok()) +} + fn distribute(s: &mut Session, p: &Value) -> Result { let ids = targets(s, p)?; if ids.len() < 3 && p.get("spacing").is_none() { @@ -4055,6 +4070,21 @@ mod label_tests { mod light_tests { use super::*; + /// The shadow and glow commands take a blend mode by name. + #[test] + fn shadow_and_glow_commands_take_a_blend_mode() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.dropShadow", &json!({"on": true, "mode": "screen", "ids": [id]})).unwrap(); + s.execute("object.innerShadow", &json!({"on": true, "mode": "overlay", "ids": [id]})).unwrap(); + s.execute("object.outerGlow", &json!({"on": true, "mode": "normal", "ids": [id]})).unwrap(); + let fx = s.doc().unwrap().doc.item(ItemId(id)).unwrap().effects.clone(); + assert_eq!(fx.drop_shadow.mode, BlendMode::Screen); + assert_eq!(fx.inner_shadow.mode, BlendMode::Overlay); + assert_eq!(fx.outer_glow.mode, BlendMode::Normal); + } + #[test] fn global_light_moves_shadows_that_use_it() { let mut s = Session::new(); diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 86fef8e46..1a845a2ac 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -2067,6 +2067,7 @@ impl<'a> Ex<'a> { .attr("Size", num(ds.size)) .attr("Spread", num(ds.spread)) .attr("Opacity", pct(ds.opacity as f64)) + .attr("BlendMode", names::blend_out(ds.mode)) .attr("EffectColor", self.sw(&ds.color)), ); } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index d770aef79..30a5caa34 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2699,6 +2699,7 @@ impl<'r> Importer<'r> { size: ds.num("Size").unwrap_or(def.size), spread: ds.num("Spread").unwrap_or(def.spread), global_light: false, + mode: ds.get("BlendMode").map_or(def.mode, names::blend_in), }; } if let Some(f) = t.find("FeatherSetting") diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 6147c9cf5..e7f54a3ea 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -102,19 +102,25 @@ impl Renderer { let mut below = vec![]; if e.outer_glow.on { let g = &e.outer_glow; - below.push((Vec2::ZERO, g.size, g.spread, g.color.clone(), g.opacity)); + below.push((Vec2::ZERO, g.size, g.spread, g.color.clone(), g.opacity, g.mode)); } if e.drop_shadow.on { let d = &e.drop_shadow; - below.push((offset(doc.light_angle(d.angle, d.global_light), d.distance), d.size, d.spread, d.color.clone(), d.opacity)); + below.push((offset(doc.light_angle(d.angle, d.global_light), d.distance), d.size, d.spread, d.color.clone(), d.opacity, d.mode)); } - for (off, size, pct, color, opacity) in below { + for (off, size, pct, color, opacity, mode) in below { let Some(c) = doc.resolve_color(&color, 1.0) else { continue }; let (sigma, grow) = split(size, pct); let filter = shadow(sigma, color_of(&c, opacity.clamp(0.0, 1.0))); // The offset moves the geometry, not the filter: vello drops layer content that lies // entirely outside the viewport before filtering. let r = (reach + off).inflate(grow, grow); + // The effect's blend sees what's behind the object: a layer around the filtered one. + let mix = crate::blend_mode(mode).mix; + if mix != Mix::Normal { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(BlendMode::new(mix, Compose::SrcOver)), None, None, None); + } self.with_filters(ctx, f, r, sigma, None, |me, c, fr| { let moved = Frame { view: fr.view * Affine::translate(off), ..*fr }; c.set_transform(moved.view); @@ -128,6 +134,9 @@ impl Renderer { } c.pop_layer(); }); + if mix != Mix::Normal { + ctx.pop_layer(); + } } // Gradient feather: the object (feathered or not) drawn in its own layer, then kept as // much as the opacity gradient says. @@ -218,7 +227,7 @@ impl Renderer { let s = &e.inner_shadow; let (sigma, choke) = split(s.size, s.choke); let off = offset(doc.light_angle(s.angle, s.global_light), s.distance); - self.inner_edge(ctx, f, reach, bp, xf, off, sigma, choke, color_of(&c, s.opacity.clamp(0.0, 1.0)), Mix::Normal); + self.inner_edge(ctx, f, reach, bp, xf, off, sigma, choke, color_of(&c, s.opacity.clamp(0.0, 1.0)), crate::blend_mode(s.mode).mix); } // Inner glow: from the edges inwards (or out from the centre), screened over the object. if e.inner_glow.on diff --git a/crates/render/tests/effects.rs b/crates/render/tests/effects.rs index a9420fbcb..a371a1470 100644 --- a/crates/render/tests/effects.rs +++ b/crates/render/tests/effects.rs @@ -141,6 +141,8 @@ fn outer_glow_surrounds_the_object() { g.size = 12.0; g.color = "C=0 M=100 Y=0 K=0".into(); g.opacity = 1.0; + // Normal, so the glow shows on the white page (screened, magenta on white is white). + g.mode = designcraft_color::BlendMode::Normal; let img = render(&d); golden("outer-glow", &img); for (x, y) in [(46, 80), (114, 80), (80, 46), (80, 114)] { @@ -150,6 +152,32 @@ fn outer_glow_surrounds_the_object() { assert_eq!(lum(img.pixel(10, 10)), WHITE); } +/// An effect's blend mode works on what's behind it: a glow screened onto a dark backdrop +/// lightens it in the glow's colour, where Normal paints the glow's colour over it. +#[test] +fn outer_glow_blends_with_its_mode() { + let glow = |mode| { + let mut d = doc(); + rect_item(&mut d, Rect::new(0.0, 0.0, 160.0, 160.0), "C=100 M=90 Y=10 K=0"); + let id = rect_item(&mut d, Rect::new(50.0, 50.0, 110.0, 110.0), "[Black]"); + let g = &mut d.item_mut(id).unwrap().effects.outer_glow; + g.on = true; + g.size = 12.0; + g.color = "C=0 M=0 Y=100 K=0".into(); + g.opacity = 1.0; + g.mode = mode; + render(&d) + }; + let screen = glow(designcraft_color::BlendMode::Screen); + let normal = glow(designcraft_color::BlendMode::Normal); + let backdrop = screen.pixel(10, 10); + let (s, n) = (screen.pixel(46, 80), normal.pixel(46, 80)); + // Screen only lightens: every channel at least the backdrop's, and the navy shows through + // (blue stays high), where Normal paints yellow (blue drops). + assert!(s.iter().zip(backdrop).take(3).all(|(a, b)| *a + 2 >= b), "screen lightens: {s:?} over {backdrop:?}"); + assert!(s[2] > n[2] + 25, "screen keeps the backdrop's blue: {s:?} vs normal {n:?}"); +} + #[test] fn inner_shadow_darkens_inside_the_lit_edge() { let mut d = doc(); diff --git a/crates/render/tests/golden/inner-shadow.png b/crates/render/tests/golden/inner-shadow.png index c267aa386ffd379a63dfe29d51894328dadf52b2..5cba476417afef661d24d3a06699a2d2cdec7133 100644 GIT binary patch delta 792 zcmcc4cbIR23M1D<)fe@D4F{pgo0as}qNkLlegY2d2)||Cnzke7@g$s$Q5=NrCGMQ!INe`&Osxj=BL{ zAoCg-fJ&NgBpi6QpL^h&YhM_nGwz&`nFhPFnZtx=}dL1~ngHsEeW&zFzP?(XPFh6==sf zvz*+6&)CkS8_ahG`WNU07NPLJ+F&1~95VR`3YN(n9FDpmPaHhP2G;WC%>kg>o*B${ zTpPd*(NYi9vm|jA)MZnc1Ov3c$eyW+IFnc`Y-XNNEo^26bm_Y0CC(GV15*^LRDGY$L@ zI)Ksn#kQng_KWSAsu|~j795rNC70C*joPU>8xFSlcsR@c1cg^8DAXN+sRZcmD_p=R z1V$n#u7N?@xZ_p)=>}f26el$gu;Cs|j6y97L^N2v8lyl_mIVyy*RW`VxFd4|+*DA4 z1Nn*xn1q;su`8&sc{TGarf`W3JI}C58!7V6oWPlRqGejb0X9yRb-%(l9b}WfbU|v% zv3hZ>|GrR1W*hi|p{a!W3gzjkPAo3KD1^qw%nMRIz!;p6TYNS2zwP7z*20MgjKn_l XJ7rg`%wRRCVE_V8S3j3^P69qfmhM6sD~XEu98JE)6U~9x-3#Pq5AYuNU%fwuc&%l8^3%(hKDQt6y5J zI65UsX@SUu9)`}<|C(&)fwE~Qa6M9&L!v9L& zh)HYa-RAN6ek;g{D@1?sr5Q(@Ih&NX&Ft(mw@nc;w(}61 zw9!$Mk5Fq^ck%tYcjkFoa^g0#$#dVPWM9t7oAL9^ndi&Ig1I~t8bMCezM0U>YnF1z zr1QTTFmzlTT2>zYGI!?rGsoI)-ZHViotAEW^NvCH&YAV+ebz1$1NjH+JfNl<6T~j7 z{F@!{Ghc{tDNx^4puVp_SIw4AD?Xf)uO=-GbZ=~WRwIWp!m61QG?%G?H7PZwcr~tT zjsk|&%`<1xfvz+&J9BN>%=13Lz*9T~bl;KC|6XP{5}egi4q1Gx2YE`EQ$>T-%VF(> z?-xQZe^0wM8|a&Kpp~`x=(e<3eS&J3(37I%19aV$q%70g^k3(`>;(pzwu=O(ia_|^ zY{?A=+k8&2e7xUws$Q9s#Y3PeXn}|ZYgS{_!mt&rS*`1u1t2a_hB#%(!Dsuqr~X$H z0*4>R1P7**9%_eNW^f2MaVT$|$vlfG@ e6A!S8edt#beP{7*^Ob81K;Y@>=d#Wzp$P!HCU6%3 diff --git a/crates/ui-egui/src/effects_dialog.rs b/crates/ui-egui/src/effects_dialog.rs index 456ca4a58..dfc345346 100644 --- a/crates/ui-egui/src/effects_dialog.rs +++ b/crates/ui-egui/src/effects_dialog.rs @@ -302,6 +302,7 @@ fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) { let key = page; heading(ui, &tr("Blending")); grid("b").show(ui, |ui| { + mode_row(ui, d, key, &tr); row(ui, "Color:"); swatch(app, ui, d, &[key, "color"]); row(ui, "Opacity:"); @@ -343,23 +344,7 @@ fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) { let key = page; heading(ui, &tr("Blending")); grid("b").show(ui, |ui| { - // Inner Glow: its blend mode (Screen, as InDesign starts it). - if key == "innerGlow" { - row(ui, "Mode:"); - let cur = d.fields.get("fx").and_then(|f| f["innerGlow"]["mode"].as_str()).unwrap_or("Screen").to_string(); - let label = |m: &designcraft_color::BlendMode| { - serde_json::to_value(m).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default() - }; - let shown = designcraft_color::BlendMode::ALL.iter().find(|m| label(m) == cur).map_or("Screen", |m| m.label()); - egui::ComboBox::from_id_salt("fx_glow_mode").selected_text(tr(shown)).width(150.0).show_ui(ui, |ui| { - for m in designcraft_color::BlendMode::ALL { - if ui.selectable_label(label(&m) == cur, tr(m.label())).clicked() { - set(d, &["innerGlow", "mode"], json!(label(&m))); - } - } - }); - ui.end_row(); - } + mode_row(ui, d, key, &tr); row(ui, "Color:"); swatch(app, ui, d, &[key, "color"]); row(ui, "Opacity:"); @@ -550,6 +535,28 @@ fn summary(lang: &str, d: &Dialog) -> String { } /// The dialog's body. +/// The Mode row of a shadow's or glow's Blending: its blend mode, as InDesign's (shadows start +/// Multiply, glows Screen). +fn mode_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, tr: &dyn Fn(&str) -> String) { + crate::rtl::label(ui, tr("Mode:")); + let fallback = if key.ends_with("Glow") { "Screen" } else { "Multiply" }; + let cur = d.fields.get("fx").and_then(|f| f[key]["mode"].as_str()).unwrap_or(fallback).to_string(); + let name = |m: &designcraft_color::BlendMode| serde_json::to_value(m).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); + let shown = designcraft_color::BlendMode::ALL.iter().find(|m| name(m) == cur).map_or(fallback, |m| m.label()); + let mut picked = None; + egui::ComboBox::from_id_salt(("fx_mode", key.to_string())).selected_text(tr(shown)).width(150.0).show_ui(ui, |ui| { + for m in designcraft_color::BlendMode::ALL { + if ui.selectable_label(name(&m) == cur, tr(m.label())).clicked() { + picked = Some(name(&m)); + } + } + }); + if let Some(m) = picked { + set(d, &[key, "mode"], json!(m)); + } + ui.end_row(); +} + pub fn show(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let lang = app.ui.language.clone(); let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); @@ -725,6 +732,30 @@ mod tests { assert!(!fx(&app).inner_shadow.on); } + /// Drop Shadow and Outer Glow have InDesign's Mode under Blending (Multiply and Screen to + /// start); a mode picked there is what OK applies. + #[test] + fn shadows_and_glows_have_a_blend_mode() { + let (mut app, id) = app_with_frame(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); + open(&mut app, "dropShadow"); + let texts = frame(&mut app, &ctx); + assert!(texts.iter().any(|x| x.trim() == "Mode:") && texts.iter().any(|x| x.trim() == "Multiply"), "{texts:?}"); + crate::dialogs::cancel(&mut app); + open(&mut app, "outerGlow"); + let texts = frame(&mut app, &ctx); + assert!(texts.iter().any(|x| x.trim() == "Screen"), "{texts:?}"); + if let Some(d) = app.ui.dialog.as_mut() { + set(d, &["outerGlow", "mode"], json!("Normal")); + } + frame(&mut app, &ctx); + crate::dialogs::confirm(&mut app).unwrap(); + let fx = app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().effects.clone(); + assert!(fx.outer_glow.on); + assert_eq!(fx.outer_glow.mode, designcraft_color::BlendMode::Normal); + } + #[test] fn offsets_follow_distance_and_angle_as_in_indesign() { let (mut app, _) = app_with_frame(); From c9e831e7321e3312004760609454a54ee259dbe9 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:58:48 +0200 Subject: [PATCH 04/20] Effect blend modes: reject an unknown mode; IDML shadows without BlendMode stay Normal Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/object.rs | 33 +++++++++++++++++++++++++++------ crates/idml/src/import.rs | 4 +++- crates/idml/src/tests.rs | 1 + 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 29358fcac..72f2fa01f 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -385,6 +385,7 @@ pub fn specs() -> Vec { "{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, spread? (%), color? (swatch), mode?: blend mode (multiply…), ids?}", has_selection, |s, p| { + let mode = blend_param(p, "object.dropShadow")?; let p2 = p.clone(); set_effects(s, p, "object.dropShadow", move |fx| { let ds = &mut fx.drop_shadow; @@ -398,7 +399,7 @@ pub fn specs() -> Vec { if let Some(c) = str_param(&p2, "color") { ds.color = c.into(); } - if let Some(m) = blend_param(&p2) { + if let Some(m) = mode { ds.mode = m; } }) @@ -412,6 +413,7 @@ pub fn specs() -> Vec { "{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, choke? (%), color?, mode?: blend mode, ids?}", has_selection, |s, p| { + let mode = blend_param(p, "object.innerShadow")?; let p2 = p.clone(); set_effects(s, p, "object.innerShadow", move |fx| { let e = &mut fx.inner_shadow; @@ -425,7 +427,7 @@ pub fn specs() -> Vec { if let Some(c) = str_param(&p2, "color") { e.color = c.into(); } - if let Some(m) = blend_param(&p2) { + if let Some(m) = mode { e.mode = m; } }) @@ -439,6 +441,7 @@ pub fn specs() -> Vec { "{on?: bool, opacity?, size?, spread? (%), color?, mode?: blend mode (screen…), ids?}", has_selection, |s, p| { + let mode = blend_param(p, "object.outerGlow")?; let p2 = p.clone(); set_effects(s, p, "object.outerGlow", move |fx| { let e = &mut fx.outer_glow; @@ -449,7 +452,7 @@ pub fn specs() -> Vec { if let Some(c) = str_param(&p2, "color") { e.color = c.into(); } - if let Some(m) = blend_param(&p2) { + if let Some(m) = mode { e.mode = m; } }) @@ -3111,9 +3114,17 @@ fn align(s: &mut Session, p: &Value) -> Result { } /// An effect's `mode` parameter: a blend mode by name ("multiply", "screen", or as saved). -fn blend_param(p: &Value) -> Option { - let v = p.get("mode")?; - v.as_str().and_then(BlendMode::parse).or_else(|| serde_json::from_value(v.clone()).ok()) +/// An effect's `mode`: `None` when omitted or null, an error when it names no blend mode. +fn blend_param(p: &Value, cmd: &str) -> Result> { + match p.get("mode") { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_str() + .and_then(BlendMode::parse) + .or_else(|| serde_json::from_value(v.clone()).ok()) + .map(Some) + .ok_or_else(|| bad(cmd, format!("unknown blend mode {v}"))), + } } fn distribute(s: &mut Session, p: &Value) -> Result { @@ -4083,6 +4094,16 @@ mod light_tests { assert_eq!(fx.drop_shadow.mode, BlendMode::Screen); assert_eq!(fx.inner_shadow.mode, BlendMode::Overlay); assert_eq!(fx.outer_glow.mode, BlendMode::Normal); + let before = s.doc().unwrap().doc.clone(); + for cmd in ["object.dropShadow", "object.innerShadow", "object.outerGlow"] { + for mode in [json!("multply"), json!(3)] { + let e = s.execute(cmd, &json!({"on": true, "mode": mode, "ids": [id]})).unwrap_err(); + assert!(e.to_string().contains("unknown blend mode"), "{cmd}: {e}"); + } + } + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "nothing changed"); + s.execute("object.dropShadow", &json!({"mode": null, "ids": [id]})).unwrap(); + assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().effects.drop_shadow.mode, BlendMode::Screen, "null keeps the mode"); } #[test] diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 30a5caa34..4de0e08cc 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2699,7 +2699,9 @@ impl<'r> Importer<'r> { size: ds.num("Size").unwrap_or(def.size), spread: ds.num("Spread").unwrap_or(def.spread), global_light: false, - mode: ds.get("BlendMode").map_or(def.mode, names::blend_in), + // Without the attribute (earlier DesignCraft exports) the shadow stays Normal, as it + // rendered then; a new shadow's default is Multiply. + mode: ds.get("BlendMode").map_or(designcraft_color::BlendMode::Normal, names::blend_in), }; } if let Some(f) = t.find("FeatherSetting") diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 035333da4..99ae66625 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -3616,6 +3616,7 @@ fn object_style_effects_import_by_category_and_based_on() { let base = fx("Base").unwrap(); assert!(base.drop_shadow.on && base.drop_shadow.size == 9.0 && (base.drop_shadow.distance - 5.0).abs() < 1e-9); assert_eq!(base.feather, 0.0, "EnableFeather=false"); + assert_eq!(base.drop_shadow.mode, designcraft_color::BlendMode::Normal, "no BlendMode: Normal, as earlier exports rendered"); let child = fx("Child").unwrap(); assert_eq!((child.drop_shadow.size, child.feather), (9.0, 6.0), "settings from the BasedOn style"); assert!(fx("Off").is_none()); From 098613b75d916157f1fc307cb4634d113bf18e80 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:05:00 +0200 Subject: [PATCH 05/20] Place cursor: a drag under the threshold is a click, with no frame drawn Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests.rs | 9 +++++++++ crates/tools/src/nav.rs | 14 ++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5d0795196..adaa0fe8e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -517,6 +517,15 @@ Y: 100.00%")), s.pointer(&PointerEvent::new(PointerKind::Up, 180.0, 401.0), v).unwrap(); let it = s.doc().unwrap().doc.spreads[0].items.last().unwrap().clone(); assert_eq!(it.bounds(), natural, "the frame previewed"); + // A drag under the threshold is a click: no frame drawn, the graphic at actual size where + // the press was. + s.execute("place.load", &json!({"base64": cmd::base64_encode(&png), "name": "a.png"})).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 500.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 102.5, 501.25), v).unwrap(); + assert!(!s.overlays(v).iter().any(|o| matches!(o, Overlay::Marquee(_))), "no frame yet"); + s.pointer(&PointerEvent::new(PointerKind::Up, 102.5, 501.25), v).unwrap(); + let it = s.doc().unwrap().doc.spreads[0].items.last().unwrap().clone(); + assert_eq!(it.bounds(), designcraft_geom::Rect::new(100.0, 500.0, 140.0, 520.0), "actual size at the press"); } #[test] diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index 4a582c385..cbefbd448 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -133,7 +133,8 @@ impl Tool for PlaceGun { let Some(a) = self.start.take() else { return vec![] }; let Some((sr, sa)) = cx.layout.spread_at(a) else { return vec![] }; let b = cx.layout.to_spread(sr, ev.pos); - if (ev.pos - a).hypot() < cx.tol(3.0) + let click = (ev.pos - a).hypot() < cx.tol(3.0); + if click && let Some((_, id)) = cx.hit(a) && cx .doc @@ -142,6 +143,10 @@ impl Tool for PlaceGun { { return vec![Action::Exec("place.drop".into(), json!({"frame": id.0}))]; } + // Under the drag threshold it's a click, as the overlay shows: actual size there. + if click { + return vec![Action::Exec("place.drop".into(), json!({"spread": crate::spread_json(sr), "x": sa.x, "y": sa.y}))]; + } // The frame the drag drew: at the graphic's proportions from where it started. let r = cx.loaded.map_or_else(|| designcraft_geom::Rect::from_points(sa, b), |size| proportional(sa, b, size).0); vec![Action::Exec( @@ -154,9 +159,14 @@ impl Tool for PlaceGun { } fn overlays(&self, cx: &ToolContext) -> Vec { let Some(a) = self.start else { return vec![] }; - let (Some(size), true) = (cx.loaded, (self.cur - a).hypot() >= cx.tol(3.0)) else { + let Some(size) = cx.loaded else { return vec![crate::Overlay::Marquee(designcraft_geom::Rect::from_points(a, self.cur))]; }; + // Under the drag threshold the release is a click (actual size where it was pressed): + // no frame is drawn yet, the loaded cursor shows what lands. + if (self.cur - a).hypot() < cx.tol(3.0) { + return vec![]; + } // As InDesign: the frame the graphic will fill, and its scale beside the pointer. let (r, k) = drawn_frame(a, self.cur, size); let pct = format!("{:.2}%", k * 100.0); From ff5b065fafdcb66dbe9eb85046be35a82bac0cb8 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:33:11 +0200 Subject: [PATCH 06/20] Place cursor: the thumbnail stays until a drag starts drawing the frame Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests.rs | 1 + crates/tools/src/nav.rs | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index adaa0fe8e..3eb129c83 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -523,6 +523,7 @@ Y: 100.00%")), s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 500.0), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Drag, 102.5, 501.25), v).unwrap(); assert!(!s.overlays(v).iter().any(|o| matches!(o, Overlay::Marquee(_))), "no frame yet"); + assert_eq!(s.cursor(p(102.5, 501.25), Mods::default(), v), Cursor::LoadedGraphic, "the thumbnail stays"); s.pointer(&PointerEvent::new(PointerKind::Up, 102.5, 501.25), v).unwrap(); let it = s.doc().unwrap().doc.spreads[0].items.last().unwrap().clone(); assert_eq!(it.bounds(), designcraft_geom::Rect::new(100.0, 500.0, 140.0, 520.0), "actual size at the press"); diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index cbefbd448..95b471e4e 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -188,9 +188,12 @@ Y: {pct}" _ => vec![], } } - fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + fn cursor(&self, cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { // Drawing the frame: the crosshair (the loaded graphic's thumbnail would hide the frame). - if self.start.is_some() { Cursor::Crosshair } else { Cursor::LoadedGraphic } + // Under the drag threshold no frame is drawn yet (the release is a click), so the + // thumbnail stays, as the overlay does. + let drawing = self.start.is_some_and(|a| cx.loaded.is_none() || (self.cur - a).hypot() >= cx.tol(3.0)); + if drawing { Cursor::Crosshair } else { Cursor::LoadedGraphic } } } From 861afad3385c7c81fae32d2f6c80ed915f204bc3 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:27:32 +0200 Subject: [PATCH 07/20] Swatches panel: rename a swatch in place As in InDesign, a click on the name of the swatch picked already (a slow double-click) turns the name into a field: Enter renames the swatch (swatch.options, so everything that uses it follows), Escape keeps the name. A double-click still opens Swatch Options, and the special swatches keep their names. Found in the Learn Skills Daily course, chapter 23. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/panels/swatches.rs | 148 +++++++++++++++++++++++++- 2 files changed, 148 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..44977f5c6 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-21** · Swatches panel: a swatch is renamed in place, as in InDesign: click it, then click its name again (a slow double-click), type, and Enter (Escape keeps the name). Everything that uses it follows; a double-click still opens Swatch Options, and [None], [Registration], [Paper] and [Black] keep their names. Found in the Learn Skills Daily course, chapter 23. 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/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 3e7030e87..1abe1d6c9 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -144,6 +144,11 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { /// Temp-data key of the swatch last clicked (Delete Swatch removes it). const PICKED: &str = "swatches_picked"; +/// Temp-data key of the swatch renamed in place and its name being typed. +const RENAMING: &str = "swatches_renaming"; +/// Temp-data key of a click on the picked swatch's name: (swatch, time). Unless it turns into a +/// double-click (Swatch Options), it starts renaming. +const RENAME_CLICK: &str = "swatches_rename_click"; /// The Swatches panel's top row, as InDesign's: a Fill/Stroke proxy (click: the other in front), /// Formatting Affects Container / Text, and the tint of the colour in front. `target` is @@ -232,6 +237,43 @@ fn folder_button(ui: &mut egui::Ui, tip: &str) -> egui::Response { }) } +/// The name field of a swatch being renamed in place, over `rect`; false when `sw` isn't. Enter or +/// a click elsewhere renames it (`swatch.options`), Escape leaves it as it was. +fn rename_field(app: &mut DesignApp, ui: &mut egui::Ui, sw: &designcraft_color::swatch::Swatch, rect: egui::Rect) -> bool { + let key = egui::Id::new(RENAMING); + let Some((name, mut text)) = ui.data(|d| d.get_temp::<(String, String)>(key)).filter(|(n, _)| *n == sw.name) else { return false }; + let id = egui::Id::new(("swatch_rename", &sw.name)); + let edit = egui::TextEdit::singleline(&mut text).id(id).font(egui::FontId::proportional(12.5)).margin(vec2(2.0, 1.0)); + let resp = ui.put(rect, edit); + if !resp.has_focus() && !resp.lost_focus() { + // The field opens with the name selected. + resp.request_focus(); + if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id) { + state + .cursor + .set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count())))); + state.store(ui.ctx(), id); + } + } + if resp.lost_focus() { + ui.data_mut(|d| d.remove_temp::<(String, String)>(key)); + let to = text.trim(); + if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && !to.is_empty() && to != name { + match app.run("swatch.options", json!({"name": name, "to": to})) { + Ok(_) => ui.data_mut(|d| { + d.insert_temp(egui::Id::new(PICKED), to.to_string()); + }), + Err(e) => { + app.status_error("Swatch Options", e); + } + } + } + } else { + ui.data_mut(|d| d.insert_temp(key, (name, text))); + } + true +} + /// One Swatches panel row: chip, name, type; click applies it to the fill or stroke in front of /// the proxy (of the frames or their text, see [`super::colors_affect_text`]), the context menu /// to the stroke, colour groups and delete. @@ -266,7 +308,10 @@ fn swatch_row( let (c, g) = crate::widgets::swatch_colors(doc, &sw.name, 1.0); let chip = egui::Rect::from_min_size(row.min + vec2(4.0 + indent, 3.0), vec2(16.0, 16.0)); crate::widgets::paint_chip(ui.painter(), chip, c, g); - ui.painter().text(row.min + vec2(28.0 + indent, 11.0), egui::Align2::LEFT_CENTER, &sw.name, egui::FontId::proportional(12.5), t.text); + let name_rect = egui::Rect::from_min_max(row.min + vec2(26.0 + indent, 1.0), egui::pos2(row.max.x - 72.0, row.max.y - 1.0)); + if !rename_field(app, ui, sw, name_rect) { + ui.painter().text(row.min + vec2(28.0 + indent, 11.0), egui::Align2::LEFT_CENTER, &sw.name, egui::FontId::proportional(12.5), t.text); + } // Type indicators: spot dot / process square, colour mode. let kind = match &sw.value { SwatchValue::Color { color, color_type } => { @@ -286,13 +331,32 @@ fn swatch_row( } ui.painter().text(row.right_center() - vec2(6.0, 0.0), egui::Align2::RIGHT_CENTER, kind, egui::FontId::proportional(10.5), t.text_dim); if resp.clicked() && !resp.double_clicked() { + // A click on the name of the swatch picked already renames it in place, as InDesign's + // (a slow double-click), unless it turns into a double-click. + let picked = ui.data(|d| d.get_temp::(egui::Id::new(PICKED))).is_some_and(|p| p == sw.name); + if picked && !sw.locked && resp.interact_pointer_pos().is_some_and(|p| name_rect.contains(p)) { + let now = ui.input(|i| i.time); + ui.data_mut(|d| d.insert_temp(egui::Id::new(RENAME_CLICK), (sw.name.clone(), now))); + } ui.data_mut(|d| d.insert_temp(egui::Id::new(PICKED), sw.name.clone())); let _ = super::apply_swatch(app, app.ui.stroke_front, &sw.name, None); } // Double-click: Swatch Options (the first click has applied the swatch). if resp.double_clicked() { + ui.data_mut(|d| d.remove_temp::<(String, f64)>(egui::Id::new(RENAME_CLICK))); let _ = app.run("app.swatchOptionsDialog", json!({"name": sw.name})); } + if let Some((name, at)) = ui.data(|d| d.get_temp::<(String, f64)>(egui::Id::new(RENAME_CLICK))).filter(|(n, _)| *n == sw.name) { + let wait = ui.ctx().options(|o| o.input_options.max_double_click_delay); + if ui.input(|i| i.time) - at > wait { + ui.data_mut(|d| { + d.remove_temp::<(String, f64)>(egui::Id::new(RENAME_CLICK)); + d.insert_temp(egui::Id::new(RENAMING), (name.clone(), name)); + }); + } else { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(wait)); + } + } resp.context_menu(|ui| { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Swatch Options…"))).clicked() { let _ = app.run("app.swatchOptionsDialog", json!({"name": sw.name})); @@ -1135,6 +1199,88 @@ mod swatches_header_tests { assert!(h.state().session.doc().unwrap().doc.swatch("Brand").is_none()); } + /// The course's chapter 23: a swatch added as "C=14 M=8 Y=95 K=0" is renamed in the panel by + /// clicking it, then clicking its name again: the name becomes a field, and Enter renames the + /// swatch (and what uses it). A double-click still opens Swatch Options, Escape keeps the name, + /// and [Black] can't be renamed. + #[test] + fn a_second_click_on_the_picked_swatch_renames_it_in_place() { + use egui::accesskit::Role; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "unassigned"})).unwrap(); + let added = app.run("swatch.create", json!({"color": {"c": 14, "m": 8, "y": 95, "k": 0}})).unwrap()["name"].as_str().unwrap().to_string(); + assert_eq!(added, "C=14 M=8 Y=95 K=0"); + // Frames 50 ms apart: the double-click time is six of them. + let mut h = Harness::builder().with_size(vec2(300.0, 520.0)).with_step_dt(0.05).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); + // The panel's own fields (Tint); renaming adds one. + let fields = h.query_all_by_role(Role::TextInput).count(); + let field = |h: &Harness<'_, DesignApp>| h.query_all_by_role(Role::TextInput).count() > fields; + let has = |h: &Harness<'_, DesignApp>, n: &str| h.state().session.doc().unwrap().doc.swatch(n).is_some(); + let name_at = |h: &Harness<'_, DesignApp>, label: &str| h.get_by_label(label).rect().left_center() + vec2(40.0, 0.0); + let click = |h: &mut Harness<'_, DesignApp>, at: egui::Pos2| { + h.hover_at(at); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.step(); + }; + // A click on the name: applied, and picked. + let at = name_at(&h, &added); + click(&mut h, at); + h.run_steps(10); + assert_eq!(items(h.state())[0].fill.swatch, added); + assert!(!field(&h)); + // Again, after the double-click time: the name is a field, with the name selected. + click(&mut h, at); + h.run_steps(10); + assert!(field(&h), "the name is a field"); + h.event(egui::Event::Text("Lemon Yellow Sun".into())); + h.step(); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert!(has(&h, "Lemon Yellow Sun") && !has(&h, &added)); + assert_eq!(items(h.state())[0].fill.swatch, "Lemon Yellow Sun", "what uses it follows"); + // Escape keeps the name. + let at = name_at(&h, "Lemon Yellow Sun"); + h.run_steps(10); + click(&mut h, at); + h.run_steps(10); + assert!(field(&h)); + h.event(egui::Event::Text("Sour".into())); + h.step(); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(has(&h, "Lemon Yellow Sun") && !field(&h)); + // A double-click on it opens Swatch Options, and doesn't rename. + h.run_steps(20); + click(&mut h, at); + click(&mut h, at); + h.run_steps(10); + assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.id.as_str()), Some("swatchOptions")); + assert!(!field(&h), "no rename"); + // [Black] keeps its name. + h.state_mut().ui.dialog = None; + h.run_steps(20); + let at = name_at(&h, "[Black]"); + click(&mut h, at); + h.run_steps(10); + click(&mut h, at); + h.run_steps(10); + assert!(!field(&h)); + } + #[test] fn the_tint_field_keeps_each_objects_own_swatch() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From d0e90e71cabcca305c94bc5eb14fe3920b56208d Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:01:26 +0200 Subject: [PATCH 08/20] Place cursor: the crosshair throughout when no graphic is loaded With nothing in the place cursor there's no thumbnail to show, so the cursor is the crosshair before a press too, as it already was while dragging. (Review.) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests.rs | 4 ++++ crates/tools/src/nav.rs | 5 +++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3eb129c83..b43ef439e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -527,6 +527,10 @@ Y: 100.00%")), s.pointer(&PointerEvent::new(PointerKind::Up, 102.5, 501.25), v).unwrap(); let it = s.doc().unwrap().doc.spreads[0].items.last().unwrap().clone(); assert_eq!(it.bounds(), designcraft_geom::Rect::new(100.0, 500.0, 140.0, 520.0), "actual size at the press"); + // With no graphic loaded there's no thumbnail to show: the crosshair, before any press too. + s.loaded = None; + s.set_tool("placeGun"); + assert_eq!(s.cursor(p(300.0, 300.0), Mods::default(), v), Cursor::Crosshair); } #[test] diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index 95b471e4e..74a8c5074 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -191,8 +191,9 @@ Y: {pct}" fn cursor(&self, cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { // Drawing the frame: the crosshair (the loaded graphic's thumbnail would hide the frame). // Under the drag threshold no frame is drawn yet (the release is a click), so the - // thumbnail stays, as the overlay does. - let drawing = self.start.is_some_and(|a| cx.loaded.is_none() || (self.cur - a).hypot() >= cx.tol(3.0)); + // thumbnail stays, as the overlay does. With no graphic loaded there's no thumbnail to + // show: the crosshair throughout. + let drawing = cx.loaded.is_none() || self.start.is_some_and(|a| (self.cur - a).hypot() >= cx.tol(3.0)); if drawing { Cursor::Crosshair } else { Cursor::LoadedGraphic } } } From d26750d947227ea7933c1a12528d6ca9509a677a Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:22:55 +0200 Subject: [PATCH 09/20] Place cursor: a short drag places the frame it previews The place cursor draws a frame from 3 points, but the canvas sent Drag events only once egui started a drag (at max_click_dist, 6 points). A release in between placed a frame the preview never showed. With the place cursor, the canvas now follows the pointer from 3 points. (Review.) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 58 ++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 66963c528..f619f8f37 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2455,9 +2455,12 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r events.push(PointerEvent { kind: PointerKind::Down, pos: pos(o), mods: m, clicks: n }); down = true; } + // The place cursor draws a frame from 3 points (its own threshold), before egui starts a + // drag (at `max_click_dist`): follow the pointer from there, so a release between the two + // places the frame that was previewed. + let place_drag = app.session.tool_id() == "placeGun" && origin.zip(latest).is_some_and(|(o, p)| (o - p).length() >= 3.0); if down - && resp.dragged() - && resp.drag_delta() != egui::Vec2::ZERO + && ((resp.dragged() && resp.drag_delta() != egui::Vec2::ZERO) || place_drag) && let Some(p) = latest { events.push(PointerEvent { kind: PointerKind::Drag, pos: pos(p), mods: m, clicks: 1 }); @@ -3052,6 +3055,57 @@ mod tests { use super::*; + /// A place-cursor drag past its 3-point threshold but short of egui's drag distance previews + /// a frame, and the release places that frame: what's shown is what lands. + #[test] + fn a_short_place_cursor_drag_places_the_frame_it_previews() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); + app.run("place.load", json!({"base64": designcraft_engine::cmd::base64_encode(&png), "name": "a.png"})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1400.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(6); + let (from, slot) = { + let app = h.state(); + let layout = CanvasLayout::new(&app.session.doc().unwrap().doc, false); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + (xf.to_screen(layout.slots[0].xf * Point::new(200.0, 200.0)), layout.slots[0].xf) + }; + // About 5.4 points: past the place cursor's 3, short of egui's 6. + let to = from + vec2(4.5, 3.0); + assert!((to - from).length() < h.ctx.options(|o| o.input_options.max_click_dist)); + h.hover_at(from); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerMoved(to)); + h.run_steps(2); + let vi = h.state().view_info(); + let preview = h + .state_mut() + .session + .overlays(vi) + .into_iter() + .find_map(|o| match o { + designcraft_tools::Overlay::Marquee(r) => Some(r), + _ => None, + }) + .expect("the frame is previewed"); + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); + let placed = h.state().session.doc().unwrap().doc.spreads[0].items.last().expect("placed").bounds(); + let shown = slot.inverse().transform_rect_bbox(preview); + for (a, b) in [(placed.x0, shown.x0), (placed.y0, shown.y0), (placed.x1, shown.x1), (placed.y1, shown.y1)] { + assert!((a - b).abs() < 0.01, "placed {placed:?}, previewed {shown:?}"); + } + } + #[test] fn a_press_after_a_drag_is_not_a_double_click() { let (delay, dist) = (0.3, 6.0); From c84ce7a545dd510981a4c4e28991f194c9c38959 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 01:25:12 +0200 Subject: [PATCH 10/20] Swatches: a click on another swatch keeps it picked and cancels a waiting rename Ending a rename by clicking another swatch keeps that swatch picked (for Delete Swatch) instead of the renamed one. A click on another swatch while the rename's double-click wait runs cancels the rename. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/swatches.rs | 37 +++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 1abe1d6c9..a41b71d5b 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -260,8 +260,12 @@ fn rename_field(app: &mut DesignApp, ui: &mut egui::Ui, sw: &designcraft_color:: let to = text.trim(); if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && !to.is_empty() && to != name { match app.run("swatch.options", json!({"name": name, "to": to})) { + // Still picked under its new name, unless the click that ended the rename picked + // another swatch. Ok(_) => ui.data_mut(|d| { - d.insert_temp(egui::Id::new(PICKED), to.to_string()); + if d.get_temp::(egui::Id::new(PICKED)).is_none_or(|p| p == name) { + d.insert_temp(egui::Id::new(PICKED), to.to_string()); + } }), Err(e) => { app.status_error("Swatch Options", e); @@ -334,6 +338,8 @@ fn swatch_row( // A click on the name of the swatch picked already renames it in place, as InDesign's // (a slow double-click), unless it turns into a double-click. let picked = ui.data(|d| d.get_temp::(egui::Id::new(PICKED))).is_some_and(|p| p == sw.name); + // A click on another swatch cancels a rename still waiting. + ui.data_mut(|d| d.remove_temp::<(String, f64)>(egui::Id::new(RENAME_CLICK))); if picked && !sw.locked && resp.interact_pointer_pos().is_some_and(|p| name_rect.contains(p)) { let now = ui.input(|i| i.time); ui.data_mut(|d| d.insert_temp(egui::Id::new(RENAME_CLICK), (sw.name.clone(), now))); @@ -348,7 +354,10 @@ fn swatch_row( } if let Some((name, at)) = ui.data(|d| d.get_temp::<(String, f64)>(egui::Id::new(RENAME_CLICK))).filter(|(n, _)| *n == sw.name) { let wait = ui.ctx().options(|o| o.input_options.max_double_click_delay); - if ui.input(|i| i.time) - at > wait { + if ui.data(|d| d.get_temp::(egui::Id::new(PICKED))).is_none_or(|p| p != name) { + // Another swatch picked since: no rename. + ui.data_mut(|d| d.remove_temp::<(String, f64)>(egui::Id::new(RENAME_CLICK))); + } else if ui.input(|i| i.time) - at > wait { ui.data_mut(|d| { d.remove_temp::<(String, f64)>(egui::Id::new(RENAME_CLICK)); d.insert_temp(egui::Id::new(RENAMING), (name.clone(), name)); @@ -1279,6 +1288,30 @@ mod swatches_header_tests { click(&mut h, at); h.run_steps(10); assert!(!field(&h)); + // A click on another swatch while the rename waits: that one is picked, and nothing + // renames. + let picked = |h: &Harness<'_, DesignApp>| h.ctx.data(|d| d.get_temp::(egui::Id::new(super::PICKED))); + let (lemon, paper) = (name_at(&h, "Lemon Yellow Sun"), name_at(&h, "[Paper]")); + h.run_steps(20); + click(&mut h, lemon); + h.run_steps(10); + click(&mut h, lemon); + click(&mut h, paper); + h.run_steps(10); + assert!(!field(&h), "the waiting rename is cancelled"); + assert_eq!(picked(&h).as_deref(), Some("[Paper]")); + // Renaming, a click on another swatch renames, and picks that one (for Delete Swatch). + click(&mut h, lemon); + h.run_steps(10); + click(&mut h, lemon); + h.run_steps(10); + assert!(field(&h)); + h.event(egui::Event::Text("Lemon".into())); + h.step(); + click(&mut h, paper); + h.run_steps(5); + assert!(has(&h, "Lemon") && !field(&h)); + assert_eq!(picked(&h).as_deref(), Some("[Paper]"), "the swatch clicked stays picked"); } #[test] From 2663cfec6870a0d031df0a3b0ca861954cfc6111 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 09:36:02 +0200 Subject: [PATCH 11/20] Canvas: main's guide snapping contexts have no loaded graphic Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index bc790df06..404045c9d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3099,6 +3099,7 @@ fn guide_spot(app: &DesignApp, xf: &Xf, g: &GuideDrag) -> Option { snap: app.ui.snap_view(), unit: app.ui.units, stroke_in_front: false, + loaded: None, }; let s = designcraft_tools::snap::snap( &cx, @@ -3357,6 +3358,7 @@ fn over_item(app: &DesignApp, xf: &Xf, p: Pos2) -> bool { snap: app.ui.snap_view(), unit: app.ui.units, stroke_in_front: false, + loaded: None, }; cx.hit(xf.to_canvas(p)).is_some() } @@ -3465,6 +3467,7 @@ fn snap_gutter(app: &DesignApp, g: &ColumnDrag, want: f64, shift: bool, zoom: f6 snap: designcraft_tools::SnapView { smart_dimensions: false, smart_spacing: false, ..app.ui.snap_view() }, unit: app.ui.units, stroke_in_front: false, + loaded: None, }; let rect = DRect::new(want, m.y0, want + width, m.y1); let s = designcraft_tools::snap::snap( From 14d68ee0b1221190252868a82aa18a86fa1a83da Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sun, 25 Oct 2026 17:15:42 +0100 Subject: [PATCH 12/20] Swatches: a rename in place ends with its document The panel keeps the uid of the document it last showed; another one (switched to, or moved into its place by a close) drops the rename and the pending rename click, so a same-named swatch there isn't renamed. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/swatches.rs | 36 +++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 14d187655..15b2f5873 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -11,6 +11,16 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); + // Another document (switched to, or moved to this one's place by a close) drops a rename in + // place: a swatch of the same name there isn't the one being renamed. + let uid = st.uid; + if ui.data(|d| d.get_temp::(egui::Id::new(RENAME_DOC))) != Some(uid) { + ui.data_mut(|d| { + d.remove_temp::<(String, String)>(egui::Id::new(RENAMING)); + d.remove_temp::<(String, f64)>(egui::Id::new(RENAME_CLICK)); + d.insert_temp(egui::Id::new(RENAME_DOC), uid); + }); + } let target = super::color_target(app); header(app, ui, &doc, target.as_ref()); // The row of the colour in front of the proxy is highlighted. @@ -159,6 +169,8 @@ const RENAMING: &str = "swatches_renaming"; /// Temp-data key of a click on the picked swatch's name: (swatch, time). Unless it turns into a /// double-click (Swatch Options), it starts renaming. const RENAME_CLICK: &str = "swatches_rename_click"; +/// Temp-data key of the document (its uid) the panel last showed: a rename belongs to it. +const RENAME_DOC: &str = "swatches_rename_doc"; /// The Swatches panel's top row, as InDesign's: a Fill/Stroke proxy (click: the other in front), /// Formatting Affects Container / Text, and the tint of the colour in front. `target` is @@ -1295,6 +1307,30 @@ mod swatches_header_tests { /// clicking it, then clicking its name again: the name becomes a field, and Enter renames the /// swatch (and what uses it). A double-click still opens Swatch Options, Escape keeps the name, /// and [Black] can't be renamed. + /// A rename in place belongs to its document: with another one active, a swatch of the same + /// name there gets no field, and nothing is renamed. + #[test] + fn a_rename_in_place_ends_with_its_document() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let color = json!({"color": {"c": 14, "m": 8, "y": 95, "k": 0}}); + let name = app.run("swatch.create", color.clone()).unwrap()["name"].as_str().unwrap().to_string(); + let mut h = Harness::builder().with_size(vec2(300.0, 520.0)).build_ui_state(|ui, app: &mut DesignApp| super::show(app, ui), app); + h.run_steps(2); + let fields = h.query_all_by_role(egui::accesskit::Role::TextInput).count(); + let renaming = egui::Id::new(super::RENAMING); + h.ctx.data_mut(|d| d.insert_temp(renaming, (name.clone(), "Typed".to_string()))); + h.run_steps(2); + assert!(h.query_all_by_role(egui::accesskit::Role::TextInput).count() > fields, "the name is a field"); + // Another document with a swatch of the same name. + h.state_mut().run("file.new", json!({})).unwrap(); + h.state_mut().run("swatch.create", color).unwrap(); + h.run_steps(3); + assert!(h.ctx.data(|d| d.get_temp::<(String, String)>(renaming)).is_none(), "the rename is dropped"); + let doc = &h.state().session.active().unwrap().doc; + assert!(doc.swatch(&name).is_some() && doc.swatch("Typed").is_none()); + } + #[test] fn a_second_click_on_the_picked_swatch_renames_it_in_place() { use egui::accesskit::Role; From 3b86c0ca06b347659f8415255864cb988c51d7da Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 14:06:07 +0100 Subject: [PATCH 13/20] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/tools/src/snap/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tools/src/snap/tests.rs b/crates/tools/src/snap/tests.rs index 300d5ad05..f97c637c1 100644 --- a/crates/tools/src/snap/tests.rs +++ b/crates/tools/src/snap/tests.rs @@ -64,7 +64,7 @@ fn ctx_on<'a>(doc: &'a Document, sel: &'a Selection, cache: &'a Cache, layout: & snap: SnapView::FACTORY, unit: Unit::Points, stroke_in_front: false, - loaded: None, + loaded: None, } } From d8b2179b5698be2c7c5e64e53374bb1f37a0387b Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:29:22 +0100 Subject: [PATCH 14/20] Loaded cursor thumbnail: its drawing after main's canvas pass, one loaded field per snap context Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 11 +++++------ crates/ui-egui/src/canvas/column_guides.rs | 1 - 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ec1f1716b..c993193de 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -363,6 +363,11 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { draw_loaded_thumbnail(app, &painter, p, &t); } } + // A second viewport has no panels, dialogs or Quick Apply after its canvas: observe here, + // once, as the end of its pass. The root viewport observes at the end of `DesignApp::ui`. + if ui.ctx().viewport_id() != egui::ViewportId::ROOT { + ime_observe(app, ui.ctx()); + } } /// Longest side of the loaded place cursor's thumbnail, in points. @@ -414,12 +419,6 @@ fn loaded_thumb_texture(ctx: &egui::Context, doc: &Document, asset: designcraft_ let t = ctx.load_texture("loaded_thumb", crate::widgets::fit_texture(ci, max), egui::TextureOptions::LINEAR); ctx.data_mut(|d| d.insert_temp(key, t.clone())); Some(t) - } - // A second viewport has no panels, dialogs or Quick Apply after its canvas: observe here, - // once, as the end of its pass. The root viewport observes at the end of `DesignApp::ui`. - if ui.ctx().viewport_id() != egui::ViewportId::ROOT { - ime_observe(app, ui.ctx()); - } } /// Presses in a row on the canvas: the last press's count, and where and when it was released diff --git a/crates/ui-egui/src/canvas/column_guides.rs b/crates/ui-egui/src/canvas/column_guides.rs index 2cc1d4d0e..c15595442 100644 --- a/crates/ui-egui/src/canvas/column_guides.rs +++ b/crates/ui-egui/src/canvas/column_guides.rs @@ -107,7 +107,6 @@ fn snap_gutter(app: &DesignApp, g: &ColumnDrag, want: f64, shift: bool, zoom: f6 unit: app.ui.units, stroke_in_front: false, loaded: None, - loaded: None, }; let rect = DRect::new(want, m.y0, want + width, m.y1); let s = designcraft_tools::snap::snap( From 793e0fc33770b1f5b48a107e2628fef7a8f7eefe Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:07:36 +0100 Subject: [PATCH 15/20] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/guides.rs | 2 +- crates/ui-egui/src/canvas/tests.rs | 98 ++++++++++++++--------------- 2 files changed, 50 insertions(+), 50 deletions(-) diff --git a/crates/ui-egui/src/canvas/guides.rs b/crates/ui-egui/src/canvas/guides.rs index 540a295ee..4689b5fb6 100644 --- a/crates/ui-egui/src/canvas/guides.rs +++ b/crates/ui-egui/src/canvas/guides.rs @@ -143,7 +143,7 @@ pub(super) fn guide_spot(app: &DesignApp, xf: &Xf, g: &GuideDrag) -> Option Some(r), - _ => None, - }) - .expect("the frame is previewed"); - h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); - h.run_steps(3); - let placed = h.state().session.doc().unwrap().doc.spreads[0].items.last().expect("placed").bounds(); - let shown = slot.inverse().transform_rect_bbox(preview); - for (a, b) in [(placed.x0, shown.x0), (placed.y0, shown.y0), (placed.x1, shown.x1), (placed.y1, shown.y1)] { - assert!((a - b).abs() < 0.01, "placed {placed:?}, previewed {shown:?}"); - } +/// A place-cursor drag past its 3-point threshold but short of egui's drag distance previews +/// a frame, and the release places that frame: what's shown is what lands. +#[test] +fn a_short_place_cursor_drag_places_the_frame_it_previews() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); + app.run("place.load", json!({"base64": designcraft_engine::cmd::base64_encode(&png), "name": "a.png"})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1400.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(6); + let (from, slot) = { + let app = h.state(); + let layout = CanvasLayout::new(&app.session.doc().unwrap().doc, false); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + (xf.to_screen(layout.slots[0].xf * Point::new(200.0, 200.0)), layout.slots[0].xf) + }; + // About 5.4 points: past the place cursor's 3, short of egui's 6. + let to = from + vec2(4.5, 3.0); + assert!((to - from).length() < h.ctx.options(|o| o.input_options.max_click_dist)); + h.hover_at(from); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerMoved(to)); + h.run_steps(2); + let vi = h.state().view_info(); + let preview = h + .state_mut() + .session + .overlays(vi) + .into_iter() + .find_map(|o| match o { + designcraft_tools::Overlay::Marquee(r) => Some(r), + _ => None, + }) + .expect("the frame is previewed"); + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); + let placed = h.state().session.doc().unwrap().doc.spreads[0].items.last().expect("placed").bounds(); + let shown = slot.inverse().transform_rect_bbox(preview); + for (a, b) in [(placed.x0, shown.x0), (placed.y0, shown.y0), (placed.x1, shown.x1), (placed.y1, shown.y1)] { + assert!((a - b).abs() < 0.01, "placed {placed:?}, previewed {shown:?}"); } +} #[test] fn a_press_after_a_drag_is_not_a_double_click() { From cdf110d6fc5bbd4f02442b8e640fd065409b9ae2 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:10:39 +0100 Subject: [PATCH 16/20] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c993193de..714c75c5d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -403,7 +403,7 @@ fn draw_loaded_thumbnail(app: &DesignApp, painter: &egui::Painter, p: Pos2, t: & /// The loaded graphic's thumbnail texture, cached per image data. fn loaded_thumb_texture(ctx: &egui::Context, doc: &Document, asset: designcraft_doc::AssetId, size: (f64, f64)) -> Option { let a = doc.assets.get(&asset)?; - let key = egui::Id::new(("loaded_thumb", std::sync::Arc::as_ptr(&a.data) as usize, a.data.len(), a.page)); + let key = egui::Id::unique(("loaded_thumb", std::sync::Arc::as_ptr(&a.data) as usize, a.data.len(), a.page)); if let Some(t) = ctx.data(|d| d.get_temp::(key)) { return Some(t); } From f5647c5e081c01f70283afc927dd15d66fd07622 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:11:29 +0100 Subject: [PATCH 17/20] Grid snap test: the tool context's loaded field in main's moved canvas test Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 51d5d32d3..ed57126bb 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -1823,6 +1823,7 @@ fn document_grid_is_drawn_where_points_snap_to_it() { snap: SnapView { snap_to_document_grid: true, zone_px: 20.0, ..SnapView::OFF }, unit: Unit::Points, stroke_in_front: false, + loaded: None, }; let (snapped, _) = designcraft_tools::snap::snap_point(&cx, sr, Point::new(page.x0 + 31.3, page.y0 + 47.9)); assert_eq!((snapped.x - page.x0, snapped.y - page.y0), (27.0, 45.0)); From c6573d95577899826ccd000144df52d351c0b60c Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 23:46:49 +0100 Subject: [PATCH 18/20] Swatch rename test: the app's fonts installed first, as main's panel tests do; egui 0.37 Id::unique Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/swatches.rs | 55 ++++++++++++++++----------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 7652251c5..789474bbe 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -14,11 +14,11 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // Another document (switched to, or moved to this one's place by a close) drops a rename in // place: a swatch of the same name there isn't the one being renamed. let uid = st.uid; - if ui.data(|d| d.get_temp::(egui::Id::new(RENAME_DOC))) != Some(uid) { + if ui.data(|d| d.get_temp::(egui::Id::unique(RENAME_DOC))) != Some(uid) { ui.data_mut(|d| { - d.remove_temp::<(String, String)>(egui::Id::new(RENAMING)); - d.remove_temp::<(String, f64)>(egui::Id::new(RENAME_CLICK)); - d.insert_temp(egui::Id::new(RENAME_DOC), uid); + d.remove_temp::<(String, String)>(egui::Id::unique(RENAMING)); + d.remove_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK)); + d.insert_temp(egui::Id::unique(RENAME_DOC), uid); }); } let target = super::color_target(app); @@ -262,9 +262,9 @@ fn folder_button(ui: &mut egui::Ui, tip: &str) -> egui::Response { /// The name field of a swatch being renamed in place, over `rect`; false when `sw` isn't. Enter or /// a click elsewhere renames it (`swatch.options`), Escape leaves it as it was. fn rename_field(app: &mut DesignApp, ui: &mut egui::Ui, sw: &designcraft_color::swatch::Swatch, rect: egui::Rect) -> bool { - let key = egui::Id::new(RENAMING); + let key = egui::Id::unique(RENAMING); let Some((name, mut text)) = ui.data(|d| d.get_temp::<(String, String)>(key)).filter(|(n, _)| *n == sw.name) else { return false }; - let id = egui::Id::new(("swatch_rename", &sw.name)); + let id = egui::Id::unique(("swatch_rename", &sw.name)); let edit = egui::TextEdit::singleline(&mut text).id(id).font(egui::FontId::proportional(12.5)).margin(vec2(2.0, 1.0)); let resp = ui.put(rect, edit); if !resp.has_focus() && !resp.lost_focus() { @@ -285,8 +285,8 @@ fn rename_field(app: &mut DesignApp, ui: &mut egui::Ui, sw: &designcraft_color:: // Still picked under its new name, unless the click that ended the rename picked // another swatch. Ok(_) => ui.data_mut(|d| { - if d.get_temp::(egui::Id::new(PICKED)).is_none_or(|p| p == name) { - d.insert_temp(egui::Id::new(PICKED), to.to_string()); + if d.get_temp::(egui::Id::unique(PICKED)).is_none_or(|p| p == name) { + d.insert_temp(egui::Id::unique(PICKED), to.to_string()); } }), Err(e) => { @@ -369,30 +369,30 @@ fn swatch_row( if resp.clicked() && !resp.double_clicked() { // A click on the name of the swatch picked already renames it in place, as InDesign's // (a slow double-click), unless it turns into a double-click. - let picked = ui.data(|d| d.get_temp::(egui::Id::new(PICKED))).is_some_and(|p| p == sw.name); + let picked = ui.data(|d| d.get_temp::(egui::Id::unique(PICKED))).is_some_and(|p| p == sw.name); // A click on another swatch cancels a rename still waiting. - ui.data_mut(|d| d.remove_temp::<(String, f64)>(egui::Id::new(RENAME_CLICK))); + ui.data_mut(|d| d.remove_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK))); if picked && !sw.locked && resp.interact_pointer_pos().is_some_and(|p| name_rect.contains(p)) { let now = ui.input(|i| i.time); - ui.data_mut(|d| d.insert_temp(egui::Id::new(RENAME_CLICK), (sw.name.clone(), now))); + ui.data_mut(|d| d.insert_temp(egui::Id::unique(RENAME_CLICK), (sw.name.clone(), now))); } ui.data_mut(|d| d.insert_temp(egui::Id::unique(PICKED), sw.name.clone())); let _ = super::apply_swatch(app, app.ui.stroke_front, &sw.name, None); } // Double-click: Swatch Options (the first click has applied the swatch). if resp.double_clicked() { - ui.data_mut(|d| d.remove_temp::<(String, f64)>(egui::Id::new(RENAME_CLICK))); + ui.data_mut(|d| d.remove_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK))); let _ = app.run("app.swatchOptionsDialog", json!({"name": sw.name})); } - if let Some((name, at)) = ui.data(|d| d.get_temp::<(String, f64)>(egui::Id::new(RENAME_CLICK))).filter(|(n, _)| *n == sw.name) { + if let Some((name, at)) = ui.data(|d| d.get_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK))).filter(|(n, _)| *n == sw.name) { let wait = ui.ctx().options(|o| o.input_options.max_double_click_delay); - if ui.data(|d| d.get_temp::(egui::Id::new(PICKED))).is_none_or(|p| p != name) { + if ui.data(|d| d.get_temp::(egui::Id::unique(PICKED))).is_none_or(|p| p != name) { // Another swatch picked since: no rename. - ui.data_mut(|d| d.remove_temp::<(String, f64)>(egui::Id::new(RENAME_CLICK))); + ui.data_mut(|d| d.remove_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK))); } else if ui.input(|i| i.time) - at > wait { ui.data_mut(|d| { - d.remove_temp::<(String, f64)>(egui::Id::new(RENAME_CLICK)); - d.insert_temp(egui::Id::new(RENAMING), (name.clone(), name)); + d.remove_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK)); + d.insert_temp(egui::Id::unique(RENAMING), (name.clone(), name)); }); } else { ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(wait)); @@ -1373,10 +1373,21 @@ mod swatches_header_tests { app.run("file.new", json!({})).unwrap(); let color = json!({"color": {"c": 14, "m": 8, "y": 95, "k": 0}}); let name = app.run("swatch.create", color.clone()).unwrap()["name"].as_str().unwrap().to_string(); - let mut h = Harness::builder().with_size(vec2(300.0, 520.0)).build_ui_state(|ui, app: &mut DesignApp| super::show(app, ui), app); - h.run_steps(2); + let mut h = Harness::builder().with_size(vec2(300.0, 520.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::unique("rename_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; + } + super::show(app, ui); + }, + app, + ); + h.run_steps(3); let fields = h.query_all_by_role(egui::accesskit::Role::TextInput).count(); - let renaming = egui::Id::new(super::RENAMING); + let renaming = egui::Id::unique(super::RENAMING); h.ctx.data_mut(|d| d.insert_temp(renaming, (name.clone(), "Typed".to_string()))); h.run_steps(2); assert!(h.query_all_by_role(egui::accesskit::Role::TextInput).count() > fields, "the name is a field"); @@ -1400,7 +1411,7 @@ mod swatches_header_tests { // Frames 50 ms apart: the double-click time is six of them. let mut h = Harness::builder().with_size(vec2(300.0, 520.0)).with_step_dt(0.05).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)); @@ -1467,7 +1478,7 @@ mod swatches_header_tests { assert!(!field(&h)); // A click on another swatch while the rename waits: that one is picked, and nothing // renames. - let picked = |h: &Harness<'_, DesignApp>| h.ctx.data(|d| d.get_temp::(egui::Id::new(super::PICKED))); + let picked = |h: &Harness<'_, DesignApp>| h.ctx.data(|d| d.get_temp::(egui::Id::unique(super::PICKED))); let (lemon, paper) = (name_at(&h, "Lemon Yellow Sun"), name_at(&h, "[Paper]")); h.run_steps(20); click(&mut h, lemon); From 515daea39311b71eee08a6c553da7dbd6992232a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 23:54:42 +0100 Subject: [PATCH 19/20] Effects: one Mode row for both glows (main's Inner Glow picker and the branch's Outer Glow), craft_ui's dropdown Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/effects_dialog.rs | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/effects_dialog.rs b/crates/ui-egui/src/effects_dialog.rs index fbf8c185a..cd536487b 100644 --- a/crates/ui-egui/src/effects_dialog.rs +++ b/crates/ui-egui/src/effects_dialog.rs @@ -360,23 +360,7 @@ fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) { let key = page; heading(ui, &tr("Blending")); grid("b").show(ui, |ui| { - // Inner Glow: its blend mode (Screen, as InDesign starts it). - if key == "innerGlow" { - row(ui, "Mode:"); - let cur = d.fields.get("fx").and_then(|f| f["innerGlow"]["mode"].as_str()).unwrap_or("Screen").to_string(); - let label = |m: &designcraft_color::BlendMode| { - serde_json::to_value(m).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default() - }; - let shown = designcraft_color::BlendMode::ALL.iter().find(|m| label(m) == cur).map_or("Screen", |m| m.label()); - craft_ui::ComboBox::from_id_salt("fx_glow_mode").selected_text(tr(shown)).width(150.0).show_ui(ui, |ui| { - for m in designcraft_color::BlendMode::ALL { - if ui.selectable_label(label(&m) == cur, tr(m.label())).clicked() { - set(d, &["innerGlow", "mode"], json!(label(&m))); - } - } - }); - ui.end_row(); - } + mode_row(ui, d, key, &tr); row(ui, "Color:"); swatch(app, ui, d, &[key, "color"]); row(ui, "Opacity:"); @@ -576,7 +560,7 @@ fn mode_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, tr: &dyn Fn(&str) -> S let name = |m: &designcraft_color::BlendMode| serde_json::to_value(m).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); let shown = designcraft_color::BlendMode::ALL.iter().find(|m| name(m) == cur).map_or(fallback, |m| m.label()); let mut picked = None; - egui::ComboBox::from_id_salt(("fx_mode", key.to_string())).selected_text(tr(shown)).width(150.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(("fx_mode", key.to_string())).selected_text(tr(shown)).width(150.0).show_ui(ui, |ui| { for m in designcraft_color::BlendMode::ALL { if ui.selectable_label(name(&m) == cur, tr(m.label())).clicked() { picked = Some(name(&m)); From 8c89bf5f1a837abd36dbf578a31ca882e73e1d66 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 22:40:44 +0100 Subject: [PATCH 20/20] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/render/src/fx.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index cedb75d66..af3c0227c 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -230,7 +230,19 @@ impl Renderer { let s = &e.inner_shadow; let (sigma, choke) = split(s.size, s.choke); let off = offset(doc.light_angle(s.angle, s.global_light), s.distance); - self.inner_edge(ctx, f, reach, bp, xf, off, sigma, choke, color_of(&c, s.opacity.clamp(0.0, 1.0)), crate::blend_mode(s.mode).mix, painted); + self.inner_edge( + ctx, + f, + reach, + bp, + xf, + off, + sigma, + choke, + color_of(&c, s.opacity.clamp(0.0, 1.0)), + crate::blend_mode(s.mode).mix, + painted, + ); } // Inner glow: from the edges inwards (or out from the centre), screened over the object. if e.inner_glow.on