M3.17: interactive gradient annotator — drag the start handle or the bar to move the gradient, the end handle to change length and angle, just past the end to rotate (Shift snaps to 45°); a bar click adds a stop, stops drag along the bar, off it to delete (never below two), Alt-drag copies, diamonds move midpoints; a click inside selected art applies the gradient from that point; double-clicking a stop opens a popover (Color/Swatches, Opacity, Location); the selected stop lives in the session (gradient.selectStop), shared by the tool, the Gradient and Color panels and agents, and takes Delete/Backspace and ←/→ ahead of their shortcuts (Tool::claims_key, also headless); stop chips draw as Overlay::Swatch with generated add/remove-stop cursors; the stop math moves into the color crate

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-04 17:39:36 +07:00
parent aa1b592935
commit 3786cfaf36
20 changed files with 1263 additions and 216 deletions

View File

@ -116,6 +116,80 @@ impl Gradient {
}
}
// ---------- stop editing (the Gradient panel, the annotator and agents share these) ----------
/// Fewest stops a gradient keeps: deleting a stop never goes below this.
pub const MIN_STOPS: usize = 2;
/// Put `s` among `stops` (sorted by offset, after stops at the same offset). Returns its index.
fn place_stop(stops: &mut Vec<GradientStop>, s: GradientStop) -> usize {
let i = stops.iter().position(|o| o.offset > s.offset).unwrap_or(stops.len());
stops.insert(i, s);
i
}
/// Insert a stop at `offset`, coloured by sampling the gradient there. Returns the new stops and
/// the new stop's index.
pub fn insert_stop(g: &Gradient, offset: f32) -> (Vec<GradientStop>, usize) {
let offset = offset.clamp(0.0, 1.0);
let (color, opacity) = g.sample(offset);
let mut stops = g.stops.clone();
let i = place_stop(&mut stops, GradientStop { offset, color, opacity, midpoint: 0.5 });
(stops, i)
}
/// Remove stop `i`; `None` when that would leave fewer than [`MIN_STOPS`].
pub fn remove_stop(stops: &[GradientStop], i: usize) -> Option<Vec<GradientStop>> {
if stops.len() <= MIN_STOPS || i >= stops.len() {
return None;
}
let mut v = stops.to_vec();
v.remove(i);
Some(v)
}
/// Move stop `i` to `offset`, keeping the list sorted. Returns the stops and the stop's new index.
pub fn move_stop(stops: &[GradientStop], i: usize, offset: f32) -> (Vec<GradientStop>, usize) {
let mut v = stops.to_vec();
if i >= v.len() {
return (v, i);
}
let mut s = v.remove(i);
s.offset = offset.clamp(0.0, 1.0);
let ni = place_stop(&mut v, s);
(v, ni)
}
/// Add a copy of stop `i` at `offset` (Alt-drag). Returns the stops and the copy's index.
pub fn duplicate_stop(stops: &[GradientStop], i: usize, offset: f32) -> (Vec<GradientStop>, usize) {
let mut v = stops.to_vec();
let Some(&s) = v.get(i) else { return (v, i) };
let ni = place_stop(&mut v, GradientStop { offset: offset.clamp(0.0, 1.0), ..s });
(v, ni)
}
/// Set the midpoint between stop `i` and `i + 1` (clamped to the diamond's 13–87 %).
pub fn set_midpoint(stops: &[GradientStop], i: usize, m: f32) -> Vec<GradientStop> {
let mut v = stops.to_vec();
if let Some(s) = v.get_mut(i) {
s.midpoint = m.clamp(0.13, 0.87);
}
v
}
/// Absolute position (0..1) of the midpoint diamond after stop `i`.
pub fn midpoint_pos(stops: &[GradientStop], i: usize) -> Option<f32> {
let (a, b) = (stops.get(i)?, stops.get(i + 1)?);
Some(a.offset + (b.offset - a.offset) * a.midpoint)
}
/// Inverse of [`midpoint_pos`]: the relative midpoint for an absolute position.
pub fn midpoint_from_pos(stops: &[GradientStop], i: usize, pos: f32) -> Option<f32> {
let (a, b) = (stops.get(i)?, stops.get(i + 1)?);
let span = (b.offset - a.offset).max(1e-6);
Some(((pos - a.offset) / span).clamp(0.13, 0.87))
}
/// Where the gradient sits on an object, in document coordinates.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct GradientGeom {
@ -357,6 +431,67 @@ mod tests {
assert!((e.param_at(GradientKind::Linear, Point::new(5.0, 7.0)) - 0.5).abs() < 1e-12);
}
fn g3() -> Gradient {
let mut g = Gradient::default();
g.stops.insert(1, GradientStop { offset: 0.5, color: Color::rgb(1.0, 0.0, 0.0), opacity: 1.0, midpoint: 0.5 });
g
}
#[test]
fn insert_samples_color_and_sorts() {
let g = Gradient::default();
let (stops, i) = insert_stop(&g, 0.25);
assert_eq!(stops.len(), 3);
assert_eq!(i, 1);
let r = stops[1].color.to_rgb()[0];
assert!((r - 0.75).abs() < 1e-4, "sampled {r}");
let (stops, i) = insert_stop(&g, 2.0);
assert_eq!(i, 2);
assert_eq!(stops[2].offset, 1.0);
}
#[test]
fn remove_keeps_two() {
let g = g3();
let v = remove_stop(&g.stops, 1).unwrap();
assert_eq!(v.len(), 2);
assert!(remove_stop(&v, 0).is_none());
assert!(remove_stop(&g.stops, 9).is_none());
}
#[test]
fn move_reorders_and_tracks_index() {
let g = g3();
let (v, i) = move_stop(&g.stops, 0, 0.8);
assert_eq!(i, 1);
assert_eq!(v[1].color.to_hex(), "#ffffff");
assert!(v.windows(2).all(|w| w[0].offset <= w[1].offset));
let (v, i) = move_stop(&g.stops, 1, -3.0);
// Clamped to 0 and placed after the existing stop at 0.
assert_eq!((i, v[i].offset, v[i].color.to_hex()), (1, 0.0, "#ff0000".to_string()));
}
#[test]
fn duplicate_copies_the_stop_to_a_new_offset() {
let g = g3();
let (v, i) = duplicate_stop(&g.stops, 1, 0.9);
assert_eq!((v.len(), i, v[i].offset, v[i].color.to_hex()), (4, 2, 0.9, "#ff0000".to_string()));
assert_eq!(v[1], g.stops[1], "the original stays");
assert_eq!(duplicate_stop(&g.stops, 7, 0.5).0, g.stops);
}
#[test]
fn midpoint_math() {
let g = g3();
assert_eq!(midpoint_pos(&g.stops, 0), Some(0.25));
assert_eq!(midpoint_pos(&g.stops, 2), None);
let m = midpoint_from_pos(&g.stops, 1, 0.6).unwrap();
assert!((m - 0.2).abs() < 1e-5);
assert_eq!(midpoint_from_pos(&g.stops, 1, 0.51).unwrap(), 0.13);
let v = set_midpoint(&g.stops, 0, 0.99);
assert_eq!(v[0].midpoint, 0.87);
}
#[test]
fn pin_fixes_the_fit_once() {
let mut p = GradientPaint::new(Gradient::default());

View File

@ -30,9 +30,51 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
set_gradient_geom
),
cmd!(
"gradient.selectStop",
"Select Gradient Stop",
[],
None,
"{index: stop index (0 = the start) | null to clear} select a stop of the gradient behind the active proxy (the first selected object's, else the default paint): the stop the Gradient tool's annotator, the Gradient and Color panels and Delete/arrow keys act on → {index}",
always,
select_stop
),
]
}
/// The gradient behind the active proxy: the first selected object's (see
/// `Node::proxy_paint`), else the default paint for new art.
pub(crate) fn active_gradient(s: &Session) -> Option<GradientPaint> {
let stroke = !s.fill_active;
let first = s.active().and_then(|d| d.selection.objects.first().and_then(|id| d.doc.node(*id)));
let paint = match first {
Some(n) => n.proxy_paint(stroke).map(|(p, ..)| p.clone()),
None => Some(if stroke { s.paint.stroke.clone() } else { s.paint.fill.clone() }),
};
match paint {
Some(Paint::Gradient(g)) => Some(*g),
_ => None,
}
}
fn select_stop(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "gradient.selectStop";
let index = match p.get("index") {
None => return Err(bad(C, "missing `index` (a stop index, or null to clear)")),
Some(Value::Null) => None,
Some(v) => {
let i = v.as_u64().ok_or_else(|| bad(C, "`index` must be a whole number or null"))? as usize;
let n = active_gradient(s).ok_or_else(|| bad(C, "the active paint is not a gradient"))?.gradient.stops.len();
if i >= n {
return Err(bad(C, format!("no stop {i} (the gradient has {n})")));
}
Some(i)
}
};
s.gradient_stop = index;
Ok(json!({ "index": index }))
}
type Parsed<T> = std::result::Result<T, String>;
/// Parse a kind name.

View File

@ -443,6 +443,9 @@ pub struct Session {
untitled_counter: u32,
/// Session-level state of the menu commands (saved selections, guide lock).
pub(crate) menu: cmd::menucmds::MenuState,
/// The selected gradient stop (`gradient.selectStop`), shared by the Gradient tool's annotator,
/// the Gradient and Color panels and agents.
pub gradient_stop: Option<usize>,
}
impl Default for Session {
@ -468,6 +471,7 @@ impl Session {
draw_inside: None,
untitled_counter: 0,
menu: Default::default(),
gradient_stop: None,
}
}

View File

@ -1,8 +1,10 @@
//! Gradients through commands: lossless, validated params (M3.14).
//! Gradients through commands: lossless, validated params (M3.14), transforms (M3.15), the active
//! proxy and type (M3.16) and the interactive annotator (M3.17).
use serde_json::{Value, json};
use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_geom::Point;
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use super::*;
@ -256,3 +258,71 @@ fn aspect_works_on_type_and_run_strokes_fit_the_inflated_box() {
assert!(doc.end.distance(Point::new(150.0, 190.0)) < 1e-9 && (doc.aspect - 2.0).abs() < 1e-9, "aspect kept: {doc:?}");
assert_eq!(g.gradient.kind, GradientKind::Radial);
}
// ---------- M3.17: the interactive annotator ----------
/// A 100 × 100 rectangle at (100, 100) with a horizontal fill gradient across it, and the
/// Gradient tool.
fn annotated() -> (Session, NodeId) {
let mut s = session();
let id = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
s.execute("paint.setFill", &json!({"gradient": {"start": [100, 150], "end": [200, 150]}})).unwrap();
s.select_tool("gradient", crate::ViewInfo::default()).unwrap();
(s, id)
}
fn gesture(s: &mut Session, events: &[(PointerKind, f64, f64)]) {
for (k, x, y) in events {
s.pointer(&PointerEvent::new(*k, *x, *y), crate::ViewInfo::default()).unwrap();
}
}
fn stop_count(s: &Session, id: NodeId) -> usize {
fill_gradient(s, id).gradient.stops.len()
}
#[test]
fn select_stop_is_validated_and_shared() {
let (mut s, _) = annotated();
assert_eq!(s.execute("gradient.selectStop", &json!({"index": 1})).unwrap(), json!({"index": 1}));
assert_eq!(s.gradient_stop, Some(1));
assert!(s.execute("gradient.selectStop", &json!({"index": 2})).is_err(), "two stops");
assert!(s.execute("gradient.selectStop", &json!({"index": "a"})).is_err());
assert!(s.execute("gradient.selectStop", &json!({})).is_err());
s.execute("gradient.selectStop", &json!({"index": null})).unwrap();
assert_eq!(s.gradient_stop, None);
// The stroke proxy's paint is solid: nothing to select there.
s.execute("paint.toggleActive", &json!({})).unwrap();
assert!(s.execute("gradient.selectStop", &json!({"index": 0})).is_err());
}
#[test]
fn annotator_gestures_edit_the_gradient_as_single_undo_steps() {
let (mut s, id) = annotated();
// A click on the bar adds a stop there and selects it.
gesture(&mut s, &[(PointerKind::Down, 130.0, 150.0), (PointerKind::Up, 130.0, 150.0)]);
let stops = fill_gradient(&s, id).gradient.stops;
assert_eq!(stops.len(), 3);
assert!((stops[1].offset - 0.3).abs() < 1e-6);
assert_eq!(s.gradient_stop, Some(1));
// Dragging the end handle changes only the end, as one undo step.
let undo = s.doc().unwrap().history.undo.len();
gesture(
&mut s,
&[(PointerKind::Down, 200.0, 150.0), (PointerKind::Drag, 210.0, 160.0), (PointerKind::Drag, 220.0, 170.0), (PointerKind::Up, 220.0, 170.0)],
);
let g = fill_geom(&s, id);
assert_eq!((g.start, g.end), (Point::new(100.0, 150.0), Point::new(220.0, 170.0)));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
s.execute("edit.undo", &json!({})).unwrap();
// The selected stop takes Delete ahead of edit.clear; the object stays.
let view = crate::ViewInfo::default();
assert!(s.tool_claims_key(ToolKey::Delete, view));
s.tool_key(ToolKey::Delete, Mods::default(), view).unwrap();
assert_eq!(stop_count(&s, id), 2);
assert_eq!(s.gradient_stop, Some(1));
// Never below two stops.
s.tool_key(ToolKey::Backspace, Mods::default(), view).unwrap();
assert_eq!(stop_count(&s, id), 2);
assert!(s.doc().unwrap().doc.node(id).is_some());
}

View File

@ -79,6 +79,7 @@ impl Session {
snap_to_point: view.snap_to_point,
corner_widgets: view.corner_widgets,
fill_active: self.fill_active,
gradient_stop: self.gradient_stop,
};
f(self.tool.as_mut(), &cx)
}
@ -109,6 +110,11 @@ impl Session {
self.tool.busy()
}
/// Does the active tool take `key` ahead of the shortcuts bound to it (see `Tool::claims_key`)?
pub fn tool_claims_key(&mut self, key: ToolKey, view: ViewInfo) -> bool {
self.with_tool_cx(view, |t, cx| t.claims_key(cx, key))
}
pub fn overlays(&mut self, view: ViewInfo) -> Vec<Overlay> {
let mut v = self.with_tool_cx(view, |t, cx| t.overlays(cx));
if let Some(d) = self.active() {

View File

@ -166,9 +166,10 @@ impl Headless {
let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false);
let mods = Mods { shift: b("shift"), alt: b("alt"), cmd: b("cmd"), ctrl: b("ctrl"), ..Mods::default() };
let tk = tool_key(key);
// A busy tool (pen path in progress, shape being dragged) gets its keys first.
// A busy tool (pen path in progress, shape being dragged) gets its keys first, as do keys
// the tool claims (the Gradient tool's selected stop takes Delete and the arrows).
if let Some(k) = tk
&& self.session.tool_busy()
&& (self.session.tool_busy() || self.session.tool_claims_key(k, self.view))
{
let mut out = vec![];
let reqs = self.session.tool_key(k, mods, self.view).map_err(|e| e.to_string())?;

View File

@ -31,3 +31,5 @@ pub const DEFAULT_ADDR: &str = "127.0.0.1:7979";
mod tests;
#[cfg(test)]
mod tests_fileio;
#[cfg(test)]
mod tests_gradient;

View File

@ -0,0 +1,55 @@
//! The Gradient tool's annotator through MCP: `pointer_gesture` edits stops and the vector,
//! `press_key` reaches the selected stop.
use serde_json::{Value, json};
use vectorcraft_doc::color::{GradientPaint, Paint};
use vectorcraft_geom::Point;
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)
}
/// The fill gradient of the selected object.
fn gradient(h: &Headless) -> GradientPaint {
let st = h.session.doc().unwrap();
match st.doc.node(st.selection.objects[0]).unwrap().appearance.fill_paint() {
Paint::Gradient(g) => *g,
p => panic!("not a gradient: {p:?}"),
}
}
fn offsets(g: &GradientPaint) -> Vec<f32> {
g.gradient.stops.iter().map(|s| (s.offset * 100.0).round()).collect()
}
#[test]
fn gradient_annotator_end_to_end() {
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.setFill", "params": {"gradient": {"start": [100, 150], "end": [200, 150]}}}));
// A click on the bar adds a stop at 30 %.
let click = json!({"tool": "gradient", "events": [{"kind": "down", "x": 130, "y": 150}, {"kind": "up", "x": 130, "y": 150}]});
ok(&mut h, "pointer_gesture", click);
assert_eq!(offsets(&gradient(&h)), vec![0.0, 30.0, 100.0]);
// Drag the new stop (its chip sits 10 px under the bar) to 60 %.
let drag = json!({"events": [{"kind": "down", "x": 130, "y": 160}, {"kind": "drag", "x": 145, "y": 160}, {"kind": "drag", "x": 160, "y": 160}, {"kind": "up", "x": 160, "y": 160}]});
ok(&mut h, "pointer_gesture", drag);
assert_eq!(offsets(&gradient(&h)), vec![0.0, 60.0, 100.0]);
// The dragged stop stays selected: Delete removes it (and leaves the rectangle).
let r = ok(&mut h, "press_key", json!({"key": "Delete"}));
assert_eq!(r["handledBy"], "tool", "{r}");
assert_eq!(offsets(&gradient(&h)), vec![0.0, 100.0]);
// The end handle changes only the end.
let end = json!({"events": [{"kind": "down", "x": 200, "y": 150}, {"kind": "drag", "x": 230, "y": 120}, {"kind": "up", "x": 230, "y": 120}]});
ok(&mut h, "pointer_gesture", end);
let g = gradient(&h).geom.unwrap();
assert_eq!((g.start, g.end), (Point::new(100.0, 150.0), Point::new(230.0, 120.0)));
// Double-clicking a stop asks for its popover.
let dbl = ok(&mut h, "pointer_gesture", json!({"events": [{"kind": "doubleclick", "x": 100, "y": 160}]}));
assert_eq!(dbl["requests"][0]["dialog"], "gradientStop", "{dbl}");
}

View File

@ -158,6 +158,8 @@ pub struct ToolContext<'a> {
pub corner_widgets: bool,
/// Which paint proxy is in front (true = Fill): the one the Gradient tool edits.
pub fill_active: bool,
/// The selected gradient stop (`gradient.selectStop`), marked on the gradient annotator.
pub gradient_stop: Option<usize>,
}
impl ToolContext<'_> {
@ -189,6 +191,8 @@ pub enum Overlay {
Measure { p: Point, text: String },
/// Translucent filled quad (text selection highlight), RGBA.
Highlight { quad: [Point; 4], color: [u8; 4] },
/// A colour chip of fixed screen size (a gradient stop), RGBA; ringed when selected.
Swatch { p: Point, color: [u8; 4], selected: bool },
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@ -217,6 +221,10 @@ pub enum Cursor {
ZoomOut,
Eyedropper,
NotAllowed,
/// Over the gradient annotator's bar: a click adds a stop.
AddStop,
/// A gradient stop dragged off the bar: releasing deletes it.
RemoveStop,
}
/// A tool state machine.
@ -241,6 +249,11 @@ pub trait Tool: Send {
fn busy(&self) -> bool {
false
}
/// Does the tool take `key` now, ahead of the command shortcuts bound to it (the Gradient tool
/// with a stop selected takes Delete and the arrows)?
fn claims_key(&self, _cx: &ToolContext, _key: ToolKey) -> bool {
false
}
/// Does the tool want typed text (Type tool editing)? Single-key shortcuts are suppressed.
fn wants_text(&self) -> bool {
false
@ -328,6 +341,7 @@ pub(crate) mod testutil {
snap_to_point: true,
corner_widgets: true,
fill_active: true,
gradient_stop: None,
}
}
}

View File

@ -1,13 +1,24 @@
//! Gradient tool (G).
//! Gradient tool (G) and the gradient annotator.
//!
//! Drag across selected objects to set the gradient vector of the paint behind the active proxy
//! (fill or stroke; type objects' runs), a solid paint becoming the default gradient. Shift constrains the angle to 45°. With nothing selected, the drag targets
//! the object under the pointer. The overlay is the gradient annotator: a bar from a round start
//! handle to a square end handle, with a tick for each colour stop.
//! (fill or stroke; type objects' runs), a solid paint becoming the default gradient. Shift
//! constrains the angle to 45°. With nothing selected, the press targets the object under the
//! pointer; a click inside selected art applies the gradient from that point.
//!
//! The annotator is a bar from a round start handle to a square end handle. Drag the start handle
//! (or the bar) to move the gradient, the end handle to change its length and angle, and just past
//! the end to rotate it (Shift snaps to 45°). Colour stops sit under the bar: click the bar to add
//! one, drag one to move it (Alt drags a copy), drag it off the bar to delete it, and drag a
//! diamond above the bar to move a midpoint. Double-clicking a stop opens its popover. The
//! selected stop (`gradient.selectStop`) is shared with the Gradient and Color panels: Delete or
//! Backspace removes it (never below two stops) and the arrow keys nudge it.
use serde_json::{Value, json};
use vectorcraft_color::gradient::{duplicate_stop, insert_stop, midpoint_from_pos, midpoint_pos, move_stop, remove_stop, set_midpoint};
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientStop};
use vectorcraft_doc::NodeId;
use vectorcraft_doc::hit::hit_test;
use vectorcraft_geom::{Point, Vec2};
use vectorcraft_geom::{Affine, Point, Vec2};
use super::paint_owner;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
@ -15,104 +26,412 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool
const BAR: [u8; 3] = [0x20, 0x20, 0x20];
const LIGHT: [u8; 3] = [0xf0, 0xf0, 0xf0];
// Annotator layout and hit radii, in screen pixels.
/// Start and end handles.
const HANDLE: f64 = 5.0;
/// The rotate zone reaches this far past the end handle.
const ROTATE: f64 = 14.0;
/// Stop chips sit this far below the bar.
const STOP_GAP: f64 = 10.0;
const STOP_HIT: f64 = 6.0;
/// Midpoint diamonds sit this far above the bar.
const MID_GAP: f64 = 7.0;
const MID_HIT: f64 = 5.0;
/// The bar (where a click adds a stop) reaches this far above it, and down through the stop row.
const BAR_HIT: f64 = 4.0;
/// A stop dragged farther than this from the bar is deleted on release.
const OFF_BAR: f64 = 24.0;
/// A press becomes a drag after this much movement.
const DRAG_START: f64 = 3.0;
/// Arrow-key nudges of the selected stop (Shift: the big step).
const NUDGE: f32 = 0.01;
const NUDGE_BIG: f32 = 0.1;
/// The gradient annotator: the gradient behind the active proxy of the first selected object that
/// has one, placed in document coordinates.
#[derive(Clone, Debug, PartialEq)]
pub struct Annotator {
pub geom: GradientGeom,
pub gradient: Gradient,
}
/// A part of the annotator.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Part {
Stop(usize),
/// The midpoint diamond after stop `i`.
Mid(usize),
Start,
End,
/// Just past the end: rotates the vector about the start.
Rotate,
/// The bar at an offset (0..1): a click adds a stop there.
Bar(f32),
}
impl Annotator {
pub fn of(cx: &ToolContext) -> Option<Self> {
cx.selection.objects.iter().find_map(|id| {
let (g, geom) = cx.doc.node(*id)?.proxy_gradient(!cx.fill_active)?;
Some(Self { geom, gradient: g.gradient.clone() })
})
}
/// An annotator showing `stops` on `geom` (the state a drag started from).
fn with(geom: GradientGeom, stops: &[GradientStop]) -> Self {
Self { geom, gradient: Gradient { kind: GradientKind::Linear, stops: stops.to_vec() } }
}
fn vector(&self) -> Vec2 {
self.geom.end - self.geom.start
}
/// The point at offset `t` along the bar.
pub fn at(&self, t: f64) -> Point {
self.geom.start + self.vector() * t
}
/// Unit normal to the bar, towards the stop row.
fn normal(&self) -> Vec2 {
let v = self.vector();
let len = v.hypot();
if len < 1e-12 { Vec2::new(0.0, 1.0) } else { Vec2::new(-v.y, v.x) / len }
}
/// Where stop `i`'s chip sits.
pub fn stop_point(&self, cx: &ToolContext, i: usize) -> Option<Point> {
let s = self.gradient.stops.get(i)?;
Some(self.at(s.offset as f64) + self.normal() * cx.tol(STOP_GAP))
}
/// Where the midpoint diamond after stop `i` sits.
pub fn mid_point(&self, cx: &ToolContext, i: usize) -> Option<Point> {
Some(self.at(midpoint_pos(&self.gradient.stops, i)? as f64) - self.normal() * cx.tol(MID_GAP))
}
/// `p` as (offset along the bar, signed distance from it towards the stop row).
fn project(&self, p: Point) -> (f64, f64) {
let v = self.vector();
let l2 = v.hypot2();
let d = p - self.geom.start;
if l2 < 1e-18 {
return (0.0, d.hypot());
}
(d.dot(v) / l2, d.dot(self.normal()))
}
/// The part under `p`: stops, then midpoints, the handles, the rotate zone and the bar.
pub fn hit(&self, cx: &ToolContext, p: Point) -> Option<Part> {
let near = |q: Option<Point>, r: f64| q.is_some_and(|q| q.distance(p) <= cx.tol(r));
let n = self.gradient.stops.len();
// The topmost (last drawn) chip wins where chips overlap.
if let Some(i) = (0..n).rev().find(|i| near(self.stop_point(cx, *i), STOP_HIT)) {
return Some(Part::Stop(i));
}
if let Some(i) = (0..n.saturating_sub(1)).find(|i| near(self.mid_point(cx, *i), MID_HIT)) {
return Some(Part::Mid(i));
}
if near(Some(self.geom.end), HANDLE) {
return Some(Part::End);
}
if near(Some(self.geom.start), HANDLE) {
return Some(Part::Start);
}
let (t, d) = self.project(p);
if t > 1.0 && near(Some(self.geom.end), ROTATE) {
return Some(Part::Rotate);
}
let across = -cx.tol(BAR_HIT)..=cx.tol(STOP_GAP + STOP_HIT);
if self.vector().hypot() > 1e-12 && (0.0..=1.0).contains(&t) && across.contains(&d) {
return Some(Part::Bar(t as f32));
}
None
}
}
/// What a press grabbed.
#[derive(Clone, Debug, PartialEq)]
enum Grab {
/// The art (or empty canvas): a click applies the gradient from there, a drag draws a vector.
Art,
/// The start handle or the bar: a drag moves the gradient (a click on the bar adds a stop).
Move {
geom: GradientGeom,
bar: Option<f32>,
},
End(GradientGeom),
Rotate(GradientGeom),
Stop {
index: usize,
from: Annotator,
copy: bool,
},
Mid {
index: usize,
from: Annotator,
},
}
#[derive(Clone, Debug, PartialEq)]
struct Gesture {
grab: Grab,
/// Where the press happened.
at: Point,
/// Moved past the drag threshold (an interaction is open).
began: bool,
/// The vector drawn so far (Art drags).
vector: Option<(Point, Point)>,
/// Where a dragged stop ends up (selected on release): its index, or None while it is dragged
/// off the bar.
stop: Option<Option<usize>>,
}
#[derive(Default)]
pub struct GradientTool {
drag: Option<(Point, Point, bool)>,
gesture: Option<Gesture>,
}
/// Start/end/stop offsets of the gradient annotator: the gradient behind the active proxy (fill or
/// stroke; a type object's runs) of the first selected object that has one, in document coordinates.
pub fn annotator(cx: &ToolContext) -> Option<(Point, Point, Vec<f32>)> {
cx.selection.objects.iter().find_map(|id| {
let (g, geom) = cx.doc.node(*id)?.proxy_gradient(!cx.fill_active)?;
Some((geom.start, geom.end, g.gradient.stops.iter().map(|s| s.offset).collect()))
})
}
fn annotator_overlays(cx: &ToolContext, a: Point, b: Point, stops: &[f32]) -> Vec<Overlay> {
/// Overlays of an annotator: the bar, the handles, the midpoint diamonds and a chip per stop.
fn annotator_overlays(cx: &ToolContext, a: &Annotator) -> Vec<Overlay> {
let (s, e) = (a.geom.start, a.geom.end);
let shade = Vec2::new(0.0, cx.tol(1.0));
let mut o = vec![
Overlay::Line { a, b, color: BAR, dashed: false },
Overlay::Line { a: a + Vec2::new(0.0, cx.tol(1.0)), b: b + Vec2::new(0.0, cx.tol(1.0)), color: LIGHT, dashed: false },
Overlay::Handle { p: a, color: BAR },
Overlay::Anchor { p: b, color: BAR, filled: true, size: 7.0 },
Overlay::Line { a: s, b: e, color: BAR, dashed: false },
Overlay::Line { a: s + shade, b: e + shade, color: LIGHT, dashed: false },
Overlay::Handle { p: s, color: BAR },
Overlay::Anchor { p: e, color: BAR, filled: true, size: 7.0 },
];
let v = b - a;
let len = v.hypot();
if len > 1e-9 {
let n = Vec2::new(-v.y, v.x) / len * cx.tol(6.0);
for s in stops {
let p = a + v * *s as f64;
o.push(Overlay::Line { a: p, b: p + n, color: BAR, dashed: false });
}
let len = a.vector().hypot();
if len < 1e-12 {
return o;
}
let r = cx.tol(4.0);
let (along, across) = (a.vector() / len * r, a.normal() * r);
for i in 0..a.gradient.stops.len().saturating_sub(1) {
let Some(c) = a.mid_point(cx, i) else { continue };
let path = super::polygon(&[c - along, c - across, c + along, c + across], true);
o.push(Overlay::Path { path, color: BAR, width: 1.0, dashed: false });
}
for (i, st) in a.gradient.stops.iter().enumerate() {
let Some(p) = a.stop_point(cx, i) else { continue };
o.push(Overlay::Line { a: a.at(st.offset as f64), b: p, color: BAR, dashed: false });
o.push(Overlay::Swatch { p, color: st.color.to_rgba8(st.opacity), selected: cx.gradient_stop == Some(i) });
}
o
}
/// The vector a click applies to `id`: its current gradient's, else the default fit on its box.
fn click_vector(cx: &ToolContext, id: NodeId) -> Option<Vec2> {
let stroke = !cx.fill_active;
let n = cx.doc.node(id)?;
if let Some((_, g)) = n.proxy_gradient(stroke) {
return Some(g.end - g.start);
}
let (to_doc, b) = match n.proxy_paint(stroke) {
Some((_, a, b)) => (a, b),
None => (Affine::IDENTITY, n.geometric_bounds()?),
};
let mut g = GradientGeom::fit(GradientKind::Linear, b, 0.0);
g.transform(to_doc, GradientKind::Linear);
Some(g.end - g.start)
}
impl GradientTool {
fn geom_params(cx: &ToolContext, start: Point, end: Point) -> Value {
json!({ "start": [start.x, start.y], "end": [end.x, end.y], "stroke": !cx.fill_active })
}
fn stops_params(cx: &ToolContext, stops: &[GradientStop]) -> Value {
json!({ "stops": crate::params::stops_json(stops), "stroke": !cx.fill_active })
}
fn select_stop(i: usize) -> Action {
Action::Exec("gradient.selectStop".into(), json!({ "index": i }))
}
fn press(&mut self, cx: &ToolContext, p: Point, mods: Mods) -> Vec<Action> {
let annotator = Annotator::of(cx);
let mut out = vec![];
let grab = match annotator.as_ref().and_then(|a| a.hit(cx, p).map(|part| (a, part))) {
Some((a, Part::Stop(index))) => {
out.push(Self::select_stop(index));
Grab::Stop { index, from: a.clone(), copy: mods.alt }
}
Some((a, Part::Mid(index))) => Grab::Mid { index, from: a.clone() },
Some((a, Part::Start)) => Grab::Move { geom: a.geom, bar: None },
Some((a, Part::Bar(t))) => Grab::Move { geom: a.geom, bar: Some(t) },
Some((a, Part::End)) => Grab::End(a.geom),
Some((a, Part::Rotate)) => Grab::Rotate(a.geom),
None => {
if cx.selection.is_empty() {
let Some(h) = hit_test(cx.doc, p, cx.hit_options()) else { return out };
out.push(Action::Exec("select.set".into(), json!({ "ids": [paint_owner(cx.doc, h.leaf).0] })));
}
Grab::Art
}
};
self.gesture = Some(Gesture { grab, at: p, began: false, vector: None, stop: None });
out
}
fn drag(&mut self, cx: &ToolContext, p: Point, mods: Mods) -> Vec<Action> {
let Some(g) = &mut self.gesture else { return vec![] };
let mut out = vec![];
if !g.began {
if p.distance(g.at) < cx.tol(DRAG_START) {
return out;
}
g.began = true;
out.push(Action::Begin("Gradient".into()));
}
let snap = |from: Point, to: Point| if mods.shift { from + vectorcraft_geom::constrain_angle(to - from, 45.0) } else { to };
let (cmd, params) = match &g.grab {
Grab::Art => {
let end = snap(g.at, p);
g.vector = Some((g.at, end));
("paint.setGradientGeom", Self::geom_params(cx, g.at, end))
}
Grab::Move { geom, .. } => {
let d = p - g.at;
("paint.setGradientGeom", Self::geom_params(cx, geom.start + d, geom.end + d))
}
Grab::End(geom) => ("paint.setGradientGeom", Self::geom_params(cx, geom.start, snap(geom.start, p))),
Grab::Rotate(geom) => {
let dir = snap(geom.start, p) - geom.start;
let len = dir.hypot();
let end = if len < 1e-12 { geom.end } else { geom.start + dir * (geom.length() / len) };
("paint.setGradientGeom", Self::geom_params(cx, geom.start, end))
}
Grab::Stop { index, from, copy } => {
let (t, d) = from.project(p);
let t = t.clamp(0.0, 1.0) as f32;
let stops = &from.gradient.stops;
let removed = (!copy && d.abs() > cx.tol(OFF_BAR)).then(|| remove_stop(stops, *index)).flatten();
let (stops, at) = match removed {
Some(v) => (v, None),
None => {
let (v, i) = if *copy { duplicate_stop(stops, *index, t) } else { move_stop(stops, *index, t) };
(v, Some(i))
}
};
g.stop = Some(at);
("paint.editGradient", Self::stops_params(cx, &stops))
}
Grab::Mid { index, from } => {
let stops = &from.gradient.stops;
let m = midpoint_from_pos(stops, *index, from.project(p).0.clamp(0.0, 1.0) as f32).unwrap_or(0.5);
("paint.editGradient", Self::stops_params(cx, &set_midpoint(stops, *index, m)))
}
};
out.push(Action::Preview(cmd.into(), params));
out
}
fn release(&mut self, cx: &ToolContext, p: Point) -> Vec<Action> {
let Some(g) = self.gesture.take() else { return vec![] };
if g.began {
let mut out = vec![Action::Commit];
match (g.grab, g.stop) {
(Grab::Stop { .. }, Some(Some(i))) => out.push(Self::select_stop(i)),
// Dragged off the bar: the stop that slid into its place is selected.
(Grab::Stop { index, from, .. }, Some(None)) => out.push(Self::select_stop(index.min(from.gradient.stops.len() - 2))),
_ => {}
}
return out;
}
match g.grab {
// A click on the bar adds a stop there.
Grab::Move { bar: Some(t), .. } => {
let Some(a) = Annotator::of(cx) else { return vec![] };
let (stops, i) = insert_stop(&a.gradient, t);
vec![Action::Exec("paint.editGradient".into(), Self::stops_params(cx, &stops)), Self::select_stop(i)]
}
// A click inside selected art applies the gradient from that point.
Grab::Art => {
let Some(h) = hit_test(cx.doc, p, cx.hit_options()) else { return vec![] };
let id = paint_owner(cx.doc, h.leaf);
let Some(v) = cx.selection.objects.contains(&id).then(|| click_vector(cx, id)).flatten() else { return vec![] };
let mut params = Self::geom_params(cx, p, p + v);
params["ids"] = json!([id.0]);
vec![Action::Exec("paint.setGradientGeom".into(), params)]
}
_ => vec![],
}
}
}
impl Tool for GradientTool {
fn id(&self) -> &'static str {
"gradient"
}
fn busy(&self) -> bool {
self.drag.is_some_and(|d| d.2)
self.gesture.as_ref().is_some_and(|g| g.began)
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
match ev.kind {
PointerKind::Down => {
let mut out = vec![];
if cx.selection.is_empty() {
let Some(h) = hit_test(cx.doc, p, cx.hit_options()) else { return out };
let id = paint_owner(cx.doc, h.leaf);
out.push(Action::Exec("select.set".into(), json!({ "ids": [id.0] })));
}
self.drag = Some((p, p, false));
out
PointerKind::Down => self.press(cx, ev.pos, ev.mods),
PointerKind::Drag => self.drag(cx, ev.pos, ev.mods),
PointerKind::Up => self.release(cx, ev.pos),
PointerKind::DoubleClick => {
let Some(a) = Annotator::of(cx) else { return vec![] };
let Some(Part::Stop(i)) = a.hit(cx, ev.pos) else { return vec![] };
let at = a.stop_point(cx, i).unwrap_or(ev.pos);
vec![Self::select_stop(i), Action::Dialog("gradientStop".into(), json!({ "index": i, "x": at.x, "y": at.y }))]
}
PointerKind::Drag => {
let Some((start, _, began)) = self.drag else { return vec![] };
let mut out = vec![];
if !began {
if p.distance(start) < cx.tol(3.0) {
return out;
}
out.push(Action::Begin("Gradient".into()));
}
let end = if ev.mods.shift { start + vectorcraft_geom::constrain_angle(p - start, 45.0) } else { p };
self.drag = Some((start, end, true));
out.push(Action::Preview(
"paint.setGradientGeom".into(),
json!({ "start": [start.x, start.y], "end": [end.x, end.y], "stroke": !cx.fill_active }),
));
out
}
PointerKind::Up => match self.drag.take() {
Some((_, _, true)) => vec![Action::Commit],
_ => vec![],
},
_ => vec![],
PointerKind::Move => vec![],
}
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
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()))
}
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
if key == ToolKey::Escape && self.busy() {
self.drag = None;
self.gesture = None;
return vec![Action::Cancel];
}
vec![]
if self.busy() || !self.claims_key(cx, key) {
return vec![];
}
let (Some(a), Some(i)) = (Annotator::of(cx), cx.gradient_stop) else { return vec![] };
let stops = &a.gradient.stops;
let (new, sel) = match key {
// Never below two stops: Delete then does nothing.
ToolKey::Delete | ToolKey::Backspace => match remove_stop(stops, i) {
Some(v) => {
let sel = i.min(v.len() - 1);
(v, sel)
}
None => return vec![],
},
_ => {
let step = if mods.shift { NUDGE_BIG } else { NUDGE };
move_stop(stops, i, stops[i].offset + if key == ToolKey::Left { -step } else { step })
}
};
vec![Action::Exec("paint.editGradient".into(), Self::stops_params(cx, &new)), Self::select_stop(sel)]
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
match (self.drag, annotator(cx)) {
(_, Some((a, b, stops))) => annotator_overlays(cx, a, b, &stops),
(Some((a, b, true)), None) => annotator_overlays(cx, a, b, &[0.0, 1.0]),
match (Annotator::of(cx), self.gesture.as_ref().and_then(|g| g.vector)) {
(Some(a), _) => annotator_overlays(cx, &a),
// A vector drawn on a paint that isn't a gradient yet.
(None, Some((s, e))) => {
annotator_overlays(cx, &Annotator::with(GradientGeom { start: s, end: e, aspect: 1.0 }, &Gradient::default().stops))
}
_ => vec![],
}
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
if let Some(g) = &self.gesture {
return match (&g.grab, g.stop) {
(Grab::Stop { .. }, Some(None)) => Cursor::RemoveStop,
(Grab::Rotate(_), _) => Cursor::Rotate,
(Grab::Art, _) => Cursor::Crosshair,
_ => Cursor::Move,
};
}
match Annotator::of(cx).and_then(|a| a.hit(cx, p)) {
Some(Part::Bar(_)) => Cursor::AddStop,
Some(Part::Rotate) => Cursor::Rotate,
Some(_) => Cursor::Move,
None => Cursor::Crosshair,
}
}
fn options(&self) -> Value {
@ -124,7 +443,58 @@ impl Tool for GradientTool {
mod tests {
use super::*;
use crate::testutil::*;
use vectorcraft_doc::Selection;
use vectorcraft_color::{GradientPaint, Paint};
use vectorcraft_doc::{Document, Selection};
fn ev(kind: PointerKind, p: Point) -> PointerEvent {
PointerEvent::new(kind, p.x, p.y)
}
fn with(m: Mods, e: PointerEvent) -> PointerEvent {
e.with_mods(m)
}
const SHIFT: Mods = Mods { shift: true, alt: false, cmd: false, ctrl: false, space: false };
const ALT: Mods = Mods { shift: false, alt: true, cmd: false, ctrl: false, space: false };
/// The test rectangle (100..200) with a horizontal gradient of `stops` from (100,150) to (200,150).
fn graded(stops: Vec<GradientStop>) -> (Document, Selection) {
let (mut d, id) = doc_with_rect();
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
g.geom = Some(GradientGeom { start: Point::new(100.0, 150.0), end: Point::new(200.0, 150.0), aspect: 1.0 });
d.node_mut(id).unwrap().appearance.set_fill(Paint::Gradient(Box::new(g)));
let mut s = Selection::default();
s.add(id);
(d, s)
}
fn two() -> Vec<GradientStop> {
Gradient::default().stops
}
fn three() -> Vec<GradientStop> {
insert_stop(&Gradient::default(), 0.5).0
}
fn preview(a: &[Action]) -> &Value {
a.iter().find_map(|a| if let Action::Preview(_, v) = a { Some(v) } else { None }).unwrap_or_else(|| panic!("no preview in {a:?}"))
}
fn exec(a: &Action) -> (&str, &Value) {
match a {
Action::Exec(c, v) => (c.as_str(), v),
a => panic!("not an Exec: {a:?}"),
}
}
fn point(v: &Value) -> Point {
Point::new(v[0].as_f64().unwrap(), v[1].as_f64().unwrap())
}
/// Stop offsets in percent (rounded: offsets travel as f32).
fn offsets(v: &Value) -> Vec<f64> {
v["stops"].as_array().unwrap().iter().map(|s| (s["offset"].as_f64().unwrap() * 100.0).round()).collect()
}
#[test]
fn drag_sets_gradient_vector_and_constrains() {
@ -135,7 +505,7 @@ mod tests {
let cx = cx(&d, &s, &p);
let mut t = GradientTool::default();
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 110.0, 150.0)).is_empty());
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 190.0, 152.0).with_mods(Mods { shift: true, ..Default::default() }));
let a = t.pointer(&cx, &with(SHIFT, PointerEvent::new(PointerKind::Drag, 190.0, 152.0)));
assert_eq!(a[0], Action::Begin("Gradient".into()));
let Action::Preview(c, v) = &a[1] else { panic!() };
assert_eq!(c, "paint.setGradientGeom");
@ -148,7 +518,6 @@ mod tests {
#[test]
fn stroke_proxy_drags_and_annotates_the_stroke() {
use vectorcraft_color::{Gradient, GradientPaint, Paint};
let (mut d, id) = doc_with_rect();
d.node_mut(id).unwrap().appearance.set_stroke(Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))));
let mut s = Selection::default();
@ -159,13 +528,12 @@ mod tests {
assert!(GradientTool::default().overlays(&cx).is_empty());
cx.fill_active = false;
// The stroke's annotator spans the stroke-inflated box (1 pt stroke: 99.5..200.5).
let (a, b, stops) = annotator(&cx).unwrap();
assert_eq!((a, b, stops.len()), (Point::new(99.5, 150.0), Point::new(200.5, 150.0), 2));
let a = Annotator::of(&cx).unwrap();
assert_eq!((a.geom.start, a.geom.end, a.gradient.stops.len()), (Point::new(99.5, 150.0), Point::new(200.5, 150.0), 2));
let mut t = GradientTool::default();
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 110.0, 150.0));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 190.0, 150.0));
let Action::Preview(_, v) = &a[1] else { panic!("{a:?}") };
assert_eq!(v["stroke"], json!(true));
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 110.0, 120.0));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 190.0, 120.0));
assert_eq!(preview(&a)["stroke"], json!(true));
}
#[test]
@ -179,4 +547,156 @@ mod tests {
assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]);
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 450.0, 450.0)).is_empty());
}
#[test]
fn the_end_handle_changes_only_the_end_and_the_start_moves_both() {
let (d, s) = graded(two());
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = GradientTool::default();
t.pointer(&cx, &ev(PointerKind::Down, Point::new(200.0, 150.0)));
let v = preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(220.0, 170.0)))).clone();
assert_eq!((point(&v["start"]), point(&v["end"])), (Point::new(100.0, 150.0), Point::new(220.0, 170.0)));
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, Point::new(220.0, 170.0))), vec![Action::Commit]);
// The start handle moves the whole vector.
t.pointer(&cx, &ev(PointerKind::Down, Point::new(100.0, 150.0)));
let v = preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(110.0, 140.0)))).clone();
assert_eq!((point(&v["start"]), point(&v["end"])), (Point::new(110.0, 140.0), Point::new(210.0, 140.0)));
}
#[test]
fn past_the_end_rotates_and_shift_snaps() {
let (d, s) = graded(two());
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = GradientTool::default();
assert_eq!(t.cursor(&cx, Point::new(210.0, 151.0), Mods::default()), Cursor::Rotate);
t.pointer(&cx, &ev(PointerKind::Down, Point::new(210.0, 151.0)));
let end = point(&preview(&t.pointer(&cx, &with(SHIFT, ev(PointerKind::Drag, Point::new(103.0, 60.0)))))["end"]);
// Snapped to straight up, keeping the 100 pt length.
assert!(end.distance(Point::new(100.0, 50.0)) < 1e-9, "{end:?}");
}
#[test]
fn a_bar_click_adds_a_stop_and_selects_it() {
let (d, s) = graded(two());
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = GradientTool::default();
assert_eq!(t.cursor(&cx, Point::new(130.0, 150.0), Mods::default()), Cursor::AddStop);
assert!(t.pointer(&cx, &ev(PointerKind::Down, Point::new(130.0, 150.0))).is_empty());
let a = t.pointer(&cx, &ev(PointerKind::Up, Point::new(130.0, 150.0)));
let (c, v) = exec(&a[0]);
assert_eq!((c, offsets(v)), ("paint.editGradient", vec![0.0, 30.0, 100.0]));
assert_eq!(a[1], GradientTool::select_stop(1));
}
#[test]
fn stops_drag_along_the_bar_off_it_to_delete_and_alt_copies() {
let (d, s) = graded(two());
let p = paint();
let cx = cx(&d, &s, &p);
let chip = Annotator::of(&cx).unwrap().stop_point(&cx, 0).unwrap();
let mut t = GradientTool::default();
// Pressing a stop selects it; dragging moves it.
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, chip)), vec![GradientTool::select_stop(0)]);
assert_eq!(offsets(preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(140.0, chip.y))))), vec![40.0, 100.0]);
assert_eq!(t.cursor(&cx, chip, Mods::default()), Cursor::Move);
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, Point::new(140.0, chip.y))), vec![Action::Commit, GradientTool::select_stop(0)]);
// Two stops never lose one: dragged off the bar, the stop stays.
t.pointer(&cx, &ev(PointerKind::Down, chip));
assert_eq!(offsets(preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(100.0, 260.0))))), vec![0.0, 100.0]);
t.pointer(&cx, &ev(PointerKind::Up, Point::new(100.0, 260.0)));
// Alt-drag leaves the original and drags a copy.
t.pointer(&cx, &with(ALT, ev(PointerKind::Down, chip)));
assert_eq!(offsets(preview(&t.pointer(&cx, &with(ALT, ev(PointerKind::Drag, Point::new(150.0, chip.y)))))), vec![0.0, 50.0, 100.0]);
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, Point::new(150.0, chip.y))), vec![Action::Commit, GradientTool::select_stop(1)]);
// With three stops, dragging the middle one off the bar deletes it.
let (d, s) = graded(three());
let cx = crate::testutil::cx(&d, &s, &p);
let chip = Annotator::of(&cx).unwrap().stop_point(&cx, 1).unwrap();
t.pointer(&cx, &ev(PointerKind::Down, chip));
assert_eq!(offsets(preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(150.0, 200.0))))), vec![0.0, 100.0]);
assert_eq!(t.cursor(&cx, Point::new(150.0, 200.0), Mods::default()), Cursor::RemoveStop);
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, Point::new(150.0, 200.0))), vec![Action::Commit, GradientTool::select_stop(1)]);
}
#[test]
fn diamonds_move_midpoints() {
let (d, s) = graded(two());
let p = paint();
let cx = cx(&d, &s, &p);
let diamond = Annotator::of(&cx).unwrap().mid_point(&cx, 0).unwrap();
let mut t = GradientTool::default();
t.pointer(&cx, &ev(PointerKind::Down, diamond));
let v = preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(130.0, diamond.y)))).clone();
assert!((v["stops"][0]["midpoint"].as_f64().unwrap() - 0.3).abs() < 1e-6, "{v}");
}
#[test]
fn delete_and_arrows_act_on_the_selected_stop() {
let (d, s) = graded(two());
let p = paint();
let mut cx = cx(&d, &s, &p);
let mut t = GradientTool::default();
assert!(!t.claims_key(&cx, ToolKey::Delete), "no stop selected");
cx.gradient_stop = Some(1);
assert!(t.claims_key(&cx, ToolKey::Delete) && t.claims_key(&cx, ToolKey::Backspace) && !t.claims_key(&cx, ToolKey::Enter));
// Two stops: Delete is claimed but does nothing.
assert!(t.key(&cx, ToolKey::Delete, Mods::default()).is_empty());
let a = t.key(&cx, ToolKey::Left, SHIFT);
let (c, v) = exec(&a[0]);
assert_eq!((c, offsets(v)), ("paint.editGradient", vec![0.0, 90.0]));
assert_eq!(a[1], GradientTool::select_stop(1));
// With three stops Delete removes the selected one and selects its successor.
let (d, s) = graded(three());
let mut cx = crate::testutil::cx(&d, &s, &p);
cx.gradient_stop = Some(1);
let a = t.key(&cx, ToolKey::Backspace, Mods::default());
assert_eq!(offsets(exec(&a[0]).1), vec![0.0, 100.0]);
assert_eq!(a[1], GradientTool::select_stop(1));
}
#[test]
fn a_click_inside_the_art_applies_the_gradient_from_there() {
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.add(id);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = GradientTool::default();
t.pointer(&cx, &ev(PointerKind::Down, Point::new(150.0, 120.0)));
let a = t.pointer(&cx, &ev(PointerKind::Up, Point::new(150.0, 120.0)));
let (c, v) = exec(&a[0]);
assert_eq!(c, "paint.setGradientGeom");
// The default fit's 100 pt horizontal vector, starting at the click.
assert_eq!((point(&v["start"]), point(&v["end"]), &v["ids"]), (Point::new(150.0, 120.0), Point::new(250.0, 120.0), &json!([id.0])));
// Outside the art a click does nothing.
t.pointer(&cx, &ev(PointerKind::Down, Point::new(400.0, 400.0)));
assert!(t.pointer(&cx, &ev(PointerKind::Up, Point::new(400.0, 400.0))).is_empty());
}
#[test]
fn double_click_on_a_stop_opens_its_popover() {
let (d, s) = graded(two());
let p = paint();
let cx = cx(&d, &s, &p);
let chip = Annotator::of(&cx).unwrap().stop_point(&cx, 1).unwrap();
let a = GradientTool::default().pointer(&cx, &ev(PointerKind::DoubleClick, chip));
assert_eq!(a[0], GradientTool::select_stop(1));
assert_eq!(a[1], Action::Dialog("gradientStop".into(), json!({"index": 1, "x": chip.x, "y": chip.y})));
}
#[test]
fn overlays_draw_a_chip_per_stop_marking_the_selected_one() {
let (d, s) = graded(two());
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.gradient_stop = Some(1);
let o = GradientTool::default().overlays(&cx);
let chips: Vec<bool> = o.iter().filter_map(|o| if let Overlay::Swatch { selected, .. } = o { Some(*selected) } else { None }).collect();
assert_eq!(chips, vec![false, true]);
assert_eq!(o.iter().filter(|o| matches!(o, Overlay::Path { .. })).count(), 1, "one midpoint diamond");
}
}

View File

@ -335,6 +335,8 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
Cursor::ZoomOut => C::ZoomOut,
Cursor::Eyedropper => C::Crosshair,
Cursor::NotAllowed => C::NotAllowed,
Cursor::AddStop => C::Copy,
Cursor::RemoveStop => C::NotAllowed,
}
}
@ -879,6 +881,17 @@ fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens) {
p.rect_filled(r, CornerRadius::same(3), t.measure_bg);
p.galley(sp + vec2(6.0, 4.0), galley, Color32::WHITE);
}
Overlay::Swatch { p: pt, color, selected } => {
// A white disc under the colour shows its opacity; a dark rim keeps it readable on
// any art, and the accent ring marks the selected stop.
let c = xf.to_screen(*pt);
p.circle_filled(c, 6.0, Color32::WHITE);
p.circle_filled(c, 5.0, Color32::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]));
p.circle_stroke(c, 6.0, Stroke::new(1.0, Color32::from_gray(32)));
if *selected {
p.circle_stroke(c, 8.0, Stroke::new(2.0, t.accent));
}
}
}
}
}

View File

@ -107,6 +107,17 @@ fn corner_radius(p: &Painter, o: Pos2) {
p.add(Shape::line(pts, Stroke::new(1.2, INK)));
}
/// The gradient annotator's stop cursors: the arrow with a plus (add a stop) or minus (delete it)
/// badge.
fn stop_badge(p: &Painter, o: Pos2, add: bool) {
arrow(p, o, false);
let b = o + vec2(13.0, 13.0);
line(p, b, b + vec2(6.0, 0.0));
if add {
line(p, b + vec2(3.0, -3.0), b + vec2(3.0, 3.0));
}
}
fn ibeam(p: &Painter, o: Pos2) {
line(p, o + vec2(0.0, -8.0), o + vec2(0.0, 8.0));
line(p, o + vec2(-3.0, -8.0), o + vec2(3.0, -8.0));
@ -137,6 +148,8 @@ pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool {
Cursor::PenClose => pen(painter, p, "o"),
Cursor::PenContinue => pen(painter, p, "/"),
Cursor::Text => ibeam(painter, p),
Cursor::AddStop => stop_badge(painter, p, true),
Cursor::RemoveStop => stop_badge(painter, p, false),
_ => return false,
}
let _ = pos2;

View File

@ -0,0 +1,251 @@
//! The gradient stop popover: double-clicking a stop on the Gradient tool's annotator edits it
//! next to its chip — its colour (the Color panel's controls, or a document swatch), opacity and
//! location. It edits the selected stop (`gradient.selectStop`) through `paint.editGradient`, and
//! closes on Escape or a click elsewhere.
//!
//! Fields: `index`, `x`, `y` (the chip, document coordinates) and `tab` (`color` or `swatches`).
//! Agents can set `color` (hex), `opacity` and `location` (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_geom::Point;
use super::{DialogResult, DialogSpec};
use crate::VectorcraftApp;
use crate::canvas::Xf;
use crate::panels::gradient::stops_json;
use crate::panels::{active_paint, live_run};
use crate::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)> {
let Paint::Gradient(g) = active_paint(app) else { return None };
let i = app.session.gradient_stop.filter(|i| *i < g.gradient.stops.len())?;
Some((g.gradient.stops, i))
}
/// Write `stops` to the gradient and select stop `i`.
fn apply(app: &mut VectorcraftApp, stops: &[GradientStop], i: usize, phase: Live) {
let params = json!({ "stops": stops_json(stops), "stroke": !app.session.fill_active });
live_run(app, "Gradient", "paint.editGradient", params, phase);
if phase == Live::Released && app.session.gradient_stop != Some(i) {
app.run("gradient.selectStop", json!({ "index": i })).ok();
}
}
/// `stops` with stop `i` given `color`.
fn recolor(mut stops: Vec<GradientStop>, i: usize, color: Color) -> Vec<GradientStop> {
stops[i].color = color;
stops
}
/// Solid colours of the document's swatches (groups included), with their names.
fn swatch_colors(app: &VectorcraftApp) -> Vec<(String, Color)> {
let Some(st) = app.session.active() else { return vec![] };
let d = &st.doc;
d.swatches.iter().chain(d.swatch_groups.iter().flat_map(|g| g.swatches.iter())).filter_map(|s| Some((s.name.clone(), s.paint.color()?))).collect()
}
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 {
app.ui.dialog = None;
return;
};
let t = Tokens::get(ctx);
let chip = Point::new(d.f64("x", 0.0), d.f64("y", 0.0));
let pos =
app.canvas_rect.zip(app.view().copied()).map_or(ctx.content_rect().center(), |(r, v)| Xf::new(r, &v).to_screen(chip) + vec2(14.0, 14.0));
let swatches = d.str("tab") == "swatches";
let mut tab = None;
let resp = egui::Area::new(egui::Id::new("gradient-stop-popover"))
.order(egui::Order::Foreground)
.fixed_pos(pos)
.constrain(true)
.show(ctx, |ui| {
egui::Frame::popup(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(10)).show(ui, |ui| {
ui.set_width(250.0);
ui.horizontal(|ui| {
for (icon, tip, on) in [("palette", "Color", !swatches), ("swatch-book", "Swatches", swatches)] {
if widgets::icon_button(ui, icon, tip, on, 24.0).clicked() {
tab = Some(if icon == "palette" { "color" } else { "swatches" });
}
}
});
ui.add_space(6.0);
if swatches {
swatch_grid(app, ui, &stops, i);
} else {
// The Color panel edits the selected stop 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;
apply(app, &v2, i, Live::Released);
}
widgets::dim_label(ui, "Location:");
if let Some(v) = widgets::plain_field(ui, "stop-pop-loc", s.offset as f64 * 100.0, "%", 1, 54.0) {
let (v2, ni) = move_stop(&stops, i, (v / 100.0) as f32);
apply(app, &v2, ni, Live::Released);
}
});
});
})
.response;
if let (Some(tab), Some(d)) = (tab, app.ui.dialog.as_mut()) {
d.fields.insert("tab".into(), json!(tab));
}
if resp.clicked_elsewhere() {
app.ui.dialog = None;
}
}
/// 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) {
let colors = swatch_colors(app);
if colors.is_empty() {
widgets::dim_label(ui, "The document has no colour swatches.");
return;
}
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());
if resp.on_hover_text(name).clicked() {
pick = Some(*c);
}
}
});
if let Some(c) = pick {
apply(app, &recolor(stops.to_vec(), i, c), i, Live::Released);
}
}
/// OK (agents): apply the `color`, `opacity` and `location` 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")?;
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);
}
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);
}
apply(app, &stops, i, Live::Released);
app.ui.dialog = None;
Ok(json!({ "index": i }))
}
#[cfg(test)]
mod tests {
use vectorcraft_engine::Session;
use vectorcraft_tools::{PointerEvent, PointerKind};
use super::*;
/// A rectangle with a three-stop gradient from (100, 150) to (200, 150), the Gradient tool
/// and stop 1 selected.
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap();
let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 0.5, "color": "#00ff00"}, {"offset": 1, "color": "#000000"}]);
app.run("paint.setFill", json!({"gradient": {"stops": stops, "start": [100, 150], "end": [200, 150]}})).unwrap();
app.select_tool("gradient");
app.run("gradient.selectStop", json!({"index": 1})).unwrap();
app
}
fn stops(app: &VectorcraftApp) -> Vec<GradientStop> {
selected(app).unwrap().0
}
/// One headless frame of the dialog layer and the shortcuts.
fn frame(app: &mut VectorcraftApp, events: Vec<egui::Event>) {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
crate::shortcuts::handle(app, ui.ctx());
super::super::show(app, ui.ctx());
});
out.textures_delta.clear();
}
fn key(k: egui::Key) -> egui::Event {
egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }
}
#[test]
fn double_clicking_a_stop_opens_the_popover_which_draws_both_tabs() {
let mut app = app();
let view = app.view_info();
let chip = Point::new(150.0, 150.0 + 10.0 / view.zoom);
crate::canvas::dispatch(&mut app, &PointerEvent { kind: PointerKind::DoubleClick, pos: chip, 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(), d.str("tab")), ("gradientStop", json!(1), "color".into()));
assert_eq!(app.session.gradient_stop, Some(1));
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(), "still open");
// Escape closes it.
frame(&mut app, vec![key(egui::Key::Escape)]);
assert!(app.ui.dialog.is_none());
}
#[test]
fn confirm_applies_colour_opacity_and_location() {
let mut app = app();
app.ui.dialog = Some(Dialog::new("gradientStop", json!({"index": 1, "x": 150, "y": 160, "color": "#ff0000", "opacity": 40, "location": 80})));
assert_eq!(super::super::confirm(&mut app).unwrap(), json!({"index": 1}));
let s = stops(&app);
assert_eq!((s[1].color.to_hex(), s[1].opacity, (s[1].offset * 100.0).round()), ("#ff0000".to_string(), 0.4, 80.0));
assert!(app.ui.dialog.is_none());
// Moving it past the last stop keeps it selected at its new index.
app.ui.dialog = Some(Dialog::new("gradientStop", json!({"location": 100})));
super::super::confirm(&mut app).unwrap();
assert_eq!((app.session.gradient_stop, stops(&app)[2].color.to_hex()), (Some(2), "#ff0000".to_string()));
app.ui.dialog = Some(Dialog::new("gradientStop", json!({"color": "red"})));
assert!(super::super::confirm(&mut app).is_err());
}
#[test]
fn the_popover_closes_when_its_stop_goes_away() {
let mut app = app();
app.ui.dialog = Some(Dialog::new("gradientStop", json!({"index": 1, "x": 150, "y": 160})));
app.run("gradient.selectStop", json!({"index": null})).unwrap();
frame(&mut app, vec![]);
assert!(app.ui.dialog.is_none());
}
#[test]
fn delete_removes_the_selected_stop_instead_of_the_object() {
let mut app = app();
frame(&mut app, vec![key(egui::Key::Delete)]);
assert_eq!(stops(&app).len(), 2);
frame(&mut app, vec![key(egui::Key::Backspace)]);
assert_eq!(stops(&app).len(), 2, "never below two stops");
assert_eq!(app.session.doc().unwrap().selection.objects.len(), 1, "the rectangle stays");
// The arrows nudge it.
let before = stops(&app)[1].offset;
frame(&mut app, vec![key(egui::Key::ArrowLeft)]);
assert!((stops(&app)[1].offset - (before - 0.01)).abs() < 1e-6);
}
}

View File

@ -14,6 +14,7 @@ mod document_setup;
mod effect;
mod export_for_screens;
mod form;
mod gradient_stop;
mod new_document;
mod path_ops;
mod recolor;
@ -127,6 +128,7 @@ registry! {
Shortcuts: ["shortcuts"] => DialogSpec::window(crate::shortcut_editor::show, |app, _| crate::shortcut_editor::confirm(app)),
Workspaces: ["newWorkspace", "manageWorkspaces"] => DialogSpec::window(crate::workspaces::show, |app, _| crate::workspaces::confirm(app)),
FindFont: ["findFont"] => DialogSpec::window(crate::find_font::show, |app, _| crate::find_font::confirm(app)),
GradientStop: ["gradientStop"] => gradient_stop::SPEC,
}
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).

View File

@ -1,5 +1,5 @@
//! Dialogs a tool click opens: shape sizes, transform values, graph size and data, flare options
//! and artboard options.
//! Dialogs a tool click opens: shape sizes, transform values, graph size and data, flare options,
//! artboard options and the gradient stop popover.
use serde_json::{Value, json};
@ -47,6 +47,8 @@ pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) {
}
Dialog::new(kind, base)
}
// Double-clicking a stop on the gradient annotator: its popover, next to the stop's chip.
"gradientStop" => Dialog::new(kind, json!({"index": p.get("index").cloned().unwrap_or(json!(0)), "x": x, "y": y, "tab": "color"})),
_ => return,
};
app.ui.dialog = Some(d);

View File

@ -159,11 +159,11 @@ enum Target {
Stop { paint: Paint, index: usize, color: Color },
}
fn target(app: &VectorcraftApp, ui: &Ui) -> Target {
fn target(app: &VectorcraftApp) -> Target {
let p = active_paint(app);
match &p {
Paint::Gradient(g) => {
let i = super::gradient::selected_stop(ui.ctx()).unwrap_or(0).min(g.gradient.stops.len().saturating_sub(1));
let i = app.session.gradient_stop.unwrap_or(0).min(g.gradient.stops.len().saturating_sub(1));
let color = g.gradient.stops.get(i).map(|s| s.color).unwrap_or(Color::BLACK);
Target::Stop { paint: p.clone(), index: i, color }
}
@ -196,7 +196,7 @@ fn apply(app: &mut VectorcraftApp, ui: &Ui, tgt: &Target, c: Color, phase: Live)
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let tgt = target(app, ui);
let tgt = target(app);
let color = match &tgt {
Target::Paint(c) => *c,
Target::Stop { color, .. } => Some(*color),
@ -376,7 +376,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
set_pstate(ui.ctx(), "color-hide-options", !hidden);
}
ui.separator();
let tgt = target(app, ui);
let tgt = target(app);
let color = match &tgt {
Target::Paint(c) => *c,
Target::Stop { color, .. } => Some(*color),

View File

@ -4,89 +4,30 @@
use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::{Value, json};
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_color::gradient::{MIN_STOPS, insert_stop, midpoint_from_pos, midpoint_pos, move_stop, remove_stop, set_midpoint};
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop, Paint};
use super::{active_paint, live_run, pstate, set_pstate};
use crate::theme::Tokens;
use crate::widgets::{self, Live, menu_item};
use crate::{VectorcraftApp, icons};
/// Minimum number of stops (Illustrator won't delete below two).
pub const MIN_STOPS: usize = 2;
/// Dragging a stop this far below the ramp removes it.
pub const REMOVE_DISTANCE: f32 = 28.0;
/// The Gradient panel's selected stop (shared with the Color panel).
pub fn selected_stop(ctx: &egui::Context) -> Option<usize> {
pstate::<Option<usize>>(ctx, "grad-stop")
}
fn select_stop(ctx: &egui::Context, i: Option<usize>) {
set_pstate(ctx, "grad-stop", i);
/// Select stop `i` (the session's selected stop, shared with the Gradient tool's annotator, the
/// Color panel and agents). Run after the edit that creates it.
fn select_stop(app: &mut VectorcraftApp, i: usize) {
app.run("gradient.selectStop", json!({ "index": i })).ok();
}
// ---------- pure stop math (unit-tested) ----------
/// Insert a stop at `offset`, coloured by sampling the gradient there. Returns the new stops and
/// the new stop's index.
pub fn insert_stop(g: &Gradient, offset: f32) -> (Vec<GradientStop>, usize) {
let offset = offset.clamp(0.0, 1.0);
let (color, opacity) = g.sample(offset);
let mut stops = g.stops.clone();
let idx = stops.iter().position(|s| s.offset > offset).unwrap_or(stops.len());
stops.insert(idx, GradientStop { offset, color, opacity, midpoint: 0.5 });
(stops, idx)
}
/// Remove stop `i`; `None` when that would leave fewer than [`MIN_STOPS`].
pub fn remove_stop(stops: &[GradientStop], i: usize) -> Option<Vec<GradientStop>> {
if stops.len() <= MIN_STOPS || i >= stops.len() {
return None;
}
let mut v = stops.to_vec();
v.remove(i);
Some(v)
}
/// Move stop `i` to `offset`, keeping the list sorted. Returns the stops and the stop's new index.
pub fn move_stop(stops: &[GradientStop], i: usize, offset: f32) -> (Vec<GradientStop>, usize) {
let mut v = stops.to_vec();
if i >= v.len() {
return (v, i);
}
let mut s = v.remove(i);
s.offset = offset.clamp(0.0, 1.0);
let idx = v.iter().position(|o| o.offset > s.offset).unwrap_or(v.len());
v.insert(idx, s);
(v, idx)
}
/// Set the midpoint between stop `i` and `i + 1` (clamped to Illustrator's 13–87 %).
pub fn set_midpoint(stops: &[GradientStop], i: usize, m: f32) -> Vec<GradientStop> {
let mut v = stops.to_vec();
if let Some(s) = v.get_mut(i) {
s.midpoint = m.clamp(0.13, 0.87);
}
v
}
// ---------- pure ramp math (unit-tested) ----------
/// Offset (0..1) of an x position on a ramp spanning `left..left + width`.
pub fn x_to_offset(x: f32, left: f32, width: f32) -> f32 {
((x - left) / width.max(1.0)).clamp(0.0, 1.0)
}
/// Absolute position (0..1) of the midpoint diamond after stop `i`.
pub fn midpoint_pos(stops: &[GradientStop], i: usize) -> Option<f32> {
let (a, b) = (stops.get(i)?, stops.get(i + 1)?);
Some(a.offset + (b.offset - a.offset) * a.midpoint)
}
/// Inverse of [`midpoint_pos`]: the relative midpoint for an absolute position.
pub fn midpoint_from_pos(stops: &[GradientStop], i: usize, pos: f32) -> Option<f32> {
let (a, b) = (stops.get(i)?, stops.get(i + 1)?);
let span = (b.offset - a.offset).max(1e-6);
Some(((pos - a.offset) / span).clamp(0.13, 0.87))
}
/// Stops as `paint.editGradient` JSON.
pub use vectorcraft_tools::params::stops_json;
@ -184,7 +125,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
ramp(app, ui, &g.gradient, is_grad);
ui.add_space(4.0);
// Stop fields.
let sel = selected_stop(ui.ctx()).filter(|i| *i < g.gradient.stops.len());
let sel = app.session.gradient_stop.filter(|i| *i < g.gradient.stops.len());
ui.horizontal(|ui| {
let enabled = is_grad && sel.is_some();
let stop = sel.and_then(|i| g.gradient.stops.get(i)).copied();
@ -205,16 +146,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
&& let Some(i) = sel
{
let (stops, ni) = move_stop(&g.gradient.stops, i, (v / 100.0) as f32);
select_stop(ui.ctx(), Some(ni));
edit(app, json!({"stops": stops_json(&stops)}), Live::Released);
select_stop(app, ni);
}
let can_del = enabled && g.gradient.stops.len() > MIN_STOPS;
if widgets::icon_button_enabled(ui, "trash-2", "Delete Stop", false, can_del, 22.0).clicked()
&& let Some(i) = sel
&& let Some(stops) = remove_stop(&g.gradient.stops, i)
{
select_stop(ui.ctx(), Some(i.min(stops.len() - 1)));
edit(app, json!({"stops": stops_json(&stops)}), Live::Released);
select_stop(app, i.min(stops.len() - 1));
}
});
if let Some(i) = sel
@ -269,7 +210,7 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
}
ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside);
let x_of = |o: f32| bar.left() + o * bar.width();
let sel = selected_stop(ui.ctx());
let sel = app.session.gradient_stop;
let mut drag: Drag = pstate(ui.ctx(), "grad-drag");
let stops = &g.stops;
// While dragging, edits are computed against the stops as they were when the drag began (the
@ -277,6 +218,8 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
let origin: Vec<GradientStop> = if drag == Drag::None { stops.clone() } else { pstate::<Vec<GradientStop>>(ui.ctx(), "grad-origin") };
let origin = if origin.len() == stops.len() { origin } else { stops.clone() };
let mut changed: Option<(Vec<GradientStop>, Live)> = None;
// The stop to select once `changed` is applied.
let mut select: Option<usize> = None;
// Midpoint diamonds.
for i in 0..stops.len().saturating_sub(1) {
let Some(p) = midpoint_pos(stops, i) else { continue };
@ -325,7 +268,7 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
continue;
}
if resp.clicked() || resp.drag_started() {
select_stop(ui.ctx(), Some(i));
select = Some(i);
}
if resp.drag_started() {
drag = Drag::Stop(i);
@ -340,12 +283,12 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
drag = Drag::None;
if removing {
if let Some(v) = remove_stop(&origin, i) {
select_stop(ui.ctx(), Some(i.min(v.len() - 1)));
select = Some(i.min(v.len() - 1));
changed = Some((v, Live::Released));
}
} else {
let (v, ni) = move_stop(&origin, i, x_to_offset(pp.x, bar.left(), bar.width()));
select_stop(ui.ctx(), Some(ni));
select = Some(ni);
changed = Some((v, Live::Released));
}
} else if removing {
@ -369,7 +312,7 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
{
if is_grad {
let (v, i) = insert_stop(g, x_to_offset(p.x, bar.left(), bar.width()));
select_stop(ui.ctx(), Some(i));
select = Some(i);
changed = Some((v, Live::Released));
} else {
edit(app, json!({}), Live::Released);
@ -383,6 +326,9 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
if let Some((v, phase)) = changed {
edit(app, json!({"stops": stops_json(&v)}), phase);
}
if let Some(i) = select {
select_stop(app, i);
}
}
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
@ -400,72 +346,19 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let d = Gradient::default();
edit(app, json!({"stops": stops_json(&d.stops)}), Live::Released);
}
let _ = Color::BLACK;
}
#[cfg(test)]
mod tests {
use super::*;
fn g3() -> Gradient {
let mut g = Gradient::default();
g.stops.insert(1, GradientStop { offset: 0.5, color: Color::rgb(1.0, 0.0, 0.0), opacity: 1.0, midpoint: 0.5 });
g
}
#[test]
fn insert_samples_color_and_sorts() {
let g = Gradient::default();
let (stops, i) = insert_stop(&g, 0.25);
assert_eq!(stops.len(), 3);
assert_eq!(i, 1);
let r = stops[1].color.to_rgb()[0];
assert!((r - 0.75).abs() < 1e-4, "sampled {r}");
let (stops, i) = insert_stop(&g, 2.0);
assert_eq!(i, 2);
assert_eq!(stops[2].offset, 1.0);
}
#[test]
fn remove_keeps_two() {
let g = g3();
let v = remove_stop(&g.stops, 1).unwrap();
assert_eq!(v.len(), 2);
assert!(remove_stop(&v, 0).is_none());
assert!(remove_stop(&g.stops, 9).is_none());
}
#[test]
fn move_reorders_and_tracks_index() {
let g = g3();
let (v, i) = move_stop(&g.stops, 0, 0.8);
assert_eq!(i, 1);
assert_eq!(v[1].color.to_hex(), "#ffffff");
assert!(v.windows(2).all(|w| w[0].offset <= w[1].offset));
let (v, i) = move_stop(&g.stops, 1, -3.0);
// Clamped to 0 and placed after the existing stop at 0.
assert_eq!((i, v[i].offset, v[i].color.to_hex()), (1, 0.0, "#ff0000".to_string()));
}
#[test]
fn midpoint_math() {
let g = g3();
assert_eq!(midpoint_pos(&g.stops, 0), Some(0.25));
assert_eq!(midpoint_pos(&g.stops, 2), None);
let m = midpoint_from_pos(&g.stops, 1, 0.6).unwrap();
assert!((m - 0.2).abs() < 1e-5);
assert_eq!(midpoint_from_pos(&g.stops, 1, 0.51).unwrap(), 0.13);
let v = set_midpoint(&g.stops, 0, 0.99);
assert_eq!(v[0].midpoint, 0.87);
}
#[test]
fn offsets_and_json() {
assert_eq!(x_to_offset(50.0, 0.0, 200.0), 0.25);
assert_eq!(x_to_offset(-5.0, 0.0, 200.0), 0.0);
assert_eq!(x_to_offset(500.0, 0.0, 200.0), 1.0);
let j = stops_json(&g3().stops);
assert_eq!(j.as_array().unwrap().len(), 3);
let j = stops_json(&Gradient::default().stops);
assert_eq!(j.as_array().unwrap().len(), 2);
assert_eq!(j[1]["midpoint"], json!(0.5));
}
}

View File

@ -144,6 +144,17 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
}
return;
}
// Keys the active tool claims ahead of their shortcuts (the Gradient tool's selected stop:
// Delete/Backspace remove it, ←/→ nudge it).
let m = ctx.input(|i| i.modifiers);
for (k, tk) in
[(Key::Delete, ToolKey::Delete), (Key::Backspace, ToolKey::Backspace), (Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right)]
{
if ctx.input(|i| i.key_pressed(k)) && app.session.tool_claims_key(tk, view) && ctx.input_mut(|i| i.consume_key(m, k)) {
let _ = app.session.tool_key(tk, crate::canvas::mods(m, false), view);
return;
}
}
// Command shortcuts.
let mut fire: Option<&'static str> = None;
for (sc, id) in all_shortcuts() {

View File

@ -19,3 +19,4 @@
| `ui.render` | `{path?, scale?}` | render the artboard headlessly (PNG) |
| `ui.resize` / `ui.focus` | | |
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`). `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit |
| `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on |

View File

@ -106,6 +106,18 @@ Drive a tool like a mouse:
{"kind":"down","x":100,"y":100},{"kind":"drag","x":150,"y":140},{"kind":"up","x":200,"y":180}]}}
```
Edit a gradient with the Gradient tool's annotator (the bar runs along the vector; stop chips sit 10 px under
it, midpoint diamonds 7 px above): a click on the bar adds a stop, dragging a chip moves it (drag it off the bar to
delete it, hold Alt to copy it), dragging the end handle changes the vector. The selected stop
(`gradient.selectStop`) takes Delete and ←/→:
```json
{"name":"run_command","arguments":{"command":"paint.setFill","params":{"gradient":{"start":[100,150],"end":[200,150]}}}}
{"name":"pointer_gesture","arguments":{"tool":"gradient","events":[{"kind":"down","x":130,"y":150},{"kind":"up","x":130,"y":150}]}}
{"name":"pointer_gesture","arguments":{"events":[{"kind":"down","x":130,"y":160},{"kind":"drag","x":160,"y":160},{"kind":"up","x":160,"y":160}]}}
{"name":"press_key","arguments":{"key":"Delete"}}
```
Raw protocol (for debugging):
```sh