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 <noreply@anthropic.com>
This commit is contained in:
moppeeer
2026-10-13 09:56:25 +02:00
co-authored by Claude Opus 5.5
parent e862e0a613
commit 7192c2e136
8 changed files with 63 additions and 5 deletions
+1
View File
@@ -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.
+11
View File
@@ -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");
}
}
+4 -1
View File
@@ -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))
}
+2
View File
@@ -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<'_> {
+11 -1
View File
@@ -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> {
+22 -1
View File
@@ -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();
+6 -1
View File
@@ -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 {
+6 -1
View File
@@ -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<String>) {