M3.52: Eyedropper Options with Picks Up / Applies trees, Alt and Shift+Alt modes, image sampling

Double-clicking the Eyedropper opens Eyedropper Options (dialog
eyedropperOptions): raster sample size (point, 3x3, 5x5) and Picks Up /
Applies trees (transparency; focal fill colour, transparency, overprint;
focal stroke colour, transparency, overprint, weight, cap, join, miter,
dash; character and paragraph attributes). The options live in the
preferences (Prefs::eyedropper, eyedropper.setOptions, prefs key
eyedropper) and replace the earlier appearance/transparency switches.

appearance.copyFrom takes pickUp/apply overrides, reverse (Alt-click:
the selection's attributes onto the clicked object) and append
(Shift+Alt-click: add the source's fills and strokes). Type copies font,
size and paragraph attributes to type. Clicking an image samples its
pixels; sampled colours keep their colour model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 02:08:10 +07:00
co-authored by Claude Opus 5.5
parent 2e6662c93b
commit 303689c8f4
21 changed files with 930 additions and 116 deletions
+4
View File
@@ -168,6 +168,9 @@ pub struct ToolContext<'a> {
/// The selected freeform gradient point (`paint.freeform.selectPoint`), marked on the
/// freeform annotator.
pub freeform_point: Option<usize>,
/// Eyedropper Options' raster sample size: the pixels square averaged when the Eyedropper
/// samples an image (1, 3 or 5).
pub raster_sample: u32,
}
impl ToolContext<'_> {
@@ -353,6 +356,7 @@ pub(crate) mod testutil {
appearance_item: None,
constrain_angle: 0.0,
freeform_point: None,
raster_sample: 1,
}
}
}
+104 -18
View File
@@ -1,8 +1,11 @@
//! Eyedropper tool (I).
//!
//! Click an object: copy its appearance (fill, stroke, weight, opacity) to the selection and the
//! paint defaults (`appearance.copyFrom`). Shift-click: sample only the colour under the cursor into
//! the active fill/stroke (`paint.sampleColor`).
//! Click an object: copy its attributes (what Eyedropper Options pick up and apply) to the
//! selection and the paint defaults (`appearance.copyFrom`); Alt-click: the selection's attributes
//! onto the clicked object (`reverse`); Shift+Alt-click: add the clicked object's appearance to the
//! selection's (`append`). Shift-click: sample only the colour under the cursor into the active
//! fill/stroke (`paint.sampleColor`, keeping the colour's model). Clicking an image samples its
//! pixels (averaged over the options' raster sample size).
//!
//! The Gradient panel's eyedropper sets the `stop` option (the tool to return to): the next click
//! samples the colour under the cursor into the selected gradient stop (`paint.sampleColor
@@ -11,9 +14,11 @@
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::hit::{HitKind, hit_test};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::Point;
use super::paint_owner;
use crate::params::color_json;
use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext};
#[derive(Default)]
@@ -36,6 +41,26 @@ pub fn paint_color_at(paint: &Paint, bounds: Option<vectorcraft_geom::Rect>, p:
}
}
/// The colour of `n`'s fill (or its stroke, when the stroke was hit or there is no fill) at `p`,
/// sampled in the paint's own space (text space for type runs) and box.
fn paint_sample(n: &Node, on_stroke: bool, p: Point) -> Option<Color> {
let fill_none = n.proxy_paint(false, None).is_none_or(|(p, ..)| p.is_none());
let (paint, to_doc, bounds) = n.proxy_paint(on_stroke || fill_none, None)?;
let p = if to_doc.determinant().abs() > 1e-12 { to_doc.inverse() * p } else { p };
paint_color_at(paint, Some(bounds), p)
}
/// The colour of image `n`'s pixels at `p`, averaged over the raster sample size.
fn image_color_at(cx: &ToolContext, n: &Node, p: Point) -> Option<Color> {
let NodeKind::Image(im) = &n.kind else { return None };
if im.xf.determinant().abs() < 1e-12 {
return None;
}
let px = im.xf.inverse() * p;
let [r, g, b, _] = cx.doc.images.get(&im.key)?.sample(px.x, px.y, cx.raster_sample)?;
Some(Color::rgb8(r, g, b))
}
impl Tool for EyedropperTool {
fn id(&self) -> &'static str {
"eyedropper"
@@ -50,18 +75,21 @@ impl Tool for EyedropperTool {
let src = paint_owner(cx.doc, h.leaf);
let Some(n) = cx.doc.node(src) else { return vec![] };
let stop = self.stop.as_ref().zip(cx.gradient_stop);
if ev.mods.shift || stop.is_some() {
let fill_none = n.proxy_paint(false, None).is_none_or(|(p, ..)| p.is_none());
let Some((paint, to_doc, bounds)) = n.proxy_paint(h.kind == HitKind::Stroke || fill_none, None) else { return vec![] };
// Sample in the paint's own space (text space for type runs) and box.
let p = if to_doc.determinant().abs() > 1e-12 { to_doc.inverse() * ev.pos } else { ev.pos };
let Some(c) = paint_color_at(paint, Some(bounds), p) else { return vec![] };
let Some((back, i)) = stop else { return vec![Action::Exec("paint.sampleColor".into(), json!({ "color": c.to_hex() }))] };
let out = vec![Action::Exec("paint.sampleColor".into(), json!({ "color": c.to_hex(), "stop": i })), Action::SwitchTool(back.clone())];
self.stop = None;
return out;
if ev.mods.alt && stop.is_none() {
let how = if ev.mods.shift { "append" } else { "reverse" };
return vec![Action::Exec("appearance.copyFrom".into(), json!({ "source": src.0, how: true }))];
}
vec![Action::Exec("appearance.copyFrom".into(), json!({ "source": src.0 }))]
let image = matches!(n.kind, NodeKind::Image(_));
if !(ev.mods.shift || stop.is_some() || image) {
return vec![Action::Exec("appearance.copyFrom".into(), json!({ "source": src.0 }))];
}
let sampled = if image { image_color_at(cx, n, ev.pos) } else { paint_sample(n, h.kind == HitKind::Stroke, ev.pos) };
let Some(c) = sampled else { return vec![] };
let color = color_json(&c);
let Some((back, i)) = stop else { return vec![Action::Exec("paint.sampleColor".into(), json!({ "color": color }))] };
let out = vec![Action::Exec("paint.sampleColor".into(), json!({ "color": color, "stop": i })), Action::SwitchTool(back.clone())];
self.stop = None;
out
}
fn options(&self) -> Value {
@@ -96,10 +124,10 @@ mod tests {
assert_eq!(a, vec![Action::Exec("appearance.copyFrom".into(), json!({"source": id.0}))]);
let shift = Mods { shift: true, ..Default::default() };
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0).with_mods(shift));
assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": "#ffffff"}))]);
assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": color_json(&Color::WHITE)}))]);
// On the stroke edge → the stroke colour.
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 150.0).with_mods(shift));
assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": "#000000"}))]);
assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": color_json(&Color::BLACK)}))]);
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 400.0)).is_empty());
}
@@ -119,10 +147,68 @@ mod tests {
// Empty canvas: still waiting.
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 400.0)).is_empty());
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 150.0));
assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": "#000000", "stop": 1})), Action::SwitchTool("gradient".into())]);
assert_eq!(
a,
vec![
Action::Exec("paint.sampleColor".into(), json!({"color": color_json(&Color::BLACK), "stop": 1})),
Action::SwitchTool("gradient".into())
]
);
assert_eq!(t.options(), json!({"stop": null}), "one sample only");
}
/// The hex of the colour a `paint.sampleColor` action samples.
fn sampled_hex(a: &[Action]) -> String {
let [Action::Exec(c, p)] = a else { panic!("one action: {a:?}") };
assert_eq!(c, "paint.sampleColor");
let v: Vec<f32> = serde_json::from_value(p["color"].clone()).unwrap();
Color::rgb(v[0], v[1], v[2]).to_hex()
}
#[test]
fn alt_sends_reverse_and_shift_alt_append() {
let (d, id) = doc_with_rect();
let (s, p) = (Selection::default(), paint());
let mut t = EyedropperTool::default();
let at = |m: Mods| PointerEvent::new(PointerKind::Down, 150.0, 150.0).with_mods(m);
let alt = Mods { alt: true, ..Default::default() };
assert_eq!(t.pointer(&cx(&d, &s, &p), &at(alt)), vec![Action::Exec("appearance.copyFrom".into(), json!({"source": id.0, "reverse": true}))]);
let both = Mods { alt: true, shift: true, ..Default::default() };
assert_eq!(t.pointer(&cx(&d, &s, &p), &at(both)), vec![Action::Exec("appearance.copyFrom".into(), json!({"source": id.0, "append": true}))]);
}
#[test]
fn sampling_keeps_the_colour_model() {
let (mut d, id) = doc_with_rect();
let cmyk = Color::cmyk(0.1, 0.2, 0.3, 0.4);
d.node_mut(id).unwrap().appearance.set_fill(Paint::solid(cmyk));
let (s, p) = (Selection::default(), paint());
let shift = Mods { shift: true, ..Default::default() };
let a = EyedropperTool::default().pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 150.0, 150.0).with_mods(shift));
assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": color_json(&cmyk)}))]);
}
#[test]
fn clicking_an_image_samples_its_pixels() {
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let id = d.alloc_id();
// Three pixels, red, green, blue, 10 pt each.
let img = image::RgbaImage::from_raw(3, 1, [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255]].concat()).unwrap();
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
d.images.insert("px".into(), ImageBlob::png(png));
let im = ImageObject { key: "px".into(), width: 3, height: 1, xf: vectorcraft_geom::Affine::scale(10.0), link: None };
d.insert(Some(l), 0, Node::new(id, NodeKind::Image(im))).unwrap();
let (s, p) = (Selection::default(), paint());
let mut cx = cx(&d, &s, &p);
let click = PointerEvent::new(PointerKind::Down, 15.0, 5.0);
assert_eq!(sampled_hex(&EyedropperTool::default().pointer(&cx, &click)), "#00ff00");
cx.raster_sample = 3;
assert_eq!(sampled_hex(&EyedropperTool::default().pointer(&cx, &click)), "#555555", "3 x 3 average");
}
#[test]
fn gradient_sampled_along_vector() {
let g = vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default());
@@ -150,7 +236,7 @@ mod tests {
let shift = Mods { shift: true, ..Default::default() };
// 80 % along the baseline (text space (80, -5)).
let a = EyedropperTool::default().pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 105.0, 180.0).with_mods(shift));
assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": "#333333"}))]);
assert_eq!(sampled_hex(&a), "#333333");
}
#[test]