From b8793eeaefdb004be7bd35e30ef1dd6fee246f32 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 03:58:19 +0700 Subject: [PATCH] M3.97: freeform points drag out to delete, spread ring drags, Lines mode draws lines With the Gradient tool on a freeform gradient the selected point shows its spread as a dashed ring with a handle; dragging either sets the spread. A point dragged out of the object is removed on release, and double-clicking a point opens the gradientStop popover, which now edits the selected freeform point (colour, opacity, spread). In Lines mode successive clicks draw one smooth line (with a rubber band to the pointer); clicking an existing point continues the line from it, and Escape or a click off the art ends it. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/node.rs | 9 + crates/engine/src/cmd/freeform.rs | 9 +- crates/engine/src/cmd/gradient.rs | 2 +- crates/engine/src/tests_freeform.rs | 2 +- crates/mcp/src/lib.rs | 2 + crates/mcp/src/tests_freeform.rs | 58 ++++ crates/tools/src/xform/freeform.rs | 332 +++++++++++++++++--- crates/tools/src/xform/gradient.rs | 20 +- crates/ui-egui/src/dialogs/gradient_stop.rs | 175 ++++++++--- crates/ui-egui/src/panels/gradient.rs | 2 +- docs/control-protocol.md | 4 + docs/mcp.md | 12 + 12 files changed, 533 insertions(+), 94 deletions(-) create mode 100644 crates/mcp/src/tests_freeform.rs diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index e0863f98..39162c46 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -1075,6 +1075,15 @@ fn keeps_gradient_fit(a: Affine) -> bool { m0 > 0.0 && m1.abs() <= eps && m2.abs() <= eps && (m0 - m3).abs() <= eps } +impl Node { + /// Whether a document point lies in this object's filled regions ([`Node::clip_shapes`]; true + /// everywhere for objects without them, such as type, whose boxes stand in). + pub fn contains_fn(&self) -> impl Fn(Point) -> bool + use<> { + let shapes = self.clip_shapes(None); + move |p| shapes.is_empty() || shapes.iter().any(|(bp, rule)| vectorcraft_geom::hit::fill_contains(bp, *rule, p)) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/src/cmd/freeform.rs b/crates/engine/src/cmd/freeform.rs index 7c99e824..21fd5c92 100644 --- a/crates/engine/src/cmd/freeform.rs +++ b/crates/engine/src/cmd/freeform.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_color::freeform::spread_scale; use vectorcraft_color::{Freeform, FreeformMode, FreeformPoint, GradientKind, Paint}; -use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_doc::NodeKind; use vectorcraft_geom::{Affine, Rect}; use super::appearance::{edit_items, edits_stroke, index_param, item_target}; @@ -323,10 +323,3 @@ fn get(s: &mut Session, p: &Value) -> Result { v["selected"] = json!(selected); Ok(v) } - -/// Whether `n`'s shape contains `p` (its filled regions; true for objects without them, such as -/// type, whose boxes stand in). -pub(crate) fn inside_fn(n: &Node) -> impl Fn(vectorcraft_geom::Point) -> bool + use<> { - let shapes = n.clip_shapes(None); - move |p| shapes.is_empty() || shapes.iter().any(|(bp, rule)| vectorcraft_geom::hit::fill_contains(bp, *rule, p)) -} diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 2dd4f4db..6a95c02a 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -357,7 +357,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { } let b = item_paint_bounds(n, index, !stroke); // Only a gradient turning freeform needs the shape (its first points go inside it). - let inside = (freeform && b.is_some()).then(|| super::freeform::inside_fn(n)); + let inside = (freeform && b.is_some()).then(|| n.contains_fn()); let inside: &dyn Fn(Point) -> bool = match &inside { Some(f) => f, None => &|_| true, diff --git a/crates/engine/src/tests_freeform.rs b/crates/engine/src/tests_freeform.rs index cc01aada..71f5a682 100644 --- a/crates/engine/src/tests_freeform.rs +++ b/crates/engine/src/tests_freeform.rs @@ -61,7 +61,7 @@ fn the_first_application_places_points_inside_the_shape() { let g = fill_gradient(&s, id); assert_eq!(g.gradient.kind, GradientKind::Freeform); let f = g.freeform.as_ref().unwrap(); - let inside = cmd::freeform::inside_fn(node(&s, id)); + let inside = node(&s, id).contains_fn(); assert!(f.points.len() >= 4 && f.points.iter().all(|p| inside(p.at)), "{:?}", f.points); // The stops follow the points' colours (swatch chips and exports show them). assert_eq!(g.gradient.stops.iter().map(|st| st.color).collect::>(), f.points.iter().map(|p| p.color).collect::>()); diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index 5b827362..50c14d34 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -32,4 +32,6 @@ mod tests; #[cfg(test)] mod tests_fileio; #[cfg(test)] +mod tests_freeform; +#[cfg(test)] mod tests_gradient; diff --git a/crates/mcp/src/tests_freeform.rs b/crates/mcp/src/tests_freeform.rs new file mode 100644 index 00000000..d35e97e1 --- /dev/null +++ b/crates/mcp/src/tests_freeform.rs @@ -0,0 +1,58 @@ +//! The Gradient tool on a freeform gradient through MCP: `pointer_gesture` adds, moves and +//! removes points, drags the spread and draws lines; `press_key` Escape ends a line. + +use serde_json::{Value, json}; + +use crate::{Backend, Headless, call_tool}; + +fn ok(h: &mut Headless, name: &str, args: Value) -> Value { + let r = call_tool(h, name, &args); + assert!(!r.is_error, "{name} {args}: {r:?}"); + serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap_or(Value::Null) +} + +fn freeform(h: &mut Headless) -> Value { + ok(h, "run_command", json!({"command": "paint.freeform.get"})) +} + +fn click(h: &mut Headless, x: f64, y: f64) -> Value { + ok(h, "pointer_gesture", json!({"tool": "gradient", "events": [{"kind": "down", "x": x, "y": y}, {"kind": "up", "x": x, "y": y}]})) +} + +fn drag(h: &mut Headless, from: [f64; 2], to: [f64; 2]) { + let mid = [(from[0] + to[0]) / 2.0, (from[1] + to[1]) / 2.0]; + let ev = |k: &str, p: [f64; 2]| json!({"kind": k, "x": p[0], "y": p[1]}); + ok(h, "pointer_gesture", json!({"events": [ev("down", from), ev("drag", mid), ev("drag", to), ev("up", to)]})); +} + +#[test] +fn freeform_points_and_lines_on_the_canvas() { + let mut h = Headless::new(); + h.call("engine.execute", json!({"command": "file.new", "params": {"width": 300, "height": 300}})).unwrap(); + ok(&mut h, "draw_shape", json!({"shape": "rectangle", "x": 100, "y": 100, "width": 100, "height": 100})); + ok(&mut h, "run_command", json!({"command": "paint.editGradient", "params": {"kind": "freeform", "mode": "lines"}})); + let n = freeform(&mut h)["points"].as_array().unwrap().len(); + // Lines mode: three clicks draw one line through three new points. + for (x, y) in [(110.0, 190.0), (150.0, 170.0), (190.0, 190.0)] { + click(&mut h, x, y); + } + let f = freeform(&mut h); + assert_eq!(f["lines"], json!([[n, n + 1, n + 2]]), "{f}"); + // Escape ends it: the next click starts afresh. + let r = ok(&mut h, "press_key", json!({"key": "Escape"})); + assert_eq!(r["handledBy"], "tool", "{r}"); + click(&mut h, 150.0, 120.0); + assert_eq!(freeform(&mut h)["lines"].as_array().unwrap().len(), 1); + // Dragging the selected point's spread handle (16 px right of it at 100 %) spreads it. + drag(&mut h, [166.0, 120.0], [176.0, 120.0]); + let f = freeform(&mut h); + let spread = f["points"][n + 3]["spread"].as_f64().unwrap(); + assert!((spread - 0.2).abs() < 1e-6, "10 pt of half the 100 pt box: {f}"); + // A point dragged out of the rectangle goes. + drag(&mut h, [150.0, 120.0], [40.0, 40.0]); + assert_eq!(freeform(&mut h)["points"].as_array().unwrap().len(), n + 3); + // Double-clicking a point asks for its popover. + let dbl = ok(&mut h, "pointer_gesture", json!({"events": [{"kind": "doubleclick", "x": 150, "y": 170}]})); + assert_eq!(dbl["requests"][0]["dialog"], "gradientStop", "{dbl}"); + assert_eq!(freeform(&mut h)["selected"], json!(n + 1)); +} diff --git a/crates/tools/src/xform/freeform.rs b/crates/tools/src/xform/freeform.rs index 5ecff9d3..7d0da406 100644 --- a/crates/tools/src/xform/freeform.rs +++ b/crates/tools/src/xform/freeform.rs @@ -1,17 +1,22 @@ //! The Gradient tool on a freeform gradient. //! //! The annotator shows each point as a colour chip, the lines through them and, around the -//! selected point, its spread. Click a point to select it (`paint.freeform.selectPoint`; the -//! Gradient and Color panels edit the selected point) and drag it to move it. Click inside the -//! selected art to add a point there, coloured as the gradient is there; in Lines mode (the -//! Gradient panel's Draw toggle) the new point joins the selected one by a line. Click a line to add -//! a point on it. Delete or Backspace removes the selected point. +//! selected point, its spread: a dashed ring with a handle. Click a point to select it +//! (`paint.freeform.selectPoint`; the Gradient and Color panels edit the selected point), drag it +//! to move it, drag it out of the object to remove it, and double-click it for its popover. Drag +//! the ring or its handle to change the spread. Click a line to add a point on it. +//! +//! Points mode (the Gradient panel's Draw toggle): a click inside the selected art adds a point +//! there, coloured as the gradient is there. Lines mode: successive clicks draw a smooth line +//! through the points they add, until Escape (or a click off the art) ends it; clicking an +//! existing point first starts the line from it. Delete or Backspace removes the selected point. use serde_json::{Value, json}; use vectorcraft_color::freeform::LineHit; -use vectorcraft_color::{Freeform, FreeformMode}; +use vectorcraft_color::{Color, Freeform, FreeformMode, FreeformPoint}; +use vectorcraft_doc::NodeId; use vectorcraft_doc::hit::hit_test; -use vectorcraft_geom::{Point, Shape}; +use vectorcraft_geom::{Point, Shape, Vec2}; use super::paint_owner; use crate::{Action, Cursor, Overlay, ToolContext, ToolKey}; @@ -20,12 +25,19 @@ const LINE: [u8; 3] = [0x20, 0x20, 0x20]; /// Hit radius of a point's chip and of a line, screen pixels. const POINT_HIT: f64 = 7.0; const LINE_HIT: f64 = 4.0; +/// Hit distance of the spread ring and its handle, screen pixels. +const RING_HIT: f64 = 4.0; +/// The spread handle sits at least this far from its point (so a spread of 0 can be dragged +/// out), screen pixels. +const RING_MIN: f64 = 16.0; /// A press becomes a drag after this much movement, screen pixels. const DRAG_START: f64 = 3.0; /// The freeform gradient behind the active proxy of the first selected object that has one: its /// points in document coordinates and the length spreads are fractions of there. pub(super) struct Annotator { + /// The object it paints. + pub id: NodeId, pub freeform: Freeform, pub scale: f64, } @@ -34,7 +46,7 @@ impl Annotator { pub fn of(cx: &ToolContext) -> Option { cx.selection.objects.iter().find_map(|id| { let (freeform, scale) = cx.doc.node(*id)?.proxy_freeform(!cx.fill_active, cx.appearance_item)?; - Some(Self { freeform, scale }) + Some(Self { id: *id, freeform, scale }) }) } @@ -53,17 +65,42 @@ impl Annotator { cx.freeform_point.filter(|i| *i < self.freeform.points.len()) } - pub fn overlays(&self, cx: &ToolContext) -> Vec { + /// Point `i`'s spread radius (document units) and where its spread handle sits: on the ring, + /// to the right of the point (at least [`RING_MIN`] pixels out). + fn ring(&self, cx: &ToolContext, i: usize) -> (f64, Point) { + let pt = &self.freeform.points[i]; + let r = pt.spread as f64 * self.scale; + (r, pt.at + Vec2::new(r.max(cx.tol(RING_MIN)), 0.0)) + } + + /// The selected point when `p` is on its spread ring or handle. + fn ring_at(&self, cx: &ToolContext, p: Point) -> Option { + let i = self.selected(cx)?; + let (r, handle) = self.ring(cx, i); + let on_ring = r > 0.0 && (self.freeform.points[i].at.distance(p) - r).abs() <= cx.tol(RING_HIT); + (on_ring || handle.distance(p) <= cx.tol(RING_HIT)).then_some(i) + } + + /// Is a line being drawn from the selected point (Lines mode)? + fn line_open(&self, cx: &ToolContext, lines: &Lines) -> Option { + self.selected(cx).filter(|_| lines.open && self.freeform.mode == FreeformMode::Lines) + } + + pub fn overlays(&self, cx: &ToolContext, lines: &Lines) -> Vec { let f = &self.freeform; let mut o: Vec = (0..f.lines.len()).map(|l| Overlay::Path { path: f.line_path(l), color: LINE, width: 1.0, dashed: false }).collect(); + // The line being drawn, on to the pointer. + if let (Some(from), Some(hover)) = (self.line_open(cx, lines), lines.hover) { + o.push(Overlay::Path { path: rubber_band(f, from, hover), color: LINE, width: 1.0, dashed: true }); + } if let Some(i) = self.selected(cx) { - let pt = &f.points[i]; - let r = pt.spread as f64 * self.scale; + let (r, handle) = self.ring(cx, i); if r > 0.0 { - let path = vectorcraft_geom::kurbo::Circle::new(pt.at, r).to_path(cx.tol(0.25)); + let path = vectorcraft_geom::kurbo::Circle::new(f.points[i].at, r).to_path(cx.tol(0.25)); o.push(Overlay::Path { path, color: LINE, width: 1.0, dashed: true }); } + o.push(Overlay::Handle { p: handle, color: LINE }); } o.extend(f.points.iter().enumerate().map(|(i, pt)| Overlay::Swatch { p: pt.at, @@ -74,11 +111,35 @@ impl Annotator { } } +/// The segment a click at `to` would add to the line drawn from point `from`. +fn rubber_band(f: &Freeform, from: usize, to: Point) -> vectorcraft_geom::BezPath { + let mut f = f.clone(); + let i = f.add_point(FreeformPoint::new(to, Color::BLACK)); + let mut bp = vectorcraft_geom::BezPath::new(); + if let Some(c) = f.connect(from, i).and_then(|l| f.segments(l).into_iter().find(|c| c.p0 == to || c.p3 == to)) { + bp.move_to(c.p0); + bp.curve_to(c.p1, c.p2, c.p3); + } + bp +} + +/// The Gradient tool's freeform state between gestures: the line Lines mode is drawing. +#[derive(Debug, Default)] +pub(super) struct Lines { + /// A line is being drawn from the selected point: the next click on the art extends it. + open: bool, + /// Where the pointer hovers over the art (the line's rubber band runs there). + hover: Option, +} + /// What a press on a freeform annotator does. #[derive(Clone, Debug, PartialEq)] -pub(super) enum Grab { - /// A point: a drag moves it. +enum Grab { + /// A point: a drag moves it, out of the object removes it. Point(usize), + /// The selected point's spread ring: a drag resizes it from radius `r` (the press `d` from + /// the point at `at`). + Spread { index: usize, at: Point, r: f64, d: f64, scale: f64 }, /// A line: a click adds a point there. Line(LineHit), /// Inside selected art: a click adds a point there. @@ -90,8 +151,14 @@ pub(super) enum Grab { #[derive(Clone, Debug, PartialEq)] pub(super) struct Gesture { grab: Grab, + /// The object whose gradient it edits and its point count at the press. + id: NodeId, + points: usize, at: Point, began: bool, + /// A dragged point is out of the object (releasing removes it); `left`: it has been. + out: bool, + left: bool, } impl Gesture { @@ -111,6 +178,11 @@ fn select(i: Option) -> Action { Action::Exec("paint.freeform.selectPoint".into(), json!({ "index": i })) } +/// Is `p` on selected art (where a click adds a point)? +fn on_art(cx: &ToolContext, p: Point) -> bool { + hit_test(cx.doc, p, cx.hit_options()).is_some_and(|h| cx.selection.objects.contains(&paint_owner(cx.doc, h.leaf))) +} + pub(super) fn press(cx: &ToolContext, a: &Annotator, p: Point) -> (Vec, Gesture) { let mut out = vec![]; let grab = if let Some(i) = a.point_at(cx, p) { @@ -118,18 +190,23 @@ pub(super) fn press(cx: &ToolContext, a: &Annotator, p: Point) -> (Vec, out.push(select(Some(i))); } Grab::Point(i) + } else if let Some(index) = a.ring_at(cx, p) { + let at = a.freeform.points[index].at; + Grab::Spread { index, at, r: a.ring(cx, index).0, d: at.distance(p), scale: a.scale } } else if let Some(h) = a.line_at(cx, p) { Grab::Line(h) - } else if hit_test(cx.doc, p, cx.hit_options()).is_some_and(|h| cx.selection.objects.contains(&paint_owner(cx.doc, h.leaf))) { + } else if on_art(cx, p) { Grab::Add } else { Grab::Clear }; - (out, Gesture { grab, at: p, began: false }) + (out, Gesture { grab, id: a.id, points: a.freeform.points.len(), at: p, began: false, out: false, left: false }) } pub(super) fn drag(cx: &ToolContext, g: &mut Gesture, p: Point) -> Vec { - let Grab::Point(index) = g.grab else { return vec![] }; + if !matches!(g.grab, Grab::Point(_) | Grab::Spread { .. }) { + return vec![]; + } let mut out = vec![]; if !g.began { if p.distance(g.at) < cx.tol(DRAG_START) { @@ -138,50 +215,124 @@ pub(super) fn drag(cx: &ToolContext, g: &mut Gesture, p: Point) -> Vec { g.began = true; out.push(Action::Begin("Gradient".into())); } - out.push(Action::Preview("paint.freeform.setPoint".into(), on_proxy(cx, json!({ "index": index, "at": [p.x, p.y] })))); + let (cmd, params) = match g.grab { + Grab::Point(index) => { + // Out of the object the point goes (never the last one). + g.out = g.points > 1 && cx.doc.node(g.id).is_some_and(|n| !n.contains_fn()(p)); + g.left |= g.out; + if g.out { + ("paint.freeform.deletePoint", json!({ "index": index })) + } else { + ("paint.freeform.setPoint", json!({ "index": index, "at": [p.x, p.y] })) + } + } + Grab::Spread { index, at, r, d, scale } => { + let spread = if scale > 0.0 { ((r + at.distance(p) - d) / scale).clamp(0.0, 1.0) } else { 0.0 }; + ("paint.freeform.setPoint", json!({ "index": index, "spread": spread })) + } + _ => unreachable!("checked above"), + }; + out.push(Action::Preview(cmd.into(), on_proxy(cx, params))); out } -pub(super) fn release(cx: &ToolContext, a: Option<&Annotator>, g: Gesture) -> Vec { +pub(super) fn release(cx: &ToolContext, a: Option<&Annotator>, g: Gesture, lines: &mut Lines) -> Vec { + let mode = a.map_or(FreeformMode::Points, |a| a.freeform.mode); if g.began { - return vec![Action::Commit]; + let mut out = vec![Action::Commit]; + // A point dragged out and back was deselected by the removal its drag previewed. + if let Grab::Point(i) = g.grab + && g.left + && !g.out + { + out.push(select(Some(i))); + } + lines.open &= !g.out; + return out; } match g.grab { - Grab::Point(_) => vec![], + // In Lines mode a line can start from an existing point. + Grab::Point(_) => { + lines.open = mode == FreeformMode::Lines; + vec![] + } + Grab::Spread { .. } => vec![], Grab::Line(h) => { + lines.open = false; vec![Action::Exec("paint.freeform.splitLine".into(), on_proxy(cx, json!({ "line": h.line, "segment": h.segment, "t": h.t })))] } Grab::Add => { - let lines = a.is_some_and(|a| a.freeform.mode == FreeformMode::Lines); - vec![Action::Exec("paint.freeform.addPoint".into(), on_proxy(cx, json!({ "at": [g.at.x, g.at.y], "line": lines })))] + let join = a.is_some_and(|a| a.line_open(cx, lines).is_some()); + lines.open = mode == FreeformMode::Lines; + vec![Action::Exec("paint.freeform.addPoint".into(), on_proxy(cx, json!({ "at": [g.at.x, g.at.y], "line": join })))] + } + Grab::Clear => { + lines.open = false; + if cx.freeform_point.is_some() { vec![select(None)] } else { vec![] } } - Grab::Clear if cx.freeform_point.is_some() => vec![select(None)], - Grab::Clear => vec![], } } -/// Delete / Backspace remove the selected point (never the last one). -pub(super) fn claims_key(cx: &ToolContext, a: &Annotator, key: ToolKey) -> bool { - matches!(key, ToolKey::Delete | ToolKey::Backspace) && a.selected(cx).is_some() && a.freeform.points.len() > 1 +/// Escape during a drag: the point a drag-out deselected is selected again. +pub(super) fn cancel(g: &Gesture) -> Vec { + match g.grab { + Grab::Point(i) if g.out => vec![Action::Cancel, select(Some(i))], + _ => vec![Action::Cancel], + } } -pub(super) fn key(cx: &ToolContext, a: &Annotator, key: ToolKey) -> Vec { - if !claims_key(cx, a, key) { +/// Double-clicking a point opens its popover beside it. +pub(super) fn double_click(cx: &ToolContext, a: &Annotator, p: Point) -> Vec { + let Some(i) = a.point_at(cx, p) else { return vec![] }; + let at = a.freeform.points[i].at; + let mut out = if cx.freeform_point != Some(i) { vec![select(Some(i))] } else { vec![] }; + out.push(Action::Dialog("gradientStop".into(), json!({ "index": i, "x": at.x, "y": at.y }))); + out +} + +/// Pointer moves: Lines mode's rubber band follows the pointer over the art (where a click +/// extends the line). +pub(super) fn hover(cx: &ToolContext, lines: &mut Lines, p: Point) { + lines.hover = (lines.open && on_art(cx, p)).then_some(p); +} + +/// Delete / Backspace remove the selected point (never the last one); Escape ends the line being +/// drawn. +pub(super) fn claims_key(cx: &ToolContext, a: &Annotator, lines: &Lines, key: ToolKey) -> bool { + match key { + ToolKey::Delete | ToolKey::Backspace => a.selected(cx).is_some() && a.freeform.points.len() > 1, + ToolKey::Escape => a.line_open(cx, lines).is_some(), + _ => false, + } +} + +pub(super) fn key(cx: &ToolContext, a: &Annotator, lines: &mut Lines, key: ToolKey) -> Vec { + if !claims_key(cx, a, lines, key) { + return vec![]; + } + if key == ToolKey::Escape { + lines.open = false; return vec![]; } vec![Action::Exec("paint.freeform.deletePoint".into(), on_proxy(cx, json!({ "index": a.selected(cx) })))] } pub(super) fn cursor(cx: &ToolContext, a: &Annotator, g: Option<&Gesture>, p: Point) -> Cursor { - if g.is_some_and(|g| g.began) || a.point_at(cx, p).is_some() { - return Cursor::Move; + match g.filter(|g| g.began).map(|g| (&g.grab, g.out)) { + Some((Grab::Point(_), true)) => return Cursor::RemoveStop, + Some((Grab::Spread { .. }, _)) => return Cursor::ResizeH, + Some(_) => return Cursor::Move, + None => {} } - if a.line_at(cx, p).is_some() - || hit_test(cx.doc, p, cx.hit_options()).is_some_and(|h| cx.selection.objects.contains(&paint_owner(cx.doc, h.leaf))) - { - return Cursor::AddStop; + if a.point_at(cx, p).is_some() { + Cursor::Move + } else if a.ring_at(cx, p).is_some() { + Cursor::ResizeH + } else if a.line_at(cx, p).is_some() || on_art(cx, p) { + Cursor::AddStop + } else { + Cursor::Crosshair } - Cursor::Crosshair } #[cfg(test)] @@ -246,6 +397,8 @@ mod tests { let mut cx = cx(&d, &s, &p); cx.freeform_point = Some(1); let mut t = GradientTool::default(); + // Clicking the selected point starts a line from it; a click on the art extends it. + assert!(click(&mut t, &cx, 180.0, 120.0).is_empty()); let a = click(&mut t, &cx, 190.0, 190.0); assert_eq!(a, vec![Action::Exec("paint.freeform.addPoint".into(), json!({"at": [190.0, 190.0], "line": true, "stroke": false}))]); t.pointer(&cx, &ev(PointerKind::Down, 120.0, 121.0)); @@ -261,4 +414,109 @@ mod tests { vec![Action::Exec("paint.freeform.deletePoint".into(), json!({"index": 1, "stroke": false}))] ); } + + #[test] + fn successive_clicks_draw_a_line_until_escape() { + let (d, s) = doc(FreeformMode::Lines); + let p = paint(); + let mut cx = cx(&d, &s, &p); + let mut t = GradientTool::default(); + let add = |x: f64, y: f64, line: bool| Action::Exec("paint.freeform.addPoint".into(), json!({"at": [x, y], "line": line, "stroke": false})); + // The first click starts a line; the engine selects each new point, which the next joins. + assert_eq!(click(&mut t, &cx, 140.0, 150.0), vec![add(140.0, 150.0, false)]); + // (The engine selects the new point; any existing one stands in for it here.) + cx.freeform_point = Some(2); + assert!(t.claims_key(&cx, ToolKey::Escape)); + // The rubber band follows the pointer from the line's end. + t.pointer(&cx, &ev(PointerKind::Move, 170.0, 160.0)); + let dashed = |t: &GradientTool, cx: &ToolContext| t.overlays(cx).iter().filter(|o| matches!(o, Overlay::Path { dashed: true, .. })).count(); + assert_eq!(dashed(&t, &cx), 2, "the rubber band and the selected point's spread ring"); + // Off the art (where a click ends the line) it goes. + t.pointer(&cx, &ev(PointerKind::Move, 400.0, 400.0)); + assert_eq!(dashed(&t, &cx), 1); + assert_eq!(click(&mut t, &cx, 170.0, 160.0), vec![add(170.0, 160.0, true)]); + // Escape ends the line: the next click starts another. + assert!(t.key(&cx, ToolKey::Escape, Default::default()).is_empty()); + assert!(!t.claims_key(&cx, ToolKey::Escape)); + assert_eq!(dashed(&t, &cx), 1); + assert_eq!(click(&mut t, &cx, 130.0, 170.0), vec![add(130.0, 170.0, false)]); + // Points mode never joins. + let (d, s) = doc(FreeformMode::Points); + let mut cx = crate::testutil::cx(&d, &s, &p); + cx.freeform_point = Some(0); + let mut t = GradientTool::default(); + click(&mut t, &cx, 120.0, 120.0); + assert_eq!(click(&mut t, &cx, 140.0, 150.0), vec![add(140.0, 150.0, false)]); + assert!(!t.claims_key(&cx, ToolKey::Escape)); + } + + #[test] + fn a_point_dragged_out_of_the_object_is_removed() { + let (d, s) = doc(FreeformMode::Points); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.freeform_point = Some(0); + let mut t = GradientTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 120.0, 120.0)); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 60.0, 60.0)); + assert_eq!(a[1], Action::Preview("paint.freeform.deletePoint".into(), json!({"index": 0, "stroke": false}))); + assert_eq!(t.cursor(&cx, Point::new(60.0, 60.0), Default::default()), Cursor::RemoveStop); + // Back inside it moves again. + let a = t.pointer(&cx, &ev(PointerKind::Drag, 110.0, 110.0)); + assert_eq!(a[0], Action::Preview("paint.freeform.setPoint".into(), json!({"index": 0, "at": [110.0, 110.0], "stroke": false}))); + // Released there it is selected again (the removal previewed on the way deselected it). + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 110.0, 110.0)), vec![Action::Commit, select(Some(0))]); + t.pointer(&cx, &ev(PointerKind::Down, 120.0, 120.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 60.0, 60.0)); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 60.0, 60.0)), vec![Action::Commit]); + // Escape mid-drag restores the point and its selection. + t.pointer(&cx, &ev(PointerKind::Down, 120.0, 120.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 60.0, 60.0)); + assert_eq!(t.key(&cx, ToolKey::Escape, Default::default()), vec![Action::Cancel, select(Some(0))]); + } + + #[test] + fn the_spread_ring_drags_the_spread() { + let (d, s) = doc(FreeformMode::Points); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.freeform_point = Some(2); + let mut t = GradientTool::default(); + // Spread 0.2 of half the 100 pt box: a 10 pt ring around (150, 180), its handle on it. + assert_eq!(t.cursor(&cx, Point::new(150.0, 170.0), Default::default()), Cursor::ResizeH); + assert!(t.overlays(&cx).contains(&Overlay::Handle { p: Point::new(166.0, 180.0), color: LINE }), "at least 16 px out"); + assert!(t.pointer(&cx, &ev(PointerKind::Down, 160.0, 180.0)).is_empty()); + let spread = |a: &[Action]| match a.last() { + Some(Action::Preview(c, v)) if c == "paint.freeform.setPoint" && v["index"] == 2 => (v["spread"].as_f64().unwrap() * 1e4).round() / 1e4, + a => panic!("{a:?}"), + }; + assert_eq!(spread(&t.pointer(&cx, &ev(PointerKind::Drag, 170.0, 180.0))), 0.4); + // Dragged in, it shrinks with the pointer's distance. + assert_eq!(spread(&t.pointer(&cx, &ev(PointerKind::Drag, 145.0, 180.0))), 0.1); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 145.0, 180.0)), vec![Action::Commit]); + // Grabbed by the handle (16 px out), it reaches no spread before the pointer reaches the point. + t.pointer(&cx, &ev(PointerKind::Down, 166.0, 180.0)); + assert_eq!(spread(&t.pointer(&cx, &ev(PointerKind::Drag, 152.0, 180.0))), 0.0); + t.pointer(&cx, &ev(PointerKind::Up, 152.0, 180.0)); + // The handle of a point without spread drags one out. + let mut d0 = d.clone(); + let n = d0.node_mut(s.objects[0]).unwrap(); + let Paint::Gradient(mut g) = n.appearance.fill_paint() else { unreachable!() }; + g.freeform.as_mut().unwrap().points[2].spread = 0.0; + n.appearance.set_fill(Paint::Gradient(g)); + let mut cx = crate::testutil::cx(&d0, &s, &p); + cx.freeform_point = Some(2); + t.pointer(&cx, &ev(PointerKind::Down, 166.0, 180.0)); + assert_eq!(spread(&t.pointer(&cx, &ev(PointerKind::Drag, 176.0, 180.0))), 0.2); + } + + #[test] + fn double_clicking_a_point_opens_its_popover() { + let (d, s) = doc(FreeformMode::Points); + let p = paint(); + let cx = cx(&d, &s, &p); + let a = GradientTool::default().pointer(&cx, &ev(PointerKind::DoubleClick, 181.0, 119.0)); + assert_eq!(a, vec![select(Some(1)), Action::Dialog("gradientStop".into(), json!({"index": 1, "x": 180.0, "y": 120.0}))]); + assert!(GradientTool::default().pointer(&cx, &ev(PointerKind::DoubleClick, 140.0, 150.0)).is_empty()); + } } diff --git a/crates/tools/src/xform/gradient.rs b/crates/tools/src/xform/gradient.rs index b1996922..1ff94247 100644 --- a/crates/tools/src/xform/gradient.rs +++ b/crates/tools/src/xform/gradient.rs @@ -188,6 +188,8 @@ pub struct GradientTool { gesture: Option, /// A press on a freeform gradient's annotator. free: Option, + /// The line Lines mode is drawing on a freeform gradient. + lines: freeform::Lines, /// Smart-guide feedback of the handle being dragged. guides: Vec, } @@ -402,9 +404,14 @@ impl Tool for GradientTool { (PointerKind::Drag, Some(g)) => return freeform::drag(cx, g, ev.pos), (PointerKind::Up, Some(_)) => { let g = self.free.take().expect("checked"); - return freeform::release(cx, FreeformAnnotator::of(cx).as_ref(), g); + return freeform::release(cx, FreeformAnnotator::of(cx).as_ref(), g, &mut self.lines); + } + (PointerKind::Move, None) => freeform::hover(cx, &mut self.lines, ev.pos), + (PointerKind::DoubleClick, _) => { + if let Some(a) = FreeformAnnotator::of(cx) { + return freeform::double_click(cx, &a, ev.pos); + } } - (PointerKind::DoubleClick, free) if free.is_some() || FreeformAnnotator::of(cx).is_some() => return vec![], _ => {} } match ev.kind { @@ -423,7 +430,7 @@ impl Tool for GradientTool { fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool { if let Some(a) = FreeformAnnotator::of(cx) { - return freeform::claims_key(cx, &a, key); + return freeform::claims_key(cx, &a, &self.lines, key); } matches!(key, ToolKey::Delete | ToolKey::Backspace | ToolKey::Left | ToolKey::Right) && cx.gradient_stop.is_some_and(|i| Annotator::of(cx).is_some_and(|a| i < a.gradient.stops.len())) @@ -432,15 +439,14 @@ 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.free = None; self.guides.clear(); - return vec![Action::Cancel]; + return self.free.take().map_or_else(|| vec![Action::Cancel], |g| freeform::cancel(&g)); } if self.busy() || !self.claims_key(cx, key) { return vec![]; } if let Some(a) = FreeformAnnotator::of(cx) { - return freeform::key(cx, &a, key); + return freeform::key(cx, &a, &mut self.lines, key); } let (Some(a), Some(i)) = (Annotator::of(cx), cx.gradient_stop) else { return vec![] }; let stops = &a.gradient.stops; @@ -463,7 +469,7 @@ impl Tool for GradientTool { fn overlays(&self, cx: &ToolContext) -> Vec { if let Some(a) = FreeformAnnotator::of(cx) { - return a.overlays(cx); + return a.overlays(cx, &self.lines); } let mut o = match (Annotator::of(cx), self.gesture.as_ref().and_then(|g| g.vector)) { (Some(a), _) => annotator_overlays(cx, &a), diff --git a/crates/ui-egui/src/dialogs/gradient_stop.rs b/crates/ui-egui/src/dialogs/gradient_stop.rs index a4088f23..319e7a57 100644 --- a/crates/ui-egui/src/dialogs/gradient_stop.rs +++ b/crates/ui-egui/src/dialogs/gradient_stop.rs @@ -2,34 +2,64 @@ //! Gradient panel's slider) edits it next to its chip — its colour (the Color panel's controls, //! or a document swatch, which stays linked when it is a global or spot colour), opacity and //! location. It edits the selected stop (`gradient.selectStop`) through `paint.editGradient`, and -//! closes on Escape or a click elsewhere. +//! closes on Escape or a click elsewhere. On a freeform gradient it edits the selected point +//! (`paint.freeform.selectPoint`) instead: its colour, opacity and spread, through +//! `paint.freeform.setPoint`. //! //! 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. +//! Agents can set `color` (hex), `opacity` and `location` (a point: `spread`) as percentages, +//! and confirm. use egui::{Sense, vec2}; use serde_json::{Value, json}; use vectorcraft_color::gradient::move_stop; -use vectorcraft_color::{Color, GradientStop, Paint}; +use vectorcraft_color::{Color, FreeformPoint, GradientKind, GradientStop, Paint}; use vectorcraft_geom::Point; +use vectorcraft_tools::params::color_json; use super::{DialogResult, DialogSpec}; use crate::VectorcraftApp; use crate::canvas::Xf; use crate::panels::active_paint; -use crate::panels::gradient::set_stops; +use crate::panels::gradient::{edit_point, set_stops, shown_points}; use crate::state::Dialog; use crate::theme::Tokens; use crate::widgets::{self, Live}; pub(super) const SPEC: DialogSpec = DialogSpec::window(show, confirm); -/// The stops of the gradient behind the active proxy and the selected stop's index. -fn selected(app: &VectorcraftApp) -> Option<(Vec, usize)> { +/// What the popover edits: the selected stop of the gradient behind the active proxy (with all +/// its stops), or the selected point of a freeform gradient. +enum Target { + Stop(Vec, usize), + Point(FreeformPoint, usize), +} + +impl Target { + fn color(&self) -> Color { + match self { + Target::Stop(stops, i) => stops[*i].color, + Target::Point(p, _) => p.color, + } + } + fn opacity(&self) -> f32 { + match self { + Target::Stop(stops, i) => stops[*i].opacity, + Target::Point(p, _) => p.opacity, + } + } +} + +fn selected(app: &VectorcraftApp) -> Option { let Paint::Gradient(g) = active_paint(app) else { return None }; + if g.gradient.kind == GradientKind::Freeform { + let f = shown_points(&g); + let i = app.session.selected_freeform_point().filter(|i| *i < f.points.len())?; + return Some(Target::Point(f.points[i], i)); + } let i = app.session.selected_stop().filter(|i| *i < g.gradient.stops.len())?; - Some((g.gradient.stops, i)) + Some(Target::Stop(g.gradient.stops, i)) } /// `stops` with stop `i` given `color`, linked to `link` (a global swatch and tint) or unlinked. @@ -47,8 +77,8 @@ fn swatch_colors(app: &VectorcraftApp) -> Vec<(String, Color)> { fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let Some(d) = app.ui.dialog.clone() else { return }; - // The stop went away (undo, another object selected): nothing left to edit. - let Some((stops, i)) = selected(app) else { + // The stop or point went away (undo, another object selected): nothing left to edit. + let Some(target) = selected(app) else { app.ui.dialog = None; return; }; @@ -78,24 +108,24 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { }); ui.add_space(6.0); if swatches { - swatch_grid(app, ui, &stops, i); + swatch_grid(app, ui, &target); } else { - // The Color panel edits the selected stop of the active gradient. + // The Color panel edits the selected stop (or point) of the active gradient. crate::panels::color::show(app, ui); } ui.add_space(6.0); ui.horizontal(|ui| { - let s = &stops[i]; widgets::dim_label(ui, "Opacity:"); - 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; - set_stops(app, &v2, Some(i), Live::Released); + if let Some(v) = widgets::plain_field(ui, "stop-pop-op", target.opacity() as f64 * 100.0, "%", 0, 54.0) { + apply(app, &target, &json!({ "opacity": v })); } - 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); - set_stops(app, &v2, Some(ni), Live::Released); + let (label, key, value) = match &target { + Target::Stop(stops, i) => ("Location:", "location", stops[*i].offset), + Target::Point(p, _) => ("Spread:", "spread", p.spread), + }; + widgets::dim_label(ui, label); + if let Some(v) = widgets::plain_field(ui, ("stop-pop", key), value as f64 * 100.0, "%", 1, 54.0) { + apply(app, &target, &json!({ key: v })); } }); }); @@ -111,45 +141,80 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { } } -/// Document swatches as tiles: a click gives the stop that colour. -fn swatch_grid(app: &mut VectorcraftApp, ui: &mut egui::Ui, stops: &[GradientStop], i: usize) { +/// Document swatches as tiles: a click gives the stop (or point) that colour. +fn swatch_grid(app: &mut VectorcraftApp, ui: &mut egui::Ui, target: &Target) { let colors = swatch_colors(app); if colors.is_empty() { widgets::dim_label(ui, "The document has no colour swatches."); return; } + let current = target.color(); let mut pick = None; ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing = vec2(2.0, 2.0); for (name, c) in &colors { let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click()); - widgets::swatch_tile(ui, r, &Paint::solid(*c), stops[i].color == *c, resp.hovered()); + widgets::swatch_tile(ui, r, &Paint::solid(*c), current == *c, resp.hovered()); if resp.on_hover_text(name).clicked() { pick = Some((name.clone(), *c)); } } }); - // A global or spot colour (or a tint of one) stays linked. - if let Some((name, c)) = pick { - let link = crate::panels::gradient::dropped_link(app, &json!({ "swatch": name })); - set_stops(app, &recolor(stops.to_vec(), i, c, link), Some(i), Live::Released); + let Some((name, c)) = pick else { return }; + match target { + // A global or spot colour (or a tint of one) stays linked. + Target::Stop(stops, i) => { + let link = crate::panels::gradient::dropped_link(app, &json!({ "swatch": name })); + set_stops(app, &recolor(stops.clone(), *i, c, link), Some(*i), Live::Released); + } + Target::Point(_, i) => edit_point(app, "paint.freeform.setPoint", json!({ "index": i, "color": color_json(&c) })), } } -/// OK (agents): apply the `color`, `opacity` and `location` fields that are set, then close. +/// Apply the `color` (hex), `opacity` and `location` or `spread` (percentages) set in `f` to the +/// target as one edit; returns the stop's (or point's) index after it. +fn apply(app: &mut VectorcraftApp, target: &Target, f: &Value) -> usize { + let pct = |k: &str| f.get(k).and_then(Value::as_f64).map(|v| (v / 100.0).clamp(0.0, 1.0)); + let color = f.get("color").and_then(Value::as_str).and_then(crate::panels::color::parse_hex); + match target { + Target::Stop(stops, i) => { + let (mut stops, mut i) = (stops.clone(), *i); + if let Some(c) = color { + stops = recolor(stops, i, c, None); + } + if let Some(o) = pct("opacity") { + stops[i].opacity = o as f32; + } + if let Some(l) = f.get("location").and_then(Value::as_f64) { + (stops, i) = move_stop(&stops, i, (l / 100.0) as f32); + } + set_stops(app, &stops, Some(i), Live::Released); + i + } + Target::Point(_, i) => { + let mut p = json!({ "index": i }); + if let Some(c) = color { + p["color"] = color_json(&c); + } + for k in ["opacity", "spread"] { + if let Some(v) = pct(k) { + p[k] = json!(v); + } + } + edit_point(app, "paint.freeform.setPoint", p); + *i + } + } +} + +/// OK (agents): apply the `color`, `opacity` and `location` (a point: `spread`) fields that are +/// set, then close. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult { - let (mut stops, mut i) = selected(app).ok_or("no gradient stop is selected")?; + let target = selected(app).ok_or("no gradient stop or freeform point is selected")?; if let Some(hex) = d.fields.get("color").and_then(Value::as_str) { - let c = crate::panels::color::parse_hex(hex).ok_or_else(|| format!("bad colour `{hex}` (#rrggbb)"))?; - stops = recolor(stops, i, c, None); + crate::panels::color::parse_hex(hex).ok_or_else(|| format!("bad colour `{hex}` (#rrggbb)"))?; } - if d.fields.contains_key("opacity") { - stops[i].opacity = (d.f64("opacity", 100.0) / 100.0).clamp(0.0, 1.0) as f32; - } - if d.fields.contains_key("location") { - (stops, i) = move_stop(&stops, i, (d.f64("location", 0.0) / 100.0) as f32); - } - set_stops(app, &stops, Some(i), Live::Released); + let i = apply(app, &target, &Value::Object(d.fields.clone())); app.ui.dialog = None; Ok(json!({ "index": i })) } @@ -175,7 +240,10 @@ mod tests { } fn stops(app: &VectorcraftApp) -> Vec { - selected(app).unwrap().0 + match selected(app) { + Some(Target::Stop(stops, _)) => stops, + _ => panic!("no stop selected"), + } } /// One headless frame of the dialog layer and the shortcuts. @@ -266,4 +334,33 @@ mod tests { frame(&mut app, vec![key(egui::Key::ArrowLeft)]); assert!((stops(&app)[1].offset - (before - 0.01)).abs() < 1e-6); } + + #[test] + fn on_a_freeform_gradient_it_edits_the_selected_point() { + let mut app = app(); + app.run("paint.editGradient", json!({"kind": "freeform"})).unwrap(); + app.run("paint.freeform.addPoint", json!({"at": [150, 150], "color": "#0000ff"})).unwrap(); + let point = |app: &VectorcraftApp| match selected(app) { + Some(Target::Point(p, i)) => (p, i), + _ => panic!("no point selected"), + }; + let (p, i) = point(&app); + // Double-clicking the point opens the popover beside it. + let view = app.view_info(); + crate::canvas::dispatch(&mut app, &PointerEvent { kind: PointerKind::DoubleClick, pos: p.at, mods: Default::default(), pressure: 1.0 }, view); + let d = app.ui.dialog.clone().expect("the popover opened"); + assert_eq!((d.kind.as_str(), d.fields["index"].clone()), ("gradientStop", json!(i))); + frame(&mut app, vec![]); + app.ui.dialog.as_mut().unwrap().fields.insert("tab".into(), json!("swatches")); + frame(&mut app, vec![]); + assert!(app.ui.dialog.is_some(), "both tabs draw"); + // Agents set its colour, opacity and spread. + for (k, v) in [("color", json!("#ff0000")), ("opacity", json!(40)), ("spread", json!(25))] { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + } + assert_eq!(super::super::confirm(&mut app).unwrap(), json!({"index": i})); + let (p, _) = point(&app); + assert_eq!((p.color.to_hex(), p.opacity, p.spread), ("#ff0000".to_string(), 0.4, 0.25)); + assert!(app.ui.dialog.is_none()); + } } diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index 0e2da2d1..51e85521 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -262,7 +262,7 @@ pub(crate) fn shown_points(g: &GradientPaint) -> std::borrow::Cow<'_, Freeform> } /// Run a `paint.freeform.*` command on the paint behind the active proxy. -fn edit_point(app: &mut VectorcraftApp, cmd: &str, mut params: Value) { +pub(crate) fn edit_point(app: &mut VectorcraftApp, cmd: &str, mut params: Value) { params["stroke"] = json!(!app.session.fill_active); live_run(app, "Gradient", cmd, params, Live::Released); } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b96f1ee0..ded1beaa 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -52,6 +52,10 @@ swatch, a Fill/Stroke proxy or the panel's gradient thumbnail onto art runs `pai 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. +Freeform gradients: double-clicking a point with the Gradient tool opens the same `gradientStop` popover on the selected +point (`paint.freeform.selectPoint`); its fields are `color` (hex), `opacity` and `spread` (percentages), applied by +`ui.dialog.confirm` through `paint.freeform.setPoint`. `ui.key` Escape ends the line Lines mode is drawing. + Tool options: double-clicking a tool button runs `tool.options {tool}`. For `gradient` it opens the Gradient panel; for `eyedropper` it opens Eyedropper Options, an `eyedropperOptions` dialog (fields `sampleSize` 1/3/5, `pickUp` and `apply`, the attribute trees of `eyedropper.setOptions`) whose `ui.dialog.confirm` runs `eyedropper.setOptions` (what diff --git a/docs/mcp.md b/docs/mcp.md index 5ef9ae9c..585f228a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -170,6 +170,18 @@ selected point: {"name":"pointer_gesture","arguments":{"tool":"gradient","events":[{"kind":"down","x":180,"y":170},{"kind":"up","x":180,"y":170}]}} ``` +On the canvas the selected point also shows its spread as a dashed ring with a handle 16 px (or the spread, if larger) +to its right: drag the ring or handle to change the spread. Dragging a point out of the object removes it, and +double-clicking one opens its popover (dialog `gradientStop`, which on a freeform gradient edits the selected point: +set `color`, `opacity` and `spread` and confirm). In Lines mode successive clicks on the art draw one smooth line +through the points they add; a click on an existing point first continues the line from it, and `press_key` Escape +(or a click off the art) ends it: + +```json +{"name":"pointer_gesture","arguments":{"tool":"gradient","events":[{"kind":"down","x":110,"y":190},{"kind":"up","x":110,"y":190},{"kind":"down","x":150,"y":170},{"kind":"up","x":150,"y":170}]}} +{"name":"press_key","arguments":{"key":"Escape"}} +``` + Raw protocol (for debugging): ```sh