From 059f623410c2152b79a3c918b3091816fc3db753 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 23:00:38 +0700 Subject: [PATCH] M3.33: Gradient panel: proxy, swatch menu, diamond location, stop popover, Alt-drag copies and swaps, swatch drops, stop eyedropper The Gradient panel gains the Fill/Stroke proxy, a thumbnail menu of the document's gradient swatches with Save to Swatches, Angle and Aspect Ratio fields with preset menus (the shared spin field), Show/Hide Options, and a stop colour row whose eyedropper samples the next colour clicked on art into the selected stop (Eyedropper tool option `stop`, `paint.sampleColor {stop}`). On the slider a selected midpoint diamond shows and sets its midpoint in Location, double-clicking a stop opens the shared stop popover, Alt-drag copies a stop and Alt-dropping it on another swaps their colours (`swap_stop_colors`), and a dropped colour swatch adds or recolours a stop. Clicking a stop no longer falls through to the add-stop strip. Swatch tiles and the gradient thumbnail drag onto art, which paints the object under the pointer through `paint.setFill`/`paint.setStroke {ids}`. The panel, the popover and the Color panel share one stop-writing helper. Co-Authored-By: Claude Opus 5.5 --- crates/color/src/gradient.rs | 22 + crates/engine/src/cmd/xform.rs | 23 +- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_gradpanel.rs | 47 ++ crates/tools/src/xform.rs | 4 +- crates/tools/src/xform/eyedropper.rs | 58 +- crates/ui-egui/src/canvas.rs | 50 +- crates/ui-egui/src/dialogs/gradient_stop.rs | 40 +- crates/ui-egui/src/panels/color.rs | 3 +- crates/ui-egui/src/panels/gradient.rs | 774 ++++++++++++++++---- crates/ui-egui/src/panels/swatches.rs | 31 +- crates/ui-egui/src/widgets.rs | 41 +- docs/control-protocol.md | 7 + 13 files changed, 897 insertions(+), 205 deletions(-) create mode 100644 crates/engine/src/tests_gradpanel.rs diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index db259875..b82df7cf 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(); @@ -480,6 +492,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(); diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index 90e5892c..56f907ba 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,7 +27,7 @@ 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 ), @@ -162,13 +163,27 @@ fn distort(s: &mut Session, p: &Value) -> Result { // ---------- eyedropper ---------- 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 6ea09da7..09a5b3a7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -754,6 +754,8 @@ mod tests_file; #[cfg(test)] mod tests_gradient; #[cfg(test)] +mod tests_gradpanel; +#[cfg(test)] mod tests_live; #[cfg(test)] mod tests_menucmds; diff --git a/crates/engine/src/tests_gradpanel.rs b/crates/engine/src/tests_gradpanel.rs new file mode 100644 index 00000000..48551f2d --- /dev/null +++ b/crates/engine/src/tests_gradpanel.rs @@ -0,0 +1,47 @@ +//! The Gradient panel's engine side (M3.33): sampling a colour into a gradient stop. + +use serde_json::json; +use vectorcraft_color::Paint; + +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()]) + ); +} 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/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7382f4f2..c7873d67 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -136,6 +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, &resp, &xf); let painter = ui.painter_at(rect); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); @@ -667,14 +668,30 @@ fn node_outline(n: &Node) -> BezPath { bp } +/// The art under the pointer (3 screen pixels of tolerance). +fn hit_under_pointer(app: &VectorcraftApp, xf: &Xf) -> Option { + let opt = vectorcraft_doc::hit::HitOptions { tol: 3.0 / xf.zoom, outline: app.ui.view.outline, path_only: false }; + vectorcraft_doc::hit::hit_test(&app.session.active()?.doc, app.hover_doc?, opt) +} + +/// A paint dragged out of a panel (a swatch, the Gradient panel's thumbnail) and dropped on art +/// paints the active proxy of the object under the pointer (`paint.setFill`/`paint.setStroke` +/// with its `ids`), selected or not. +fn paint_drop(app: &mut VectorcraftApp, resp: &egui::Response, xf: &Xf) { + let Some(d) = resp.dnd_release_payload::() else { return }; + let Some(hit) = hit_under_pointer(app, xf) else { return }; + let Some(st) = app.session.active() else { return }; + let mut params = d.params.clone(); + params["ids"] = json!([vectorcraft_tools::xform::paint_owner(&st.doc, hit.leaf).0]); + app.run(crate::panels::proxy_cmd(app, false), params).ok(); +} + fn hover_highlight(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { - let Some(h) = app.hover_doc else { return }; if app.session.tool_busy() || !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection") { return; } let Some(st) = app.session.active() else { return }; - let opt = vectorcraft_doc::hit::HitOptions { tol: 3.0 / xf.zoom, outline: app.ui.view.outline, path_only: false }; - let Some(hit) = vectorcraft_doc::hit::hit_test(&st.doc, h, opt) else { return }; + let Some(hit) = hit_under_pointer(app, xf) else { return }; let id = if app.session.tool_id() == "selection" { hit.top_object(st.isolation) } else { hit.leaf }; if st.selection.contains(id) { return; @@ -1081,4 +1098,31 @@ mod tests { assert_eq!(app.view().unwrap().center, after.center); assert_eq!(app.session.active().unwrap().doc.art_bounds(), None); } + + #[test] + fn a_paint_dropped_on_art_paints_that_object_selected_or_not() { + 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": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("select.none", &json!({})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let at = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(150.0, 150.0)); + let drop = |app: &mut VectorcraftApp, at: Pos2, params: serde_json::Value| { + egui::DragAndDrop::set_payload(&ctx, widgets::PaintDrag { paint: vectorcraft_color::Paint::None, params }); + frame(app, &ctx, vec![egui::Event::PointerMoved(at)]); + let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; + frame(app, &ctx, vec![up]); + }; + drop(&mut app, at, json!({"color": "#ff0000"})); + let fill = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.fill_paint(); + assert_eq!(fill(&app).color().map(|c| c.to_hex()), Some("#ff0000".into())); + assert!(app.session.active().unwrap().selection.is_empty(), "the drop does not select"); + // Off the art nothing changes. + drop(&mut app, at - vec2(0.0, 120.0), json!({"color": "#00ff00"})); + assert_eq!(fill(&app).color().map(|c| c.to_hex()), Some("#ff0000".into())); + // A gradient fits the object it lands on. + drop(&mut app, at, json!({"gradient": {}})); + assert!(matches!(fill(&app), vectorcraft_color::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/panels/color.rs b/crates/ui-egui/src/panels/color.rs index 07903388..f81db974 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..848e39cb 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, PaintDrag, 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,173 @@ 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::paint_drag_source(ui, &resp, || { + let paint = Paint::Gradient(Box::new(GradientPaint { geom: None, ..g.clone() })); + PaintDrag { params: super::paint_params(&paint), paint } + }); + 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 +389,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.paint.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 +459,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 +482,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.paint.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 +584,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, PaintDrag { paint: Paint::solid(Color::rgb(1.0, 0.0, 0.0)), params: json!({"color": "#ff0000"}) }); + 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 5018fd29..6dca1536 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -121,15 +121,28 @@ fn entries(app: &VectorcraftApp, kind: Kind) -> Vec { out } -/// Apply a clicked swatch to the active proxy (Alt: the inactive one). -fn apply(app: &mut VectorcraftApp, ui: &Ui, e: &Entry) { - let params = match e { +/// The paint params (`paint.setFill`) of an entry; none for a group. +fn 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, - }; - super::apply_click(app, ui, params); + 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(p) = params(e) { + super::apply_click(app, ui, p); + } +} + +/// Let a swatch tile be dragged onto art or the Gradient panel's ramp. +fn drag_source(ui: &Ui, resp: &egui::Response, e: &Entry) { + if let Entry::Swatch { paint, .. } = e { + widgets::paint_drag_source(ui, resp, || widgets::PaintDrag { paint: paint.clone(), params: params(e).unwrap_or_default() }); + } } /// A pattern swatch drawn as a rendered tile (cached by the definition's identity and size). @@ -268,7 +281,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.set_width(ui.available_width()); if view.is_list() { for e in &items { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), pitch), Sense::click()); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), pitch), Sense::click_and_drag()); + drag_source(ui, &resp, e); let name = e.name(); if is_sel(name) { ui.painter().rect_filled(r, 0.0, t.row_selected); @@ -335,7 +349,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let (r, _) = ui.allocate_exact_size(vec2(w, pitch), Sense::hover()); for (c, e) in row.into_iter().enumerate() { let cell = Rect::from_min_size(r.min + vec2(1.0 + c as f32 * pitch, (pitch - tile) / 2.0), vec2(tile, tile)); - let resp = ui.interact(cell, tile_id(e), Sense::click()); + let resp = ui.interact(cell, tile_id(e), Sense::click_and_drag()); + drag_source(ui, &resp, e); let name = e.name(); match e { Entry::Registration => draw_registration(ui, cell), diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index a065000b..5eef13d5 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -187,7 +187,11 @@ pub fn mixed_field( /// Draw a paint preview (swatch chip) into `rect`. pub fn paint_chip(ui: &Ui, rect: Rect, paint: &Paint) { - let p = ui.painter(); + draw_paint(ui.painter(), rect, paint); +} + +/// [`paint_chip`] on any painter (a layer above the panels). +fn draw_paint(p: &egui::Painter, rect: Rect, paint: &Paint) { match paint { Paint::None => { p.rect_filled(rect, 0.0, Color32::WHITE); @@ -197,7 +201,7 @@ pub fn paint_chip(ui: &Ui, rect: Rect, paint: &Paint) { let [r, g, b, _] = color.to_rgba8(1.0); p.rect_filled(rect, 0.0, Color32::from_rgb(r, g, b)); } - Paint::Gradient(gp) => gradient_chip(ui, rect, &gp.gradient), + Paint::Gradient(gp) => draw_gradient(p, rect, &gp.gradient), Paint::Pattern { .. } => { p.rect_filled(rect, 0.0, Color32::from_gray(200)); for i in 0..4 { @@ -210,7 +214,10 @@ pub fn paint_chip(ui: &Ui, rect: Rect, paint: &Paint) { /// Draw a gradient preview into `rect` (linear left to right; radial from the centre). pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::Gradient) { - let p = ui.painter(); + draw_gradient(ui.painter(), rect, gradient); +} + +fn draw_gradient(p: &egui::Painter, rect: Rect, gradient: &vectorcraft_color::Gradient) { let radial = gradient.kind == vectorcraft_color::GradientKind::Radial; if radial { // Outer colour fills the corners beyond the largest circle. @@ -902,3 +909,31 @@ pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: None } } + +/// A paint dragged out of a panel (a swatch, the Gradient panel's thumbnail). Dropped on art it +/// paints the active proxy through `paint.setFill`/`paint.setStroke` with `params` (`{swatch}`, +/// `{color}`, `{gradient}`…); dropped on the Gradient panel's ramp its colour adds or recolours a +/// stop. +#[derive(Clone, Debug, PartialEq)] +pub struct PaintDrag { + pub paint: Paint, + pub params: serde_json::Value, +} + +/// Make `resp` (which senses drags) a source of the [`PaintDrag`] `drag` builds when the drag +/// starts, and draw that paint under the pointer while it travels. +pub fn paint_drag_source(ui: &Ui, resp: &Response, drag: impl FnOnce() -> PaintDrag) { + if resp.drag_started() { + egui::DragAndDrop::set_payload(ui.ctx(), drag()); + } + if resp.dragged() + && let Some(at) = ui.ctx().pointer_interact_pos() + && let Some(d) = egui::DragAndDrop::payload::(ui.ctx()) + { + let t = Tokens::get(ui.ctx()); + let p = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("paint-drag"))); + let r = Rect::from_min_size(at + vec2(10.0, 10.0), Vec2::splat(18.0)); + draw_paint(&p, r, &d.paint); + p.rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4aacf46a..789017dd 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -36,3 +36,10 @@ fill or stroke (fields as in `swatchOptions` plus `group`; a gradient or pattern `swatches`); `ui.dialog.confirm` runs `swatch.new` / `swatch.newGroup`. The Color Picker is a dialog too: `engine.execute {command: "ui.colorPicker", params: {stroke?, color?}}` opens it for the fill (or stroke) proxy; `ui.dialog.set {field: "hex", value: "00FF00"}` (or `color`, `channel`, `webOnly`, `swatches`) then `ui.dialog.confirm` applies the colour through `paint.setFill` / `paint.setStroke`. + +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 or the panel's gradient thumbnail onto art runs `paint.setFill` / `paint.setStroke` (the active proxy) with +the swatch's params and the object's `ids`.