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}