diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index db259875..4fec8988 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -168,6 +168,18 @@ pub fn duplicate_stop(stops: &[GradientStop], i: usize, offset: f32) -> (Vec Vec { + let mut v = stops.to_vec(); + if a < v.len() && b < v.len() { + let c = v[a].color; + v[a].color = v[b].color; + v[b].color = c; + } + v +} + /// Set the midpoint between stop `i` and `i + 1` (clamped to the diamond's 13–87 %). pub fn set_midpoint(stops: &[GradientStop], i: usize, m: f32) -> Vec { let mut v = stops.to_vec(); @@ -268,6 +280,18 @@ impl GradientGeom { self.aspect = if len > 1e-12 { other / len } else { 1.0 }; } + /// Move the placement from box `from` to box `to`: the start and end keep their position + /// relative to the box (along a side `from` has no extent on, their offset from its centre). + /// The aspect ratio is kept. + pub fn rebase(&mut self, from: Rect, to: Rect) { + let axis = |v: f64, a0: f64, a1: f64, b0: f64, b1: f64| { + if (a1 - a0).abs() > 1e-12 { b0 + (v - a0) / (a1 - a0) * (b1 - b0) } else { v - (a0 + a1) / 2.0 + (b0 + b1) / 2.0 } + }; + let map = |p: Point| Point::new(axis(p.x, from.x0, from.x1, to.x0, to.x1), axis(p.y, from.y0, from.y1, to.y0, to.y1)); + self.start = map(self.start); + self.end = map(self.end); + } + /// The gradient parameter (0 at the start, 1 at the end) at document point `p`: the projection /// onto the vector for linear gradients, the elliptical radius (honouring `aspect`) for radial. pub fn param_at(&self, kind: GradientKind, p: Point) -> f64 { @@ -320,6 +344,12 @@ impl GradientPaint { self.geom = Some(self.resolve(bounds)); } } + /// Move a placed gradient from box `from` to box `to` (see [`GradientGeom::rebase`]). + pub fn rebase(&mut self, from: Rect, to: Rect) { + if let Some(g) = &mut self.geom { + g.rebase(from, to); + } + } /// Map a placed gradient through `a` (see [`GradientGeom::transform`]). pub fn transform(&mut self, a: Affine) { if let Some(g) = &mut self.geom { @@ -480,6 +510,16 @@ mod tests { assert_eq!(duplicate_stop(&g.stops, 7, 0.5).0, g.stops); } + #[test] + fn swap_exchanges_only_the_colours() { + let mut g = g3(); + g.stops[0].opacity = 0.25; + let v = swap_stop_colors(&g.stops, 0, 1); + assert_eq!((v[0].color.to_hex(), v[1].color.to_hex()), ("#ff0000".to_string(), "#ffffff".to_string())); + assert_eq!((v[0].offset, v[0].opacity, v[1].offset, v[1].opacity), (0.0, 0.25, 0.5, 1.0)); + assert_eq!(swap_stop_colors(&g.stops, 0, 9), g.stops); + } + #[test] fn midpoint_math() { let g = g3(); @@ -492,6 +532,21 @@ mod tests { assert_eq!(v[0].midpoint, 0.87); } + #[test] + fn rebase_keeps_relative_positions() { + let mut g = GradientGeom { start: Point::new(110.0, 150.0), end: Point::new(190.0, 120.0), aspect: 0.5 }; + g.rebase(Rect::new(100.0, 100.0, 200.0, 200.0), Rect::new(500.0, 0.0, 700.0, 50.0)); + assert!(close(g.start, Point::new(520.0, 25.0)) && close(g.end, Point::new(680.0, 10.0)) && g.aspect == 0.5, "{g:?}"); + // A box without height (a horizontal line) keeps the offset from its centre line. + let mut h = GradientGeom { start: Point::new(0.0, 12.0), end: Point::new(10.0, 10.0), aspect: 1.0 }; + h.rebase(Rect::new(0.0, 10.0, 10.0, 10.0), Rect::new(0.0, 0.0, 20.0, 40.0)); + assert!(close(h.start, Point::new(0.0, 22.0)) && close(h.end, Point::new(20.0, 20.0)), "{h:?}"); + // Unplaced paints have nothing to move. + let mut p = GradientPaint::new(Gradient::default()); + p.rebase(Rect::new(0.0, 0.0, 1.0, 1.0), Rect::new(5.0, 5.0, 9.0, 9.0)); + assert_eq!(p.geom, None); + } + #[test] fn pin_fixes_the_fit_once() { let mut p = GradientPaint::new(Gradient::default()); diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 53dde758..7fa32f35 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -540,14 +540,49 @@ impl Appearance { _ => None, }) } - /// Does a fill or stroke carry a gradient that is fitted to the bounds on each render? - pub fn has_unplaced_gradient(&self) -> bool { + /// Gradient paints of the fills and strokes. + fn gradients(&self) -> impl Iterator { + self.items.iter().filter_map(|i| match i { + AppearanceItem::Fill(FillLayer { paint: Paint::Gradient(g), .. }) + | AppearanceItem::Stroke(StrokeLayer { paint: Paint::Gradient(g), .. }) => Some(&**g), + _ => None, + }) + } + /// Is `self` equal to `other`, placed gradients within rounding (1e-6 pt) of each other, as + /// moving them between boxes leaves them? + pub fn approx_eq(&self, other: &Appearance) -> bool { + if self == other { + return true; + } + let mut a = self.clone(); + for (g, o) in a.gradients_mut().zip(other.gradients()) { + if let (Some(ga), Some(go)) = (&mut g.geom, &o.geom) + && ga.start.distance(go.start) <= 1e-6 + && ga.end.distance(go.end) <= 1e-6 + && (ga.aspect - go.aspect).abs() <= 1e-6 + { + *ga = *go; + } + } + a == *other + } + /// Does a fill or stroke carry a gradient whose placement `placed` is (some: placed in document + /// space; none: fitted to the bounds on each render)? + fn has_gradient(&self, placed: bool) -> bool { self.items.iter().any(|i| match i { AppearanceItem::Fill(FillLayer { paint: Paint::Gradient(g), .. }) - | AppearanceItem::Stroke(StrokeLayer { paint: Paint::Gradient(g), .. }) => g.geom.is_none(), + | AppearanceItem::Stroke(StrokeLayer { paint: Paint::Gradient(g), .. }) => g.geom.is_some() == placed, _ => false, }) } + /// Does a fill or stroke carry a gradient that is fitted to the bounds on each render? + pub fn has_unplaced_gradient(&self) -> bool { + self.has_gradient(false) + } + /// Does a fill or stroke carry a placed gradient (start and end in document space)? + pub fn has_placed_gradient(&self) -> bool { + self.has_gradient(true) + } /// Fix unplaced gradients to their fit on `bounds` (the object's geometric bounds; strokes fit /// the stroke-inflated box), so they can follow transforms that refitting wouldn't reproduce. pub fn pin_gradients(&mut self, bounds: vectorcraft_geom::Rect) { @@ -564,6 +599,23 @@ impl Appearance { } } } + /// Move placed gradients from an object whose geometric bounds are `from` to one whose bounds + /// are `to`, keeping them at the same place relative to the box (strokes: relative to their + /// stroke-inflated boxes, as they fit). + pub fn rebase_gradients(&mut self, from: vectorcraft_geom::Rect, to: vectorcraft_geom::Rect) { + for i in &mut self.items { + match i { + AppearanceItem::Fill(FillLayer { paint: Paint::Gradient(g), .. }) => g.rebase(from, to), + AppearanceItem::Stroke(s) => { + let (f, t) = (s.paint_bounds(from), s.paint_bounds(to)); + if let Paint::Gradient(g) = &mut s.paint { + g.rebase(f, t); + } + } + _ => {} + } + } + } /// Map placed gradients through `a`. pub fn transform_gradients(&mut self, a: vectorcraft_geom::Affine) { for g in self.gradients_mut() { @@ -594,6 +646,25 @@ mod tests { assert_eq!(a.stroke_width(), 1.0); } + #[test] + fn rebase_moves_fills_by_the_box_and_strokes_by_their_inflated_box() { + use vectorcraft_color::{Gradient, GradientGeom, GradientPaint}; + use vectorcraft_geom::{Point, Rect}; + let placed = |x: f64| { + let mut g = GradientPaint::new(Gradient::default()); + g.geom = Some(GradientGeom { start: Point::new(x, 0.0), end: Point::new(x + 10.0, 0.0), aspect: 1.0 }); + Paint::Gradient(Box::new(g)) + }; + let mut a = Appearance::basic(placed(0.0), placed(-5.0), 10.0); + a.rebase_gradients(Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(100.0, 0.0, 300.0, 100.0)); + let start = |p: Paint| match p { + Paint::Gradient(g) => g.geom.unwrap().start, + _ => unreachable!(), + }; + // The fill's start stays on the left edge; the stroke's on its inflated box's (−5 → 95). + assert_eq!((start(a.fill_paint()), start(a.stroke_paint())), (Point::new(100.0, 0.0), Point::new(95.0, 0.0))); + } + #[test] fn items_by_index() { let mut a = Appearance::default_art(); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index f0bdd7ce..2faf4195 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -282,6 +282,11 @@ pub struct GraphicStyle { pub isolate: bool, #[serde(default, skip_serializing_if = "skip::is_default")] pub knockout: Knockout, + /// Placed gradients are stored relative to the unit box (0, 0)–(1, 1), so applying the style + /// places them on each object's own bounds. Styles saved before this kept document + /// coordinates (false). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub unit_box: bool, } fn one() -> f32 { @@ -292,14 +297,40 @@ fn one() -> f32 { pub const DEFAULT_GRAPHIC_STYLE: &str = "Default Graphic Style"; impl GraphicStyle { - /// A style with `appearance` and default transparency (no id yet). + /// The box a style's placed gradients are stored relative to. + pub const UNIT_BOX: Rect = Rect::new(0.0, 0.0, 1.0, 1.0); + + /// A style with `appearance` (its placed gradients, if any, in unit-box space) and default + /// transparency (no id yet). pub fn new(name: impl Into, appearance: Appearance) -> Self { - Self { name: name.into(), appearance, id: 0, opacity: 1.0, blend: Default::default(), isolate: false, knockout: Knockout::Neutral } + Self { + name: name.into(), + appearance, + id: 0, + opacity: 1.0, + blend: Default::default(), + isolate: false, + knockout: Knockout::Neutral, + unit_box: true, + } } - /// A style capturing `n`'s transparency and `appearance`. + /// A style capturing `n`'s transparency and `appearance` (in unit-box space). pub fn of(name: impl Into, appearance: Appearance, n: &Node) -> Self { Self { opacity: n.opacity, blend: n.blend, isolate: n.isolate, knockout: n.knockout, ..Self::new(name, appearance) } } + /// The style's appearance as object `n` takes it: placed gradients stored in unit-box space + /// land at the same place relative to `n`'s geometric bounds. + pub fn appearance_on(&self, n: &Node) -> std::borrow::Cow<'_, Appearance> { + let placed = self.unit_box && self.appearance.has_placed_gradient(); + match n.geometric_bounds().filter(|_| placed) { + Some(b) => { + let mut ap = self.appearance.clone(); + ap.rebase_gradients(Self::UNIT_BOX, b); + std::borrow::Cow::Owned(ap) + } + None => std::borrow::Cow::Borrowed(&self.appearance), + } + } /// Give `n` this style's transparency. pub fn apply_transparency(&self, n: &mut Node) { (n.opacity, n.blend, n.isolate, n.knockout) = (self.opacity, self.blend, self.isolate, self.knockout); diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 0a5010da..f623671b 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -83,7 +83,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?} copy fill, stroke, weight, opacity and blend from `source` to ids (default: selection) and to the paint defaults", + "{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)", has_doc, copy_from ), @@ -461,18 +461,31 @@ fn move_item(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))?; - let appearance = match &src.kind { - NodeKind::Text(t) => match t.runs.first() { - Some(r) => Appearance::basic(r.style.fill.clone(), r.style.stroke.clone(), r.style.stroke_width), - None => src.appearance.clone(), - }, - _ => src.appearance.clone(), + // 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]; + (Appearance::basic(r.style.fill.clone(), r.style.stroke.clone(), r.style.stroke_width), Some(t.local_bounds())) + } + _ => (src.appearance.clone(), src.geometric_bounds()), }; - s.paint.fill = appearance.fill_paint(); - s.paint.stroke = appearance.stroke_paint(); - s.remember_paint(&s.paint.fill.clone()); - if appearance.stroke().is_some() { - s.paint.stroke_width = appearance.stroke_width(); + // `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") { Some(v) => v, @@ -480,24 +493,30 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { }; let mut targets = leaf_targets(s, &ids)?; targets.retain(|id| *id != src_id); - if targets.is_empty() { + if targets.is_empty() || !(opts.appearance || opts.transparency) { 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 }; - n.opacity = opacity; - n.blend = blend; + if opts.transparency { + n.opacity = opacity; + n.blend = blend; + } + if !opts.appearance { + continue; + } if let NodeKind::Text(t) = &mut n.kind { + let ap = placed(Some(t.local_bounds())); for r in &mut t.runs { - r.style.fill = appearance.fill_paint(); - r.style.stroke = appearance.stroke_paint(); - r.style.stroke_width = appearance.stroke_width(); + r.style.fill = ap.fill_paint(); + r.style.stroke = ap.stroke_paint(); + r.style.stroke_width = ap.stroke_width(); } continue; } - n.appearance = appearance.clone(); + n.appearance = placed(n.geometric_bounds()); } Ok(()) })?; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index d0bba76c..866e1011 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -346,3 +346,6 @@ pub(crate) fn parse_range(s: &str, count: usize) -> std::result::Result Vec { "Apply Graphic Style", ["Window", "Graphic Styles"], None, - "{name, add?: bool, ids?} give the objects (default: the selection; a group's contents) the style's appearance, and themselves its opacity, blend mode, isolate and knockout, and link them to it; add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks", + "{name, add?: bool, ids?} give the objects (default: the selection; a group's contents) the style's appearance, and themselves its opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks", has_doc, style_apply ), @@ -29,7 +34,7 @@ pub fn specs() -> Vec { "New Graphic Style", ["Window", "Graphic Styles"], None, - "{name?, id?} a style from object `id` or the first selected object: its appearance, opacity, blend mode, isolate and knockout (a group without fills or strokes of its own lends its topmost object's, type its characters'); links the object. Names are made unique → {name}", + "{name?, id?} a style from object `id` or the first selected object: its appearance, opacity, blend mode, isolate and knockout (a group without fills or strokes of its own lends its topmost object's, type its characters'; placed gradients are kept relative to its bounds); links the object. Names are made unique → {name}", has_doc, style_new ), @@ -136,7 +141,7 @@ fn paint_mut(n: &mut Node, top: bool, f: &mut impl FnMut(&mut Node)) { pub(crate) fn in_sync(n: &Node, g: &GraphicStyle) -> bool { let mut leaves = vec![]; painted(n, true, &mut leaves); - g.transparency_matches(n) && leaves.iter().all(|l| l.appearance == g.appearance) + g.transparency_matches(n) && leaves.iter().all(|l| g.appearance_on(l).approx_eq(&l.appearance)) } /// The style `n` is linked to (and still looks like). @@ -170,18 +175,29 @@ fn links(d: &Document) -> HashMap> { out } +/// `ap` with its placed gradients moved from box `from` (an object's bounds) to the unit box. +fn unitized(mut ap: Appearance, from: Option) -> Appearance { + if let Some(b) = from.filter(|_| ap.has_placed_gradient()) { + ap.rebase_gradients(b, GraphicStyle::UNIT_BOX); + } + ap +} + /// The appearance a new or redefined style takes from `n`: its own. A group without fills or /// strokes of its own lends those of its topmost painted object, and type its characters' fill -/// and stroke. +/// and stroke. Placed gradients come relative to the unit box (from the bounds of the object +/// they're on). fn captured(n: &Node) -> Appearance { let mut ap = n.appearance.clone(); if !ap.items.is_empty() { - return ap; + return unitized(ap, n.geometric_bounds()); } match &n.kind { NodeKind::Text(t) => { if let Some(r) = t.runs.first() { ap.items = Appearance::basic(r.style.fill.clone(), r.style.stroke.clone(), r.style.stroke_width).items; + // Type paints its characters in text space. + ap = unitized(ap, Some(t.local_bounds())); } } _ => { @@ -201,7 +217,7 @@ fn captured(n: &Node) -> Appearance { /// Give `id` style `g` (its painted objects the appearance, itself the transparency) and link it. fn apply_style(d: &mut Document, id: NodeId, g: &GraphicStyle) { if let Some(n) = d.node_mut(id) { - paint_mut(n, true, &mut |l| l.appearance = g.appearance.clone()); + paint_mut(n, true, &mut |l| l.appearance = g.appearance_on(l).into_owned()); g.apply_transparency(n); n.graphic_style = Some(g.id); } @@ -251,12 +267,13 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { } s.edit("Apply Graphic Style", |d, _| { if add { - let ap = d.graphic_styles[i].appearance.clone(); + let g = d.graphic_styles[i].clone(); for id in &ids { if let Some(n) = d.node_mut(*id) { paint_mut(n, true, &mut |l| { - l.appearance.items.extend(ap.items.iter().cloned()); - l.appearance.effects.extend(ap.effects.iter().cloned()); + let ap = g.appearance_on(l).into_owned(); + l.appearance.items.extend(ap.items); + l.appearance.effects.extend(ap.effects); }); n.graphic_style = None; } diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index 90e5892c..5ca14a9f 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use serde_json::{Value, json}; +use vectorcraft_color::Paint; use vectorcraft_doc::{Node, NodeId, NodeKind}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; @@ -26,10 +27,19 @@ pub fn specs() -> Vec { "Sample Color", [], None, - "{color, stroke?: bool (default: whichever proxy is active), ids?} sample a colour into the active fill/stroke (and the selection)", + "{color, stroke?: bool (default: whichever proxy is active), ids?, stop?: index} sample a colour into the active fill/stroke (and the selection); with `stop`, recolour that stop of the gradient behind the proxy instead (paint.editGradient)", has_doc, sample_color ), + cmd!( + "eyedropper.setOptions", + "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}", + always, + eyedropper_options + ), cmd!( "artboard.move", "Move Artboard", @@ -161,14 +171,57 @@ fn distort(s: &mut Session, p: &Value) -> Result { // ---------- eyedropper ---------- +/// What the Eyedropper copies from the object it clicks (`appearance.copyFrom`). +#[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. + pub transparency: bool, +} + +impl Default for EyedropperOptions { + fn default() -> Self { + Self { appearance: true, transparency: true } + } +} + +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 + } +} + +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()) +} + fn sample_color(s: &mut Session, p: &Value) -> Result { + const C: &str = "paint.sampleColor"; let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active); - let c = p.get("color").ok_or_else(|| bad("paint.sampleColor", "missing color"))?; - let mut q = json!({ "color": c }); + let c = p.get("color").ok_or_else(|| bad(C, "missing color"))?; + let (id, mut q) = match p.get("stop") { + None | Some(Value::Null) => (if stroke { "paint.setStroke" } else { "paint.setFill" }, json!({ "color": c })), + Some(v) => { + let i = v.as_u64().ok_or_else(|| bad(C, "`stop` must be a stop index"))? as usize; + let color = color_value(c).ok_or_else(|| bad(C, format!("bad color {c}")))?; + let (fill, stroke_paint) = s.proxy_paints(); + let Paint::Gradient(g) = (if stroke { stroke_paint } else { fill }) else { + return Err(bad(C, "the paint behind the proxy is not a gradient")); + }; + let mut stops = g.gradient.stops; + let n = stops.len(); + stops.get_mut(i).ok_or_else(|| bad(C, format!("no stop {i} (the gradient has {n})")))?.color = color; + ("paint.editGradient", json!({ "stops": vectorcraft_tools::params::stops_json(&stops), "stroke": stroke })) + } + }; if let Some(ids) = p.get("ids") { q["ids"] = ids.clone(); } - let id = if stroke { "paint.setStroke" } else { "paint.setFill" }; let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?; (spec.run)(s, &q) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 91362a01..8cdb54b8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -18,6 +18,7 @@ use vectorcraft_doc::{Document, NodeId, NodeKind, Selection}; use vectorcraft_geom::Affine; use vectorcraft_tools::{PaintDefaults, Tool}; +pub use cmd::EyedropperOptions; pub use cmd::rasterfx::{export_pdf, flatten_raster_effects}; pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; pub use tooling::{UiRequest, ViewInfo}; @@ -459,6 +460,8 @@ 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, } impl Default for Session { @@ -490,6 +493,7 @@ impl Session { last_gradient: GradientPaint::new(Default::default()), recent_colors: vec![], pending_paint: None, + eyedropper: Default::default(), } } @@ -754,6 +758,8 @@ mod tests_file; #[cfg(test)] mod tests_gradient; #[cfg(test)] +mod tests_gradpanel; +#[cfg(test)] mod tests_knockout; #[cfg(test)] mod tests_live; diff --git a/crates/engine/src/tests_gradpanel.rs b/crates/engine/src/tests_gradpanel.rs new file mode 100644 index 00000000..104dff92 --- /dev/null +++ b/crates/engine/src/tests_gradpanel.rs @@ -0,0 +1,168 @@ +//! Gradient panel and gradient behaviour: sampling a colour into a gradient stop (M3.33) and +//! copied appearances placing their gradients on the target's own bounds (M3.34) and the +//! Gradient tool's snapping and Eyedropper Options (M3.35). + +use serde_json::json; +use vectorcraft_color::{GradientGeom, Paint}; +use vectorcraft_geom::Point; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn stop_hexes(s: &Session, id: NodeId, stroke: bool) -> Vec { + let a = &s.doc().unwrap().doc.node(id).unwrap().appearance; + match if stroke { a.stroke_paint() } else { a.fill_paint() } { + Paint::Gradient(g) => g.gradient.stops.iter().map(|s| s.color.to_hex()).collect(), + p => panic!("expected a gradient, got {p:?}"), + } +} + +#[test] +fn sample_color_with_a_stop_recolours_that_stop_in_one_undo_step() { + let mut s = session(); + let id = rect(&mut s, 10.0, 10.0, 100.0, 100.0); + // A solid fill: no stop to recolour. + let err = s.execute("paint.sampleColor", &json!({"color": "#ff0000", "stop": 0})).unwrap_err(); + assert!(err.to_string().contains("not a gradient"), "{err}"); + s.execute("paint.setFill", &json!({"gradient": {}})).unwrap(); + s.execute("paint.sampleColor", &json!({"color": "#ff0000", "stop": 1})).unwrap(); + assert_eq!(stop_hexes(&s, id, false), ["#ffffff", "#ff0000"]); + assert!(s.execute("paint.sampleColor", &json!({"color": "#ff0000", "stop": 2})).unwrap_err().to_string().contains("no stop 2")); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(stop_hexes(&s, id, false), ["#ffffff", "#000000"]); + // The stroke's gradient through `stroke`, the fill left alone. + s.execute("paint.setStroke", &json!({"gradient": {}})).unwrap(); + s.execute("paint.sampleColor", &json!({"color": "#00ff00", "stop": 0, "stroke": true})).unwrap(); + assert_eq!( + (stop_hexes(&s, id, true), stop_hexes(&s, id, false)), + (vec!["#00ff00".into(), "#000000".into()], vec!["#ffffff".into(), "#000000".into()]) + ); +} + +fn fill_geom(s: &Session, id: NodeId) -> Option { + match s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint() { + Paint::Gradient(g) => g.geom, + p => panic!("expected a gradient, got {p:?}"), + } +} + +fn close(a: Point, b: Point) -> bool { + a.distance(b) < 1e-9 +} + +/// A 100 × 100 source at (10, 10) with a placed gradient from 20 % to 80 % across its middle, +/// and a 200 × 100 target at x = 500. +fn source_and_target() -> (Session, NodeId, NodeId) { + let mut s = session(); + let src = rect(&mut s, 10.0, 10.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"start": [30, 60], "end": [90, 60]}})).unwrap(); + let dst = rect(&mut s, 500.0, 10.0, 200.0, 100.0); + (s, src, dst) +} + +#[test] +fn the_eyedropper_places_copied_gradients_on_the_target() { + let (mut s, src, dst) = source_and_target(); + s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [dst.0]})).unwrap(); + let g = fill_geom(&s, dst).expect("placed"); + assert!(close(g.start, Point::new(540.0, 60.0)) && close(g.end, Point::new(660.0, 60.0)), "{g:?}"); + // The source keeps its own; new art fits the gradient to itself. + assert!(close(fill_geom(&s, src).unwrap().start, Point::new(30.0, 60.0))); + assert!(matches!(&s.paint.fill, Paint::Gradient(g) if g.geom.is_none())); + // The eyedropper tool's click runs the same command. + s.execute("select.set", &json!({"ids": [dst.0]})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.select_tool("eyedropper", ViewInfo::default()).unwrap(); + let ev = vectorcraft_tools::PointerEvent::new(vectorcraft_tools::PointerKind::Down, 50.0, 50.0); + s.pointer(&ev, ViewInfo::default()).unwrap(); + assert!(close(fill_geom(&s, dst).unwrap().start, Point::new(540.0, 60.0))); +} + +#[test] +fn graphic_styles_keep_gradients_relative_to_the_bounds() { + let (mut s, src, dst) = source_and_target(); + s.execute("select.set", &json!({"ids": [src.0]})).unwrap(); + s.execute("graphicStyle.new", &json!({"name": "Glow"})).unwrap(); + let style = s.doc().unwrap().doc.graphic_styles.iter().find(|g| g.name == "Glow").cloned().unwrap(); + let Paint::Gradient(g) = style.appearance.fill_paint() else { panic!() }; + assert!(style.unit_box && close(g.geom.unwrap().start, Point::new(0.2, 0.5)), "stored in unit-box space: {g:?}"); + s.execute("select.set", &json!({"ids": [dst.0]})).unwrap(); + s.execute("graphicStyle.apply", &json!({"name": "Glow"})).unwrap(); + let g = fill_geom(&s, dst).unwrap(); + assert!(close(g.start, Point::new(540.0, 60.0)) && close(g.end, Point::new(660.0, 60.0)), "{g:?}"); + // Both stay linked (each has the style's look on its own bounds); redefining the style from an + // edited source moves the new placement onto the linked target's bounds too. + let linked = |s: &mut Session| { + let list = s.execute("graphicStyle.list", &json!({})).unwrap(); + list["styles"].as_array().unwrap().iter().find(|g| g["name"] == "Glow").unwrap()["linked"].clone() + }; + assert_eq!(linked(&mut s), json!([src.0, dst.0])); + s.execute("select.set", &json!({"ids": [src.0]})).unwrap(); + s.execute("paint.setFill", &json!({"gradient": {"start": [13.3, 47.7], "end": [97.1, 71.9]}})).unwrap(); + assert_eq!(linked(&mut s), json!([dst.0]), "the edited source is no longer linked"); + s.execute("graphicStyle.redefine", &json!({"name": "Glow"})).unwrap(); + let g = fill_geom(&s, dst).unwrap(); + assert!(close(g.start, Point::new(506.6, 47.7)) && close(g.end, Point::new(674.2, 71.9)), "{g:?}"); + assert_eq!(linked(&mut s), json!([src.0, dst.0])); + s.execute("select.set", &json!({"ids": [dst.0]})).unwrap(); + // The flag survives a save; a style from an older file (no flag) keeps document coordinates. + let bytes = vectorcraft_format::save(&s.doc().unwrap().doc, false); + let back = vectorcraft_format::load(&bytes).unwrap(); + assert_eq!(back.graphic_styles, s.doc().unwrap().doc.graphic_styles); + let legacy: vectorcraft_doc::GraphicStyle = serde_json::from_value(json!({"name": "Old", "appearance": style.appearance})).unwrap(); + assert!(!legacy.unit_box); + s.edit("Add", |d, _| { + d.graphic_styles.push(legacy); + Ok(()) + }) + .unwrap(); + s.execute("graphicStyle.apply", &json!({"name": "Old"})).unwrap(); + assert!(close(fill_geom(&s, dst).unwrap().start, Point::new(0.2, 0.5)), "legacy styles are applied as stored"); +} + +#[test] +fn eyedropper_options_choose_what_copy_from_takes() { + let mut s = session(); + let src = rect(&mut s, 10.0, 10.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + 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 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(); + 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": []})); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); +} + +#[test] +fn the_gradient_tool_reads_the_constrain_angle_preference() { + let mut s = session(); + rect(&mut s, 100.0, 100.0, 100.0, 100.0); + s.execute("prefs.set", &json!({"key": "constrainAngle", "value": 30})).unwrap(); + s.select_tool("gradient", ViewInfo::default()).unwrap(); + let ev = |kind, x, y, shift| vectorcraft_tools::PointerEvent::new(kind, x, y).with_mods(vectorcraft_tools::Mods { shift, ..Default::default() }); + use vectorcraft_tools::PointerKind::{Down, Drag, Up}; + s.pointer(&ev(Down, 120.0, 180.0, false), ViewInfo::default()).unwrap(); + s.pointer(&ev(Drag, 190.0, 140.0, true), ViewInfo::default()).unwrap(); + s.pointer(&ev(Up, 190.0, 140.0, true), ViewInfo::default()).unwrap(); + let id = s.doc().unwrap().selection.objects[0]; + assert!((fill_geom(&s, id).unwrap().angle_deg() - 30.0).abs() < 1e-9); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 9f040abc..54129edc 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -81,6 +81,7 @@ impl Session { fill_active: self.fill_active, gradient_stop: self.selected_stop(), appearance_item: self.appearance_item(), + constrain_angle: self.prefs.constrain_angle, }; f(self.tool.as_mut(), &cx) } diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 8145625d..bc828e71 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -42,12 +42,18 @@ pub fn normalize_deg(mut a: f64) -> f64 { /// Snap a vector to the nearest multiple of `step_deg` (used for Shift-constrained drags). pub fn constrain_angle(v: Vec2, step_deg: f64) -> Vec2 { + constrain_angle_from(v, step_deg, 0.0) +} + +/// [`constrain_angle`] in steps counted from `base_deg` (the Constrain Angle preference, +/// counter-clockwise as seen on the y-down page). +pub fn constrain_angle_from(v: Vec2, step_deg: f64, base_deg: f64) -> Vec2 { let len = v.hypot(); if len < EPS { return v; } - let step = step_deg.to_radians(); - let a = (v.y.atan2(v.x) / step).round() * step; + let (step, base) = (step_deg.to_radians(), -base_deg.to_radians()); + let a = base + ((v.y.atan2(v.x) - base) / step).round() * step; Vec2::new(a.cos() * len, a.sin() * len) } @@ -83,6 +89,15 @@ mod tests { assert!((v.x - v.y).abs() < 1e-9); } + #[test] + fn constrain_from_a_base_angle() { + // 30° base: 45° steps land on 30°, 75°, … (counter-clockwise on the page, y down). + let deg = |v: Vec2| (-v.y).atan2(v.x).to_degrees(); + assert!((deg(constrain_angle_from(Vec2::new(10.0, -4.0), 45.0, 30.0)) - 30.0).abs() < 1e-9); + assert!((deg(constrain_angle_from(Vec2::new(3.0, -10.0), 45.0, 30.0)) - 75.0).abs() < 1e-9); + assert!((deg(constrain_angle_from(Vec2::new(10.0, 1.0), 45.0, 30.0)) + 15.0).abs() < 1e-9); + } + #[test] fn reference_points() { let r = Rect::new(0.0, 0.0, 10.0, 20.0); diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 605403fe..b26b9289 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -163,6 +163,8 @@ pub struct ToolContext<'a> { /// The Appearance panel's active fill/stroke (paint-order index into the first selected /// object's stack); paint tools show and edit that item. pub appearance_item: Option, + /// General → Constrain Angle (degrees): Shift constrains drags to 45° steps from it. + pub constrain_angle: f64, } impl ToolContext<'_> { @@ -346,6 +348,7 @@ pub(crate) mod testutil { fill_active: true, gradient_stop: None, appearance_item: None, + constrain_angle: 0.0, } } } diff --git a/crates/tools/src/xform.rs b/crates/tools/src/xform.rs index 706ccb0c..b978116e 100644 --- a/crates/tools/src/xform.rs +++ b/crates/tools/src/xform.rs @@ -33,7 +33,7 @@ pub fn create(id: &str) -> Option> { "scale" => Box::new(TransformTool::new(TransformKind::Scale)), "shear" => Box::new(TransformTool::new(TransformKind::Shear)), "freeTransform" => Box::new(FreeTransformTool::default()), - "eyedropper" => Box::new(EyedropperTool), + "eyedropper" => Box::new(EyedropperTool::default()), "gradient" => Box::new(GradientTool::default()), "artboard" => Box::new(ArtboardTool::default()), "magicWand" => Box::new(MagicWandTool), @@ -79,7 +79,7 @@ pub(crate) fn rect_corners(r: Rect) -> [Point; 4] { } /// The node that owns the appearance for a hit leaf (a compound path's child → the compound). -pub(crate) fn paint_owner(doc: &Document, leaf: NodeId) -> NodeId { +pub fn paint_owner(doc: &Document, leaf: NodeId) -> NodeId { match doc.parent_of(leaf) { Some(p) if doc.node(p).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })) => p, _ => leaf, diff --git a/crates/tools/src/xform/eyedropper.rs b/crates/tools/src/xform/eyedropper.rs index ef6d7e84..46228190 100644 --- a/crates/tools/src/xform/eyedropper.rs +++ b/crates/tools/src/xform/eyedropper.rs @@ -3,8 +3,12 @@ //! 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`). +//! +//! The Gradient panel's eyedropper sets the `stop` option (the tool to return to): the next click +//! samples the colour under the cursor into the selected gradient stop (`paint.sampleColor +//! {stop}`) and switches back to that tool. -use serde_json::json; +use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::hit::{HitKind, hit_test}; use vectorcraft_geom::Point; @@ -12,7 +16,11 @@ use vectorcraft_geom::Point; use super::paint_owner; use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext}; -pub struct EyedropperTool; +#[derive(Default)] +pub struct EyedropperTool { + /// Sampling for the selected gradient stop: the tool to switch back to afterwards. + stop: Option, +} /// The colour of `paint` at `p` (gradients are sampled along their vector, radial ones honouring /// their aspect ratio). @@ -41,19 +49,31 @@ impl Tool for EyedropperTool { let Some(h) = hit_test(cx.doc, ev.pos, opts) else { return vec![] }; let src = paint_owner(cx.doc, h.leaf); let Some(n) = cx.doc.node(src) else { return vec![] }; - if ev.mods.shift { + 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 }; - return match paint_color_at(paint, Some(bounds), p) { - Some(c) => vec![Action::Exec("paint.sampleColor".into(), json!({ "color": c.to_hex() }))], - None => vec![], - }; + 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; } vec![Action::Exec("appearance.copyFrom".into(), json!({ "source": src.0 }))] } + fn options(&self) -> Value { + json!({ "stop": self.stop }) + } + + fn set_option(&mut self, key: &str, value: &Value) { + if key == "stop" { + self.stop = value.as_str().map(str::to_string); + } + } + fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { Cursor::Eyedropper } @@ -71,7 +91,7 @@ mod tests { let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); - let mut t = EyedropperTool; + let mut t = EyedropperTool::default(); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); assert_eq!(a, vec![Action::Exec("appearance.copyFrom".into(), json!({"source": id.0}))]); let shift = Mods { shift: true, ..Default::default() }; @@ -83,6 +103,26 @@ mod tests { assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 400.0)).is_empty()); } + #[test] + fn the_stop_option_samples_into_the_selected_stop_then_switches_back() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + let mut t = EyedropperTool::default(); + t.set_option("stop", &json!("gradient")); + assert_eq!(t.options(), json!({"stop": "gradient"})); + // Without a selected stop the click copies the appearance as usual. + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); + assert!(matches!(&a[0], Action::Exec(c, _) if c == "appearance.copyFrom")); + cx.gradient_stop = Some(1); + // 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!(t.options(), json!({"stop": null}), "one sample only"); + } + #[test] fn gradient_sampled_along_vector() { let g = vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default()); @@ -109,7 +149,7 @@ mod tests { let (s, p) = (Selection::default(), paint()); let shift = Mods { shift: true, ..Default::default() }; // 80 % along the baseline (text space (80, -5)). - let a = EyedropperTool.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 105.0, 180.0).with_mods(shift)); + 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"}))]); } diff --git a/crates/tools/src/xform/gradient.rs b/crates/tools/src/xform/gradient.rs index 2c823f0f..f854374d 100644 --- a/crates/tools/src/xform/gradient.rs +++ b/crates/tools/src/xform/gradient.rs @@ -1,13 +1,14 @@ //! Gradient tool (G) and the gradient annotator. //! //! Drag across selected objects to set the gradient vector of the paint behind the active proxy -//! (fill or stroke; type objects' runs), a solid paint becoming the default gradient. Shift -//! constrains the angle to 45°. With nothing selected, the press targets the object under the +//! (fill or stroke; type objects' runs), a solid paint becoming the default gradient. With nothing +//! selected, the press targets the object under the //! pointer; a click inside selected art applies the gradient from that point. //! //! The annotator is a bar from a round start handle to a square end handle. Drag the start handle //! (or the bar) to move the gradient, the end handle to change its length and angle, and just past -//! the end to rotate it (Shift snaps to 45°). Colour stops sit under the bar: click the bar to add +//! the end to rotate it. Dragged handles snap to anchors, edges and smart guides; Shift instead +//! constrains the vector to 45° steps from the Constrain Angle preference. Colour stops sit under the bar: click the bar to add //! one, drag one to move it (Alt drags a copy), drag it off the bar to delete it, and drag a //! diamond above the bar to move a midpoint. Double-clicking a stop opens its popover. The //! selected stop (`gradient.selectStop`) is shared with the Gradient and Color panels: Delete or @@ -18,9 +19,10 @@ use vectorcraft_color::gradient::{duplicate_stop, insert_stop, midpoint_from_pos use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientStop}; use vectorcraft_doc::NodeId; use vectorcraft_doc::hit::hit_test; -use vectorcraft_geom::{Affine, Point, Vec2}; +use vectorcraft_geom::{Affine, Point, Vec2, constrain_angle_from}; use super::paint_owner; +use crate::guides::snap_draw; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; const BAR: [u8; 3] = [0x20, 0x20, 0x20]; @@ -181,6 +183,8 @@ struct Gesture { #[derive(Default)] pub struct GradientTool { gesture: Option, + /// Smart-guide feedback of the handle being dragged. + guides: Vec, } /// Overlays of an annotator: the bar, the handles, the midpoint diamonds and a chip per stop. @@ -260,7 +264,9 @@ impl GradientTool { Grab::Art } }; - self.gesture = Some(Gesture { grab, at: p, began: false, vector: None, stop: None }); + // A vector drawn on the art starts on the snapped point. + let at = if grab == Grab::Art { snap_draw(cx, p, &[]).0 } else { p }; + self.gesture = Some(Gesture { grab, at, began: false, vector: None, stop: None }); out } @@ -274,20 +280,33 @@ impl GradientTool { g.began = true; out.push(Action::Begin("Gradient".into())); } - let snap = |from: Point, to: Point| if mods.shift { from + vectorcraft_geom::constrain_angle(to - from, 45.0) } else { to }; + let constrain = |v: Vec2| constrain_angle_from(v, 45.0, cx.constrain_angle); + // Where a handle dragged to `to` lands: Shift constrains the vector from `from`, else it + // snaps (keeping the guides to show). + let mut guides = vec![]; + let mut place = |from: Point, to: Point| { + if mods.shift { + from + constrain(to - from) + } else { + let (q, o) = snap_draw(cx, to, &[]); + guides = o; + q + } + }; let (cmd, params) = match &g.grab { Grab::Art => { - let end = snap(g.at, p); + let end = place(g.at, p); g.vector = Some((g.at, end)); ("paint.setGradientGeom", Self::geom_params(cx, g.at, end)) } Grab::Move { geom, .. } => { - let d = p - g.at; - ("paint.setGradientGeom", Self::geom_params(cx, geom.start + d, geom.end + d)) + // The start handle snaps; Shift constrains the move. + let start = place(geom.start, geom.start + (p - g.at)); + ("paint.setGradientGeom", Self::geom_params(cx, start, start + (geom.end - geom.start))) } - Grab::End(geom) => ("paint.setGradientGeom", Self::geom_params(cx, geom.start, snap(geom.start, p))), + Grab::End(geom) => ("paint.setGradientGeom", Self::geom_params(cx, geom.start, place(geom.start, p))), Grab::Rotate(geom) => { - let dir = snap(geom.start, p) - geom.start; + let dir = if mods.shift { constrain(p - geom.start) } else { p - geom.start }; let len = dir.hypot(); let end = if len < 1e-12 { geom.end } else { geom.start + dir * (geom.length() / len) }; ("paint.setGradientGeom", Self::geom_params(cx, geom.start, end)) @@ -313,11 +332,13 @@ impl GradientTool { ("paint.editGradient", Self::stops_params(cx, &set_midpoint(stops, *index, m))) } }; + self.guides = guides; out.push(Action::Preview(cmd.into(), params)); out } fn release(&mut self, cx: &ToolContext, p: Point) -> Vec { + self.guides.clear(); let Some(g) = self.gesture.take() else { return vec![] }; if g.began { let mut out = vec![Action::Commit]; @@ -386,6 +407,7 @@ impl Tool for GradientTool { fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec { if key == ToolKey::Escape && self.busy() { self.gesture = None; + self.guides.clear(); return vec![Action::Cancel]; } if self.busy() || !self.claims_key(cx, key) { @@ -411,14 +433,16 @@ impl Tool for GradientTool { } fn overlays(&self, cx: &ToolContext) -> Vec { - match (Annotator::of(cx), self.gesture.as_ref().and_then(|g| g.vector)) { + let mut o = match (Annotator::of(cx), self.gesture.as_ref().and_then(|g| g.vector)) { (Some(a), _) => annotator_overlays(cx, &a), // A vector drawn on a paint that isn't a gradient yet. (None, Some((s, e))) => { annotator_overlays(cx, &Annotator::with(GradientGeom { start: s, end: e, aspect: 1.0 }, &Gradient::default().stops)) } _ => vec![], - } + }; + o.extend(self.guides.iter().cloned()); + o } fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { @@ -581,6 +605,39 @@ mod tests { assert!(end.distance(Point::new(100.0, 50.0)) < 1e-9, "{end:?}"); } + #[test] + fn handles_snap_to_anchors_and_shift_uses_the_constrain_angle() { + let (d, s) = graded(two()); + let p = paint(); + let mut cx = cx(&d, &s, &p); + let mut t = GradientTool::default(); + // The end handle dragged near the rectangle's corner lands on it, with a guide label. + t.pointer(&cx, &ev(PointerKind::Down, Point::new(200.0, 150.0))); + let v = preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(202.0, 197.0)))).clone(); + assert_eq!(point(&v["end"]), Point::new(200.0, 200.0)); + assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "anchor"))); + t.pointer(&cx, &ev(PointerKind::Up, Point::new(202.0, 197.0))); + assert!(!t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Label { .. })), "the guides go with the drag"); + // A vector drawn from near a corner starts on it. + let (d2, id) = doc_with_rect(); + let mut s2 = Selection::default(); + s2.add(id); + let cx2 = crate::testutil::cx(&d2, &s2, &p); + t.pointer(&cx2, &ev(PointerKind::Down, Point::new(101.0, 102.0))); + let v = preview(&t.pointer(&cx2, &ev(PointerKind::Drag, Point::new(150.0, 130.0)))).clone(); + assert_eq!(point(&v["start"]), Point::new(100.0, 100.0)); + t.pointer(&cx2, &ev(PointerKind::Up, Point::new(150.0, 130.0))); + // Constrain Angle 30: Shift gives 30 and 75 degrees (and no snapping). + cx.constrain_angle = 30.0; + let angle = |v: &Value| GradientGeom { start: point(&v["start"]), end: point(&v["end"]), aspect: 1.0 }.angle_deg(); + for (to, want) in [(Point::new(190.0, 100.0), 30.0), (Point::new(125.0, 60.0), 75.0)] { + t.pointer(&cx, &ev(PointerKind::Down, Point::new(200.0, 150.0))); + let v = preview(&t.pointer(&cx, &with(SHIFT, ev(PointerKind::Drag, to)))).clone(); + assert!((angle(&v) - want).abs() < 1e-9, "{want}: {v}"); + t.pointer(&cx, &ev(PointerKind::Up, to)); + } + } + #[test] fn a_bar_click_adds_a_stop_and_selects_it() { let (d, s) = graded(two()); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3825d092..b879e0e8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -136,7 +136,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { handle_input(app, ui, &resp, rect); let v = *app.view().unwrap_or(&View::default()); let xf = Xf::new(rect, &v); - paint_drop(app, ui, &resp, &xf); + panel_drop(app, ui, &resp, &xf); let painter = ui.painter_at(rect); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); @@ -674,21 +674,33 @@ fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option() else { return }; let Some(hit) = hit_at(app, xf.to_doc(pos), xf.zoom) else { return }; - params["ids"] = json!([hit.leaf.0]); - params["focus"] = json!(false); - if let Err(e) = app.run(crate::panels::proxy_cmd(app, false), params) { + let Some(st) = app.session.active() else { return }; + let (cmd, params) = match &*d { + widgets::PanelDrag::Paint { params, .. } => { + // Colour groups paint nothing. + if params.is_null() { + return; + } + let mut params = params.clone(); + params["ids"] = json!([vectorcraft_tools::xform::paint_owner(&st.doc, hit.leaf).0]); + params["focus"] = json!(false); + (crate::panels::proxy_cmd(app, false), params) + } + }; + if let Err(e) = app.run(cmd, params) { app.status(e); } } @@ -1109,8 +1121,8 @@ mod tests { #[test] fn swatches_and_proxy_paints_dropped_on_art_fill_the_object_hit() { - use crate::panels::swatches::SwatchDrag; - use vectorcraft_color::{Color, Paint}; + use vectorcraft_color::{Color, GradientGeom, GradientPaint, Paint}; + use widgets::{PanelDrag, SwatchRows}; let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); @@ -1118,25 +1130,29 @@ mod tests { let ctx = egui::Context::default(); frame(&mut app, &ctx, vec![]); let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let release = |at: Pos2| { - vec![ - egui::Event::PointerMoved(at), - egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }, - ] + let drop = |app: &mut VectorcraftApp, d: PanelDrag, at: Point| { + let at = xf.to_screen(at); + egui::DragAndDrop::set_payload(&ctx, d); + let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; + frame(app, &ctx, vec![egui::Event::PointerMoved(at), up]); }; let fill = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.fill_paint(); - let drag = SwatchDrag { grabbed: "Red".into(), names: vec!["Red".into()], groups: false }; + let red = Paint::solid(Color::from_hex("#ed1c24").unwrap()); + let rows = SwatchRows { grabbed: "Red".into(), names: vec!["Red".into()], groups: false }; + let swatch = PanelDrag::Paint { paint: red.clone(), params: json!({"swatch": "Red"}), rows: Some(rows) }; // Off the art nothing happens. - egui::DragAndDrop::set_payload(&ctx, drag.clone()); - frame(&mut app, &ctx, release(xf.to_screen(Point::new(300.0, 250.0)))); + drop(&mut app, swatch.clone(), Point::new(300.0, 250.0)); assert_eq!(fill(&app), Paint::solid(Color::WHITE)); - egui::DragAndDrop::set_payload(&ctx, drag); - frame(&mut app, &ctx, release(xf.to_screen(Point::new(100.0, 100.0)))); - assert_eq!(fill(&app), Paint::solid(Color::from_hex("#ed1c24").unwrap()), "paint.setFill with the hit id"); + drop(&mut app, swatch, Point::new(100.0, 100.0)); + assert_eq!(fill(&app), red, "paint.setFill with the hit id"); assert!(app.session.active().unwrap().selection.is_empty(), "the selection stays as it was"); // A proxy's paint works the same. - egui::DragAndDrop::set_payload(&ctx, Paint::solid(Color::rgb(0.0, 0.0, 1.0))); - frame(&mut app, &ctx, release(xf.to_screen(Point::new(60.0, 60.0)))); + drop(&mut app, PanelDrag::paint(Paint::solid(Color::rgb(0.0, 0.0, 1.0))), Point::new(60.0, 60.0)); assert_eq!(fill(&app), Paint::solid(Color::rgb(0.0, 0.0, 1.0))); + // A dragged gradient (the Gradient panel's thumbnail, a proxy) fits the object it lands on. + let mut g = GradientPaint::new(Default::default()); + g.geom = Some(GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0 }); + drop(&mut app, PanelDrag::paint(Paint::Gradient(Box::new(g))), Point::new(100.0, 100.0)); + assert!(matches!(fill(&app), Paint::Gradient(g) if g.geom.is_none())); } } diff --git a/crates/ui-egui/src/dialogs/gradient_stop.rs b/crates/ui-egui/src/dialogs/gradient_stop.rs index 9ea5772c..01d0ce0d 100644 --- a/crates/ui-egui/src/dialogs/gradient_stop.rs +++ b/crates/ui-egui/src/dialogs/gradient_stop.rs @@ -1,9 +1,10 @@ -//! The gradient stop popover: double-clicking a stop on the Gradient tool's annotator edits it -//! next to its chip — its colour (the Color panel's controls, or a document swatch), opacity and -//! location. It edits the selected stop (`gradient.selectStop`) through `paint.editGradient`, and -//! closes on Escape or a click elsewhere. +//! The gradient stop popover: double-clicking a stop on the Gradient tool's annotator (or the +//! Gradient panel's slider) edits it next to its chip — its colour (the Color panel's controls, +//! or a document swatch), opacity and location. It edits the selected stop +//! (`gradient.selectStop`) through `paint.editGradient`, and closes on Escape or a click elsewhere. //! -//! Fields: `index`, `x`, `y` (the chip, document coordinates) and `tab` (`color` or `swatches`). +//! Fields: `index`, `x`, `y` (the chip, document coordinates) or `screen` ([x, y], screen points, +//! from the panel), and `tab` (`color` or `swatches`). //! Agents can set `color` (hex), `opacity` and `location` (percentages) and confirm. use egui::{Sense, vec2}; @@ -15,8 +16,8 @@ use vectorcraft_geom::Point; use super::{DialogResult, DialogSpec}; use crate::VectorcraftApp; use crate::canvas::Xf; -use crate::panels::gradient::stops_json; -use crate::panels::{active_paint, live_run}; +use crate::panels::active_paint; +use crate::panels::gradient::set_stops; use crate::state::Dialog; use crate::theme::Tokens; use crate::widgets::{self, Live}; @@ -30,15 +31,6 @@ fn selected(app: &VectorcraftApp) -> Option<(Vec, usize)> { Some((g.gradient.stops, i)) } -/// Write `stops` to the gradient and select stop `i`. -fn apply(app: &mut VectorcraftApp, stops: &[GradientStop], i: usize, phase: Live) { - let params = json!({ "stops": stops_json(stops), "stroke": !app.session.fill_active }); - live_run(app, "Gradient", "paint.editGradient", params, phase); - if phase == Live::Released && app.session.selected_stop() != Some(i) { - app.run("gradient.selectStop", json!({ "index": i })).ok(); - } -} - /// `stops` with stop `i` given `color`. fn recolor(mut stops: Vec, i: usize, color: Color) -> Vec { stops[i].color = color; @@ -60,9 +52,13 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { return; }; let t = Tokens::get(ctx); + // Opened from the Gradient panel: at `screen`; from the annotator: beside the chip. + let screen = + d.fields.get("screen").and_then(Value::as_array).and_then(|a| Some(egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32))); let chip = Point::new(d.f64("x", 0.0), d.f64("y", 0.0)); - let pos = - app.canvas_rect.zip(app.view().copied()).map_or(ctx.content_rect().center(), |(r, v)| Xf::new(r, &v).to_screen(chip) + vec2(14.0, 14.0)); + let pos = screen.unwrap_or_else(|| { + app.canvas_rect.zip(app.view().copied()).map_or(ctx.content_rect().center(), |(r, v)| Xf::new(r, &v).to_screen(chip) + vec2(14.0, 14.0)) + }); let swatches = d.str("tab") == "swatches"; let mut tab = None; let resp = egui::Area::new(egui::Id::new("gradient-stop-popover")) @@ -93,12 +89,12 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { if let Some(v) = widgets::plain_field(ui, "stop-pop-op", s.opacity as f64 * 100.0, "%", 0, 54.0) { let mut v2 = stops.clone(); v2[i].opacity = (v / 100.0).clamp(0.0, 1.0) as f32; - apply(app, &v2, i, Live::Released); + set_stops(app, &v2, Some(i), Live::Released); } widgets::dim_label(ui, "Location:"); if let Some(v) = widgets::plain_field(ui, "stop-pop-loc", s.offset as f64 * 100.0, "%", 1, 54.0) { let (v2, ni) = move_stop(&stops, i, (v / 100.0) as f32); - apply(app, &v2, ni, Live::Released); + set_stops(app, &v2, Some(ni), Live::Released); } }); }); @@ -133,7 +129,7 @@ fn swatch_grid(app: &mut VectorcraftApp, ui: &mut egui::Ui, stops: &[GradientSto } }); if let Some(c) = pick { - apply(app, &recolor(stops.to_vec(), i, c), i, Live::Released); + set_stops(app, &recolor(stops.to_vec(), i, c), Some(i), Live::Released); } } @@ -150,7 +146,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult { if d.fields.contains_key("location") { (stops, i) = move_stop(&stops, i, (d.f64("location", 0.0) / 100.0) as f32); } - apply(app, &stops, i, Live::Released); + set_stops(app, &stops, Some(i), Live::Released); app.ui.dialog = None; Ok(json!({ "index": i })) } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ca5e7473..80ce683d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -195,6 +195,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{name?} open Graphic Style Options (dialog `graphicStyleOptions`, field `name`): for style `name` OK renames it (graphicStyle.rename); without, OK makes a new style of that name from the selection (graphicStyle.new)", ), + ( + "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)", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -531,6 +537,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::open_color_picker(app, p), "ui.graphicStyleOptions" => crate::dialogs::graphic_style_options::open(app, s("name").as_deref()), + "tool.options" => crate::toolbar::open_options(app, &s("tool").unwrap_or_default()), _ => return None, }; Some(r) diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index 1d10c87e..d997c004 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -299,8 +299,7 @@ fn apply(app: &mut VectorcraftApp, tgt: &Target, c: Color, phase: Live, behind: if let Some(s) = stops.get_mut(*index) { s.color = c; } - let params = json!({"stroke": !app.session.fill_active, "stops": super::gradient::stops_json(&stops)}); - live_run(app, "Gradient", "paint.editGradient", params, phase); + super::gradient::set_stops(app, &stops, None, phase); } _ => { let cmd = super::proxy_cmd(app, behind); diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index 80583ce0..84b1ed2e 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -1,23 +1,41 @@ -//! Gradient panel: type buttons, angle / aspect ratio / reverse, the gradient slider with draggable -//! stops and midpoint diamonds (click below the ramp adds a stop, drag a stop off to remove it), -//! and stop Opacity / Location fields. Stop colours are edited with the Color panel. +//! Gradient panel: the gradient thumbnail (drag it onto art; its menu lists the document's +//! gradient swatches and Save to Swatches), type buttons, the Fill/Stroke proxy, angle and aspect +//! ratio fields with preset menus, Reverse, the gradient slider and the selected stop's fields. +//! +//! On the slider: click below the ramp to add a stop, drag a stop to move it (Alt drags a copy; +//! Alt-dropping it on another stop swaps their colours), drag it off to remove it, double-click it +//! for the stop popover, and drag a diamond to move a midpoint (a selected diamond's midpoint +//! shows in Location). Dropping a colour swatch on the ramp adds a stop of that colour, or +//! recolours the stop it lands on. Hide Options leaves the thumbnail, the proxy and the slider. -use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::{Value, json}; -use vectorcraft_color::gradient::{MIN_STOPS, insert_stop, midpoint_from_pos, midpoint_pos, move_stop, remove_stop, set_midpoint}; -use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop, Paint}; +use vectorcraft_color::gradient::{ + MIN_STOPS, duplicate_stop, insert_stop, midpoint_from_pos, midpoint_pos, move_stop, remove_stop, set_midpoint, swap_stop_colors, +}; +use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; +use vectorcraft_doc::NodeId; use super::{active_paint, live_run, pstate, set_pstate}; +use crate::state::Dialog; use crate::theme::Tokens; -use crate::widgets::{self, Live, menu_item}; +use crate::widgets::{self, Live, PanelDrag, menu_item}; use crate::{VectorcraftApp, icons}; /// Dragging a stop this far below the ramp removes it. -pub const REMOVE_DISTANCE: f32 = 28.0; +const REMOVE_DISTANCE: f32 = 28.0; +/// The Angle field's preset menu, degrees. +const ANGLE_PRESETS: [f64; 9] = [-180.0, -135.0, -90.0, -45.0, 0.0, 45.0, 90.0, 135.0, 180.0]; +/// The Aspect Ratio field's preset menu, percent. +const ASPECT_PRESETS: [f64; 9] = [10.0, 25.0, 50.0, 75.0, 100.0, 150.0, 200.0, 300.0, 400.0]; +/// Panel-state key of Show/Hide Options. +const HIDE_OPTIONS: &str = "gradient-hide-options"; +/// Panel-state key of the selected midpoint diamond. +const MID_SELECTED: &str = "grad-mid"; /// Select stop `i` (the session's selected stop, shared with the Gradient tool's annotator, the -/// Color panel and agents). Run after the edit that creates it. -fn select_stop(app: &mut VectorcraftApp, i: usize) { +/// Color panel and agents), or none. +fn select_stop(app: &mut VectorcraftApp, i: Option) { app.run("gradient.selectStop", json!({ "index": i })).ok(); } @@ -31,8 +49,32 @@ pub fn x_to_offset(x: f32, left: f32, width: f32) -> f32 { /// Stops as `paint.editGradient` JSON. pub use vectorcraft_tools::params::stops_json; -// ---------- UI ---------- +/// A colour dropped on the ramp: recolours stop `on` (fully opaque), or adds an opaque stop of +/// that colour at `offset`. Returns the stops and the index of the stop that took the colour. +fn drop_color(g: &Gradient, on: Option, offset: f32, c: Color) -> (Vec, usize) { + let (mut v, i) = match on.filter(|i| *i < g.stops.len()) { + Some(i) => (g.stops.clone(), i), + None => insert_stop(g, offset), + }; + v[i].color = c; + v[i].opacity = 1.0; + (v, i) +} +/// The stops after dragging stop `i` of `origin` to `offset`: moved, or with `copy` (Alt) a copy +/// left there, or the colours swapped with stop `over` when the copy is dropped on it. Returns +/// the stops and the stop to select. +fn dragged_stops(origin: &[GradientStop], i: usize, offset: f32, copy: bool, over: Option) -> (Vec, usize) { + match (copy, over.filter(|j| *j != i)) { + (true, Some(j)) => (swap_stop_colors(origin, i, j), i), + (true, None) => duplicate_stop(origin, i, offset), + (false, _) => move_stop(origin, i, offset), + } +} + +// ---------- shared edits ---------- + +/// The gradient behind the active proxy. fn current(app: &VectorcraftApp) -> Option { match active_paint(app) { Paint::Gradient(g) => Some(*g), @@ -40,36 +82,86 @@ fn current(app: &VectorcraftApp) -> Option { } } +/// Edit the gradient behind the active proxy (`paint.editGradient` params). fn edit(app: &mut VectorcraftApp, params: Value, phase: Live) { let mut p = params; p["stroke"] = json!(!app.session.fill_active); live_run(app, "Gradient", "paint.editGradient", p, phase); } +/// Write `stops` to the gradient behind the active proxy (live while dragging) and, once +/// released, select stop `select` (the panel, the stop popover and the Color panel share this). +pub(crate) fn set_stops(app: &mut VectorcraftApp, stops: &[GradientStop], select: Option, phase: Live) { + edit(app, json!({ "stops": stops_json(stops) }), phase); + if phase == Live::Released + && let Some(i) = select + && app.session.selected_stop() != Some(i) + { + select_stop(app, Some(i)); + } +} + +/// Save the gradient as a swatch. +fn save_to_swatches(app: &mut VectorcraftApp, g: &GradientPaint) { + app.run("swatch.new", super::paint_params(&Paint::Gradient(Box::new(g.clone())))).ok(); +} + +/// The document's gradient swatches (groups included): name and gradient. +fn gradient_swatches(app: &VectorcraftApp) -> Vec<(String, Gradient)> { + let Some(st) = app.session.active() else { return vec![] }; + let d = &st.doc; + d.swatches + .iter() + .chain(d.swatch_groups.iter().flat_map(|g| g.swatches.iter())) + .filter_map(|s| match &s.paint { + Paint::Gradient(g) => Some((s.name.clone(), g.gradient.clone())), + _ => None, + }) + .collect() +} + +/// The selected midpoint diamond: kept for the object it was picked on, and given up when a stop +/// is selected. +fn selected_mid(app: &VectorcraftApp, ctx: &egui::Context, stops: usize) -> Option { + let (i, owner) = pstate::)>>(ctx, MID_SELECTED)?; + (app.session.selected_stop().is_none() && i + 1 < stops && owner == first_id(app)).then_some(i) +} + +fn first_id(app: &VectorcraftApp) -> Option { + app.session.active().and_then(|d| d.selection.objects.first().copied()) +} + +// ---------- UI ---------- + /// Drag state of the ramp: which handle is being dragged. #[derive(Clone, Copy, Debug, Default, PartialEq)] enum Drag { #[default] None, - Stop(usize), + /// Stop `index`; `copy` (Alt held when the drag began) drags a copy. + Stop { + index: usize, + copy: bool, + }, Mid(usize), } pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let gp = current(app); - let fallback = GradientPaint::new(Gradient::default()); - let g = gp.clone().unwrap_or(fallback); let is_grad = gp.is_some(); + let g = gp.unwrap_or_else(|| GradientPaint::new(Gradient::default())); let kind = g.gradient.kind; - // Header: gradient swatch + type buttons + Edit Gradient. + let hidden: bool = pstate(ui.ctx(), HIDE_OPTIONS); + // Header: thumbnail + its swatch menu, the type buttons and Edit Gradient. ui.horizontal(|ui| { - let (r, resp) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::click()); - widgets::paint_chip(ui, r, &Paint::Gradient(Box::new(g.clone()))); - ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside); - if resp.on_hover_text("Gradient Fill — click to apply").clicked() && !is_grad { - edit(app, json!({}), Live::Released); + thumbnail(app, ui, &g, is_grad); + if hidden { + ui.add_space(6.0); + super::proxy(app, ui, 36.0); + return; } + ui.add_space(4.0); ui.vertical(|ui| { ui.horizontal(|ui| { widgets::dim_label(ui, "Type:"); @@ -83,13 +175,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } } }); - ui.horizontal(|ui| { - if widgets::flat_button(ui, "Edit Gradient", 96.0).clicked() { - app.select_tool("gradient"); - } - }); + if widgets::flat_button(ui, "Edit Gradient", 96.0).clicked() { + app.select_tool("gradient"); + } }); }); + if hidden { + ui.add_space(6.0); + ramp(app, ui, &g.gradient, is_grad); + return; + } ui.add_space(4.0); ui.horizontal(|ui| { widgets::dim_label(ui, "Stroke:"); @@ -101,87 +196,170 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { widgets::icon_button_enabled(ui, icon, tip, icon == "dc-stroke-center" && !app.session.fill_active, false, 22.0); } }); + // The proxy beside the angle and aspect ratio fields. ui.horizontal(|ui| { - icons::icon(ui, "rotate-ccw", 15.0, t.icon).on_hover_text("Angle"); - if let Some(a) = widgets::plain_field(ui, "grad-angle", g.geom.map(|x| x.angle_deg()).unwrap_or(g.angle), "°", 1, 64.0) { - edit(app, json!({"angle": a}), Live::Released); - } + super::proxy(app, ui, 36.0); ui.add_space(4.0); - let radial = kind == GradientKind::Radial; - icons::icon(ui, "scaling", 15.0, if radial { t.icon } else { t.text_disabled }).on_hover_text("Aspect Ratio"); - let asp = g.geom.map(|x| x.aspect * 100.0).unwrap_or(100.0); - if radial { - if let Some(a) = widgets::plain_field(ui, "grad-aspect", asp, "%", 1, 60.0) { - edit(app, json!({"aspect": a}), Live::Released); - } - } else { - ui.add_enabled(false, egui::Label::new(egui::RichText::new(format!("{asp:.0}%")).color(t.text_disabled))); - } - if widgets::icon_button_enabled(ui, "dc-reverse", "Reverse Gradient", false, is_grad, 22.0).clicked() { - edit(app, json!({"reverse": true}), Live::Released); - } + ui.vertical(|ui| { + ui.horizontal(|ui| { + icons::icon(ui, "rotate-ccw", 15.0, t.icon).on_hover_text("Angle"); + let angle = g.geom.map_or(g.angle, |x| x.angle_deg()); + if let Some(a) = widgets::spin_plain(ui, "grad-angle", (angle * 10.0).round() / 10.0, "°", 1, 104.0, 1.0, -180.0, &ANGLE_PRESETS) { + edit(app, json!({"angle": a}), Live::Released); + } + if widgets::icon_button_enabled(ui, "dc-reverse", "Reverse Gradient", false, is_grad, 22.0).clicked() { + edit(app, json!({"reverse": true}), Live::Released); + } + }); + let radial = kind == GradientKind::Radial; + ui.horizontal(|ui| { + icons::icon(ui, "scaling", 15.0, if radial { t.icon } else { t.text_disabled }).on_hover_text("Aspect Ratio"); + let asp = g.geom.map_or(100.0, |x| (x.aspect * 1000.0).round() / 10.0); + let set = ui.add_enabled_ui(radial, |ui| widgets::spin_plain(ui, "grad-aspect", asp, "%", 1, 104.0, 1.0, 0.5, &ASPECT_PRESETS)).inner; + if let Some(a) = set { + edit(app, json!({"aspect": a}), Live::Released); + } + }); + }); }); ui.add_space(6.0); ramp(app, ui, &g.gradient, is_grad); ui.add_space(4.0); - // Stop fields. - let sel = app.session.selected_stop().filter(|i| *i < g.gradient.stops.len()); - ui.horizontal(|ui| { - let enabled = is_grad && sel.is_some(); - let stop = sel.and_then(|i| g.gradient.stops.get(i)).copied(); - widgets::dim_label(ui, "Opacity:"); - let op = stop.map(|s| s.opacity as f64 * 100.0).unwrap_or(100.0); - if let Some(v) = widgets::plain_field(ui, "grad-op", op, "%", 0, 54.0) - && enabled - && let Some(i) = sel - { - let mut stops = g.gradient.stops.clone(); - stops[i].opacity = (v / 100.0).clamp(0.0, 1.0) as f32; - edit(app, json!({"stops": stops_json(&stops)}), Live::Released); - } - widgets::dim_label(ui, "Location:"); - let loc = stop.map(|s| s.offset as f64 * 100.0).unwrap_or(0.0); - if let Some(v) = widgets::plain_field(ui, "grad-loc", loc, "%", 1, 54.0) - && enabled - && let Some(i) = sel - { - let (stops, ni) = move_stop(&g.gradient.stops, i, (v / 100.0) as f32); - edit(app, json!({"stops": stops_json(&stops)}), Live::Released); - select_stop(app, ni); - } - let can_del = enabled && g.gradient.stops.len() > MIN_STOPS; - if widgets::icon_button_enabled(ui, "trash-2", "Delete Stop", false, can_del, 22.0).clicked() - && let Some(i) = sel - && let Some(stops) = remove_stop(&g.gradient.stops, i) - { - edit(app, json!({"stops": stops_json(&stops)}), Live::Released); - select_stop(app, i.min(stops.len() - 1)); - } - }); - if let Some(i) = sel - && is_grad - { - let c = g.gradient.stops[i].color; - ui.horizontal(|ui| { - widgets::dim_label(ui, "Stop color:"); - let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click()); - widgets::swatch_tile(ui, r, &Paint::solid(c), false, resp.hovered()); - widgets::dim_label(ui, &c.to_hex().to_uppercase()); - if resp.on_hover_text("Edit the stop in the Color panel").clicked() { - app.ui.open_panel = Some("color".into()); - } - }); - } + stop_fields(app, ui, &g.gradient, is_grad); if !is_grad { widgets::dim_label(ui, "Click the ramp or a type button to apply a gradient."); } } +/// The gradient thumbnail (click: apply a gradient; drag: onto art) and its menu of gradient +/// swatches with Save to Swatches. +fn thumbnail(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint, is_grad: bool) { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::click_and_drag()); + widgets::gradient_chip(ui, r, &g.gradient); + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside); + let resp = resp.on_hover_text("Gradient Fill: click to apply, drag onto art"); + if resp.clicked() && !is_grad { + edit(app, json!({}), Live::Released); + } + // The dragged copy carries no placement: it fits the art it lands on. + widgets::drag_source(ui, &resp, || PanelDrag::paint(Paint::Gradient(Box::new(g.clone())))); + let dr = Rect::from_min_size(r.right_top() + vec2(1.0, 0.0), vec2(14.0, 40.0)); + ui.advance_cursor_after_rect(dr); + let dresp = ui.interact(dr, ui.id().with("grad-swatches"), Sense::click()); + icons::paint(ui, "chevron-down", Rect::from_center_size(dr.center(), vec2(12.0, 12.0)), if dresp.hovered() { t.text_strong } else { t.icon }); + let dresp = dresp.on_hover_text("Gradient swatches"); + egui::Popup::menu(&dresp).show(|ui| { + ui.set_min_width(200.0); + let mut chosen = None; + let swatches = gradient_swatches(app); + if swatches.is_empty() { + widgets::dim_label(ui, "No gradient swatches"); + } + for (name, grad) in &swatches { + let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(row, 0.0, t.hover); + } + let chip = Rect::from_min_size(row.left_center() + vec2(4.0, -8.0), vec2(16.0, 16.0)); + widgets::gradient_chip(ui, chip, grad); + ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside); + ui.painter().text(chip.right_center() + vec2(8.0, 0.0), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.5), t.text); + if resp.clicked() { + chosen = Some(name.clone()); + } + } + ui.separator(); + if menu_item(ui, "Save to Swatches", is_grad, false) { + save_to_swatches(app, g); + } + if let Some(name) = chosen { + super::apply_click(app, ui, json!({ "swatch": name })); + ui.close(); + } + }); +} + +/// The selected stop's colour (click: its popover; the eyedropper samples one from the art), +/// opacity and location (a selected diamond's midpoint instead), and Delete Stop. +fn stop_fields(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) { + let stops = &g.stops; + let sel = app.session.selected_stop().filter(|i| *i < stops.len() && is_grad); + let mid = selected_mid(app, ui.ctx(), stops.len()).filter(|_| is_grad); + let stop = sel.map(|i| stops[i]); + if let (Some(i), Some(s)) = (sel, stop) { + ui.horizontal(|ui| { + widgets::dim_label(ui, "Color:"); + let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click()); + widgets::swatch_tile(ui, r, &Paint::solid(s.color), false, resp.hovered()); + widgets::dim_label(ui, &s.color.to_hex().to_uppercase()); + if resp.on_hover_text("Edit the stop").clicked() { + open_popover(app, i, r.left_bottom() + vec2(0.0, 4.0)); + } + ui.add_space(ui.available_width() - 22.0); + if widgets::icon_button(ui, "pipette", "Eyedropper: click the art to sample the stop's colour", false, 22.0).clicked() { + stop_eyedropper(app); + } + }); + } + ui.horizontal(|ui| { + ui.add_enabled_ui(sel.is_some(), |ui| { + widgets::dim_label(ui, "Opacity:"); + if let Some(v) = widgets::plain_field(ui, "grad-op", stop.map_or(100.0, |s| s.opacity as f64 * 100.0), "%", 0, 54.0) + && let Some(i) = sel + { + let mut v2 = stops.clone(); + v2[i].opacity = (v / 100.0).clamp(0.0, 1.0) as f32; + set_stops(app, &v2, Some(i), Live::Released); + } + }); + ui.add_enabled_ui(sel.is_some() || mid.is_some(), |ui| { + widgets::dim_label(ui, "Location:"); + let loc = match (mid, stop) { + (Some(m), _) => stops[m].midpoint as f64 * 100.0, + (None, Some(s)) => s.offset as f64 * 100.0, + _ => 0.0, + }; + if let Some(v) = widgets::plain_field(ui, ("grad-loc", mid.is_some()), loc, "%", 1, 54.0) { + let v = (v / 100.0) as f32; + if let Some(m) = mid { + set_stops(app, &set_midpoint(stops, m, v), None, Live::Released); + } else if let Some(i) = sel { + let (v2, ni) = move_stop(stops, i, v); + set_stops(app, &v2, Some(ni), Live::Released); + } + } + }); + let can_del = sel.is_some() && stops.len() > MIN_STOPS; + if widgets::icon_button_enabled(ui, "trash-2", "Delete Stop", false, can_del, 22.0).clicked() + && let Some(i) = sel + && let Some(v) = remove_stop(stops, i) + { + let next = i.min(v.len() - 1); + set_stops(app, &v, Some(next), Live::Released); + } + }); +} + +/// The stop's eyedropper: the Eyedropper tool samples the next colour clicked on the art into the +/// selected stop, then hands back to the current tool. +fn stop_eyedropper(app: &mut VectorcraftApp) { + let back = app.session.tool_id(); + app.select_tool("eyedropper"); + app.session.set_tool_option("stop", &json!(back)); +} + +/// Open the stop popover for stop `i` at screen position `at`. +fn open_popover(app: &mut VectorcraftApp, i: usize, at: Pos2) { + select_stop(app, Some(i)); + app.ui.dialog = Some(Dialog::new("gradientStop", json!({ "index": i, "screen": [at.x, at.y], "tab": "color" }))); +} + /// The gradient slider: ramp, stops below, midpoint diamonds above. fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) { let t = Tokens::get(ui.ctx()); let w = ui.available_width(); - let (area, _) = ui.allocate_exact_size(vec2(w, 50.0), Sense::hover()); + let (area, area_resp) = ui.allocate_exact_size(vec2(w, 50.0), Sense::hover()); let bar = Rect::from_min_size(area.min + vec2(8.0, 10.0), vec2(w - 16.0, 18.0)); // Checkerboard under the ramp (opacity). let cell = 6.0; @@ -208,37 +386,67 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) { Rect::from_min_size(pos2(bar.left() + i as f32 * bar.width() / n as f32, bar.top()), vec2(bar.width() / n as f32 + 0.5, bar.height())); ui.painter().rect_filled(rr, 0.0, Color32::from_rgba_unmultiplied(r, gg, b, a)); } - ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); + // A colour swatch held over the ramp: outline it as a drop target. + let droppable = is_grad && area_resp.dnd_hover_payload::().is_some_and(|d| d.color().is_some()); + let edge = if droppable { Stroke::new(2.0, t.accent) } else { Stroke::new(1.0, t.border) }; + ui.painter().rect_stroke(bar, 0.0, edge, StrokeKind::Outside); let x_of = |o: f32| bar.left() + o * bar.width(); + let offset_at = |p: Pos2| x_to_offset(p.x, bar.left(), bar.width()); + let marker = |o: f32| Rect::from_min_size(pos2(x_of(o) - 6.0, bar.bottom() + 2.0), vec2(12.0, 16.0)); let sel = app.session.selected_stop(); - let mut drag: Drag = pstate(ui.ctx(), "grad-drag"); let stops = &g.stops; + let mid_sel = selected_mid(app, ui.ctx(), stops.len()); + let mut drag: Drag = pstate(ui.ctx(), "grad-drag"); // While dragging, edits are computed against the stops as they were when the drag began (the // document shows the preview). let origin: Vec = if drag == Drag::None { stops.clone() } else { pstate::>(ui.ctx(), "grad-origin") }; - let origin = if origin.len() == stops.len() { origin } else { stops.clone() }; + let origin = if origin.is_empty() { stops.clone() } else { origin }; + // The stop of `origin` other than `i` whose marker is under `p`. + let stop_under = |p: Pos2, i: usize| origin.iter().enumerate().position(|(j, s)| j != i && marker(s.offset).contains(p)); let mut changed: Option<(Vec, Live)> = None; // The stop to select once `changed` is applied. let mut select: Option = None; + let mut pick_mid: Option = None; + let mut popover: Option<(usize, Pos2)> = None; + // The ramp and the strip below it, under the stops and diamonds (interacted first): a click + // below adds a stop, and without a gradient either applies one. + let below = Rect::from_min_max(pos2(bar.left(), bar.bottom()), pos2(bar.right(), area.bottom())); + let add_resp = ui.interact(below, ui.id().with("grad-add"), Sense::click()); + let bar_resp = ui.interact(bar, ui.id().with("grad-bar"), Sense::click()); + if add_resp.hovered() && is_grad { + ui.ctx().set_cursor_icon(egui::CursorIcon::Copy); + } + if !is_grad && (bar_resp.clicked() || add_resp.clicked()) { + edit(app, json!({}), Live::Released); + } else if add_resp.clicked() + && let Some(p) = add_resp.interact_pointer_pos() + { + let (v, i) = insert_stop(g, offset_at(p)); + select = Some(i); + changed = Some((v, Live::Released)); + } // Midpoint diamonds. for i in 0..stops.len().saturating_sub(1) { let Some(p) = midpoint_pos(stops, i) else { continue }; let c = pos2(x_of(p), bar.top() - 5.0); let rect = Rect::from_center_size(c, vec2(10.0, 10.0)); let resp = ui.interact(rect, ui.id().with(("grad-mid", i)), Sense::click_and_drag()); - let active = drag == Drag::Mid(i) || resp.hovered(); + let lit = drag == Drag::Mid(i) || mid_sel == Some(i) || resp.hovered(); let pts = vec![c + vec2(0.0, -4.0), c + vec2(4.0, 0.0), c + vec2(0.0, 4.0), c + vec2(-4.0, 0.0)]; - ui.painter().add(egui::Shape::convex_polygon(pts, if active { Color32::WHITE } else { t.icon }, Stroke::new(1.0, t.border))); + ui.painter().add(egui::Shape::convex_polygon(pts, if lit { Color32::WHITE } else { t.icon }, Stroke::new(1.0, t.border))); if !is_grad { continue; } + if resp.clicked() || resp.drag_started() { + pick_mid = Some(i); + } if resp.drag_started() { drag = Drag::Mid(i); set_pstate(ui.ctx(), "grad-origin", stops.clone()); } if (resp.dragged() || resp.drag_stopped()) && let Some(pp) = resp.interact_pointer_pos() - && let Some(m) = midpoint_from_pos(&origin, i, x_to_offset(pp.x, bar.left(), bar.width())) + && let Some(m) = midpoint_from_pos(&origin, i, offset_at(pp)) { let phase = if resp.drag_stopped() { Live::Released } else { Live::Dragging }; changed = Some((set_midpoint(&origin, i, m), phase)); @@ -248,15 +456,19 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) { } } // Stops (house-shaped markers under the ramp). - let mut hit_stop = false; + let alt = super::alt_held(ui); for (i, s) in stops.iter().enumerate() { let x = x_of(s.offset); - let top = bar.bottom() + 2.0; - let marker = Rect::from_min_size(pos2(x - 6.0, top), vec2(12.0, 16.0)); - let resp = ui.interact(marker, ui.id().with(("grad-stop", i)), Sense::click_and_drag()); - hit_stop |= resp.hovered() || resp.dragged(); - let dragging_this = drag == Drag::Stop(i); - let off = dragging_this && resp.interact_pointer_pos().is_some_and(|p| p.y > bar.bottom() + REMOVE_DISTANCE) && origin.len() > MIN_STOPS; + let m = marker(s.offset); + let top = m.top(); + let resp = ui.interact(m, ui.id().with(("grad-stop", i)), Sense::click_and_drag()); + let (dragging_this, copy) = match drag { + Drag::Stop { index, copy } => (index == i, copy), + _ => (false, false), + }; + // A moved stop (not a copy) dragged this far below the ramp is removed on release. + let off_ramp = |p: Pos2| !copy && p.y > bar.bottom() + REMOVE_DISTANCE && origin.len() > MIN_STOPS; + let off = dragging_this && resp.interact_pointer_pos().is_some_and(off_ramp); let outline = if sel == Some(i) { t.accent } else { t.border }; let body = vec![pos2(x, top), pos2(x + 6.0, top + 5.0), pos2(x + 6.0, top + 15.0), pos2(x - 6.0, top + 15.0), pos2(x - 6.0, top + 5.0)]; if !off { @@ -267,89 +479,97 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) { if !is_grad { continue; } + if resp.double_clicked() { + popover = Some((i, m.left_bottom() + vec2(0.0, 4.0))); + } if resp.clicked() || resp.drag_started() { select = Some(i); } if resp.drag_started() { - drag = Drag::Stop(i); + drag = Drag::Stop { index: i, copy: alt }; set_pstate(ui.ctx(), "grad-origin", stops.clone()); } if (resp.dragged() || resp.drag_stopped()) && dragging_this && let Some(pp) = resp.interact_pointer_pos() { - let removing = pp.y > bar.bottom() + REMOVE_DISTANCE && origin.len() > MIN_STOPS; + let phase = if resp.drag_stopped() { Live::Released } else { Live::Dragging }; if resp.drag_stopped() { drag = Drag::None; - if removing { - if let Some(v) = remove_stop(&origin, i) { - select = Some(i.min(v.len() - 1)); - changed = Some((v, Live::Released)); - } - } else { - let (v, ni) = move_stop(&origin, i, x_to_offset(pp.x, bar.left(), bar.width())); - select = Some(ni); - changed = Some((v, Live::Released)); + } + if !off_ramp(pp) { + let over = if copy { stop_under(pp, i) } else { None }; + let (v, ni) = dragged_stops(&origin, i, offset_at(pp), copy, over); + select = Some(ni); + changed = Some((v, phase)); + } else if phase == Live::Released { + if let Some(v) = remove_stop(&origin, i) { + select = Some(i.min(v.len() - 1)); + changed = Some((v, phase)); } - } else if removing { - // Dragged off: show the gradient without moving the stop until release. - changed = Some((origin.clone(), Live::Dragging)); } else { - let (v, _) = move_stop(&origin, i, x_to_offset(pp.x, bar.left(), bar.width())); - changed = Some((v, Live::Dragging)); + // Dragged off: show the gradient without the stop moving until release. + changed = Some((origin.clone(), phase)); } } } - // Click below the ramp (not on a stop) adds a stop; clicking the ramp applies a gradient. - let below = Rect::from_min_max(pos2(bar.left(), bar.bottom()), pos2(bar.right(), area.bottom())); - let resp = ui.interact(below, ui.id().with("grad-add"), Sense::click()); - if resp.hovered() && !hit_stop && is_grad { - ui.ctx().set_cursor_icon(egui::CursorIcon::Copy); - } - if resp.clicked() - && !hit_stop - && let Some(p) = resp.interact_pointer_pos() + // A colour swatch dropped on the ramp. + if is_grad + && let Some(d) = area_resp.dnd_release_payload::() + && let Some(c) = d.color() + && let Some(p) = ui.input(|i| i.pointer.latest_pos()) { - if is_grad { - let (v, i) = insert_stop(g, x_to_offset(p.x, bar.left(), bar.width())); - select = Some(i); - changed = Some((v, Live::Released)); - } else { - edit(app, json!({}), Live::Released); - } - } - let bar_resp = ui.interact(bar, ui.id().with("grad-bar"), Sense::click()); - if bar_resp.clicked() && !is_grad { - edit(app, json!({}), Live::Released); + let (v, i) = drop_color(g, stops.iter().position(|s| marker(s.offset).contains(p)), offset_at(p), c); + select = Some(i); + changed = Some((v, Live::Released)); } set_pstate(ui.ctx(), "grad-drag", drag); - if let Some((v, phase)) = changed { - edit(app, json!({"stops": stops_json(&v)}), phase); + if let Some(i) = pick_mid { + set_pstate(ui.ctx(), MID_SELECTED, Some((i, first_id(app)))); + if sel.is_some() { + select_stop(app, None); + } + } else if select.is_some() { + set_pstate(ui.ctx(), MID_SELECTED, None::<(usize, Option)>); } - if let Some(i) = select { - select_stop(app, i); + match changed { + Some((v, phase)) => set_stops(app, &v, select, phase), + None => { + if let Some(i) = select.filter(|i| sel != Some(*i)) { + select_stop(app, Some(i)); + } + } + } + if let Some((i, at)) = popover { + open_popover(app, i, at); } } pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let g = current(app); - menu_item(ui, "Hide Options", false, false); - if menu_item(ui, "Add to Swatches", g.is_some(), false) - && let Some(g) = g - { - app.run("swatch.new", super::paint_params(&Paint::Gradient(Box::new(g)))).ok(); + let hidden: bool = pstate(ui.ctx(), HIDE_OPTIONS); + if menu_item(ui, if hidden { "Show Options" } else { "Hide Options" }, true, false) { + set_pstate(ui.ctx(), HIDE_OPTIONS, !hidden); } - if menu_item(ui, "Reverse Gradient", current(app).is_some(), false) { + ui.separator(); + if menu_item(ui, "Add to Swatches", g.is_some(), false) + && let Some(g) = &g + { + save_to_swatches(app, g); + } + if menu_item(ui, "Reverse Gradient", g.is_some(), false) { edit(app, json!({"reverse": true}), Live::Released); } if menu_item(ui, "Reset to White, Black", true, false) { - let d = Gradient::default(); - edit(app, json!({"stops": stops_json(&d.stops)}), Live::Released); + set_stops(app, &Gradient::default().stops, None, Live::Released); } } #[cfg(test)] mod tests { + use egui::{Event, Modifiers, PointerButton}; + use vectorcraft_engine::Session; + use super::*; #[test] @@ -361,4 +581,251 @@ mod tests { assert_eq!(j.as_array().unwrap().len(), 2); assert_eq!(j[1]["midpoint"], json!(0.5)); } + + #[test] + fn dropped_colours_recolour_or_add_an_opaque_stop() { + let mut g = Gradient::default(); + g.stops[1].opacity = 0.5; + let red = Color::rgb(1.0, 0.0, 0.0); + let (v, i) = drop_color(&g, Some(1), 0.3, red); + assert_eq!((v.len(), i, v[1].color, v[1].opacity), (2, 1, red, 1.0)); + let (v, i) = drop_color(&g, None, 0.3, red); + assert_eq!((v.len(), i, v[1].color, v[1].offset, v[1].opacity), (3, 1, red, 0.3, 1.0)); + } + + #[test] + fn dragged_stops_move_copy_or_swap() { + let g = Gradient::default(); + let (v, i) = dragged_stops(&g.stops, 0, 0.4, false, Some(1)); + assert_eq!((v.len(), i, v[0].offset), (2, 0, 0.4)); + let (v, i) = dragged_stops(&g.stops, 0, 0.4, true, None); + assert_eq!((v.len(), i, v[1].offset, v[1].color), (3, 1, 0.4, Color::WHITE)); + let (v, i) = dragged_stops(&g.stops, 0, 1.0, true, Some(1)); + assert_eq!((v.len(), i, v[0].color, v[1].color), (2, 0, Color::BLACK, Color::WHITE)); + // Dropped on itself: a copy. + assert_eq!(dragged_stops(&g.stops, 0, 0.0, true, Some(0)).0.len(), 3); + } + + // ---------- headless frames ---------- + + /// A selected rectangle filled with `fill` (`paint.setFill` params). + fn app(fill: Value) -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); + app.run("paint.setFill", fill).unwrap(); + app + } + + /// A headless Gradient panel: frames share one context and a clock. + struct Panel { + ctx: egui::Context, + time: f64, + } + + impl Panel { + fn new() -> Self { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + Self { ctx, time: 0.0 } + } + + /// One frame with `events` (and `modifiers` held); returns the texts drawn and where. + fn frame(&mut self, app: &mut VectorcraftApp, events: Vec, modifiers: Modifiers) -> Vec<(String, Pos2)> { + fn texts(s: &egui::Shape, out: &mut Vec<(String, Pos2)>) { + match s { + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos + t.galley.rect.center().to_vec2())), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + self.time += 0.1; + let mut events = events; + events.insert(0, Event::ModifiersChanged(modifiers)); + let input = egui::RawInput { + time: Some(self.time), + events, + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(260.0, 600.0))), + ..Default::default() + }; + let mut out = self.ctx.run_ui(input, |ui| show(app, ui)); + out.textures_delta.clear(); + let mut v = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut v)); + v + } + + /// The rect of the widget its parent ui keyed `key` (last frame). + fn widget(&self, key: impl std::hash::Hash + std::fmt::Debug + Copy) -> Rect { + self.ctx + .viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).find(|w| w.id == w.parent_id.with(key)).map(|w| w.rect)) + .expect("widget drawn") + } + + fn click(&mut self, app: &mut VectorcraftApp, at: Pos2) -> Vec<(String, Pos2)> { + let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + self.frame(app, vec![Event::PointerMoved(at), b(true)], Modifiers::NONE); + self.frame(app, vec![b(false)], Modifiers::NONE); + self.frame(app, vec![], Modifiers::NONE) + } + + /// Press at `from`, move to `to` in steps and release there, with `m` held. + fn drag(&mut self, app: &mut VectorcraftApp, from: Pos2, to: Pos2, m: Modifiers) { + let b = |at, pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: m }; + self.frame(app, vec![Event::PointerMoved(from), b(from, true)], m); + for k in 1..=4 { + self.frame(app, vec![Event::PointerMoved(from + (to - from) * (k as f32 / 4.0))], m); + } + self.frame(app, vec![b(to, false)], m); + self.frame(app, vec![], Modifiers::NONE); + } + } + + fn fill(app: &VectorcraftApp) -> GradientPaint { + match crate::panels::current_paints(app).0 { + Paint::Gradient(g) => *g, + p => panic!("expected a gradient fill, got {p:?}"), + } + } + + fn hexes(app: &VectorcraftApp) -> Vec { + fill(app).gradient.stops.iter().map(|s| s.color.to_hex()).collect() + } + + #[test] + fn the_proxy_toggles_fill_and_stroke_and_stays_with_options_hidden() { + let mut app = app(json!({"gradient": {}})); + let mut p = Panel::new(); + let texts = p.frame(&mut app, vec![], Modifiers::NONE); + assert!(texts.iter().any(|(t, _)| t == "Opacity:")); + let stroke = p.widget("stroke-proxy"); + p.click(&mut app, stroke.right_bottom() - vec2(2.0, 2.0)); + assert!(!app.session.fill_active, "the Stroke proxy came to the front"); + set_pstate(&p.ctx, HIDE_OPTIONS, true); + let texts = p.frame(&mut app, vec![], Modifiers::NONE); + assert!(!texts.iter().any(|(t, _)| t == "Opacity:" || t == "Type:"), "{texts:?}"); + let fill = p.widget("fill-proxy"); + p.click(&mut app, fill.left_top() + vec2(2.0, 2.0)); + assert!(app.session.fill_active, "the proxy works with the options hidden"); + p.widget(("grad-stop", 1)); + } + + #[test] + fn a_selected_diamond_shows_and_sets_its_midpoint_in_location() { + let stops = json!([{"offset": 0, "color": "#ffffff", "midpoint": 0.3}, {"offset": 1, "color": "#000000"}]); + let mut app = app(json!({"gradient": {"stops": stops}})); + app.run("gradient.selectStop", json!({"index": 1})).unwrap(); + let mut p = Panel::new(); + let texts = p.frame(&mut app, vec![], Modifiers::NONE); + assert!(texts.iter().any(|(t, _)| t == "100%"), "the stop's location: {texts:?}"); + let diamond = p.widget(("grad-mid", 0)); + let texts = p.click(&mut app, diamond.center()); + assert_eq!(app.session.selected_stop(), None, "the diamond replaces the stop selection"); + assert!(texts.iter().any(|(t, _)| t == "30%"), "the midpoint: {texts:?}"); + // Typing a location moves the midpoint. + let field = texts.iter().find(|(t, _)| t == "30%").unwrap().1; + p.click(&mut app, field); + let key = |k, modifiers| Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers }; + p.frame(&mut app, vec![key(egui::Key::A, Modifiers::COMMAND), Event::Text("60".into())], Modifiers::NONE); + p.frame(&mut app, vec![key(egui::Key::Enter, Modifiers::NONE)], Modifiers::NONE); + assert!((fill(&app).gradient.stops[0].midpoint - 0.6).abs() < 1e-6, "{:?}", fill(&app).gradient.stops); + // Clicking a stop selects it again. + let stop = p.widget(("grad-stop", 0)); + let texts = p.click(&mut app, stop.center()); + assert_eq!(app.session.selected_stop(), Some(0)); + assert!(texts.iter().any(|(t, _)| t == "0%")); + } + + #[test] + fn the_thumbnail_menu_applies_gradient_swatches_and_saves_one() { + let mut app = app(json!({"color": "#336699"})); + let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]); + app.run("swatch.new", json!({"name": "Dusk", "gradient": {"stops": stops}})).unwrap(); + let mut p = Panel::new(); + p.frame(&mut app, vec![], Modifiers::NONE); + let menu = p.widget("grad-swatches").center(); + let texts = p.click(&mut app, menu); + let dusk = texts.iter().find(|(t, _)| t == "Dusk").unwrap_or_else(|| panic!("the menu lists the swatch: {texts:?}")).1; + assert!(texts.iter().any(|(t, _)| t.ends_with("Save to Swatches"))); + p.click(&mut app, dusk); + assert_eq!(hexes(&app), ["#ff0000", "#0000ff"]); + // Save to Swatches adds the current gradient. + let before = gradient_swatches(&app).len(); + let texts = p.click(&mut app, menu); + let save = texts.iter().find(|(t, _)| t.ends_with("Save to Swatches")).unwrap().1; + p.click(&mut app, save); + assert_eq!(gradient_swatches(&app).len(), before + 1); + } + + #[test] + fn alt_drag_copies_a_stop_and_alt_dropping_on_a_stop_swaps_colours() { + let mut app = app(json!({"gradient": {}})); + let mut p = Panel::new(); + p.frame(&mut app, vec![], Modifiers::NONE); + let (a, b) = (p.widget(("grad-stop", 0)).center(), p.widget(("grad-stop", 1)).center()); + let mid = pos2((a.x + b.x) / 2.0, a.y); + let offsets = |app: &VectorcraftApp| fill(app).gradient.stops.iter().map(|s| (s.offset * 10.0).round() / 10.0).collect::>(); + // A plain drag moves the stop. + p.drag(&mut app, a, mid, Modifiers::NONE); + assert_eq!(offsets(&app), [0.5, 1.0]); + app.run("edit.undo", json!({})).unwrap(); + // Alt leaves the original and drags a copy, selected on release; one undo step. + p.frame(&mut app, vec![], Modifiers::NONE); + p.drag(&mut app, a, mid, Modifiers::ALT); + assert_eq!((offsets(&app), hexes(&app)), (vec![0.0, 0.5, 1.0], vec!["#ffffff".to_string(), "#ffffff".into(), "#000000".into()])); + assert_eq!(app.session.selected_stop(), Some(1)); + // Alt-dropping the first stop on the last swaps their colours. + p.drag(&mut app, a, b, Modifiers::ALT); + assert_eq!((offsets(&app), hexes(&app)), (vec![0.0, 0.5, 1.0], vec!["#000000".to_string(), "#ffffff".into(), "#ffffff".into()])); + app.run("edit.undo", json!({})).unwrap(); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(hexes(&app), ["#ffffff", "#000000"]); + } + + #[test] + fn a_dropped_swatch_adds_or_recolours_a_stop() { + let mut app = app(json!({"gradient": {}})); + let mut p = Panel::new(); + p.frame(&mut app, vec![], Modifiers::NONE); + let (a, b) = (p.widget(("grad-stop", 0)).center(), p.widget(("grad-stop", 1)).center()); + let drop = |p: &mut Panel, app: &mut VectorcraftApp, at: Pos2| { + egui::DragAndDrop::set_payload(&p.ctx, PanelDrag::paint(Paint::solid(Color::rgb(1.0, 0.0, 0.0)))); + p.frame(app, vec![Event::PointerMoved(at)], Modifiers::NONE); + let up = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }; + p.frame(app, vec![up], Modifiers::NONE); + }; + drop(&mut p, &mut app, pos2((a.x + b.x) / 2.0, a.y - 20.0)); + assert_eq!(hexes(&app), ["#ffffff", "#ff0000", "#000000"]); + p.frame(&mut app, vec![], Modifiers::NONE); + let last = p.widget(("grad-stop", 2)).center(); + drop(&mut p, &mut app, last); + assert_eq!(hexes(&app), ["#ffffff", "#ff0000", "#ff0000"]); + } + + #[test] + fn double_clicking_a_stop_opens_the_shared_popover_beside_it() { + let mut app = app(json!({"gradient": {}})); + let mut p = Panel::new(); + p.frame(&mut app, vec![], Modifiers::NONE); + let stop = p.widget(("grad-stop", 1)); + let b = |pressed| Event::PointerButton { pos: stop.center(), button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + p.frame(&mut app, vec![Event::PointerMoved(stop.center()), b(true), b(false), b(true), b(false)], Modifiers::NONE); + let d = app.ui.dialog.clone().expect("the popover opened"); + assert_eq!( + (d.kind.as_str(), &d.fields["index"], &d.fields["screen"]), + ("gradientStop", &json!(1), &json!([stop.left(), stop.bottom() + 4.0])) + ); + assert_eq!(app.session.selected_stop(), Some(1)); + } + + #[test] + fn the_stop_eyedropper_arms_the_eyedropper_for_the_stop() { + let mut app = app(json!({"gradient": {}})); + app.select_tool("gradient"); + app.run("gradient.selectStop", json!({"index": 0})).unwrap(); + let texts = Panel::new().frame(&mut app, vec![], Modifiers::NONE); + assert!(texts.iter().any(|(t, _)| t == "#FFFFFF"), "the stop's colour row: {texts:?}"); + stop_eyedropper(&mut app); + assert_eq!((app.session.tool_id(), app.session.tool_options()), ("eyedropper", json!({"stop": "gradient"}))); + } } diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 465c7e7f..4a82cb32 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -2,8 +2,8 @@ //! or list, colour groups as folders, None/Registration first, bottom bar and panel menu. //! //! Swatches drag to reorder, into and out of colour groups (`swatch.move`) and onto art (the active -//! proxy's paint command with the object's id); a Fill/Stroke proxy dropped on the panel becomes a -//! swatch (`swatch.new`). +//! proxy's paint command with the object's id) and onto the Gradient panel's ramp; a Fill/Stroke +//! proxy or the Gradient panel's thumbnail dropped on the panel becomes a swatch (`swatch.new`). use egui::{Color32, Rect, Response, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::{Value, json}; @@ -12,7 +12,7 @@ use vectorcraft_doc::Document; use super::{active_paint, pstate, set_pstate}; use crate::theme::Tokens; -use crate::widgets::{self, menu_item, swatch_tile}; +use crate::widgets::{self, PanelDrag, SwatchRows, menu_item, swatch_tile}; use crate::{VectorcraftApp, icons}; /// View modes of the swatch list. @@ -135,18 +135,19 @@ fn entries(app: &VectorcraftApp, kind: Kind, query: &str) -> Vec { out } -/// The paint command params that apply swatch (or Registration) `name`; `None` for colour groups -/// and unknown names. -fn swatch_params(d: &Document, name: &str) -> Option { - if name == REGISTRATION { - return Some(json!({"color": {"c": 1.0, "m": 1.0, "y": 1.0, "k": 1.0}})); - } - Some(if d.swatch(name)?.paint.is_none() { json!({"none": true}) } else { json!({"swatch": name}) }) +/// The paint command params that apply entry `e` (`paint.setFill`); none for a colour group. +fn swatch_params(e: &Entry) -> Option { + Some(match e { + Entry::Registration => json!({"color": {"c": 1.0, "m": 1.0, "y": 1.0, "k": 1.0}}), + Entry::Swatch { paint, .. } if paint.is_none() => json!({"none": true}), + Entry::Swatch { name, .. } => json!({"swatch": name}), + Entry::Folder(_) => return None, + }) } /// Apply a clicked swatch to the active proxy (Alt: the inactive one). fn apply(app: &mut VectorcraftApp, ui: &Ui, e: &Entry) { - if let Some(params) = app.session.active().and_then(|st| swatch_params(&st.doc, e.name())) { + if let Some(params) = swatch_params(e) { super::apply_click(app, ui, params); } } @@ -329,32 +330,34 @@ fn click_selection(ui: &Ui, mut sel: Vec, order: &[&str], name: &str, m: sel } -/// What the Swatches panel drags: the swatch, None, Registration or colour group under the pointer -/// when the drag started (it paints art it's dropped on) and the names it moves: the panel -/// selection when the grabbed one is part of it (only colour groups, or only swatches, like the -/// grabbed one), in panel order. -#[derive(Clone, Debug)] -pub(crate) struct SwatchDrag { - pub grabbed: String, - pub names: Vec, - /// The names are colour groups. - pub groups: bool, -} - /// A drag released on the panel. enum Drop { /// Swatches (or colour groups) dropped before or `after` row `target`, into folder `target`, or /// at the end (`None`). Move { names: Vec, target: Option, after: bool }, - /// A Fill/Stroke proxy's paint dropped on row `target` (or between rows) becomes a swatch. + /// A paint from elsewhere (a Fill/Stroke proxy, the Gradient panel's thumbnail) dropped on row + /// `target` (or between rows) becomes a swatch. New { paint: Paint, target: Option }, } -/// A drag payload of type `T` released on `resp`. (egui's release takes any payload, so the type is -/// checked first: a payload of another type stays for the next drop target.) -fn released(resp: &Response) -> Option> { - resp.dnd_hover_payload::()?; - resp.dnd_release_payload::() +impl Drop { + /// What releasing `d` before or `after` row `target` (`None`: at the end) does: move the rows + /// it carries, or make a swatch of a paint dragged from elsewhere. + fn of(d: &PanelDrag, target: Option, after: bool) -> Self { + let PanelDrag::Paint { paint, rows, .. } = d; + match rows { + Some(r) => Drop::Move { names: r.names.clone(), target, after }, + None => Drop::New { paint: paint.clone(), target }, + } + } +} + +/// The drag a tile or row of `e` starts, moving rows `names`. Registration and colour groups have +/// no paint of their own (the chip under the pointer draws Registration's mark). +fn drag_of(e: &Entry, names: Vec) -> PanelDrag { + let paint = if let Entry::Swatch { paint, .. } = e { paint.clone() } else { Paint::None }; + let rows = SwatchRows { grabbed: e.name().to_string(), names, groups: e.is_folder() }; + PanelDrag::Paint { paint, params: swatch_params(e).unwrap_or_default(), rows: Some(rows) } } /// What the tiles or rows saw this frame. @@ -370,8 +373,7 @@ struct TileEvents { /// Clicks, double-clicks, tooltips and drag and drop of the tile or row `resp` of entry `e` /// (`items`: the panel's rows, `sel`: its selection, `list`: rows stack downwards). A drag from it -/// starts a [`SwatchDrag`]; a drag held over it shows where it would land (before or after it by -/// the pointer's half, or into a folder) and a release there sets the drop. +/// starts a [`PanelDrag`] carrying the rows it moves; see [`tile_drop`] for drags held over it. fn tile_input(ui: &Ui, resp: Response, e: &Entry, items: &[Entry], sel: &[String], list: bool, ev: &mut TileEvents) { let name = e.name(); if matches!(e, Entry::Swatch { .. }) && resp.double_clicked() { @@ -384,36 +386,9 @@ fn tile_input(ui: &Ui, resp: Response, e: &Entry, items: &[Entry], sel: &[String .filter(|x| x.is_folder() == e.is_folder() && (x.name() == name || (with_sel && sel.iter().any(|s| s == x.name())))) .map(|x| x.name().to_string()) .collect(); - egui::DragAndDrop::set_payload(ui.ctx(), SwatchDrag { grabbed: name.to_string(), names, groups: e.is_folder() }); - } - let moving = resp.dnd_hover_payload::(); - // Swatches dropped on one of themselves stay where they are. - if moving.as_ref().is_some_and(|d| d.names.iter().any(|n| n == name)) { - ev.over = true; - released::(&resp); - } else if moving.is_some() || resp.dnd_hover_payload::().is_some() { - ev.over = true; - let t = Tokens::get(ui.ctx()); - let r = resp.rect; - let at = ui.input(|i| i.pointer.interact_pos()).unwrap_or(r.center()); - let after = if list { at.y > r.center().y } else { at.x > r.center().x }; - // A proxy's paint, or swatches over a folder, go into it; otherwise a bar marks the slot. - if moving.as_ref().is_none_or(|d| e.is_folder() && !d.groups) { - ui.painter().rect_stroke(r.expand(1.0), 0.0, Stroke::new(1.5, t.accent), StrokeKind::Outside); - } else if list { - let y = if after { r.bottom() } else { r.top() }; - ui.painter().line_segment([pos2(r.left(), y), pos2(r.right(), y)], Stroke::new(2.0, t.accent)); - } else { - let x = if after { r.right() + 0.75 } else { r.left() - 0.75 }; - ui.painter().line_segment([pos2(x, r.top() - 1.0), pos2(x, r.bottom() + 1.0)], Stroke::new(2.0, t.accent)); - } - let target = Some(name.to_string()); - if let Some(d) = moving.and_then(|_| released::(&resp)) { - ev.drop = Some(Drop::Move { names: d.names.clone(), target, after }); - } else if let Some(p) = released::(&resp) { - ev.drop = Some(Drop::New { paint: (*p).clone(), target }); - } + egui::DragAndDrop::set_payload(ui.ctx(), drag_of(e, names)); } + tile_drop(ui, &resp, e, list, ev); let resp = match e { Entry::Folder(n) => resp.on_hover_text(format!("Color Group: {n}")), Entry::Swatch { paint, global, spot, .. } if list => resp.on_hover_ui(|ui| { @@ -426,17 +401,46 @@ fn tile_input(ui: &Ui, resp: Response, e: &Entry, items: &[Entry], sel: &[String } } +/// A drag held over the tile or row `resp` of entry `e` shows where it would land (before or after +/// it by the pointer's half, or into a folder); a release there sets the drop. +fn tile_drop(ui: &Ui, resp: &Response, e: &Entry, list: bool, ev: &mut TileEvents) { + let Some(d) = resp.dnd_hover_payload::() else { return }; + let PanelDrag::Paint { rows, .. } = &*d; + let name = e.name(); + ev.over = true; + // Swatches dropped on one of themselves stay where they are. + if rows.as_ref().is_some_and(|r| r.names.iter().any(|n| n == name)) { + resp.dnd_release_payload::(); + return; + } + let t = Tokens::get(ui.ctx()); + let r = resp.rect; + let at = ui.input(|i| i.pointer.interact_pos()).unwrap_or(r.center()); + let after = if list { at.y > r.center().y } else { at.x > r.center().x }; + // A paint from elsewhere, or swatches over a folder, go into it; otherwise a bar marks the slot. + if rows.as_ref().is_none_or(|d| e.is_folder() && !d.groups) { + ui.painter().rect_stroke(r.expand(1.0), 0.0, Stroke::new(1.5, t.accent), StrokeKind::Outside); + } else if list { + let y = if after { r.bottom() } else { r.top() }; + ui.painter().line_segment([pos2(r.left(), y), pos2(r.right(), y)], Stroke::new(2.0, t.accent)); + } else { + let x = if after { r.right() + 0.75 } else { r.left() - 0.75 }; + ui.painter().line_segment([pos2(x, r.top() - 1.0), pos2(x, r.bottom() + 1.0)], Stroke::new(2.0, t.accent)); + } + if resp.dnd_release_payload::().is_some() { + ev.drop = Some(Drop::of(&d, Some(name.to_string()), after)); + } +} + /// Drops between and after the tiles (`zone`: the list's viewport) go to the end of the ungrouped /// swatches (colour groups to the end of the groups); the list is outlined while one is held there. fn zone_input(ui: &Ui, zone: &Response, ev: &mut TileEvents) { - let held = zone.dnd_hover_payload::().is_some() || zone.dnd_hover_payload::().is_some(); - if held && !ev.over { + let Some(d) = zone.dnd_hover_payload::() else { return }; + if !ev.over { ui.painter().rect_stroke(zone.rect, 0.0, Stroke::new(1.5, Tokens::get(ui.ctx()).accent), StrokeKind::Inside); } - if let Some(d) = released::(zone) { - ev.drop = Some(Drop::Move { names: d.names.clone(), target: None, after: false }); - } else if let Some(p) = released::(zone) { - ev.drop = Some(Drop::New { paint: (*p).clone(), target: None }); + if zone.dnd_release_payload::().is_some() { + ev.drop = Some(Drop::of(&d, None, false)); } } @@ -505,33 +509,16 @@ fn apply_drop(app: &mut VectorcraftApp, drop: Drop) { } } -/// Paint command params for swatches or a Fill/Stroke proxy released on `resp`: the grabbed swatch -/// (`None` for a colour group), or the proxy's paint (a gradient fits the new object afresh). -pub(crate) fn released_paint(app: &VectorcraftApp, resp: &Response) -> Option { - if let Some(d) = released::(resp) { - return swatch_params(&app.session.active()?.doc, &d.grabbed); - } - let mut paint = (*released::(resp)?).clone(); - if let Paint::Gradient(g) = &mut paint { - g.geom = None; - } - Some(super::paint_params(&paint)) -} - -/// While swatches or a Fill/Stroke proxy are dragged, a chip of the grabbed paint follows the -/// pointer. +/// While a paint is dragged (swatches, a Fill/Stroke proxy, the Gradient panel's thumbnail), a chip +/// of it follows the pointer. pub(crate) fn drag_preview(app: &VectorcraftApp, ctx: &egui::Context) { - let (paint, registration) = if let Some(d) = egui::DragAndDrop::payload::(ctx) { - match app.session.active().and_then(|st| st.doc.swatch(&d.grabbed)) { - Some(w) => (w.paint.clone(), false), - None if d.grabbed == REGISTRATION => (Paint::None, true), - None => return, - } - } else if let Some(p) = egui::DragAndDrop::payload::(ctx) { - ((*p).clone(), false) - } else { + let Some(d) = egui::DragAndDrop::payload::(ctx) else { return }; + let PanelDrag::Paint { paint, params, rows } = &*d; + // Colour groups paint nothing. + if params.is_null() { return; - }; + } + let registration = rows.as_ref().is_some_and(|r| r.grabbed == REGISTRATION); let Some(at) = ctx.pointer_hover_pos() else { return }; let area = egui::Area::new(egui::Id::new("swatch-drag-preview")).order(egui::Order::Tooltip).fixed_pos(at + vec2(12.0, 12.0)); area.interactable(false).show(ctx, |ui| { @@ -539,8 +526,8 @@ pub(crate) fn drag_preview(app: &VectorcraftApp, ctx: &egui::Context) { if registration { draw_registration(ui, r); } else { - swatch_tile(ui, r, &paint, false, false); - pattern_thumb(app, ui, r.shrink(1.0), &paint); + swatch_tile(ui, r, paint, false, false); + pattern_thumb(app, ui, r.shrink(1.0), paint); } }); } @@ -1184,7 +1171,7 @@ mod tests { let ctx = context(); frame(&mut app, &ctx, vec![], 0.0, show); let at = tile_center(&ctx, "Bright Red"); - egui::DragAndDrop::set_payload(&ctx, Paint::solid(Color::from_hex("#123456").unwrap())); + egui::DragAndDrop::set_payload(&ctx, PanelDrag::paint(Paint::solid(Color::from_hex("#123456").unwrap()))); let release = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }; frame(&mut app, &ctx, vec![Event::PointerMoved(at), release], 1.0, show); assert_eq!( @@ -1192,7 +1179,7 @@ mod tests { Some("R=18 G=52 B=86"), "dropped on a group's colour: into that group" ); - assert!(egui::DragAndDrop::payload::(&ctx).is_none()); + assert!(egui::DragAndDrop::payload::(&ctx).is_none()); } #[test] diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 461bc9aa..c53780d1 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -157,6 +157,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } else if resp.clicked() && alt && slot.len() > 1 { let idx = slot.iter().position(|x| *x == shown.id).unwrap_or(0); app.select_tool(slot[(idx + 1) % slot.len()]); + } else if resp.double_clicked() { + app.select_tool(shown.id); + app.run("tool.options", json!({ "tool": shown.id })).ok(); } else if resp.clicked() { app.select_tool(shown.id); } @@ -178,6 +181,28 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { flyout(app, ui.ctx()); } +/// Open a tool's options (`tool.options`, a double-click on its button): the Gradient tool's are +/// the Gradient panel, the Eyedropper's the Eyedropper Options dialog. +pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result { + match tool { + "gradient" if app.ui.open_panel.as_deref() == Some("gradient") => 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" })) + } + _ if vectorcraft_tools::tool_info(tool).is_none() => Err(format!("unknown tool `{tool}`")), + _ => Err(format!("the {tool} tool has no options")), + } +} + fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens) { ui.vertical_centered(|ui| crate::panels::proxy(app, ui, 36.0)); ui.add_space(5.0); @@ -289,3 +314,59 @@ fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) { } let _ = theme::semibold; } + +#[cfg(test)] +mod tests { + use egui::{Event, PointerButton, Pos2}; + use vectorcraft_engine::Session; + + use super::*; + + /// One headless frame of the toolbar; returns the tool buttons' rects, top to bottom. + fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec) -> Vec { + let input = egui::RawInput { + time: Some(time), + events, + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 1200.0))), + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| show(app, ui)); + out.textures_delta.clear(); + let mut r: Vec = ctx.viewport(|vp| { + let size = vec2(36.0, PITCH - 1.0); + vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == size).map(|w| w.rect).collect() + }); + r.sort_by(|a, b| a.top().total_cmp(&b.top())); + r + } + + /// A double-click at `at`, `time` seconds in (a second apart from the last). + fn double_click(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, at: Pos2) { + let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, ctx, time, vec![Event::PointerMoved(at), b(true), b(false), b(true), b(false)]); + frame(app, ctx, time + 0.1, vec![]); + } + + #[test] + fn double_clicking_a_tool_button_opens_its_options() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + let buttons = frame(&mut app, &ctx, 0.0, vec![]); + // The Color category's slots close the list: Gradient, then Eyedropper. + let (gradient, eyedropper) = (buttons[buttons.len() - 2], buttons[buttons.len() - 1]); + double_click(&mut app, &ctx, 1.0, gradient.center()); + assert_eq!((app.session.tool_id(), app.ui.open_panel.as_deref()), ("gradient", Some("gradient"))); + // Again: the panel stays open. + double_click(&mut app, &ctx, 2.0, gradient.center()); + 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())); + // OK applies the options. + app.ui.dialog.as_mut().unwrap().fields.insert("transparency".into(), json!(false)); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.session.eyedropper.appearance && !app.session.eyedropper.transparency); + 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 a6d84496..bffab921 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -259,8 +259,8 @@ pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, mixed: (bool let fill_resp = ui.interact(fill_r, ui.id().with("fill-proxy"), Sense::click_and_drag()); // A proxy drags its paint (onto the Swatches panel or art), unless it shows "?". for (resp, paint, mixed) in [(&fill_resp, fill, mixed.0), (&stroke_resp, stroke, mixed.1)] { - if resp.drag_started() && !mixed { - egui::DragAndDrop::set_payload(ui.ctx(), paint.clone()); + if !mixed { + drag_source(ui, resp, || PanelDrag::paint(paint.clone())); } } let question = |ui: &Ui, r: Rect, color: Color32| { @@ -944,3 +944,52 @@ pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: None } } + +/// What panels drag onto art and onto each other. One payload type, so the canvas has one drop +/// handler for all of them (`canvas::panel_drop`); a chip of a dragged paint follows the pointer. +#[derive(Clone, Debug, PartialEq)] +pub enum PanelDrag { + /// A paint: Swatches panel tiles, a Fill/Stroke proxy or the Gradient panel's thumbnail. Art it + /// is dropped on takes `params` (`{swatch}`, `{color}`, `{gradient}`…; null for a colour group, + /// which paints nothing) through the active proxy's `paint.setFill`/`paint.setStroke`; the + /// Gradient panel's ramp takes its colour as a stop; the Swatches panel moves `rows`, or makes + /// a swatch of a paint dragged from elsewhere. + Paint { paint: Paint, params: serde_json::Value, rows: Option }, +} + +/// The Swatches panel rows a drag from that panel moves: the swatch, None, Registration or colour +/// group under the pointer when the drag started (`grabbed`) and `names`, the panel selection when +/// the grabbed one is part of it (only colour groups, or only swatches, like the grabbed one), in +/// panel order. +#[derive(Clone, Debug, PartialEq)] +pub struct SwatchRows { + pub grabbed: String, + pub names: Vec, + /// The names are colour groups. + pub groups: bool, +} + +impl PanelDrag { + /// `paint` dragged from a Fill/Stroke proxy or the Gradient panel's thumbnail. A gradient + /// carries no placement: it fits the art it lands on. + pub fn paint(mut paint: Paint) -> Self { + if let Paint::Gradient(g) = &mut paint { + g.geom = None; + } + Self::Paint { params: crate::panels::paint_params(&paint), paint, rows: None } + } + /// The dragged colour (a solid paint's), which the Gradient panel's ramp takes. + pub fn color(&self) -> Option { + match self { + Self::Paint { paint, .. } => paint.color(), + } + } +} + +/// Make `resp` (which senses drags) a source of the [`PanelDrag`] `drag` builds when a drag starts +/// on it. +pub fn drag_source(ui: &Ui, resp: &Response, drag: impl FnOnce() -> PanelDrag) { + if resp.drag_started() { + egui::DragAndDrop::set_payload(ui.ctx(), drag()); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d8a38197..74b29c1b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -40,3 +40,16 @@ The Color Picker is a dialog too: `engine.execute {command: "ui.colorPicker", pa Graphic styles: `ui.graphicStyleOptions {name}` opens the `graphicStyleOptions` dialog (field `name`) for a style and `ui.dialog.confirm` renames it with `graphicStyle.rename` (a name another style has returns an error and keeps the dialog open); without `name` it names a new style made from the selection (`graphicStyle.new`). + +Gradient panel: double-clicking a stop on the panel's slider opens the same `gradientStop` popover, with a `screen` +field (`[x, y]`, screen points) in place of `x`/`y`. The panel's stop eyedropper selects the Eyedropper tool with the +tool option `stop` (the tool to return to; `tool.setOption {key: "stop", value: "gradient"}`): its next click on art +samples the colour there into the selected stop (`paint.sampleColor {color, stop}`) and switches back. Dragging a +swatch, a Fill/Stroke proxy or the panel's gradient thumbnail onto art runs `paint.setFill` / `paint.setStroke` (the +active proxy) with the paint's params and the object's `ids`; a colour dropped on the panel's ramp adds or recolours +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 +anchors, edges and smart guides; Shift constrains them to 45° steps from the `constrainAngle` preference.