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/node.rs b/crates/doc/src/node.rs index 7cadd5d9..fe28a65f 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -574,7 +574,8 @@ impl Node { let b = t.local_bounds(); return Some(if stroke { (&st.stroke, t.xf, crate::appearance::stroke_paint_bounds(b, st.stroke_width)) } else { (&st.fill, t.xf, b) }); } - let b = self.geometric_bounds()?; + // An object without bounds (an empty path) still shows its paints, fitted to an empty box. + let b = self.geometric_bounds().unwrap_or(Rect::ZERO); if stroke { self.appearance.stroke_at(item).map(|s| (&s.paint, Affine::IDENTITY, s.paint_bounds(b))) } else { 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 Vec { has_doc, default_paint ), - cmd!("paint.toggleActive", "Toggle Fill/Stroke Focus", [], Some("X"), "{}", always, |s, _| { - s.fill_active = !s.fill_active; - Ok(json!({ "fillActive": s.fill_active })) - }), + cmd!( + "paint.toggleActive", + "Toggle Fill/Stroke Focus", + [], + Some("X"), + "{fill?: bool (true: bring the Fill proxy forward, false: the Stroke proxy; omitted: swap which is in front)} → {fillActive}", + always, + |s, p| { + s.fill_active = p.get("fill").and_then(Value::as_bool).unwrap_or(!s.fill_active); + Ok(json!({ "fillActive": s.fill_active })) + } + ), cmd!("paint.none", "None", [], Some("/"), "{} set the active proxy (fill or stroke) to None", has_doc, |s, _| { let f = s.fill_active; set_paint(s, &json!({"none": true}), f) @@ -69,18 +77,18 @@ pub fn proxy_specs() -> Vec { "Invert", [], None, - "{stroke?: bool (default: the active proxy), ids?} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; with nothing selected, invert the default → {changed}", + "{stroke?: bool (default: the active proxy), ids?} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with nothing selected, invert the default → {changed}", has_doc, - |s, p| proxy_recolor(s, p, "Invert", &super::colorcmds::invert) + |s, p| proxy_recolor(s, p, "paint.invert", "Invert", &super::colorcmds::invert) ), cmd!( "paint.complement", "Complement", [], None, - "{stroke?: bool (default: the active proxy), ids?} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; with nothing selected, the default → {changed}", + "{stroke?: bool (default: the active proxy), ids?} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; honours the Appearance panel's active item as paint.invert does; with nothing selected, the default → {changed}", has_doc, - |s, p| proxy_recolor(s, p, "Complement", &|c: Color| c.complement_keep_model()) + |s, p| proxy_recolor(s, p, "paint.complement", "Complement", &|c: Color| c.complement_keep_model()) ), cmd!( "paint.lastColor", @@ -231,36 +239,44 @@ pub(crate) fn painted<'a>(n: &'a Node, top: bool, out: &mut Vec<&'a Node>) { impl DocState { /// Whether the selected objects' fills and strokes differ as the proxies show them (a "?" - /// proxy; a group whose contents differ counts). One walk of the document, so callers that ask + /// proxy; a group whose contents differ counts). `item` (the Appearance panel's active item) + /// stands for the proxy of its kind, as in [`Session::proxy_paints`]: that fill or stroke of + /// each selected object's own stack is compared. One walk of the document, so callers that ask /// every frame cache it by revision. - pub fn proxy_mixed(&self) -> (bool, bool) { + pub fn proxy_mixed(&self, item: Option) -> (bool, bool) { if self.selection.is_empty() { return (false, false); } let ids: std::collections::HashSet = self.selection.objects.iter().copied().collect(); - let mut nodes = vec![]; + let (mut selected, mut nodes) = (vec![], vec![]); self.doc.walk(|n| { if ids.contains(&n.id) { + selected.push(n); painted(n, true, &mut nodes); } }); - let mut paints = nodes.iter().map(|n| (proxy_paint(n, false, None), proxy_paint(n, true, None))); - let Some((f0, s0)) = paints.next() else { return (false, false) }; - let mut mixed = (false, false); - for (f, s) in paints { - mixed.0 |= !same_in_proxy(&f0, &f); - mixed.1 |= !same_in_proxy(&s0, &s); - if mixed.0 && mixed.1 { - break; - } - } - mixed + let first = self.selection.objects.first().and_then(|id| self.doc.node(*id)); + let differ = |stroke: bool| { + let item = first.and_then(|f| f.appearance.item_of_kind(item, !stroke)); + let set = if item.is_some() { &selected } else { &nodes }; + let mut paints = set.iter().map(|n| proxy_paint(n, stroke, item)); + let Some(p0) = paints.next() else { return false }; + paints.any(|p| !same_in_proxy(&p0, &p)) + }; + (differ(false), differ(true)) + } +} + +impl Session { + /// [`DocState::proxy_mixed`] of the active document for the Appearance panel's active item. + pub fn proxy_mixed(&self) -> (bool, bool) { + self.active().map_or((false, false), |st| st.proxy_mixed(self.appearance_item())) } } fn proxies(s: &mut Session, _: &Value) -> Result { let (fill, stroke) = s.proxy_paints(); - let (fill_mixed, stroke_mixed) = s.active().map_or((false, false), DocState::proxy_mixed); + let (fill_mixed, stroke_mixed) = s.proxy_mixed(); Ok(json!({"fill": fill, "stroke": stroke, "fillActive": s.fill_active, "fillMixed": fill_mixed, "strokeMixed": stroke_mixed})) } @@ -428,9 +444,10 @@ fn default_paint(s: &mut Session, p: &Value) -> Result { ok() } -/// Invert / Complement: recolour the active proxy of the targets (or the default when there are -/// none) and remember the first target's new paint. -fn proxy_recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color) -> Result { +/// Invert / Complement (`cmd`, undone as `label`): recolour the active proxy of the targets (or the +/// default when there are none) and remember the first target's new paint. The Appearance panel's +/// active item of the proxy's kind is recoloured in each selected object's own stack instead. +fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result { let stroke = stroke_param(s, p); let ids = match ids_param(p, "ids") { Some(ids) => ids, @@ -443,11 +460,27 @@ fn proxy_recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> C s.remember_paint(&shown); return Ok(json!({ "changed": changed as usize })); } - // The proxy's colours only: images and pattern tiles stay as they are. - let q = json!({"fill": !stroke, "stroke": stroke, "includeImages": false, "includePatterns": false, "ids": ids.iter().map(|i| i.0).collect::>()}); - let r = super::colorcmds::recolor(s, &q, label, f)?; - let first = leaf_targets(s, &ids)?.first().and_then(|id| s.doc().ok()?.doc.node(*id).map(|n| proxy_paint(n, stroke, None))); - if let Some(shown) = first { + let item = item_target(s, p, cmd)?.of_kind(s, !stroke); + let (r, shown_item) = match item { + ItemTarget::Item { index, .. } => { + let ids = item.targets(s, p)?; + let mut changed = 0; + edit_items(s, &ids, item, cmd, label, !stroke, |n, i| { + let paint = + if stroke { n.appearance.stroke_at_mut(i).map(|l| &mut l.paint) } else { n.appearance.fill_at_mut(i).map(|l| &mut l.paint) }; + changed += paint.is_some_and(|paint| super::colorcmds::map_paint(paint, f)) as usize; + Ok(()) + })?; + (json!({ "changed": changed }), Some(index)) + } + // The proxy's colours only: images and pattern tiles stay as they are. + ItemTarget::Top => { + let q = json!({"fill": !stroke, "stroke": stroke, "includeImages": false, "includePatterns": false, "ids": ids.iter().map(|i| i.0).collect::>()}); + (super::colorcmds::recolor(s, &q, label, f)?, None) + } + }; + let shown_ids = if shown_item.is_some() { ids } else { leaf_targets(s, &ids)? }; + if let Some(shown) = shown_ids.first().and_then(|id| s.doc().ok()?.doc.node(*id).map(|n| proxy_paint(n, stroke, shown_item))) { s.remember_paint(&shown); } Ok(r) diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index d9d49afe..910e01ac 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -300,6 +300,23 @@ pub fn spec(key: &str) -> 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 cda8edf8..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; @@ -820,6 +823,8 @@ mod tests_pattern; #[cfg(test)] mod tests_prefs; #[cfg(test)] +mod tests_proxyitems; +#[cfg(test)] mod tests_strokegeom; #[cfg(test)] mod tests_strokeux; 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_paintproxy.rs b/crates/engine/src/tests_paintproxy.rs index be0ee209..33f0aaa3 100644 --- a/crates/engine/src/tests_paintproxy.rs +++ b/crates/engine/src/tests_paintproxy.rs @@ -191,7 +191,7 @@ fn a_mixed_selection_shows_a_question_mark_proxy() { assert_eq!(q(&mut s)["fillMixed"], false); s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); s.execute("paint.setFill", &json!({"gradient": {"stops": [{"offset": 0, "color": "#000000"}, {"offset": 1, "color": "#ffffff"}]}})).unwrap(); - assert_eq!(s.doc().unwrap().proxy_mixed(), (false, false)); + assert_eq!(s.proxy_mixed(), (false, false)); s.execute("select.none", &json!({})).unwrap(); assert_eq!(q(&mut s)["fillMixed"], false); } @@ -209,7 +209,7 @@ fn every_selection_change_bumps_the_revision() { s.execute("select.magicWand", &json!({"id": a.0})).unwrap(); assert_eq!(s.doc().unwrap().selection.len(), 2, "{a:?} and {b:?} are within the tolerance"); assert!(s.doc().unwrap().revision > rev); - assert_eq!(s.doc().unwrap().proxy_mixed(), (true, false)); + assert_eq!(s.proxy_mixed(), (true, false)); let r = s.execute("text.create", &json!({"x": 10, "y": 150, "text": "Hi"})).unwrap(); let family = match &node(&s, NodeId(r["id"].as_u64().unwrap())).kind { NodeKind::Text(t) => t.first_style().font_family, @@ -265,7 +265,7 @@ fn a_group_shows_its_contents_in_the_proxies() { // The group shows what the paint commands change: its contents' red fill, not mixed. assert_eq!(s.doc().unwrap().selection.len(), 1); assert_eq!(s.proxy_paints().0.color().unwrap().to_hex(), "#ff0000"); - assert_eq!(s.doc().unwrap().proxy_mixed(), (false, false)); + assert_eq!(s.proxy_mixed(), (false, false)); // Contents that differ make even a single selected group a "?" proxy. s.execute("paint.setFill", &json!({"color": "#00ff00", "ids": [b.0]})).unwrap(); assert_eq!(s.execute("paint.proxies", &json!({})).unwrap()["fillMixed"], true); 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/tests_proxyitems.rs b/crates/engine/src/tests_proxyitems.rs new file mode 100644 index 00000000..4ce73602 --- /dev/null +++ b/crates/engine/src/tests_proxyitems.rs @@ -0,0 +1,105 @@ +//! The Fill/Stroke proxies and the Appearance panel's active item: Invert / Complement and the "?" +//! state follow the active fill or stroke, objects without bounds still show their paints, and the +//! proxy focus can be set directly (M3.51). + +use serde_json::json; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Node}; +use vectorcraft_geom::PathData; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s +} + +/// A selected rectangle with a red fill below its stroke and a blue fill on top: [Fill, Stroke, Fill]. +fn two_fills(s: &mut Session, x: f64) -> NodeId { + let r = s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 50, "height": 50})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("appearance.addFill", &json!({})).unwrap(); + s.execute("appearance.setItem", &json!({"index": 2, "color": "#0000ff"})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn item_hex(s: &Session, id: NodeId, index: usize) -> String { + s.doc().unwrap().doc.node(id).unwrap().appearance.items[index].paint().color().unwrap().to_hex() +} + +#[test] +fn invert_and_complement_recolour_the_active_item() { + let mut s = session(); + let id = two_fills(&mut s, 10.0); + // Without an active item the proxy's colours go (every fill, as Edit Colors does). + s.execute("paint.invert", &json!({})).unwrap(); + assert_eq!((item_hex(&s, id, 0), item_hex(&s, id, 2)), ("#00ffff".into(), "#ffff00".into())); + s.execute("edit.undo", &json!({})).unwrap(); + // The bottom fill is the Appearance panel's active row: only it changes, as one undo step. + s.execute("appearance.setActiveItem", &json!({"index": 0})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + assert_eq!(s.execute("paint.invert", &json!({})).unwrap(), json!({"changed": 1})); + assert_eq!((item_hex(&s, id, 0), item_hex(&s, id, 2)), ("#00ffff".into(), "#0000ff".into())); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + assert_eq!(s.proxy_paints().0.color().unwrap().to_hex(), "#00ffff", "the proxy shows the active row"); + assert_eq!(s.recent_colors[0].to_hex(), "#00ffff"); + s.execute("paint.complement", &json!({})).unwrap(); + assert_eq!((item_hex(&s, id, 0), item_hex(&s, id, 2)), ("#ff0000".into(), "#0000ff".into())); + // The Stroke proxy isn't the active row's kind: the stroke is complemented as usual. + s.execute("paint.complement", &json!({"stroke": true})).unwrap(); + assert_eq!(item_hex(&s, id, 1), "#000000", "black's complement is black"); + assert_eq!((item_hex(&s, id, 0), item_hex(&s, id, 2)), ("#ff0000".into(), "#0000ff".into())); +} + +#[test] +fn the_mixed_proxy_compares_the_active_item() { + let mut s = session(); + let a = two_fills(&mut s, 10.0); + let b = two_fills(&mut s, 100.0); + // Same top fills, different bottom fills. + s.execute("appearance.setItem", &json!({"index": 0, "color": "#00ff00", "ids": [b.0]})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + assert_eq!(s.proxy_mixed(), (false, false)); + s.execute("appearance.setActiveItem", &json!({"index": 0})).unwrap(); + assert_eq!(s.proxy_mixed(), (true, false), "the active bottom fills differ"); + assert_eq!(s.execute("paint.proxies", &json!({})).unwrap()["fillMixed"], true); + // A stroke row leaves the fill proxy on the top fills. + s.execute("appearance.setActiveItem", &json!({"index": 1})).unwrap(); + assert_eq!(s.proxy_mixed(), (false, false)); +} + +#[test] +fn an_object_without_bounds_shows_its_paints() { + let mut s = session(); + let id = s + .edit("Add", |d, _| { + let l = d.layers[0].id; + let id = d.alloc_id(); + let ap = Appearance::basic(Paint::solid(Color::from_hex("#123456").unwrap()), Paint::solid(Color::from_hex("#654321").unwrap()), 2.0); + d.insert(Some(l), 0, Node::path(id, PathData::default(), ap)).map_err(|e| crate::EngineError::Other(e.to_string())) + }) + .unwrap(); + assert!(s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().is_none()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let (f, st) = s.proxy_paints(); + assert_eq!((f.color().unwrap().to_hex(), st.color().unwrap().to_hex()), ("#123456".into(), "#654321".into())); +} + +#[test] +fn toggle_active_can_name_the_proxy() { + let mut s = Session::new(); + assert_eq!(s.execute("paint.toggleActive", &json!({"fill": false})).unwrap(), json!({"fillActive": false})); + assert_eq!(s.execute("paint.toggleActive", &json!({"fill": false})).unwrap(), json!({"fillActive": false}), "already in front"); + assert_eq!(s.execute("paint.toggleActive", &json!({})).unwrap(), json!({"fillActive": true})); +} + +#[test] +fn the_last_colour_keeps_its_model() { + let mut s = session(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + s.execute("paint.setFill", &json!({"color": {"c": 0.1, "m": 0.9, "y": 0.2, "k": 0.0}})).unwrap(); + s.execute("paint.setFill", &json!({"none": true})).unwrap(); + s.execute("paint.lastColor", &json!({})).unwrap(); + assert_eq!(s.proxy_paints().0.color(), Some(Color::cmyk(0.1, 0.9, 0.2, 0.0))); +} 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 8a4c233f..a76c8812 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::NodeKind; use crate::panels::stroke as stroke_panel; use crate::state::{ZOOM_STOPS, zoom_label}; use crate::theme::{self, Tokens}; -use crate::widgets::{self, paint_chip}; +use crate::widgets; use crate::{VectorcraftApp, icons, menus, titlebar}; /// The application bar: brand mark, Home, menus, then Discord, search and the workspace switcher @@ -88,17 +88,6 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { } } -/// A Control bar chip that opens something: `draw` paints the chip, a chevron follows it. -fn chip_button(ui: &mut Ui, tip: &str, draw: impl FnOnce(&Ui, egui::Rect)) -> egui::Response { - let t = Tokens::get(ui.ctx()); - let (r, resp) = ui.allocate_exact_size(vec2(34.0, 22.0), Sense::click()); - let chip = egui::Rect::from_min_size(r.min + vec2(0.0, 2.0), vec2(18.0, 18.0)); - draw(ui, chip); - ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Outside); - icons::paint(ui, "chevron-down", egui::Rect::from_min_size(r.min + vec2(21.0, 5.0), vec2(12.0, 12.0)), t.text_dim); - resp.on_hover_text(tip) -} - /// The Control bar (Window → Control), context-sensitive like Illustrator's. pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); @@ -127,24 +116,12 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { }; ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text)); ui.add_space(6.0); - let (fill, stroke) = crate::panels::current_paints(app); let shown_stroke = crate::panels::current_stroke(app); let mixed = crate::panels::stroke_mixed(app, ui.ctx()); let weight = stroke_panel::shown_weight(app, shown_stroke.as_ref(), &mixed); let opacity = if first.is_some() { crate::panels::current_transparency(app).map_or(1.0, |t| t.0) } else { 1.0 }; - if chip_button(ui, "Fill", |ui, r| paint_chip(ui, r, &fill)).clicked() { - app.run("paint.toggleActive", json!({})).ok(); - app.session.fill_active = true; - app.ui.open_panel = Some("swatches".into()); - } - let stroke_chip = |ui: &Ui, r: egui::Rect| { - paint_chip(ui, r, &stroke); - ui.painter().rect_filled(r.shrink(5.0), 0.0, t.panel); - }; - if chip_button(ui, "Stroke", stroke_chip).clicked() { - app.session.fill_active = false; - app.ui.open_panel = Some("swatches".into()); - } + crate::panels::paint_chip(app, ui, false, 22.0, true); + crate::panels::paint_chip(app, ui, true, 22.0, true); // The link opens the Stroke panel as a popover under it; then the weight spinner // (with presets) and the width profile. let link = ui.link(egui::RichText::new("Stroke:").size(12.0).color(t.text).underline()).on_hover_text("Stroke options"); @@ -170,7 +147,8 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { if ui.link(egui::RichText::new("Style:").size(12.0).color(t.text).underline()).clicked() { app.ui.open_panel = Some("graphicStyles".into()); } - let resp = chip_button(ui, "Graphic Style", |ui, r| crate::panels::graphic_styles::paint_linked(app, ui, r)); + let resp = widgets::chip_button(ui, 22.0, true, |ui, r| crate::panels::graphic_styles::paint_linked(app, ui, r)) + .on_hover_text("Graphic Style"); egui::Popup::menu(&resp).show(|ui| crate::panels::graphic_styles::picker(app, ui)); } ui.separator(); @@ -455,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/lib.rs b/crates/ui-egui/src/lib.rs index dfbcd2e8..d0d46a11 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -36,6 +36,8 @@ pub mod workspaces; mod tests_labels; #[cfg(test)] mod tests_overprint; +#[cfg(test)] +mod tests_paintchips; use std::sync::mpsc::{Receiver, Sender}; use std::sync::{Arc, Mutex}; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index cb01b4f6..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", @@ -1456,7 +1456,7 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked continue; } let label = dynamic_label(app, id, label); - let sc = if p.is_null() { shortcut_of(id).map(pretty_shortcut).unwrap_or_default() } else { String::new() }; + let sc = item_shortcut(id, p).map(pretty_shortcut).unwrap_or_default(); let chk = checked(app, id, p); let text = match chk { Some(true) => format!("✓ {label}"), @@ -1638,7 +1638,7 @@ pub fn menu_entries(app: &VectorcraftApp) -> Vec { command: Some(id.to_string()), params: p.clone(), enabled: enabled(app, id), - shortcut: shortcut_of(id).unwrap_or("").to_string(), + shortcut: item_shortcut(id, p).or_else(|| shortcut_of(id)).unwrap_or("").to_string(), }), Item::Todo(l, sc) => out.push(MenuEntry { path: path.clone(), @@ -1787,6 +1787,16 @@ fn effect_menu() -> Vec { out } +/// The shortcut a menu item shows: its command's (items without params), or a panel's for the +/// Window menu's `window.panel {panel}` items. +pub fn item_shortcut(id: &str, p: &Value) -> Option<&'static str> { + match (id, p.get("panel").and_then(Value::as_str)) { + ("window.panel", Some(panel)) => crate::shortcut_editor::panel_shortcut(panel), + _ if p.is_null() => shortcut_of(id), + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 0593cce8..cdb7fdc7 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -192,14 +192,14 @@ pub(crate) fn current_paints(app: &VectorcraftApp) -> (Paint, Paint) { } /// Whether the selected objects' fills and strokes differ (the proxies show "?"), cached per -/// document revision. +/// document revision and Appearance panel item. pub(crate) fn mixed_paints(app: &VectorcraftApp, ctx: &egui::Context) -> (bool, bool) { let Some(st) = app.session.active() else { return (false, false) }; - let key = (st.uid, st.revision); - match pstate::>(ctx, "proxy-mixed") { + let key = (st.uid, st.revision, app.session.appearance_item()); + match pstate::), (bool, bool))>>(ctx, "proxy-mixed") { Some((k, mixed)) if k == key => mixed, _ => { - let mixed = st.proxy_mixed(); + let mixed = app.session.proxy_mixed(); set_pstate(ctx, "proxy-mixed", Some((key, mixed))); mixed } @@ -272,6 +272,56 @@ pub(crate) fn proxy(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) { } } +/// The Fill (or `stroke`) chip of the Control bar and the Properties panel, `size` square (with a +/// `chevron` after it): the proxy's paint, "?" when the selected objects' paints differ. A click +/// brings that proxy forward and opens a popover with the Swatches panel; Shift-click (or the +/// popover's toggle) shows the Color panel's mixer instead. +pub(crate) fn paint_chip(app: &mut VectorcraftApp, ui: &mut Ui, stroke: bool, size: f32, chevron: bool) { + let (fill, stroke_paint) = current_paints(app); + let mixed = mixed_paints(app, ui.ctx()); + let (paint, mixed) = if stroke { (stroke_paint, mixed.1) } else { (fill, mixed.0) }; + let resp = crate::widgets::chip_button(ui, size, chevron, |ui, chip| { + crate::widgets::proxy_chip(ui, chip, &paint, mixed, stroke.then_some(size * 0.25)); + }); + let resp = resp.on_hover_text(if stroke { + "Stroke: click for swatches, Shift-click for the color mixer" + } else { + "Fill: click for swatches, Shift-click for the color mixer" + }); + if resp.clicked() { + app.run("paint.toggleActive", json!({ "fill": !stroke })).ok(); + set_pstate(ui.ctx(), MIXER, ui.input(|i| i.modifiers.shift)); + } + egui::Popup::from_toggle_button_response(&resp) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .width(POPOVER_WIDTH) + .show(|ui| paint_popover(app, ui)); +} + +/// The chip popovers' width (a narrow Swatches or Color panel). +const POPOVER_WIDTH: f32 = 248.0; +/// Panel state: the chip popovers show the Color panel's mixer instead of the swatches. +const MIXER: &str = "paint-popover-mixer"; + +/// The body of a Fill/Stroke chip popover: a Swatches / Color mixer toggle above that panel's body. +fn paint_popover(app: &mut VectorcraftApp, ui: &mut Ui) { + ui.set_width(POPOVER_WIDTH); + let mixer: bool = pstate(ui.ctx(), MIXER); + ui.horizontal(|ui| { + for (icon, tip, m) in [("swatch-book", "Swatches", false), ("palette", "Color Mixer (Shift-click the chip)", true)] { + if crate::widgets::icon_button(ui, icon, tip, mixer == m, 22.0).clicked() { + set_pstate(ui.ctx(), MIXER, m); + } + } + }); + crate::widgets::divider(ui); + if mixer { + color::show(app, ui); + } else { + swatches::popover(app, ui); + } +} + /// Run `cmd` live: while dragging, preview on top of an interaction snapshot; on release, commit /// it as one undo step. Falls back to a plain run when no document is open. pub(crate) fn live_run(app: &mut VectorcraftApp, label: &str, cmd: &str, params: Value, phase: Live) { diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 3322e984..5d9b9959 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1,10 +1,10 @@ //! The Properties panel: context-sensitive sections like Illustrator's. -use egui::{Sense, Stroke, StrokeKind, Ui, vec2}; +use egui::Ui; use serde_json::json; use vectorcraft_doc::{NodeKind, Unit}; -use super::first_selected; +use super::{first_selected, pstate, set_pstate}; use crate::theme::Tokens; use crate::widgets::{self, dim_label, divider, section_header}; use crate::{VectorcraftApp, icons}; @@ -90,6 +90,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if is_live { actions.push(("Expand Shape", "object.expandShape")); } + if multi_color(app, ui.ctx()) { + actions.push(("Recolor", "ui.recolorDialog")); + } actions.push(("Offset Path", "object.path.offsetPath")); actions.push(("Simplify", "object.path.simplify")); actions.push(("Arrange: Bring to Front", "object.arrange.bringToFront")); @@ -106,6 +109,22 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } } +/// Whether the selection uses more than one colour (`recolor.colors`), which offers the Recolor +/// quick action; cached per document revision. +fn multi_color(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { + let Some(st) = app.session.active() else { return false }; + let key = (st.uid, st.revision); + match pstate::>(ctx, "props-multi-color") { + Some((k, multi)) if k == key => multi, + _ => { + let r = app.session.execute("recolor.colors", &json!({})).unwrap_or_default(); + let multi = r["colors"].as_array().is_some_and(|c| c.len() > 1); + set_pstate(ctx, "props-multi-color", Some((key, multi))); + multi + } + } +} + fn document_sections(app: &mut VectorcraftApp, ui: &mut Ui) { let units = app.session.active().map(|d| d.doc.units).unwrap_or_default(); section_header(ui, "Document"); @@ -262,25 +281,12 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) { } fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); let Some(n) = first_selected(app) else { return }; section_header(ui, "Appearance"); - let (fill, stroke) = super::current_paints(app); let weight = super::stroke::shown_weight(app, super::current_stroke(app).as_ref(), &super::stroke_mixed(app, ui.ctx())); - for (label, paint, is_fill) in [("Fill", fill, true), ("Stroke", stroke, false)] { + for (label, is_fill) in [("Fill", true), ("Stroke", false)] { ui.horizontal(|ui| { - let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); - widgets::paint_chip(ui, r.shrink(2.0), &paint); - if !is_fill { - ui.painter().rect_filled(r.shrink(7.0), 0.0, t.panel); - } - ui.painter().rect_stroke(r.shrink(2.0), 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Outside); - if resp.double_clicked() { - app.run("ui.colorPicker", json!({ "stroke": !is_fill })).ok(); - } else if resp.clicked() { - app.session.fill_active = is_fill; - app.ui.open_panel = Some("swatches".into()); - } + super::paint_chip(app, ui, !is_fill, 22.0, false); ui.label(egui::RichText::new(label).size(12.0)); if !is_fill { ui.add_space(8.0); diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index e1f09163..ab91c3a6 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -578,6 +578,21 @@ fn find_id() -> egui::Id { } pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { + body(app, ui, TILE); +} + +/// The panel in a Fill/Stroke chip popover (the Control bar's and Properties'): the same body, its +/// tiles under their own ids so it can be open beside the panel. +pub(crate) fn popover(app: &mut VectorcraftApp, ui: &mut Ui) { + body(app, ui, POPOVER_TILE); +} + +/// Id salts of the panel's tiles and of a popover's. +const TILE: &str = "swatch-tile"; +const POPOVER_TILE: &str = "swatch-pop-tile"; + +/// The panel's body; `salt` keys its tiles (see [`tile_id`]). +fn body(app: &mut VectorcraftApp, ui: &mut Ui, salt: &'static str) { if app.session.active().is_none() { super::empty_state(ui, "swatch-book", "No document", "Open a document to see its swatches."); return; @@ -621,7 +636,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if view.is_list() { for e in &items { let name = e.name(); - let (r, resp, chip) = list_row(ui, tile_id(e), view, is_sel(name)); + let (r, resp, chip) = list_row(ui, tile_id(salt, e), view, is_sel(name)); match e { Entry::Registration => draw_registration(ui, chip), Entry::Swatch { paint, global, spot, .. } => { @@ -636,7 +651,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } } else { for (e, cell) in tile_grid(ui, &items, view, Entry::is_folder) { - let resp = ui.interact(cell, tile_id(e), Sense::click_and_drag()); + let resp = ui.interact(cell, tile_id(salt, e), Sense::click_and_drag()); let name = e.name(); match e { Entry::Registration => draw_registration(ui, cell), @@ -740,9 +755,9 @@ pub(crate) fn folder_tile(ui: &Ui, cell: Rect, selected: bool) { } } -/// The interaction id of a tile or list row (stable per swatch or group name). -fn tile_id(e: &Entry) -> egui::Id { - egui::Id::new(("swatch-tile", e.name())) +/// The interaction id of a tile or list row (stable per swatch or group name and host `salt`). +fn tile_id(salt: &'static str, e: &Entry) -> egui::Id { + egui::Id::new((salt, e.name())) } /// The selected swatches and groups that can be deleted (all but None and Registration). diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index e145c7de..919bef73 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -79,11 +79,35 @@ pub fn default_command_shortcut(id: &str) -> Option<&'static str> { menus::UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| c.2).filter(|s| !s.is_empty()) } -/// Default shortcut of an entry key (`tool:` or a command id). +/// Window menu items that show a panel (`window.panel {panel}`) with a default shortcut: (panel +/// id, shortcut). Every icon panel can be given one in the editor (entry key `panel:`). +pub const PANEL_SHORTCUTS: &[(&str, &str)] = &[ + ("color", "F6"), + ("colorGuide", "Shift+F3"), + ("appearance", "Shift+F6"), + ("graphicStyles", "Shift+F5"), + ("stroke", "Cmd+F10"), + ("gradient", "Cmd+F9"), + ("transparency", "Cmd+Shift+F10"), +]; + +/// Default shortcut of an entry key (`tool:`, `panel:` or a command id). pub fn default_of(key: &str) -> Option<&'static str> { - match key.strip_prefix("tool:") { - Some(t) => vectorcraft_tools::tool_info(t).and_then(|t| t.shortcut), - None => default_command_shortcut(key), + if let Some(t) = key.strip_prefix("tool:") { + return vectorcraft_tools::tool_info(t).and_then(|t| t.shortcut); + } + if let Some(p) = key.strip_prefix("panel:") { + return PANEL_SHORTCUTS.iter().find(|(id, _)| *id == p).map(|(_, sc)| *sc); + } + default_command_shortcut(key) +} + +/// Effective shortcut of an entry key, honouring the user's overrides. +fn effective(key: &str) -> Option<&'static str> { + match global_override(key) { + Some("") => None, + Some(s) => Some(s), + None => default_of(key), } } @@ -98,20 +122,17 @@ pub fn effective_in(overrides: &BTreeMap, key: &str) -> Option Option<&'static str> { - match global_override(id) { - Some("") => None, - Some(s) => Some(s), - None => default_command_shortcut(id), - } + effective(id) } /// Effective shortcut of a tool. pub fn tool_shortcut(tool: &str) -> Option<&'static str> { - match global_override(&format!("tool:{tool}")) { - Some("") => None, - Some(s) => Some(s), - None => vectorcraft_tools::tool_info(tool).and_then(|t| t.shortcut), - } + effective(&format!("tool:{tool}")) +} + +/// Effective shortcut of the Window menu item showing `panel` (`window.panel {panel}`). +pub fn panel_shortcut(panel: &str) -> Option<&'static str> { + effective(&format!("panel:{panel}")) } /// Tool whose (effective) shortcut is `key` ("V", "Shift+M"). @@ -205,6 +226,12 @@ pub fn entries() -> &'static [Entry] { } v.push(Entry { key: c.id.into(), label: c.label.into(), group: c.menu.join(" › "), is_tool: false }); } + v.extend(crate::state::ICON_PANELS.iter().map(|(id, label, _)| Entry { + key: format!("panel:{id}"), + label: label.to_string(), + group: "Window".into(), + is_tool: false, + })); for (id, label, sc, params) in menus::UI_COMMANDS { if !seen.insert(id) || (sc.is_empty() && !params.starts_with("{}") && !params.starts_with("{path")) { continue; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index d1ac7d4a..eaf5f988 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -43,21 +43,27 @@ pub fn parse(s: &str) -> Option { Some(KeyboardShortcut::new(m, key)) } -/// Every command shortcut in effect (user overrides from Edit → Keyboard Shortcuts win). -fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str)> { - let mut v: Vec<(KeyboardShortcut, &'static str)> = vec![]; +/// Every command shortcut in effect (user overrides from Edit → Keyboard Shortcuts win), with the +/// command's params: `{}`, or `{panel}` for the panel shortcuts (`window.panel`). +pub(crate) fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str, serde_json::Value)> { + let mut v = vec![]; for c in vectorcraft_engine::command_specs() { if let Some(sc) = crate::menus::shortcut_of(c.id).and_then(parse) { - v.push((sc, c.id)); + v.push((sc, c.id, json!({}))); } } for c in crate::menus::UI_COMMANDS { if let Some(sc) = crate::menus::shortcut_of(c.0).and_then(parse) { - v.push((sc, c.0)); + v.push((sc, c.0, json!({}))); + } + } + for (panel, _, _) in crate::state::ICON_PANELS { + if let Some(sc) = crate::shortcut_editor::panel_shortcut(panel).and_then(parse) { + v.push((sc, "window.panel", json!({ "panel": panel }))); } } // Most specific (most modifiers) first so Cmd+Shift+Z isn't eaten by Cmd+Z. - v.sort_by_key(|(sc, _)| { + v.sort_by_key(|(sc, ..)| { std::cmp::Reverse(sc.modifiers.shift as u8 + sc.modifiers.alt as u8 + sc.modifiers.command as u8 + sc.modifiers.ctrl as u8) }); v @@ -105,13 +111,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { // Editing keys with modifiers, clipboard and Cmd+A. crate::panels::character::route_type_input(app, ctx); // Enter was already delivered above as ToolKey::Enter (newline). - let fire = all_shortcuts() - .into_iter() - .filter(|(sc, _)| sc.modifiers.command) - .find(|(sc, _)| ctx.input_mut(|i| i.consume_shortcut(sc))) - .map(|(_, id)| id); - if let Some(id) = fire { - crate::menus::invoke(app, id, json!({})); + let fire = all_shortcuts().into_iter().filter(|(sc, ..)| sc.modifiers.command).find(|(sc, ..)| ctx.input_mut(|i| i.consume_shortcut(sc))); + if let Some((_, id, p)) = fire { + crate::menus::invoke(app, id, p); } return; } @@ -156,8 +158,8 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { } } // Command shortcuts. - let mut fire: Option<&'static str> = None; - for (sc, id) in all_shortcuts() { + let mut fire = None; + for (sc, id, p) in all_shortcuts() { // Letter / punctuation keys without Cmd/Alt/Ctrl are handled below as text (tool shortcuts, // X, D, /, `,` and `.`). let plain = !(sc.modifiers.command || sc.modifiers.alt || sc.modifiers.ctrl); @@ -168,12 +170,12 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { continue; } if ctx.input_mut(|i| i.consume_shortcut(&sc)) { - fire = Some(id); + fire = Some((id, p)); break; } } - if let Some(id) = fire { - crate::menus::invoke(app, id, json!({})); + if let Some((id, p)) = fire { + crate::menus::invoke(app, id, p); return; } // Arrow nudges (Shift = ×10, Alt = copy). diff --git a/crates/ui-egui/src/tests_paintchips.rs b/crates/ui-egui/src/tests_paintchips.rs new file mode 100644 index 00000000..c3315302 --- /dev/null +++ b/crates/ui-egui/src/tests_paintchips.rs @@ -0,0 +1,165 @@ +//! The Fill/Stroke proxies and chips (toolbar, Control bar, Properties), their swatch and mixer +//! popovers, the Recolor quick action and the panel shortcuts (M3.51). + +use egui::{Color32, Event, Modifiers, PointerButton, Pos2, Rect, Shape, vec2}; +use serde_json::json; +use vectorcraft_engine::Session; + +use crate::{VectorcraftApp, chrome, panels, shortcut_editor, shortcuts, toolbar}; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app +} + +fn context() -> egui::Context { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx +} + +/// One headless frame of `draw` with `events` (and `modifiers` held); returns the painted shapes. +fn frame( + app: &mut VectorcraftApp, + ctx: &egui::Context, + events: Vec, + modifiers: Modifiers, + draw: fn(&mut VectorcraftApp, &mut egui::Ui), +) -> Vec { + let events = std::iter::once(Event::ModifiersChanged(modifiers)).chain(events).collect(); + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 1000.0))); + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect, ..Default::default() }, |ui| draw(app, ui)); + out.textures_delta.clear(); + out.shapes.into_iter().map(|c| c.shape).collect() +} + +/// Click at `at` with `modifiers` held (press and release in two frames), then settle a frame. +fn click(app: &mut VectorcraftApp, ctx: &egui::Context, at: Pos2, modifiers: Modifiers, draw: fn(&mut VectorcraftApp, &mut egui::Ui)) { + let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers }; + frame(app, ctx, vec![Event::PointerMoved(at), button(true)], modifiers, draw); + frame(app, ctx, vec![button(false)], modifiers, draw); + frame(app, ctx, vec![], Modifiers::NONE, draw); +} + +/// Whether `shapes` paint a rectangle filled with `color`. +fn paints_rect(shapes: &[Shape], color: Color32) -> bool { + shapes.iter().any(|s| match s { + Shape::Rect(r) => r.fill == color, + Shape::Vec(v) => paints_rect(v, color), + _ => false, + }) +} + +/// The Control bar's Fill and Stroke chips (22 pt squares with a chevron), left to right. +fn chips(ctx: &egui::Context) -> (Rect, Rect) { + let mut r: Vec = ctx.viewport(|vp| { + vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == vec2(38.0, 22.0)).map(|w| w.rect).collect() + }); + r.sort_by(|a, b| a.left().total_cmp(&b.left())); + (r[0], r[1]) +} + +fn control_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) { + chrome::control_bar(app, ui); +} + +fn fill_hex(app: &VectorcraftApp) -> String { + panels::current_paints(app).0.color().map(|c| c.to_hex()).unwrap_or_default() +} + +#[test] +fn red_text_shows_a_red_toolbar_fill() { + let mut app = app(); + app.run("text.create", json!({"x": 20, "y": 60, "text": "Red"})).unwrap(); + app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap(); + let ctx = context(); + frame(&mut app, &ctx, vec![], Modifiers::NONE, toolbar::show); + let shapes = frame(&mut app, &ctx, vec![], Modifiers::NONE, toolbar::show); + assert!(paints_rect(&shapes, Color32::from_rgb(255, 0, 0)), "the toolbar's Fill proxy shows the type's red"); +} + +#[test] +fn the_chips_show_a_question_mark_for_mixed_fills() { + let mut app = app(); + let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap(); + let b = app.run("shape.rectangle", json!({"x": 100, "y": 10, "width": 50, "height": 50})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"color": "#00ff00"})).unwrap(); + let text = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, control_bar); + assert!(!text(&mut app).lines().any(|l| l == "?")); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + assert!(text(&mut app).lines().any(|l| l == "?"), "the Fill chip shows ?"); + let props = crate::tests_labels::painted_text(&mut app, panels::properties::show); + assert!(props.lines().any(|l| l == "?"), "so does the Properties panel's"); +} + +#[test] +fn a_popover_tile_click_sets_the_fill() { + let mut app = app(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + app.run("paint.toggleActive", json!({"fill": false})).unwrap(); + let ctx = context(); + frame(&mut app, &ctx, vec![], Modifiers::NONE, control_bar); + let (fill, stroke) = chips(&ctx); + click(&mut app, &ctx, fill.center(), Modifiers::NONE, control_bar); + assert!(app.session.fill_active, "the Fill chip brings its proxy forward"); + let tile = ctx.read_response(egui::Id::new(("swatch-pop-tile", "Red"))).expect("the popover shows the swatches").rect; + click(&mut app, &ctx, tile.center(), Modifiers::NONE, control_bar); + assert_eq!(fill_hex(&app), "#ed1c24"); + assert_eq!(app.session.doc().unwrap().history.undo.last().unwrap().label, "Fill Color", "through paint.setFill"); + // The Stroke chip: its proxy comes forward; Shift-click shows the mixer instead of swatches. + click(&mut app, &ctx, Pos2::new(1300.0, 900.0), Modifiers::NONE, control_bar); + click(&mut app, &ctx, stroke.center(), Modifiers::SHIFT, control_bar); + assert!(!app.session.fill_active); + assert!(ctx.read_response(egui::Id::new(("swatch-pop-tile", "Red"))).is_none(), "no swatches in the mixer"); + let text = { + let mut t = String::new(); + for s in frame(&mut app, &ctx, vec![], Modifiers::NONE, control_bar) { + if let Shape::Text(g) = s { + t.push_str(g.galley.text()); + t.push('\n'); + } + } + t + }; + assert!(text.contains("Recent Colors"), "the Color panel's body: {text}"); +} + +#[test] +fn recolor_is_a_quick_action_for_multicolour_selections() { + let mut app = app(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + app.run("paint.setStroke", json!({"none": true})).unwrap(); + let text = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, panels::properties::show); + assert!(!text(&mut app).contains("Recolor"), "one colour"); + app.run("paint.setStroke", json!({"color": "#ff0000"})).unwrap(); + assert!(text(&mut app).contains("Recolor"), "white and red"); +} + +#[test] +fn the_shortcut_table_maps_f6_to_color() { + assert_eq!(shortcut_editor::panel_shortcut("color"), Some("F6")); + assert_eq!(shortcut_editor::panel_shortcut("transparency"), Some("Cmd+Shift+F10")); + let all = shortcuts::all_shortcuts(); + let f6 = all.iter().find(|(sc, ..)| sc.logical_key == egui::Key::F6 && sc.modifiers == Modifiers::NONE).expect("F6"); + assert_eq!((f6.1, &f6.2), ("window.panel", &json!({"panel": "color"}))); + // The keys toggle their panels; Shift+F6 isn't taken by F6. + let mut app = app(); + let ctx = context(); + let key = + |key, modifiers| vec![Event::ModifiersChanged(modifiers), Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }]; + let press = |app: &mut VectorcraftApp, k, m| { + let mut out = ctx.run_ui(egui::RawInput { events: key(k, m), ..Default::default() }, |ui| shortcuts::handle(app, ui.ctx())); + out.textures_delta.clear(); + }; + press(&mut app, egui::Key::F6, Modifiers::NONE); + assert_eq!(app.ui.open_panel.as_deref(), Some("color")); + press(&mut app, egui::Key::F6, Modifiers::SHIFT); + assert_eq!(app.ui.open_panel.as_deref(), Some("appearance")); + press(&mut app, egui::Key::F9, Modifiers::COMMAND); + assert_eq!(app.ui.open_panel.as_deref(), Some("gradient")); + // The Window menu lists them. + let color = crate::menus::menu_entries(&app).into_iter().find(|e| e.path == ["Window"] && e.label == "Color").unwrap(); + assert_eq!(color.shortcut, "F6"); +} 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/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 9a429e55..d2711516 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -236,6 +236,45 @@ pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::Gradient } } +/// A Fill chip of `paint` into `rect` or, with `ring`, a Stroke chip: a frame `ring` wide round a +/// panel-coloured hole (returned; the whole chip for fills). A selection whose paints differ +/// (`mixed`) shows a white chip with a "?" (in the hole for strokes). +pub fn proxy_chip(ui: &Ui, rect: Rect, paint: &Paint, mixed: bool, ring: Option) -> Rect { + let t = Tokens::get(ui.ctx()); + if mixed { + ui.painter().rect_filled(rect, 0.0, Color32::WHITE); + } else { + paint_chip(ui, rect, paint); + } + let (hole, mark) = match ring { + Some(w) => { + let inner = rect.shrink(w); + ui.painter().rect_filled(inner, 0.0, t.panel); + (inner, t.text_strong) + } + None => (rect, Color32::BLACK), + }; + if mixed { + ui.painter().text(hole.center(), egui::Align2::CENTER_CENTER, "?", egui::FontId::proportional(hole.height() * 0.8), mark); + } + hole +} + +/// A chip that opens something (the Control bar's and Properties' Fill/Stroke chips, the Control +/// bar's Style picker): `draw` paints the `size`-square chip, which gets a frame, and with +/// `chevron` a chevron follows it. +pub fn chip_button(ui: &mut Ui, size: f32, chevron: bool, draw: impl FnOnce(&Ui, Rect)) -> Response { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(if chevron { size + 16.0 } else { size }, size), Sense::click()); + let chip = Rect::from_min_size(r.min, vec2(size, size)).shrink(2.0); + draw(ui, chip); + ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Outside); + if chevron { + icons::paint(ui, "chevron-down", Rect::from_center_size(pos2(chip.right() + 9.0, r.center().y), vec2(12.0, 12.0)), t.text_dim); + } + resp +} + /// What the user did on a [`fill_stroke_proxy`]. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct ProxyClicks { @@ -263,31 +302,13 @@ pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, mixed: (bool drag_source(ui, resp, || PanelDrag::paint(paint.clone())); } } - let question = |ui: &Ui, r: Rect, color: Color32| { - ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "?", egui::FontId::proportional(r.height() * 0.8), color); - }; let draw_fill = |ui: &Ui| { - if mixed.0 { - ui.painter().rect_filled(fill_r, 0.0, Color32::WHITE); - question(ui, fill_r, Color32::BLACK); - } else { - paint_chip(ui, fill_r, fill); - } + proxy_chip(ui, fill_r, fill, mixed.0, None); ui.painter().rect_stroke(fill_r, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Inside); ui.painter().rect_stroke(fill_r.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_gray(150)), StrokeKind::Outside); }; let draw_stroke = |ui: &Ui| { - let w = s * 0.28; - if mixed.1 { - ui.painter().rect_filled(stroke_r, 0.0, Color32::WHITE); - } else { - paint_chip(ui, stroke_r, stroke); - } - let inner = stroke_r.shrink(w); - ui.painter().rect_filled(inner, 0.0, t.panel); - if mixed.1 { - question(ui, inner, t.text_strong); - } + let inner = proxy_chip(ui, stroke_r, stroke, mixed.1, Some(s * 0.28)); ui.painter().rect_stroke(stroke_r, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Inside); ui.painter().rect_stroke(inner, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Outside); ui.painter().rect_stroke(stroke_r.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_gray(150)), StrokeKind::Outside); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index a0278e51..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 @@ -100,3 +101,9 @@ caption buttons (Minimize, Maximize/Restore, Close) sit at the bar's right end, not commands, so drive them with `ui.click` if needed. Close runs `app.quit` (the same `saveChanges` questions for modified documents), and empty bar space and the 5 pt window edges move and resize the window. macOS and the web build are unchanged. + +Fill/Stroke chips and panel shortcuts: the Control bar's and Properties' Fill and Stroke chips bring their proxy +forward (`paint.toggleActive {fill}`) and open a popover with the Swatches panel (Shift-click: the Color panel's mixer); +a swatch clicked there runs `paint.setFill` / `paint.setStroke`. Panel keys (Color F6, Color Guide Shift+F3, +Appearance Shift+F6, Graphic Styles Shift+F5, Stroke Cmd+F10, Gradient Cmd+F9, Transparency Cmd+Shift+F10) run +`window.panel {panel}` and can be pressed with `ui.key`; `ui.menu.list` shows them on the Window menu's items. 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]}}} +```