mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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:
1 parent
37acaebf95
commit
b8793eeaef
12 files changed
+533
-94
No files matched your search
@@ -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::*;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<_>>());
|
||||
|
||||
@@ -32,4 +32,6 @@ mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_fileio;
|
||||
#[cfg(test)]
|
||||
mod tests_freeform;
|
||||
#[cfg(test)]
|
||||
mod tests_gradient;
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user