Files
vectorcraft/crates/tools/src/draw2/gesture.rs
T
dotCooCoo d68e14a393 Pencil and Paintbrush: with Keep selected off, a drawn path is not left selected
A Pencil or Paintbrush stroke always left the path it drew or continued
selected. The reference app's Pencil and Paintbrush options have Keep
selected for this, and VectorCraft's Tool Options had no such row.

The Pencil and Paintbrush Tool Options now have a Keep selected
checkbox, on by default, which keeps today's behavior. When it is off,
a new path is not selected and the selection stays as it was, and a
continued path is removed from the selection while the rest stays. The
stroke is still one undo step. The option persists like the tools'
other options, and path.freehand and brush.freehand take keepSelected.

Docs: docs/target-app-parity.md, docs/ui-parity.md, docs/control-protocol.md.
2026-10-16 02:26:00 +07:00

478 lines
20 KiB
Rust

//! Freehand gesture tools: Pencil (N), Paintbrush (B), Blob Brush (Shift+B), Eraser (Shift+E),
//! Knife, Smooth, Path Eraser and Join.
//!
//! Pencil and Paintbrush preview the fitted path live (`path.freehand` re-applied on the snapshot)
//! and commit on release; Alt on release (or ending near the start) closes the path, and starting
//! near an end of a selected open path continues it. The Pencil's new path starts and ends on
//! Smart Guides ([`DrawSnap`]: an anchor, a path, in line with the art), hovering too; the points
//! between follow the hand. The Paintbrush records the pen pressure of each point (a stroke a pen
//! pressed less than fully on sends `[x, y, pressure]` points) for pressure-sensitive brushes. The
//! other tools collect the drag polyline (shown as an overlay) and run
//! one command on release.
use serde_json::{Value, json};
use vectorcraft_doc::NodeId;
use vectorcraft_geom::Point;
use super::{FEEDBACK, points_json, polyline};
use crate::guides::DrawSnap;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
pub struct GestureTool {
id: &'static str,
points: Vec<Point>,
/// The pen pressure of each of `points` (the Paintbrush only).
pressures: Vec<f32>,
active: bool,
began: bool,
/// Pencil/Paintbrush continuing a selected open path: (path, continue from its start).
extend: Option<(NodeId, bool)>,
/// Fit tolerance in points (Pencil/Paintbrush/Smooth "Fidelity").
pub fidelity: f64,
/// Brush / eraser diameter in points.
pub size: f64,
/// Fill new pencil/brush strokes.
pub fill: bool,
/// "Keep selected": select the path a Pencil or Paintbrush stroke draws or continues.
pub keep_selected: bool,
/// "Edit selected paths within N pixels".
pub edit_within: f64,
/// "Close paths when ends are within N pixels".
pub close_within: f64,
/// Smart Guides for the Pencil's ends.
snap: DrawSnap,
}
impl GestureTool {
pub fn new(id: &str) -> Self {
let id: &'static str = match id {
"paintbrush" => "paintbrush",
"blobBrush" => "blobBrush",
"eraser" => "eraser",
"knife" => "knife",
"smooth" => "smooth",
"pathEraser" => "pathEraser",
"join" => "join",
_ => "pencil",
};
let fidelity = if id == "smooth" { 2.5 } else { 1.5 };
Self {
id,
points: vec![],
pressures: vec![],
active: false,
began: false,
extend: None,
fidelity,
size: 10.0,
fill: false,
keep_selected: true,
edit_within: 12.0,
close_within: 15.0,
snap: DrawSnap::default(),
}
}
/// Do the ends of its strokes snap (the Pencil)?
fn snaps(&self) -> bool {
self.id == "pencil"
}
fn draws_path(&self) -> bool {
matches!(self.id, "pencil" | "paintbrush")
}
/// Add a stroke sample.
fn push(&mut self, p: Point, pressure: f32) {
self.points.push(p);
if self.id == "paintbrush" {
self.pressures.push(pressure);
}
}
/// Forget the stroke.
fn clear(&mut self) {
self.points.clear();
self.pressures.clear();
}
/// The stroke's points, each `[x, y, pressure]` when a pen pressed less than fully anywhere
/// along it (a mouse always presses fully).
fn stroke_json(&self) -> Value {
if self.pressures.len() == self.points.len() && self.pressures.iter().any(|p| *p < 1.0) {
Value::Array(self.points.iter().zip(&self.pressures).map(|(p, f)| json!([p.x, p.y, (f64::from(*f) * 1000.0).round() / 1000.0])).collect())
} else {
points_json(&self.points)
}
}
fn label(&self) -> &'static str {
match self.id {
"paintbrush" => "Paintbrush",
"blobBrush" => "Blob Brush",
"eraser" => "Eraser",
"knife" => "Knife",
"smooth" => "Smooth",
"pathEraser" => "Path Eraser",
"join" => "Join",
_ => "Pencil",
}
}
/// The selected open path whose end is near `p`.
fn find_extend(&self, cx: &ToolContext, p: Point) -> Option<(NodeId, bool)> {
let tol = cx.tol(self.edit_within);
for id in &cx.selection.objects {
let Some(pd) = cx.doc.node(*id).and_then(|n| n.path_data()) else { continue };
if !cx.doc.is_editable(*id) {
continue;
}
let Some(sp) = pd.subpaths.iter().rev().find(|s| !s.closed && !s.anchors.is_empty()) else { continue };
let (first, last) = (sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p);
if p.distance(last) <= tol {
return Some((*id, false));
}
if p.distance(first) <= tol {
return Some((*id, true));
}
}
None
}
/// Parameters of `path.freehand` for the current stroke.
pub fn freehand_params(&self, closed: bool) -> Value {
let mut v = json!({
"points": self.stroke_json(),
"fidelity": self.fidelity,
"closed": closed,
"style": if self.id == "paintbrush" { "brush" } else { "pencil" },
"fill": self.fill,
"keepSelected": self.keep_selected,
});
if let Some((id, start)) = self.extend {
v["extend"] = json!({"id": id.0, "end": if start { "start" } else { "end" }});
}
v
}
fn finish(&mut self, cx: &ToolContext, m: Mods) -> Vec<Action> {
let pts = &self.points;
let has_sel = !cx.selection.is_empty();
let tol = cx.tol(self.edit_within);
match self.id {
"pencil" | "paintbrush" => {
if !self.began {
return vec![];
}
let near = pts.len() > 2 && pts[0].distance(pts[pts.len() - 1]) <= cx.tol(self.close_within);
let closed = m.alt || (self.extend.is_none() && near);
vec![Action::Preview("path.freehand".into(), self.freehand_params(closed)), Action::Commit]
}
"blobBrush" => vec![Action::Exec("path.blob".into(), json!({"points": points_json(pts), "size": self.size}))],
"eraser" => vec![Action::Exec("path.eraseRegion".into(), json!({"points": points_json(pts), "size": self.size}))],
"knife" if pts.len() >= 2 => vec![Action::Exec("path.knife".into(), json!({"points": points_json(pts)}))],
"smooth" if has_sel && pts.len() >= 2 => {
vec![Action::Exec("path.smoothRegion".into(), json!({"points": points_json(pts), "radius": tol, "fidelity": self.fidelity}))]
}
"pathEraser" if has_sel => vec![Action::Exec("path.eraseSegments".into(), json!({"points": points_json(pts), "width": cx.tol(8.0)}))],
"join" if pts.len() >= 2 => vec![Action::Exec("path.joinScrub".into(), json!({"points": points_json(pts), "tolerance": tol}))],
_ => vec![],
}
}
}
impl Tool for GestureTool {
fn id(&self) -> &'static str {
self.id
}
fn busy(&self) -> bool {
self.active
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
match ev.kind {
PointerKind::Move if self.snaps() => {
self.snap.hover(cx, p, &[], None);
vec![]
}
PointerKind::Down => {
self.active = true;
self.began = false;
self.extend = if self.draws_path() { self.find_extend(cx, p) } else { None };
// A path continued starts at its end; a new one where it snaps.
let start = if self.snaps() && self.extend.is_none() { self.snap.press(cx, p, &[], None) } else { p };
self.clear();
self.push(start, ev.pressure);
vec![]
}
PointerKind::Drag => {
if !self.active {
return vec![];
}
if self.snaps() {
// Where the stroke would end: shown, taken on release.
self.snap.drag(cx, p, None);
}
if self.points.last().is_some_and(|l| l.distance(p) < cx.tol(1.0)) {
return vec![];
}
self.push(p, ev.pressure);
if !self.draws_path() {
return vec![];
}
let mut out = vec![];
if !self.began {
self.began = true;
out.push(Action::Begin(self.label().into()));
}
out.push(Action::Preview("path.freehand".into(), self.freehand_params(false)));
out
}
PointerKind::Up => {
if !self.active {
return vec![];
}
let end = if self.snaps() { self.snap.drag(cx, p, None) } else { p };
self.snap.clear();
if self.points.last().is_some_and(|l| l.distance(p) >= cx.tol(1.0)) {
self.push(end, ev.pressure);
} else if self.snaps()
&& self.points.len() > 1
&& let Some(last) = self.points.last_mut()
{
// The last step was where the button went up: it moves where that snaps.
*last = end;
}
let out = self.finish(cx, ev.mods);
self.active = false;
self.began = false;
self.clear();
self.extend = None;
out
}
_ => vec![],
}
}
/// `[` and `]` are the Blob Brush's and the Eraser's: their size (#1661).
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
matches!(key, ToolKey::BracketLeft | ToolKey::BracketRight) && matches!(self.id, "blobBrush" | "eraser")
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec<Action> {
match key {
ToolKey::Escape if self.active => {
let began = self.began;
self.snap.clear();
self.active = false;
self.began = false;
self.clear();
if began { vec![Action::Cancel] } else { vec![] }
}
ToolKey::BracketLeft if matches!(self.id, "blobBrush" | "eraser") => {
self.size = (self.size - 1.0).max(1.0);
vec![]
}
ToolKey::BracketRight if matches!(self.id, "blobBrush" | "eraser") => {
self.size = (self.size + 1.0).min(1000.0);
vec![]
}
_ => vec![],
}
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
let began = self.began;
self.snap.clear();
self.active = false;
self.began = false;
self.clear();
if began { vec![Action::Commit] } else { vec![] }
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut o = self.snap.guides().to_vec();
if !self.active || self.points.len() < 2 || self.began {
return o;
}
let width = match self.id {
"blobBrush" | "eraser" => (self.size * cx.zoom) as f32,
_ => 1.0,
};
let color = if self.id == "eraser" { [0x80, 0x80, 0x80] } else { FEEDBACK };
o.push(Overlay::Path { path: polyline(&self.points), color, width, dashed: self.id == "knife" });
o
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
}
fn options(&self) -> Value {
match self.id {
"pencil" | "paintbrush" => {
json!({
"fidelity": self.fidelity,
"fill": self.fill,
"keepSelected": self.keep_selected,
"editWithin": self.edit_within,
"closeWithin": self.close_within
})
}
"smooth" => json!({"fidelity": self.fidelity}),
"blobBrush" | "eraser" => json!({"size": self.size}),
_ => Value::Null,
}
}
fn set_option(&mut self, key: &str, v: &Value) {
match key {
"fidelity" => self.fidelity = v.as_f64().unwrap_or(self.fidelity).clamp(0.05, 100.0),
"size" => self.size = v.as_f64().unwrap_or(self.size).clamp(0.1, 1000.0),
"fill" => self.fill = v.as_bool().unwrap_or(self.fill),
"keepSelected" => self.keep_selected = v.as_bool().unwrap_or(self.keep_selected),
"editWithin" => self.edit_within = v.as_f64().unwrap_or(self.edit_within).clamp(0.0, 100.0),
"closeWithin" => self.close_within = v.as_f64().unwrap_or(self.close_within).clamp(0.0, 100.0),
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use vectorcraft_doc::Selection;
fn drag(t: &mut GestureTool, cx: &ToolContext, pts: &[(f64, f64)], m: Mods) -> Vec<Vec<Action>> {
let mut v = vec![t.pointer(cx, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1))];
for &(x, y) in &pts[1..] {
v.push(t.pointer(cx, &PointerEvent::new(PointerKind::Drag, x, y)));
}
let l = pts[pts.len() - 1];
v.push(t.pointer(cx, &PointerEvent::new(PointerKind::Up, l.0, l.1).with_mods(m)));
v
}
/// The Pencil's new path starts and ends on Smart Guides (#506), the points between where
/// the hand went.
#[test]
fn pencil_ends_snap_to_smart_guides() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = GestureTool::new("pencil");
let acts = drag(&mut t, &cx, &[(102.0, 98.0), (151.0, 300.0), (250.0, 320.0), (198.0, 203.0)], Mods::default());
let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") };
assert_eq!(v["points"], json!([[100.0, 100.0], [151.0, 300.0], [250.0, 320.0], [200.0, 200.0]]));
// The Paintbrush follows the hand all the way.
let mut t = GestureTool::new("paintbrush");
let acts = drag(&mut t, &cx, &[(102.0, 98.0), (151.0, 300.0)], Mods::default());
let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") };
assert_eq!(v["points"][0], json!([102.0, 98.0]));
}
/// #852: the Paintbrush sends each point's pen pressure; a mouse (always fully pressed) and
/// the Pencil send plain points.
#[test]
fn paintbrush_sends_pen_pressure() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let stroke = |id: &str, pressures: [f32; 3]| {
let mut t = GestureTool::new(id);
let kinds = [PointerKind::Down, PointerKind::Drag, PointerKind::Up];
let mut last = vec![];
for ((k, x), f) in kinds.into_iter().zip([0.0, 40.0, 80.0]).zip(pressures) {
last = t.pointer(&cx, &PointerEvent { pressure: f, ..PointerEvent::new(k, x, 0.0) });
}
let Some(Action::Preview(_, v)) = last.first() else { panic!("{last:?}") };
v["points"].clone()
};
assert_eq!(stroke("paintbrush", [0.25, 0.5, 1.0]), json!([[0.0, 0.0, 0.25], [40.0, 0.0, 0.5], [80.0, 0.0, 1.0]]));
assert_eq!(stroke("paintbrush", [1.0; 3]), json!([[0.0, 0.0], [40.0, 0.0], [80.0, 0.0]]));
assert_eq!(stroke("pencil", [0.25, 0.5, 1.0]), json!([[0.0, 0.0], [40.0, 0.0], [80.0, 0.0]]));
}
#[test]
fn pencil_previews_and_commits() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = GestureTool::new("pencil");
let acts = drag(&mut t, &cx, &[(0.0, 0.0), (10.0, 5.0), (20.0, 0.0)], Mods::default());
assert!(acts[0].is_empty());
assert_eq!(acts[1][0], Action::Begin("Pencil".into()));
assert!(matches!(&acts[1][1], Action::Preview(c, v) if c == "path.freehand" && v["points"].as_array().unwrap().len() == 2));
let last = acts.last().unwrap();
assert!(matches!(&last[0], Action::Preview(_, v) if v["closed"] == false && v["style"] == "pencil"));
assert_eq!(last[1], Action::Commit);
assert!(!t.busy());
}
/// Keep selected is on by default, and every Pencil and Paintbrush preview sends it.
#[test]
fn pencil_and_paintbrush_send_keep_selected() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
for id in ["pencil", "paintbrush"] {
let mut t = GestureTool::new(id);
assert_eq!(t.options()["keepSelected"], json!(true), "{id}");
for keep in [true, false] {
t.set_option("keepSelected", &json!(keep));
assert_eq!(t.options()["keepSelected"], json!(keep), "{id}");
let acts = drag(&mut t, &cx, &[(0.0, 0.0), (10.0, 5.0), (20.0, 0.0)], Mods::default());
assert!(acts[1..].iter().all(|a| a.iter().all(|a| !matches!(a, Action::Preview(_, v) if v["keepSelected"] != keep))), "{id}");
assert!(matches!(&acts.last().unwrap()[0], Action::Preview(_, v) if v["keepSelected"] == keep), "{id}");
}
}
}
#[test]
fn pencil_alt_closes_and_continues_selected_path() {
let (d, id) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = GestureTool::new("paintbrush");
let acts = drag(&mut t, &cx, &[(0.0, 0.0), (10.0, 5.0), (20.0, 0.0)], Mods { alt: true, ..Default::default() });
assert!(matches!(&acts.last().unwrap()[0], Action::Preview(_, v) if v["closed"] == true && v["style"] == "brush"));
// A selected closed rect is not continued; an open path would be (see engine tests).
let mut s2 = Selection::default();
s2.set([id]);
let cx2 = crate::testutil::cx(&d, &s2, &p);
let acts = drag(&mut t, &cx2, &[(100.0, 100.0), (90.0, 90.0)], Mods::default());
assert!(matches!(&acts[1][1], Action::Preview(_, v) if v.get("extend").is_none()));
}
#[test]
fn eraser_and_knife_exec_on_release() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = GestureTool::new("eraser");
let acts = drag(&mut t, &cx, &[(50.0, 150.0), (250.0, 150.0)], Mods::default());
assert!(acts[1].is_empty());
assert!(matches!(&acts[2][0], Action::Exec(c, v) if c == "path.eraseRegion" && v["size"] == 10.0));
let mut k = GestureTool::new("knife");
k.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0));
k.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0));
assert_eq!(k.overlays(&cx).len(), 1);
let a = k.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0));
assert!(matches!(&a[0], Action::Exec(c, v) if c == "path.knife" && v["points"].as_array().unwrap().len() == 3));
}
#[test]
fn smooth_needs_selection_and_brackets_resize() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = GestureTool::new("smooth");
let acts = drag(&mut t, &cx, &[(50.0, 150.0), (250.0, 150.0)], Mods::default());
assert!(acts.iter().all(|a| a.is_empty()));
let mut b = GestureTool::new("blobBrush");
b.key(&cx, ToolKey::BracketRight, Mods::default());
assert_eq!(b.options()["size"], 11.0);
}
}