mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-08 20:01:37 +00:00
- The Selection tool takes a Shift-press on a selected object's centre point too: a Shift-drag moves the selection held to 45° steps, and a Shift-click there takes the object out of the selection, as a Shift-press on the object does. - One rotate zone for the Selection tool and the shape tools (`select::rotate_zone`): a press on a bounding-box handle is not a turn, so the shape tools' zone matches the Selection tool's classification exactly. - The shape tools' hover cursor follows the press order (corner and pie widgets, then the centre point, then the rotate zone), and a move of the selection shows the Selection tool's feedback: SelectionCopy with Alt, ArrowHollow while it snaps. - Engine-level tests for the whole gesture (`crates/engine/src/tests/shapetools.rs`): repeated previews and one undo step, Alt copy, Escape then release, a turn about the centre, the corner widget still rounding, and an unfilled shape moved by the Selection tool. - docs: evidence in target-app-parity.md's Shapes bullet and the M2 milestone, and the Move cursor's entry names the copy and snap cursors.
654 lines
29 KiB
Rust
654 lines
29 KiB
Rust
//! Shape tools: Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment.
|
||
//!
|
||
//! Drag draws (Shift constrains to square/circle/45°, Alt draws from the centre, Space held moves
|
||
//! the shape being drawn); a click without a drag asks the UI for the size dialog (like
|
||
//! Illustrator). ↑/↓ during a polygon/star drag change the side/point count; during a star drag,
|
||
//! Ctrl/Cmd keeps the inner radius where it is while the outer one follows the pointer, and Alt
|
||
//! keeps the star's shoulders straight (#812). The start point and
|
||
//! the dragged corner snap to Smart Guides ([`DrawSnap`]), hovering before the press too.
|
||
//!
|
||
//! The selection keeps its bounding box and its Live Shape widgets on show with the Rectangle,
|
||
//! Rounded Rectangle, Ellipse, Polygon and Star tools, so a shape just drawn can be adjusted at
|
||
//! once: a drag on a live rectangle's or polygon's corner widget rounds its corners and a drag on
|
||
//! a live ellipse's pie widget sets its pie angle, as with the Selection tool; a press on the
|
||
//! selection's centre point (Show Center) moves it, and one just outside a corner of its box turns
|
||
//! it about that centre, as the Selection tool's rotate zone does; a press anywhere else draws a
|
||
//! new shape (#5477).
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||
|
||
use crate::bbox::{move_delta, rotate_for_drag};
|
||
use crate::corners::{self, CornerDrag};
|
||
use crate::guides::{DrawSnap, Leave, square};
|
||
use crate::pie::{self, PieDrag};
|
||
use crate::select::{MoveSnap, hit_center, matrix_json, rotate_zone, shown_box};
|
||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||
|
||
/// The largest corner radius the Rounded Rectangle tool draws, points: the range of General ›
|
||
/// Corner Radius, which the tool's radius is (its `prefs.list` spec), so ↑ and a Tool Options
|
||
/// value stop there too.
|
||
pub const MAX_CORNER_RADIUS: f64 = 1296.0;
|
||
|
||
/// Does tool `id` keep the selection's bounding box and Live Shape widgets on show and let its
|
||
/// corner and pie widgets be dragged (#5477)? The shape tools that draw a box: not Line Segment.
|
||
pub fn shows_shape_widgets(id: &str) -> bool {
|
||
matches!(id, "rectangle" | "roundedRectangle" | "ellipse" | "polygon" | "star")
|
||
}
|
||
|
||
/// A drag on a Live Shape widget of the selection, taken by a shape tool.
|
||
enum WidgetDrag {
|
||
Corner(CornerDrag),
|
||
Pie(PieDrag),
|
||
}
|
||
|
||
/// A drag of the selection itself, taken by a shape tool: the centre point moves it and the zone
|
||
/// just outside a corner of its box turns it, as the Selection tool's drags do (#5477).
|
||
enum SelectionDrag {
|
||
/// Moving the selection by the centre point `hold`: `start` is where the press was, and the
|
||
/// move has begun once the pointer left the drag threshold.
|
||
Move { start: Point, hold: Point, began: bool, snap: Box<MoveSnap> },
|
||
/// Turning the selection about `center` from where the press was.
|
||
Rotate { center: Point, start: Point },
|
||
}
|
||
|
||
pub struct ShapeTool {
|
||
id: &'static str,
|
||
start: Option<Point>,
|
||
last: Point,
|
||
mods: Mods,
|
||
began: bool,
|
||
pub sides: u32,
|
||
pub points: u32,
|
||
pub corner_radius: f64,
|
||
/// Star inner radius as a fraction of the outer radius.
|
||
pub star_ratio: f64,
|
||
/// The star's inner radius held while Ctrl/Cmd is down in a drag.
|
||
inner: Option<f64>,
|
||
/// Line Segment Tool Options: the length and angle (degrees) a click's dialog opens with, and
|
||
/// Fill Line (new lines take the current fill).
|
||
pub line_length: f64,
|
||
pub line_angle: f64,
|
||
pub fill_line: bool,
|
||
snap: DrawSnap,
|
||
/// A widget of the selection is being dragged instead of a shape drawn.
|
||
widget: Option<WidgetDrag>,
|
||
/// The selection is being moved or turned instead of a shape drawn.
|
||
selection: Option<SelectionDrag>,
|
||
/// The Smart Guides a move of the selection shows.
|
||
guides: Vec<Overlay>,
|
||
/// The measurement label of a move or a turn: where it goes and what it reads.
|
||
measure: Option<(Point, String)>,
|
||
/// The move of the selection snapped to a point, a guide or the grid at its last step: the
|
||
/// arrow turns hollow, as the Selection tool's does.
|
||
snapped: bool,
|
||
}
|
||
|
||
/// The inner-to-outer radius ratio that keeps a star of `points` points' shoulders straight (each
|
||
/// point's sides in line with the next points' sides: a regular star polygon {n/2}); none for
|
||
/// fewer than five points, whose shoulders can't line up.
|
||
fn straight_ratio(points: u32) -> Option<f64> {
|
||
let n = f64::from(points);
|
||
(points >= 5).then(|| (2.0 * std::f64::consts::PI / n).cos() / (std::f64::consts::PI / n).cos())
|
||
}
|
||
|
||
impl ShapeTool {
|
||
pub fn new(id: &str) -> Self {
|
||
let id: &'static str = match id {
|
||
"roundedRectangle" => "roundedRectangle",
|
||
"ellipse" => "ellipse",
|
||
"polygon" => "polygon",
|
||
"star" => "star",
|
||
"lineSegment" => "lineSegment",
|
||
_ => "rectangle",
|
||
};
|
||
Self {
|
||
id,
|
||
start: None,
|
||
last: Point::ZERO,
|
||
mods: Mods::default(),
|
||
began: false,
|
||
sides: 6,
|
||
points: 5,
|
||
corner_radius: 12.0,
|
||
star_ratio: 0.5,
|
||
inner: None,
|
||
snap: DrawSnap::default(),
|
||
line_length: 100.0,
|
||
line_angle: 0.0,
|
||
fill_line: false,
|
||
widget: None,
|
||
selection: None,
|
||
guides: vec![],
|
||
measure: None,
|
||
snapped: false,
|
||
}
|
||
}
|
||
|
||
/// A press on a corner or pie widget of the selection: the drag adjusts it.
|
||
fn press_widget(&mut self, cx: &ToolContext, ev: &PointerEvent) -> bool {
|
||
if !shows_shape_widgets(self.id) {
|
||
return false;
|
||
}
|
||
self.widget = CornerDrag::hit(cx, ev, false).map(WidgetDrag::Corner).or_else(|| PieDrag::hit(cx, ev).map(WidgetDrag::Pie));
|
||
self.widget.is_some()
|
||
}
|
||
|
||
/// Is `p` over a corner or pie widget of the selection that a press would drag?
|
||
fn over_widget(&self, cx: &ToolContext, p: Point) -> bool {
|
||
shows_shape_widgets(self.id) && (corners::over_widget(cx, p, false) || pie::over_widget(cx, p))
|
||
}
|
||
|
||
/// The selection's box, while this tool shows it (the tools that draw a box, with a selection
|
||
/// and no anchors picked on their own).
|
||
fn box_of(&self, cx: &ToolContext) -> Option<vectorcraft_doc::OrientedBox> {
|
||
shows_shape_widgets(self.id).then(|| shown_box(cx)).flatten()
|
||
}
|
||
|
||
/// Is `p` in the zone just outside a corner of the selection's box, where a press turns the
|
||
/// selection about its centre ([`crate::select::rotate_zone`])? On the corner itself the Live
|
||
/// Corners widget rounds it (#5477).
|
||
fn over_rotate(&self, cx: &ToolContext, p: Point) -> Option<Point> {
|
||
rotate_zone(cx, &self.box_of(cx)?, p)
|
||
}
|
||
|
||
/// Is `p` over the centre point of a selected object (Attributes › Show Center), where a press
|
||
/// moves the selection? The point shows whatever View › Show Bounding Box says, so the press
|
||
/// takes it too.
|
||
fn over_center(&self, cx: &ToolContext, p: Point) -> bool {
|
||
shows_shape_widgets(self.id) && hit_center(cx, p, cx.point_tol()).is_some()
|
||
}
|
||
|
||
/// A press on the selection's centre point or just outside a corner of its box: the drag moves
|
||
/// or turns the selection instead of drawing a new shape (#5477). A turn begins with the press,
|
||
/// as the Selection tool's does; a move begins once the pointer leaves the drag threshold.
|
||
fn press_selection(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option<Vec<Action>> {
|
||
if !shows_shape_widgets(self.id) {
|
||
return None;
|
||
}
|
||
self.selection = match hit_center(cx, ev.pos, cx.point_tol()) {
|
||
Some((_, hold)) => Some(SelectionDrag::Move { start: ev.pos, hold, began: false, snap: Box::new(MoveSnap::new(cx)) }),
|
||
None => self.over_rotate(cx, ev.pos).map(|center| SelectionDrag::Rotate { center, start: ev.pos }),
|
||
};
|
||
self.selection.as_ref()?;
|
||
self.snap.clear();
|
||
self.guides.clear();
|
||
self.measure = None;
|
||
Some(match self.selection {
|
||
Some(SelectionDrag::Rotate { .. }) => vec![Action::Begin("Rotate".into())],
|
||
_ => vec![],
|
||
})
|
||
}
|
||
|
||
/// The pointer and keys while the selection is moved or turned: `None` when it is not.
|
||
fn selection_pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option<Vec<Action>> {
|
||
let d = self.selection.as_mut()?;
|
||
let out = match (ev.kind, d) {
|
||
(PointerKind::Drag, SelectionDrag::Move { start, hold, began, snap }) => {
|
||
let mut out = vec![];
|
||
if !*began {
|
||
if ev.pos.distance(*start) < cx.tol(3.0) {
|
||
return Some(out);
|
||
}
|
||
*began = true;
|
||
out.push(Action::Begin(if ev.mods.alt { "Copy".into() } else { "Move".into() }));
|
||
}
|
||
let shift = ev.mods.shift;
|
||
let unsnapped = move_delta(*start, ev.pos, shift, cx.constrain_angle);
|
||
let (delta, guides) =
|
||
if shift { snap.snap_along(cx, *hold, unsnapped, ev.mods.alt) } else { snap.snap(cx, *hold, unsnapped, ev.mods.alt) };
|
||
// Shift has already constrained the move: any change is a snap.
|
||
self.snapped = (delta - unsnapped).hypot() > 1e-9;
|
||
self.guides = guides;
|
||
self.measure = cx.measurement_labels.then(|| (ev.pos, cx.offset_label(delta.x, delta.y)));
|
||
out.push(Action::Preview("object.transform".into(), json!({"matrix": matrix_json(Affine::translate(delta)), "copy": ev.mods.alt})));
|
||
out
|
||
}
|
||
(PointerKind::Drag, SelectionDrag::Rotate { center, start }) => {
|
||
let (a, deg) = rotate_for_drag(*center, *start, ev.pos, ev.mods.shift);
|
||
self.measure = cx.transform_tools_guides.then(|| (ev.pos, format!("{:.1}°", -deg)));
|
||
vec![Action::Preview("object.transform".into(), json!({"matrix": matrix_json(a), "copy": false}))]
|
||
}
|
||
(PointerKind::Up, SelectionDrag::Move { began, .. }) => {
|
||
let began = *began;
|
||
self.selection = None;
|
||
self.guides.clear();
|
||
self.measure = None;
|
||
self.snapped = false;
|
||
if began { vec![Action::Commit] } else { vec![] }
|
||
}
|
||
(PointerKind::Up, SelectionDrag::Rotate { .. }) => {
|
||
self.selection = None;
|
||
self.measure = None;
|
||
vec![Action::Commit]
|
||
}
|
||
_ => vec![],
|
||
};
|
||
Some(out)
|
||
}
|
||
|
||
/// Escape during a drag of the selection turns it back, as the Selection tool's does.
|
||
fn cancel_selection(&mut self) -> Vec<Action> {
|
||
self.selection = None;
|
||
self.guides.clear();
|
||
self.measure = None;
|
||
self.snapped = false;
|
||
vec![Action::Cancel]
|
||
}
|
||
|
||
/// The pointer and keys while a widget is dragged: `None` when none is.
|
||
fn widget_pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option<Vec<Action>> {
|
||
let w = self.widget.as_mut()?;
|
||
Some(match ev.kind {
|
||
PointerKind::Drag => match w {
|
||
WidgetDrag::Corner(c) => c.drag(cx, ev.pos),
|
||
WidgetDrag::Pie(d) => d.drag(cx, ev.pos),
|
||
},
|
||
PointerKind::Up => match self.widget.take() {
|
||
Some(WidgetDrag::Corner(c)) => c.finish(),
|
||
Some(WidgetDrag::Pie(d)) => d.finish(),
|
||
None => vec![],
|
||
},
|
||
_ => vec![],
|
||
})
|
||
}
|
||
|
||
/// Ctrl/Cmd in a star drag from `start` to `p`, where the last drag showed a star with the inner
|
||
/// radius `shown`: once the star has begun, the inner radius stays as the star showed it when
|
||
/// the key went down, so the ratio follows the outer radius (and stays so once the key is let
|
||
/// go). When the key goes down before the star has begun, the inner radius stays as the first
|
||
/// star the drag shows has it.
|
||
fn hold_inner(&mut self, shown: f64, start: Point, p: Point, m: Mods) {
|
||
let outer = (p - start).hypot();
|
||
match (m.cmd, self.inner) {
|
||
(true, None) if self.began => self.inner = Some(shown),
|
||
(false, Some(_)) => self.inner = None,
|
||
_ => {}
|
||
}
|
||
if let Some(inner) = self.inner
|
||
&& outer > 0.0
|
||
&& (inner / outer).is_finite()
|
||
{
|
||
self.star_ratio = (inner / outer).clamp(0.01, 1.0);
|
||
}
|
||
}
|
||
|
||
fn label(&self) -> &'static str {
|
||
match self.id {
|
||
"roundedRectangle" => "Rounded Rectangle",
|
||
"ellipse" => "Ellipse",
|
||
"polygon" => "Polygon",
|
||
"star" => "Star",
|
||
"lineSegment" => "Line",
|
||
_ => "Rectangle",
|
||
}
|
||
}
|
||
|
||
/// How the dragged point keeps to the start: a polygon's or star's radius goes anywhere, a
|
||
/// line is a segment (Shift: 45° steps), the rest are boxes (Shift: a square).
|
||
fn leave(&self, cx: &ToolContext, start: Point, m: Mods) -> Option<Leave> {
|
||
match self.id {
|
||
"polygon" | "star" => None,
|
||
"lineSegment" => Some(Leave::segment(cx, start, m.shift)),
|
||
// With a Constrain Angle, Shift's square is taken along the rotated axes
|
||
// ([`Self::command_at`]), not the page's diagonal.
|
||
_ => Some(Leave::diagonal(start, m.shift && vectorcraft_geom::constrain_axes(cx.constrain_angle).is_none())),
|
||
}
|
||
}
|
||
|
||
/// The command for a drag from `start` to `p` (snapped: a line's end already keeps Shift's
|
||
/// angle).
|
||
pub fn command(&self, start: Point, p: Point, m: Mods) -> (String, Value) {
|
||
self.command_at(start, p, m, 0.0)
|
||
}
|
||
|
||
/// [`Self::command`] with General › Constrain Angle `base` (degrees, counter-clockwise):
|
||
/// rectangles, rounded rectangles and ellipses are drawn along the x and y axes turned by it,
|
||
/// as the reference app draws new objects along the rotated axes
|
||
/// (<https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rotate-objects-by-specific-angles.html>).
|
||
pub fn command_at(&self, start: Point, p: Point, m: Mods, base: f64) -> (String, Value) {
|
||
match self.id {
|
||
"polygon" | "star" => {
|
||
let v = p - start;
|
||
let r = v.hypot().max(0.01);
|
||
// Shift keeps the base orientation: upright, turned by the Constrain Angle.
|
||
let rot = if m.shift { vectorcraft_geom::constrain_rotation(base).unwrap_or(0.0) } else { (v.atan2().to_degrees() + 90.0) % 360.0 };
|
||
if self.id == "polygon" {
|
||
("shape.polygon".into(), json!({ "cx": start.x, "cy": start.y, "radius": r, "sides": self.sides, "rotation": rot }))
|
||
} else {
|
||
let ratio = if m.alt { straight_ratio(self.points).unwrap_or(self.star_ratio) } else { self.star_ratio };
|
||
(
|
||
"shape.star".into(),
|
||
json!({ "cx": start.x, "cy": start.y, "radius1": r, "radius2": r * ratio, "points": self.points, "rotation": rot }),
|
||
)
|
||
}
|
||
}
|
||
"lineSegment" => {
|
||
let (a, b) = if m.alt { (start - (p - start), p) } else { (start, p) };
|
||
let mut v = json!({ "x1": a.x, "y1": a.y, "x2": b.x, "y2": b.y });
|
||
if self.fill_line {
|
||
v["fill"] = json!(true);
|
||
}
|
||
("shape.line".into(), v)
|
||
}
|
||
_ => {
|
||
let cmd = if self.id == "ellipse" { "shape.ellipse" } else { "shape.rectangle" };
|
||
let mut v = box_params(start, p, m, base);
|
||
if self.id == "roundedRectangle" {
|
||
v["radius"] = json!(self.corner_radius);
|
||
}
|
||
(cmd.into(), v)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The box a drag from `start` to `p` draws (from its centre with Alt, square with Shift),
|
||
/// measured along the page axes turned by Constrain Angle `base`: the box in those axes, and the
|
||
/// axes (`None` for the page's own: 0° or a whole turn, where the box is on the page exactly).
|
||
/// The drawn shape and its size label both come from it.
|
||
pub(crate) fn turned_box(start: Point, p: Point, m: Mods, base: f64) -> (Rect, Option<Affine>) {
|
||
match vectorcraft_geom::constrain_axes(base) {
|
||
None => (drag_rect(start, p, m), None),
|
||
Some(axes) => {
|
||
let back = axes.inverse();
|
||
(drag_rect(back * start, back * p, m), Some(axes))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The `{x, y, width, height, rotation?}` params of the box a drag from `start` to `p` draws along
|
||
/// Constrain Angle `base` ([`turned_box`]): the box's page position at 0° or a whole turn, else
|
||
/// the turned box's centre out on the page again with the rotation that turns it about it.
|
||
pub(crate) fn box_params(start: Point, p: Point, m: Mods, base: f64) -> Value {
|
||
match turned_box(start, p, m, base) {
|
||
(r, None) => json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() }),
|
||
(r, Some(axes)) => {
|
||
let c = axes * r.center();
|
||
let rotation = -vectorcraft_geom::normalize_deg(base);
|
||
json!({ "x": c.x - r.width() / 2.0, "y": c.y - r.height() / 2.0, "width": r.width(), "height": r.height(), "rotation": rotation })
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Space held once a shape drag has begun moves the shape being drawn instead of sizing it: `start`
|
||
/// moves as far as the pointer did since `last`, which becomes the pointer.
|
||
pub(crate) fn space_moves(start: &mut Point, last: &mut Point, ev: &PointerEvent, began: bool) {
|
||
if ev.mods.space && began {
|
||
*start += ev.pos - *last;
|
||
}
|
||
*last = ev.pos;
|
||
}
|
||
|
||
/// The rectangle a drag from `start` to `p` draws: Shift makes it a square, Alt draws it from its
|
||
/// centre.
|
||
pub(crate) fn drag_rect(start: Point, p: Point, m: Mods) -> Rect {
|
||
let d = if m.shift { square(p - start) } else { p - start };
|
||
if m.alt { Rect::from_points(start - d, start + d) } else { Rect::from_points(start, start + d) }
|
||
}
|
||
|
||
impl Tool for ShapeTool {
|
||
fn id(&self) -> &'static str {
|
||
self.id
|
||
}
|
||
fn busy(&self) -> bool {
|
||
self.start.is_some() || self.widget.is_some() || self.selection.is_some()
|
||
}
|
||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||
if let Some(out) = self.widget_pointer(cx, ev) {
|
||
return out;
|
||
}
|
||
if let Some(out) = self.selection_pointer(cx, ev) {
|
||
return out;
|
||
}
|
||
match ev.kind {
|
||
PointerKind::Move => {
|
||
// Over a widget a press would drag, no Smart Guide for a new shape's start.
|
||
if self.over_widget(cx, ev.pos) || self.over_center(cx, ev.pos) || self.over_rotate(cx, ev.pos).is_some() {
|
||
self.snap.clear();
|
||
} else {
|
||
self.snap.hover(cx, ev.pos, &[], None);
|
||
}
|
||
vec![]
|
||
}
|
||
PointerKind::Down => {
|
||
if self.press_widget(cx, ev) {
|
||
self.snap.clear();
|
||
return vec![];
|
||
}
|
||
if let Some(out) = self.press_selection(cx, ev) {
|
||
return out;
|
||
}
|
||
let p = self.snap.press(cx, ev.pos, &[], None);
|
||
self.start = Some(p);
|
||
self.last = ev.pos;
|
||
self.began = false;
|
||
self.inner = None;
|
||
vec![]
|
||
}
|
||
PointerKind::Drag => {
|
||
let Some(mut s) = self.start else { return vec![] };
|
||
let pos = self.snap.drag(cx, ev.pos, self.leave(cx, s, ev.mods).as_ref());
|
||
let ev = &PointerEvent { pos, ..*ev };
|
||
// The inner radius of the star the last drag showed, with the ratio it drew with:
|
||
// the canvas sends a drag only once the pointer moves, so a key pressed in between
|
||
// first arrives with the next position.
|
||
let ratio = if self.mods.alt { straight_ratio(self.points).unwrap_or(self.star_ratio) } else { self.star_ratio };
|
||
let shown = (self.last - s).hypot() * ratio;
|
||
space_moves(&mut s, &mut self.last, ev, self.began);
|
||
self.start = Some(s);
|
||
self.mods = ev.mods;
|
||
if self.id == "star" {
|
||
self.hold_inner(shown, s, ev.pos, ev.mods);
|
||
}
|
||
let mut out = vec![];
|
||
if !self.began {
|
||
if ev.pos.distance(s) < cx.tol(2.0) {
|
||
return out;
|
||
}
|
||
self.began = true;
|
||
out.push(Action::Begin(self.label().into()));
|
||
}
|
||
let (c, v) = self.command_at(s, ev.pos, ev.mods, cx.constrain_angle);
|
||
out.push(Action::Preview(c, v));
|
||
out
|
||
}
|
||
PointerKind::Up => {
|
||
let Some(s) = self.start.take() else { return vec![] };
|
||
self.snap.clear();
|
||
self.inner = None;
|
||
if self.began {
|
||
self.began = false;
|
||
vec![Action::Commit]
|
||
} else {
|
||
// Alt (draw from centre) is passed on, so the size dialog can centre the shape on the click.
|
||
let args = if ev.mods.alt { json!({ "x": s.x, "y": s.y, "alt": true }) } else { json!({ "x": s.x, "y": s.y }) };
|
||
vec![Action::Dialog(self.id.into(), args)]
|
||
}
|
||
}
|
||
_ => vec![],
|
||
}
|
||
}
|
||
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||
// A drag of the selection: Escape turns it back, as with the Selection tool.
|
||
if self.selection.is_some() {
|
||
return if key == ToolKey::Escape { self.cancel_selection() } else { vec![] };
|
||
}
|
||
// A widget drag: Up and Down step a corner's kind; Escape cancels it, as with the
|
||
// Selection tool.
|
||
if let Some(w) = &mut self.widget {
|
||
return match (key, w) {
|
||
(ToolKey::Up | ToolKey::Down, WidgetDrag::Corner(c)) => c.step_kind(key == ToolKey::Up),
|
||
(ToolKey::Escape, _) => match self.widget.take() {
|
||
Some(WidgetDrag::Corner(c)) if c.adjusting() => vec![Action::Cancel],
|
||
Some(WidgetDrag::Pie(d)) if d.adjusting() => vec![Action::Cancel],
|
||
_ => vec![],
|
||
},
|
||
_ => vec![],
|
||
};
|
||
}
|
||
let Some(s) = self.start.filter(|_| self.began) else { return vec![] };
|
||
let changed = match (self.id, key) {
|
||
("polygon", ToolKey::Up) => {
|
||
self.sides = (self.sides + 1).min(1000);
|
||
true
|
||
}
|
||
("polygon", ToolKey::Down) => {
|
||
self.sides = self.sides.saturating_sub(1).max(3);
|
||
true
|
||
}
|
||
("star", ToolKey::Up) => {
|
||
self.points = (self.points + 1).min(1000);
|
||
true
|
||
}
|
||
("star", ToolKey::Down) => {
|
||
self.points = self.points.saturating_sub(1).max(3);
|
||
true
|
||
}
|
||
("roundedRectangle", ToolKey::Up) => {
|
||
self.corner_radius = (self.corner_radius + 1.0).min(MAX_CORNER_RADIUS);
|
||
true
|
||
}
|
||
("roundedRectangle", ToolKey::Down) => {
|
||
self.corner_radius = (self.corner_radius - 1.0).max(0.0);
|
||
true
|
||
}
|
||
_ => false,
|
||
};
|
||
if changed {
|
||
let (c, v) = self.command_at(s, self.last, self.mods, cx.constrain_angle);
|
||
vec![Action::Preview(c, v)]
|
||
} else if key == ToolKey::Escape {
|
||
self.start = None;
|
||
self.began = false;
|
||
self.inner = None;
|
||
self.snap.clear();
|
||
vec![Action::Cancel]
|
||
} else {
|
||
vec![]
|
||
}
|
||
}
|
||
/// Adjusting a widget (the canvas hides the bounding box meanwhile, as for the Selection tool).
|
||
fn transforming(&self) -> bool {
|
||
match &self.widget {
|
||
Some(WidgetDrag::Corner(c)) => c.adjusting(),
|
||
Some(WidgetDrag::Pie(d)) => d.adjusting(),
|
||
None => match &self.selection {
|
||
Some(SelectionDrag::Move { began, .. }) => *began,
|
||
Some(SelectionDrag::Rotate { .. }) => true,
|
||
None => false,
|
||
},
|
||
}
|
||
}
|
||
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
|
||
match (&self.widget, &self.selection) {
|
||
(Some(WidgetDrag::Corner(_)), _) => matches!(key, ToolKey::Up | ToolKey::Down),
|
||
(_, Some(_)) => key == ToolKey::Escape,
|
||
_ => false,
|
||
}
|
||
}
|
||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||
match &self.widget {
|
||
Some(WidgetDrag::Corner(c)) => return c.overlays(cx),
|
||
Some(WidgetDrag::Pie(d)) => return d.overlays(cx),
|
||
None => {}
|
||
}
|
||
let mut o = self.snap.guides().to_vec();
|
||
o.extend(self.guides.iter().cloned());
|
||
if let Some((p, text)) = &self.measure {
|
||
o.push(Overlay::Measure { p: *p, text: text.clone() });
|
||
}
|
||
if let Some(s) = self.start.filter(|_| self.began && cx.measurement_labels) {
|
||
let p = self.last + Vec2::new(cx.tol(12.0), cx.tol(12.0));
|
||
let text = if self.id == "lineSegment" {
|
||
cx.segment_measure_label(s, self.last)
|
||
} else {
|
||
// A box's size as drawn (from its centre with Alt, square with Shift).
|
||
match self.id {
|
||
"star" => {
|
||
let outer = (self.last - s).hypot().max(0.01);
|
||
// The same ratio `command_at` draws with, so the label matches the preview
|
||
// (Ctrl/Cmd's held radius is already in `star_ratio`, clamped; Alt overrides it).
|
||
let ratio = if self.mods.alt { straight_ratio(self.points).unwrap_or(self.star_ratio) } else { self.star_ratio };
|
||
cx.star_radii_label(outer, outer * ratio)
|
||
}
|
||
_ => {
|
||
let (w, h) = match self.id {
|
||
"polygon" => ((self.last - s).x.abs(), (self.last - s).y.abs()),
|
||
_ => {
|
||
let (r, _) = turned_box(s, self.last, self.mods, cx.constrain_angle);
|
||
(r.width(), r.height())
|
||
}
|
||
};
|
||
cx.size_label(w, h)
|
||
}
|
||
}
|
||
};
|
||
o.push(Overlay::Measure { p, text });
|
||
}
|
||
o
|
||
}
|
||
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
|
||
// Over a widget a press would drag (or dragging one), the Selection tool's pointer for it.
|
||
let idle = self.start.is_none();
|
||
match &self.widget {
|
||
Some(WidgetDrag::Corner(_)) => return Cursor::CornerRadius,
|
||
Some(WidgetDrag::Pie(_)) => return Cursor::ArrowHollow,
|
||
None => {}
|
||
}
|
||
// Alt-dragging the selection leaves a copy behind; otherwise the arrow, hollow while the
|
||
// move snaps, as the Selection tool's.
|
||
match &self.selection {
|
||
Some(SelectionDrag::Rotate { center, .. }) => return Cursor::rotate_about_in_view(*center, p, cx.view_angle()),
|
||
Some(SelectionDrag::Move { began: true, .. }) if m.alt => return Cursor::SelectionCopy,
|
||
Some(SelectionDrag::Move { began: true, .. }) => return if self.snapped { Cursor::ArrowHollow } else { Cursor::Arrow },
|
||
Some(_) => return Cursor::Arrow,
|
||
None => {}
|
||
}
|
||
// A press takes the corner and pie widgets first, then the centre point, then the zone
|
||
// just outside a corner; everywhere else it draws a new shape.
|
||
if idle {
|
||
if shows_shape_widgets(self.id) && corners::over_widget(cx, p, false) {
|
||
return Cursor::CornerRadius;
|
||
}
|
||
if self.over_widget(cx, p) {
|
||
return Cursor::ArrowHollow;
|
||
}
|
||
if self.over_center(cx, p) {
|
||
return Cursor::Move;
|
||
}
|
||
if let Some(center) = self.over_rotate(cx, p) {
|
||
return Cursor::rotate_about_in_view(center, p, cx.view_angle());
|
||
}
|
||
}
|
||
Cursor::Crosshair
|
||
}
|
||
fn options(&self) -> Value {
|
||
json!({
|
||
"sides": self.sides,
|
||
"points": self.points,
|
||
"cornerRadius": self.corner_radius,
|
||
"starRatio": self.star_ratio,
|
||
"length": self.line_length,
|
||
"angle": self.line_angle,
|
||
"fillLine": self.fill_line,
|
||
})
|
||
}
|
||
fn set_option(&mut self, key: &str, v: &Value) {
|
||
let finite = v.as_f64().filter(|n| n.is_finite());
|
||
match key {
|
||
"sides" => self.sides = v.as_u64().unwrap_or(6).clamp(3, 1000) as u32,
|
||
"points" => self.points = v.as_u64().unwrap_or(5).clamp(3, 1000) as u32,
|
||
"cornerRadius" => self.corner_radius = finite.unwrap_or(12.0).clamp(0.0, MAX_CORNER_RADIUS),
|
||
"starRatio" => self.star_ratio = v.as_f64().unwrap_or(0.5).clamp(0.01, 1.0),
|
||
"length" => self.line_length = finite.map_or(self.line_length, |n| n.abs().min(1e6)),
|
||
"angle" => self.line_angle = finite.map_or(self.line_angle, |n| n.rem_euclid(360.0)),
|
||
"fillLine" => self.fill_line = v.as_bool().unwrap_or(self.fill_line),
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|