mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 20:27:05 -06:00
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1039 lines
51 KiB
Rust
1039 lines
51 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. Shift held at the press draws a straight
|
|
//! Pencil line at 45° steps from the Constrain Angle instead. 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. With Shift held, the Blob Brush's points stay on the horizontal or
|
|
//! vertical line through the press point.
|
|
|
|
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 {
|
|
/// How many points the last preview of the stroke had ([`crate::previews_at`]).
|
|
previewed: usize,
|
|
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)>,
|
|
/// Pencil/Paintbrush starting on a selected path away from its ends: (path, Edit Selected
|
|
/// Paths' distance in points); the stroke redraws the section it ends on (#2850).
|
|
redraw: Option<(NodeId, f64)>,
|
|
/// 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, Paintbrush or Blob Brush stroke draws, continues
|
|
/// or merges into (on for the Pencil and Paintbrush, off for the Blob Brush).
|
|
pub keep_selected: bool,
|
|
/// "Alt key toggles to Smooth Tool": holding Alt with the Pencil tool smooths selected paths
|
|
/// instead of closing the path (Pencil only, off by default).
|
|
pub alt_smooth: bool,
|
|
/// Active drag is smoothing with the Pencil tool.
|
|
smoothing: bool,
|
|
/// Active Pencil drag draws a straight line (Shift held at the press): the stroke is the
|
|
/// press point and the pointer constrained to 45° steps from it.
|
|
straight: bool,
|
|
/// Where the pointer was for each Blob Brush point dragged since Shift went down: the last
|
|
/// `shift_raw.len()` of `points`.
|
|
shift_raw: Vec<Point>,
|
|
/// Where the pointer was for the Shift-constrained point the next [`Self::push`] adds.
|
|
shift_next: Option<Point>,
|
|
/// The angle of the line the dragged points were last moved onto: while it holds they are on
|
|
/// it, and the next drag event need not move them again.
|
|
shift_line: Option<f64>,
|
|
/// "Merge only with selection": a Blob Brush stroke merges only with selected paths.
|
|
pub merge_only_selection: 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,
|
|
previewed: 0,
|
|
points: vec![],
|
|
pressures: vec![],
|
|
active: false,
|
|
began: false,
|
|
extend: None,
|
|
redraw: None,
|
|
fidelity,
|
|
size: 10.0,
|
|
fill: false,
|
|
keep_selected: id != "blobBrush",
|
|
// Off, as Pencil Tool Options ships it: Alt keeps drawing a closed path until it is on.
|
|
alt_smooth: false,
|
|
smoothing: false,
|
|
straight: false,
|
|
shift_raw: vec![],
|
|
shift_next: None,
|
|
shift_line: None,
|
|
merge_only_selection: false,
|
|
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) {
|
|
let raw = self.shift_next.take();
|
|
if self.points.is_empty() {
|
|
self.shift_raw.clear();
|
|
} else if let Some(raw) = raw {
|
|
self.shift_raw.push(raw);
|
|
}
|
|
self.points.push(p);
|
|
if self.id == "paintbrush" {
|
|
self.pressures.push(pressure);
|
|
}
|
|
}
|
|
|
|
/// Where the stroke takes a pointer at `p`. For the Blob Brush with Shift held, that is the
|
|
/// horizontal or vertical line through the press point that is nearer in angle to `p` (90°
|
|
/// steps from the Constrain Angle), at `p`'s distance from the press point. The points dragged
|
|
/// since Shift went down move to that line too, each at its pointer position's distance and on
|
|
/// that position's side of the press point; a pointer at the press point leaves them on their
|
|
/// line. Without Shift the stroke takes `p`.
|
|
fn constrained(&mut self, cx: &ToolContext, p: Point, m: Mods) -> Point {
|
|
self.shift_next = None;
|
|
if self.straight
|
|
&& let Some(&start) = self.points.first()
|
|
{
|
|
return start + vectorcraft_geom::constrain_angle_from(p - start, 45.0, cx.constrain_angle);
|
|
}
|
|
let Some(&start) = self.points.first().filter(|_| self.id == "blobBrush" && m.shift) else {
|
|
self.shift_raw.clear();
|
|
self.shift_line = None;
|
|
return p;
|
|
};
|
|
self.shift_next = Some(p);
|
|
// The press point is on both lines: the points dragged before stay on theirs.
|
|
if (p - start).hypot() < vectorcraft_geom::EPS {
|
|
return start;
|
|
}
|
|
let d = vectorcraft_geom::constrain_angle_from(p - start, 90.0, cx.constrain_angle);
|
|
// `line` is the line's angle, measured counterclockwise on screen as the Constrain Angle is measured.
|
|
let line = (-d.y).atan2(d.x).to_degrees();
|
|
let on_line = |q: Point| start + vectorcraft_geom::constrain_angle_from(q - start, 180.0, line);
|
|
if self.shift_line != Some(line) {
|
|
for (q, raw) in self.points.iter_mut().skip(1).rev().zip(self.shift_raw.iter().rev()) {
|
|
*q = on_line(*raw);
|
|
}
|
|
self.shift_line = Some(line);
|
|
}
|
|
on_line(p)
|
|
}
|
|
|
|
/// Forget the stroke.
|
|
fn clear(&mut self) {
|
|
self.points.clear();
|
|
self.pressures.clear();
|
|
self.previewed = 0;
|
|
self.shift_line = None;
|
|
}
|
|
|
|
/// 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` (Edit Selected Paths, off at 0 pixels), and
|
|
/// that end.
|
|
fn find_extend(&self, cx: &ToolContext, p: Point) -> Option<(NodeId, bool, Point)> {
|
|
if self.edit_within <= 0.0 {
|
|
return None;
|
|
}
|
|
let tol = cx.tol(self.edit_within);
|
|
let editable = cx.doc.editable();
|
|
for id in &cx.selection.objects {
|
|
let Some(pd) = cx.doc.node(*id).and_then(|n| n.path_data()) else { continue };
|
|
if !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, last));
|
|
}
|
|
if p.distance(first) <= tol {
|
|
return Some((*id, true, first));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// The selected path whose outline is near `p` (Edit Selected Paths, off at 0 pixels).
|
|
fn find_redraw(&self, cx: &ToolContext, p: Point) -> Option<(NodeId, f64)> {
|
|
let tol = cx.tol(self.edit_within);
|
|
if self.edit_within <= 0.0 || !tol.is_finite() {
|
|
return None;
|
|
}
|
|
cx.selection.objects.iter().find_map(|id| {
|
|
let pd = cx.doc.node(*id).and_then(|n| n.path_data()).filter(|_| cx.doc.is_editable(*id))?;
|
|
pd.nearest(p).filter(|n| n.4 <= tol).map(|_| (*id, tol))
|
|
})
|
|
}
|
|
|
|
/// 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" }});
|
|
} else if let Some((id, within)) = self.redraw {
|
|
v["redraw"] = json!({"id": id.0, "within": within});
|
|
}
|
|
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);
|
|
if self.smoothing {
|
|
if has_sel && pts.len() >= 2 {
|
|
return vec![Action::Exec("path.smoothRegion".into(), json!({"points": points_json(pts), "radius": tol, "fidelity": self.fidelity}))];
|
|
}
|
|
return vec![];
|
|
}
|
|
match self.id {
|
|
"pencil" | "paintbrush" => {
|
|
if !self.began {
|
|
return vec![];
|
|
}
|
|
// Close Paths When Ends Are Within is off at 0 pixels.
|
|
let near = self.close_within > 0.0 && pts.len() > 2 && pts[0].distance(pts[pts.len() - 1]) <= cx.tol(self.close_within);
|
|
// A straight line has two points: closing it would draw it back over itself.
|
|
let closed = !self.straight && ((m.alt && !self.alt_smooth) || (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,
|
|
"keepSelected": self.keep_selected,
|
|
"mergeOnlySelection": self.merge_only_selection
|
|
}),
|
|
)],
|
|
"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.smoothing = self.id == "pencil" && self.alt_smooth && ev.mods.alt;
|
|
self.straight = self.id == "pencil" && !self.smoothing && ev.mods.shift;
|
|
let extend = if self.draws_path() && !self.smoothing { self.find_extend(cx, p) } else { None };
|
|
self.extend = extend.map(|(id, at_start, _)| (id, at_start));
|
|
self.redraw = if self.draws_path() && !self.smoothing && self.extend.is_none() { self.find_redraw(cx, p) } else { None };
|
|
// A path continued starts at its end, one redrawn on it; a new one where it snaps.
|
|
// A straight line is constrained from the end it continues, so it starts there.
|
|
let start = if let Some((_, _, end)) = extend.filter(|_| self.straight) {
|
|
end
|
|
} else if self.snaps() && !self.smoothing && self.extend.is_none() && self.redraw.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![];
|
|
}
|
|
let p = self.constrained(cx, p, ev.mods);
|
|
if !self.began && !self.straight && self.id == "pencil" && self.alt_smooth && ev.mods.alt {
|
|
self.smoothing = true;
|
|
}
|
|
if self.snaps() && !self.smoothing && !self.straight {
|
|
// Where the stroke would end: shown, taken on release.
|
|
self.snap.drag(cx, p, None);
|
|
}
|
|
if self.straight {
|
|
// The press's Smart Guides would stay on screen: the line's end doesn't snap.
|
|
self.snap.clear();
|
|
// The line runs from the press point to the pointer, wherever the drag went.
|
|
if self.points.first().is_some_and(|s| s.distance(p) < cx.tol(1.0)) {
|
|
// Back at the press point there is no line: the one shown goes.
|
|
self.points.truncate(1);
|
|
return if std::mem::take(&mut self.began) { vec![Action::Cancel] } else { vec![] };
|
|
}
|
|
self.points.truncate(1);
|
|
} else if self.points.last().is_some_and(|l| l.distance(p) < cx.tol(1.0)) || self.points.len() >= crate::MAX_STROKE_SAMPLES {
|
|
return vec![];
|
|
}
|
|
self.push(p, ev.pressure);
|
|
if !self.draws_path() || self.smoothing {
|
|
return vec![];
|
|
}
|
|
let mut out = vec![];
|
|
if !self.began {
|
|
self.began = true;
|
|
out.push(Action::Begin(self.label().into()));
|
|
}
|
|
if crate::previews_at(self.points.len()) {
|
|
self.previewed = self.points.len();
|
|
out.push(Action::Preview("path.freehand".into(), self.freehand_params(false)));
|
|
}
|
|
out
|
|
}
|
|
PointerKind::Up => {
|
|
if !self.active {
|
|
return vec![];
|
|
}
|
|
let p = self.constrained(cx, p, ev.mods);
|
|
let end = if self.snaps() && !self.smoothing && !self.straight { self.snap.drag(cx, p, None) } else { p };
|
|
self.snap.clear();
|
|
let mut cancel = false;
|
|
if self.straight {
|
|
self.points.truncate(1);
|
|
if self.points.first().is_some_and(|s| s.distance(p) >= cx.tol(1.0)) {
|
|
self.push(end, ev.pressure);
|
|
} else {
|
|
cancel = self.began;
|
|
}
|
|
} else 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.smoothing
|
|
&& 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 = if cancel { vec![Action::Cancel] } else { self.finish(cx, ev.mods) };
|
|
self.active = false;
|
|
self.began = false;
|
|
self.smoothing = false;
|
|
self.straight = false;
|
|
self.clear();
|
|
self.extend = None;
|
|
self.redraw = 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.smoothing = false;
|
|
self.straight = 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;
|
|
// Drags past the last preview are in the stroke kept, too.
|
|
let stale = (began && self.previewed != self.points.len() && self.draws_path() && !self.smoothing)
|
|
.then(|| Action::Preview("path.freehand".into(), self.freehand_params(false)));
|
|
self.snap.clear();
|
|
self.active = false;
|
|
self.began = false;
|
|
self.straight = false;
|
|
self.clear();
|
|
if began { stale.into_iter().chain([Action::Commit]).collect() } 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 {
|
|
// A drag that smooths, or Alt before a Pencil stroke has begun to draw (a stroke that
|
|
// has begun keeps drawing with Alt).
|
|
if self.smoothing || (self.id == "pencil" && self.alt_smooth && m.alt && !self.began) {
|
|
return Cursor::Smooth;
|
|
}
|
|
// Alt held at the release closes the path (`finish`).
|
|
// A straight line stays open, so its release has nothing to close.
|
|
if self.draws_path() && !self.alt_smooth && m.alt && !self.straight {
|
|
return if self.id == "paintbrush" { Cursor::PaintbrushClose } else { Cursor::PencilClose };
|
|
}
|
|
match self.id {
|
|
"paintbrush" => Cursor::Paintbrush,
|
|
"blobBrush" => Cursor::BlobBrush,
|
|
"eraser" => Cursor::Eraser,
|
|
"knife" => Cursor::Knife,
|
|
"smooth" => Cursor::Smooth,
|
|
"pathEraser" => Cursor::PathEraser,
|
|
"join" => Cursor::Join,
|
|
_ => Cursor::Pencil,
|
|
}
|
|
}
|
|
fn options(&self) -> Value {
|
|
match self.id {
|
|
"pencil" => {
|
|
json!({
|
|
"fidelity": self.fidelity,
|
|
"fill": self.fill,
|
|
"altSmooth": self.alt_smooth,
|
|
"keepSelected": self.keep_selected,
|
|
"editWithin": self.edit_within,
|
|
"closeWithin": self.close_within
|
|
})
|
|
}
|
|
"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" => json!({"size": self.size, "keepSelected": self.keep_selected, "mergeOnlySelection": self.merge_only_selection}),
|
|
"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),
|
|
"altSmooth" => self.alt_smooth = v.as_bool().unwrap_or(self.alt_smooth),
|
|
"keepSelected" => self.keep_selected = v.as_bool().unwrap_or(self.keep_selected),
|
|
"mergeOnlySelection" => self.merge_only_selection = v.as_bool().unwrap_or(self.merge_only_selection),
|
|
"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
|
|
}
|
|
|
|
fn drag_held(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).with_mods(m))];
|
|
for &(x, y) in &pts[1..] {
|
|
v.push(t.pointer(cx, &PointerEvent::new(PointerKind::Drag, x, y).with_mods(m)));
|
|
}
|
|
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}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Holding Alt shows the Pencil's and the Paintbrush's closing cursor, before the press and while
|
|
/// drawing; a release with Alt still held closes the path, and Alt let go before the release
|
|
/// leaves it open.
|
|
#[test]
|
|
fn alt_shows_the_closing_cursor_and_a_release_with_alt_closes() {
|
|
let (d, _) = doc_with_rect();
|
|
let s = Selection::default();
|
|
let p = paint();
|
|
let cx = cx(&d, &s, &p);
|
|
let alt = Mods { alt: true, ..Default::default() };
|
|
let at = Point::new(10.0, 5.0);
|
|
for (id, own, closing, style) in
|
|
[("pencil", Cursor::Pencil, Cursor::PencilClose, "pencil"), ("paintbrush", Cursor::Paintbrush, Cursor::PaintbrushClose, "brush")]
|
|
{
|
|
let mut t = GestureTool::new(id);
|
|
assert_eq!(t.cursor(&cx, at, Mods::default()), own, "{id}");
|
|
assert_eq!(t.cursor(&cx, at, alt), closing, "{id}");
|
|
for alt_at_release in [true, false] {
|
|
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0));
|
|
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0).with_mods(alt));
|
|
assert_eq!(t.cursor(&cx, at, alt), closing, "{id} while drawing");
|
|
assert_eq!(t.cursor(&cx, at, Mods::default()), own, "{id} while drawing");
|
|
let up = Mods { alt: alt_at_release, ..Default::default() };
|
|
let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 20.0, 0.0).with_mods(up));
|
|
assert!(
|
|
matches!(&acts[0], Action::Preview(_, v) if v["closed"] == alt_at_release && v["style"] == style),
|
|
"{id}, Alt at the release {alt_at_release}: {acts:?}"
|
|
);
|
|
}
|
|
}
|
|
// With Alt key toggles to Smooth Tool on, Alt lends the Pencil the Smooth tool instead, until
|
|
// a stroke has begun to draw: Alt then neither smooths nor closes, and the cursor stays the Pencil's.
|
|
let mut t = GestureTool::new("pencil");
|
|
t.set_option("altSmooth", &json!(true));
|
|
assert_eq!(t.cursor(&cx, at, alt), Cursor::Smooth);
|
|
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0));
|
|
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0));
|
|
assert_eq!(t.cursor(&cx, at, alt), Cursor::Pencil, "a stroke that has begun");
|
|
let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 20.0, 0.0).with_mods(alt));
|
|
assert!(matches!(&acts[0], Action::Preview(_, v) if v["closed"] == false), "{acts:?}");
|
|
// A smoothing drag keeps the Smooth cursor when Alt is let go.
|
|
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(alt));
|
|
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0).with_mods(alt));
|
|
assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::Smooth, "a smoothing drag");
|
|
}
|
|
|
|
/// The Blob Brush has Keep selected and Merge only with selection, both off by default, and its
|
|
/// stroke sends both.
|
|
#[test]
|
|
fn blob_brush_sends_keep_selected_and_merge_only_with_selection() {
|
|
let (d, _) = doc_with_rect();
|
|
let s = Selection::default();
|
|
let p = paint();
|
|
let cx = cx(&d, &s, &p);
|
|
let mut t = GestureTool::new("blobBrush");
|
|
assert_eq!((t.options()["keepSelected"].clone(), t.options()["mergeOnlySelection"].clone()), (json!(false), json!(false)));
|
|
for (keep, only) in [(false, false), (true, false), (false, true)] {
|
|
t.set_option("keepSelected", &json!(keep));
|
|
t.set_option("mergeOnlySelection", &json!(only));
|
|
let acts = drag(&mut t, &cx, &[(50.0, 150.0), (250.0, 150.0)], Mods::default());
|
|
assert!(
|
|
matches!(&acts[2][0], Action::Exec(c, v) if c == "path.blob" && v["keepSelected"] == keep && v["mergeOnlySelection"] == only),
|
|
"{acts:?}"
|
|
);
|
|
}
|
|
// The Eraser keeps Size only.
|
|
assert_eq!(GestureTool::new("eraser").options(), json!({"size": 10.0}));
|
|
}
|
|
|
|
/// Shift keeps a Blob Brush stroke on the horizontal or vertical line through the press point,
|
|
/// whichever is nearer to the pointer, at 90° steps from the Constrain Angle; the points
|
|
/// dragged with Shift held move to the other line together, and each point follows the Shift
|
|
/// state of its own event.
|
|
#[test]
|
|
fn blob_brush_shift_keeps_the_stroke_horizontal_or_vertical() {
|
|
let (d, _) = doc_with_rect();
|
|
let s = Selection::default();
|
|
let p = paint();
|
|
let mut cx = cx(&d, &s, &p);
|
|
let shift = Mods { shift: true, ..Default::default() };
|
|
let blob = |acts: &[Vec<Action>]| -> Vec<(f64, f64)> {
|
|
let Some(Action::Exec(c, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") };
|
|
assert_eq!(c, "path.blob");
|
|
v["points"].as_array().unwrap().iter().map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect()
|
|
};
|
|
let mut t = GestureTool::new("blobBrush");
|
|
let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, 5.0), (50.0, 8.0)], shift));
|
|
assert_eq!(pts.len(), 3);
|
|
assert!(pts.iter().all(|q| q.1 == 0.0 && q.0 >= 0.0), "{pts:?}");
|
|
let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (5.0, 30.0), (8.0, 50.0)], shift));
|
|
assert!(pts.iter().all(|q| q.0.abs() < 1e-9 && q.1 >= 0.0), "{pts:?}");
|
|
// A pointer that crosses to the vertical line takes the points before it along, each on
|
|
// its own side of the press point and at its own distance.
|
|
let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (-40.0, -2.0), (100.0, 99.0), (90.0, 120.0)], shift));
|
|
assert_eq!(pts.len(), 4, "{pts:?}");
|
|
assert!(pts.iter().all(|q| q.0.abs() < 1e-9), "{pts:?}");
|
|
assert!((pts[1].1 + 1604f64.sqrt()).abs() < 1e-9 && (pts[2].1 - 19801f64.sqrt()).abs() < 1e-9 && (pts[3].1 - 150.0).abs() < 1e-9, "{pts:?}");
|
|
// Without Shift the stroke follows the pointer.
|
|
let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, 5.0), (50.0, 8.0)], Mods::default()));
|
|
assert_eq!(pts, vec![(0.0, 0.0), (30.0, 5.0), (50.0, 8.0)]);
|
|
// Shift pressed and let go mid-stroke constrains only the points dragged while it is down,
|
|
// and a later press of Shift leaves the points of the earlier one where they are.
|
|
let ev = |k, x, y, m| PointerEvent::new(k, x, y).with_mods(m);
|
|
t.pointer(&cx, &ev(PointerKind::Down, 0.0, 0.0, Mods::default()));
|
|
t.pointer(&cx, &ev(PointerKind::Drag, 20.0, 10.0, Mods::default()));
|
|
t.pointer(&cx, &ev(PointerKind::Drag, 40.0, 10.0, shift));
|
|
t.pointer(&cx, &ev(PointerKind::Drag, 60.0, 10.0, Mods::default()));
|
|
let pts = blob(&[t.pointer(&cx, &ev(PointerKind::Up, 10.0, 80.0, shift))]);
|
|
assert_eq!((pts.len(), pts[1], pts[3]), (5, (20.0, 10.0), (60.0, 10.0)), "{pts:?}");
|
|
assert!(pts[2].1 == 0.0 && (pts[2].0 - 1700f64.sqrt()).abs() < 1e-9, "{pts:?}");
|
|
assert!(pts[4].0.abs() < 1e-9 && (pts[4].1 - 6500f64.sqrt()).abs() < 1e-9, "{pts:?}");
|
|
// The axes turn with the Constrain Angle.
|
|
cx.constrain_angle = 30.0;
|
|
let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (40.0, -20.0)], shift));
|
|
let a = (-pts[1].1).atan2(pts[1].0).to_degrees();
|
|
assert!((a - 30.0).abs() < 1e-9, "{pts:?}");
|
|
// A pointer back at the press point leaves the stroke on its line.
|
|
let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (40.0, -20.0), (0.0, 0.0)], shift));
|
|
let a = (-pts[1].1).atan2(pts[1].0).to_degrees();
|
|
assert!(pts.len() == 3 && pts[2] == (0.0, 0.0) && (a - 30.0).abs() < 1e-9, "{pts:?}");
|
|
}
|
|
|
|
/// #3799: Shift held at the press draws a straight Pencil line from the press point, at 45°
|
|
/// steps from the Constrain Angle, previewed as it is dragged; the Paintbrush keeps following
|
|
/// the hand.
|
|
#[test]
|
|
fn pencil_shift_draws_a_straight_line_at_45_degree_steps() {
|
|
let (d, _) = doc_with_rect();
|
|
let s = Selection::default();
|
|
let p = paint();
|
|
let mut cx = cx(&d, &s, &p);
|
|
cx.smart_guides = false;
|
|
let shift = Mods { shift: true, ..Default::default() };
|
|
let pts = |acts: &[Vec<Action>]| -> Vec<Vec<(f64, f64)>> {
|
|
acts.iter()
|
|
.filter_map(|a| a.iter().find_map(|a| if let Action::Preview(_, v) = a { Some(v["points"].clone()) } else { None }))
|
|
.map(|v| v.as_array().unwrap().iter().map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect())
|
|
.collect()
|
|
};
|
|
let angle = |q: (f64, f64), from: (f64, f64)| (-(q.1 - from.1)).atan2(q.0 - from.0).to_degrees();
|
|
let mut t = GestureTool::new("pencil");
|
|
// A wavy drag heading up and right at about 40°: a line at 45°, as long as the drag.
|
|
let acts = drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, -10.0), (50.0, -60.0), (100.0, -84.0)], shift);
|
|
let all = pts(&acts);
|
|
assert!(all.iter().all(|v| v.len() == 2 && v[0] == (0.0, 0.0)), "{all:?}");
|
|
let end = *all.last().unwrap().last().unwrap();
|
|
assert!((angle(end, (0.0, 0.0)) - 45.0).abs() < 1e-9, "{end:?}");
|
|
assert!((end.0.hypot(end.1) - 100f64.hypot(84.0)).abs() < 1e-9, "{end:?}");
|
|
let last = acts.last().unwrap();
|
|
assert!(matches!(&last[0], Action::Preview(_, v) if v["closed"] == false) && last[1] == Action::Commit, "{last:?}");
|
|
// The steps turn with the Constrain Angle: at 30°, a drag at 40° gives 30°, one at 60° gives 75°.
|
|
cx.constrain_angle = 30.0;
|
|
for (to, want) in [((100.0, -84.0), 30.0), ((50.0, -86.6), 75.0)] {
|
|
let all = pts(&drag_held(&mut t, &cx, &[(10.0, 10.0), (to.0 + 10.0, to.1 + 10.0)], shift));
|
|
let end = *all.last().unwrap().last().unwrap();
|
|
assert!((angle(end, (10.0, 10.0)) - want).abs() < 1e-9, "{to:?}: {all:?}");
|
|
}
|
|
// Without Shift, and with the Paintbrush, the stroke follows the hand.
|
|
for (id, m) in [("pencil", Mods::default()), ("paintbrush", shift)] {
|
|
let mut t = GestureTool::new(id);
|
|
let all = pts(&drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, -10.0), (50.0, -60.0)], m));
|
|
assert_eq!(all.last().unwrap().len(), 3, "{id}: {all:?}");
|
|
}
|
|
}
|
|
|
|
/// #3799: a Shift line leaves no Smart Guides of its press on screen while it is dragged
|
|
/// (its end doesn't snap), and Alt at its release leaves it open.
|
|
#[test]
|
|
fn pencil_shift_line_drops_the_press_guides_and_stays_open_with_alt() {
|
|
let (d, _) = doc_with_rect();
|
|
let s = Selection::default();
|
|
let p = paint();
|
|
let cx = cx(&d, &s, &p);
|
|
let shift = Mods { shift: true, ..Default::default() };
|
|
let mut t = GestureTool::new("pencil");
|
|
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 102.0, 98.0).with_mods(shift));
|
|
assert!(!t.overlays(&cx).is_empty(), "the press snaps to the corner and shows it");
|
|
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 140.0).with_mods(shift));
|
|
assert!(t.overlays(&cx).is_empty(), "{:?}", t.overlays(&cx));
|
|
let shift_alt = Mods { shift: true, alt: true, ..Default::default() };
|
|
let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 300.0, 140.0).with_mods(shift_alt));
|
|
assert!(matches!(&acts[0], Action::Preview(_, v) if v["closed"] == false && v["points"].as_array().unwrap().len() == 2), "{acts:?}");
|
|
}
|
|
|
|
/// #3799: a Shift line started on an end of a selected open path continues the path from that
|
|
/// end, constrained from the end itself.
|
|
#[test]
|
|
fn pencil_shift_line_continues_a_selected_path_from_its_end() {
|
|
let (mut d, _) = doc_with_rect();
|
|
let l = d.layers[0].id;
|
|
let id = d.alloc_id();
|
|
let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![
|
|
vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)),
|
|
vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)),
|
|
]));
|
|
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap();
|
|
let mut s = Selection::default();
|
|
s.set([id]);
|
|
let p = paint();
|
|
let mut cx = cx(&d, &s, &p);
|
|
cx.smart_guides = false;
|
|
let mut t = GestureTool::new("pencil");
|
|
// Pressed 5 pt off the end, released 100 pt right and 4 pt above it.
|
|
let acts = drag_held(&mut t, &cx, &[(405.0, 303.0), (450.0, 290.0), (500.0, 296.0)], Mods { shift: true, ..Default::default() });
|
|
let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") };
|
|
assert_eq!(v["extend"], json!({"id": id.0, "end": "end"}));
|
|
assert_eq!(v["points"], json!([[400.0, 300.0], [400.0 + 100f64.hypot(4.0), 300.0]]));
|
|
}
|
|
|
|
/// #3846: Alt during a Shift line doesn't promise a close the release can't do, and with Alt
|
|
/// Toggles to Smooth, Alt pressed after the press doesn't turn the line into a smoothing drag.
|
|
#[test]
|
|
fn pencil_shift_line_ignores_alt_for_cursor_and_smoothing() {
|
|
let (d, _) = doc_with_rect();
|
|
let s = Selection::default();
|
|
let p = paint();
|
|
let mut cx = cx(&d, &s, &p);
|
|
cx.smart_guides = false;
|
|
let shift = Mods { shift: true, ..Default::default() };
|
|
let shift_alt = Mods { shift: true, alt: true, ..Default::default() };
|
|
let mut t = GestureTool::new("pencil");
|
|
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(shift));
|
|
assert_eq!(t.cursor(&cx, Point::new(0.0, 0.0), shift_alt), Cursor::Pencil);
|
|
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 0.0, 0.0).with_mods(shift));
|
|
|
|
let mut t = GestureTool::new("pencil");
|
|
t.alt_smooth = true;
|
|
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(shift));
|
|
let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 80.0, 0.0).with_mods(shift_alt));
|
|
assert!(matches!(acts.as_slice(), [Action::Begin(_), Action::Preview(..)]), "{acts:?}");
|
|
let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 80.0, 0.0).with_mods(shift_alt));
|
|
assert!(matches!(&acts[0], Action::Preview(_, v) if v["points"].as_array().unwrap().len() == 2), "{acts:?}");
|
|
}
|
|
|
|
/// #3846: a Shift line dragged back to its press point has no line: the preview is cancelled
|
|
/// and the release commits nothing (not the previous end of the line).
|
|
#[test]
|
|
fn pencil_shift_line_back_at_the_press_point_draws_nothing() {
|
|
let (d, _) = doc_with_rect();
|
|
let s = Selection::default();
|
|
let p = paint();
|
|
let mut cx = cx(&d, &s, &p);
|
|
cx.smart_guides = false;
|
|
let shift = Mods { shift: true, ..Default::default() };
|
|
// Released back at the press point, and dragged back there first.
|
|
for back_in_drag in [false, true] {
|
|
let mut t = GestureTool::new("pencil");
|
|
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(shift));
|
|
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 80.0, 0.0).with_mods(shift));
|
|
let mut acts = vec![];
|
|
if back_in_drag {
|
|
// The shown line goes as soon as the pointer is back, not only at the release.
|
|
let back = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 0.2, 0.0).with_mods(shift));
|
|
assert_eq!(back, vec![Action::Cancel]);
|
|
acts.extend(back);
|
|
}
|
|
acts.extend(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 0.2, 0.0).with_mods(shift)));
|
|
assert!(acts.contains(&Action::Cancel), "{back_in_drag}: {acts:?}");
|
|
assert!(!acts.contains(&Action::Commit) && !acts.iter().any(|a| matches!(a, Action::Preview(..))), "{back_in_drag}: {acts:?}");
|
|
}
|
|
}
|
|
|
|
#[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()));
|
|
}
|
|
|
|
/// Edit selected paths within 0 turns editing off: a stroke that starts exactly on an end of a
|
|
/// selected open path draws a new path; at 12 px it continues the path from that end.
|
|
#[test]
|
|
fn edit_selected_paths_within_0_draws_a_new_path_from_an_end() {
|
|
let (mut d, _) = doc_with_rect();
|
|
let l = d.layers[0].id;
|
|
let id = d.alloc_id();
|
|
let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![
|
|
vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)),
|
|
vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)),
|
|
]));
|
|
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap();
|
|
let mut s = Selection::default();
|
|
s.set([id]);
|
|
let p = paint();
|
|
let cx = cx(&d, &s, &p);
|
|
for tool in ["pencil", "paintbrush"] {
|
|
let mut t = GestureTool::new(tool);
|
|
for (within, extends) in [(12.0, true), (0.0, false)] {
|
|
t.set_option("editWithin", &json!(within));
|
|
for (x, end) in [(400.0, "end"), (300.0, "start")] {
|
|
let acts = drag(&mut t, &cx, &[(x, 300.0), (x + 20.0, 350.0), (x, 380.0)], Mods::default());
|
|
let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{tool}: {acts:?}") };
|
|
if extends {
|
|
assert_eq!(v["extend"], json!({"id": id.0, "end": end}), "{tool} from the {end}");
|
|
} else {
|
|
assert!(v.get("extend").is_none() && v.get("redraw").is_none(), "{tool} from the {end}: {v}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Close paths when ends are within 0 turns closing off: a stroke that ends exactly where it
|
|
/// started stays open; at 15 px it is closed.
|
|
#[test]
|
|
fn close_paths_within_0_leaves_a_stroke_that_ends_at_its_start_open() {
|
|
let (d, _) = doc_with_rect();
|
|
let s = Selection::default();
|
|
let p = paint();
|
|
let cx = cx(&d, &s, &p);
|
|
for tool in ["pencil", "paintbrush"] {
|
|
let mut t = GestureTool::new(tool);
|
|
for (within, closes) in [(15.0, true), (0.0, false)] {
|
|
t.set_option("closeWithin", &json!(within));
|
|
let acts = drag(&mut t, &cx, &[(310.0, 330.0), (360.0, 350.0), (330.0, 390.0), (310.0, 330.0)], Mods::default());
|
|
let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{tool}: {acts:?}") };
|
|
assert_eq!(v["points"][0], v["points"][3], "{tool}");
|
|
assert_eq!(v["closed"], json!(closes), "{tool} at {within} px");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
|
|
#[test]
|
|
fn pencil_alt_toggles_to_smooth_when_enabled_and_closes_when_disabled() {
|
|
let (d, id) = doc_with_rect();
|
|
let mut s = Selection::default();
|
|
s.set([id]);
|
|
let p = paint();
|
|
let cx = cx(&d, &s, &p);
|
|
|
|
// Off by default, as Pencil Tool Options ships it; on, Alt lends the Smooth tool.
|
|
let mut t = GestureTool::new("pencil");
|
|
assert_eq!(t.options()["altSmooth"], json!(false));
|
|
t.set_option("altSmooth", &json!(true));
|
|
|
|
// When alt is pressed, cursor shows smooth tool.
|
|
let alt_mod = Mods { alt: true, ..Default::default() };
|
|
assert_eq!(t.cursor(&cx, Point::new(50.0, 50.0), alt_mod), Cursor::Smooth);
|
|
assert_eq!(t.cursor(&cx, Point::new(50.0, 50.0), Mods::default()), Cursor::Pencil);
|
|
|
|
// Alt-drag over selected path smooths it.
|
|
let acts = drag_held(&mut t, &cx, &[(50.0, 150.0), (150.0, 150.0), (250.0, 150.0)], alt_mod);
|
|
assert!(acts[1].is_empty(), "drag does not preview freehand path while smoothing");
|
|
let last = acts.last().unwrap();
|
|
assert!(
|
|
matches!(&last[0], Action::Exec(c, v) if c == "path.smoothRegion" && v["points"].as_array().unwrap().len() == 3),
|
|
"expected path.smoothRegion, got: {last:?}"
|
|
);
|
|
|
|
// When altSmooth is disabled:
|
|
t.set_option("altSmooth", &json!(false));
|
|
assert_eq!(t.options()["altSmooth"], json!(false));
|
|
assert_eq!(t.cursor(&cx, Point::new(50.0, 50.0), alt_mod), Cursor::PencilClose);
|
|
|
|
// Alt-drag draws a closed pencil path.
|
|
let empty_sel = Selection::default();
|
|
let cx_empty = crate::testutil::cx(&d, &empty_sel, &p);
|
|
let acts = drag(&mut t, &cx_empty, &[(0.0, 0.0), (10.0, 5.0), (20.0, 0.0)], alt_mod);
|
|
assert_eq!(acts[1][0], Action::Begin("Pencil".into()));
|
|
let last = acts.last().unwrap();
|
|
assert!(matches!(&last[0], Action::Preview(_, v) if v["closed"] == true && v["style"] == "pencil"));
|
|
assert_eq!(last[1], Action::Commit);
|
|
}
|
|
}
|