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::{Point, ResizeHandle, Shape};
4
5/// What a mouse-press on the overlay grabs.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum GrabKind {
8    /// Inside a shape: drag moves it.
9    Move,
10    /// On a shape's border: drag resizes it.
11    Resize(ResizeHandle),
12}
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct Selection {
16    pub shape: Shape,
17    pub label: String,
18    /// Index into the session's monitor list.
19    pub monitor: usize,
20    /// Rotation in degrees (`0..360`) about the shape's bbox center.
21    pub rot_deg: i32,
22}
23
24impl Selection {
25    pub const fn new(shape: Shape, monitor: usize) -> Self {
26        Self {
27            shape,
28            label: String::new(),
29            monitor,
30            rot_deg: 0,
31        }
32    }
33}
34
35/// State-restoring operations. Applying one yields its own inverse, which
36/// is what lets a single engine drive both undo and redo.
37#[derive(Debug, Clone)]
38enum UndoOp {
39    RemoveLast,
40    Restack { from: usize, to: usize },
41    RemoveAt { index: usize },
42    Reinsert { index: usize, selection: Selection },
43    RestoreShape { index: usize, shape: Shape },
44    RestoreLabel { index: usize, label: String },
45    RestoreRotation { index: usize, deg: i32 },
46}
47
48#[derive(Debug, Default)]
49pub struct SelectionSet {
50    items: Vec<Selection>,
51    undo: Vec<UndoOp>,
52    redo: Vec<UndoOp>,
53}
54
55impl SelectionSet {
56    pub fn new() -> Self {
57        Self::default()
58    }
59
60    /// A set pre-populated with existing selections — restoring a saved
61    /// session. Seeding is not an edit: no undo history is created, so
62    /// undo in a resumed session stops at the resume point instead of
63    /// dismantling the session it reopened.
64    pub fn seed(items: Vec<Selection>) -> Self {
65        Self {
66            items,
67            undo: Vec::new(),
68            redo: Vec::new(),
69        }
70    }
71
72    pub fn items(&self) -> &[Selection] {
73        &self.items
74    }
75
76    pub fn len(&self) -> usize {
77        self.items.len()
78    }
79
80    pub fn is_empty(&self) -> bool {
81        self.items.is_empty()
82    }
83
84    pub fn get(&self, index: usize) -> Option<&Selection> {
85        self.items.get(index)
86    }
87
88    /// Topmost selection on `monitor` containing `p` (most recent wins),
89    /// respecting each selection's rotation.
90    pub fn hit_topmost(&self, monitor: usize, p: Point) -> Option<usize> {
91        self.items
92            .iter()
93            .enumerate()
94            .rev()
95            .find(|(_, s)| s.monitor == monitor && s.shape.hit_test_rotated(s.rot_deg, p))
96            .map(|(i, _)| i)
97    }
98
99    /// What a press at `p` grabs, topmost selection first: a border within
100    /// `tolerance` resizes, an interior moves. A shape's border outranks
101    /// its own interior; a topmost shape outranks anything beneath it.
102    pub fn grab_topmost(
103        &self,
104        monitor: usize,
105        p: Point,
106        tolerance: i32,
107    ) -> Option<(usize, GrabKind)> {
108        self.items
109            .iter()
110            .enumerate()
111            .rev()
112            .filter(|(_, s)| s.monitor == monitor)
113            .find_map(|(i, s)| {
114                if let Some(handle) = s.shape.resize_grab_rotated(s.rot_deg, p, tolerance) {
115                    return Some((i, GrabKind::Resize(handle)));
116                }
117                if s.shape.hit_test_rotated(s.rot_deg, p) {
118                    return Some((i, GrabKind::Move));
119                }
120                None
121            })
122    }
123
124    /// Rotate a selection by `delta` degrees about its bbox center; circles
125    /// are inherently rotation-free and are left untouched. Returns the new
126    /// rotation, or `None` if nothing changed.
127    pub fn rotate(&mut self, index: usize, delta: i32) -> Option<i32> {
128        let s = self.items.get_mut(index)?;
129        if matches!(s.shape, Shape::Circle { .. }) || delta == 0 {
130            return None;
131        }
132        let previous = s.rot_deg;
133        s.rot_deg = crate::geometry::normalize_deg(s.rot_deg + delta);
134        self.undo.push(UndoOp::RestoreRotation {
135            index,
136            deg: previous,
137        });
138        self.redo.clear();
139        Some(self.items[index].rot_deg)
140    }
141
142    pub fn add(&mut self, selection: Selection) {
143        self.items.push(selection);
144        self.undo.push(UndoOp::RemoveLast);
145        self.redo.clear();
146    }
147
148    pub fn delete(&mut self, index: usize) -> bool {
149        if index >= self.items.len() {
150            return false;
151        }
152        let selection = self.items.remove(index);
153        self.undo.push(UndoOp::Reinsert { index, selection });
154        self.redo.clear();
155        true
156    }
157
158    /// Update a shape mid-drag without recording undo history; pair with
159    /// `commit_move` when the drag ends.
160    pub fn set_shape_live(&mut self, index: usize, shape: Shape) {
161        if let Some(s) = self.items.get_mut(index) {
162            s.shape = shape;
163        }
164    }
165
166    /// Record a completed move: `original` is the shape as it was when the
167    /// drag started. No-op if the shape ended up back where it began.
168    /// Returns whether the shape actually changed — a no-op move records
169    /// nothing and shouldn't dirty the session.
170    pub fn commit_move(&mut self, index: usize, original: Shape) -> bool {
171        let Some(s) = self.items.get(index) else {
172            return false;
173        };
174        if s.shape == original {
175            return false;
176        }
177        self.undo.push(UndoOp::RestoreShape {
178            index,
179            shape: original,
180        });
181        self.redo.clear();
182        true
183    }
184
185    /// Returns whether the label actually changed — committing the editor
186    /// without edits records nothing.
187    pub fn set_label(&mut self, index: usize, label: String) -> bool {
188        let Some(s) = self.items.get_mut(index) else {
189            return false;
190        };
191        if s.label == label {
192            return false;
193        }
194        let previous = std::mem::replace(&mut s.label, label);
195        self.undo.push(UndoOp::RestoreLabel {
196            index,
197            label: previous,
198        });
199        self.redo.clear();
200        true
201    }
202
203    /// Send the topmost shape under `p` to the bottom of the stack, so
204    /// the next grab reaches what was beneath it. Returns false when
205    /// fewer than two shapes are under the point. Stacking order is also
206    /// save order, so this is an undoable edit like any other.
207    pub fn cycle_at(&mut self, monitor: usize, p: Point) -> bool {
208        let hits: Vec<usize> = self
209            .items
210            .iter()
211            .enumerate()
212            .filter(|(_, s)| s.monitor == monitor && s.shape.hit_test_rotated(s.rot_deg, p))
213            .map(|(i, _)| i)
214            .collect();
215        let (Some(&bottom), Some(&top)) = (hits.first(), hits.last()) else {
216            return false;
217        };
218        if bottom == top {
219            return false;
220        }
221        let selection = self.items.remove(top);
222        self.items.insert(bottom, selection);
223        self.undo.push(UndoOp::Restack {
224            from: bottom,
225            to: top,
226        });
227        self.redo.clear();
228        true
229    }
230
231    /// Undo the most recent operation. Returns false when there is nothing
232    /// to undo.
233    pub fn undo(&mut self) -> bool {
234        let Some(op) = self.undo.pop() else {
235            return false;
236        };
237        if let Some(inverse) = self.apply(op) {
238            self.redo.push(inverse);
239        }
240        true
241    }
242
243    /// Re-apply the most recently undone operation. Returns false when
244    /// there is nothing to redo — any new edit empties the redo branch.
245    pub fn redo(&mut self) -> bool {
246        let Some(op) = self.redo.pop() else {
247            return false;
248        };
249        if let Some(inverse) = self.apply(op) {
250            self.undo.push(inverse);
251        }
252        true
253    }
254
255    /// Apply a state-restoring op and return its inverse — the op that
256    /// puts things back exactly as they were before this call.
257    fn apply(&mut self, op: UndoOp) -> Option<UndoOp> {
258        match op {
259            UndoOp::Restack { from, to } => {
260                if from >= self.items.len() {
261                    return None;
262                }
263                let selection = self.items.remove(from);
264                let to = to.min(self.items.len());
265                self.items.insert(to, selection);
266                Some(UndoOp::Restack { from: to, to: from })
267            }
268            UndoOp::RemoveLast => {
269                let selection = self.items.pop()?;
270                Some(UndoOp::Reinsert {
271                    index: self.items.len(),
272                    selection,
273                })
274            }
275            UndoOp::RemoveAt { index } => {
276                if index >= self.items.len() {
277                    return None;
278                }
279                let selection = self.items.remove(index);
280                Some(UndoOp::Reinsert { index, selection })
281            }
282            UndoOp::Reinsert { index, selection } => {
283                let index = index.min(self.items.len());
284                self.items.insert(index, selection);
285                Some(UndoOp::RemoveAt { index })
286            }
287            UndoOp::RestoreShape { index, shape } => {
288                let s = self.items.get_mut(index)?;
289                let previous = std::mem::replace(&mut s.shape, shape);
290                Some(UndoOp::RestoreShape {
291                    index,
292                    shape: previous,
293                })
294            }
295            UndoOp::RestoreLabel { index, label } => {
296                let s = self.items.get_mut(index)?;
297                let previous = std::mem::replace(&mut s.label, label);
298                Some(UndoOp::RestoreLabel {
299                    index,
300                    label: previous,
301                })
302            }
303            UndoOp::RestoreRotation { index, deg } => {
304                let s = self.items.get_mut(index)?;
305                let previous = std::mem::replace(&mut s.rot_deg, deg);
306                Some(UndoOp::RestoreRotation {
307                    index,
308                    deg: previous,
309                })
310            }
311        }
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::geometry::Rect;
319
320    fn rect_at(x: i32) -> Shape {
321        Shape::Rect(Rect::new(x, 0, 100, 100))
322    }
323
324    #[test]
325    fn hit_topmost_prefers_most_recent() {
326        let mut set = SelectionSet::new();
327        set.add(Selection::new(rect_at(0), 0));
328        set.add(Selection::new(rect_at(50), 0));
329        // (60, 10) is inside both; the later one wins.
330        assert_eq!(set.hit_topmost(0, Point::new(60, 10)), Some(1));
331        assert_eq!(set.hit_topmost(0, Point::new(10, 10)), Some(0));
332        assert_eq!(set.hit_topmost(0, Point::new(500, 500)), None);
333    }
334
335    #[test]
336    fn hit_topmost_filters_by_monitor() {
337        let mut set = SelectionSet::new();
338        set.add(Selection::new(rect_at(0), 1));
339        assert_eq!(set.hit_topmost(0, Point::new(10, 10)), None);
340        assert_eq!(set.hit_topmost(1, Point::new(10, 10)), Some(0));
341    }
342
343    #[test]
344    fn undo_add_removes_it() {
345        let mut set = SelectionSet::new();
346        set.add(Selection::new(rect_at(0), 0));
347        assert!(set.undo());
348        assert!(set.is_empty());
349    }
350
351    #[test]
352    fn undo_delete_reinserts_at_original_position() {
353        let mut set = SelectionSet::new();
354        set.add(Selection::new(rect_at(0), 0));
355        set.add(Selection::new(rect_at(200), 0));
356        set.add(Selection::new(rect_at(400), 0));
357        assert!(set.delete(1));
358        assert_eq!(set.len(), 2);
359        assert!(set.undo());
360        assert_eq!(set.items()[1].shape, rect_at(200));
361    }
362
363    #[test]
364    fn undo_move_restores_original_shape() {
365        let mut set = SelectionSet::new();
366        set.add(Selection::new(rect_at(0), 0));
367        let original = set.items()[0].shape.clone();
368        set.set_shape_live(0, rect_at(300));
369        set.commit_move(0, original.clone());
370        assert!(set.undo());
371        assert_eq!(set.items()[0].shape, original);
372    }
373
374    #[test]
375    fn commit_move_without_change_records_nothing() {
376        let mut set = SelectionSet::new();
377        set.add(Selection::new(rect_at(0), 0));
378        let original = set.items()[0].shape.clone();
379        set.commit_move(0, original.clone());
380        // Only the add is on the stack: one undo empties the set.
381        assert!(set.undo());
382        assert!(set.is_empty());
383        assert!(!set.undo());
384    }
385
386    #[test]
387    fn noop_label_and_move_record_nothing() {
388        let mut set = SelectionSet::new();
389        set.add(Selection::new(rect_at(0), 0));
390        // Committing the same label (including empty -> empty) is a no-op.
391        assert!(!set.set_label(0, String::new()));
392        assert!(set.set_label(0, "named".into()));
393        assert!(!set.set_label(0, "named".into()));
394        // An unchanged move is a no-op.
395        let original = set.items()[0].shape.clone();
396        assert!(!set.commit_move(0, original));
397        // Stack: label change, then the add — exactly two undos.
398        assert!(set.undo());
399        assert_eq!(set.items()[0].label, "");
400        assert!(set.undo());
401        assert!(!set.undo());
402    }
403
404    #[test]
405    fn undo_label_restores_previous_text() {
406        let mut set = SelectionSet::new();
407        set.add(Selection::new(rect_at(0), 0));
408        set.set_label(0, "first".into());
409        set.set_label(0, "second".into());
410        assert!(set.undo());
411        assert_eq!(set.items()[0].label, "first");
412        assert!(set.undo());
413        assert_eq!(set.items()[0].label, "");
414    }
415
416    #[test]
417    fn cycling_overlap_reaches_the_shape_beneath_and_undoes() {
418        let mut set = SelectionSet::new();
419        let mut below = Selection::new(Shape::Rect(Rect::new(0, 0, 100, 100)), 0);
420        below.label = "below".into();
421        let mut above = Selection::new(Shape::Rect(Rect::new(10, 10, 50, 50)), 0);
422        above.label = "above".into();
423        set.add(below);
424        set.add(above);
425        let p = Point::new(20, 20);
426        assert_eq!(set.items()[set.hit_topmost(0, p).unwrap()].label, "above");
427
428        assert!(set.cycle_at(0, p));
429        assert_eq!(
430            set.items()[set.hit_topmost(0, p).unwrap()].label,
431            "below",
432            "the shape beneath is now grabbable"
433        );
434        // Cycling again comes back around.
435        assert!(set.cycle_at(0, p));
436        assert_eq!(set.items()[set.hit_topmost(0, p).unwrap()].label, "above");
437        // And the whole dance unwinds.
438        assert!(set.undo());
439        assert!(set.undo());
440        assert_eq!(set.items()[set.hit_topmost(0, p).unwrap()].label, "above");
441        assert_eq!(set.items()[0].label, "below", "original order restored");
442    }
443
444    #[test]
445    fn get_returns_the_selection_or_nothing() {
446        let mut set = SelectionSet::new();
447        set.add(Selection::new(Shape::Rect(Rect::new(1, 1, 2, 2)), 0));
448        assert!(set.get(0).is_some());
449        assert!(set.get(1).is_none());
450    }
451
452    #[test]
453    fn cycling_needs_at_least_two_shapes_under_the_point() {
454        let mut set = SelectionSet::new();
455        set.add(Selection::new(Shape::Rect(Rect::new(0, 0, 10, 10)), 0));
456        assert!(
457            !set.cycle_at(0, Point::new(5, 5)),
458            "one shape: nothing to cycle"
459        );
460        assert!(
461            !set.cycle_at(0, Point::new(50, 50)),
462            "no shape: nothing to cycle"
463        );
464    }
465
466    #[test]
467    fn seeding_restores_items_without_undo_history() {
468        let mut sel = Selection::new(Shape::Rect(Rect::new(1, 2, 3, 4)), 0);
469        sel.label = "kept".into();
470        sel.rot_deg = 30;
471        let mut set = SelectionSet::seed(vec![sel]);
472        assert_eq!(set.len(), 1);
473        assert_eq!(set.items()[0].label, "kept");
474        assert!(!set.undo(), "the resume point is the floor of history");
475        // Edits after seeding behave normally.
476        set.delete(0);
477        assert!(set.undo());
478        assert_eq!(set.len(), 1);
479    }
480
481    #[test]
482    fn redo_reapplies_undone_edits_across_every_op_kind() {
483        let mut set = SelectionSet::new();
484        set.add(Selection::new(Shape::Rect(Rect::new(0, 0, 10, 10)), 0));
485        set.set_label(0, "a".into());
486        set.rotate(0, 45);
487        set.set_shape_live(0, Shape::Rect(Rect::new(5, 5, 10, 10)));
488        set.commit_move(0, Shape::Rect(Rect::new(0, 0, 10, 10)));
489        set.delete(0);
490        while set.undo() {}
491        assert!(set.is_empty(), "everything unwinds");
492        while set.redo() {}
493        assert!(set.is_empty(), "the final delete replays too");
494        // One more undo brings the deleted selection back fully formed.
495        assert!(set.undo());
496        let s = &set.items()[0];
497        assert_eq!(s.label, "a");
498        assert_eq!(s.rot_deg, 45);
499        assert_eq!(s.shape, Shape::Rect(Rect::new(5, 5, 10, 10)));
500    }
501
502    #[test]
503    fn a_new_edit_empties_the_redo_branch() {
504        let mut set = SelectionSet::new();
505        set.add(Selection::new(Shape::Rect(Rect::new(0, 0, 10, 10)), 0));
506        assert!(set.undo());
507        set.add(Selection::new(Shape::Rect(Rect::new(9, 9, 5, 5)), 0));
508        assert!(!set.redo(), "a new edit forked history; redo is gone");
509    }
510
511    #[test]
512    fn redo_with_nothing_undone_is_false() {
513        let mut set = SelectionSet::new();
514        assert!(!set.redo());
515        set.add(Selection::new(Shape::Rect(Rect::new(0, 0, 4, 4)), 0));
516        assert!(!set.redo(), "un-undone edits leave nothing to redo");
517    }
518
519    #[test]
520    fn undo_is_lifo_across_mixed_ops() {
521        let mut set = SelectionSet::new();
522        set.add(Selection::new(rect_at(0), 0));
523        set.set_label(0, "a".into());
524        set.delete(0);
525        assert!(set.undo()); // reinsert with label "a"
526        assert_eq!(set.items()[0].label, "a");
527        assert!(set.undo()); // label back to ""
528        assert_eq!(set.items()[0].label, "");
529        assert!(set.undo()); // remove the add
530        assert!(set.is_empty());
531        assert!(!set.undo());
532    }
533
534    #[test]
535    fn delete_out_of_range_is_false() {
536        let mut set = SelectionSet::new();
537        assert!(!set.delete(0));
538    }
539
540    #[test]
541    fn rotate_records_undo_and_skips_circles() {
542        let mut set = SelectionSet::new();
543        set.add(Selection::new(rect_at(0), 0));
544        set.add(Selection::new(Shape::Circle { cx: 0, cy: 0, r: 9 }, 0));
545
546        assert_eq!(set.rotate(0, 15), Some(15));
547        assert_eq!(set.rotate(0, -30), Some(345));
548        assert_eq!(set.rotate(1, 15), None, "circles don't rotate");
549        assert_eq!(set.rotate(0, 0), None, "zero delta is a no-op");
550
551        assert!(set.undo());
552        assert_eq!(set.items()[0].rot_deg, 15);
553        assert!(set.undo());
554        assert_eq!(set.items()[0].rot_deg, 0);
555    }
556
557    #[test]
558    fn rotated_selection_hit_follows_rotation() {
559        let mut set = SelectionSet::new();
560        // Wide flat rect at (0,0) 100x20... rect_at gives 100x100; use a
561        // custom flat one so rotation visibly changes the hit region.
562        set.add(Selection::new(Shape::Rect(Rect::new(100, 100, 200, 20)), 0));
563        set.rotate(0, 90);
564        assert_eq!(set.hit_topmost(0, Point::new(200, 30)), Some(0));
565        assert_eq!(set.hit_topmost(0, Point::new(290, 110)), None);
566    }
567
568    #[test]
569    fn grab_prefers_border_resize_over_interior_move() {
570        let mut set = SelectionSet::new();
571        set.add(Selection::new(rect_at(0), 0)); // (0,0,100,100)
572        // On the left edge: resize.
573        let Some((0, GrabKind::Resize(_))) = set.grab_topmost(0, Point::new(1, 50), 5) else {
574            panic!("expected a resize grab on the edge");
575        };
576        // Deep inside: move.
577        assert_eq!(
578            set.grab_topmost(0, Point::new(50, 50), 5),
579            Some((0, GrabKind::Move))
580        );
581        // Far away: nothing.
582        assert_eq!(set.grab_topmost(0, Point::new(500, 500), 5), None);
583    }
584
585    #[test]
586    fn grab_topmost_shape_shadows_lower_border() {
587        let mut set = SelectionSet::new();
588        set.add(Selection::new(rect_at(0), 0)); // right edge at x=100
589        set.add(Selection::new(Shape::Rect(Rect::new(50, 0, 200, 100)), 0)); // covers it
590        // (100, 50) is the lower rect's edge but the upper rect's interior:
591        // the topmost shape wins with a move grab.
592        assert_eq!(
593            set.grab_topmost(0, Point::new(100, 50), 5),
594            Some((1, GrabKind::Move))
595        );
596    }
597}