mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
Merge pull request #4166 from Taha-Mahmoodi/fix/2691-repeat-grid-handles
Repeat: a selected Grid Repeat has on-canvas handles for its rows, columns and spacing (part of #2691)
This commit is contained in:
@@ -30,6 +30,7 @@ pub mod pen;
|
||||
pub mod pie;
|
||||
pub mod place;
|
||||
pub mod printtiling;
|
||||
pub mod repeat_grid;
|
||||
pub mod rulerguide;
|
||||
pub mod select;
|
||||
pub mod settings;
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
//! Grid Repeat's on-canvas handles (#2691): a handle below the grid adds or removes rows, one right
|
||||
//! of it columns, one past its bottom-right corner both, and a slider between the first two
|
||||
//! columns (rows) sets the horizontal (vertical) spacing, as the public "Set grid repeat options"
|
||||
//! page describes. Drawing and hit testing share the document-space geometry of
|
||||
//! [`GridHandles::of`]; a drag previews the existing Repeat Options command and commits as one undo
|
||||
//! step.
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::pattern::tile_offset;
|
||||
use vectorcraft_doc::{Document, NodeId, NodeKind, OrientedBox, RepeatKind, Selection};
|
||||
use vectorcraft_geom::{Point, Rect, Vec2};
|
||||
|
||||
use crate::bbox::Handle;
|
||||
use crate::{Action, Cursor, Mods, ToolContext};
|
||||
|
||||
/// The radius of the row, column and corner handles (screen points).
|
||||
pub const HANDLE_RADIUS: f32 = 5.0;
|
||||
|
||||
/// How far (screen points) the row, column and corner handles sit outside the bounding box, clear
|
||||
/// of its own handles and rotate zones.
|
||||
pub const HANDLE_GAP: f64 = 22.0;
|
||||
|
||||
/// Half the length of a spacing slider's bar (screen points).
|
||||
pub const SLIDER_HALF: f32 = 7.0;
|
||||
|
||||
/// How near (screen points) a press must come to a handle to take it.
|
||||
const REACH: f64 = 8.0;
|
||||
|
||||
/// The smallest tile (source size plus spacing) a spacing slider leaves, in points.
|
||||
const MIN_TILE: f64 = 0.01;
|
||||
|
||||
/// The most rows or columns a grid repeat has (as `object.repeat.options` clamps them).
|
||||
const MAX_COUNT: f64 = 500.0;
|
||||
|
||||
/// A control of [`GridHandles`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GridPart {
|
||||
/// Below the grid: drag down for more rows, up for fewer.
|
||||
Rows,
|
||||
/// Right of the grid: drag right for more columns, left for fewer.
|
||||
Cols,
|
||||
/// Past the bottom-right corner: rows and columns together.
|
||||
Corner,
|
||||
/// Between the first two columns: the horizontal spacing.
|
||||
HSpacing,
|
||||
/// Between the first two rows: the vertical spacing.
|
||||
VSpacing,
|
||||
}
|
||||
|
||||
impl GridPart {
|
||||
/// Whether the control draws as a slider bar rather than a round handle.
|
||||
pub fn is_slider(self) -> bool {
|
||||
matches!(self, Self::HSpacing | Self::VSpacing)
|
||||
}
|
||||
|
||||
/// The bounding-box handle whose resize cursor the control shows.
|
||||
fn handle(self) -> Handle {
|
||||
match self {
|
||||
Self::Rows | Self::VSpacing => Handle::Bottom,
|
||||
Self::Cols | Self::HSpacing => Handle::Right,
|
||||
Self::Corner => Handle::BottomRight,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The controls of a selected grid repeat, in document space.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct GridHandles {
|
||||
pub id: NodeId,
|
||||
rows: u32,
|
||||
cols: u32,
|
||||
h_spacing: f64,
|
||||
v_spacing: f64,
|
||||
/// The source's bounds (the first tile).
|
||||
source: Rect,
|
||||
/// From one column to the next (x) and one row to the next (y).
|
||||
step: Vec2,
|
||||
/// How much `step` moves for each point of spacing (0.75 along a hex grid's staggered axis).
|
||||
gain: Vec2,
|
||||
pub rows_at: Point,
|
||||
pub cols_at: Point,
|
||||
pub corner_at: Point,
|
||||
/// With two columns or more.
|
||||
pub h_slider: Option<Point>,
|
||||
/// With two rows or more.
|
||||
pub v_slider: Option<Point>,
|
||||
}
|
||||
|
||||
impl GridHandles {
|
||||
/// The controls of a single editable grid repeat selected whole, placed outside `frame` (the
|
||||
/// bounding box the Selection tool shows; its geometric bounds without one) at `zoom`.
|
||||
pub fn of(doc: &Document, selection: &Selection, frame: Option<OrientedBox>, zoom: f64) -> Option<Self> {
|
||||
let [id] = selection.objects.as_slice() else { return None };
|
||||
if !selection.anchors.is_empty() || !selection.segments.is_empty() || !doc.is_editable(*id) || !zoom.is_finite() || zoom <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let n = doc.node(*id)?;
|
||||
let NodeKind::Repeat(spec) = &n.kind else { return None };
|
||||
let RepeatKind::Grid { h_spacing, v_spacing, rows, cols, grid_type, .. } = spec.kind else { return None };
|
||||
if !h_spacing.is_finite() || !v_spacing.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let source = spec.source_bounds()?;
|
||||
// As `repeat_transforms` lays the tiles out.
|
||||
let (w, h) = ((source.width() + h_spacing).max(1e-3), (source.height() + v_spacing).max(1e-3));
|
||||
let step = Vec2::new(tile_offset(grid_type, w, h, 1, 0).x, tile_offset(grid_type, w, h, 0, 1).y);
|
||||
let gain = Vec2::new(tile_offset(grid_type, 1.0, 1.0, 1, 0).x, tile_offset(grid_type, 1.0, 1.0, 0, 1).y);
|
||||
if ![step.x, step.y, gain.x, gain.y].iter().all(|v| v.is_finite() && *v > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let frame = match frame {
|
||||
Some(f) => aabb(f.corners())?,
|
||||
None => n.geometric_bounds_with(&doc.symbol_boxes())?,
|
||||
};
|
||||
let gap = HANDLE_GAP / zoom;
|
||||
let (rows, cols) = (rows.clamp(1, 500), cols.clamp(1, 500));
|
||||
let g = Self {
|
||||
id: *id,
|
||||
rows,
|
||||
cols,
|
||||
h_spacing,
|
||||
v_spacing,
|
||||
source,
|
||||
step,
|
||||
gain,
|
||||
rows_at: Point::new(frame.center().x, frame.y1 + gap),
|
||||
cols_at: Point::new(frame.x1 + gap, frame.center().y),
|
||||
corner_at: Point::new(frame.x1 + gap, frame.y1 + gap),
|
||||
// Halfway between the right edge of the first column and the left edge of the second,
|
||||
// level with the first tile; likewise between the first two rows.
|
||||
h_slider: (cols > 1).then(|| Point::new(source.x0 + (source.width() + step.x) / 2.0, source.center().y)),
|
||||
v_slider: (rows > 1).then(|| Point::new(source.center().x, source.y0 + (source.height() + step.y) / 2.0)),
|
||||
};
|
||||
g.parts().iter().all(|(_, p)| finite(*p)).then_some(g)
|
||||
}
|
||||
|
||||
/// Every control with where it is: the round handles, then the sliders there are.
|
||||
pub fn parts(&self) -> Vec<(GridPart, Point)> {
|
||||
let mut v = vec![(GridPart::Rows, self.rows_at), (GridPart::Cols, self.cols_at), (GridPart::Corner, self.corner_at)];
|
||||
v.extend(self.h_slider.map(|p| (GridPart::HSpacing, p)));
|
||||
v.extend(self.v_slider.map(|p| (GridPart::VSpacing, p)));
|
||||
v
|
||||
}
|
||||
|
||||
/// The control under `p` (the nearest within reach) and the grid's controls, unless a
|
||||
/// bounding-box handle is there, which keeps its scaling.
|
||||
fn hit(cx: &ToolContext, p: Point) -> Option<(Self, GridPart)> {
|
||||
if !cx.show_bbox || !finite(p) {
|
||||
return None;
|
||||
}
|
||||
let g = Self::of(cx.doc, cx.selection, crate::select::selection_box(cx), cx.zoom)?;
|
||||
let reach = cx.tol(REACH);
|
||||
let (part, _) =
|
||||
g.parts().into_iter().map(|(part, q)| (part, q.distance(p))).filter(|(_, d)| *d <= reach).min_by(|a, b| a.1.total_cmp(&b.1))?;
|
||||
(!crate::select::bbox_handle_at(cx, p)).then_some((g, part))
|
||||
}
|
||||
|
||||
/// The Repeat Options that dragging `part` by `d` (document points) from the press gives.
|
||||
fn options(&self, part: GridPart, d: Vec2) -> Value {
|
||||
let count = |n: u32, by: f64, step: f64| (f64::from(n) + (by / step).round()).clamp(1.0, MAX_COUNT) as u32;
|
||||
// The slider sits halfway across the gap, which widens by `gain` for each point of spacing:
|
||||
// the spacing that keeps the slider under the pointer.
|
||||
let spacing = |s: f64, by: f64, gain: f64, size: f64| ((s + 2.0 * by / gain).max(MIN_TILE - size) * 100.0).round() / 100.0;
|
||||
match part {
|
||||
GridPart::Rows => json!({ "rows": count(self.rows, d.y, self.step.y) }),
|
||||
GridPart::Cols => json!({ "cols": count(self.cols, d.x, self.step.x) }),
|
||||
GridPart::Corner => json!({ "rows": count(self.rows, d.y, self.step.y), "cols": count(self.cols, d.x, self.step.x) }),
|
||||
GridPart::HSpacing => json!({ "hSpacing": spacing(self.h_spacing, d.x, self.gain.x, self.source.width()) }),
|
||||
GridPart::VSpacing => json!({ "vSpacing": spacing(self.v_spacing, d.y, self.gain.y, self.source.height()) }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The page-square bounds of `corners`.
|
||||
fn aabb(corners: [Point; 4]) -> Option<Rect> {
|
||||
let r = corners.iter().skip(1).fold(Rect::from_points(corners[0], corners[0]), |r, p| r.union_pt(*p));
|
||||
[r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()).then_some(r)
|
||||
}
|
||||
|
||||
fn finite(p: Point) -> bool {
|
||||
p.x.is_finite() && p.y.is_finite()
|
||||
}
|
||||
|
||||
/// The resize cursor of `part`, turned with the view.
|
||||
fn part_cursor(cx: &ToolContext, part: GridPart) -> Cursor {
|
||||
crate::select::handle_cursor(part.handle(), -cx.view_angle())
|
||||
}
|
||||
|
||||
/// The cursor over a grid repeat's control at `p`, if one is there.
|
||||
pub fn cursor(cx: &ToolContext, p: Point) -> Option<Cursor> {
|
||||
let (_, part) = GridHandles::hit(cx, p)?;
|
||||
Some(part_cursor(cx, part))
|
||||
}
|
||||
|
||||
/// A drag of one of a grid repeat's controls.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GridDrag {
|
||||
grid: GridHandles,
|
||||
part: GridPart,
|
||||
start: Point,
|
||||
/// The options the press found, and the last ones previewed.
|
||||
from: Value,
|
||||
last: Option<Value>,
|
||||
}
|
||||
|
||||
impl GridDrag {
|
||||
pub fn hit(cx: &ToolContext, p: Point, mods: Mods) -> Option<Self> {
|
||||
if mods.cmd || mods.ctrl || mods.alt {
|
||||
return None;
|
||||
}
|
||||
let (grid, part) = GridHandles::hit(cx, p)?;
|
||||
Some(Self { grid, part, start: p, from: grid.options(part, Vec2::ZERO), last: None })
|
||||
}
|
||||
|
||||
/// Whether the drag has changed the grid (and so previews it).
|
||||
pub fn adjusting(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
pub fn cursor(&self, cx: &ToolContext) -> Cursor {
|
||||
part_cursor(cx, self.part)
|
||||
}
|
||||
|
||||
/// Preview the options the pointer at `p` gives, when they change; the first change opens
|
||||
/// the undo step.
|
||||
pub fn drag(&mut self, p: Point) -> Vec<Action> {
|
||||
if !finite(p) {
|
||||
return vec![];
|
||||
}
|
||||
let v = self.grid.options(self.part, p - self.start);
|
||||
if self.last.as_ref().unwrap_or(&self.from) == &v {
|
||||
return vec![];
|
||||
}
|
||||
let mut out = vec![];
|
||||
if self.last.is_none() {
|
||||
out.push(Action::Begin("Repeat Options".into()));
|
||||
}
|
||||
out.push(Action::Preview("object.repeat.options".into(), v.clone()));
|
||||
self.last = Some(v);
|
||||
out
|
||||
}
|
||||
|
||||
/// Commit the drag; one that ends where it began leaves no undo step.
|
||||
pub fn finish(self) -> Vec<Action> {
|
||||
match self.last {
|
||||
Some(v) if v == self.from => vec![Action::Cancel],
|
||||
Some(_) => vec![Action::Commit],
|
||||
None => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cancel(self) -> Vec<Action> {
|
||||
if self.last.is_some() { vec![Action::Cancel] } else { vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testutil::{cx, doc_with_rect, paint};
|
||||
use std::sync::Arc;
|
||||
use vectorcraft_doc::pattern::RepeatSpec;
|
||||
|
||||
/// [`doc_with_rect`]'s 100 × 100 square at (100, 100) as a 3 × 3 grid repeat with 20 pt of
|
||||
/// spacing, selected: tiles every 120 pt, the grid 340 pt square.
|
||||
fn grid() -> (Document, Selection) {
|
||||
let (mut doc, id) = doc_with_rect();
|
||||
let mut source = doc.node(id).unwrap().clone();
|
||||
source.id = doc.alloc_id();
|
||||
doc.node_mut(id).unwrap().kind = NodeKind::Repeat(RepeatSpec::grid(vec![Arc::new(source)], 20.0, 20.0));
|
||||
let mut selection = Selection::default();
|
||||
selection.set([id]);
|
||||
(doc, selection)
|
||||
}
|
||||
|
||||
fn preview(out: &[Action]) -> Option<&Value> {
|
||||
out.iter().find_map(|a| match a {
|
||||
Action::Preview(cmd, v) if cmd == "object.repeat.options" => Some(v),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_handles_sit_outside_the_grid_at_every_zoom() {
|
||||
let (doc, selection) = grid();
|
||||
for zoom in [0.5, 1.0, 4.0] {
|
||||
let g = GridHandles::of(&doc, &selection, None, zoom).unwrap();
|
||||
let gap = HANDLE_GAP / zoom;
|
||||
assert_eq!(g.rows_at, Point::new(270.0, 440.0 + gap), "{zoom}");
|
||||
assert_eq!(g.cols_at, Point::new(440.0 + gap, 270.0), "{zoom}");
|
||||
assert_eq!(g.corner_at, Point::new(440.0 + gap, 440.0 + gap), "{zoom}");
|
||||
// Halfway across the gaps between the first two columns and rows.
|
||||
assert_eq!(g.h_slider, Some(Point::new(210.0, 150.0)), "{zoom}");
|
||||
assert_eq!(g.v_slider, Some(Point::new(150.0, 210.0)), "{zoom}");
|
||||
}
|
||||
// Placed outside the bounding box the Selection tool shows.
|
||||
let frame = OrientedBox::aligned(Rect::new(90.0, 90.0, 450.0, 450.0));
|
||||
let g = GridHandles::of(&doc, &selection, Some(frame), 1.0).unwrap();
|
||||
assert_eq!(g.rows_at, Point::new(270.0, 450.0 + HANDLE_GAP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_handles_without_a_single_valid_grid_repeat() {
|
||||
let (mut doc, mut selection) = grid();
|
||||
for zoom in [0.0, -1.0, f64::NAN, f64::INFINITY] {
|
||||
assert!(GridHandles::of(&doc, &selection, None, zoom).is_none(), "{zoom}");
|
||||
}
|
||||
let paint = paint();
|
||||
let mut c = cx(&doc, &selection, &paint);
|
||||
let g = GridHandles::of(&doc, &selection, None, 1.0).unwrap();
|
||||
assert!(cursor(&c, g.rows_at).is_some());
|
||||
c.show_bbox = false;
|
||||
assert!(cursor(&c, g.rows_at).is_none(), "the bounding box is hidden");
|
||||
let id = selection.objects[0];
|
||||
let other = doc.alloc_id();
|
||||
let mut second = doc.node(id).unwrap().clone();
|
||||
second.id = other;
|
||||
let layer = doc.layers[0].id;
|
||||
doc.insert(Some(layer), 1, second).unwrap();
|
||||
selection.set([id, other]);
|
||||
assert!(GridHandles::of(&doc, &selection, None, 1.0).is_none(), "two objects selected");
|
||||
selection.set([id]);
|
||||
let NodeKind::Repeat(spec) = &mut doc.node_mut(id).unwrap().kind else { panic!("a repeat") };
|
||||
spec.kind = RepeatKind::Mirror { angle: 90.0, center: Point::ZERO };
|
||||
assert!(GridHandles::of(&doc, &selection, None, 1.0).is_none(), "a mirror repeat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_one_by_one_grid_has_no_spacing_sliders() {
|
||||
let (mut doc, selection) = grid();
|
||||
let NodeKind::Repeat(spec) = &mut doc.node_mut(selection.objects[0]).unwrap().kind else { panic!("a repeat") };
|
||||
let RepeatKind::Grid { rows, cols, .. } = &mut spec.kind else { panic!("a grid") };
|
||||
(*rows, *cols) = (1, 1);
|
||||
let g = GridHandles::of(&doc, &selection, None, 1.0).unwrap();
|
||||
assert_eq!((g.h_slider, g.v_slider), (None, None));
|
||||
assert_eq!(g.parts().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_handles_previews_rows_columns_and_spacing() {
|
||||
let (doc, selection) = grid();
|
||||
let paint = paint();
|
||||
let c = cx(&doc, &selection, &paint);
|
||||
let g = GridHandles::of(&doc, &selection, crate::select::selection_box(&c), 1.0).unwrap();
|
||||
// Two tile heights down: five rows, one undo step.
|
||||
let mut d = GridDrag::hit(&c, g.rows_at, Mods::default()).unwrap();
|
||||
assert!(d.drag(g.rows_at + Vec2::new(0.0, 30.0)).is_empty(), "less than half a tile changes nothing");
|
||||
assert!(!d.adjusting());
|
||||
let out = d.drag(g.rows_at + Vec2::new(3.0, 240.0));
|
||||
assert!(matches!(out.first(), Some(Action::Begin(l)) if l == "Repeat Options"), "{out:?}");
|
||||
assert_eq!(preview(&out), Some(&json!({"rows": 5})));
|
||||
assert!(d.drag(g.rows_at + Vec2::new(0.0, 250.0)).is_empty(), "the same count isn't previewed again");
|
||||
assert!(matches!(d.finish().as_slice(), [Action::Commit]));
|
||||
// Left past the first column: one column at least.
|
||||
let mut d = GridDrag::hit(&c, g.cols_at, Mods::default()).unwrap();
|
||||
assert_eq!(preview(&d.drag(g.cols_at - Vec2::new(900.0, 0.0))), Some(&json!({"cols": 1})));
|
||||
let mut d = GridDrag::hit(&c, g.corner_at, Mods::default()).unwrap();
|
||||
assert_eq!(preview(&d.drag(g.corner_at + Vec2::new(120.0, -120.0))), Some(&json!({"rows": 2, "cols": 4})));
|
||||
// The spacing slider stays under the pointer: 10 pt right is 20 pt more spacing.
|
||||
let mut d = GridDrag::hit(&c, g.h_slider.unwrap(), Mods::default()).unwrap();
|
||||
assert_eq!(preview(&d.drag(g.h_slider.unwrap() + Vec2::new(10.0, 4.0))), Some(&json!({"hSpacing": 40.0})));
|
||||
// Back where it began: no undo step.
|
||||
assert_eq!(preview(&d.drag(g.h_slider.unwrap())), Some(&json!({"hSpacing": 20.0})));
|
||||
assert!(matches!(d.finish().as_slice(), [Action::Cancel]));
|
||||
// Overlapping tiles keep a tile of at least 0.01 pt.
|
||||
let mut d = GridDrag::hit(&c, g.v_slider.unwrap(), Mods::default()).unwrap();
|
||||
assert_eq!(preview(&d.drag(g.v_slider.unwrap() - Vec2::new(0.0, 500.0))), Some(&json!({"vSpacing": -99.99})));
|
||||
assert!(matches!(d.cancel().as_slice(), [Action::Cancel]));
|
||||
// Alt, Cmd and Ctrl keep the Selection tool's own gestures.
|
||||
assert!(GridDrag::hit(&c, g.rows_at, Mods { alt: true, ..Mods::default() }).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bounding_box_handle_wins_where_they_overlap() {
|
||||
let (doc, selection) = grid();
|
||||
let paint = paint();
|
||||
let mut c = cx(&doc, &selection, &paint);
|
||||
// Zoomed far out (the 340 pt grid 6.8 px across), the spacing slider lies on the top-left
|
||||
// bounding-box handle, which keeps its scaling; the round handles stay clear of the box.
|
||||
c.zoom = 0.02;
|
||||
let g = GridHandles::of(&doc, &selection, crate::select::selection_box(&c), c.zoom).unwrap();
|
||||
let slider = g.h_slider.unwrap();
|
||||
assert!(crate::select::bbox_handle_at(&c, slider));
|
||||
assert!(GridDrag::hit(&c, slider, Mods::default()).is_none());
|
||||
assert!(cursor(&c, slider).is_none());
|
||||
assert!(!crate::select::bbox_handle_at(&c, g.rows_at));
|
||||
assert!(GridDrag::hit(&c, g.rows_at, Mods::default()).is_some());
|
||||
// The spacing slider, inside the box, is the grid's.
|
||||
c.zoom = 1.0;
|
||||
let g = GridHandles::of(&doc, &selection, crate::select::selection_box(&c), c.zoom).unwrap();
|
||||
assert!(GridDrag::hit(&c, g.h_slider.unwrap(), Mods::default()).is_some());
|
||||
assert_eq!(cursor(&c, g.h_slider.unwrap()), Some(Cursor::ResizeH));
|
||||
assert_eq!(cursor(&c, g.rows_at), Some(Cursor::ResizeV));
|
||||
assert_eq!(cursor(&c, g.corner_at), Some(Cursor::ResizeNwSe));
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,8 @@ enum State {
|
||||
Pie(PieDrag),
|
||||
/// Move or rotate a live mirror axis.
|
||||
Mirror(crate::mirror::MirrorDrag),
|
||||
/// Drag a grid repeat's row, column or corner handle or a spacing slider.
|
||||
GridRepeat(crate::repeat_grid::GridDrag),
|
||||
/// Dragging a bracket of type on a path.
|
||||
Bracket(BracketDrag),
|
||||
/// Loaded with the text of area type or type on a path `from` by a click on its out port, or
|
||||
@@ -560,6 +562,12 @@ impl Tool for SelectionTool {
|
||||
{
|
||||
return d.cancel();
|
||||
}
|
||||
if key == ToolKey::Escape
|
||||
&& matches!(self.state, State::GridRepeat(_))
|
||||
&& let State::GridRepeat(d) = std::mem::take(&mut self.state)
|
||||
{
|
||||
return d.cancel();
|
||||
}
|
||||
if key == ToolKey::Escape && matches!(self.state, State::Loaded { .. }) {
|
||||
self.state = State::Idle;
|
||||
return vec![];
|
||||
@@ -570,7 +578,7 @@ impl Tool for SelectionTool {
|
||||
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
|
||||
match self.state {
|
||||
State::Marquee { .. } => key == ToolKey::Letter('e'),
|
||||
State::Loaded { .. } | State::Mirror(_) => key == ToolKey::Escape,
|
||||
State::Loaded { .. } | State::Mirror(_) | State::GridRepeat(_) => key == ToolKey::Escape,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -593,6 +601,7 @@ impl Tool for SelectionTool {
|
||||
State::Pie(d) => d.adjusting(),
|
||||
State::Bracket(b) => b.adjusting(),
|
||||
State::Mirror(d) => d.adjusting(),
|
||||
State::GridRepeat(d) => d.adjusting(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -606,7 +615,8 @@ impl Tool for SelectionTool {
|
||||
{
|
||||
return out;
|
||||
}
|
||||
// Mirror controls take precedence over art, bounding handles and ruler guides.
|
||||
// Mirror and grid repeat controls take precedence over art and ruler guides; the mirror
|
||||
// axis line and the grid's controls yield to the bounding-box handles they overlap.
|
||||
if let State::Mirror(mut d) = self.state.clone() {
|
||||
match ev.kind {
|
||||
PointerKind::Drag | PointerKind::Up => {
|
||||
@@ -622,6 +632,21 @@ impl Tool for SelectionTool {
|
||||
_ => return vec![],
|
||||
}
|
||||
}
|
||||
if let State::GridRepeat(mut d) = self.state.clone() {
|
||||
match ev.kind {
|
||||
PointerKind::Drag | PointerKind::Up => {
|
||||
let mut out = d.drag(ev.pos);
|
||||
if ev.kind == PointerKind::Up {
|
||||
out.extend(d.finish());
|
||||
self.state = State::Idle;
|
||||
} else {
|
||||
self.state = State::GridRepeat(d);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
_ => return vec![],
|
||||
}
|
||||
}
|
||||
if matches!(self.state, State::Idle)
|
||||
&& ev.kind == PointerKind::Down
|
||||
&& let Some(d) = crate::mirror::MirrorDrag::hit(cx, ev.pos, ev.mods)
|
||||
@@ -630,6 +655,14 @@ impl Tool for SelectionTool {
|
||||
self.hover = None;
|
||||
return vec![];
|
||||
}
|
||||
if matches!(self.state, State::Idle)
|
||||
&& ev.kind == PointerKind::Down
|
||||
&& let Some(d) = crate::repeat_grid::GridDrag::hit(cx, ev.pos, ev.mods)
|
||||
{
|
||||
self.state = State::GridRepeat(d);
|
||||
self.hover = None;
|
||||
return vec![];
|
||||
}
|
||||
if let Some(out) = self.guide.pointer(cx, ev) {
|
||||
return out;
|
||||
}
|
||||
@@ -925,11 +958,14 @@ impl Tool for SelectionTool {
|
||||
if let State::Mirror(d) = &self.state {
|
||||
return d.cursor(cx, p);
|
||||
}
|
||||
if let State::GridRepeat(d) = &self.state {
|
||||
return d.cursor(cx);
|
||||
}
|
||||
if matches!(self.state, State::Idle)
|
||||
&& !m.cmd
|
||||
&& !m.ctrl
|
||||
&& !m.alt
|
||||
&& let Some(cursor) = crate::mirror::cursor(cx, p)
|
||||
&& let Some(cursor) = crate::mirror::cursor(cx, p).or_else(|| crate::repeat_grid::cursor(cx, p))
|
||||
{
|
||||
return cursor;
|
||||
}
|
||||
@@ -1052,7 +1088,7 @@ pub(crate) fn release_guide_at(cx: &ToolContext, p: Point, m: Mods) -> Option<Ac
|
||||
/// The resize cursor for handle `h` of a box turned by `angle` (counter-clockwise degrees): the
|
||||
/// handle that sits where `h` appears on screen, in 45° steps. In a turned view, `angle` is the
|
||||
/// box's own angle less [`ToolContext::view_angle`].
|
||||
fn handle_cursor(h: Handle, angle: f64) -> Cursor {
|
||||
pub(crate) fn handle_cursor(h: Handle, angle: f64) -> Cursor {
|
||||
let steps = if angle.is_finite() { (angle / 45.0).round() as i64 } else { 0 };
|
||||
let h = Handle::ALL.get((h as i64 - steps).rem_euclid(8) as usize).copied().unwrap_or(h);
|
||||
match h {
|
||||
|
||||
@@ -689,6 +689,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
selection_overlay(app, &painter, &xf, overlays.iter().any(|o| matches!(o, Overlay::CurvePoint { .. })));
|
||||
mirror_axis_overlay(app, &painter, &xf);
|
||||
grid_repeat_overlay(app, &painter, &xf);
|
||||
if app.ui.view.text_threads {
|
||||
// Under the tools' overlays: a selected frame's own ports are drawn over its thread's.
|
||||
let mut threads = thread_overlays(app, xf.zoom);
|
||||
@@ -2857,6 +2858,36 @@ fn mirror_axis_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
p.rect_stroke(handle, 2.0, Stroke::new(2.0, t.measure_bg), StrokeKind::Outside);
|
||||
}
|
||||
|
||||
/// A selected grid repeat's row, column and corner handles and its spacing sliders (#2691), at
|
||||
/// the places the Selection tool's hit testing finds them.
|
||||
fn grid_repeat_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
use vectorcraft_tools::repeat_grid::{GridHandles, GridPart, HANDLE_RADIUS, SLIDER_HALF};
|
||||
if app.session.tool_id() != "selection" || !app.ui.view.bounding_box {
|
||||
return;
|
||||
}
|
||||
let frame = app.selection_box();
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let Some(grid) = GridHandles::of(&st.doc, &st.selection, frame, xf.zoom) else { return };
|
||||
let t = Tokens::get(p.ctx());
|
||||
let half = f64::from(SLIDER_HALF) / xf.zoom;
|
||||
for (part, at) in grid.parts() {
|
||||
let c = xf.to_screen(at);
|
||||
if !c.is_finite() {
|
||||
continue;
|
||||
}
|
||||
if part.is_slider() {
|
||||
// A bar across the gap's middle, square to the spacing it sets.
|
||||
let along = if part == GridPart::HSpacing { vectorcraft_geom::Vec2::new(0.0, half) } else { vectorcraft_geom::Vec2::new(half, 0.0) };
|
||||
let ends = [xf.to_screen(at - along), xf.to_screen(at + along)];
|
||||
p.line_segment(ends, Stroke::new(6.0, t.measure_bg));
|
||||
p.line_segment(ends, Stroke::new(2.5, t.selection));
|
||||
} else {
|
||||
p.circle_filled(c, HANDLE_RADIUS, t.measure_bg);
|
||||
p.circle_stroke(c, HANDLE_RADIUS, Stroke::new(2.0, t.selection));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The radius of the Rotate View dial, in screen points.
|
||||
const DIAL_RADIUS: f32 = 40.0;
|
||||
|
||||
@@ -6504,6 +6535,79 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A selected grid repeat draws its row handle below the grid and a slider between its first
|
||||
/// two columns (none with the bounding box hidden). Dragging the row handle down two tiles in
|
||||
/// a zoomed view makes five rows without scaling the source, as one undo step, and the slider
|
||||
/// sets the horizontal spacing (#2691).
|
||||
#[test]
|
||||
fn grid_repeat_handles_set_rows_and_spacing_in_one_undo_step_each() {
|
||||
use vectorcraft_tools::repeat_grid::{GridHandles, HANDLE_RADIUS};
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 40, "y": 40, "width": 40, "height": 40})).unwrap();
|
||||
app.run("object.repeat.grid", json!({"hSpacing": 10, "vSpacing": 10})).unwrap();
|
||||
app.ui.task_bar = false;
|
||||
app.ui.view.rulers = false;
|
||||
let ctx = egui::Context::default();
|
||||
// The first frame fits the artboard in the window; then zoom to 1.5 px a point.
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
app.view_mut().unwrap().zoom = 1.5;
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
assert_eq!(app.view().unwrap().zoom, 1.5);
|
||||
// Rows, columns, horizontal spacing and the source's bounds.
|
||||
let grid = |app: &VectorcraftApp| {
|
||||
let st = app.session.active().unwrap();
|
||||
let n = st.doc.node(st.selection.objects[0]).unwrap();
|
||||
let vectorcraft_doc::NodeKind::Repeat(spec) = &n.kind else { panic!("a repeat") };
|
||||
let vectorcraft_doc::RepeatKind::Grid { rows, cols, h_spacing, .. } = spec.kind else { panic!("a grid repeat") };
|
||||
(rows, cols, h_spacing, spec.source_bounds().unwrap())
|
||||
};
|
||||
let handles = |app: &mut VectorcraftApp| {
|
||||
let frame = app.selection_box();
|
||||
let st = app.session.active().unwrap();
|
||||
GridHandles::of(&st.doc, &st.selection, frame, app.view().unwrap().zoom).unwrap()
|
||||
};
|
||||
let before = grid(&app);
|
||||
assert_eq!((before.0, before.1, before.2), (3, 3, 10.0));
|
||||
let g = handles(&mut app);
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
let drawn = shapes(&mut app, &ctx);
|
||||
let circle_at = |drawn: &[Shape], at: Pos2| {
|
||||
drawn.iter().any(|s| matches!(s, Shape::Circle(c) if c.center.distance(at) < 0.01 && c.radius == HANDLE_RADIUS))
|
||||
};
|
||||
assert!(circle_at(&drawn, xf.to_screen(g.rows_at)), "the row handle is drawn");
|
||||
assert!(circle_at(&drawn, xf.to_screen(g.corner_at)), "the corner handle is drawn");
|
||||
app.ui.view.bounding_box = false;
|
||||
assert!(!circle_at(&shapes(&mut app, &ctx), xf.to_screen(g.rows_at)), "hidden with the bounding box");
|
||||
app.ui.view.bounding_box = true;
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
let drag = |app: &mut VectorcraftApp, from: Pos2, by: egui::Vec2| {
|
||||
frame(app, &ctx, vec![egui::Event::PointerMoved(from), button(from, true)]);
|
||||
for k in 1..=4 {
|
||||
frame(app, &ctx, vec![egui::Event::PointerMoved(from + by * k as f32 / 4.0)]);
|
||||
}
|
||||
frame(app, &ctx, vec![button(from + by, false)]);
|
||||
};
|
||||
// Two tiles of 50 pt down, at 1.5 px a point.
|
||||
drag(&mut app, xf.to_screen(g.rows_at), vec2(0.0, 150.0));
|
||||
let after = grid(&app);
|
||||
assert_eq!((after.0, after.1, after.2), (5, 3, 10.0));
|
||||
assert_eq!(after.3, before.3, "the source keeps its size");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(grid(&app).0, 3, "one undo step");
|
||||
app.run("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(grid(&app).0, 5);
|
||||
// The slider between the first two columns, 6 px (4 pt) right: it stays halfway across a
|
||||
// gap 8 pt wider, 18 pt of spacing.
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let g = handles(&mut app);
|
||||
drag(&mut app, xf.to_screen(g.h_slider.unwrap()), vec2(6.0, 0.0));
|
||||
assert_eq!(grid(&app).2, 18.0);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!((grid(&app).0, grid(&app).2), (5, 10.0), "the spacing's own undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_bounding_box_handle_scales_a_default_mirror_repeat() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
|
||||
+4
-1
File diff suppressed because one or more lines are too long
+2
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user