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:
BFlat
2026-10-26 13:49:33 +07:00
committed by GitHub
8 changed files with 553 additions and 9 deletions
+1
View File
@@ -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;
+395
View File
@@ -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));
}
}
+40 -4
View File
@@ -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 {
+104
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3 -1
View File
File diff suppressed because one or more lines are too long