Files
vectorcraft/crates/tools/src/distort/width.rs
T
bflatasticandClaude Opus 5.5 3b99df3b9a M14.5: Preferences > Units drive every length the UI shows and reads
The General unit is the active document's units: Preferences > Units >
General sets them (one undo step) and is the units new documents start
in (and the unit shown with no document open); Document Setup changes
only the document. The Transform panel, Properties, the Control bar, the
Info panel, rulers, the status bar, Align spacing, Tabs, paragraph
indents, Pattern Options, dialog distances (Move, shape sizes, path
dialogs, Artboard Options, effect distances, Transform Each, parameter
dialogs), New Document and the canvas measurement labels (smart-guide
sizes, moves, the Measure tool, the Live Corners radius) all follow it,
live. Units > Type drives font size, leading and baseline shift; Units >
Stroke also reaches the Appearance panel and the Magic Wand weight
tolerance. Typed units ("5 mm", "12 pt") work in any field; all ten
units are choices; length preferences show in their unit and accept a
unit; offset/simplify/grid/type-size params read typed units. Typed text
starting with a multi-byte character no longer panics the parser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:43:41 +07:00

561 lines
26 KiB
Rust

//! Width tool (Shift+W).
//!
//! Hovering a stroked path shows a hollow width-point diamond with the stroke's width at that
//! spot. Dragging outward from the path creates (or, on an existing width point's handle end,
//! edits) a width point — symmetric by default, Alt changes only the side being dragged. Dragging
//! a width point's centre slides it along the path; Alt-dragging the centre slides a copy; dropping
//! either onto another width point makes a discontinuous point (the width steps there). Shift-click
//! adds width points to the selection (or takes them out), and dragging one of several selected
//! points moves or widens them all. Double-clicking a width point opens Width Point Edit (the
//! `widthPoint` dialog). Delete/Backspace removes the selected width points.
//!
//! Gestures preview `stroke.widthPoint.set {id, t, left, right, index?}` (side widths in points),
//! `stroke.widthPoint.copy {id, index, t}` or, for several points, `stroke.widthProfile.set {ids,
//! points}`, and commit on release; Delete runs `stroke.widthPoint.remove {id, indices}`.
use serde_json::json;
use vectorcraft_doc::{Document, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{Point, Vec2};
use super::pathutil::{eval_fraction, left_normal, nearest_fraction};
use super::{BLUE, diamond};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
/// Width points closer than this (in t) are at the same place (the two sides of a discontinuous
/// point).
const SAME_T: f64 = 1e-6;
/// Where the pointer is relative to a stroked path.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Spot {
id: NodeId,
sub: usize,
/// Fraction along the subpath.
t: f64,
p: Point,
/// Left normal.
n: Vec2,
/// Side widths (points) at `t`.
left: f64,
right: f64,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Mode {
/// Change widths; `Some(left?)` = only one side (Alt).
Width(Option<bool>),
/// Slide along the path.
Move,
/// Slide a copy along the path (Alt-drag the centre).
Copy,
}
/// A width point as the tool shows it.
#[derive(Clone, Copy, Debug, PartialEq)]
struct WPoint {
index: usize,
t: f64,
/// On-path point and left normal.
p: Point,
n: Vec2,
/// Side widths (points).
left: f64,
right: f64,
}
/// Several selected width points dragged together.
#[derive(Clone, Debug, PartialEq)]
struct Group {
/// The profile's points when the drag began.
points: Vec<(f64, f64, f64)>,
/// Which of them move.
moving: Vec<usize>,
/// The stroke's half weight.
half: f64,
}
#[derive(Clone, Debug, PartialEq)]
struct Drag {
/// Where the dragged point started (`t`, widths); `p` and `n` follow a slide.
spot: Spot,
index: Option<usize>,
mode: Mode,
left: f64,
right: f64,
t: f64,
group: Option<Group>,
/// The other width points of the subpath when the drag began, which a slid point snaps onto.
others: Vec<WPoint>,
moved: bool,
}
#[derive(Default)]
pub struct WidthTool {
hover: Option<Spot>,
drag: Option<Drag>,
/// Selected width points: the path and their positions t.
selected: Option<(NodeId, Vec<f64>)>,
}
fn stroke_of(doc: &Document, id: NodeId) -> Option<&StrokeLayer> {
doc.node(id)?.appearance.stroke().filter(|s| s.visible && s.width > 0.0 && !s.paint.is_none())
}
/// Side widths (points) of a stroke at fraction `t`.
pub fn sides_at(st: &StrokeLayer, t: f64) -> (f64, f64) {
let (l, r) = st.profile.as_ref().map(|p| p.at(t)).unwrap_or((1.0, 1.0));
(l * st.width / 2.0, r * st.width / 2.0)
}
impl WidthTool {
/// The stroked path nearest to `p` within reach (stroke half-width + a few pixels).
fn spot_at(cx: &ToolContext, p: Point) -> Option<Spot> {
let consider = |id: NodeId| -> Option<(f64, Spot)> {
if !cx.doc.is_editable(id) {
return None;
}
let n = cx.doc.node(id)?;
let NodeKind::Path { path, guide: false, .. } = &n.kind else { return None };
let st = stroke_of(cx.doc, id)?;
let widest = st.profile.as_ref().map_or(1.0, |pr| pr.points.iter().map(|q| q.1.max(q.2)).fold(1.0, f64::max));
let reach = widest * st.width / 2.0 + cx.tol(4.0);
if !path.bounds()?.inflate(reach, reach).contains(p) {
return None;
}
let (sub, t, q, tan, d) = nearest_fraction(path, p)?;
if d > reach {
return None;
}
let (left, right) = sides_at(st, t);
Some((d, Spot { id, sub, t, p: q, n: left_normal(tan), left, right }))
};
let best_of = |ids: &mut dyn Iterator<Item = NodeId>| ids.filter_map(consider).min_by(|a, b| a.0.total_cmp(&b.0));
if let Some(b) = best_of(&mut cx.selection.objects.iter().copied()) {
return Some(b.1);
}
let mut all = vec![];
cx.doc.walk(|n| {
if matches!(n.kind, NodeKind::Path { .. }) {
all.push(n.id);
}
});
best_of(&mut all.into_iter().rev()).map(|b| b.1)
}
/// Width points of `id` on subpath `sub`.
fn points_of(cx: &ToolContext, id: NodeId, sub: usize) -> Vec<WPoint> {
let Some(st) = stroke_of(cx.doc, id) else { return vec![] };
let Some(pr) = &st.profile else { return vec![] };
let Some(NodeKind::Path { path, .. }) = cx.doc.node(id).map(|n| &n.kind) else { return vec![] };
let Some(sp) = path.subpaths.get(sub) else { return vec![] };
pr.points
.iter()
.enumerate()
.filter_map(|(index, (t, l, r))| {
let (p, tan) = eval_fraction(sp, *t)?;
Some(WPoint { index, t: *t, p, n: left_normal(tan), left: l * st.width / 2.0, right: r * st.width / 2.0 })
})
.collect()
}
/// The width point part nearest to `p` within a few pixels: a centre (`None`) or the end of a
/// left (`Some(true)`) or right (`Some(false)`) handle.
fn hit(cx: &ToolContext, spot: &Spot, p: Point) -> Option<(WPoint, Option<bool>)> {
let tol = cx.tol(5.0);
Self::points_of(cx, spot.id, spot.sub)
.into_iter()
.flat_map(|w| [(w, None, w.p), (w, Some(true), w.p + w.n * w.left), (w, Some(false), w.p - w.n * w.right)])
.map(|(w, part, q)| (q.distance(p), w, part))
.filter(|h| h.0 <= tol)
.min_by(|a, b| a.0.total_cmp(&b.0))
.map(|(_, w, part)| (w, part))
}
fn is_selected(&self, id: NodeId, t: f64) -> bool {
self.selected.as_ref().is_some_and(|(sid, ts)| *sid == id && ts.iter().any(|s| (s - t).abs() <= SAME_T))
}
/// Shift-click on the width point at `t` of `id`: add it to the selection or take it out.
/// → whether it is selected now.
fn toggle(&mut self, id: NodeId, t: f64) -> bool {
match &mut self.selected {
Some((sid, ts)) if *sid == id => match ts.iter().position(|s| (s - t).abs() <= SAME_T) {
Some(i) => {
ts.remove(i);
false
}
None => {
ts.push(t);
true
}
},
_ => {
self.selected = Some((id, vec![t]));
true
}
}
}
/// The indices of the selected width points of `id` (both sides of a discontinuous point).
fn selected_indices(&self, cx: &ToolContext, id: NodeId) -> Vec<usize> {
let Some(pr) = stroke_of(cx.doc, id).and_then(|s| s.profile.as_ref()) else { return vec![] };
(0..pr.points.len()).filter(|i| self.is_selected(id, pr.points[*i].0)).collect()
}
/// A drag of `id`'s selected points together: a move when more than one profile point is
/// selected (a discontinuous point moves as one), a width change when several are.
fn group(&self, cx: &ToolContext, id: NodeId, mode: Mode) -> Option<Group> {
let st = stroke_of(cx.doc, id)?;
let moving = self.selected_indices(cx, id);
let several = match mode {
Mode::Move => moving.len() > 1,
Mode::Width(_) => self.selected.as_ref().is_some_and(|(_, ts)| ts.len() > 1),
Mode::Copy => false,
};
several.then(|| Group { points: st.profile.as_ref().map(|p| p.points.clone()).unwrap_or_default(), moving, half: st.width / 2.0 })
}
/// The command previewing drag `d`.
fn preview(d: &Drag) -> Action {
let id = d.spot.id.0;
if let Some(g) = &d.group {
let (dt, dl, dr) = (d.t - d.spot.t, (d.left - d.spot.left) / g.half, (d.right - d.spot.right) / g.half);
let mut pts: Vec<[f64; 3]> = g
.points
.iter()
.enumerate()
.map(|(i, &(t, l, r))| match (g.moving.contains(&i), d.mode) {
(true, Mode::Move) => [(t + dt).clamp(0.0, 1.0), l, r],
(true, _) => [t, (l + dl).max(0.0), (r + dr).max(0.0)],
(false, _) => [t, l, r],
})
.collect();
pts.sort_by(|a, b| a[0].total_cmp(&b[0]));
return Action::Preview("stroke.widthProfile.set".into(), json!({"ids": [id], "points": pts}));
}
match (d.mode, d.index) {
(Mode::Copy, Some(i)) => Action::Preview("stroke.widthPoint.copy".into(), json!({"id": id, "index": i, "t": d.t})),
(_, index) => {
let mut v = json!({"id": id, "t": d.t, "left": d.left, "right": d.right});
if let Some(i) = index {
v["index"] = json!(i);
}
Action::Preview("stroke.widthPoint.set".into(), v)
}
}
}
}
impl Tool for WidthTool {
fn id(&self) -> &'static str {
"width"
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
match ev.kind {
PointerKind::Move => {
self.hover = Self::spot_at(cx, p);
vec![]
}
PointerKind::DoubleClick => {
let Some(spot) = Self::spot_at(cx, p) else { return vec![] };
let Some((w, _)) = Self::hit(cx, &spot, p) else { return vec![] };
self.selected = Some((spot.id, vec![w.t]));
vec![Action::Dialog("widthPoint".into(), json!({"id": spot.id.0, "index": w.index}))]
}
PointerKind::Down => {
let Some(spot) = Self::spot_at(cx, p) else {
if !ev.mods.shift {
self.selected = None;
}
return vec![];
};
let drag = match Self::hit(cx, &spot, p) {
Some((w, part)) => {
let mode = match part {
None if ev.mods.alt => Mode::Copy,
None => Mode::Move,
Some(left) => Mode::Width(ev.mods.alt.then_some(left)),
};
if ev.mods.shift {
if !self.toggle(spot.id, w.t) {
return vec![];
}
} else if mode == Mode::Copy || !self.is_selected(spot.id, w.t) {
self.selected = Some((spot.id, vec![w.t]));
}
let others = Self::points_of(cx, spot.id, spot.sub).into_iter().filter(|o| o.index != w.index).collect();
let group = self.group(cx, spot.id, mode);
let spot = Spot { t: w.t, p: w.p, n: w.n, left: w.left, right: w.right, ..spot };
Drag { spot, index: Some(w.index), mode, left: w.left, right: w.right, t: w.t, group, others, moved: false }
}
None => {
let side = (p - spot.p).dot(spot.n) >= 0.0;
self.selected = Some((spot.id, vec![spot.t]));
let mode = Mode::Width(ev.mods.alt.then_some(side));
Drag { spot, index: None, mode, left: spot.left, right: spot.right, t: spot.t, group: None, others: vec![], moved: false }
}
};
let label = if drag.mode == Mode::Copy { "Copy Width Point" } else { "Width Point" };
self.drag = Some(drag);
vec![Action::Begin(label.into())]
}
PointerKind::Drag => {
let Some(d) = &mut self.drag else { return vec![] };
d.moved = true;
match d.mode {
Mode::Width(side) => {
let off = (p - d.spot.p).dot(d.spot.n);
match side {
None => {
d.left = off.abs();
d.right = off.abs();
}
Some(true) => d.left = off.max(0.0),
Some(false) => d.right = (-off).max(0.0),
}
}
Mode::Move | Mode::Copy => {
let Some(NodeKind::Path { path, .. }) = cx.doc.node(d.spot.id).map(|n| &n.kind) else { return vec![] };
let Some(sp) = path.subpaths.get(d.spot.sub) else { return vec![] };
let Some((_, t, q, tan, _)) = nearest_fraction(&vectorcraft_geom::PathData::single(sp.clone()), p) else { return vec![] };
(d.t, d.spot.p, d.spot.n) = (t, q, left_normal(tan));
// Dropped onto another width point (a single point only): it lands exactly
// there, making a discontinuous point.
let tol = cx.tol(5.0);
if d.group.is_none()
&& let Some(o) = d.others.iter().find(|o| o.p.distance(q) <= tol)
{
(d.t, d.spot.p, d.spot.n) = (o.t, o.p, o.n);
}
}
}
vec![Self::preview(d)]
}
PointerKind::Up => match self.drag.take() {
Some(d) => {
let dt = d.t - d.spot.t;
self.selected = match (d.group.is_some(), d.moved, self.selected.take()) {
// A click on one of several selected points selects just that one.
(true, false, _) if !ev.mods.shift => Some((d.spot.id, vec![d.spot.t])),
// Points moved together stay selected.
(true, _, Some((id, ts))) if d.mode == Mode::Move => Some((id, ts.into_iter().map(|t| (t + dt).clamp(0.0, 1.0)).collect())),
(true, _, sel) => sel,
(false, _, _) => Some((d.spot.id, vec![d.t])),
};
vec![Action::Commit]
}
None => vec![],
},
}
}
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
if !matches!(key, ToolKey::Delete | ToolKey::Backspace) {
return vec![];
}
let Some(id) = self.selected.as_ref().map(|s| s.0) else { return vec![] };
let indices = self.selected_indices(cx, id);
if indices.is_empty() {
return vec![];
}
self.selected = None;
vec![Action::Exec("stroke.widthPoint.remove".into(), json!({"id": id.0, "indices": indices}))]
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut out = vec![];
let r = cx.tol(4.0);
let focus = self.drag.as_ref().map(|d| d.spot).or(self.hover);
let show = |id: NodeId, sub: usize, out: &mut Vec<Overlay>| {
for w in Self::points_of(cx, id, sub) {
let sel = self.is_selected(id, w.t);
out.push(Overlay::Line { a: w.p + w.n * w.left, b: w.p - w.n * w.right, color: BLUE, dashed: false });
out.push(Overlay::Path { path: diamond(w.p, r), color: BLUE, width: if sel { 2.5 } else { 1.0 }, dashed: false });
out.push(Overlay::Handle { p: w.p + w.n * w.left, color: BLUE });
out.push(Overlay::Handle { p: w.p - w.n * w.right, color: BLUE });
}
};
if let Some(s) = focus {
show(s.id, s.sub, &mut out);
}
if let Some((id, _)) = &self.selected
&& focus.is_none_or(|s| s.id != *id)
{
show(*id, 0, &mut out);
}
if let Some(d) = &self.drag {
let q = d.spot.p;
out.push(Overlay::Line { a: q + d.spot.n * d.left, b: q - d.spot.n * d.right, color: BLUE, dashed: false });
out.push(Overlay::Path { path: diamond(q, r), color: BLUE, width: 2.0, dashed: false });
out.push(Overlay::Measure { p: q + d.spot.n * (d.left + r * 3.0), text: format!("W: {}", cx.stroke_unit.readout(d.left + d.right)) });
} else if let Some(h) = &self.hover {
out.push(Overlay::Line { a: h.p + h.n * h.left, b: h.p - h.n * h.right, color: BLUE, dashed: true });
out.push(Overlay::Path { path: diamond(h.p, r), color: BLUE, width: 1.0, dashed: false });
}
out
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
Cursor::Crosshair
}
fn busy(&self) -> bool {
self.drag.is_some()
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
self.hover = None;
if self.drag.take().is_some() { vec![Action::Commit] } else { vec![] }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use vectorcraft_doc::{Appearance, Node, Selection, WidthProfile};
use vectorcraft_geom::{PathData, SubPath};
fn doc_line() -> (Document, NodeId) {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let id = d.alloc_id();
let mut ap = Appearance::default_art();
ap.stroke_mut().unwrap().width = 10.0;
let path = PathData::single(SubPath::polyline(&[Point::new(100.0, 200.0), Point::new(300.0, 200.0)], false));
d.insert(Some(l), 0, Node::path(id, path, ap)).unwrap();
(d, id)
}
/// The line with width points at t = 0.25, 0.5 and 0.75 (x = 150, 200, 250; 5 pt either side,
/// the stroke's own width).
fn doc_three_points() -> (Document, NodeId) {
let (mut d, id) = doc_line();
let points = vec![(0.0, 1.0, 1.0), (0.25, 1.0, 1.0), (0.5, 1.0, 1.0), (0.75, 1.0, 1.0), (1.0, 1.0, 1.0)];
d.node_mut(id).unwrap().appearance.stroke_mut().unwrap().profile = Some(WidthProfile { points });
(d, id)
}
fn ev(kind: PointerKind, x: f64, y: f64, mods: Mods) -> PointerEvent {
PointerEvent::new(kind, x, y).with_mods(mods)
}
fn preview(acts: &[Action]) -> (&str, &serde_json::Value) {
match acts {
[Action::Preview(cmd, v)] => (cmd.as_str(), v),
_ => panic!("{acts:?}"),
}
}
#[test]
fn drag_outward_creates_symmetric_width_point() {
let (d, id) = doc_line();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let mut t = WidthTool::default();
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 201.0)), vec![Action::Begin("Width Point".into())]);
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 150.0, 220.0));
let (cmd, v) = preview(&acts);
assert_eq!(cmd, "stroke.widthPoint.set");
assert_eq!(v["id"], json!(id.0));
assert!((v["t"].as_f64().unwrap() - 0.25).abs() < 1e-3);
assert!((v["left"].as_f64().unwrap() - 20.0).abs() < 1e-6 && (v["right"].as_f64().unwrap() - 20.0).abs() < 1e-6);
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 150.0, 220.0)), vec![Action::Commit]);
}
#[test]
fn alt_drag_changes_one_side_and_move_slides() {
let (mut d, id) = doc_line();
let n = d.node_mut(id).unwrap();
n.appearance.stroke_mut().unwrap().profile = Some(WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 2.0, 2.0), (1.0, 1.0, 1.0)] });
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let mut t = WidthTool::default();
// Alt-drag the left handle end (y = 200 - 10) further up.
let alt = Mods { alt: true, ..Default::default() };
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 200.0, 190.0).with_mods(alt));
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 200.0, 170.0).with_mods(alt));
let (_, v) = preview(&acts);
assert_eq!(v["index"], json!(1));
assert!((v["left"].as_f64().unwrap() - 30.0).abs() < 1e-6 && (v["right"].as_f64().unwrap() - 10.0).abs() < 1e-6);
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 200.0, 170.0));
let del = t.key(&c, ToolKey::Delete, Mods::default());
assert_eq!(del, vec![Action::Exec("stroke.widthPoint.remove".into(), json!({"id": id.0, "indices": [1]}))]);
// Drag the centre along the path.
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 200.0, 200.0));
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 205.0));
let (_, v) = preview(&acts);
assert!((v["t"].as_f64().unwrap() - 0.75).abs() < 1e-3);
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 250.0, 205.0));
assert!(t.selected.as_ref().is_some_and(|(_, ts)| (ts[0] - 0.75).abs() < 1e-3), "the moved point stays selected");
}
#[test]
fn shift_click_selects_several_points_that_drag_together() {
let (d, id) = doc_three_points();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let mut t = WidthTool::default();
let (none, shift) = (Mods::default(), Mods { shift: true, ..Default::default() });
// Click the point at t = 0.25, Shift-click the one at 0.75.
t.pointer(&c, &ev(PointerKind::Down, 150.0, 200.0, none));
t.pointer(&c, &ev(PointerKind::Up, 150.0, 200.0, none));
assert_eq!(t.pointer(&c, &ev(PointerKind::Down, 250.0, 200.0, shift)), vec![Action::Begin("Width Point".into())]);
t.pointer(&c, &ev(PointerKind::Up, 250.0, 200.0, shift));
assert_eq!(t.selected_indices(&c, id), vec![1, 3]);
// Dragging one of them 20 pt along moves both by a tenth of the path, as one command.
t.pointer(&c, &ev(PointerKind::Down, 150.0, 200.0, none));
let acts = t.pointer(&c, &ev(PointerKind::Drag, 170.0, 203.0, none));
let (cmd, v) = preview(&acts);
assert_eq!((cmd, &v["ids"]), ("stroke.widthProfile.set", &json!([id.0])));
let ts: Vec<f64> = v["points"].as_array().unwrap().iter().map(|q| (q[0].as_f64().unwrap() * 1000.0).round() / 1000.0).collect();
assert_eq!(ts, vec![0.0, 0.35, 0.5, 0.85, 1.0]);
t.pointer(&c, &ev(PointerKind::Up, 170.0, 203.0, none));
let (_, sel) = t.selected.clone().unwrap();
assert!((sel[0] - 0.35).abs() < 1e-3 && (sel[1] - 0.85).abs() < 1e-3, "the selection moves with them: {sel:?}");
// Widening one handle widens every selected point by as much.
let mut t = WidthTool { selected: Some((id, vec![0.25, 0.75])), ..Default::default() };
t.pointer(&c, &ev(PointerKind::Down, 250.0, 195.0, none));
let acts = t.pointer(&c, &ev(PointerKind::Drag, 250.0, 190.0, none));
let pts = preview(&acts).1["points"].as_array().unwrap().clone();
let w = |i: usize, side: usize| (pts[i][side].as_f64().unwrap() * 1e6).round() / 1e6;
assert_eq!((w(1, 1), w(3, 2), w(2, 1)), (2.0, 2.0, 1.0));
t.pointer(&c, &ev(PointerKind::Up, 250.0, 190.0, none));
// Shift-clicking a selected point takes it out again (and drags nothing).
assert!(t.pointer(&c, &ev(PointerKind::Down, 150.0, 200.0, shift)).is_empty());
assert_eq!(t.selected_indices(&c, id), vec![3]);
// A plain click on one of several selected points selects just that one.
let mut t = WidthTool { selected: Some((id, vec![0.25, 0.75])), ..Default::default() };
t.pointer(&c, &ev(PointerKind::Down, 250.0, 200.0, none));
t.pointer(&c, &ev(PointerKind::Up, 250.0, 200.0, none));
assert_eq!(t.selected_indices(&c, id), vec![3]);
}
#[test]
fn alt_drag_copies_and_dropping_onto_a_point_snaps_to_it() {
let (d, id) = doc_three_points();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let mut t = WidthTool::default();
let alt = Mods { alt: true, ..Default::default() };
assert_eq!(t.pointer(&c, &ev(PointerKind::Down, 150.0, 200.0, alt)), vec![Action::Begin("Copy Width Point".into())]);
let acts = t.pointer(&c, &ev(PointerKind::Drag, 180.0, 200.0, alt));
let (cmd, v) = preview(&acts);
assert_eq!((cmd, &v["id"], &v["index"]), ("stroke.widthPoint.copy", &json!(id.0), &json!(1)));
assert!((v["t"].as_f64().unwrap() - 0.4).abs() < 1e-3);
t.pointer(&c, &ev(PointerKind::Up, 180.0, 200.0, alt));
// Sliding the point at 0.25 to within a few pixels of the one at 0.5 lands exactly on it.
t.pointer(&c, &ev(PointerKind::Down, 150.0, 200.0, Mods::default()));
let acts = t.pointer(&c, &ev(PointerKind::Drag, 197.0, 200.0, Mods::default()));
let (cmd, v) = preview(&acts);
assert_eq!((cmd, v["t"].as_f64(), v["index"].as_u64()), ("stroke.widthPoint.set", Some(0.5), Some(1)));
}
#[test]
fn double_click_on_a_width_point_opens_width_point_edit() {
let (d, id) = doc_three_points();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let mut t = WidthTool::default();
let acts = t.pointer(&c, &ev(PointerKind::DoubleClick, 200.0, 195.0, Mods::default()));
assert_eq!(acts, vec![Action::Dialog("widthPoint".into(), json!({"id": id.0, "index": 2}))]);
assert!(t.pointer(&c, &ev(PointerKind::DoubleClick, 120.0, 200.0, Mods::default())).is_empty(), "not on a width point");
}
}