Fix #494: Pen + Cmd edits the path being drawn; Pen + Alt reshapes segments; handle drags fixed and fast
Reported: with the Pen there was no way to edit curves in place as Ctrl does in Illustrator on Windows (Cmd on the Mac), segments couldn't be grabbed, and the curve-editing tools felt slow and sometimes didn't take a handle. - Cmd/Ctrl held at a press with any tool but a selection tool now borrows the selection tool in the engine (Session::pointer), not in the canvas: the selection tool chosen last, or Direct Selection for the Pen, Curvature and anchor tools until one is chosen. The tool lent to isn't deactivated, so the Pen goes on drawing its path after the release while that path stays selected; it no longer draws on another open path selected meanwhile. Agents reach it with pointer_gesture mods.cmd. With Cmd held the pointer is the borrowed tool's. Choosing a tool or switching documents mid-gesture gives the tool back first. - Pen + Alt over a segment of a selected path reshapes it, as the Anchor Point tool does. - The handles shown are the handles that drag (one helper for Direct Selection, the Anchor Point tool and Pen + Alt): Direct Selection now drags the handles of a path selected as a whole (as the Pen leaves it, so Cmd + drag of a handle while drawing works), the Anchor Point tool no longer grabs handles that Show handles when multiple anchors are selected hides, the nearest handle wins, and a handle shorter than the tolerance leaves its anchor to be picked. The anchor tools pick within Selection & Anchor Display > Tolerance instead of a fixed 4 px. - path.reshapeSegment keeps a smooth anchor at either end smooth (its other handle turns with the moved one) instead of kinking it. - A dragged handle gathered its smart guide targets from the whole document on every pointer move (21 ms per move on 20,000 paths): HandleSnap now gathers them once per drag (1 ms). vectorcraft-cli perf measures handle and segment drags on a 5,000-anchor path. - catalog::is_selection_tool and catalog::edits_anchors replace the UI's own lists. - Tests in tools, engine and MCP (headless pointer_gesture); docs/mcp.md, control-protocol.md, ROADMAP. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d12d5195da
commit
aefd961bbd
File diff suppressed because one or more lines are too long
|
|
@ -70,6 +70,49 @@ fn load_average() -> Option<f64> {
|
|||
text.split_whitespace().map(|t| t.trim_matches(|c| c == '{' || c == '}')).find_map(|t| t.parse().ok())
|
||||
}
|
||||
|
||||
/// Milliseconds per pointer move of a Direct Selection drag of a handle, then of a segment, of a
|
||||
/// 5,000-anchor wave of smooth anchors added on top of the synthetic `n`-path document.
|
||||
fn edit_drags(n: usize) -> Result<(f64, f64), String> {
|
||||
use vectorcraft_engine::tools::{Mods, PointerEvent, PointerKind};
|
||||
use vectorcraft_geom::{Anchor, PathData, SubPath, Vec2};
|
||||
let mut doc = synthetic(n)?;
|
||||
let l = doc.layers.first().map(|l| l.id);
|
||||
let id = doc.alloc_id();
|
||||
// Anchors 2 pt apart, up and down by 20 pt, their handles 0.6 pt long.
|
||||
let anchor = |i: usize| {
|
||||
let p = Point::new(i as f64 * 2.0, if i.is_multiple_of(2) { 500.0 } else { 520.0 });
|
||||
Anchor::smooth(p, p + Vec2::new(0.6, 0.0))
|
||||
};
|
||||
let path = PathData::single(SubPath::new((0..5000).map(anchor).collect(), false));
|
||||
doc.insert(l, usize::MAX, Node::path(id, path, Appearance::default_art())).map_err(|e| e.to_string())?;
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.add_document(doc, None);
|
||||
let view = vectorcraft_engine::ViewInfo { zoom: 8.0, ..Default::default() };
|
||||
let err = |e: vectorcraft_engine::EngineError| e.to_string();
|
||||
let mut drag = |down: Point| -> Result<f64, String> {
|
||||
let ev = |kind, y: f64| PointerEvent { kind, pos: down + Vec2::new(0.0, y), mods: Mods::default(), pressure: 1.0 };
|
||||
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 400]]})).map_err(err)?;
|
||||
s.select_tool("directSelection", view).map_err(err)?;
|
||||
let undo = s.doc().map_err(err)?.history.undo.len();
|
||||
s.pointer(&ev(PointerKind::Down, 0.0), view).map_err(err)?;
|
||||
let mut y = 0.0;
|
||||
let ms = median_ms(9, || {
|
||||
y += 3.0;
|
||||
// A failed move shows as the missing undo step below.
|
||||
let _ = s.pointer(&ev(PointerKind::Drag, y), view);
|
||||
});
|
||||
s.pointer(&ev(PointerKind::Up, y), view).map_err(err)?;
|
||||
if s.doc().map_err(err)?.history.undo.len() != undo + 1 {
|
||||
return Err(format!("the drag from {down:?} made no undo step"));
|
||||
}
|
||||
Ok(ms)
|
||||
};
|
||||
// Anchor 400 is at (800, 500): its outgoing handle's end, then its segment to anchor 401.
|
||||
let handle = drag(Point::new(800.6, 500.0))?;
|
||||
let segment = drag(Point::new(801.0, 510.0))?;
|
||||
Ok((handle, segment))
|
||||
}
|
||||
|
||||
pub fn run(args: &[String]) -> Result<(), String> {
|
||||
let mut n = 50_000usize;
|
||||
let mut it = args.iter();
|
||||
|
|
@ -145,6 +188,15 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
|||
}
|
||||
});
|
||||
rows.push(("Pathfinder Unite, 1,000 paths", unite, 150.0));
|
||||
|
||||
// Path editing: one pointer move of a Direct Selection drag (the tool event and the preview it
|
||||
// applies), on a 5,000-anchor path among the synthetic paths.
|
||||
let (handle, segment) = edit_drags(n).unwrap_or_else(|e| {
|
||||
failed = Some(e);
|
||||
(0.0, 0.0)
|
||||
});
|
||||
rows.push(("handle drag, 5,000-anchor path (per move)", handle, 8.0));
|
||||
rows.push(("segment drag, 5,000-anchor path (per move)", segment, 8.0));
|
||||
if let Some(e) = failed {
|
||||
return Err(e);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -701,13 +701,16 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
return Err(EngineError::Other("no such segment".into()));
|
||||
}
|
||||
let n = sp.anchors.len();
|
||||
// Moving both inner control points by v moves B(t) by 3t(1-t)·v.
|
||||
// Moving both inner control points by v moves B(t) by 3t(1-t)·v. A smooth anchor stays
|
||||
// smooth: its other handle turns with the moved one.
|
||||
let v = dv / (3.0 * t * (1.0 - t));
|
||||
let (i0, i1) = (seg % n, (seg + 1) % n);
|
||||
let a = sp.anchors[i0];
|
||||
sp.anchors[i0] = Anchor::with_handles(a.p, a.h_in, a.h_out + v);
|
||||
let b = sp.anchors[i1];
|
||||
sp.anchors[i1] = Anchor::with_handles(b.p, b.h_in + v, b.h_out);
|
||||
if let Some(a) = sp.anchors.get_mut(i0) {
|
||||
a.set_handle(true, a.h_out + v, false);
|
||||
}
|
||||
if let Some(b) = sp.anchors.get_mut(i1) {
|
||||
b.set_handle(false, b.h_in + v, false);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
|
|
|
|||
|
|
@ -721,6 +721,12 @@ pub struct Session {
|
|||
/// Executed commands (for actions and debugging).
|
||||
pub journal: Vec<(String, Value)>,
|
||||
pub(crate) tool: Box<dyn Tool>,
|
||||
/// While Cmd lends `tool` (a selection tool) for a drag: the tool it was lent to, which comes
|
||||
/// back as it was at the release ([`Session::pointer`]).
|
||||
pub(crate) lender: Option<Box<dyn Tool>>,
|
||||
/// The selection tool chosen last (Selection, Direct Selection or Group Selection): the one Cmd
|
||||
/// lends the other tools. None until one is chosen.
|
||||
pub(crate) last_selection_tool: Option<&'static str>,
|
||||
pub(crate) last_view: ViewInfo,
|
||||
depth: u32,
|
||||
/// Set when the active tool panicked (see [`guard`]); reported by the next tool event.
|
||||
|
|
@ -800,6 +806,8 @@ impl Session {
|
|||
clipboard: Clipboard::default(),
|
||||
journal: vec![],
|
||||
tool: vectorcraft_tools::create("selection"),
|
||||
lender: None,
|
||||
last_selection_tool: None,
|
||||
last_view: ViewInfo::default(),
|
||||
depth: 0,
|
||||
tool_panic: None,
|
||||
|
|
@ -858,6 +866,8 @@ impl Session {
|
|||
return;
|
||||
}
|
||||
let view = self.last_view;
|
||||
// The switch goes on whatever the lent tool's last actions did, as for the deactivation.
|
||||
let _ = self.give_back_tool(view);
|
||||
let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx));
|
||||
let _ = self.apply_actions(acts);
|
||||
// A batch leaves its interaction open in the document it leaves, to keep or roll back with
|
||||
|
|
|
|||
|
|
@ -57,6 +57,20 @@ fn click(s: &mut Session, tool: &str, x: f64, y: f64) -> Vec<UiRequest> {
|
|||
drag(s, tool, &[(x, y)], Mods::default())
|
||||
}
|
||||
|
||||
/// A gesture of the active tool, `m` held throughout: press at the first point, drag through the
|
||||
/// others, release at the last. The undo steps it made.
|
||||
fn gesture(s: &mut Session, pts: &[(f64, f64)], m: Mods) -> usize {
|
||||
let v = view();
|
||||
let undo = undo_steps(s);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1).with_mods(m), v).unwrap();
|
||||
for &(x, y) in &pts[1..] {
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, x, y).with_mods(m), v).unwrap();
|
||||
}
|
||||
let l = pts[pts.len() - 1];
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, l.0, l.1).with_mods(m), v).unwrap();
|
||||
undo_steps(s) - undo
|
||||
}
|
||||
|
||||
/// A wavy stroke sampled every 2 pt.
|
||||
fn wave(x0: f64, x1: f64, y: f64) -> Vec<(f64, f64)> {
|
||||
let n = ((x1 - x0) / 2.0) as usize;
|
||||
|
|
@ -622,16 +636,6 @@ fn pen_with_alt_moves_one_handle_and_converts_anchors() {
|
|||
let anchors = json!([{"x": 100, "y": 200}, {"x": 200, "y": 100, "in": [150, 100], "out": [250, 100]}, {"x": 300, "y": 200}]);
|
||||
let id = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap());
|
||||
s.select_tool("pen", v).unwrap();
|
||||
let gesture = |s: &mut Session, pts: &[(f64, f64)], m: Mods| {
|
||||
let undo = undo_steps(s);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1).with_mods(m), v).unwrap();
|
||||
for &(x, y) in &pts[1..] {
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, x, y).with_mods(m), v).unwrap();
|
||||
}
|
||||
let l = pts[pts.len() - 1];
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, l.0, l.1).with_mods(m), v).unwrap();
|
||||
undo_steps(s) - undo
|
||||
};
|
||||
// Alt-drag the out handle: it moves alone, the in handle stays.
|
||||
assert_eq!(gesture(&mut s, &[(250.0, 100.0), (260.0, 130.0), (275.0, 165.0)], alt), 1);
|
||||
assert!(!s.in_interaction());
|
||||
|
|
@ -659,3 +663,74 @@ fn pen_with_alt_moves_one_handle_and_converts_anchors() {
|
|||
assert_eq!(path(&s, new).subpaths[0].anchors.len(), 3);
|
||||
assert_eq!(paths(&s).len(), 2);
|
||||
}
|
||||
|
||||
/// The anchor's handles point opposite ways.
|
||||
fn smooth(a: vectorcraft_geom::Anchor) -> bool {
|
||||
let (i, o) = (a.h_in - a.p, a.h_out - a.p);
|
||||
(i.x * o.y - i.y * o.x).abs() < 1e-6 && i.x * o.x + i.y * o.y < 0.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pen_with_cmd_borrows_direct_selection_and_goes_on_drawing() {
|
||||
// #494: Cmd (Ctrl) held with the Pen drags with the selection tool used last (Direct Selection
|
||||
// until one is chosen): handles, segments and anchors of the path being drawn; released, the
|
||||
// Pen goes on drawing that path. Through pointer events, so agents reach it too.
|
||||
let (none, cmd) = (Mods::default(), Mods { cmd: true, ..Mods::default() });
|
||||
let v = view();
|
||||
let mut s = session();
|
||||
s.select_tool("pen", v).unwrap();
|
||||
gesture(&mut s, &[(100.0, 300.0)], none);
|
||||
gesture(&mut s, &[(200.0, 300.0), (250.0, 300.0)], none);
|
||||
let id = s.doc().unwrap().selection.objects[0];
|
||||
assert_eq!(s.cursor(Point::new(250.0, 300.0), none, v), vectorcraft_tools::Cursor::Pen);
|
||||
assert_eq!(s.cursor(Point::new(250.0, 300.0), cmd, v), vectorcraft_tools::Cursor::ArrowHollow, "Direct Selection's");
|
||||
// The new anchor's outgoing handle: it moves, its incoming one turning with it.
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 250.0, 300.0).with_mods(cmd), v).unwrap();
|
||||
assert_eq!(s.tool_id(), "directSelection");
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 250.0, 260.0).with_mods(cmd), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 250.0, 260.0).with_mods(cmd), v).unwrap();
|
||||
assert_eq!(s.tool_id(), "pen");
|
||||
let a = path(&s, id).subpaths[0].anchors[1];
|
||||
assert!(a.h_out == Point::new(250.0, 260.0) && smooth(a), "{a:?}");
|
||||
// The segment between the two anchors bends, the smooth anchor staying smooth.
|
||||
let before = path(&s, id).subpaths[0].anchors[1].h_in;
|
||||
gesture(&mut s, &[(120.0, 300.0), (120.0, 270.0)], cmd);
|
||||
let a = path(&s, id).subpaths[0].anchors[1];
|
||||
assert!(a.h_in != before && smooth(a), "{a:?}");
|
||||
assert_eq!(s.tool_id(), "pen");
|
||||
// A plain click goes on drawing the same path.
|
||||
gesture(&mut s, &[(300.0, 350.0)], none);
|
||||
assert_eq!((paths(&s).len(), path(&s, id).subpaths[0].anchors.len()), (1, 3));
|
||||
// A Cmd-click away from the art deselects: the path is done, the next click starts another.
|
||||
gesture(&mut s, &[(600.0, 100.0)], cmd);
|
||||
gesture(&mut s, &[(400.0, 500.0)], none);
|
||||
assert_eq!(paths(&s).len(), 2);
|
||||
assert_eq!(path(&s, id).subpaths[0].anchors.len(), 3);
|
||||
// With the Selection tool chosen last, Cmd lends it.
|
||||
s.select_tool("selection", v).unwrap();
|
||||
s.select_tool("pen", v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 600.0, 100.0).with_mods(cmd), v).unwrap();
|
||||
assert_eq!(s.tool_id(), "selection");
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 600.0, 100.0).with_mods(cmd), v).unwrap();
|
||||
assert_eq!(s.tool_id(), "pen");
|
||||
// Choosing a tool mid-gesture keeps it: the Pen doesn't come back over it.
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 600.0, 100.0).with_mods(cmd), v).unwrap();
|
||||
s.select_tool("rectangle", v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 600.0, 100.0), v).unwrap();
|
||||
assert_eq!(s.tool_id(), "rectangle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshaping_a_segment_keeps_smooth_anchors_smooth() {
|
||||
// #494: the anchors at the ends of a dragged segment keep their kind: a smooth one's other
|
||||
// handle turns with the moved one (at its length), a corner's stays.
|
||||
let mut s = session();
|
||||
let anchors = json!([{"x": 100, "y": 300}, {"x": 200, "y": 300, "in": [160, 300], "out": [240, 300]}, {"x": 300, "y": 300}]);
|
||||
let id = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("path.reshapeSegment", &json!({"id": id.0, "segment": 0, "t": 0.5, "dx": 0, "dy": -30})).unwrap();
|
||||
let sp = &path(&s, id).subpaths[0];
|
||||
let a = sp.anchors[1];
|
||||
assert!(near(a.h_in, Point::new(160.0, 260.0)) && smooth(a), "{a:?}");
|
||||
assert!((a.h_out.distance(a.p) - 40.0).abs() < 1e-9, "its length kept");
|
||||
assert!(near(sp.anchors[0].h_out, Point::new(100.0, 260.0)) && !sp.anchors[0].has_in(), "the corner end: {:?}", sp.anchors[0]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ impl Session {
|
|||
/// Switch to tool `id` without choosing it afresh (back from a temporary tool: a hidden
|
||||
/// perspective grid stays hidden), finishing any pending tool work first.
|
||||
pub fn switch_tool(&mut self, id: &str, view: ViewInfo) -> Result<()> {
|
||||
self.give_back_tool(view)?;
|
||||
if self.tool.id() == id {
|
||||
return Ok(());
|
||||
}
|
||||
|
|
@ -89,9 +90,47 @@ impl Session {
|
|||
self.apply_actions(acts)?;
|
||||
self.keep_tool_settings();
|
||||
self.tool = self.make_tool(id);
|
||||
if vectorcraft_tools::catalog::is_selection_tool(id) {
|
||||
self.last_selection_tool = Some(self.tool.id());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The selection tool Cmd lends tool `id` for a drag: the one chosen last; until one is,
|
||||
/// Direct Selection to the tools that edit anchors (the Pen) and Selection to the others.
|
||||
/// None for the selection tools themselves and the view tools.
|
||||
fn lent_tool(&self, id: &str) -> Option<&'static str> {
|
||||
use vectorcraft_tools::catalog::{edits_anchors, is_selection_tool};
|
||||
if is_selection_tool(id) || matches!(id, "hand" | "zoom" | "rotateView") {
|
||||
return None;
|
||||
}
|
||||
Some(self.last_selection_tool.unwrap_or(if edits_anchors(id) { "directSelection" } else { "selection" }))
|
||||
}
|
||||
|
||||
/// A press with Cmd held (Ctrl elsewhere) and any other tool than a selection tool: the
|
||||
/// selection tool [`Self::lent_tool`] takes the gesture, pressing without Cmd (a plain click,
|
||||
/// not Select Behind). The tool lent to stays as it was, unaware, until the release.
|
||||
fn lend_selection_tool(&mut self, ev: &PointerEvent) -> PointerEvent {
|
||||
if ev.kind != PointerKind::Down || !ev.mods.cmd || self.lender.is_some() {
|
||||
return *ev;
|
||||
}
|
||||
let Some(id) = self.lent_tool(self.tool.id()) else { return *ev };
|
||||
let lent = self.make_tool(id);
|
||||
self.lender = Some(std::mem::replace(&mut self.tool, lent));
|
||||
PointerEvent { mods: Mods { cmd: false, ..ev.mods }, ..*ev }
|
||||
}
|
||||
|
||||
/// The lent selection tool's gesture is over: the tool it was lent to is back as it was (the
|
||||
/// Pen goes on drawing its path).
|
||||
pub(crate) fn give_back_tool(&mut self, view: ViewInfo) -> Result<()> {
|
||||
let Some(lender) = self.lender.take() else { return Ok(()) };
|
||||
let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx));
|
||||
let r = self.apply_actions(acts);
|
||||
self.keep_tool_settings();
|
||||
self.tool = lender;
|
||||
r.map(drop)
|
||||
}
|
||||
|
||||
/// A fresh `id` tool with the options it keeps ([`settings`]): its last values, from this
|
||||
/// session or a saved one.
|
||||
pub(crate) fn make_tool(&self, id: &str) -> Box<dyn Tool> {
|
||||
|
|
@ -212,13 +251,19 @@ impl Session {
|
|||
if let Some(r) = self.plane_widget_pointer(ev, view) {
|
||||
return r;
|
||||
}
|
||||
let ev = &self.lend_selection_tool(ev);
|
||||
let acts = self.with_tool_cx(view, |t, cx| t.pointer(cx, ev));
|
||||
self.take_tool_panic()?;
|
||||
// A gesture may change options (Alt-drag sizes a Liquify brush): keep them.
|
||||
let r = self.take_tool_panic().and_then(|()| {
|
||||
// A gesture may change options (Alt-drag sizes a Liquify brush): keep them.
|
||||
if ev.kind == PointerKind::Up {
|
||||
self.keep_tool_settings();
|
||||
}
|
||||
self.apply_actions(acts)
|
||||
});
|
||||
if ev.kind == PointerKind::Up {
|
||||
self.keep_tool_settings();
|
||||
self.give_back_tool(view)?;
|
||||
}
|
||||
self.apply_actions(acts)
|
||||
r
|
||||
}
|
||||
|
||||
/// A guide dragged out of a ruler, whatever the tool: a vertical one out of the left ruler, a
|
||||
|
|
@ -325,9 +370,20 @@ impl Session {
|
|||
}
|
||||
|
||||
/// The pointer the active tool shows at `p` (General › Use Precise Cursors makes the drawing
|
||||
/// tools' a crosshair, [`Cursor::precise`]).
|
||||
/// tools' a crosshair, [`Cursor::precise`]). With Cmd held, the pointer of the selection tool a
|
||||
/// press would borrow ([`Self::lend_selection_tool`]).
|
||||
pub fn cursor(&mut self, p: Point, mods: Mods, view: ViewInfo) -> Cursor {
|
||||
let c = self.with_tool_cx(view, |t, cx| t.cursor(cx, p, mods));
|
||||
let lent = if mods.cmd && self.lender.is_none() { self.lent_tool(self.tool.id()) } else { None };
|
||||
let c = match lent {
|
||||
Some(id) => {
|
||||
let lent = self.make_tool(id);
|
||||
let own = std::mem::replace(&mut self.tool, lent);
|
||||
let c = self.with_tool_cx(view, |t, cx| t.cursor(cx, p, Mods { cmd: false, ..mods }));
|
||||
self.tool = own;
|
||||
c
|
||||
}
|
||||
None => self.with_tool_cx(view, |t, cx| t.cursor(cx, p, mods)),
|
||||
};
|
||||
if self.prefs.use_precise_cursors { c.precise() } else { c }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ mod tests_links;
|
|||
#[cfg(test)]
|
||||
mod tests_liquify;
|
||||
#[cfg(test)]
|
||||
mod tests_pen;
|
||||
#[cfg(test)]
|
||||
mod tests_persp;
|
||||
#[cfg(test)]
|
||||
mod tests_place;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
//! The Pen over MCP, headless: `pointer_gesture` with the modifiers held as on the desktop.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Backend, Headless, call_tool};
|
||||
|
||||
fn text(r: &crate::ToolResult) -> Value {
|
||||
assert!(!r.is_error, "{r:?}");
|
||||
serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// A press at the first point, drags through the others, the release at the last.
|
||||
fn gesture(pts: &[(f64, f64)]) -> Value {
|
||||
let (first, last) = (pts[0], pts[pts.len() - 1]);
|
||||
let mut events = vec![json!({"kind": "down", "x": first.0, "y": first.1})];
|
||||
events.extend(pts[1..].iter().map(|(x, y)| json!({"kind": "drag", "x": x, "y": y})));
|
||||
events.push(json!({"kind": "up", "x": last.0, "y": last.1}));
|
||||
json!({ "events": events })
|
||||
}
|
||||
|
||||
/// The anchors of each path, in paint order.
|
||||
fn paths(h: &Headless) -> Vec<Vec<vectorcraft_geom::Anchor>> {
|
||||
let mut v = vec![];
|
||||
h.session.doc().unwrap().doc.walk(|n| {
|
||||
if let Some(p) = n.path_data() {
|
||||
v.push(p.subpaths.iter().flat_map(|s| s.anchors.clone()).collect());
|
||||
}
|
||||
});
|
||||
v
|
||||
}
|
||||
|
||||
/// #494: Cmd held at the press borrows Direct Selection from the Pen; the path goes on afterwards.
|
||||
#[test]
|
||||
fn cmd_with_the_pen_edits_the_path_being_drawn() {
|
||||
let mut h = Headless::new();
|
||||
h.call("engine.execute", json!({"command": "file.new", "params": {"width": 800, "height": 600}})).unwrap();
|
||||
text(&call_tool(&mut h, "select_tool", &json!({"tool": "pen"})));
|
||||
text(&call_tool(&mut h, "pointer_gesture", &gesture(&[(100.0, 300.0)])));
|
||||
text(&call_tool(&mut h, "pointer_gesture", &gesture(&[(200.0, 300.0), (250.0, 300.0)])));
|
||||
let mut drag = gesture(&[(250.0, 300.0), (250.0, 280.0), (250.0, 263.0)]);
|
||||
drag["mods"] = json!({"cmd": true});
|
||||
let r = text(&call_tool(&mut h, "pointer_gesture", &drag));
|
||||
assert_eq!(r["tool"], "pen", "{r}");
|
||||
assert_eq!(paths(&h)[0][1].h_out, vectorcraft_geom::Point::new(250.0, 263.0));
|
||||
text(&call_tool(&mut h, "pointer_gesture", &gesture(&[(300.0, 350.0)])));
|
||||
assert_eq!(paths(&h).iter().map(Vec::len).collect::<Vec<_>>(), [3], "the same path, three anchors");
|
||||
}
|
||||
|
|
@ -154,6 +154,17 @@ pub fn group_of(id: &str) -> Option<usize> {
|
|||
TOOL_GROUPS.iter().position(|g| g.iter().any(|t| t.id == id))
|
||||
}
|
||||
|
||||
/// Selection, Direct Selection and Group Selection: the tools Cmd held lends the others for a drag.
|
||||
pub fn is_selection_tool(id: &str) -> bool {
|
||||
matches!(id, "selection" | "directSelection" | "groupSelection")
|
||||
}
|
||||
|
||||
/// The tools that show a selected path's anchors and handles as Direct Selection does, and drag
|
||||
/// them: a Cmd drag with one of them is a Direct Selection drag until a selection tool is chosen.
|
||||
pub fn edits_anchors(id: &str) -> bool {
|
||||
matches!(id, "directSelection" | "pen" | "addAnchor" | "deleteAnchor" | "anchorPoint" | "curvature")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -178,5 +189,7 @@ mod tests {
|
|||
assert_eq!(tool_for_shortcut("V").unwrap().id, "selection");
|
||||
assert_eq!(tool_for_shortcut("Shift+M").unwrap().id, "shapeBuilder");
|
||||
assert_eq!(group_of("star"), group_of("rectangle"));
|
||||
assert!(is_selection_tool("groupSelection") && !is_selection_tool("pen"));
|
||||
assert!(edits_anchors("pen") && !edits_anchors("rectangle"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,11 +28,11 @@ use std::borrow::Cow;
|
|||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::hit::{HitKind, hit_test};
|
||||
use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{PathData, Point, Rect};
|
||||
use vectorcraft_geom::{Anchor, PathData, Point, Rect};
|
||||
|
||||
use crate::bbox::move_delta;
|
||||
use crate::corners::{self, CornerDrag, over_widget};
|
||||
use crate::guides::{PointSnap, Targets};
|
||||
use crate::guides::{HandleSnap, PointSnap, Targets};
|
||||
use crate::meshedit::MeshEdit;
|
||||
use crate::pathtype::{self, BracketDrag, over_bracket};
|
||||
use crate::rulerguide::GuideEdit;
|
||||
|
|
@ -112,6 +112,8 @@ pub struct DirectSelectionTool {
|
|||
guides: Vec<Overlay>,
|
||||
/// What the dragged anchors snap to, gathered when the drag begins.
|
||||
anchor_snap: Option<PointSnap>,
|
||||
/// What the dragged handle snaps to.
|
||||
handle_snap: HandleSnap,
|
||||
/// What the objects being moved snap to, gathered when the move begins.
|
||||
move_snap: Option<MoveSnap>,
|
||||
/// The anchor under the pointer (Highlight anchors on mouse over): its path, where it is.
|
||||
|
|
@ -128,6 +130,7 @@ impl DirectSelectionTool {
|
|||
mesh: MeshEdit::default(),
|
||||
guides: vec![],
|
||||
anchor_snap: None,
|
||||
handle_snap: HandleSnap::default(),
|
||||
move_snap: None,
|
||||
hover: None,
|
||||
guide: GuideEdit::default(),
|
||||
|
|
@ -217,30 +220,37 @@ fn hit_frame_edge(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, Vec<A
|
|||
})
|
||||
}
|
||||
|
||||
/// Selection & Anchor Display → Show handles when multiple anchors are selected: off, handles show
|
||||
/// (and drag) only while a single anchor is selected.
|
||||
pub fn handles_shown(selection: &vectorcraft_doc::Selection, multiple: bool) -> bool {
|
||||
multiple || selection.anchors.values().map(|set| set.len()).sum::<usize>() <= 1
|
||||
/// The anchors whose direction handles show, and drag, with the tools that edit anchors
|
||||
/// ([`crate::catalog::edits_anchors`]): the direct-selected anchors of a path, every anchor of a
|
||||
/// path selected as a whole. Selection & Anchor Display → Show handles when multiple anchors are
|
||||
/// selected off, none while more than one anchor is selected.
|
||||
fn handle_anchors(cx: &ToolContext) -> Vec<(NodeId, usize, usize, Anchor)> {
|
||||
let mut v = vec![];
|
||||
for &id in &cx.selection.objects {
|
||||
let Some(pd) = cx.doc.node(id).and_then(|n| n.path_data()) else { continue };
|
||||
let shown = cx.selection.partial(id);
|
||||
v.extend(pd.anchors().filter(|(si, ai, _)| shown.is_none_or(|set| set.contains(&(*si, *ai)))).map(|(si, ai, a)| (id, si, ai, *a)));
|
||||
}
|
||||
if !cx.handles_multiple && v.len() > 1 {
|
||||
v.clear();
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Direction handle of a partially selected anchor under `p`: (id, si, ai, is_out).
|
||||
fn hit_handle(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, bool)> {
|
||||
if !handles_shown(cx.selection, cx.handles_multiple) {
|
||||
return None;
|
||||
}
|
||||
for (id, set) in &cx.selection.anchors {
|
||||
let Some(pd) = cx.doc.node(*id).and_then(|n| n.path_data()) else { continue };
|
||||
for &(si, ai) in set {
|
||||
let Some(a) = pd.subpaths.get(si).and_then(|s| s.anchors.get(ai)) else { continue };
|
||||
if a.has_out() && a.h_out.distance(p) <= tol {
|
||||
return Some((*id, si, ai, true));
|
||||
}
|
||||
if a.has_in() && a.h_in.distance(p) <= tol {
|
||||
return Some((*id, si, ai, false));
|
||||
/// The end of a shown direction handle ([`handle_anchors`]) nearest to `p` within `tol`, and
|
||||
/// nearer to it than the handle's anchor (a short handle leaves its anchor to be picked): (path,
|
||||
/// subpath, anchor, is_out).
|
||||
pub(crate) fn hit_handle(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, bool)> {
|
||||
let mut best: Option<((NodeId, usize, usize, bool), f64)> = None;
|
||||
for (id, si, ai, a) in handle_anchors(cx) {
|
||||
for (out, has, h) in [(true, a.has_out(), a.h_out), (false, a.has_in(), a.h_in)] {
|
||||
let d = h.distance(p);
|
||||
if has && d <= tol && d < a.p.distance(p) && best.is_none_or(|b| d < b.1) {
|
||||
best = Some(((id, si, ai, out), d));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
best.map(|b| b.0)
|
||||
}
|
||||
|
||||
fn anchors_json(v: &[AnchorRef]) -> Value {
|
||||
|
|
@ -335,6 +345,7 @@ impl Tool for DirectSelectionTool {
|
|||
self.mesh.unfocus();
|
||||
if let Some((id, si, ai, out)) = hit_handle(cx, p, tol) {
|
||||
self.state = State::Handle { id, si, ai, out };
|
||||
self.handle_snap = HandleSnap::default();
|
||||
return vec![Action::Begin("Reshape".into())];
|
||||
}
|
||||
if let Some((id, anchor, from)) = hit_spine_point(cx, p, tol) {
|
||||
|
|
@ -471,7 +482,7 @@ impl Tool for DirectSelectionTool {
|
|||
out
|
||||
}
|
||||
(PointerKind::Drag, State::Handle { id, si, ai, out }) => {
|
||||
let (q, guides) = crate::guides::snap_handle(cx, (id, si, ai), p, ev.mods.shift);
|
||||
let (q, guides) = self.handle_snap.snap(cx, (id, si, ai), p, ev.mods.shift);
|
||||
self.guides = guides;
|
||||
vec![Action::Preview(
|
||||
"path.setHandle".into(),
|
||||
|
|
@ -871,6 +882,61 @@ mod tests {
|
|||
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "object.transform"), "{a:?}");
|
||||
}
|
||||
|
||||
/// #494: the handles shown are the handles dragged. A path selected as a whole (the one the
|
||||
/// Pen just drew) shows all its handles, and they drag; the direct-selected anchors' handles
|
||||
/// only otherwise; none with Show handles when multiple anchors are selected off and several
|
||||
/// anchors selected. A handle shorter than the tolerance leaves its anchor to be picked, and
|
||||
/// the tolerance is the preference's, in screen pixels at any zoom.
|
||||
#[test]
|
||||
fn the_handles_shown_are_the_handles_dragged() {
|
||||
let (d, arch) = arch_doc();
|
||||
let p = paint();
|
||||
// Does a press at (x, y) take a handle?
|
||||
let handle = |c: &ToolContext, x: f64, y: f64| {
|
||||
DirectSelectionTool::new(false).pointer(c, &PointerEvent::new(PointerKind::Down, x, y)) == vec![Action::Begin("Reshape".into())]
|
||||
};
|
||||
let mut whole = Selection::default();
|
||||
whole.set([arch]);
|
||||
let c = cx(&d, &whole, &p);
|
||||
assert!(handle(&c, 101.0, 251.0), "the first anchor's handle, the path selected whole");
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 101.0, 251.0));
|
||||
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 120.0, 240.0));
|
||||
assert!(matches!(&a[..], [Action::Preview(c, v)] if c == "path.setHandle" && v["anchor"] == 0 && v["which"] == "out"), "{a:?}");
|
||||
// Only the second anchor direct-selected: the first one's handle is hidden.
|
||||
let second = anchors_of(arch, &[(0, 1)]);
|
||||
assert!(!handle(&cx(&d, &second, &p), 101.0, 251.0));
|
||||
assert!(handle(&cx(&d, &second, &p), 201.0, 251.0));
|
||||
// Show handles when multiple anchors are selected off: the whole path's are hidden.
|
||||
assert!(!handle(&ToolContext { handles_multiple: false, ..cx(&d, &whole, &p) }, 101.0, 251.0));
|
||||
assert!(handle(&ToolContext { handles_multiple: false, ..cx(&d, &second, &p) }, 201.0, 251.0));
|
||||
// The tolerance is the preference's, in screen pixels: 3 px is 0.75 pt at 400%.
|
||||
let zoomed = ToolContext { zoom: 4.0, ..cx(&d, &whole, &p) };
|
||||
assert!(!handle(&zoomed, 101.0, 251.0));
|
||||
assert!(handle(&zoomed, 100.5, 250.5));
|
||||
assert!(handle(&ToolContext { selection_tolerance: 8.0, ..cx(&d, &whole, &p) }, 106.0, 254.0));
|
||||
}
|
||||
|
||||
/// #494: a handle 2 pt long, inside the tolerance round its anchor, doesn't hide the anchor.
|
||||
#[test]
|
||||
fn a_short_handle_leaves_its_anchor_to_be_picked() {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false);
|
||||
sp.anchors[0].h_out = Point::new(102.0, 300.0);
|
||||
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, PathData::single(sp), vectorcraft_doc::Appearance::default_art())).unwrap();
|
||||
let s = anchors_of(id, &[(0, 0)]);
|
||||
let p = paint();
|
||||
let c = cx(&d, &s, &p);
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 300.0)), vec![], "the anchor, already selected");
|
||||
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 90.0, 290.0));
|
||||
assert_eq!(a[0], Action::Begin("Move".into()));
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 90.0, 290.0));
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 102.5, 300.0)), vec![Action::Begin("Reshape".into())], "the handle");
|
||||
}
|
||||
|
||||
/// [`doc_with_rect`] (A, 100..200) and a second square B, 300..400: (A, B).
|
||||
fn two_squares() -> (vectorcraft_doc::Document, NodeId, NodeId) {
|
||||
let (mut d, a) = doc_with_rect();
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ use vectorcraft_geom::{Anchor, AnchorKind, BezPath, Point, SubPath};
|
|||
use crate::{Action, Tool, ToolContext};
|
||||
|
||||
pub use anchor::AnchorTool;
|
||||
pub(crate) use anchor::hit_handle;
|
||||
pub use curvature::CurvatureTool;
|
||||
pub use family::FamilyTool;
|
||||
pub use gesture::GestureTool;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ use vectorcraft_doc::NodeId;
|
|||
use vectorcraft_geom::Point;
|
||||
|
||||
use super::{hit_anchor, hit_segment, insert_anchor, remove_anchor};
|
||||
use crate::direct::hit_handle;
|
||||
use crate::guides::HandleSnap;
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
|
|
@ -25,8 +27,9 @@ enum State {
|
|||
pub struct AnchorTool {
|
||||
id: &'static str,
|
||||
state: State,
|
||||
/// Smart guides of the handle being dragged.
|
||||
/// Smart guides of the handle being dragged, and what it snaps to.
|
||||
guides: Vec<Overlay>,
|
||||
snap: HandleSnap,
|
||||
}
|
||||
|
||||
impl AnchorTool {
|
||||
|
|
@ -37,36 +40,18 @@ impl AnchorTool {
|
|||
"scissors" => "scissors",
|
||||
_ => "addAnchor",
|
||||
};
|
||||
Self { id, state: State::Idle, guides: vec![] }
|
||||
Self { id, state: State::Idle, guides: vec![], snap: HandleSnap::default() }
|
||||
}
|
||||
|
||||
fn add(cx: &ToolContext, p: Point) -> Vec<Action> {
|
||||
hit_segment(cx, p, cx.tol(4.0)).map(insert_anchor).into_iter().collect()
|
||||
hit_segment(cx, p, cx.pick_tol()).map(insert_anchor).into_iter().collect()
|
||||
}
|
||||
|
||||
fn delete(cx: &ToolContext, p: Point) -> Vec<Action> {
|
||||
hit_anchor(cx, p, cx.tol(4.0)).map(remove_anchor).into_iter().collect()
|
||||
hit_anchor(cx, p, cx.pick_tol()).map(remove_anchor).into_iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A direction handle of a selected path under `p`, among those shown (a path with anchors
|
||||
/// direct-selected shows theirs only): (id, subpath, anchor, is_out).
|
||||
pub(crate) fn hit_handle(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, bool)> {
|
||||
for id in &cx.selection.objects {
|
||||
let Some(pd) = cx.doc.node(*id).and_then(|n| n.path_data()) else { continue };
|
||||
let shown = cx.selection.partial(*id);
|
||||
for (si, ai, a) in pd.anchors().filter(|(si, ai, _)| shown.is_none_or(|set| set.contains(&(*si, *ai)))) {
|
||||
if a.has_out() && a.h_out.distance(p) <= tol {
|
||||
return Some((*id, si, ai, true));
|
||||
}
|
||||
if a.has_in() && a.h_in.distance(p) <= tol {
|
||||
return Some((*id, si, ai, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
impl Tool for AnchorTool {
|
||||
fn id(&self) -> &'static str {
|
||||
self.id
|
||||
|
|
@ -76,7 +61,7 @@ impl Tool for AnchorTool {
|
|||
}
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
let p = ev.pos;
|
||||
let tol = cx.tol(4.0);
|
||||
let tol = cx.pick_tol();
|
||||
match (self.id, ev.kind) {
|
||||
("addAnchor", PointerKind::Down) => {
|
||||
if ev.mods.alt {
|
||||
|
|
@ -104,6 +89,7 @@ impl Tool for AnchorTool {
|
|||
("anchorPoint", PointerKind::Down) => {
|
||||
if let Some((id, si, ai, out)) = hit_handle(cx, p, tol) {
|
||||
self.state = State::Handle { id, si, ai, out, began: false };
|
||||
self.snap = HandleSnap::default();
|
||||
} else if let Some((id, si, ai)) = hit_anchor(cx, p, tol) {
|
||||
self.state = State::Convert { id, si, ai, start: p, began: false };
|
||||
} else if let Some((id, si, seg, t)) = hit_segment(cx, p, tol) {
|
||||
|
|
@ -134,7 +120,7 @@ impl Tool for AnchorTool {
|
|||
json!({"id": id.0, "subpath": si, "anchor": ai, "to": "smooth", "x": p.x, "y": p.y}),
|
||||
),
|
||||
State::Handle { id, si, ai, out, .. } => {
|
||||
let (q, guides) = crate::guides::snap_handle(cx, (id, si, ai), p, ev.mods.shift);
|
||||
let (q, guides) = self.snap.snap(cx, (id, si, ai), p, ev.mods.shift);
|
||||
self.guides = guides;
|
||||
Action::Preview(
|
||||
"path.setHandle".into(),
|
||||
|
|
@ -192,7 +178,7 @@ impl Tool for AnchorTool {
|
|||
"deleteAnchor" if !m.alt => Cursor::PenDelete,
|
||||
"deleteAnchor" => Cursor::PenAdd,
|
||||
"scissors" => {
|
||||
if hit_segment(cx, p, cx.tol(4.0)).is_some() {
|
||||
if hit_segment(cx, p, cx.pick_tol()).is_some() {
|
||||
Cursor::Crosshair
|
||||
} else {
|
||||
Cursor::NotAllowed
|
||||
|
|
@ -257,4 +243,38 @@ mod tests {
|
|||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 230.0));
|
||||
assert!(matches!(&a[1], Action::Preview(c, v) if c == "path.reshapeSegment" && v["dy"] == 30.0));
|
||||
}
|
||||
|
||||
/// #494: the Anchor Point tool picks within Selection & Anchor Display → Tolerance, and drags
|
||||
/// only the handles shown.
|
||||
#[test]
|
||||
fn anchor_point_picks_within_the_tolerance_and_drags_shown_handles() {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0), Point::new(300.0, 300.0)], false);
|
||||
sp.anchors[1] = vectorcraft_geom::Anchor::smooth(Point::new(200.0, 300.0), Point::new(240.0, 300.0));
|
||||
let path = vectorcraft_geom::PathData::single(sp);
|
||||
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, path, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
let p = paint();
|
||||
let press = |c: &ToolContext, x: f64, y: f64| {
|
||||
let mut t = AnchorTool::new("anchorPoint");
|
||||
t.pointer(c, &PointerEvent::new(PointerKind::Down, x, y));
|
||||
t.pointer(c, &PointerEvent::new(PointerKind::Drag, x + 10.0, y + 30.0))
|
||||
};
|
||||
let dragged = |a: Vec<Action>| match a.as_slice() {
|
||||
[Action::Begin(_), Action::Preview(c, _)] => c.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
// The segment 6 px off: picked with an 8 px tolerance, not with the default 3 px.
|
||||
assert_eq!(dragged(press(&cx(&d, &s, &p), 150.0, 306.0)), "");
|
||||
let wide = ToolContext { selection_tolerance: 8.0, ..cx(&d, &s, &p) };
|
||||
assert_eq!(dragged(press(&wide, 150.0, 306.0)), "path.reshapeSegment");
|
||||
// The middle anchor's handle drags; with Show handles when multiple anchors are selected
|
||||
// off it's hidden (three anchors selected) and the press finds the segment under it.
|
||||
assert_eq!(dragged(press(&cx(&d, &s, &p), 240.0, 301.0)), "path.setHandle");
|
||||
let single = ToolContext { handles_multiple: false, ..cx(&d, &s, &p) };
|
||||
assert_eq!(dragged(press(&single, 240.0, 301.0)), "path.reshapeSegment");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -553,25 +553,35 @@ impl PointSnap {
|
|||
}
|
||||
}
|
||||
|
||||
/// Where a dragged direction handle of anchor `ai` of subpath `si` of path `id` goes for the
|
||||
/// pointer at `p`: with Shift at a multiple of 45° (from the Constrain Angle) round its anchor,
|
||||
/// else snapped as a drawn point is (smart guides, the grid, pixels).
|
||||
pub fn snap_handle(cx: &ToolContext, (id, si, ai): (NodeId, usize, usize), p: Point, shift: bool) -> (Point, Vec<Overlay>) {
|
||||
let path = cx.doc.node(id).and_then(|n| n.path_data());
|
||||
if shift && let Some(a) = path.and_then(|pd| pd.subpaths.get(si)?.anchors.get(ai)) {
|
||||
return (a.p + vectorcraft_geom::constrain_angle_from(p - a.p, 45.0, cx.constrain_angle), vec![]);
|
||||
}
|
||||
snap_with(cx, p, || {
|
||||
// The path's bounds move with the handle, so they would chase it: its anchors (which stay
|
||||
// put) are the targets instead, the handle lining up with them too.
|
||||
let mut t = Targets::collect(cx.doc, &[id], None);
|
||||
for (_, _, a) in path.into_iter().flat_map(|pd| pd.anchors()).take(20_000) {
|
||||
t.points.push((a.p, Kind::Anchor));
|
||||
t.xs.push((a.p.x, a.p, Kind::Anchor));
|
||||
t.ys.push((a.p.y, a.p, Kind::Anchor));
|
||||
/// Snapping for a dragged direction handle: Shift keeps it at 45° steps round its anchor, else it
|
||||
/// snaps as a drawn point does. The targets are gathered on the first move that needs them and
|
||||
/// kept for the rest of the drag (gathering them takes as long as the document is big).
|
||||
#[derive(Default)]
|
||||
pub struct HandleSnap(Option<PointSnap>);
|
||||
|
||||
impl HandleSnap {
|
||||
/// Where the handle of anchor `ai` of subpath `si` of path `id` goes with the pointer at `p`,
|
||||
/// and the guides showing why.
|
||||
pub fn snap(&mut self, cx: &ToolContext, (id, si, ai): (NodeId, usize, usize), p: Point, shift: bool) -> (Point, Vec<Overlay>) {
|
||||
let path = cx.doc.node(id).and_then(|n| n.path_data());
|
||||
if shift && let Some(a) = path.and_then(|pd| pd.subpaths.get(si)?.anchors.get(ai)) {
|
||||
return (a.p + vectorcraft_geom::constrain_angle_from(p - a.p, 45.0, cx.constrain_angle), vec![]);
|
||||
}
|
||||
t
|
||||
})
|
||||
let snap = self.0.get_or_insert_with(|| {
|
||||
PointSnap::new(cx, || {
|
||||
// The path's bounds move with the handle, so they would chase it: its anchors
|
||||
// (which stay put) are the targets instead, the handle lining up with them too.
|
||||
let mut t = Targets::collect(cx.doc, &[id], None);
|
||||
for (_, _, a) in path.into_iter().flat_map(|pd| pd.anchors()).take(20_000) {
|
||||
t.points.push((a.p, Kind::Anchor));
|
||||
t.xs.push((a.p.x, a.p, Kind::Anchor));
|
||||
t.ys.push((a.p.y, a.p, Kind::Anchor));
|
||||
}
|
||||
t
|
||||
})
|
||||
});
|
||||
snap.snap(cx, p)
|
||||
}
|
||||
}
|
||||
|
||||
/// Snap a picked point (a transform tool's reference point) to the nearest anchor or centre of the
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@
|
|||
//! follows the pointer; Space held moves the anchor, handles and all); Shift constrains to 45°.
|
||||
//! Clicking the first anchor closes the path. Clicking the last one retracts its outgoing handle, so
|
||||
//! the next segment leaves it as a corner; dragging from it pulls a new one out on its own.
|
||||
//! Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as
|
||||
//! the Anchor Point tool: dragging a handle moves it alone, clicking a smooth anchor makes it a
|
||||
//! corner and dragging an anchor pulls out new symmetric handles.
|
||||
//! Alt held over a handle end, an anchor or a segment of a selected path (the one being drawn too)
|
||||
//! works as the Anchor Point tool: dragging a handle moves it alone, clicking a smooth anchor makes
|
||||
//! it a corner, dragging an anchor pulls out new symmetric handles and dragging a segment reshapes
|
||||
//! it. Cmd held lends the selection tool used last for a drag (the engine's
|
||||
//! `Session::pointer`); the path being drawn goes on afterwards while it stays selected.
|
||||
//! Enter/Esc (or switching tools) ends the path. Clicking the end of a selected open path continues
|
||||
//! it. The rubber-band preview shows the next segment (Enable Rubber Band for Pen Tool). Auto Add/Delete: between paths, a click on a
|
||||
//! segment of a selected path adds an anchor there and a click on one of its anchors deletes it
|
||||
|
|
@ -18,13 +20,16 @@ use serde_json::json;
|
|||
use vectorcraft_doc::{NodeId, NodeKind};
|
||||
use vectorcraft_geom::{BezPath, Point};
|
||||
|
||||
use crate::draw2::{AnchorTool, hit_handle};
|
||||
use crate::direct::hit_handle;
|
||||
use crate::draw2::AnchorTool;
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PenTool {
|
||||
/// The path being drawn (set once the first anchor exists and the engine selected it).
|
||||
/// A path is being drawn...
|
||||
drawing: bool,
|
||||
/// ...this one, once the engine made it: another path selected meanwhile isn't drawn on.
|
||||
path: Option<NodeId>,
|
||||
drag: Option<(Point, bool)>,
|
||||
hover: Option<Point>,
|
||||
/// The incoming handle of the anchor being dragged out, as last previewed.
|
||||
|
|
@ -63,10 +68,14 @@ fn last_anchor(cx: &ToolContext, id: NodeId) -> Option<(usize, usize)> {
|
|||
Some((si, ai))
|
||||
}
|
||||
|
||||
/// Alt held over a handle end or an anchor of a selected path: the Anchor Point tool's gesture.
|
||||
/// Alt held over a handle end, an anchor or a segment of a selected path: the Anchor Point tool's
|
||||
/// gesture, within its tolerance.
|
||||
fn alt_converts(cx: &ToolContext, p: Point, m: Mods) -> bool {
|
||||
let tol = cx.tol(4.0);
|
||||
m.alt && (hit_handle(cx, p, tol).is_some() || crate::draw2::anchor_in(cx, editable_paths(cx), p, tol).is_some())
|
||||
let tol = cx.pick_tol();
|
||||
m.alt
|
||||
&& (hit_handle(cx, p, tol).is_some()
|
||||
|| crate::draw2::anchor_in(cx, editable_paths(cx), p, tol).is_some()
|
||||
|| crate::draw2::segment_in(cx, editable_paths(cx), p, tol).is_some())
|
||||
}
|
||||
|
||||
/// The selected paths the pen edits (not guides, nor locked or hidden ones).
|
||||
|
|
@ -97,9 +106,13 @@ impl Tool for PenTool {
|
|||
let exclude: Vec<vectorcraft_doc::NodeId> = if self.drawing { cx.selection.objects.clone() } else { vec![] };
|
||||
let (mut p, _) = if matches!(ev.kind, PointerKind::Down) { crate::guides::snap_draw(cx, ev.pos, &exclude) } else { (ev.pos, vec![]) };
|
||||
let tol = cx.tol(5.0);
|
||||
let active = if self.drawing { active_path(cx) } else { None };
|
||||
// The path a press began is the selected one by the next event.
|
||||
if self.drawing && self.path.is_none() {
|
||||
self.path = active_path(cx).map(|a| a.0);
|
||||
}
|
||||
let active = self.active(cx);
|
||||
if self.drawing && active.is_none() && ev.kind == PointerKind::Down {
|
||||
self.drawing = false;
|
||||
self.stop();
|
||||
}
|
||||
match ev.kind {
|
||||
PointerKind::Move => {
|
||||
|
|
@ -142,6 +155,7 @@ impl Tool for PenTool {
|
|||
&& (p.distance(last) <= tol || p.distance(first) <= tol)
|
||||
{
|
||||
self.drawing = true;
|
||||
self.path = active_path(cx).map(|a| a.0);
|
||||
if p.distance(first) <= tol && p.distance(last) > tol {
|
||||
return vec![Action::Exec("path.reverse".into(), json!({}))];
|
||||
}
|
||||
|
|
@ -150,7 +164,7 @@ impl Tool for PenTool {
|
|||
if let Some(act) = auto_add_delete(cx, ev.pos, ev.mods, tol) {
|
||||
return vec![act];
|
||||
}
|
||||
self.drawing = true;
|
||||
(self.drawing, self.path) = (true, None);
|
||||
self.drag = Some((p, false));
|
||||
vec![Action::Begin("Pen".into()), Action::Preview("path.create".into(), json!({"anchors": [{"x": p.x, "y": p.y}]}))]
|
||||
}
|
||||
|
|
@ -179,7 +193,7 @@ impl Tool for PenTool {
|
|||
out_h = a + vectorcraft_geom::constrain_angle(ev.pos - a, 45.0);
|
||||
}
|
||||
let alt = ev.mods.alt;
|
||||
let Some((id, ..)) = active_path(cx).or(active) else {
|
||||
let Some((id, ..)) = active else {
|
||||
// First anchor of a new path: re-issue create with handles.
|
||||
let in_h = a - (out_h - a);
|
||||
return vec![Action::Preview(
|
||||
|
|
@ -211,7 +225,7 @@ impl Tool for PenTool {
|
|||
}
|
||||
let Some((_, closing)) = self.drag.take() else { return vec![] };
|
||||
if closing {
|
||||
self.drawing = false;
|
||||
self.stop();
|
||||
}
|
||||
vec![Action::Commit]
|
||||
}
|
||||
|
|
@ -221,7 +235,7 @@ impl Tool for PenTool {
|
|||
fn key(&mut self, cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec<Action> {
|
||||
match key {
|
||||
ToolKey::Enter | ToolKey::Escape => {
|
||||
self.drawing = false;
|
||||
self.stop();
|
||||
self.drag = None;
|
||||
self.handle = None;
|
||||
// An Alt gesture under way ends as it stands.
|
||||
|
|
@ -231,17 +245,17 @@ impl Tool for PenTool {
|
|||
}
|
||||
}
|
||||
fn deactivate(&mut self, cx: &ToolContext) -> Vec<Action> {
|
||||
self.drawing = false;
|
||||
self.stop();
|
||||
self.convert.take().map(|mut t| t.deactivate(cx)).unwrap_or_default()
|
||||
}
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
if let Some(t) = &self.convert {
|
||||
return t.overlays(cx);
|
||||
}
|
||||
if !cx.pen_rubber_band || !self.drawing || self.drag.is_some() || self.handle.is_some() {
|
||||
if !cx.pen_rubber_band || self.drag.is_some() || self.handle.is_some() {
|
||||
return vec![];
|
||||
}
|
||||
let (Some((id, _, last, out)), Some(h)) = (active_path(cx), self.hover) else { return vec![] };
|
||||
let (Some((id, _, last, out)), Some(h)) = (self.active(cx), self.hover) else { return vec![] };
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(last);
|
||||
if out.distance(last) > 1e-9 {
|
||||
|
|
@ -254,10 +268,7 @@ impl Tool for PenTool {
|
|||
}
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
|
||||
let tol = cx.tol(5.0);
|
||||
let active = active_path(cx);
|
||||
if self.drawing
|
||||
&& let Some((id, first, last, _)) = active
|
||||
{
|
||||
if let Some((id, first, last, _)) = self.active(cx) {
|
||||
let one = last_anchor(cx, id).is_some_and(|(_, ai)| ai == 0);
|
||||
if p.distance(first) <= tol && !one {
|
||||
return Cursor::PenClose;
|
||||
|
|
@ -275,7 +286,7 @@ impl Tool for PenTool {
|
|||
Some([_]) => return Cursor::PenAdd,
|
||||
_ => {}
|
||||
}
|
||||
if active.is_some_and(|(_, first, last, _)| p.distance(last) <= tol || p.distance(first) <= tol) {
|
||||
if active_path(cx).is_some_and(|(_, first, last, _)| p.distance(last) <= tol || p.distance(first) <= tol) {
|
||||
return Cursor::PenContinue;
|
||||
}
|
||||
match auto_add_delete(cx, p, m, tol) {
|
||||
|
|
@ -289,13 +300,27 @@ impl Tool for PenTool {
|
|||
}
|
||||
|
||||
impl PenTool {
|
||||
/// Hand an Alt press on a selected path's handle or anchor to the Anchor Point tool.
|
||||
/// The open path being drawn: the one the pen made or went on with, while it is the single
|
||||
/// selected open path.
|
||||
fn active(&self, cx: &ToolContext) -> Option<(NodeId, Point, Point, Point)> {
|
||||
active_path(cx).filter(|(id, ..)| self.drawing && self.path.is_none_or(|p| p == *id))
|
||||
}
|
||||
|
||||
/// The path is finished.
|
||||
fn stop(&mut self) {
|
||||
(self.drawing, self.path) = (false, None);
|
||||
}
|
||||
|
||||
/// Hand an Alt press on a selected path's handle, anchor or segment to the Anchor Point tool.
|
||||
fn alt_convert(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option<Vec<Action>> {
|
||||
if !alt_converts(cx, ev.pos, ev.mods) {
|
||||
return None;
|
||||
}
|
||||
let mut t = AnchorTool::new("anchorPoint");
|
||||
let acts = t.pointer(cx, ev);
|
||||
if !t.busy() {
|
||||
return None;
|
||||
}
|
||||
self.convert = Some(t);
|
||||
Some(acts)
|
||||
}
|
||||
|
|
@ -587,4 +612,53 @@ mod tests {
|
|||
let cx1 = cx(&d, &s, &p);
|
||||
assert_eq!(t.cursor(&cx1, Point::new(250.0, 101.0), alt), Cursor::Pen);
|
||||
}
|
||||
|
||||
/// #494: Alt held over a segment of a selected path reshapes it as the Anchor Point tool does,
|
||||
/// while drawing too; Alt away from paths still places an anchor.
|
||||
#[test]
|
||||
fn alt_over_a_segment_reshapes_it() {
|
||||
let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false));
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let alt = Mods { alt: true, ..Mods::default() };
|
||||
let mut t = PenTool { drawing: true, ..PenTool::default() };
|
||||
assert_eq!(t.cursor(&cx, Point::new(150.0, 302.0), alt), Cursor::PenConvert);
|
||||
assert_eq!(t.cursor(&cx, Point::new(150.0, 302.0), Mods::default()), Cursor::Pen);
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 302.0).with_mods(alt)), vec![]);
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 262.0).with_mods(alt));
|
||||
assert_eq!(a[0], Action::Begin("Reshape".into()));
|
||||
assert!(
|
||||
matches!(&a[1], Action::Preview(c, v) if c == "path.reshapeSegment" && v["id"] == id.0 && v["segment"] == 0 && v["dy"] == -40.0),
|
||||
"{a:?}"
|
||||
);
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 262.0)), vec![Action::Commit]);
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 250.0, 400.0).with_mods(alt));
|
||||
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.appendAnchor"), "{a:?}");
|
||||
}
|
||||
|
||||
/// #494: the Pen goes on drawing the path it drew while that path stays selected (after a Cmd
|
||||
/// drag with Direct Selection), but never another open path selected meanwhile.
|
||||
#[test]
|
||||
fn the_pen_draws_on_the_path_it_began_only() {
|
||||
let (mut d, a, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false));
|
||||
let l = d.layers[0].id;
|
||||
let b = d.alloc_id();
|
||||
let line = vectorcraft_geom::shapes::line(Point::new(10.0, 400.0), Point::new(60.0, 400.0));
|
||||
d.insert(Some(l), 2, vectorcraft_doc::Node::path(b, line, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||||
let p = paint();
|
||||
let mut t = PenTool { drawing: true, ..PenTool::default() };
|
||||
let cx_a = cx(&d, &s, &p);
|
||||
let appended = |acts: &[Action]| match acts {
|
||||
[Action::Begin(_), Action::Preview(c, v)] if c == "path.appendAnchor" => v["id"].as_u64(),
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!(appended(&click(&mut t, &cx_a, 100.0, 320.0, Mods::default())), Some(a.0));
|
||||
// Path B selected instead: the next click starts a new path.
|
||||
let mut sb = Selection::default();
|
||||
sb.set([b]);
|
||||
let cx_b = cx(&d, &sb, &p);
|
||||
assert_eq!(t.cursor(&cx_b, Point::new(150.0, 350.0), Mods::default()), Cursor::Pen);
|
||||
let acts = click(&mut t, &cx_b, 150.0, 350.0, Mods::default());
|
||||
assert!(matches!(&acts[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.create"), "{acts:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,31 +76,19 @@ enum Drag {
|
|||
start_angle: f64,
|
||||
start_rot: f64,
|
||||
},
|
||||
/// Cmd held: temporary selection tool; restore this tool on release.
|
||||
TempSelect,
|
||||
/// A Selection tool move dragged off the canvas: the panels get the art
|
||||
/// ([`widgets::PanelDrag::Art`]); `temp` restores the tool Cmd switched from on release.
|
||||
Art {
|
||||
temp: bool,
|
||||
},
|
||||
/// ([`widgets::PanelDrag::Art`]).
|
||||
Art,
|
||||
}
|
||||
|
||||
fn drag_id() -> egui::Id {
|
||||
egui::Id::new("canvas-drag")
|
||||
}
|
||||
fn temp_tool_id() -> egui::Id {
|
||||
egui::Id::new("canvas-temp-tool")
|
||||
}
|
||||
/// Where the pointer was last seen during a press on the canvas.
|
||||
fn drag_pos_id() -> egui::Id {
|
||||
egui::Id::new("canvas-drag-pos")
|
||||
}
|
||||
|
||||
/// Selection, Direct Selection and Group Selection: the tools Cmd switches to for a drag.
|
||||
pub(crate) fn is_selection_tool(id: &str) -> bool {
|
||||
matches!(id, "selection" | "directSelection" | "groupSelection")
|
||||
}
|
||||
|
||||
/// The pen pressure (0..1) of the press in progress: the force of this frame's pen or touch
|
||||
/// input, else the last one seen since the press (`pressed`: a new press, whose mouse has none
|
||||
/// until a pen reports it). A mouse presses fully (1).
|
||||
|
|
@ -531,19 +519,10 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
|||
let c = rect.center();
|
||||
Drag::RotateView { start_angle: (p.y - c.y).atan2(p.x - c.x) as f64, start_rot: v.rotation }
|
||||
} else {
|
||||
let mut kind = Drag::Tool;
|
||||
// Cmd with another tool: drag with the selection tool used last.
|
||||
if m.command && !is_selection_tool(tool) {
|
||||
ui.data_mut(|d| d.insert_temp(temp_tool_id(), tool.to_string()));
|
||||
let last = app.ui.last_selection_tool.clone();
|
||||
app.select_tool(if is_selection_tool(&last) { &last } else { "selection" });
|
||||
kind = Drag::TempSelect;
|
||||
}
|
||||
// Cmd only brought the selection tool: its click is a plain one, not Select Behind.
|
||||
let mods = Mods { cmd: m.command && kind != Drag::TempSelect, ..mods(m, space) };
|
||||
let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods, pressure: pen_pressure(ui, true) };
|
||||
// Cmd with another tool drags with the selection tool used last (the session lends it).
|
||||
let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, true) };
|
||||
dispatch(app, &ev, view);
|
||||
kind
|
||||
Drag::Tool
|
||||
};
|
||||
ui.data_mut(|dd| {
|
||||
dd.insert_temp(drag_id(), d);
|
||||
|
|
@ -580,11 +559,11 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
|||
vm.rotation = vectorcraft_geom::normalize_deg(deg);
|
||||
}
|
||||
}
|
||||
Drag::ZoomBox { .. } | Drag::Art { .. } => {}
|
||||
Drag::Tool | Drag::TempSelect if drag_art_out(app, ui, resp, p, view) => {
|
||||
ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Art { temp: d == Drag::TempSelect }));
|
||||
Drag::ZoomBox { .. } | Drag::Art => {}
|
||||
Drag::Tool if drag_art_out(app, ui, resp, p, view) => {
|
||||
ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Art));
|
||||
}
|
||||
Drag::Tool | Drag::TempSelect => {
|
||||
Drag::Tool => {
|
||||
if pointer.delta() != egui::Vec2::ZERO {
|
||||
let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) };
|
||||
dispatch(app, &ev, view);
|
||||
|
|
@ -620,17 +599,11 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
|||
}
|
||||
}
|
||||
}
|
||||
Drag::Tool | Drag::TempSelect | Drag::Art { .. } => {
|
||||
if !matches!(d, Drag::Art { .. }) {
|
||||
let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) };
|
||||
dispatch(app, &ev, view);
|
||||
}
|
||||
if matches!(d, Drag::TempSelect | Drag::Art { temp: true })
|
||||
&& let Some(prev) = ui.data(|dd| dd.get_temp::<String>(temp_tool_id()))
|
||||
{
|
||||
app.restore_tool(&prev);
|
||||
}
|
||||
Drag::Tool => {
|
||||
let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) };
|
||||
dispatch(app, &ev, view);
|
||||
}
|
||||
Drag::Art => {}
|
||||
Drag::Pan { .. } | Drag::RotateView { .. } => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1371,7 +1344,7 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
|||
let app = &*app;
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let tool = app.session.tool_id();
|
||||
let direct = matches!(tool, "directSelection" | "pen" | "addAnchor" | "deleteAnchor" | "anchorPoint" | "curvature");
|
||||
let direct = vectorcraft_tools::catalog::edits_anchors(tool);
|
||||
// Selection & Anchor Display › Size (1–7, 3 the default): anchors, handles and the bounding
|
||||
// box's handles a point bigger or smaller per step.
|
||||
let look = HandleLook::of(&app.session.prefs);
|
||||
|
|
|
|||
|
|
@ -540,19 +540,8 @@ impl VectorcraftApp {
|
|||
|
||||
/// Select a tool (also used by the toolbar and shortcuts).
|
||||
pub fn select_tool(&mut self, id: &str) {
|
||||
self.change_tool(id, true);
|
||||
}
|
||||
|
||||
/// Go back to tool `id` after a temporary one, without choosing it afresh
|
||||
/// ([`vectorcraft_engine::Session::switch_tool`]).
|
||||
pub fn restore_tool(&mut self, id: &str) {
|
||||
self.change_tool(id, false);
|
||||
}
|
||||
|
||||
fn change_tool(&mut self, id: &str, choose: bool) {
|
||||
let v = self.view_info();
|
||||
let r = if choose { self.session.select_tool(id, v) } else { self.session.switch_tool(id, v) };
|
||||
if let Err(e) = r {
|
||||
if let Err(e) = self.session.select_tool(id, v) {
|
||||
self.ui.status = e.to_string();
|
||||
}
|
||||
if let Some(g) = vectorcraft_tools::catalog::group_of(id)
|
||||
|
|
@ -560,9 +549,6 @@ impl VectorcraftApp {
|
|||
{
|
||||
*slot = id.to_string();
|
||||
}
|
||||
if canvas::is_selection_tool(id) {
|
||||
self.ui.last_selection_tool = id.to_string();
|
||||
}
|
||||
toolbar::remember(self, id);
|
||||
self.ui.flyout = None;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -246,7 +246,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
|||
// Enter with a selection tool opens the Move dialog, as a double-click on its button
|
||||
// does.
|
||||
let tool = app.session.tool_id();
|
||||
if app.ui.dialog.is_none() && crate::canvas::is_selection_tool(tool) {
|
||||
if app.ui.dialog.is_none() && vectorcraft_tools::catalog::is_selection_tool(tool) {
|
||||
// Nothing selected: it fails and nothing opens (the menu item is disabled then too).
|
||||
let _ = crate::toolbar::open_options(app, tool);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -283,9 +283,6 @@ pub struct UiState {
|
|||
pub flyout: Option<usize>,
|
||||
/// Last tool shown for each toolbar group (flyout selection sticks).
|
||||
pub group_tool: Vec<String>,
|
||||
/// The selection tool used last (Selection, Direct Selection or Group Selection): a Cmd press
|
||||
/// with any other tool drags with it.
|
||||
pub last_selection_tool: String,
|
||||
pub status: String,
|
||||
pub palette_open: bool,
|
||||
pub palette_query: String,
|
||||
|
|
@ -496,7 +493,6 @@ impl Default for UiState {
|
|||
dialog: None,
|
||||
flyout: None,
|
||||
group_tool: vectorcraft_tools::TOOL_GROUPS.iter().map(|g| g[0].id.to_string()).collect(),
|
||||
last_selection_tool: "selection".into(),
|
||||
status: String::new(),
|
||||
palette_open: false,
|
||||
palette_query: String::new(),
|
||||
|
|
|
|||
|
|
@ -300,7 +300,7 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<serde_json::
|
|||
"hand" => app.run("view.fitArtboard", json!({})),
|
||||
"zoom" => app.run("view.actualSize", json!({})),
|
||||
"rotate" | "scale" | "reflect" | "shear" | "selection" | "directSelection" | "groupSelection" => {
|
||||
let dialog = if crate::canvas::is_selection_tool(tool) { "move" } else { tool };
|
||||
let dialog = if vectorcraft_tools::catalog::is_selection_tool(tool) { "move" } else { tool };
|
||||
let id = format!("object.{dialog}");
|
||||
if let Some(c) = vectorcraft_engine::find_command(&id) {
|
||||
(c.enabled)(&app.session)?;
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ so only enable it while you use it. Transport: `apps/vectorcraft/src/control_ser
|
|||
| `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item |
|
||||
| `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected |
|
||||
| `ui.tool.select` / `ui.tool.list` | `{tool}` | |
|
||||
| `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse |
|
||||
| `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse: `mods.cmd` held at a press with any tool but a selection tool drags with the selection tool chosen last (Direct Selection with the Pen until one is), and the release gives the tool back as it was |
|
||||
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | synthetic keyboard input |
|
||||
| `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls and Cmd- or Alt-wheel (Option on the Mac) zooms about the pointer; with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it |
|
||||
| `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?}` | |
|
||||
|
|
|
|||
25
docs/mcp.md
25
docs/mcp.md
|
|
@ -1049,7 +1049,20 @@ on top of each other, and an open path becomes one path per piece. Each cut leav
|
|||
so `path.moveAnchors {dx, dy}` (or a Direct Selection drag) pulls the path apart there; `path.join {}` (Connect
|
||||
Selected End Points) joins the ends again. `path.convertAnchor {id, subpath?, anchor, to, x?, y?}` and
|
||||
`path.split {id, subpath?, anchor}` do the same to one anchor (the Anchor Point and Scissors tools); the Pen with Alt
|
||||
held over a selected path's handle or anchor works as the Anchor Point tool.
|
||||
held over a selected path's handle, anchor or segment works as the Anchor Point tool. `path.reshapeSegment {id,
|
||||
subpath?, segment, t, dx, dy}` (a segment dragged with Direct Selection or the Anchor Point tool) moves the segment's
|
||||
point at `t` by `dx`, `dy`: a smooth anchor at either end stays smooth, its other handle turning with the moved one.
|
||||
|
||||
A `pointer_gesture` with `mods: {cmd: true}` held at the press, with any tool but the selection tools (Command on
|
||||
macOS, Ctrl elsewhere), drags with the selection tool chosen last (`select_tool`), or Direct Selection with the Pen,
|
||||
Curvature and anchor tools until one is chosen; the release gives the tool back as it was, so the Pen goes on drawing
|
||||
the path it had begun while that stays selected. Its reply's `tool` is the tool given back.
|
||||
|
||||
```json
|
||||
{"name":"pointer_gesture","arguments":{"tool":"pen","events":[{"kind":"down","x":100,"y":300},{"kind":"up","x":100,"y":300},{"kind":"down","x":200,"y":300},{"kind":"drag","x":250,"y":300},{"kind":"up","x":250,"y":300}]}}
|
||||
{"name":"pointer_gesture","arguments":{"mods":{"cmd":true},"events":[{"kind":"down","x":250,"y":300},{"kind":"drag","x":250,"y":260},{"kind":"up","x":250,"y":260}]}}
|
||||
{"name":"pointer_gesture","arguments":{"events":[{"kind":"down","x":300,"y":350},{"kind":"up","x":300,"y":350}]}}
|
||||
```
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"select.anchors","params":{"id":12,"anchors":[[0,2]]}}}
|
||||
|
|
@ -1153,8 +1166,9 @@ Effects off a 100 pt wide rectangle with a 10 pt stroke set to `width: 220` gets
|
|||
|
||||
The Selection & Anchor Display and General preferences apply to `pointer_gesture` as they do to the mouse:
|
||||
|
||||
- `selectionTolerance` (1–8 px, 3 by default): how near a click must be to a path to pick it, and to an anchor or
|
||||
handle for Direct Selection.
|
||||
- `selectionTolerance` (1–8 px, 3 by default): how near a click must be to a path to pick it, and to an anchor,
|
||||
handle or segment for Direct Selection, the Anchor Point, Add and Delete Anchor Point and Scissors tools, and the
|
||||
Pen with Alt. A handle shorter than that leaves its anchor to be picked when pressed nearer the anchor.
|
||||
- `objectSelectionByPathOnly`: a click inside a filled path or compound path doesn't select it, one on its path does.
|
||||
- `ctrlClickSelectsBehind` (on by default): a Selection tool click with `mods: {cmd: true}` (Command on macOS, Ctrl
|
||||
elsewhere) selects the object under the selected one there, the next such click the one under that, then the
|
||||
|
|
@ -1168,8 +1182,9 @@ The Selection & Anchor Display and General preferences apply to `pointer_gesture
|
|||
hidden art with the artboard too; off (the default) it stays where it is.
|
||||
- `penRubberBand`, `curvatureRubberBand` (on by default): off, the Pen and Curvature tools draw no preview segment to
|
||||
the pointer.
|
||||
- `showHandlesMultipleAnchors` (on by default): off, Direct Selection shows and drags direction handles only while a
|
||||
single anchor is selected. `handleStyle` (`solid`, `hollow`, `large`) draws their ends (desktop app).
|
||||
- `showHandlesMultipleAnchors` (on by default): off, Direct Selection (and the Anchor Point tool, and the Pen with Alt)
|
||||
shows and drags direction handles only while a single anchor is selected. On, the handles of the direct-selected
|
||||
anchors, or of every anchor of a path selected as a whole, show and drag. `handleStyle` (`solid`, `hollow`, `large`) draws their ends (desktop app).
|
||||
- `hideCornerWidgetAbove` (177° by default): corners wider than this show no Live Corners widget (a rectangle's right
|
||||
angles hide below 90°).
|
||||
- `transformPatternTiles` (off by default): the default of the transforms' `patterns` param (`object.transform`,
|
||||
|
|
|
|||
Loading…
Reference in New Issue