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_rect`.
12//!
13//! The selection set is exposed as a `Signal<BTreeSet<ItemId>>`
14//! so `SceneItem` paint code can render selected items differently
15//! by binding their colors / strokes to a derived signal:
16//!
17//! ```
18//! # use teksilo_scene::{SceneModel, SceneSelection, SceneSelectionMode};
19//! # use teksilo_canvas::{Point, Rect};
20//! # use teksilo_tokens::Color;
21//! # let model = SceneModel::new();
22//! # let card_id = model.add_item(teksilo_scene::RectItem::new(Rect::new(0.0, 0.0, 100.0, 80.0)), Point::ZERO);
23//! let selection = SceneSelection::new(SceneSelectionMode::Multi);
24//! let selected = selection.selection_signal();
25//! let stroke_color = selected.map(move |s| {
26//!     if s.contains(&card_id) { Color::BLUE } else { Color::TRANSPARENT }
27//! });
28//! ```
29
30use std::cell::Cell;
31use std::collections::BTreeSet;
32use std::rc::Rc;
33
34use teksilo_canvas::Rect;
35use teksilo_core::signal::Signal;
36
37use crate::item::ItemId;
38use crate::scene::Scene;
39
40/// Selection-mode discriminator. Mirrors `teksilo_data::SelectionMode`.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum SceneSelectionMode {
43    /// Selection disabled. Click does nothing, marquee does nothing.
44    None,
45    /// At most one item selected at a time.
46    Single,
47    /// Multiple items can be selected; Ctrl+click toggles, Shift+click
48    /// extends a range from the anchor.
49    Multi,
50}
51
52/// Reactive selection state for a `Scene`.
53///
54/// Cheap-to-clone via `Rc` internals — all clones share the same
55/// underlying signal. Pass clones into widget closures or item
56/// `register_bindings` impls without worrying about ownership.
57#[derive(Clone)]
58pub struct SceneSelection {
59    mode: SceneSelectionMode,
60    selection: Signal<BTreeSet<ItemId>>,
61    /// Anchor for Shift+click range extension. Shared via `Rc<Cell>`
62    /// so clones see the same anchor.
63    anchor: Rc<Cell<Option<ItemId>>>,
64}
65
66impl SceneSelection {
67    /// New selection model with the given mode. Initially empty,
68    /// no anchor.
69    pub fn new(mode: SceneSelectionMode) -> Self {
70        Self {
71            mode,
72            selection: Signal::new(BTreeSet::new()),
73            anchor: Rc::new(Cell::new(None)),
74        }
75    }
76
77    /// The configured selection mode.
78    pub fn mode(&self) -> SceneSelectionMode {
79        self.mode
80    }
81
82    /// Live selection signal. Bind reactive consumers (item paint,
83    /// status-bar item-count labels) to this.
84    pub fn selection_signal(&self) -> Signal<BTreeSet<ItemId>> {
85        self.selection.clone()
86    }
87
88    /// Whether the given item id is currently selected.
89    pub fn is_selected(&self, id: ItemId) -> bool {
90        self.selection.get().contains(&id)
91    }
92
93    /// Selected item ids in sorted order.
94    pub fn selected(&self) -> Vec<ItemId> {
95        self.selection.get().into_iter().collect()
96    }
97
98    /// Number of selected items.
99    pub fn count(&self) -> usize {
100        self.selection.get().len()
101    }
102
103    /// Clear the selection. The anchor is also cleared so a
104    /// subsequent Shift+click extends from a fresh starting point.
105    pub fn clear(&self) {
106        self.selection.set(BTreeSet::new());
107        self.anchor.set(None);
108    }
109
110    /// Replace the selection with a single item; sets the anchor
111    /// for subsequent range extension. No-op in `None` mode.
112    pub fn select_one(&self, id: ItemId) {
113        if matches!(self.mode, SceneSelectionMode::None) {
114            return;
115        }
116        let mut set = BTreeSet::new();
117        set.insert(id);
118        self.selection.set(set);
119        self.anchor.set(Some(id));
120    }
121
122    /// Toggle membership for the given id (Ctrl+click semantic).
123    /// Sets the anchor on toggle-on; leaves it unchanged on
124    /// toggle-off. No-op in `None` mode; in `Single` mode behaves
125    /// like `select_one` if the item is currently unselected, or
126    /// `clear` if it is.
127    pub fn toggle(&self, id: ItemId) {
128        match self.mode {
129            SceneSelectionMode::None => (),
130            SceneSelectionMode::Single => {
131                if self.is_selected(id) {
132                    self.clear();
133                } else {
134                    self.select_one(id);
135                }
136            }
137            SceneSelectionMode::Multi => {
138                let mut set = self.selection.get();
139                if set.remove(&id) {
140                    // Toggle-off: anchor unchanged.
141                } else {
142                    set.insert(id);
143                    self.anchor.set(Some(id));
144                }
145                self.selection.set(set);
146            }
147        }
148    }
149
150    /// Replace the selection with the given set of ids. Used by
151    /// marquee on commit. Anchor is cleared. No-op in `None`
152    /// mode; in `Single` mode keeps at most one (the first id in
153    /// `ids`).
154    pub fn replace(&self, ids: impl IntoIterator<Item = ItemId>) {
155        match self.mode {
156            SceneSelectionMode::None => {}
157            SceneSelectionMode::Single => {
158                let mut iter = ids.into_iter();
159                let mut set = BTreeSet::new();
160                if let Some(first) = iter.next() {
161                    set.insert(first);
162                }
163                self.selection.set(set);
164                self.anchor.set(None);
165            }
166            SceneSelectionMode::Multi => {
167                let set: BTreeSet<ItemId> = ids.into_iter().collect();
168                self.selection.set(set);
169                self.anchor.set(None);
170            }
171        }
172    }
173
174    /// Add `ids` to the existing selection (marquee with
175    /// Ctrl-modifier — additive box-select). No-op in `None` mode;
176    /// in `Single` mode reduces to `select_one(last)`.
177    pub fn extend(&self, ids: impl IntoIterator<Item = ItemId>) {
178        match self.mode {
179            SceneSelectionMode::None => {}
180            SceneSelectionMode::Single => {
181                if let Some(last) = ids.into_iter().last() {
182                    self.select_one(last);
183                }
184            }
185            SceneSelectionMode::Multi => {
186                let mut set = self.selection.get();
187                set.extend(ids);
188                self.selection.set(set);
189            }
190        }
191    }
192
193    /// Marquee commit helper: replace (or extend, if `additive`)
194    /// the selection with every scene item whose AABB intersects
195    /// `marquee_rect_in_scene`. Lightweight items and heavyweight
196    /// widget entries are both candidates — the spatial index
197    /// returns ids regardless of kind.
198    pub fn commit_marquee(&self, scene: &Scene, marquee_rect: Rect, additive: bool) {
199        // Filter to items carrying `IS_SELECTABLE`. The spatial
200        // index returns every entry whose AABB intersects — both
201        // selectable and non-selectable (locked layers, decoration-
202        // only items, logical groups). The marquee commit must
203        // respect the flag so single-click + marquee agree about
204        // what can be selected. (Unit 9: was previously unfiltered;
205        // see edge_cases::marquee_commit_respects_is_selectable_flag.)
206        let hits: Vec<crate::item::ItemId> = scene
207            .items_in_rect(marquee_rect)
208            .into_iter()
209            .filter(|id| {
210                scene
211                    .flags(*id)
212                    .map(|f| f.contains(crate::flags::ItemFlags::IS_SELECTABLE))
213                    .unwrap_or(false)
214            })
215            .collect();
216        if additive {
217            self.extend(hits);
218        } else {
219            self.replace(hits);
220        }
221    }
222}
223
224impl std::fmt::Debug for SceneSelection {
225    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226        f.debug_struct("SceneSelection")
227            .field("mode", &self.mode)
228            .field("count", &self.count())
229            .finish_non_exhaustive()
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    fn fresh_id() -> ItemId {
238        ItemId::next()
239    }
240
241    #[test]
242    fn none_mode_ignores_all_mutations() {
243        let sel = SceneSelection::new(SceneSelectionMode::None);
244        sel.select_one(fresh_id());
245        sel.toggle(fresh_id());
246        sel.replace([fresh_id(), fresh_id()]);
247        assert_eq!(sel.count(), 0);
248    }
249
250    #[test]
251    fn single_mode_keeps_at_most_one() {
252        let sel = SceneSelection::new(SceneSelectionMode::Single);
253        let a = fresh_id();
254        let b = fresh_id();
255        sel.select_one(a);
256        sel.select_one(b);
257        assert_eq!(sel.selected(), vec![b]);
258    }
259
260    #[test]
261    fn multi_toggle_round_trip() {
262        let sel = SceneSelection::new(SceneSelectionMode::Multi);
263        let a = fresh_id();
264        let b = fresh_id();
265        sel.toggle(a);
266        sel.toggle(b);
267        assert_eq!(sel.count(), 2);
268        sel.toggle(a);
269        assert_eq!(sel.selected(), vec![b]);
270    }
271
272    #[test]
273    fn replace_clears_then_inserts() {
274        let sel = SceneSelection::new(SceneSelectionMode::Multi);
275        let a = fresh_id();
276        let b = fresh_id();
277        let c = fresh_id();
278        sel.select_one(a);
279        sel.replace([b, c]);
280        assert_eq!(sel.count(), 2);
281        assert!(sel.is_selected(b));
282        assert!(sel.is_selected(c));
283        assert!(!sel.is_selected(a));
284    }
285
286    #[test]
287    fn extend_is_additive() {
288        let sel = SceneSelection::new(SceneSelectionMode::Multi);
289        let a = fresh_id();
290        let b = fresh_id();
291        sel.select_one(a);
292        sel.extend([b]);
293        assert_eq!(sel.count(), 2);
294    }
295
296    #[test]
297    fn signal_updates_observable() {
298        let sel = SceneSelection::new(SceneSelectionMode::Multi);
299        let signal = sel.selection_signal();
300        let id = fresh_id();
301        assert!(signal.get().is_empty());
302        sel.select_one(id);
303        assert!(signal.get().contains(&id));
304    }
305}