Skip to main content

teksilo_scene/
selection.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Selection model for `Scene` items.
5//!
6//! Mirrors the API of `teksilo_data::SelectionModel` but keyed by
7//! [`ItemId`] instead of `usize` — the natural address for scene
8//! entries. Click-to-select, Ctrl+click toggle, Shift+click range,
9//! and marquee box-select all flow through this single model;
10//! `SceneView` paints a marquee overlay during the drag and
11//! commits the result via [`Scene::items_in_region`](crate::Scene::items_in_region),
12//! so a rubber band picks items by the same geometry a click does.
13//!
14//! The selection set is exposed as a `Signal<BTreeSet<ItemId>>`
15//! so `SceneItem` paint code can render selected items differently
16//! by binding their colors / strokes to a derived signal:
17//!
18//! ```
19//! # use teksilo_scene::{SceneModel, SceneSelection, SceneSelectionMode};
20//! # use teksilo_canvas::{Point, Rect};
21//! # use teksilo_tokens::Color;
22//! # let model = SceneModel::new();
23//! # let card_id = model.add_item(teksilo_scene::RectItem::new(Rect::new(0.0, 0.0, 100.0, 80.0)), Point::ZERO);
24//! let selection = SceneSelection::new(SceneSelectionMode::Multi);
25//! let selected = selection.selection_signal();
26//! let stroke_color = selected.map(move |s| {
27//!     if s.contains(&card_id) { Color::BLUE } else { Color::TRANSPARENT }
28//! });
29//! ```
30
31use std::cell::Cell;
32use std::collections::BTreeSet;
33use std::rc::Rc;
34
35use teksilo_canvas::Rect;
36use teksilo_core::signal::Signal;
37
38use crate::item::ItemId;
39use crate::scene::Scene;
40use crate::shape::{ItemSelectionMode, SceneRegion};
41
42/// Selection-mode discriminator. Mirrors `teksilo_data::SelectionMode`.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum SceneSelectionMode {
45    /// Selection disabled. Click does nothing, marquee does nothing.
46    None,
47    /// At most one item selected at a time.
48    Single,
49    /// Multiple items can be selected; Ctrl+click toggles, Shift+click
50    /// extends a range from the anchor.
51    Multi,
52}
53
54/// Reactive selection state for a `Scene`.
55///
56/// Cheap-to-clone via `Rc` internals — all clones share the same
57/// underlying signal. Pass clones into widget closures or item
58/// `register_bindings` impls without worrying about ownership.
59#[derive(Clone)]
60pub struct SceneSelection {
61    mode: SceneSelectionMode,
62    selection: Signal<BTreeSet<ItemId>>,
63    /// Anchor for Shift+click range extension. Shared via `Rc<Cell>`
64    /// so clones see the same anchor.
65    anchor: Rc<Cell<Option<ItemId>>>,
66}
67
68impl SceneSelection {
69    /// New selection model with the given mode. Initially empty,
70    /// no anchor.
71    pub fn new(mode: SceneSelectionMode) -> Self {
72        Self {
73            mode,
74            selection: Signal::new(BTreeSet::new()),
75            anchor: Rc::new(Cell::new(None)),
76        }
77    }
78
79    /// The configured selection mode.
80    pub fn mode(&self) -> SceneSelectionMode {
81        self.mode
82    }
83
84    /// Live selection signal. Bind reactive consumers (item paint,
85    /// status-bar item-count labels) to this.
86    pub fn selection_signal(&self) -> Signal<BTreeSet<ItemId>> {
87        self.selection.clone()
88    }
89
90    /// Whether the given item id is currently selected.
91    pub fn is_selected(&self, id: ItemId) -> bool {
92        self.selection.get().contains(&id)
93    }
94
95    /// Selected item ids in sorted order.
96    pub fn selected(&self) -> Vec<ItemId> {
97        self.selection.get().into_iter().collect()
98    }
99
100    /// Number of selected items.
101    pub fn count(&self) -> usize {
102        self.selection.get().len()
103    }
104
105    /// Clear the selection. The anchor is also cleared so a
106    /// subsequent Shift+click extends from a fresh starting point.
107    pub fn clear(&self) {
108        self.selection.set(BTreeSet::new());
109        self.anchor.set(None);
110    }
111
112    /// Replace the selection with a single item; sets the anchor
113    /// for subsequent range extension. No-op in `None` mode.
114    pub fn select_one(&self, id: ItemId) {
115        if matches!(self.mode, SceneSelectionMode::None) {
116            return;
117        }
118        let mut set = BTreeSet::new();
119        set.insert(id);
120        self.selection.set(set);
121        self.anchor.set(Some(id));
122    }
123
124    /// Toggle membership for the given id (Ctrl+click semantic).
125    /// Sets the anchor on toggle-on; leaves it unchanged on
126    /// toggle-off. No-op in `None` mode; in `Single` mode behaves
127    /// like `select_one` if the item is currently unselected, or
128    /// `clear` if it is.
129    pub fn toggle(&self, id: ItemId) {
130        match self.mode {
131            SceneSelectionMode::None => (),
132            SceneSelectionMode::Single => {
133                if self.is_selected(id) {
134                    self.clear();
135                } else {
136                    self.select_one(id);
137                }
138            }
139            SceneSelectionMode::Multi => {
140                let mut set = self.selection.get();
141                if set.remove(&id) {
142                    // Toggle-off: anchor unchanged.
143                } else {
144                    set.insert(id);
145                    self.anchor.set(Some(id));
146                }
147                self.selection.set(set);
148            }
149        }
150    }
151
152    /// Replace the selection with the given set of ids. Used by
153    /// marquee on commit. Anchor is cleared. No-op in `None`
154    /// mode; in `Single` mode keeps at most one (the first id in
155    /// `ids`).
156    pub fn replace(&self, ids: impl IntoIterator<Item = ItemId>) {
157        match self.mode {
158            SceneSelectionMode::None => {}
159            SceneSelectionMode::Single => {
160                let mut iter = ids.into_iter();
161                let mut set = BTreeSet::new();
162                if let Some(first) = iter.next() {
163                    set.insert(first);
164                }
165                self.selection.set(set);
166                self.anchor.set(None);
167            }
168            SceneSelectionMode::Multi => {
169                let set: BTreeSet<ItemId> = ids.into_iter().collect();
170                self.selection.set(set);
171                self.anchor.set(None);
172            }
173        }
174    }
175
176    /// Add `ids` to the existing selection (marquee with
177    /// Ctrl-modifier — additive box-select). No-op in `None` mode;
178    /// in `Single` mode reduces to `select_one(last)`.
179    pub fn extend(&self, ids: impl IntoIterator<Item = ItemId>) {
180        match self.mode {
181            SceneSelectionMode::None => {}
182            SceneSelectionMode::Single => {
183                if let Some(last) = ids.into_iter().last() {
184                    self.select_one(last);
185                }
186            }
187            SceneSelectionMode::Multi => {
188                let mut set = self.selection.get();
189                set.extend(ids);
190                self.selection.set(set);
191            }
192        }
193    }
194
195    /// Marquee commit helper: replace (or extend, if `additive`)
196    /// the selection with every selectable scene item the rectangle picks up.
197    ///
198    /// Sugar for [`commit_marquee_region`](Self::commit_marquee_region) with a
199    /// rectangular region, [`ItemSelectionMode::IntersectsItemShape`] and unit
200    /// view scale.
201    ///
202    /// # Behaviour change
203    ///
204    /// This used to be a pure AABB query. It now consults each item's
205    /// [`shape`](crate::SceneItem::shape), so a band that merely grazes a
206    /// connector's bounding box no longer selects the connector — it has to
207    /// cross the stroke. For an item with an identity transform and the
208    /// default box shape the result is unchanged; for a **rotated** or scaled
209    /// item it is tighter, because the shape test happens in the item's own
210    /// frame rather than against its enlarged scene-space hull. Pass
211    /// [`ItemSelectionMode::IntersectsItemBoundingRect`] to
212    /// `commit_marquee_region` to get the old rule back.
213    pub fn commit_marquee(&self, scene: &Scene, marquee_rect: Rect, additive: bool) {
214        self.commit_marquee_region(
215            scene,
216            &SceneRegion::rect(marquee_rect),
217            ItemSelectionMode::default(),
218            1.0,
219            additive,
220        );
221    }
222
223    /// Marquee commit over an arbitrary [`SceneRegion`] and
224    /// [`ItemSelectionMode`] — the rotated-view rubber band (an exact
225    /// quadrilateral) and the freehand lasso both arrive here.
226    ///
227    /// Lightweight items and heavyweight widget entries are both candidates —
228    /// the spatial index returns ids regardless of kind, and a widget entry's
229    /// shape is its box (see
230    /// [`Scene::item_shape`](crate::Scene::item_shape)). `view_scale` is the
231    /// live zoom, consulted only by a cosmetic stroke band.
232    pub fn commit_marquee_region(
233        &self,
234        scene: &Scene,
235        region: &SceneRegion,
236        mode: ItemSelectionMode,
237        view_scale: f32,
238        additive: bool,
239    ) {
240        // Filter to items carrying `IS_SELECTABLE`. The spatial
241        // index returns every entry whose AABB intersects — both
242        // selectable and non-selectable (locked layers, decoration-
243        // only items, logical groups). The marquee commit must
244        // respect the flag so single-click + marquee agree about
245        // what can be selected. (Unit 9: was previously unfiltered;
246        // see edge_cases::marquee_commit_respects_is_selectable_flag.)
247        let hits: Vec<crate::item::ItemId> = scene
248            .items_in_region(region, mode, view_scale)
249            .into_iter()
250            .filter(|id| {
251                scene
252                    .flags(*id)
253                    .map(|f| f.contains(crate::flags::ItemFlags::IS_SELECTABLE))
254                    .unwrap_or(false)
255            })
256            .collect();
257        if additive {
258            self.extend(hits);
259        } else {
260            self.replace(hits);
261        }
262    }
263}
264
265impl std::fmt::Debug for SceneSelection {
266    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
267        f.debug_struct("SceneSelection")
268            .field("mode", &self.mode)
269            .field("count", &self.count())
270            .finish_non_exhaustive()
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    fn fresh_id() -> ItemId {
279        ItemId::next()
280    }
281
282    #[test]
283    fn none_mode_ignores_all_mutations() {
284        let sel = SceneSelection::new(SceneSelectionMode::None);
285        sel.select_one(fresh_id());
286        sel.toggle(fresh_id());
287        sel.replace([fresh_id(), fresh_id()]);
288        assert_eq!(sel.count(), 0);
289    }
290
291    #[test]
292    fn single_mode_keeps_at_most_one() {
293        let sel = SceneSelection::new(SceneSelectionMode::Single);
294        let a = fresh_id();
295        let b = fresh_id();
296        sel.select_one(a);
297        sel.select_one(b);
298        assert_eq!(sel.selected(), vec![b]);
299    }
300
301    #[test]
302    fn multi_toggle_round_trip() {
303        let sel = SceneSelection::new(SceneSelectionMode::Multi);
304        let a = fresh_id();
305        let b = fresh_id();
306        sel.toggle(a);
307        sel.toggle(b);
308        assert_eq!(sel.count(), 2);
309        sel.toggle(a);
310        assert_eq!(sel.selected(), vec![b]);
311    }
312
313    #[test]
314    fn replace_clears_then_inserts() {
315        let sel = SceneSelection::new(SceneSelectionMode::Multi);
316        let a = fresh_id();
317        let b = fresh_id();
318        let c = fresh_id();
319        sel.select_one(a);
320        sel.replace([b, c]);
321        assert_eq!(sel.count(), 2);
322        assert!(sel.is_selected(b));
323        assert!(sel.is_selected(c));
324        assert!(!sel.is_selected(a));
325    }
326
327    #[test]
328    fn extend_is_additive() {
329        let sel = SceneSelection::new(SceneSelectionMode::Multi);
330        let a = fresh_id();
331        let b = fresh_id();
332        sel.select_one(a);
333        sel.extend([b]);
334        assert_eq!(sel.count(), 2);
335    }
336
337    #[test]
338    fn signal_updates_observable() {
339        let sel = SceneSelection::new(SceneSelectionMode::Multi);
340        let signal = sel.selection_signal();
341        let id = fresh_id();
342        assert!(signal.get().is_empty());
343        sel.select_one(id);
344        assert!(signal.get().contains(&id));
345    }
346}