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 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-05 04:43:43 +07:00
1 parent 37acaebf95
commit b8793eeaef
12 files changed
+533 -94

No files matched your search

+9
View File
@@ -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::*;
+1 -8
View File
@@ -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<Value> {
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))
}
+1 -1
View File
@@ -357,7 +357,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
}
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,
+1 -1
View File
@@ -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::<Vec<_>>(), f.points.iter().map(|p| p.color).collect::<Vec<_>>());
+2
View File
@@ -32,4 +32,6 @@ mod tests;
#[cfg(test)]
mod tests_fileio;
#[cfg(test)]
mod tests_freeform;
#[cfg(test)]
mod tests_gradient;
+58
View File
@@ -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));
}
+295 -37
View File
@@ -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<Self> {
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<Overlay> {
/// 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<usize> {
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<usize> {
self.selected(cx).filter(|_| lines.open && self.freeform.mode == FreeformMode::Lines)
}
pub fn overlays(&self, cx: &ToolContext, lines: &Lines) -> Vec<Overlay> {
let f = &self.freeform;
let mut o: Vec<Overlay> =
(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<Point>,
}
/// 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<usize>) -> 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<Action>, 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<Action>,
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<Action> {
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<Action> {
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<Action> {
pub(super) fn release(cx: &ToolContext, a: Option<&Annotator>, g: Gesture, lines: &mut Lines) -> Vec<Action> {
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<Action> {
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<Action> {
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<Action> {
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<Action> {
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());
}
}
+13 -7
View File
@@ -188,6 +188,8 @@ pub struct GradientTool {
gesture: Option<Gesture>,
/// A press on a freeform gradient's annotator.
free: Option<freeform::Gesture>,
/// The line Lines mode is drawing on a freeform gradient.
lines: freeform::Lines,
/// Smart-guide feedback of the handle being dragged.
guides: Vec<Overlay>,
}
@@ -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<Action> {
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<Overlay> {
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),
+136 -39
View File
@@ -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<GradientStop>, 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<GradientStop>, 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<Target> {
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<GradientStop> {
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());
}
}
+1 -1
View File
@@ -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);
}
+4
View File
@@ -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
+12
View File
@@ -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