mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-27 03:36:51 -06:00
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:
co-authored by
Claude Opus 5.5
parent
9ab226d741
commit
142e5d91e0
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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<'_> {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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"}));
|
||||
|
||||
Reference in New Issue
Block a user