diff --git a/ROADMAP.md b/ROADMAP.md index 02437c82d..16055c133 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **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 b09e78503..042fdd74b 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -1731,4 +1731,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 247494f84..5959d8258 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -200,6 +200,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 0d3981904..7e0ba2617 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] @@ -750,6 +760,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 e0fa1dff0..06c2f8a03 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -491,6 +491,8 @@ pub struct DesignApp { pub color_applied: bool, pub perf: Perf, pub synthetic: Vec, + /// The toolbox's Stroke box is in front (the toolbox keeps it; tools read it). + pub stroke_in_front: bool, /// Story open in the Story Editor. pub story_editor: Option, control_rx: Option>, @@ -545,6 +547,7 @@ impl DesignApp { color_applied: false, perf: Perf::default(), synthetic: vec![], + stroke_in_front: false, story_editor: None, control_rx: None, pending_shots: vec![], @@ -593,7 +596,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.stroke_in_front, + } } pub fn status(&mut self, s: impl Into) { diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 5a5e1f08a..ba15711a0 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -199,6 +199,7 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { let stroke_front_id = egui::Id::new("proxy_stroke_front"); let text_id = egui::Id::new("proxy_text"); let stroke_front: bool = ui.data(|d| d.get_temp(stroke_front_id)).unwrap_or(false); + app.stroke_in_front = stroke_front; let text_mode: bool = ui.data(|d| d.get_temp(text_id)).unwrap_or(false); let (fill_sw, stroke_sw) = match &info { Some(i) => (i.fill.clone(), i.stroke.clone()), @@ -279,6 +280,7 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { let front = if stroke_front { !fresp.clicked() } else { sresp.clicked() && !fill_r.contains(sresp.interact_pointer_pos().unwrap_or_default()) }; ui.data_mut(|d| d.insert_temp(stroke_front_id, front)); + app.stroke_in_front = front; } if fresp.double_clicked() || sresp.double_clicked() { let _ = app.run("window.panel", json!({"panel": "swatches"}));