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}