G5: Pattern brush Key Color eyedropper in Brush Options

Hue Shift colorization can sample key color from canvas art via the
eyedropper next to the swatches, matching Illustrator's brush workflow.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
nrq6993-a11y
2026-10-21 23:38:03 +07:00
committed by bflatastic
co-authored by Cursor
parent cdef620083
commit 6433b6ca3a
5 changed files with 104 additions and 10 deletions
+48 -5
View File
@@ -11,6 +11,9 @@
//! 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
//! {stop}`) and switches back to that tool.
//!
//! Brush Options' Key Color eyedropper sets `brushKeyColor` and `stop`: the next click samples into
//! the open dialog (`brushKeyColor` UI request) and switches back.
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
@@ -26,6 +29,8 @@ use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext};
pub struct EyedropperTool {
/// Sampling for the selected gradient stop: the tool to switch back to afterwards.
stop: Option<String>,
/// Brush Options › Key Color (Hue Shift): sample into the dialog, then [`Self::stop`].
brush_key_color: bool,
}
/// The colour of `paint` at `p` (gradients are sampled along their vector, radial ones honouring
@@ -83,6 +88,22 @@ impl Tool for EyedropperTool {
// A click inside a fill or among characters samples it too, whatever Object and Type
// Object Selection by Path Only say.
let opts = vectorcraft_doc::hit::HitOptions { path_only: false, type_path_only: false, ..cx.hit_options() };
if self.brush_key_color {
let h = hit_test_visible(cx.doc, ev.pos, opts);
let Some(h) = h else { return vec![] };
let src = paint_owner(cx.doc, h.leaf);
let Some(n) = cx.doc.node(src) else { return vec![] };
let image = matches!(n.kind, NodeKind::Image(_));
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 back = self.stop.clone().unwrap_or_else(|| "selection".to_string());
self.brush_key_color = false;
self.stop = None;
return vec![
Action::Dialog("brushKeyColor".into(), json!({ "color": color_json(&c) })),
Action::SwitchTool(back),
];
}
let stop = self.stop.as_ref().zip(cx.gradient_stop);
// Reading a source is independent of its editability. Only Alt's reverse operation
// writes to the clicked object; Shift+Alt appends its appearance to the selection.
@@ -108,12 +129,14 @@ impl Tool for EyedropperTool {
}
fn options(&self) -> Value {
json!({ "stop": self.stop })
json!({ "stop": self.stop, "brushKeyColor": self.brush_key_color })
}
fn set_option(&mut self, key: &str, value: &Value) {
if key == "stop" {
self.stop = value.as_str().map(str::to_string);
match key {
"stop" => self.stop = value.as_str().map(str::to_string),
"brushKeyColor" => self.brush_key_color = value.as_bool().unwrap_or(false),
_ => {}
}
}
@@ -175,7 +198,7 @@ mod tests {
let mut cx = cx(&d, &s, &p);
let mut t = EyedropperTool::default();
t.set_option("stop", &json!("gradient"));
assert_eq!(t.options(), json!({"stop": "gradient"}));
assert_eq!(t.options(), json!({"stop": "gradient", "brushKeyColor": false}));
// Without a selected stop the click copies the appearance as usual.
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0));
assert!(matches!(&a[0], Action::Exec(c, _) if c == "appearance.copyFrom"));
@@ -190,7 +213,27 @@ mod tests {
Action::SwitchTool("gradient".into())
]
);
assert_eq!(t.options(), json!({"stop": null}), "one sample only");
assert_eq!(t.options(), json!({"stop": null, "brushKeyColor": false}), "one sample only");
}
#[test]
fn brush_key_color_samples_into_the_dialog_then_switches_back() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = EyedropperTool::default();
t.set_option("brushKeyColor", &json!(true));
t.set_option("stop", &json!("selection"));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0));
assert_eq!(
a,
vec![
Action::Dialog("brushKeyColor".into(), json!({ "color": color_json(&Color::WHITE) })),
Action::SwitchTool("selection".into())
]
);
assert_eq!(t.options(), json!({ "stop": null, "brushKeyColor": false }));
}
/// The hex of the colour a `paint.sampleColor` action samples.
@@ -28,6 +28,7 @@ use super::brush_options::{clamped, mode_key, stroke_preview};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::widgets;
use crate::VectorcraftApp;
/// Scatter values: (field, suffix), in [`Scatter::RANGES`] order.
const SCATTER: [(&str, &str); 4] = [("size", "%"), ("spacing", "%"), ("scatter", "%"), ("rotation", "°")];
@@ -199,14 +200,14 @@ pub(super) fn params(d: &Dialog) -> Value {
// ---------- body ----------
pub(super) fn body(ui: &mut egui::Ui, d: &mut Dialog) {
pub(super) fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
match d.str("__type").as_str() {
"scatter" => scatter_body(ui, d),
"art" => art_body(ui, d),
_ => pattern_body(ui, d),
}
ui.add_space(6.0);
colorization_body(ui, d);
colorization_body(app, ui, d);
ui.add_space(10.0);
// The brush as OK would make it, on a sample stroke.
let ty = d.str("__type");
@@ -479,7 +480,15 @@ fn tile_doc(art: &Node, w: f64, h: f64) -> Option<Document> {
Some(doc)
}
fn colorization_body(ui: &mut egui::Ui, d: &mut Dialog) {
/// Key Color eyedropper: sample the next click on the art into this dialog's `keyColor`.
fn key_color_eyedropper(app: &mut VectorcraftApp) {
let back = app.session.tool_id();
app.select_tool("eyedropper");
app.session.set_tool_option("brushKeyColor", &json!(true));
app.session.set_tool_option("stop", &json!(back));
}
fn colorization_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
widgets::section_header(ui, tl!("Colorization"));
let methods = [tl!("None"), tl!("Tints"), tl!("Tints and Shades"), tl!("Hue Shift")];
let cur = d.str("colorization");
@@ -513,6 +522,19 @@ fn colorization_body(ui: &mut egui::Ui, d: &mut Dialog) {
d.fields.insert("keyColor".into(), serde_json::to_value(c).unwrap_or_default());
}
}
if widgets::icon_button(ui, "pipette", tl!("Eyedropper: click the art to sample the key color"), false, 22.0).clicked() {
key_color_eyedropper(app);
}
});
});
}
/// Apply a sampled colour to the open Brush Options dialog (`brushKeyColor` tool request).
pub(super) fn apply_key_color_sample(app: &mut VectorcraftApp, color: &Value) {
let Some(d) = app.ui.dialog.as_mut().filter(|d| d.kind == super::brush_options::KIND) else { return };
if d.str("colorization") != "hueShift" {
return;
}
let Some(c) = vectorcraft_engine::cmd::color_value(color) else { return };
d.fields.insert("keyColor".into(), serde_json::to_value(c).unwrap_or_default());
}
+20 -1
View File
@@ -242,7 +242,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
match d.str("__type").as_str() {
"bristle" => bristle_body(app, ui, d),
"calligraphic" => calligraphic_body(ui, d),
_ => brush_art_options::body(ui, d),
_ => brush_art_options::body(app, ui, d),
}
false
}
@@ -521,6 +521,25 @@ mod tests {
assert_eq!((d.str("sizeMode"), d.str("spacingMode"), d.str("colorization")), ("fixed".into(), "pressure".into(), "hueShift".into()));
}
#[test]
fn brush_key_color_eyedropper_updates_the_open_dialog() {
use vectorcraft_tools::{PointerEvent, PointerKind};
let mut app = app();
app.run("paint.setFill", json!({"color": "#336699"})).unwrap();
app.run("shape.rectangle", json!({"x": 40, "y": 40, "width": 80, "height": 60})).unwrap();
app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap();
set(&mut app, json!({"colorization": "hueShift"}));
let back = app.session.tool_id();
app.select_tool("eyedropper");
app.session.set_tool_option("brushKeyColor", &json!(true));
app.session.set_tool_option("stop", &json!(back));
let view = app.view_info();
crate::canvas::dispatch(&mut app, &PointerEvent::new(PointerKind::Down, 80.0, 70.0), view);
let key = app.ui.dialog.as_ref().unwrap().fields.get("keyColor").cloned().unwrap();
assert_eq!(vectorcraft_engine::cmd::color_value(&key).unwrap().to_hex(), "#336699");
assert_eq!(app.session.tool_id(), back);
}
#[test]
fn art_options_set_width_scale_direction_flips_and_colorization() {
let mut app = app();
+7
View File
@@ -68,6 +68,13 @@ pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) {
}
// Double-clicking a stop on the gradient annotator: its popover, next to the stop's chip.
"gradientStop" => Dialog::new(kind, json!({"index": p.get("index").cloned().unwrap_or(json!(0)), "x": x, "y": y, "tab": "color"})),
// Brush Options › Key Color eyedropper: update the open dialog, do not replace it.
"brushKeyColor" => {
if let Some(c) = p.get("color") {
super::brush_art_options::apply_key_color_sample(app, c);
}
return;
}
// Double-clicking a width point with the Width tool: Width Point Edit.
super::width_point::KIND => {
if let Err(e) = super::width_point::open(app, &p) {
+4 -1
View File
@@ -1119,6 +1119,9 @@ mod tests {
let texts = Panel::new().frame(&mut app, vec![], Modifiers::NONE);
assert!(texts.iter().any(|(t, _)| t == "#FFFFFF"), "the stop's colour row: {texts:?}");
stop_eyedropper(&mut app);
assert_eq!((app.session.tool_id(), app.session.tool_options()), ("eyedropper", json!({"stop": "gradient"})));
assert_eq!(
(app.session.tool_id(), app.session.tool_options()),
("eyedropper", json!({"stop": "gradient", "brushKeyColor": false}))
);
}
}