From 7192c2e13684e985f8fb2787d3bffe8b4f859976 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 23:48:11 +0200 Subject: [PATCH] feat(eyedropper): A sampled colour goes to the stroke when the Stroke box is in front As in InDesign, the Eyedropper applies a colour sampled from a placed image to the fill or the stroke, whichever is in front in the toolbox: with the Stroke box in front a selected line takes it on its stroke (it has no fill to show it). The toolbox's state reaches tools through ViewInfo/ToolContext::stroke_in_front. Depends on #572. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/style.rs | 11 +++++++++++ crates/engine/src/tooling.rs | 5 ++++- crates/tools/src/lib.rs | 2 ++ crates/tools/src/snap.rs | 12 +++++++++++- crates/tools/src/tests.rs | 23 ++++++++++++++++++++++- crates/tools/src/xform.rs | 7 ++++++- crates/ui-egui/src/lib.rs | 7 ++++++- 8 files changed, 63 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..ad1576ab3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -138,6 +138,7 @@ Newest first. - **2026-10-12** · The Pen tool continues an open path from either end point, as in InDesign: the cursor shows it over an end point, a click there carries on drawing that path, a drag first reshapes the end point's direction lines, and the other end closes it. Object › Paths › Reverse Path (`path.reverse`); `path.setAnchor`; `path.appendAnchor` and `path.close` take a subpath. No readiness percentage change. - **2026-10-12** · Editing type on a path: the Type tool clicks, drags and double-clicks along the curve (the character under the pointer, not one on the straightened line), a click on the letters themselves hits the frame, and the caret and highlight bend with the path. No readiness percentage change. - **2026-10-12** · Type on a path has start and end brackets, as in InDesign: selected path type shows them across the path, and dragging either with the Selection or Direct Selection tool moves where the text starts or ends (text past the end bracket is overset). `type.pathOptions {end}`; IDML reads and writes EndBracket. No readiness percentage change. +- **2026-10-12** · The Eyedropper puts a colour sampled from an image on the stroke when the toolbox's Stroke box is in front, as in InDesign (a line takes it on its stroke). No readiness percentage change. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index c4d20f099..9f0321560 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -1750,4 +1750,15 @@ mod sample_color_tests { assert_eq!(text.format_after(0).over.stroke.as_deref(), r["swatch"].as_str(), "selected text stroke: {command}"); } } + + #[test] + fn sampling_with_the_stroke_in_front_colours_a_lines_stroke() { + let (mut s, _) = session_with_image(); + let line = s.execute("line.create", &json!({"a": [300, 300], "b": [500, 300]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [line]})).unwrap(); + let r = s.execute("color.sample", &json!({"at": [100, 100], "target": "stroke"})).unwrap(); + let it = s.doc().unwrap().doc.item(ItemId(line)).unwrap().clone(); + assert_eq!(it.stroke.swatch, r["swatch"].as_str().unwrap(), "{r}"); + assert!(it.fill.is_none(), "the fill stays empty"); + } } diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 1bf8db930..28ed3dd33 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -35,11 +35,13 @@ pub struct ViewInfo { pub zoom: f64, pub snap: SnapView, pub unit: Unit, + /// The toolbox's Stroke box is in front (X): colour tools apply to strokes. + pub stroke_in_front: bool, } impl ViewInfo { pub const fn at_zoom(zoom: f64) -> Self { - Self { zoom, snap: SnapView::FACTORY, unit: Unit::Points } + Self { zoom, snap: SnapView::FACTORY, unit: Unit::Points, stroke_in_front: false } } } @@ -93,6 +95,7 @@ impl Session { layer, snap: view.snap, unit: view.unit, + stroke_in_front: view.stroke_in_front, }; Some(f(self.tool.as_mut(), &cx)) } diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index f3de390a4..d8a5c5446 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -205,6 +205,8 @@ pub struct ToolContext<'a> { pub layer: designcraft_doc::LayerId, pub snap: SnapView, pub unit: Unit, + /// The toolbox's Stroke box is in front: sampled colours go to strokes (the Eyedropper). + pub stroke_in_front: bool, } impl ToolContext<'_> { diff --git a/crates/tools/src/snap.rs b/crates/tools/src/snap.rs index 6d5c8f6a5..a7b4dad3b 100644 --- a/crates/tools/src/snap.rs +++ b/crates/tools/src/snap.rs @@ -1473,7 +1473,17 @@ mod tests { } fn ctx_on<'a>(doc: &'a Document, sel: &'a Selection, cache: &'a Cache, layout: &'a CanvasLayout) -> ToolContext<'a> { - ToolContext { doc, selection: sel, cache, layout, zoom: 1.0, layer: doc.default_layer(), snap: SnapView::FACTORY, unit: Unit::Points } + ToolContext { + doc, + selection: sel, + cache, + layout, + zoom: 1.0, + layer: doc.default_layer(), + snap: SnapView::FACTORY, + unit: Unit::Points, + stroke_in_front: false, + } } fn y_req(rect: Rect) -> SnapRequest<'static> { diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 6776902aa..f058e5e03 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -9,7 +9,17 @@ use designcraft_geom::{Point, Rect, Unit}; use super::*; fn ctx<'a>(d: &'a Document, s: &'a Selection, c: &'a Cache, l: &'a CanvasLayout) -> ToolContext<'a> { - ToolContext { doc: d, selection: s, cache: c, layout: l, zoom: 1.0, layer: d.default_layer(), snap: SnapView::OFF, unit: Unit::Points } + ToolContext { + doc: d, + selection: s, + cache: c, + layout: l, + zoom: 1.0, + layer: d.default_layer(), + snap: SnapView::OFF, + unit: Unit::Points, + stroke_in_front: false, + } } #[test] @@ -857,6 +867,17 @@ fn eyedropper_samples_a_placed_image_and_matches_other_objects() { assert_eq!(a, vec![sample(120.0, 130.0)]); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 180.0 + off.x, 180.0 + off.y)); assert_eq!(a, vec![sample(180.0, 180.0)], "through the text frame"); + // With the toolbox's Stroke box in front, the colour goes to the stroke (a line has no fill). + let mut cx_stroke = ctx(&d, &s, &c, &l); + cx_stroke.stroke_in_front = true; + let a = t.pointer(&cx_stroke, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y)); + assert_eq!( + a, + vec![Action::Exec( + "color.sample".into(), + serde_json::json!({"at": [120.0, 130.0], "spread": {"kind": "doc", "index": 0}, "target": "stroke"}) + )] + ); // A filled object is matched as before (here on a locked layer: not reachable, so nothing). assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 320.0 + off.x, 120.0 + off.y)).is_empty()); let mut d2 = d.clone(); diff --git a/crates/tools/src/xform.rs b/crates/tools/src/xform.rs index e0bcd24f3..194ceff05 100644 --- a/crates/tools/src/xform.rs +++ b/crates/tools/src/xform.rs @@ -299,7 +299,12 @@ impl Tool for EyedropperTool { let selected = !cx.selection.items.is_empty() || cx.selection.text.is_some() || cx.selection.cells.is_some(); // A placed image under the pointer is sampled (its layer may be locked: sampling changes nothing). if selected && let Some((sr, at)) = image_under(cx, ev.pos) { - return vec![Action::Exec("color.sample".into(), json!({"at": [at.x, at.y], "spread": sr}))]; + // To the fill or stroke in front in the toolbox, as in InDesign. + let mut p = json!({"at": [at.x, at.y], "spread": sr}); + if cx.stroke_in_front { + p["target"] = json!("stroke"); + } + return vec![Action::Exec("color.sample".into(), p)]; } let Some((_, id)) = cx.hit(ev.pos) else { return vec![] }; if !selected { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc7a3e634..4dd58ebe4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -598,7 +598,12 @@ impl DesignApp { self.pane = pane; } pub fn view_info(&self) -> ViewInfo { - ViewInfo { zoom: self.view().map(|v| v.zoom).unwrap_or(1.0), snap: self.ui.snap_view(), unit: self.ui.units } + ViewInfo { + zoom: self.view().map(|v| v.zoom).unwrap_or(1.0), + snap: self.ui.snap_view(), + unit: self.ui.units, + stroke_in_front: self.ui.stroke_front, + } } pub fn status(&mut self, s: impl Into) {