diff --git a/Cargo.lock b/Cargo.lock index b6856aa3..0d73e0f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4301,6 +4301,7 @@ dependencies = [ name = "vectorcraft-tools" version = "0.1.1" dependencies = [ + "image", "kurbo 0.13.1", "serde", "serde_json", diff --git a/crates/doc/src/pixels.rs b/crates/doc/src/pixels.rs index 242dc243..d02edf4d 100644 --- a/crates/doc/src/pixels.rs +++ b/crates/doc/src/pixels.rs @@ -42,6 +42,48 @@ impl ImageBlob { } } +impl ImageBlob { + /// The colour around pixel (`x`, `y`) (pixel space): the alpha-weighted average of the `size` × + /// `size` pixels centred on it, clipped to the image, as straight RGBA. `None` outside the + /// image, where every pixel is transparent, or when the image can't be decoded. The last image + /// decoded is kept for the next sample (the Eyedropper samples one image click after click). + pub fn sample(&self, x: f64, y: f64, size: u32) -> Option<[u8; 4]> { + thread_local! { + static LAST: std::cell::RefCell)>> = const { std::cell::RefCell::new(None) }; + } + let key = self.content_key(); + let img = LAST.with_borrow_mut(|last| match last { + Some((k, img)) if *k == key => Some(img.clone()), + _ => { + let img = Arc::new(image::load_from_memory(&self.bytes).ok()?.to_rgba8()); + *last = Some((key, img.clone())); + Some(img) + } + })?; + let (w, h) = (img.width() as i64, img.height() as i64); + let (cx, cy) = (x.floor() as i64, y.floor() as i64); + if !(0..w).contains(&cx) || !(0..h).contains(&cy) { + return None; + } + let r = size.max(1) as i64 / 2; + let (mut sum, mut alpha, mut n) = ([0u64; 3], 0u64, 0u64); + for py in (cy - r).max(0)..=(cy + r).min(h - 1) { + for px in (cx - r).max(0)..=(cx + r).min(w - 1) { + let [pr, pg, pb, pa] = img.get_pixel(px as u32, py as u32).0; + let a = pa as u64; + sum = [sum[0] + pr as u64 * a, sum[1] + pg as u64 * a, sum[2] + pb as u64 * a]; + alpha += a; + n += 1; + } + } + if alpha == 0 { + return None; + } + let c = |v: u64| ((v + alpha / 2) / alpha) as u8; + Some([c(sum[0]), c(sum[1]), c(sum[2]), ((alpha + n / 2) / n) as u8]) + } +} + #[cfg(test)] mod tests { use super::*; @@ -57,6 +99,17 @@ mod tests { image::load_from_memory(&b.bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect() } + #[test] + fn samples_a_pixel_or_the_average_around_it() { + let b = blob(&[[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [9, 9, 9, 0]], 4, 1); + assert_eq!(b.sample(1.5, 0.5, 1), Some([0, 255, 0, 255])); + assert_eq!(b.sample(1.5, 0.5, 3), Some([85, 85, 85, 255]), "red, green and blue averaged"); + assert_eq!(b.sample(3.2, 0.5, 3), Some([0, 0, 255, 128]), "transparent pixels don't count towards the colour"); + assert_eq!(b.sample(3.2, 0.5, 1), None, "all transparent"); + assert_eq!(b.sample(-0.5, 0.5, 5), None, "outside"); + assert_eq!(b.sample(0.0, 0.0, 5), Some([85, 85, 85, 255]), "clipped to the image"); + } + #[test] fn maps_each_colour_once_and_keeps_alpha() { let b = blob(&[[255, 0, 0, 255], [255, 0, 0, 128], [0, 0, 255, 255], [10, 20, 30, 0]], 2, 2); diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 6d86eec3..bc68407b 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -19,12 +19,14 @@ use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, NodeKind, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, FillLayer, Node, NodeKind, ParaStyle, StrokeLayer}; +use vectorcraft_geom::Rect; use super::edit::selected_roots; use super::opacitymask::percent; use super::paint::paint_from; use super::*; +use super::{AppearanceAttrs, EyedropperAttrs, FillAttrs, StrokeAttrs}; use crate::EngineError; pub fn specs() -> Vec { @@ -115,7 +117,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?, appearance?: bool, transparency?: bool (default: eyedropper.setOptions)} copy fill, stroke, weight and effects (appearance) and opacity and blend (transparency) from `source` to ids (default: selection), the appearance also to the paint defaults; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art)", + "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: the selection), the fill, stroke and weight also to the paint defaults; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead; `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}", has_doc, copy_from ), @@ -630,68 +632,192 @@ fn target_contents(s: &mut Session, p: &Value) -> Result { } fn copy_from(s: &mut Session, p: &Value) -> Result { - let src_id = id_param(p, "source").ok_or_else(|| bad("appearance.copyFrom", "missing `source` id"))?; - let src = s.doc()?.doc.node(src_id).cloned().ok_or(EngineError::NoNode(src_id))?; - // The appearance and the box its placed gradients are relative to (type: its first run's - // paints, in text space). - let (appearance, src_box) = match &src.kind { - NodeKind::Text(t) if !t.runs.is_empty() => { - let r = &t.runs[0]; - (r.style.basic_appearance(), Some(t.local_bounds())) - } - _ => (src.appearance.clone(), src.geometric_bounds()), - }; - // `appearance` placed on an object whose box (in its paint space) is `to`. - let placed = |to: Option| { - let mut ap = appearance.clone(); - if let (Some(f), Some(t)) = (src_box, to) { - ap.rebase_gradients(f, t); - } - ap - }; - let opts = s.eyedropper.with(p); - if opts.appearance { - s.paint.fill = super::gradient::unplaced(&appearance.fill_paint()); - s.paint.stroke = super::gradient::unplaced(&appearance.stroke_paint()); - s.remember_paint(&s.paint.fill.clone()); - if appearance.stroke().is_some() { - s.paint.stroke_width = appearance.stroke_width(); - } - } - let ids = match ids_param(p, "ids") { + const C: &str = "appearance.copyFrom"; + let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?; + let (reverse, append) = (bool_or(p, "reverse", false), bool_or(p, "append", false)); + let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?; + let selected = match ids_param(p, "ids") { Some(v) => v, None => selected_roots(s)?, }; - let mut targets = leaf_targets(s, &ids)?; + // Reversed, the first selected object's attributes go onto the clicked object. + let (src_id, mut targets) = if reverse { + let first = leaf_targets(s, &selected)?.into_iter().next().ok_or_else(|| bad(C, "select the object to copy from"))?; + (first, leaf_targets(s, &[clicked])?) + } else { + (clicked, leaf_targets(s, &selected)?) + }; targets.retain(|id| *id != src_id); - if targets.is_empty() || !(opts.appearance || opts.transparency) { + let picked = Picked::of(s.doc()?.doc.node(src_id).ok_or(EngineError::NoNode(src_id))?); + if !reverse { + picked.to_defaults(s, &attrs.appearance); + } + if targets.is_empty() || attrs.is_empty() { return Ok(json!({ "ids": [] })); } - let (opacity, blend) = (src.opacity, src.blend); s.edit("Eyedropper", |d, _| { for id in &targets { - let Some(n) = d.node_mut(*id) else { continue }; - if opts.transparency { - n.opacity = opacity; - n.blend = blend; + if let Some(n) = d.node_mut(*id) { + picked.apply(n, &attrs, append); } - if !opts.appearance { - continue; - } - if let NodeKind::Text(t) = &mut n.kind { - let ap = placed(Some(t.local_bounds())); - // Characters without a painted stroke get no weight. - let stroke = - ap.stroke().map_or_else(|| StrokeLayer::new(Paint::None, 0.0), |s| StrokeLayer { width: ap.stroke_width(), ..s.clone() }); - for r in &mut t.runs { - r.style.fill = ap.fill_paint(); - r.style.set_stroke_layer(&stroke); - } - continue; - } - n.appearance = placed(n.geometric_bounds()); } Ok(()) })?; Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::>() })) } + +/// What the Eyedropper picks up from an object. +struct Picked { + /// The appearance stack (type: its first run's fill and stroke as a basic appearance). + appearance: Appearance, + /// The box (in the source's paint space) its placed gradients are relative to. + bounds: Option, + opacity: f32, + blend: BlendMode, + /// Type's first character style and its paragraph attributes. + text: Option<(CharStyle, ParaStyle)>, +} + +impl Picked { + fn of(n: &Node) -> Self { + let (appearance, bounds, text) = match &n.kind { + NodeKind::Text(t) if !t.runs.is_empty() => { + let st = &t.runs[0].style; + (st.basic_appearance(), Some(t.local_bounds()), Some((st.clone(), t.para.clone()))) + } + _ => (n.appearance.clone(), n.geometric_bounds(), None), + }; + Self { appearance, bounds, opacity: n.opacity, blend: n.blend, text } + } + + /// The appearance placed on an object whose box (in its paint space) is `to`: placed gradients + /// land at the same place relative to it. + fn placed(&self, to: Option) -> Appearance { + let mut ap = self.appearance.clone(); + if let (Some(f), Some(t)) = (self.bounds, to) { + ap.rebase_gradients(f, t); + } + ap + } + + /// The paint defaults for new art take the picked-up fill, stroke and weight. + fn to_defaults(&self, s: &mut Session, a: &AppearanceAttrs) { + if a.fill.color { + s.paint.fill = super::gradient::unplaced(&self.appearance.fill_paint()); + s.remember_paint(&s.paint.fill.clone()); + } + if a.stroke.color { + s.paint.stroke = super::gradient::unplaced(&self.appearance.stroke_paint()); + } + if a.stroke.weight && self.appearance.stroke().is_some() { + s.paint.stroke_width = self.appearance.stroke_width(); + } + } + + /// Apply attributes `a` to `n`. `append` adds the fills and strokes (and effects) on top of its + /// stack instead. Type takes the paints on its characters, and character and paragraph + /// attributes from type. + fn apply(&self, n: &mut Node, a: &EyedropperAttrs, append: bool) { + let ap = &a.appearance; + if ap.transparency { + n.opacity = self.opacity; + n.blend = self.blend; + } + if append { + let placed = self.placed(n.geometric_bounds()); + n.appearance.items.extend(placed.items.into_iter().filter(|it| if it.is_fill() { ap.fill.color } else { ap.stroke.color })); + n.appearance.effects.extend(placed.effects); + return; + } + if let NodeKind::Text(t) = &mut n.kind { + let placed = self.placed(Some(t.local_bounds())); + let text = self.text.as_ref(); + for r in &mut t.runs { + if let Some((cs, _)) = text.filter(|_| a.character) { + // The character attributes; the paints follow the appearance attributes. + let st = &mut r.style; + *st = CharStyle { + fill: std::mem::take(&mut st.fill), + stroke: std::mem::take(&mut st.stroke), + stroke_width: st.stroke_width, + overprint_fill: st.overprint_fill, + overprint_stroke: st.overprint_stroke, + ..cs.clone() + }; + } + run_attrs(&mut r.style, &placed, ap); + } + if let Some((_, para)) = text.filter(|_| a.paragraph) { + t.para = para.clone(); + } + if text.is_some() && a.character { + super::typecmd::refresh_bounds(t); + } + return; + } + let placed = self.placed(n.geometric_bounds()); + if ap.fill == FillAttrs::ALL && ap.stroke == StrokeAttrs::ALL { + n.appearance = placed; + } else { + focal_attrs(&mut n.appearance, &placed, ap); + } + } +} + +/// The focal (topmost) fill and stroke attributes `a` of `from` onto `to`. Taking a colour creates +/// a missing fill or stroke. +fn focal_attrs(to: &mut Appearance, from: &Appearance, a: &AppearanceAttrs) { + if a.fill.color { + to.set_fill(from.fill_paint()); + } + if let (Some(t), Some(f)) = (to.fill_mut(), from.fill()) { + if a.fill.transparency { + (t.opacity, t.blend) = (f.opacity, f.blend); + } + if a.fill.overprint { + t.overprint = f.overprint; + } + } + if a.stroke.color { + to.set_stroke(from.stroke_paint()); + } + if let (Some(t), Some(k)) = (to.stroke_mut(), from.stroke()) { + let s = &a.stroke; + if s.transparency { + (t.opacity, t.blend) = (k.opacity, k.blend); + } + if s.overprint { + t.overprint = k.overprint; + } + if s.weight { + t.width = k.width; + } + if s.cap { + t.cap = k.cap; + } + if s.join { + t.join = k.join; + } + if s.miter { + t.miter_limit = k.miter_limit; + } + if s.dash { + t.dash = k.dash.clone(); + } + } +} + +/// The fill and stroke attributes `a` of `from` onto a type run's characters (their stroke takes +/// what characters have: paint, weight, cap, join, miter limit, dashes and overprint). Characters +/// without a painted stroke get no weight, a painted one at least 1 pt. +fn run_attrs(st: &mut CharStyle, from: &Appearance, a: &AppearanceAttrs) { + let mut ap = st.basic_appearance(); + focal_attrs(&mut ap, from, a); + let (Some(f), Some(k)) = (ap.fill(), ap.stroke()) else { return }; + (st.fill, st.overprint_fill, st.overprint_stroke) = (f.paint.clone(), f.overprint, k.overprint); + let width = if a.stroke.color { ap.stroke_width() } else { k.width }; + st.set_stroke_layer(&StrokeLayer { width, ..k.clone() }); + if !st.stroke.is_none() { + super::gradient::run_stroke_weight(st); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 8c451e25..2dc35ea1 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -355,8 +355,8 @@ pub(crate) fn parse_range(s: &str, count: usize) -> std::result::Result Option<&'static PrefSpec> { PREF_SPECS.iter().find(|s| s.key == key) } +/// Preferences kept as one object with a command of their own instead of [`PREF_SPECS`] rows (they +/// aren't in the Preferences dialog): the Eyedropper Options (`eyedropper.setOptions`). `prefs.get` +/// and `prefs.set` take them by key (a partial object updates what it names) and `prefs.reset` +/// without a category resets them. +pub const PREF_GROUPS: &[&str] = &["eyedropper"]; + +/// Validate a value for preference group `key` against `current`. +fn validate_group(key: &str, current: &Value, v: &Value) -> std::result::Result { + match key { + "eyedropper" => { + let cur: super::EyedropperOptions = serde_json::from_value(current.clone()).unwrap_or_default(); + cur.merged(v).map(|o| json!(o)) + } + _ => Err(format!("unknown preference `{key}`")), + } +} + /// Validate (and normalize) a value for `key`. Numbers may be given as strings; booleans as /// "true"/"false"; choices by value or label (case-insensitive). pub fn validate(key: &str, v: &Value) -> std::result::Result { @@ -354,7 +371,7 @@ impl Prefs { pub fn set_values(&mut self, values: &Map) -> std::result::Result<(), String> { let mut obj = self.to_json(); for (k, v) in values { - obj[k.as_str()] = validate(k, v)?; + obj[k.as_str()] = if PREF_GROUPS.contains(&k.as_str()) { validate_group(k, &obj[k.as_str()], v)? } else { validate(k, v)? }; } *self = serde_json::from_value(obj).map_err(|e| e.to_string())?; Ok(()) @@ -367,6 +384,11 @@ impl Prefs { for sp in PREF_SPECS.iter().filter(|s| category.is_none_or(|c| c.eq_ignore_ascii_case(s.category))) { obj[sp.key] = d[sp.key].clone(); } + if category.is_none() { + for g in PREF_GROUPS { + obj[*g] = d[*g].clone(); + } + } if let Ok(p) = serde_json::from_value(obj) { *self = p; } @@ -404,13 +426,13 @@ impl Session { pub fn specs() -> Vec { vec![ - cmd!(query "prefs.get", "Get Preferences", [], None, "{key?} → the value of one preference, or {prefs: {…all…}}", always, get), + cmd!(query "prefs.get", "Get Preferences", [], None, "{key?} → the value of one preference (or preference group: eyedropper), or {prefs: {…all…}}", always, get), cmd!( query "prefs.set", "Set Preference", [], None, - "{key, value} or {values: {key: value, …}} set preferences (validated; see prefs.list)", + "{key, value} or {values: {key: value, …}} set preferences (validated; see prefs.list; the eyedropper group takes a partial object, as eyedropper.setOptions)", always, set ), @@ -422,7 +444,7 @@ pub fn specs() -> Vec { fn get(s: &mut Session, p: &Value) -> Result { let all = s.prefs.to_json(); match str_param(p, "key") { - Some(k) if spec(k).is_some() => Ok(all[k].clone()), + Some(k) if spec(k).is_some() || PREF_GROUPS.contains(&k) => Ok(all[k].clone()), Some(k) => Err(bad("prefs.get", format!("unknown preference `{k}`"))), None => Ok(json!({ "prefs": all })), } diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index 5ca14a9f..1fd28221 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Eyedropper Options", [], None, - "{appearance?: bool (fill, stroke and effects), transparency?: bool (opacity and blend mode)} what the Eyedropper copies (appearance.copyFrom); {} reads them → {appearance, transparency}", + "{sampleSize?: 1|3|5 (pixels square averaged when sampling an image: point, 3×3, 5×5), pickUp?, apply?: {appearance?: {transparency?, fill?: {color?, transparency?, overprint?}, stroke?: {color?, transparency?, overprint?, weight?, cap?, join?, miter?, dash?}}, character?, paragraph?} (bools; a bool for a branch sets all of it)} Eyedropper Options, kept in the preferences: what appearance.copyFrom picks up from the clicked object and applies (only attributes in both trees are copied; every fill and stroke attribute copies the whole appearance stack); {} reads them → the options", always, eyedropper_options ), @@ -171,33 +171,173 @@ fn distort(s: &mut Session, p: &Value) -> Result { // ---------- eyedropper ---------- -/// What the Eyedropper copies from the object it clicks (`appearance.copyFrom`). +/// The focal (topmost) fill's attributes the Eyedropper picks up or applies. #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct EyedropperOptions { - /// The fill, stroke and effects (the appearance stack; type: its run paints). - pub appearance: bool, - /// The opacity and blend mode. +#[serde(default)] +pub struct FillAttrs { + pub color: bool, + /// Its own opacity and blend mode. pub transparency: bool, + pub overprint: bool, +} + +impl FillAttrs { + pub const ALL: Self = Self { color: true, transparency: true, overprint: true }; +} + +impl Default for FillAttrs { + fn default() -> Self { + Self::ALL + } +} + +/// The focal (topmost) stroke's attributes the Eyedropper picks up or applies. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(default)] +pub struct StrokeAttrs { + pub color: bool, + /// Its own opacity and blend mode. + pub transparency: bool, + pub overprint: bool, + pub weight: bool, + pub cap: bool, + pub join: bool, + /// The miter limit. + pub miter: bool, + /// The dash pattern. + pub dash: bool, +} + +impl StrokeAttrs { + pub const ALL: Self = Self { color: true, transparency: true, overprint: true, weight: true, cap: true, join: true, miter: true, dash: true }; +} + +impl Default for StrokeAttrs { + fn default() -> Self { + Self::ALL + } +} + +/// Appearance attributes: the object's transparency (opacity and blend mode) and its focal fill and +/// stroke. With every fill and stroke attribute, the whole appearance stack (every fill and stroke, +/// with their effects, and the object's effects) is copied. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(default)] +pub struct AppearanceAttrs { + pub transparency: bool, + pub fill: FillAttrs, + pub stroke: StrokeAttrs, +} + +impl Default for AppearanceAttrs { + fn default() -> Self { + Self { transparency: true, fill: FillAttrs::ALL, stroke: StrokeAttrs::ALL } + } +} + +/// One of the Eyedropper Options' trees: what it picks up, or what it applies. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(default)] +pub struct EyedropperAttrs { + pub appearance: AppearanceAttrs, + /// Type's character attributes (font, size, leading, tracking…; its paints follow `appearance`). + pub character: bool, + /// Type's paragraph attributes. + pub paragraph: bool, +} + +impl Default for EyedropperAttrs { + fn default() -> Self { + Self { appearance: Default::default(), character: true, paragraph: true } + } +} + +impl EyedropperAttrs { + /// No attribute at all. + pub fn is_empty(&self) -> bool { + fn any(v: &Value) -> bool { + match v { + Value::Bool(b) => *b, + Value::Object(o) => o.values().any(any), + _ => false, + } + } + !any(&json!(self)) + } +} + +/// Eyedropper Options (stored in the preferences): the raster sample size and what the Eyedropper +/// picks up from the object it clicks and applies to the selection (`appearance.copyFrom`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct EyedropperOptions { + /// Pixels square averaged when sampling an image: 1 (point), 3 or 5. + pub sample_size: u32, + pub pick_up: EyedropperAttrs, + pub apply: EyedropperAttrs, } impl Default for EyedropperOptions { fn default() -> Self { - Self { appearance: true, transparency: true } + Self { sample_size: 1, pick_up: Default::default(), apply: Default::default() } } } impl EyedropperOptions { - /// These options with any `appearance` / `transparency` booleans in `p` applied. - pub(crate) fn with(mut self, p: &Value) -> Self { - self.appearance = bool_or(p, "appearance", self.appearance); - self.transparency = bool_or(p, "transparency", self.transparency); - self + /// These options with `sampleSize`, `pickUp` and `apply` from `p` applied: a tree given as an + /// object sets the flags it names, a bool sets every flag of its branch. + pub(crate) fn merged(&self, p: &Value) -> std::result::Result { + let mut v = serde_json::to_value(self).map_err(|e| e.to_string())?; + if let Some(n) = p.get("sampleSize").filter(|n| !n.is_null()) { + let n = n.as_u64().filter(|n| matches!(n, 1 | 3 | 5)).ok_or("`sampleSize` must be 1, 3 or 5")?; + v["sampleSize"] = json!(n); + } + for key in ["pickUp", "apply"] { + if let Some(t) = p.get(key).filter(|t| !t.is_null()) { + merge_flags(&mut v[key], t, key)?; + } + } + serde_json::from_value(v).map_err(|e| e.to_string()) + } + + /// What one copy takes: the attributes both picked up and applied, with the `pickUp` / `apply` + /// params of that call merged over the options. + pub(crate) fn attrs(&self, p: &Value) -> std::result::Result { + let o = self.merged(p)?; + let mut both = json!(o.pick_up); + and_flags(&mut both, &json!(o.apply)); + serde_json::from_value(both).map_err(|e| e.to_string()) + } +} + +/// Set the flags of `dst` named by `src` (`path`: where `dst` is, for errors). +fn merge_flags(dst: &mut Value, src: &Value, path: &str) -> std::result::Result<(), String> { + match (dst, src) { + (Value::Bool(d), Value::Bool(b)) => { + *d = *b; + Ok(()) + } + (Value::Object(d), Value::Bool(_)) => d.values_mut().try_for_each(|v| merge_flags(v, src, path)), + (Value::Object(d), Value::Object(s)) => s.iter().try_for_each(|(k, v)| match d.get_mut(k) { + Some(dv) => merge_flags(dv, v, &format!("{path}.{k}")), + None => Err(format!("unknown option `{path}.{k}`")), + }), + _ => Err(format!("`{path}` must be a bool or an object of bools")), + } +} + +/// `dst` and `other` flag by flag. +fn and_flags(dst: &mut Value, other: &Value) { + match (dst, other) { + (Value::Bool(d), Value::Bool(b)) => *d &= *b, + (Value::Object(d), Value::Object(o)) => d.iter_mut().for_each(|(k, v)| and_flags(v, &o[k])), + _ => {} } } fn eyedropper_options(s: &mut Session, p: &Value) -> Result { - s.eyedropper = s.eyedropper.with(p); - Ok(serde_json::to_value(s.eyedropper).unwrap_or_default()) + s.prefs.eyedropper = s.prefs.eyedropper.merged(p).map_err(|e| bad("eyedropper.setOptions", e))?; + Ok(json!(s.prefs.eyedropper)) } fn sample_color(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5be6c36a..536cd62b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -314,6 +314,9 @@ pub struct Prefs { /// Object → Pattern → Tile Edge Color (`#rrggbb`): the tile edge and swatch bounds in pattern /// editing mode. pub pattern_tile_edge_color: String, + /// Eyedropper Options (`eyedropper.setOptions`; a preference group, see + /// [`cmd::prefscmds::PREF_GROUPS`]). + pub eyedropper: EyedropperOptions, } impl Default for Prefs { @@ -431,6 +434,7 @@ impl Default for Prefs { let [r, g, b] = vectorcraft_doc::LAYER_COLORS[0].1; Color::rgb8(r, g, b).to_hex() }, + eyedropper: Default::default(), } } } @@ -473,8 +477,6 @@ pub struct Session { pub recent_colors: Vec, /// A paint applied by a live preview: remembered when the interaction commits. pub(crate) pending_paint: Option, - /// What the Eyedropper copies (`eyedropper.setOptions`); not saved. - pub eyedropper: EyedropperOptions, /// User Defined and loaded swatch libraries (Window → Swatch Libraries); not saved. pub swatch_libraries: cmd::swatchlib::Libraries, /// The selected freeform gradient point (`paint.freeform.selectPoint`) and whose gradient it @@ -511,7 +513,6 @@ impl Session { last_gradient: GradientPaint::new(Default::default()), recent_colors: vec![], pending_paint: None, - eyedropper: Default::default(), swatch_libraries: Default::default(), freeform_point: None, } @@ -786,6 +787,8 @@ mod tests_editcolors; #[cfg(test)] mod tests_effectedit; #[cfg(test)] +mod tests_eyedropper; +#[cfg(test)] mod tests_file; #[cfg(test)] mod tests_flatten; diff --git a/crates/engine/src/tests_eyedropper.rs b/crates/engine/src/tests_eyedropper.rs new file mode 100644 index 00000000..2a4c9b42 --- /dev/null +++ b/crates/engine/src/tests_eyedropper.rs @@ -0,0 +1,205 @@ +//! Eyedropper Options and modes (M3.52): the Picks Up / Applies trees, type attributes, Alt-click +//! (reverse) and Shift+Alt-click (append), image pixels, colour models and the preference. + +use serde_json::json; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{ImageBlob, ImageObject, Justify, LineCap, Node, NodeKind}; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn id_of(v: Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn rect(s: &mut Session, x: f64) -> NodeId { + id_of(s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 50, "height": 50})).unwrap()) +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +fn click(s: &mut Session, x: f64, y: f64, mods: Mods) { + s.select_tool("eyedropper", ViewInfo::default()).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, x, y).with_mods(mods), ViewInfo::default()).unwrap(); +} + +/// A red-filled source with a 5 pt round-capped blue stroke at 50 % opacity, and a selected +/// target with the default white fill and a 2 pt black stroke. +fn source_and_target(s: &mut Session) -> (NodeId, NodeId) { + let src = rect(s, 300.0); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#0000ff"})).unwrap(); + s.execute("stroke.set", &json!({"weight": 5, "cap": "round"})).unwrap(); + s.execute("transparency.set", &json!({"opacity": 50})).unwrap(); + let dst = rect(s, 10.0); + s.execute("paint.default", &json!({})).unwrap(); + s.execute("stroke.set", &json!({"weight": 2})).unwrap(); + (src, dst) +} + +#[test] +fn picking_up_without_weight_keeps_the_target_weight() { + let mut s = session(); + let (src, dst) = source_and_target(&mut s); + s.execute("eyedropper.setOptions", &json!({"pickUp": {"appearance": {"stroke": {"weight": false}}}})).unwrap(); + let weight = s.paint.stroke_width; + s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap(); + let n = node(&s, dst); + let st = n.appearance.stroke().unwrap(); + assert_eq!((st.paint.color().unwrap().to_hex(), st.width, st.cap), ("#0000ff".into(), 2.0, LineCap::Round)); + assert_eq!(n.appearance.fill_paint().color().unwrap().to_hex(), "#ff0000"); + assert_eq!(n.opacity, 0.5); + assert_eq!(s.paint.stroke_width, weight, "new art keeps its weight too"); + // Applying no transparency (one call) keeps the target's opacity; undo is one step per copy. + s.execute("transparency.set", &json!({"opacity": 100, "ids": [dst.0]})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("appearance.copyFrom", &json!({"source": src.0, "apply": {"appearance": {"transparency": false}}})).unwrap(); + assert_eq!((node(&s, dst).opacity, s.doc().unwrap().history.undo.len()), (1.0, undo + 1)); + assert!(s.execute("appearance.copyFrom", &json!({"source": src.0, "pickUp": {"nope": true}})).is_err()); +} + +#[test] +fn every_appearance_attribute_copies_the_whole_stack() { + let mut s = session(); + let (src, dst) = source_and_target(&mut s); + s.execute("select.set", &json!({"ids": [src.0]})).unwrap(); + s.execute("appearance.addFill", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [dst.0]})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap(); + assert_eq!(node(&s, dst).appearance, node(&s, src).appearance); + // Without the fill's overprint only the focal attributes go: the target keeps its two items. + let dst2 = rect(&mut s, 150.0); + s.execute("appearance.copyFrom", &json!({"source": src.0, "apply": {"appearance": {"fill": {"overprint": false}}}})).unwrap(); + assert_eq!(node(&s, dst2).appearance.items.len(), 2); +} + +#[test] +fn type_picks_up_font_and_size() { + let mut s = session(); + let src = id_of(s.execute("text.create", &json!({"x": 10, "y": 300, "text": "Source"})).unwrap()); + s.execute("text.setStyle", &json!({"font": "Inter", "size": 30, "tracking": 50, "justify": "center", "fill": "#00ff00"})).unwrap(); + let dst = id_of(s.execute("text.create", &json!({"x": 10, "y": 400, "text": "Target"})).unwrap()); + let style = |s: &Session, id| match &node(s, id).kind { + NodeKind::Text(t) => (t.first_style(), t.para.justify), + _ => panic!("not type"), + }; + let width = |s: &Session, id| node(s, id).geometric_bounds().unwrap().width(); + let before = width(&s, dst); + s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap(); + let (st, justify) = style(&s, dst); + assert_eq!((st.font_family.as_str(), st.size, st.tracking, justify), ("Inter", 30.0, 50.0, Justify::Center)); + assert_eq!(st.fill.color().unwrap().to_hex(), "#00ff00"); + assert!(width(&s, dst) > before, "laid out again at the new size"); + // Without character or paragraph attributes only the paints go. + let dst2 = id_of(s.execute("text.create", &json!({"x": 10, "y": 500, "text": "Other"})).unwrap()); + s.execute("appearance.copyFrom", &json!({"source": src.0, "pickUp": {"character": false, "paragraph": false}})).unwrap(); + let (st, justify) = style(&s, dst2); + assert_eq!((st.size, justify, st.fill.color().unwrap().to_hex()), (12.0, Justify::Left, "#00ff00".into())); +} + +#[test] +fn alt_click_applies_the_selection_to_the_clicked_object() { + let mut s = session(); + let (src, dst) = source_and_target(&mut s); + // `dst` is selected; Alt-click `src`: it takes the selection's white fill and 2 pt stroke. + let defaults = s.paint.clone(); + click(&mut s, 325.0, 35.0, Mods { alt: true, ..Default::default() }); + let n = node(&s, src); + assert_eq!((n.appearance.fill_paint(), n.appearance.stroke_width()), (Paint::solid(Color::WHITE), 2.0)); + assert_eq!(node(&s, dst).appearance.fill_paint(), Paint::solid(Color::WHITE), "the selection is unchanged"); + assert_eq!(s.paint, defaults, "the defaults stay"); + s.execute("select.none", &json!({})).unwrap(); + assert!(s.execute("appearance.copyFrom", &json!({"source": src.0, "reverse": true})).is_err(), "nothing to copy from"); +} + +#[test] +fn shift_alt_click_appends_the_appearance() { + let mut s = session(); + let (_, dst) = source_and_target(&mut s); + click(&mut s, 325.0, 35.0, Mods { alt: true, shift: true, ..Default::default() }); + let items = node(&s, dst).appearance.items; + assert_eq!(items.len(), 4, "the source's fill and stroke on top of the target's"); + assert_eq!((items[0].paint().color().unwrap().to_hex(), items[2].paint().color().unwrap().to_hex()), ("#ffffff".into(), "#ff0000".into())); +} + +#[test] +fn cmyk_stays_cmyk() { + let mut s = session(); + let src = rect(&mut s, 300.0); + let cmyk = Color::cmyk(0.1, 0.5, 0.9, 0.2); + s.execute("paint.setFill", &json!({"color": {"c": 0.1, "m": 0.5, "y": 0.9, "k": 0.2}})).unwrap(); + let dst = rect(&mut s, 10.0); + click(&mut s, 325.0, 35.0, Mods { shift: true, ..Default::default() }); + assert_eq!(node(&s, dst).appearance.fill_paint().color(), Some(cmyk), "a sampled colour keeps its model"); + s.execute("paint.setFill", &json!({"color": "#ffffff"})).unwrap(); + click(&mut s, 325.0, 35.0, Mods::default()); + assert_eq!(node(&s, dst).appearance.fill_paint().color(), Some(cmyk)); + let _ = src; +} + +#[test] +fn clicking_an_image_samples_its_pixel() { + let mut s = session(); + // Two pixels, orange and teal, 100 pt each, at (300, 0). + let img = image::RgbaImage::from_raw(2, 1, [[255, 128, 0, 255], [0, 128, 128, 255]].concat()).unwrap(); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + s.edit("Place", |d, _| { + d.images.insert("px".into(), ImageBlob::png(png)); + let l = d.layers[0].id; + let id = d.alloc_id(); + let xf = vectorcraft_geom::Affine::translate((300.0, 0.0)) * vectorcraft_geom::Affine::scale(100.0); + let im = ImageObject { key: "px".into(), width: 2, height: 1, xf, link: None }; + d.insert(Some(l), 0, Node::new(id, NodeKind::Image(im))).map_err(|e| crate::EngineError::Other(e.to_string())) + }) + .unwrap(); + let dst = rect(&mut s, 10.0); + click(&mut s, 450.0, 50.0, Mods::default()); + assert_eq!(node(&s, dst).appearance.fill_paint().color().unwrap().to_hex(), "#008080"); + s.execute("eyedropper.setOptions", &json!({"sampleSize": 5})).unwrap(); + click(&mut s, 350.0, 50.0, Mods::default()); + assert_eq!(node(&s, dst).appearance.fill_paint().color().unwrap().to_hex(), "#808040", "5 x 5 average of both pixels"); +} + +#[test] +fn the_options_are_a_preference() { + let mut s = Session::new(); + assert!(s.execute("eyedropper.setOptions", &json!({"sampleSize": 4})).is_err()); + assert!(s.execute("eyedropper.setOptions", &json!({"apply": {"character": 1}})).is_err()); + s.execute("eyedropper.setOptions", &json!({"sampleSize": 3, "apply": {"paragraph": false}})).unwrap(); + let got = s.execute("prefs.get", &json!({"key": "eyedropper"})).unwrap(); + assert_eq!((&got["sampleSize"], &got["apply"]["paragraph"], &got["pickUp"]["paragraph"]), (&json!(3), &json!(false), &json!(true))); + s.execute("prefs.set", &json!({"key": "eyedropper", "value": {"pickUp": {"appearance": false}}})).unwrap(); + assert!(!s.prefs.eyedropper.pick_up.appearance.fill.color && s.prefs.eyedropper.sample_size == 3); + // Saved with the preferences; older preferences (no options) load the defaults. + let back: Prefs = serde_json::from_value(s.prefs.to_json()).unwrap(); + assert_eq!(back, s.prefs); + let mut old = Prefs::default().to_json(); + old.as_object_mut().unwrap().remove("eyedropper"); + assert_eq!(serde_json::from_value::(old).unwrap().eyedropper, EyedropperOptions::default()); + s.execute("prefs.reset", &json!({})).unwrap(); + assert_eq!(s.prefs.eyedropper, EyedropperOptions::default()); +} + +#[test] +fn type_takes_the_stroke_options_it_has() { + let mut s = session(); + let (src, _) = source_and_target(&mut s); + s.execute("select.set", &json!({"ids": [src.0]})).unwrap(); + s.execute("stroke.set", &json!({"dash": [4, 2]})).unwrap(); + let dst = id_of(s.execute("text.create", &json!({"x": 10, "y": 400, "text": "Target"})).unwrap()); + s.execute("eyedropper.setOptions", &json!({"apply": {"appearance": {"stroke": {"dash": false}}}})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap(); + let NodeKind::Text(t) = &node(&s, dst).kind else { panic!("not type") }; + let st = t.first_style(); + assert_eq!((st.stroke.color().unwrap().to_hex(), st.stroke_width, st.stroke_cap), ("#0000ff".into(), 5.0, LineCap::Round)); + assert!(st.stroke_dash.is_none(), "the dash pattern isn't applied"); +} diff --git a/crates/engine/src/tests_gradpanel.rs b/crates/engine/src/tests_gradpanel.rs index 104dff92..ed273722 100644 --- a/crates/engine/src/tests_gradpanel.rs +++ b/crates/engine/src/tests_gradpanel.rs @@ -138,17 +138,20 @@ fn eyedropper_options_choose_what_copy_from_takes() { s.execute("transparency.set", &json!({"opacity": 40})).unwrap(); let dst = rect(&mut s, 200.0, 10.0, 100.0, 100.0); s.execute("paint.setFill", &json!({"color": "#ffffff"})).unwrap(); - assert_eq!(s.execute("eyedropper.setOptions", &json!({})).unwrap(), json!({"appearance": true, "transparency": true})); - assert_eq!(s.execute("eyedropper.setOptions", &json!({"appearance": false})).unwrap(), json!({"appearance": false, "transparency": true})); + let o = s.execute("eyedropper.setOptions", &json!({})).unwrap(); + assert_eq!((&o["sampleSize"], &o["pickUp"]["appearance"]["fill"]["color"]), (&json!(1), &json!(true))); + let o = s.execute("eyedropper.setOptions", &json!({"pickUp": {"appearance": {"fill": false, "stroke": false}}})).unwrap(); + assert_eq!((&o["pickUp"]["appearance"]["fill"]["overprint"], &o["apply"]["appearance"]["fill"]["color"]), (&json!(false), &json!(true))); let n = |s: &Session| s.doc().unwrap().doc.node(dst).unwrap().clone(); s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [dst.0]})).unwrap(); assert_eq!((n(&s).opacity, n(&s).appearance.fill_paint().color().unwrap().to_hex()), (0.4, "#ffffff".into())); // Params override the options for one call. - s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [dst.0], "appearance": true})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [dst.0], "pickUp": {"appearance": {"fill": true}}})).unwrap(); assert_eq!(n(&s).appearance.fill_paint().color().unwrap().to_hex(), "#ff0000"); // Nothing to copy: no edit. let undo = s.doc().unwrap().history.undo.len(); - assert_eq!(s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [dst.0], "transparency": false})).unwrap(), json!({"ids": []})); + let r = s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [dst.0], "apply": false})).unwrap(); + assert_eq!(r, json!({"ids": []})); assert_eq!(s.doc().unwrap().history.undo.len(), undo); } diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index e0c84f58..155a962d 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -3,7 +3,7 @@ use serde_json::json; use super::*; -use crate::cmd::prefscmds::{PREF_CATEGORIES, PREF_SPECS, validate}; +use crate::cmd::prefscmds::{PREF_CATEGORIES, PREF_GROUPS, PREF_SPECS, validate}; #[test] fn every_spec_matches_a_prefs_field_and_back() { @@ -16,7 +16,7 @@ fn every_spec_matches_a_prefs_field_and_back() { assert!(validate(sp.key, &obj[sp.key]).is_ok(), "default of `{}` fails validation", sp.key); } for k in obj.keys() { - assert!(PREF_SPECS.iter().any(|s| s.key == k), "Prefs field `{k}` has no spec"); + assert!(PREF_SPECS.iter().any(|s| s.key == k) || PREF_GROUPS.contains(&k.as_str()), "Prefs field `{k}` has no spec"); } for c in PREF_CATEGORIES { assert!(PREF_SPECS.iter().any(|s| s.category == *c), "category {c} is empty"); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 7bf35416..d06773ac 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -83,6 +83,7 @@ impl Session { appearance_item: self.appearance_item(), constrain_angle: self.prefs.constrain_angle, freeform_point: self.selected_freeform_point(), + raster_sample: self.prefs.eyedropper.sample_size, }; f(self.tool.as_mut(), &cx) } diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index 5c6c1297..7717de87 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -17,3 +17,6 @@ vectorcraft-text = { workspace = true } kurbo = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } + +[dev-dependencies] +image = { workspace = true } diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index ebfa4808..de189ea3 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -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, + /// 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, } } } diff --git a/crates/tools/src/xform/eyedropper.rs b/crates/tools/src/xform/eyedropper.rs index 46228190..217a28a5 100644 --- a/crates/tools/src/xform/eyedropper.rs +++ b/crates/tools/src/xform/eyedropper.rs @@ -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, 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 { + 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 { + 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 = 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] diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 66e091ca..a76c8812 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -433,7 +433,14 @@ fn hint_for(tool: &str) -> &'static [(&'static str, bool)] { ("Drag", true), (" to zoom into an area", false), ], - "eyedropper" => &[("Click", true), (" an object to copy its appearance | ", false), ("Shift+Click", true), (" to sample a color", false)], + "eyedropper" => &[ + ("Click", true), + (" an object to copy its attributes | ", false), + ("Alt+Click", true), + (" to apply the selection's to it | ", false), + ("Shift+Click", true), + (" to sample a color", false), + ], "gradient" => &[("Drag", true), (" across a selected object to set the gradient direction", false)], "artboard" => &[("Click", true), (" to select an artboard | ", false), ("Drag", true), (" on the canvas to create one", false)], _ => &[("Press ", false), ("Cmd+Shift+/", true), (" to search every command", false)], diff --git a/crates/ui-egui/src/dialogs/eyedropper.rs b/crates/ui-egui/src/dialogs/eyedropper.rs new file mode 100644 index 00000000..0b758da5 --- /dev/null +++ b/crates/ui-egui/src/dialogs/eyedropper.rs @@ -0,0 +1,149 @@ +//! Eyedropper Options (double-click the Eyedropper tool): the raster sample size and the Picks Up / +//! Applies trees of attributes. OK runs `eyedropper.setOptions` (the options live in the +//! preferences). +//! +//! Fields: `sampleSize` (1, 3 or 5), `pickUp` and `apply` (`{appearance: {transparency, fill: +//! {color, transparency, overprint}, stroke: {color, transparency, overprint, weight, cap, join, +//! miter, dash}}, character, paragraph}`, each leaf a bool). + +use serde_json::{Value, json}; + +use super::DialogSpec; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Eyedropper Options. +pub const KIND: &str = "eyedropperOptions"; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Eyedropper Options".into(), body, confirm, min_width: 460.0, ..DialogSpec::FORM }; + +/// The raster sample sizes: (pixels square, label). +const SAMPLE_SIZES: [(u64, &str); 3] = [(1, "Point Sample"), (3, "3 x 3 Average"), (5, "5 x 5 Average")]; + +/// The rows of a Picks Up / Applies tree: (JSON pointer into the tree, label, depth). +const TREE: &[(&str, &str, u8)] = &[ + ("/appearance", "Appearance", 0), + ("/appearance/transparency", "Transparency", 1), + ("/appearance/fill", "Focal Fill", 1), + ("/appearance/fill/color", "Color", 2), + ("/appearance/fill/transparency", "Transparency", 2), + ("/appearance/fill/overprint", "Overprint", 2), + ("/appearance/stroke", "Focal Stroke", 1), + ("/appearance/stroke/color", "Color", 2), + ("/appearance/stroke/transparency", "Transparency", 2), + ("/appearance/stroke/overprint", "Overprint", 2), + ("/appearance/stroke/weight", "Weight", 2), + ("/appearance/stroke/cap", "Cap", 2), + ("/appearance/stroke/join", "Join", 2), + ("/appearance/stroke/miter", "Miter Limit", 2), + ("/appearance/stroke/dash", "Dash Pattern", 2), + ("/character", "Character Style", 0), + ("/paragraph", "Paragraph Style", 0), +]; + +/// Open the dialog with the current options. +pub fn open(app: &mut VectorcraftApp) { + app.ui.dialog = Some(Dialog::new(KIND, json!(app.session.prefs.eyedropper))); +} + +/// A flag or a branch of flags: `Some(on)` when every flag under it is `on`, `None` when mixed. +fn state(v: &Value) -> Option { + match v { + Value::Bool(b) => Some(*b), + Value::Object(o) => { + let mut s = o.values().map(state); + let first = s.next()??; + s.all(|x| x == Some(first)).then_some(first) + } + _ => Some(false), + } +} + +/// Set every flag under `v` to `on`. +fn set_all(v: &mut Value, on: bool) { + match v { + Value::Object(o) => o.values_mut().for_each(|c| set_all(c, on)), + _ => *v = Value::Bool(on), + } +} + +/// One tree (`key`: `pickUp` or `apply`) of checkboxes; a branch's box sets everything under it. +fn tree(ui: &mut egui::Ui, d: &mut Dialog, key: &str, title: &str) { + ui.vertical(|ui| { + ui.label(egui::RichText::new(title).strong()); + ui.add_space(4.0); + let Some(t) = d.fields.get_mut(key) else { return }; + for (path, label, depth) in TREE { + let Some(node) = t.pointer_mut(path) else { continue }; + let s = state(node); + ui.horizontal(|ui| { + ui.add_space(*depth as f32 * 18.0); + if widgets::check3(ui, label, s, true) { + set_all(node, s != Some(true)); + } + }); + } + }); +} + +fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let size = d.fields.get("sampleSize").and_then(Value::as_u64).unwrap_or(1); + let current = SAMPLE_SIZES.iter().find(|(n, _)| *n == size).map_or(SAMPLE_SIZES[0].1, |s| s.1); + ui.horizontal(|ui| { + widgets::dim_label(ui, "Raster Sample Size:"); + let labels = SAMPLE_SIZES.map(|s| s.1); + if let Some(i) = widgets::dropdown(ui, "eyedropper-sample", current, &labels, 150.0) { + d.fields.insert("sampleSize".into(), json!(SAMPLE_SIZES[i].0)); + } + }); + ui.add_space(10.0); + ui.horizontal_top(|ui| { + tree(ui, d, "pickUp", "Eyedropper Picks Up:"); + ui.add_space(24.0); + tree(ui, d, "apply", "Eyedropper Applies:"); + }); + false +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + super::run_and_close(app, "eyedropper.setOptions", Value::Object(d.fields.clone())) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + #[test] + fn the_trees_set_the_options() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("tool.options", json!({"tool": "eyedropper"})).unwrap(); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| super::super::show(app, ui.ctx())); + for label in [ + "Eyedropper Options", + "Raster Sample Size:", + "Point Sample", + "Eyedropper Picks Up:", + "Eyedropper Applies:", + "Focal Stroke", + "Dash Pattern", + "Paragraph Style", + ] { + assert!(text.contains(label), "{label} in {text}"); + } + // A branch's box sets the flags under it; a mixed branch shows a dash. + let d = app.ui.dialog.as_mut().unwrap(); + let t = d.fields.get_mut("apply").unwrap(); + set_all(t.pointer_mut("/appearance/stroke").unwrap(), false); + assert_eq!(state(&t["appearance"]["stroke"]), Some(false)); + assert_eq!(state(&t["appearance"]), None); + d.fields.insert("sampleSize".into(), json!(5)); + super::super::confirm(&mut app).unwrap(); + let o = app.session.prefs.eyedropper; + assert_eq!( + (o.sample_size, o.apply.appearance.stroke.dash, o.apply.appearance.fill.color, o.pick_up.appearance.stroke.dash), + (5, false, true, true) + ); + assert!(app.ui.dialog.is_none()); + } +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index c53552a6..27be3738 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -17,6 +17,7 @@ pub mod confirm; mod document_setup; mod effect; mod export_for_screens; +pub mod eyedropper; mod form; mod gradient_stop; pub mod graphic_style_options; @@ -154,6 +155,7 @@ registry! { Saturate: [saturate::KIND] => saturate::SPEC, SaveSwatchLibrary: [save_swatch_library::KIND] => save_swatch_library::SPEC, TileEdgeColor: [tile_edge_color::KIND] => tile_edge_color::SPEC, + EyedropperOptions: [eyedropper::KIND] => eyedropper::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d8dbcbfd..adb056c4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -199,7 +199,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "tool.options", "Tool Options…", "", - "{tool: id} what double-clicking a tool button opens: gradient → the Gradient panel (window.panel), eyedropper → Eyedropper Options (dialog `command` running eyedropper.setOptions, fields appearance, transparency)", + "{tool: id} what double-clicking a tool button opens: gradient → the Gradient panel (window.panel), eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions)", ), ( "ui.colorGuideOptions", diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index c53780d1..445570bb 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -188,15 +188,8 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result Ok(json!({ "open": "gradient" })), "gradient" => app.run("window.panel", json!({ "panel": "gradient" })), "eyedropper" => { - let o = app.session.eyedropper; - let fields = json!({ - "__command": "eyedropper.setOptions", - "__label": "Eyedropper Options", - "appearance": o.appearance, - "transparency": o.transparency, - }); - app.ui.dialog = Some(crate::state::Dialog::new("command", fields)); - Ok(json!({ "dialog": "command" })) + crate::dialogs::eyedropper::open(app); + Ok(json!({ "dialog": crate::dialogs::eyedropper::KIND })) } _ if vectorcraft_tools::tool_info(tool).is_none() => Err(format!("unknown tool `{tool}`")), _ => Err(format!("the {tool} tool has no options")), @@ -362,11 +355,12 @@ mod tests { assert_eq!(app.ui.open_panel.as_deref(), Some("gradient")); double_click(&mut app, &ctx, 3.0, eyedropper.center()); let d = app.ui.dialog.clone().expect("Eyedropper Options"); - assert_eq!((app.session.tool_id(), d.kind.as_str(), d.str("__command")), ("eyedropper", "command", "eyedropper.setOptions".into())); + assert_eq!((app.session.tool_id(), d.kind.as_str()), ("eyedropper", crate::dialogs::eyedropper::KIND)); // OK applies the options. - app.ui.dialog.as_mut().unwrap().fields.insert("transparency".into(), json!(false)); + app.ui.dialog.as_mut().unwrap().fields.insert("apply".into(), json!({"appearance": {"transparency": false}})); crate::dialogs::confirm(&mut app).unwrap(); - assert!(app.session.eyedropper.appearance && !app.session.eyedropper.transparency); + let o = app.session.prefs.eyedropper; + assert!(o.pick_up.appearance.transparency && !o.apply.appearance.transparency && o.apply.appearance.fill.color); assert!(app.run("tool.options", json!({"tool": "zoom"})).is_err()); } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7b3baf67..66223797 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -53,8 +53,9 @@ active proxy) with the paint's params and the object's `ids`; a colour dropped o a stop. Tool options: double-clicking a tool button runs `tool.options {tool}`. For `gradient` it opens the Gradient panel; -for `eyedropper` it opens Eyedropper Options, a `command` dialog (fields `appearance`, `transparency`) whose -`ui.dialog.confirm` runs `eyedropper.setOptions` (what `appearance.copyFrom` copies). Gradient tool handles snap to +for `eyedropper` it opens Eyedropper Options, an `eyedropperOptions` dialog (fields `sampleSize` 1/3/5, `pickUp` and +`apply`, the attribute trees of `eyedropper.setOptions`) whose `ui.dialog.confirm` runs `eyedropper.setOptions` (what +`appearance.copyFrom` copies). Gradient tool handles snap to anchors, edges and smart guides; Shift constrains them to 45° steps from the `constrainAngle` preference. Effect dialogs: `engine.execute {command: "effect.dialog", params: {effect, index?, item?}}` opens the `effect` dialog diff --git a/docs/mcp.md b/docs/mcp.md index 09cdeaf9..7c975c49 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -324,6 +324,7 @@ filter on the whole group. {"name":"run_command","arguments":{"command":"appearance.moveItem","params":{"ids":[7],"from":"contents","to":1}}} {"name":"run_command","arguments":{"command":"effect.apply","params":{"ids":[2],"effect":"stylize.dropShadow"}}} {"name":"run_command","arguments":{"command":"appearance.addStroke","params":{"ids":[7],"target":"contents"}}} +``` ## Swatch libraries @@ -382,3 +383,16 @@ In pattern editing mode the tile edge (`pattern.options {showTileEdge}`) and the {"name":"run_command","arguments":{"command":"pattern.options","params":{"showSwatchBounds":true}}} {"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"patternTileEdgeColor","value":"#ff4f4f"}}} ``` + +The Eyedropper: `appearance.copyFrom {source}` copies what the Eyedropper Options pick up and apply from `source` to the +selection (`ids`); `reverse: true` copies the selection's attributes onto `source` (Alt-click) and `append: true` adds +the source's fills and strokes on top of each target's stack (Shift+Alt-click). The options live in the preferences: +`eyedropper.setOptions {sampleSize?, pickUp?, apply?}` (or `prefs.get`/`prefs.set` with key `eyedropper`) reads and +sets them; a tree names flags under `appearance` (`transparency`, `fill` `{color, transparency, overprint}`, `stroke` +`{color, transparency, overprint, weight, cap, join, miter, dash}`), `character` and `paragraph`, and a bool for a +branch sets all of it. `paint.sampleColor {color}` puts a sampled colour (in its own model) into the active proxy. + +```json +{"name":"run_command","arguments":{"command":"eyedropper.setOptions","params":{"pickUp":{"appearance":{"stroke":{"weight":false}}}}}} +{"name":"run_command","arguments":{"command":"appearance.copyFrom","params":{"source":12,"ids":[7,8]}}} +```