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-12 23:48:11 +02:00
co-authored by Claude Opus 5.5
parent 9ab226d741
commit 142e5d91e0
9 changed files with 68 additions and 5 deletions
+1
View File
@@ -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.
+11
View File
@@ -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");
}
}
+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
@@ -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<'_> {
+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]
@@ -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();
+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 {
+9 -1
View File
@@ -491,6 +491,8 @@ pub struct DesignApp {
pub color_applied: bool,
pub perf: Perf,
pub synthetic: Vec<egui::Event>,
/// 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<designcraft_doc::StoryId>,
control_rx: Option<Receiver<ControlRequest>>,
@@ -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<String>) {
+2
View File
@@ -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"}));