Skip to main content

pixelcoords_core/
selection.rs

1//! The set of committed selections plus per-op undo and redo stacks.
2
3use crate::geometry::{Line, Point, ResizeHandle, Shape};
4
5/// A two-point measurement laid on the frozen image.
6///
7/// Deliberately not a [`Selection`]: it has no interior, so it produces
8/// no crop, contributes nothing to a cutout, and has no click point for
9/// `assert` or `emit` to answer about. Forcing it through the selection
10/// list would make every one of those grow a special case for a thing
11/// they cannot say anything useful about.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct Measure {
14    pub line: Line,
15    pub label: String,
16    /// Index into the session's monitor list. A measure lives within one
17    /// monitor's frame, like every shape.
18    pub monitor: usize,
19}
20
21impl Measure {
22    #[must_use]
23    pub const fn new(line: Line, monitor: usize) -> Self {
24        Self {
25            line,
26            label: String::new(),
27            monitor,
28        }
29    }
30}
31
32/// Which end of a measure a drag has hold of.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum MeasureGrab {
35    /// An endpoint: `true` is `a`, `false` is `b`.
36    Endpoint(bool),
37    /// The line itself: drag moves the whole ruler.
38    Move,
39}
40
41/// What a mouse-press on the overlay grabs.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum GrabKind {
44    /// Inside a shape: drag moves it.
45    Move,
46    /// On a shape's border: drag resizes it.
47    Resize(ResizeHandle),
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct Selection {
52    pub shape: Shape,
53    pub label: String,
54    /// Index into the session's monitor list.
55    pub monitor: usize,
56    /// Rotation in degrees (`0..360`) about the shape's bbox center.
57    pub rot_deg: i32,
58}
59
60impl Selection {
61    pub const fn new(shape: Shape, monitor: usize) -> Self {
62        Self {
63            shape,
64            label: String::new(),
65            monitor,
66            rot_deg: 0,
67        }
68    }
69}
70
71/// State-restoring operations. Applying one yields its own inverse, which
72/// is what lets a single engine drive both undo and redo.
73#[derive(Debug, Clone)]
74enum UndoOp {
75    RemoveLast,
76    Restack { from: usize, to: usize },
77    RemoveAt { index: usize },
78    Reinsert { index: usize, selection: Selection },
79    RestoreShape { index: usize, shape: Shape },
80    RestoreLabel { index: usize, label: String },
81    RestoreRotation { index: usize, deg: i32 },
82    // Measures ride the same stack as selections rather than a parallel
83    // one, so Z undoes the last thing the user did whatever kind it was.
84    // Two stacks would undo the last *shape* and leave a ruler drawn
85    // after it standing, which is not what anybody means by undo.
86    RemoveLastMeasure,
87    ReinsertMeasure { index: usize, measure: Measure },
88    RemoveMeasureAt { index: usize },
89    RestoreMeasureLine { index: usize, line: Line },
90    RestoreMeasureLabel { index: usize, label: String },
91}
92
93#[derive(Debug, Default)]
94pub struct SelectionSet {
95    items: Vec<Selection>,
96    measures: Vec<Measure>,
97    undo: Vec<UndoOp>,
98    redo: Vec<UndoOp>,
99}
100
101impl SelectionSet {
102    pub fn new() -> Self {
103        Self::default()
104    }
105
106    /// A set pre-populated with existing selections — restoring a saved
107    /// session. Seeding is not an edit: no undo history is created, so
108    /// undo in a resumed session stops at the resume point instead of
109    /// dismantling the session it reopened.
110    pub fn seed(items: Vec<Selection>, measures: Vec<Measure>) -> Self {
111        Self {
112            items,
113            measures,
114            undo: Vec::new(),
115            redo: Vec::new(),
116        }
117    }
118
119    pub fn items(&self) -> &[Selection] {
120        &self.items
121    }
122
123    pub fn len(&self) -> usize {
124        self.items.len()
125    }
126
127    pub fn is_empty(&self) -> bool {
128        self.items.is_empty()
129    }
130
131    pub fn get(&self, index: usize) -> Option<&Selection> {
132        self.items.get(index)
133    }
134
135    /// Topmost selection on `monitor` containing `p` (most recent wins),
136    /// respecting each selection's rotation.
137    pub fn hit_topmost(&self, monitor: usize, p: Point) -> Option<usize> {
138        self.items
139            .iter()
140            .enumerate()
141            .rev()
142            .find(|(_, s)| s.monitor == monitor && s.shape.hit_test_rotated(s.rot_deg, p))
143            .map(|(i, _)| i)
144    }
145
146    /// What a press at `p` grabs, topmost selection first: a border within
147    /// `tolerance` resizes, an interior moves. A shape's border outranks
148    /// its own interior; a topmost shape outranks anything beneath it.
149    pub fn grab_topmost(
150        &self,
151        monitor: usize,
152        p: Point,
153        tolerance: i32,
154    ) -> Option<(usize, GrabKind)> {
155        self.items
156            .iter()
157            .enumerate()
158            .rev()
159            .filter(|(_, s)| s.monitor == monitor)
160            .find_map(|(i, s)| {
161                if let Some(handle) = s.shape.resize_grab_rotated(s.rot_deg, p, tolerance) {
162                    return Some((i, GrabKind::Resize(handle)));
163                }
164                if s.shape.hit_test_rotated(s.rot_deg, p) {
165                    return Some((i, GrabKind::Move));
166                }
167                None
168            })
169    }
170
171    /// Rotate a selection by `delta` degrees about its bbox center; circles
172    /// are inherently rotation-free and are left untouched. Returns the new
173    /// rotation, or `None` if nothing changed.
174    pub fn rotate(&mut self, index: usize, delta: i32) -> Option<i32> {
175        let s = self.items.get_mut(index)?;
176        if matches!(s.shape, Shape::Circle { .. }) || delta == 0 {
177            return None;
178        }
179        let previous = s.rot_deg;
180        s.rot_deg = crate::geometry::normalize_deg(s.rot_deg + delta);
181        self.undo.push(UndoOp::RestoreRotation {
182            index,
183            deg: previous,
184        });
185        self.redo.clear();
186        Some(self.items[index].rot_deg)
187    }
188
189    pub fn measures(&self) -> &[Measure] {
190        &self.measures
191    }
192
193    pub fn add_measure(&mut self, measure: Measure) {
194        self.measures.push(measure);
195        self.undo.push(UndoOp::RemoveLastMeasure);
196        self.redo.clear();
197    }
198
199    pub fn delete_measure(&mut self, index: usize) -> bool {
200        if index >= self.measures.len() {
201            return false;
202        }
203        let measure = self.measures.remove(index);
204        self.undo.push(UndoOp::ReinsertMeasure { index, measure });
205        self.redo.clear();
206        true
207    }
208
209    /// Topmost measure on `monitor` that a press at `p` grabs — an
210    /// endpoint first, then the line itself. Most recent wins, as with
211    /// selections.
212    pub fn grab_measure(
213        &self,
214        monitor: usize,
215        p: Point,
216        tolerance: i32,
217    ) -> Option<(usize, MeasureGrab)> {
218        self.measures
219            .iter()
220            .enumerate()
221            .rev()
222            .filter(|(_, m)| m.monitor == monitor)
223            .find_map(|(i, m)| {
224                if let Some(is_a) = m.line.endpoint_grab(p, tolerance) {
225                    return Some((i, MeasureGrab::Endpoint(is_a)));
226                }
227                m.line
228                    .hit_test(p, tolerance)
229                    .then_some((i, MeasureGrab::Move))
230            })
231    }
232
233    /// Update a measure mid-drag without recording history; pair with
234    /// `commit_measure` when the drag ends.
235    pub fn set_measure_line_live(&mut self, index: usize, line: Line) {
236        if let Some(m) = self.measures.get_mut(index) {
237            m.line = line;
238        }
239    }
240
241    /// Record a finished measure edit. `original` is the line as it stood
242    /// when the drag began; a drag that ended where it started records
243    /// nothing and does not dirty the session.
244    pub fn commit_measure(&mut self, index: usize, original: Line) -> bool {
245        let Some(m) = self.measures.get(index) else {
246            return false;
247        };
248        if m.line == original {
249            return false;
250        }
251        self.undo.push(UndoOp::RestoreMeasureLine {
252            index,
253            line: original,
254        });
255        self.redo.clear();
256        true
257    }
258
259    /// Set a measure's label. An unchanged label records nothing, the
260    /// same rule the selection labels follow.
261    pub fn label_measure(&mut self, index: usize, label: String) -> bool {
262        let Some(m) = self.measures.get_mut(index) else {
263            return false;
264        };
265        if m.label == label {
266            return false;
267        }
268        let previous = std::mem::replace(&mut m.label, label);
269        self.undo.push(UndoOp::RestoreMeasureLabel {
270            index,
271            label: previous,
272        });
273        self.redo.clear();
274        true
275    }
276
277    pub fn add(&mut self, selection: Selection) {
278        self.items.push(selection);
279        self.undo.push(UndoOp::RemoveLast);
280        self.redo.clear();
281    }
282
283    pub fn delete(&mut self, index: usize) -> bool {
284        if index >= self.items.len() {
285            return false;
286        }
287        let selection = self.items.remove(index);
288        self.undo.push(UndoOp::Reinsert { index, selection });
289        self.redo.clear();
290        true
291    }
292
293    /// Update a shape mid-drag without recording undo history; pair with
294    /// `commit_move` when the drag ends.
295    pub fn set_shape_live(&mut self, index: usize, shape: Shape) {
296        if let Some(s) = self.items.get_mut(index) {
297            s.shape = shape;
298        }
299    }
300
301    /// Record a completed move: `original` is the shape as it was when the
302    /// drag started. No-op if the shape ended up back where it began.
303    /// Returns whether the shape actually changed — a no-op move records
304    /// nothing and shouldn't dirty the session.
305    pub fn commit_move(&mut self, index: usize, original: Shape) -> bool {
306        let Some(s) = self.items.get(index) else {
307            return false;
308        };
309        if s.shape == original {
310            return false;
311        }
312        self.undo.push(UndoOp::RestoreShape {
313            index,
314            shape: original,
315        });
316        self.redo.clear();
317        true
318    }
319
320    /// Returns whether the label actually changed — committing the editor
321    /// without edits records nothing.
322    pub fn set_label(&mut self, index: usize, label: String) -> bool {
323        let Some(s) = self.items.get_mut(index) else {
324            return false;
325        };
326        if s.label == label {
327            return false;
328        }
329        let previous = std::mem::replace(&mut s.label, label);
330        self.undo.push(UndoOp::RestoreLabel {
331            index,
332            label: previous,
333        });
334        self.redo.clear();
335        true
336    }
337
338    /// Send the topmost shape under `p` to the bottom of the stack, so
339    /// the next grab reaches what was beneath it. Returns false when
340    /// fewer than two shapes are under the point. Stacking order is also
341    /// save order, so this is an undoable edit like any other.
342    pub fn cycle_at(&mut self, monitor: usize, p: Point) -> bool {
343        let hits: Vec<usize> = self
344            .items
345            .iter()
346            .enumerate()
347            .filter(|(_, s)| s.monitor == monitor && s.shape.hit_test_rotated(s.rot_deg, p))
348            .map(|(i, _)| i)
349            .collect();
350        let (Some(&bottom), Some(&top)) = (hits.first(), hits.last()) else {
351            return false;
352        };
353        if bottom == top {
354            return false;
355        }
356        let selection = self.items.remove(top);
357        self.items.insert(bottom, selection);
358        self.undo.push(UndoOp::Restack {
359            from: bottom,
360            to: top,
361        });
362        self.redo.clear();
363        true
364    }
365
366    /// Undo the most recent operation. Returns false when there is nothing
367    /// to undo.
368    pub fn undo(&mut self) -> bool {
369        let Some(op) = self.undo.pop() else {
370            return false;
371        };
372        if let Some(inverse) = self.apply(op) {
373            self.redo.push(inverse);
374        }
375        true
376    }
377
378    /// Re-apply the most recently undone operation. Returns false when
379    /// there is nothing to redo — any new edit empties the redo branch.
380    pub fn redo(&mut self) -> bool {
381        let Some(op) = self.redo.pop() else {
382            return false;
383        };
384        if let Some(inverse) = self.apply(op) {
385            self.undo.push(inverse);
386        }
387        true
388    }
389
390    /// Apply a state-restoring op and return its inverse — the op that
391    /// puts things back exactly as they were before this call.
392    fn apply(&mut self, op: UndoOp) -> Option<UndoOp> {
393        match op {
394            UndoOp::Restack { from, to } => {
395                if from >= self.items.len() {
396                    return None;
397                }
398                let selection = self.items.remove(from);
399                let to = to.min(self.items.len());
400                self.items.insert(to, selection);
401                Some(UndoOp::Restack { from: to, to: from })
402            }
403            UndoOp::RemoveLast => {
404                let selection = self.items.pop()?;
405                Some(UndoOp::Reinsert {
406                    index: self.items.len(),
407                    selection,
408                })
409            }
410            UndoOp::RemoveAt { index } => {
411                if index >= self.items.len() {
412                    return None;
413                }
414                let selection = self.items.remove(index);
415                Some(UndoOp::Reinsert { index, selection })
416            }
417            UndoOp::Reinsert { index, selection } => {
418                let index = index.min(self.items.len());
419                self.items.insert(index, selection);
420                Some(UndoOp::RemoveAt { index })
421            }
422            UndoOp::RestoreShape { index, shape } => {
423                let s = self.items.get_mut(index)?;
424                let previous = std::mem::replace(&mut s.shape, shape);
425                Some(UndoOp::RestoreShape {
426                    index,
427                    shape: previous,
428                })
429            }
430            UndoOp::RestoreLabel { index, label } => {
431                let s = self.items.get_mut(index)?;
432                let previous = std::mem::replace(&mut s.label, label);
433                Some(UndoOp::RestoreLabel {
434                    index,
435                    label: previous,
436                })
437            }
438            UndoOp::RestoreRotation { index, deg } => {
439                let s = self.items.get_mut(index)?;
440                let previous = std::mem::replace(&mut s.rot_deg, deg);
441                Some(UndoOp::RestoreRotation {
442                    index,
443                    deg: previous,
444                })
445            }
446            UndoOp::RemoveLastMeasure => {
447                let measure = self.measures.pop()?;
448                Some(UndoOp::ReinsertMeasure {
449                    index: self.measures.len(),
450                    measure,
451                })
452            }
453            UndoOp::ReinsertMeasure { index, measure } => {
454                let index = index.min(self.measures.len());
455                self.measures.insert(index, measure);
456                Some(UndoOp::RemoveMeasureAt { index })
457            }
458            UndoOp::RemoveMeasureAt { index } => {
459                if index >= self.measures.len() {
460                    return None;
461                }
462                let measure = self.measures.remove(index);
463                Some(UndoOp::ReinsertMeasure { index, measure })
464            }
465            UndoOp::RestoreMeasureLine { index, line } => {
466                let m = self.measures.get_mut(index)?;
467                let previous = m.line;
468                m.line = line;
469                Some(UndoOp::RestoreMeasureLine {
470                    index,
471                    line: previous,
472                })
473            }
474            UndoOp::RestoreMeasureLabel { index, label } => {
475                let m = self.measures.get_mut(index)?;
476                let previous = std::mem::replace(&mut m.label, label);
477                Some(UndoOp::RestoreMeasureLabel {
478                    index,
479                    label: previous,
480                })
481            }
482        }
483    }
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489
490    #[test]
491    fn measures_and_selections_share_one_undo_stack() {
492        // The reason they share it: Z should undo the last thing done,
493        // whatever kind it was. Two stacks would undo the last *shape*
494        // and leave a ruler drawn after it standing.
495        let mut set = SelectionSet::new();
496        set.add(Selection::new(Shape::Rect(Rect::new(0, 0, 10, 10)), 0));
497        set.add_measure(Measure::new(
498            Line::new(Point::new(0, 0), Point::new(50, 0)),
499            0,
500        ));
501        assert_eq!((set.len(), set.measures().len()), (1, 1));
502
503        assert!(set.undo(), "undoes the measure, the most recent edit");
504        assert_eq!((set.len(), set.measures().len()), (1, 0));
505        assert!(set.undo(), "then the selection");
506        assert_eq!((set.len(), set.measures().len()), (0, 0));
507
508        assert!(set.redo());
509        assert!(set.redo());
510        assert_eq!((set.len(), set.measures().len()), (1, 1));
511    }
512
513    #[test]
514    fn a_deleted_measure_comes_back_where_it_was() {
515        let mut set = SelectionSet::new();
516        for x in [10, 20, 30] {
517            set.add_measure(Measure::new(
518                Line::new(Point::new(x, 0), Point::new(x, 40)),
519                0,
520            ));
521        }
522        assert!(set.delete_measure(1));
523        assert_eq!(set.measures().len(), 2);
524        assert!(set.undo());
525        assert_eq!(set.measures()[1].line.a.x, 20, "restored in position");
526    }
527
528    #[test]
529    fn a_measure_drag_that_ends_where_it_began_records_nothing() {
530        let mut set = SelectionSet::new();
531        let line = Line::new(Point::new(0, 0), Point::new(60, 20));
532        set.add_measure(Measure::new(line, 0));
533        set.set_measure_line_live(0, Line::new(Point::new(0, 0), Point::new(99, 99)));
534        set.set_measure_line_live(0, line);
535        assert!(
536            !set.commit_measure(0, line),
537            "a no-op drag must not dirty the session"
538        );
539        // The only history is the add, so one undo empties it.
540        assert!(set.undo());
541        assert!(set.measures().is_empty());
542        assert!(!set.undo());
543    }
544
545    #[test]
546    fn relabeling_a_measure_is_undoable_and_a_no_op_label_is_not() {
547        let mut set = SelectionSet::new();
548        set.add_measure(Measure::new(
549            Line::new(Point::new(0, 0), Point::new(10, 0)),
550            0,
551        ));
552        assert!(set.label_measure(0, "gap".into()));
553        assert!(!set.label_measure(0, "gap".into()), "unchanged label");
554        assert!(set.undo());
555        assert_eq!(set.measures()[0].label, "");
556    }
557
558    #[test]
559    fn grabbing_prefers_an_endpoint_over_the_line_and_respects_the_monitor() {
560        let mut set = SelectionSet::new();
561        set.add_measure(Measure::new(
562            Line::new(Point::new(0, 0), Point::new(100, 0)),
563            0,
564        ));
565        assert_eq!(
566            set.grab_measure(0, Point::new(1, 1), 6),
567            Some((0, MeasureGrab::Endpoint(true)))
568        );
569        assert_eq!(
570            set.grab_measure(0, Point::new(50, 2), 6),
571            Some((0, MeasureGrab::Move))
572        );
573        assert_eq!(set.grab_measure(0, Point::new(50, 40), 6), None);
574        assert_eq!(
575            set.grab_measure(1, Point::new(1, 1), 6),
576            None,
577            "a measure belongs to one monitor's frame"
578        );
579    }
580    use crate::geometry::Rect;
581
582    fn rect_at(x: i32) -> Shape {
583        Shape::Rect(Rect::new(x, 0, 100, 100))
584    }
585
586    #[test]
587    fn hit_topmost_prefers_most_recent() {
588        let mut set = SelectionSet::new();
589        set.add(Selection::new(rect_at(0), 0));
590        set.add(Selection::new(rect_at(50), 0));
591        // (60, 10) is inside both; the later one wins.
592        assert_eq!(set.hit_topmost(0, Point::new(60, 10)), Some(1));
593        assert_eq!(set.hit_topmost(0, Point::new(10, 10)), Some(0));
594        assert_eq!(set.hit_topmost(0, Point::new(500, 500)), None);
595    }
596
597    #[test]
598    fn hit_topmost_filters_by_monitor() {
599        let mut set = SelectionSet::new();
600        set.add(Selection::new(rect_at(0), 1));
601        assert_eq!(set.hit_topmost(0, Point::new(10, 10)), None);
602        assert_eq!(set.hit_topmost(1, Point::new(10, 10)), Some(0));
603    }
604
605    #[test]
606    fn undo_add_removes_it() {
607        let mut set = SelectionSet::new();
608        set.add(Selection::new(rect_at(0), 0));
609        assert!(set.undo());
610        assert!(set.is_empty());
611    }
612
613    #[test]
614    fn undo_delete_reinserts_at_original_position() {
615        let mut set = SelectionSet::new();
616        set.add(Selection::new(rect_at(0), 0));
617        set.add(Selection::new(rect_at(200), 0));
618        set.add(Selection::new(rect_at(400), 0));
619        assert!(set.delete(1));
620        assert_eq!(set.len(), 2);
621        assert!(set.undo());
622        assert_eq!(set.items()[1].shape, rect_at(200));
623    }
624
625    #[test]
626    fn undo_move_restores_original_shape() {
627        let mut set = SelectionSet::new();
628        set.add(Selection::new(rect_at(0), 0));
629        let original = set.items()[0].shape.clone();
630        set.set_shape_live(0, rect_at(300));
631        set.commit_move(0, original.clone());
632        assert!(set.undo());
633        assert_eq!(set.items()[0].shape, original);
634    }
635
636    #[test]
637    fn commit_move_without_change_records_nothing() {
638        let mut set = SelectionSet::new();
639        set.add(Selection::new(rect_at(0), 0));
640        let original = set.items()[0].shape.clone();
641        set.commit_move(0, original.clone());
642        // Only the add is on the stack: one undo empties the set.
643        assert!(set.undo());
644        assert!(set.is_empty());
645        assert!(!set.undo());
646    }
647
648    #[test]
649    fn noop_label_and_move_record_nothing() {
650        let mut set = SelectionSet::new();
651        set.add(Selection::new(rect_at(0), 0));
652        // Committing the same label (including empty -> empty) is a no-op.
653        assert!(!set.set_label(0, String::new()));
654        assert!(set.set_label(0, "named".into()));
655        assert!(!set.set_label(0, "named".into()));
656        // An unchanged move is a no-op.
657        let original = set.items()[0].shape.clone();
658        assert!(!set.commit_move(0, original));
659        // Stack: label change, then the add — exactly two undos.
660        assert!(set.undo());
661        assert_eq!(set.items()[0].label, "");
662        assert!(set.undo());
663        assert!(!set.undo());
664    }
665
666    #[test]
667    fn undo_label_restores_previous_text() {
668        let mut set = SelectionSet::new();
669        set.add(Selection::new(rect_at(0), 0));
670        set.set_label(0, "first".into());
671        set.set_label(0, "second".into());
672        assert!(set.undo());
673        assert_eq!(set.items()[0].label, "first");
674        assert!(set.undo());
675        assert_eq!(set.items()[0].label, "");
676    }
677
678    #[test]
679    fn cycling_overlap_reaches_the_shape_beneath_and_undoes() {
680        let mut set = SelectionSet::new();
681        let mut below = Selection::new(Shape::Rect(Rect::new(0, 0, 100, 100)), 0);
682        below.label = "below".into();
683        let mut above = Selection::new(Shape::Rect(Rect::new(10, 10, 50, 50)), 0);
684        above.label = "above".into();
685        set.add(below);
686        set.add(above);
687        let p = Point::new(20, 20);
688        assert_eq!(set.items()[set.hit_topmost(0, p).unwrap()].label, "above");
689
690        assert!(set.cycle_at(0, p));
691        assert_eq!(
692            set.items()[set.hit_topmost(0, p).unwrap()].label,
693            "below",
694            "the shape beneath is now grabbable"
695        );
696        // Cycling again comes back around.
697        assert!(set.cycle_at(0, p));
698        assert_eq!(set.items()[set.hit_topmost(0, p).unwrap()].label, "above");
699        // And the whole dance unwinds.
700        assert!(set.undo());
701        assert!(set.undo());
702        assert_eq!(set.items()[set.hit_topmost(0, p).unwrap()].label, "above");
703        assert_eq!(set.items()[0].label, "below", "original order restored");
704    }
705
706    #[test]
707    fn get_returns_the_selection_or_nothing() {
708        let mut set = SelectionSet::new();
709        set.add(Selection::new(Shape::Rect(Rect::new(1, 1, 2, 2)), 0));
710        assert!(set.get(0).is_some());
711        assert!(set.get(1).is_none());
712    }
713
714    #[test]
715    fn cycling_needs_at_least_two_shapes_under_the_point() {
716        let mut set = SelectionSet::new();
717        set.add(Selection::new(Shape::Rect(Rect::new(0, 0, 10, 10)), 0));
718        assert!(
719            !set.cycle_at(0, Point::new(5, 5)),
720            "one shape: nothing to cycle"
721        );
722        assert!(
723            !set.cycle_at(0, Point::new(50, 50)),
724            "no shape: nothing to cycle"
725        );
726    }
727
728    #[test]
729    fn seeding_restores_items_without_undo_history() {
730        let mut sel = Selection::new(Shape::Rect(Rect::new(1, 2, 3, 4)), 0);
731        sel.label = "kept".into();
732        sel.rot_deg = 30;
733        let mut set = SelectionSet::seed(vec![sel], Vec::new());
734        assert_eq!(set.len(), 1);
735        assert_eq!(set.items()[0].label, "kept");
736        assert!(!set.undo(), "the resume point is the floor of history");
737        // Edits after seeding behave normally.
738        set.delete(0);
739        assert!(set.undo());
740        assert_eq!(set.len(), 1);
741    }
742
743    #[test]
744    fn redo_reapplies_undone_edits_across_every_op_kind() {
745        let mut set = SelectionSet::new();
746        set.add(Selection::new(Shape::Rect(Rect::new(0, 0, 10, 10)), 0));
747        set.set_label(0, "a".into());
748        set.rotate(0, 45);
749        set.set_shape_live(0, Shape::Rect(Rect::new(5, 5, 10, 10)));
750        set.commit_move(0, Shape::Rect(Rect::new(0, 0, 10, 10)));
751        set.delete(0);
752        while set.undo() {}
753        assert!(set.is_empty(), "everything unwinds");
754        while set.redo() {}
755        assert!(set.is_empty(), "the final delete replays too");
756        // One more undo brings the deleted selection back fully formed.
757        assert!(set.undo());
758        let s = &set.items()[0];
759        assert_eq!(s.label, "a");
760        assert_eq!(s.rot_deg, 45);
761        assert_eq!(s.shape, Shape::Rect(Rect::new(5, 5, 10, 10)));
762    }
763
764    #[test]
765    fn a_new_edit_empties_the_redo_branch() {
766        let mut set = SelectionSet::new();
767        set.add(Selection::new(Shape::Rect(Rect::new(0, 0, 10, 10)), 0));
768        assert!(set.undo());
769        set.add(Selection::new(Shape::Rect(Rect::new(9, 9, 5, 5)), 0));
770        assert!(!set.redo(), "a new edit forked history; redo is gone");
771    }
772
773    #[test]
774    fn redo_with_nothing_undone_is_false() {
775        let mut set = SelectionSet::new();
776        assert!(!set.redo());
777        set.add(Selection::new(Shape::Rect(Rect::new(0, 0, 4, 4)), 0));
778        assert!(!set.redo(), "un-undone edits leave nothing to redo");
779    }
780
781    #[test]
782    fn undo_is_lifo_across_mixed_ops() {
783        let mut set = SelectionSet::new();
784        set.add(Selection::new(rect_at(0), 0));
785        set.set_label(0, "a".into());
786        set.delete(0);
787        assert!(set.undo()); // reinsert with label "a"
788        assert_eq!(set.items()[0].label, "a");
789        assert!(set.undo()); // label back to ""
790        assert_eq!(set.items()[0].label, "");
791        assert!(set.undo()); // remove the add
792        assert!(set.is_empty());
793        assert!(!set.undo());
794    }
795
796    #[test]
797    fn delete_out_of_range_is_false() {
798        let mut set = SelectionSet::new();
799        assert!(!set.delete(0));
800    }
801
802    #[test]
803    fn rotate_records_undo_and_skips_circles() {
804        let mut set = SelectionSet::new();
805        set.add(Selection::new(rect_at(0), 0));
806        set.add(Selection::new(Shape::Circle { cx: 0, cy: 0, r: 9 }, 0));
807
808        assert_eq!(set.rotate(0, 15), Some(15));
809        assert_eq!(set.rotate(0, -30), Some(345));
810        assert_eq!(set.rotate(1, 15), None, "circles don't rotate");
811        assert_eq!(set.rotate(0, 0), None, "zero delta is a no-op");
812
813        assert!(set.undo());
814        assert_eq!(set.items()[0].rot_deg, 15);
815        assert!(set.undo());
816        assert_eq!(set.items()[0].rot_deg, 0);
817    }
818
819    #[test]
820    fn rotated_selection_hit_follows_rotation() {
821        let mut set = SelectionSet::new();
822        // Wide flat rect at (0,0) 100x20... rect_at gives 100x100; use a
823        // custom flat one so rotation visibly changes the hit region.
824        set.add(Selection::new(Shape::Rect(Rect::new(100, 100, 200, 20)), 0));
825        set.rotate(0, 90);
826        assert_eq!(set.hit_topmost(0, Point::new(200, 30)), Some(0));
827        assert_eq!(set.hit_topmost(0, Point::new(290, 110)), None);
828    }
829
830    #[test]
831    fn grab_prefers_border_resize_over_interior_move() {
832        let mut set = SelectionSet::new();
833        set.add(Selection::new(rect_at(0), 0)); // (0,0,100,100)
834        // On the left edge: resize.
835        let Some((0, GrabKind::Resize(_))) = set.grab_topmost(0, Point::new(1, 50), 5) else {
836            panic!("expected a resize grab on the edge");
837        };
838        // Deep inside: move.
839        assert_eq!(
840            set.grab_topmost(0, Point::new(50, 50), 5),
841            Some((0, GrabKind::Move))
842        );
843        // Far away: nothing.
844        assert_eq!(set.grab_topmost(0, Point::new(500, 500), 5), None);
845    }
846
847    #[test]
848    fn grab_topmost_shape_shadows_lower_border() {
849        let mut set = SelectionSet::new();
850        set.add(Selection::new(rect_at(0), 0)); // right edge at x=100
851        set.add(Selection::new(Shape::Rect(Rect::new(50, 0, 200, 100)), 0)); // covers it
852        // (100, 50) is the lower rect's edge but the upper rect's interior:
853        // the topmost shape wins with a move grab.
854        assert_eq!(
855            set.grab_topmost(0, Point::new(100, 50), 5),
856            Some((1, GrabKind::Move))
857        );
858    }
859}