mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 03:06:57 -06:00
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:
co-authored by
Claude Opus 5.5
parent
aa1b592935
commit
3786cfaf36
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user