mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 12:24:54 +00:00
Merge pull request #6704 from dotCooCoo/fix/shape-tool-handles-scale
Shape tools: a drag on a bounding-box handle scales the selection
This commit is contained in:
@@ -115,6 +115,9 @@ fn constrain_angle_draws_boxes_along_the_turned_axes() {
|
||||
let mut s = new_doc();
|
||||
set_pref(&mut s, "constrainAngle", json!(90));
|
||||
for tool in ["rectangle", "ellipse"] {
|
||||
// The shape drawn before stays selected, and a press on its box's corner handle would
|
||||
// scale it instead of drawing.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.select_tool(tool, ViewInfo::default()).unwrap();
|
||||
// 60 right and 20 down on the page: along axes turned 90°, 20 wide and 60 high.
|
||||
gesture(&mut s, Mods::default(), &[(Down, 100.0, 100.0), (Drag, 130.0, 110.0), (Drag, 160.0, 120.0), (Up, 160.0, 120.0)]);
|
||||
|
||||
@@ -1966,6 +1966,10 @@ fn drawing_gestures_snap_to_smart_guides() {
|
||||
let b = &made["bounds"];
|
||||
[&b["x"], &b["y"], &b["width"], &b["height"]].map(|v| v.as_f64().unwrap())
|
||||
};
|
||||
// The rectangle drawn first stays selected, and the press lands on its box's corner handle,
|
||||
// which would scale it instead of drawing.
|
||||
let r = call(&mut s, 30, "run_command", json!({"command": "select.none", "params": {}}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let events = json!([{"kind": "move", "x": 202, "y": 203}, {"kind": "down", "x": 202, "y": 203}, {"kind": "drag", "x": 330, "y": 341}, {"kind": "up", "x": 330, "y": 341}]);
|
||||
assert_eq!(bounds(&mut s, 3, events, "rectangle"), [200.0, 200.0, 130.0, 141.0]);
|
||||
let r = call(&mut s, 5, "run_command", json!({"command": "select.none", "params": {}}));
|
||||
|
||||
+130
-24
@@ -11,19 +11,20 @@
|
||||
//! 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).
|
||||
//! a live ellipse's pie widget sets its pie angle, as with the Selection tool; a drag on one of the
|
||||
//! box's eight handles scales the selection, 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 handles, centre point and rotate zone do; a press anywhere else draws a new shape (#5477).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::OrientedBox;
|
||||
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
use crate::bbox::{move_delta, rotate_for_drag};
|
||||
use crate::bbox::{Handle, move_delta, rotate_for_drag, scale_for_drag};
|
||||
use crate::corners::{self, CornerDrag};
|
||||
use crate::guides::{DrawSnap, Leave, square};
|
||||
use crate::guides::{DrawSnap, Leave, PointSnap, Targets, square};
|
||||
use crate::pie::{self, PieDrag};
|
||||
use crate::select::{MoveSnap, hit_center, matrix_json, rotate_zone, shown_box};
|
||||
use crate::select::{MoveSnap, handle_cursor, hit_center, is_area_type, 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 ›
|
||||
@@ -31,6 +32,22 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool
|
||||
/// value stop there too.
|
||||
pub const MAX_CORNER_RADIUS: f64 = 1296.0;
|
||||
|
||||
/// How far (screen points, along each axis) from a bounding-box handle's centre a shape tool's
|
||||
/// press scales the selection: half the drawn handle square, which is 6 points wide at the default
|
||||
/// Selection & Anchor Display › Size 3 and a point wider or narrower per step. The Selection tool
|
||||
/// takes 5 points.
|
||||
fn handle_reach(cx: &ToolContext) -> f64 {
|
||||
(6.0 + f64::from(cx.anchor_size.clamp(1, 7)) - 3.0) / 2.0
|
||||
}
|
||||
|
||||
/// A document offset `d` in screen points: across and down the window.
|
||||
fn screen_offset(cx: &ToolContext, d: Vec2) -> (f64, f64) {
|
||||
match cx.screen {
|
||||
Some(s) if s.right.hypot2() > 0.0 && s.down.hypot2() > 0.0 => (d.dot(s.right) / s.right.hypot2(), d.dot(s.down) / s.down.hypot2()),
|
||||
_ => (d.x * cx.zoom, d.y * cx.zoom),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -43,9 +60,15 @@ enum WidgetDrag {
|
||||
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).
|
||||
/// A drag of the selection itself, taken by a shape tool: a handle of its box scales it, 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 {
|
||||
/// Scaling the selection by `handle` of its box `bx`, as it was when the press came. `areas`:
|
||||
/// area type is selected, and its frame resizes while its type keeps its size. `snap` holds
|
||||
/// what the moved handle snaps to. `last`: where the last drag was, and whether Backtick was
|
||||
/// down then.
|
||||
Scale { handle: Handle, bx: OrientedBox, areas: bool, snap: Box<PointSnap>, last: Option<(Point, bool)> },
|
||||
/// 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> },
|
||||
@@ -178,6 +201,22 @@ impl ShapeTool {
|
||||
rotate_zone(cx, &self.box_of(cx)?, p)
|
||||
}
|
||||
|
||||
/// The handle of the selection's box under `p`, with the box: a press there scales the
|
||||
/// selection, as with the Selection tool. A shape tool takes only the drawn handle square
|
||||
/// ([`handle_reach`]), which stays square to the screen when the box or the view is turned, so
|
||||
/// a press just beside a handle still draws a new shape.
|
||||
fn over_handle(&self, cx: &ToolContext, p: Point) -> Option<(Handle, OrientedBox)> {
|
||||
let bx = self.box_of(cx)?;
|
||||
let reach = handle_reach(cx);
|
||||
Handle::ALL
|
||||
.into_iter()
|
||||
.find(|h| {
|
||||
let (x, y) = screen_offset(cx, p - bx.to_doc() * h.pos(bx.rect));
|
||||
x.abs() <= reach && y.abs() <= reach
|
||||
})
|
||||
.map(|h| (h, bx))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -185,22 +224,35 @@ impl ShapeTool {
|
||||
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.
|
||||
/// A press on a handle of the selection's box, on its centre point or just outside a corner of
|
||||
/// its box: the drag scales, moves or turns the selection instead of drawing a new shape
|
||||
/// (#5477). The handles come first, as with the Selection tool. A scale or a turn begins with
|
||||
/// the press, as the Selection tool's do; 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 = if let Some((handle, bx)) = self.over_handle(cx, ev.pos) {
|
||||
let is_area = |id: &vectorcraft_doc::NodeId| cx.doc.node(*id).is_some_and(is_area_type);
|
||||
let areas = cx.selection.objects.iter().any(is_area);
|
||||
Some(SelectionDrag::Scale { handle, bx, areas, snap: Box::new(PointSnap::new(cx, || Targets::for_scale(cx))), last: None })
|
||||
} else {
|
||||
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 {
|
||||
// Only area type: the step resizes type areas; anything else scales.
|
||||
Some(SelectionDrag::Scale { areas, .. }) => {
|
||||
let only_areas = areas && cx.selection.objects.iter().all(|id| cx.doc.node(*id).is_some_and(is_area_type));
|
||||
vec![Action::Begin(if only_areas { "Resize Type Area" } else { "Scale" }.into())]
|
||||
}
|
||||
Some(SelectionDrag::Rotate { .. }) => vec![Action::Begin("Rotate".into())],
|
||||
_ => vec![],
|
||||
})
|
||||
@@ -235,6 +287,32 @@ impl ShapeTool {
|
||||
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::Drag, SelectionDrag::Scale { handle, bx, areas, snap, last }) => {
|
||||
// As the Selection tool scales: in the box's own frame, the moved handle snapped,
|
||||
// and with Use Preview Bounds by the visual box's size unless the drag flips it.
|
||||
*last = Some((ev.pos, ev.backquote));
|
||||
let m = ev.mods;
|
||||
let a = scale_for_drag(bx.rect, *handle, bx.to_local(ev.pos), m.shift, m.alt);
|
||||
let (a, guides) = snap.snap_scale(cx, bx, *handle, a, m.shift, m.alt);
|
||||
self.guides = guides;
|
||||
let nr = a.transform_rect_bbox(bx.rect);
|
||||
self.measure = cx.transform_tools_guides.then(|| (ev.pos, cx.size_label(nr.width(), nr.height())));
|
||||
let [sx, _, _, sy, _, _] = a.as_coeffs();
|
||||
let (cmd, mut params) = if !ev.backquote && cx.preview_bounds && sx > 0.0 && sy > 0.0 {
|
||||
let reference = if m.alt { 4 } else { handle.opposite().reference() };
|
||||
("object.setBounds", json!({ "width": nr.width(), "height": nr.height(), "reference": reference, "copy": false }))
|
||||
} else {
|
||||
("object.transform", json!({ "matrix": matrix_json(bx.conjugate(a)), "copy": false }))
|
||||
};
|
||||
if *areas {
|
||||
params["typeAreas"] = json!(true);
|
||||
}
|
||||
if ev.backquote {
|
||||
params["objects"] = json!(false);
|
||||
params["patterns"] = json!(true);
|
||||
}
|
||||
vec![Action::Preview(cmd.into(), params)]
|
||||
}
|
||||
(PointerKind::Up, SelectionDrag::Move { began, .. }) => {
|
||||
let began = *began;
|
||||
self.selection = None;
|
||||
@@ -243,8 +321,22 @@ impl ShapeTool {
|
||||
self.snapped = false;
|
||||
if began { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
(PointerKind::Up, SelectionDrag::Scale { last, .. }) => {
|
||||
// Backtick pressed or let go after the last drag: the scale is previewed again at
|
||||
// that drag's position with the key as it is at the release, as the Selection tool
|
||||
// does.
|
||||
let again = last.filter(|(_, backquote)| *backquote != ev.backquote).map(|(pos, _)| pos);
|
||||
let mut out =
|
||||
again.and_then(|pos| self.selection_pointer(cx, &PointerEvent { kind: PointerKind::Drag, pos, ..*ev })).unwrap_or_default();
|
||||
self.selection = None;
|
||||
self.guides.clear();
|
||||
self.measure = None;
|
||||
out.push(Action::Commit);
|
||||
out
|
||||
}
|
||||
(PointerKind::Up, SelectionDrag::Rotate { .. }) => {
|
||||
self.selection = None;
|
||||
self.guides.clear();
|
||||
self.measure = None;
|
||||
vec![Action::Commit]
|
||||
}
|
||||
@@ -422,8 +514,9 @@ impl Tool for ShapeTool {
|
||||
fn busy(&self) -> bool {
|
||||
self.start.is_some() || self.widget.is_some() || self.selection.is_some()
|
||||
}
|
||||
/// A widget or selection drag takes the pointer; otherwise a press draws, unless it lands on
|
||||
/// the selection's centre point or in the rotate zone just outside its box's corner.
|
||||
/// A widget or selection drag takes the pointer; otherwise a press draws, unless it lands on a
|
||||
/// handle of the selection's box, its centre point or the rotate zone just outside its box's
|
||||
/// corner.
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
if let Some(out) = self.widget_pointer(cx, ev) {
|
||||
return out;
|
||||
@@ -434,7 +527,11 @@ impl Tool for ShapeTool {
|
||||
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() {
|
||||
if self.over_widget(cx, ev.pos)
|
||||
|| self.over_handle(cx, ev.pos).is_some()
|
||||
|| 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);
|
||||
@@ -566,6 +663,11 @@ impl Tool for ShapeTool {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
/// A drag of a handle of the selection's box: the canvas keeps drawing the box at the changing
|
||||
/// bounds, as for the Selection tool.
|
||||
fn resizing(&self) -> bool {
|
||||
matches!(self.selection, Some(SelectionDrag::Scale { .. }))
|
||||
}
|
||||
/// Adjusting a widget (the canvas hides the bounding box meanwhile, as for the Selection tool).
|
||||
fn transforming(&self) -> bool {
|
||||
match &self.widget {
|
||||
@@ -573,7 +675,7 @@ impl Tool for ShapeTool {
|
||||
Some(WidgetDrag::Pie(d)) => d.adjusting(),
|
||||
None => match &self.selection {
|
||||
Some(SelectionDrag::Move { began, .. }) => *began,
|
||||
Some(SelectionDrag::Rotate { .. }) => true,
|
||||
Some(SelectionDrag::Rotate { .. } | SelectionDrag::Scale { .. }) => true,
|
||||
None => false,
|
||||
},
|
||||
}
|
||||
@@ -630,9 +732,9 @@ impl Tool for ShapeTool {
|
||||
}
|
||||
o
|
||||
}
|
||||
/// The Selection tool's pointer over a Live Shape widget, the selection's centre point or the
|
||||
/// rotate zone just outside its box's corner; the crosshair everywhere else, where a press
|
||||
/// draws.
|
||||
/// The Selection tool's pointer over a Live Shape widget, a handle of the selection's box, its
|
||||
/// centre point or the rotate zone just outside its box's corner; the crosshair everywhere
|
||||
/// else, where a press draws.
|
||||
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();
|
||||
@@ -645,13 +747,14 @@ impl Tool for ShapeTool {
|
||||
// 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::Scale { handle, bx, .. }) => return handle_cursor(*handle, bx.angle - 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.
|
||||
// A press takes the corner and pie widgets first, then the box's handles, 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;
|
||||
@@ -659,6 +762,9 @@ impl Tool for ShapeTool {
|
||||
if self.over_widget(cx, p) {
|
||||
return Cursor::ArrowHollow;
|
||||
}
|
||||
if let Some((handle, bx)) = self.over_handle(cx, p) {
|
||||
return handle_cursor(handle, bx.angle - cx.view_angle());
|
||||
}
|
||||
if self.over_center(cx, p) {
|
||||
return Cursor::Move;
|
||||
}
|
||||
|
||||
@@ -583,6 +583,96 @@ fn a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corn
|
||||
assert_eq!(a.first(), Some(&Action::Begin("Rectangle".into())), "{a:?}");
|
||||
}
|
||||
|
||||
/// With a shape tool, a drag on one of the eight handles of the selection's bounding box scales the
|
||||
/// selection, as with the Selection tool, instead of drawing a new shape from the handle.
|
||||
#[test]
|
||||
fn a_shape_tool_scales_the_selection_by_its_bounding_box_handles() {
|
||||
let (d, s) = unfilled_live_rect();
|
||||
let p = paint();
|
||||
let c = cx(&d, &s, &p);
|
||||
let corner = Point::new(200.0, 200.0);
|
||||
for id in ["rectangle", "roundedRectangle", "polygon", "star", "ellipse"] {
|
||||
let mut t = ShapeTool::new(id);
|
||||
assert_eq!(t.cursor(&c, corner, Mods::default()), handle_cursor(Handle::BottomRight, 0.0), "{id}");
|
||||
// No Smart Guide for a new shape's start over a handle.
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Move, corner.x, corner.y));
|
||||
assert!(t.overlays(&c).is_empty(), "{id}");
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, corner.x, corner.y)), vec![Action::Begin("Scale".into())], "{id}");
|
||||
// A resize: the canvas keeps the box on show at the changing bounds.
|
||||
assert!(t.busy() && t.transforming() && t.resizing(), "{id}");
|
||||
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 220.0, 230.0));
|
||||
let want = scale_for_drag(Rect::new(100.0, 100.0, 200.0, 200.0), Handle::BottomRight, Point::new(220.0, 230.0), false, false);
|
||||
assert_eq!(a, vec![Action::Preview("object.transform".into(), json!({"matrix": matrix_json(want), "copy": false}))], "{id}");
|
||||
assert_eq!(t.cursor(&c, Point::new(220.0, 230.0), Mods::default()), handle_cursor(Handle::BottomRight, 0.0), "{id}");
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 220.0, 230.0)), vec![Action::Commit], "{id}");
|
||||
assert!(!t.busy() && !t.transforming() && !t.resizing(), "{id}");
|
||||
}
|
||||
// The side handles scale along one axis; Escape turns the scale back.
|
||||
let mut t = ShapeTool::new("rectangle");
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 100.0)), vec![Action::Begin("Scale".into())]);
|
||||
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 150.0, 80.0));
|
||||
let want = scale_for_drag(Rect::new(100.0, 100.0, 200.0, 200.0), Handle::Top, Point::new(150.0, 80.0), false, false);
|
||||
assert_eq!(a, vec![Action::Preview("object.transform".into(), json!({"matrix": matrix_json(want), "copy": false}))]);
|
||||
assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel]);
|
||||
assert!(!t.busy());
|
||||
// Backtick pressed after the last drag: the release previews the scale again for the patterns
|
||||
// only, then commits.
|
||||
let mut t = ShapeTool::new("rectangle");
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, corner.x, corner.y));
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 220.0, 230.0));
|
||||
let a = t.pointer(&c, &PointerEvent { backquote: true, ..PointerEvent::new(PointerKind::Up, 220.0, 230.0) });
|
||||
let want = scale_for_drag(Rect::new(100.0, 100.0, 200.0, 200.0), Handle::BottomRight, Point::new(220.0, 230.0), false, false);
|
||||
let patterns = json!({"matrix": matrix_json(want), "copy": false, "objects": false, "patterns": true});
|
||||
assert_eq!(a, vec![Action::Preview("object.transform".into(), patterns), Action::Commit]);
|
||||
assert!(!t.busy());
|
||||
// View › Hide Bounding Box hides the handles, and a press there draws a new shape.
|
||||
let hidden = ToolContext { show_bbox: false, ..cx(&d, &s, &p) };
|
||||
let mut t = ShapeTool::new("rectangle");
|
||||
assert_eq!(t.cursor(&hidden, corner, Mods::default()), Cursor::Crosshair);
|
||||
t.pointer(&hidden, &PointerEvent::new(PointerKind::Down, corner.x, corner.y));
|
||||
let a = t.pointer(&hidden, &PointerEvent::new(PointerKind::Drag, 240.0, 240.0));
|
||||
assert_eq!(a.first(), Some(&Action::Begin("Rectangle".into())), "{a:?}");
|
||||
// The Line Segment tool shows no box, so its press draws.
|
||||
let mut t = ShapeTool::new("lineSegment");
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, corner.x, corner.y));
|
||||
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 240.0, 240.0));
|
||||
assert_eq!(a.first(), Some(&Action::Begin("Line".into())), "{a:?}");
|
||||
}
|
||||
|
||||
/// A shape tool's press scales the selection only on the drawn handle square: 3 screen points from
|
||||
/// the handle's centre along each axis at the default Anchor Size, at any zoom. A press 4 points
|
||||
/// from it draws a new shape; at Anchor Size 7 the square is 10 points wide and that press scales.
|
||||
#[test]
|
||||
fn a_shape_tool_draws_from_a_press_just_beside_a_handle() {
|
||||
let (d, s) = unfilled_live_rect();
|
||||
let p = paint();
|
||||
for zoom in [1.0, 4.0] {
|
||||
let c = ToolContext { zoom, ..cx(&d, &s, &p) };
|
||||
let on = Point::new(200.0 + 3.0 / zoom, 200.0 - 3.0 / zoom);
|
||||
let mut t = ShapeTool::new("rectangle");
|
||||
assert_eq!(t.cursor(&c, on, Mods::default()), handle_cursor(Handle::BottomRight, 0.0), "{zoom}");
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, on.x, on.y)), vec![Action::Begin("Scale".into())], "{zoom}");
|
||||
let beside = Point::new(200.0 + 2.0 / zoom, 200.0 + 4.0 / zoom);
|
||||
let mut t = ShapeTool::new("rectangle");
|
||||
assert_eq!(t.cursor(&c, beside, Mods::default()), Cursor::Crosshair, "{zoom}");
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, beside.x, beside.y));
|
||||
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 260.0, 260.0));
|
||||
assert_eq!(a.first(), Some(&Action::Begin("Rectangle".into())), "{zoom}: {a:?}");
|
||||
let big = ToolContext { anchor_size: 7, ..c };
|
||||
let mut t = ShapeTool::new("rectangle");
|
||||
assert_eq!(t.cursor(&big, beside, Mods::default()), handle_cursor(Handle::BottomRight, 0.0), "{zoom}");
|
||||
assert_eq!(t.pointer(&big, &PointerEvent::new(PointerKind::Down, beside.x, beside.y)), vec![Action::Begin("Scale".into())], "{zoom}");
|
||||
}
|
||||
// In a view turned 45°, the drawn square stays square to the screen: a press 3 screen points
|
||||
// right of and below the handle's centre, 4.24 pt along the box's x axis, scales.
|
||||
let screen = turned_view(45.0);
|
||||
let c = ToolContext { screen, ..cx(&d, &s, &p) };
|
||||
let f = screen.unwrap_or_else(|| panic!("a turned view"));
|
||||
let corner = Point::new(200.0, 200.0) + f.right * 3.0 + f.down * 3.0;
|
||||
let mut t = ShapeTool::new("rectangle");
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, corner.x, corner.y)), vec![Action::Begin("Scale".into())]);
|
||||
}
|
||||
|
||||
/// Two selected live rectangles, the second placed so that its top-left Live Corners widget (10 pt
|
||||
/// in along the corner's bisector) lands on the first one's centre point.
|
||||
fn two_live_rects() -> (vectorcraft_doc::Document, Selection) {
|
||||
|
||||
@@ -123,7 +123,7 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf:
|
||||
// shows while the key is down, where its handles take that press.
|
||||
let held = super::drag::mods(p.ctx().input(|i| i.modifiers), false);
|
||||
let lends_selection = !app.session.tool_busy() && app.session.lent_tool_id(&held) == Some("selection");
|
||||
let show_box = (app.session.tool_id() == "selection" || lends_selection || (shape_tool && !app.session.tool_busy()))
|
||||
let show_box = (app.session.tool_id() == "selection" || lends_selection || (shape_tool && (!app.session.tool_busy() || resizing)))
|
||||
&& (resizing || !app.session.tool_transforming() || moved.is_some())
|
||||
&& app.ui.view.bounding_box
|
||||
&& app.session.active().is_some_and(|st| !st.selection.is_empty() && st.selection.anchors.is_empty());
|
||||
|
||||
@@ -1173,6 +1173,56 @@ fn a_click_just_outside_the_canvas_does_not_reach_the_tool() {
|
||||
assert_eq!(selected(&app), vec![vectorcraft_doc::NodeId(id)]);
|
||||
}
|
||||
|
||||
/// The box-drawing shape tools show the bounding box of the shape just drawn (#5477). A drag of
|
||||
/// its bottom-right handle with the tool still active scales that shape, as with the Selection
|
||||
/// tool, rather than drawing a second shape from the handle.
|
||||
#[test]
|
||||
fn a_shape_tool_scales_the_shape_just_drawn_by_its_bounding_box_handle() {
|
||||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
for tool in ["rectangle", "roundedRectangle", "ellipse", "polygon", "star"] {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.select_tool(tool);
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let drag = |app: &mut VectorcraftApp, a: Pos2, b: Pos2| {
|
||||
for ev in [
|
||||
egui::Event::PointerMoved(a),
|
||||
button(a, true),
|
||||
egui::Event::PointerMoved(a.lerp(b, 0.5)),
|
||||
egui::Event::PointerMoved(b),
|
||||
button(b, false),
|
||||
] {
|
||||
frame(app, &ctx, vec![ev]);
|
||||
}
|
||||
frame(app, &ctx, vec![]);
|
||||
};
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
drag(&mut app, xf.to_screen(Point::new(150.0, 120.0)), xf.to_screen(Point::new(210.0, 170.0)));
|
||||
let objects = |app: &VectorcraftApp| {
|
||||
let mut n = 0;
|
||||
app.session.active().unwrap().doc.walk(|node| n += usize::from(node.path_data().is_some()));
|
||||
n
|
||||
};
|
||||
assert_eq!(objects(&app), 1, "{tool}: the drag draws one shape");
|
||||
let before = app.selection_box().unwrap_or_else(|| panic!("{tool}: the shape just drawn shows its box"));
|
||||
// The bottom-right handle, dragged 40 pt along the box's width and 30 pt along its height
|
||||
// (a polygon's or star's box turns with the shape).
|
||||
let (corner, to) = (Point::new(before.rect.x1, before.rect.y1), Point::new(before.rect.x1 + 40.0, before.rect.y1 + 30.0));
|
||||
drag(&mut app, xf.to_screen(before.to_doc() * corner), xf.to_screen(before.to_doc() * to));
|
||||
let after = app.selection_box().unwrap_or_else(|| panic!("{tool}: the shape stays selected"));
|
||||
let label = app.session.active().and_then(|st| st.history.undo.last().map(|e| e.label.clone()));
|
||||
assert_eq!(objects(&app), 1, "{tool}: no second shape, last step {label:?}");
|
||||
assert!(
|
||||
(after.rect.width() - before.rect.width() - 40.0).abs() < 0.5 && (after.rect.height() - before.rect.height() - 30.0).abs() < 0.5,
|
||||
"{tool}: {:?} -> {:?}, last step {label:?}",
|
||||
before.rect,
|
||||
after.rect
|
||||
);
|
||||
assert_eq!(label.as_deref(), Some("Scale"), "{tool}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_from_a_ruler_onto_the_canvas_makes_a_guide() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
|
||||
@@ -951,7 +951,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
Curvature point markers are circular, with no direction handles. While drawing, its own point feedback replaces generic selection markers; existing paths use the selection overlay, including partial selections and Hide Edges. Anchor Size applies, and Direct Selection and Pen retain their handles. The real egui-frame regressions in `crates/ui-egui/src/canvas.rs` verify marker positions, counts and unchanged documents (#1236, in part); the existing-path interpolation request remains open. The curve through the points (`curvature_spline` in `crates/tools/src/draw2.rs`) makes each stretch of three points follow the circle through them: a smooth point's handles lie along that circle's tangent, each `chord / (3 cos²(α/2))` long (α between handle and chord, capped at 90°), and the ends of an open path mirror their neighbour's tangent in their chord, so a J or a U curves evenly, a turn tighter than 30° comes to a point, and uneven spacing doesn't loop; the rubber band, a dragged or inserted point and `path.convertAnchor` to smooth use the same rule, while the Pencil's fitting keeps its own; `tests/draw2.rs` checks that the rubber band is the path the click makes (#1236, in part). The Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512).
|
||||
While Alt is held during a Paintbrush stroke or before a press with no path selected that the Smooth tool can act on, the Paintbrush shows a closing cursor (a crosshair with a ring), and so does the Pencil with Alt key toggles to Smooth Tool off when Alt is pressed after the drag starts; releasing the mouse button with Alt still held closes the path. Before a Paintbrush press with such a path selected, Alt shows the Smooth cursor, and holding Alt at the press smooths that path with the Smooth tool's radius and Smooth Tool Options' Fidelity. A Pencil drag started with Shift or Alt held draws a straight line instead, which stays open.
|
||||
With Shift held at the press, the Pencil draws a straight line from the press point to the pointer at 45° steps from the Constrain Angle, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes; pressed on an end of a selected open path, the line continues the path from that end (part of #3799). With Option/Alt held at the press and Alt key toggles to Smooth Tool off, the Pencil draws a straight line at any angle, whose ends snap to Smart Guides, and it stays open when Alt is still held at the release; Shift and Alt together keep the 45° steps (#3799). While Shift or Alt is held before the press, the Pencil shows a straight-line cursor (a crosshair with a short line), and over an end of a selected open path, where the line continues the path, a path-continuation cursor (the same line leaving a small square), as the page describes.
|
||||
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. A shape just drawn stays adjustable with its tool: its Live Shape widgets drag, a press on its centre point (Show Center) moves the selection with the Selection tool's move arrow, snapping and Alt copy, and a press in the rotate zone just outside a bounding-box corner turns it about the centre with the Selection tool's rotate cursor (#5477; tests `a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corner` and, through the engine, `shapetools.rs`). The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812).
|
||||
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. A shape just drawn stays adjustable with its tool: its Live Shape widgets drag, a press on its centre point (Show Center) moves the selection with the Selection tool's move arrow, snapping and Alt copy, and a press in the rotate zone just outside a bounding-box corner turns it about the centre with the Selection tool's rotate cursor (#5477; tests `a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corner` and, through the engine, `shapetools.rs`). A drag on one of its eight bounding-box handles scales it as the Selection tool's handles do, with their cursors and snapping, instead of drawing a new shape from the handle. A shape tool takes a press as a handle's only on the drawn handle square (VectorCraft's own rule: 3 screen points from its centre along each axis at the default Anchor Size, a half point more or less per step; the Selection tool takes 5), so a press just beside a handle still draws (tests `a_shape_tool_scales_the_selection_by_its_bounding_box_handles`, `a_shape_tool_draws_from_a_press_just_beside_a_handle` and `a_shape_tool_scales_the_shape_just_drawn_by_its_bounding_box_handle`). The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812).
|
||||
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays by 0.1 radian at each drag update in which the pointer's document y grows, and back by as much at each one in which it falls, Shift held keeps their turn, and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812).
|
||||
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays with the pointer, Shift keeps them in their original orientation (unturned, or turned by a nonzero General › Constrain Angle), and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812).
|
||||
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays with the pointer, Shift keeps them in their original orientation, and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812).
|
||||
|
||||
Reference in New Issue
Block a user