Skip to main content

galeon_engine/
selection.rs

1// SPDX-License-Identifier: AGPL-3.0-only OR Commercial
2
3//! World-global mouse selection state.
4//!
5//! Input adapters (the WASM bridge for browsers, hand-written input layers for
6//! native tools) apply pick and pick-rect events to the [`Selection`] resource.
7//! Game systems read it through the [`Res<Selection>`](crate::Res) system param
8//! to react to user input — issue movement orders, highlight units, etc.
9//!
10//! Modifier-key semantics follow the StarCraft / OpenRA consensus catalogued in
11//! the `#214` discovery notes: `shift` = additive (toggle on click), `ctrl` =
12//! subtractive, `alt` = intersect (rect-only). The TypeScript helper in
13//! `@galeon/picking` reports modifiers per event; this module decides what
14//! they mean for selection state.
15
16use std::collections::HashSet;
17
18use crate::entity::Entity;
19
20/// Modifier-key bitmask for selection-modifying input events.
21///
22/// Encodes shift/ctrl/alt/meta in the low four bits so the WASM bridge can
23/// pass a single `u32` across the JS boundary without serialising a struct.
24#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
25pub struct PickModifiers(pub u32);
26
27impl PickModifiers {
28    pub const NONE: Self = Self(0);
29    pub const SHIFT: u32 = 1 << 0;
30    pub const CTRL: u32 = 1 << 1;
31    pub const ALT: u32 = 1 << 2;
32    pub const META: u32 = 1 << 3;
33
34    /// Construct from boolean flags.
35    pub fn from_bools(shift: bool, ctrl: bool, alt: bool, meta: bool) -> Self {
36        let mut bits = 0;
37        if shift {
38            bits |= Self::SHIFT;
39        }
40        if ctrl {
41            bits |= Self::CTRL;
42        }
43        if alt {
44            bits |= Self::ALT;
45        }
46        if meta {
47            bits |= Self::META;
48        }
49        Self(bits)
50    }
51
52    pub fn shift(self) -> bool {
53        (self.0 & Self::SHIFT) != 0
54    }
55    pub fn ctrl(self) -> bool {
56        (self.0 & Self::CTRL) != 0
57    }
58    pub fn alt(self) -> bool {
59        (self.0 & Self::ALT) != 0
60    }
61    pub fn meta(self) -> bool {
62        (self.0 & Self::META) != 0
63    }
64}
65
66/// World-space hit point of the most recent click pick.
67#[derive(Clone, Copy, Debug, PartialEq)]
68pub struct PickPoint {
69    pub x: f32,
70    pub y: f32,
71    pub z: f32,
72}
73
74/// World-global selection state — current entity set plus last hit point.
75///
76/// Insert via [`World::insert_resource`](crate::World::insert_resource) before
77/// any system that reads it. The TS helper emits both single-click and marquee
78/// events; both flow into [`apply_pick`](Self::apply_pick) /
79/// [`apply_pick_rect`](Self::apply_pick_rect).
80#[derive(Default, Debug)]
81pub struct Selection {
82    /// Currently selected entities.
83    pub entities: HashSet<Entity>,
84    /// World-space point of the most recent successful click pick, if any.
85    pub last_pick: Option<PickPoint>,
86}
87
88impl Selection {
89    pub fn new() -> Self {
90        Self::default()
91    }
92
93    /// Apply a single-click pick.
94    ///
95    /// - **No modifier**: replace selection with `entity` (or clear if `None`).
96    /// - **Shift**: toggle (add if absent, remove if present). Clicks on empty
97    ///   space are no-ops to avoid accidental clears mid-shift.
98    /// - **Ctrl**: subtract. Clicks on empty space are no-ops.
99    /// - **Other modifier combinations**: same as no-modifier on a hit;
100    ///   no-op on a miss.
101    ///
102    /// `last_pick` is updated whenever `point` is provided, regardless of
103    /// modifier — game code may want the cursor position even on a clear.
104    pub fn apply_pick(
105        &mut self,
106        entity: Option<Entity>,
107        point: Option<PickPoint>,
108        modifiers: PickModifiers,
109    ) {
110        if let Some(p) = point {
111            self.last_pick = Some(p);
112        }
113        // Dispatch on the full modifier bitmask, not on individual flags, so
114        // multi-modifier clicks (e.g. Shift+Ctrl) fall through to the documented
115        // "other combinations" branch instead of being absorbed by the first
116        // matching single-modifier rule.
117        match (entity, modifiers.0) {
118            (None, 0) => {
119                self.entities.clear();
120            }
121            (None, _) => {
122                // Any modifier on a miss is a no-op.
123            }
124            (Some(entity), PickModifiers::SHIFT) => {
125                if !self.entities.insert(entity) {
126                    self.entities.remove(&entity);
127                }
128            }
129            (Some(entity), PickModifiers::CTRL) => {
130                self.entities.remove(&entity);
131            }
132            (Some(entity), _) => {
133                self.entities.clear();
134                self.entities.insert(entity);
135            }
136        }
137    }
138
139    /// Apply a marquee pick.
140    ///
141    /// - **No modifier**: replace selection with `entities`.
142    /// - **Shift only**: add `entities` to selection.
143    /// - **Ctrl only**: remove `entities` from selection.
144    /// - **Alt only**: keep only entities present in both the current selection
145    ///   and `entities` (set intersection).
146    /// - **Other modifier combinations** (Shift+Ctrl, Shift+Alt, Meta, …):
147    ///   same as no-modifier (replace).
148    ///
149    /// Dispatch is on the full modifier bitmask — mirroring
150    /// [`apply_pick`](Self::apply_pick) — so that a multi-modifier marquee
151    /// (e.g. a user holding Shift+Ctrl during a drag) falls through to the
152    /// documented replace branch instead of being absorbed by the first
153    /// matching single-modifier rule.
154    pub fn apply_pick_rect<I>(&mut self, entities: I, modifiers: PickModifiers)
155    where
156        I: IntoIterator<Item = Entity>,
157    {
158        match modifiers.0 {
159            PickModifiers::SHIFT => {
160                self.entities.extend(entities);
161            }
162            PickModifiers::CTRL => {
163                for e in entities {
164                    self.entities.remove(&e);
165                }
166            }
167            PickModifiers::ALT => {
168                let new: HashSet<Entity> = entities.into_iter().collect();
169                self.entities.retain(|e| new.contains(e));
170            }
171            _ => {
172                self.entities.clear();
173                self.entities.extend(entities);
174            }
175        }
176    }
177
178    /// Whether `entity` is currently selected.
179    pub fn contains(&self, entity: Entity) -> bool {
180        self.entities.contains(&entity)
181    }
182
183    /// Number of selected entities.
184    pub fn len(&self) -> usize {
185        self.entities.len()
186    }
187
188    /// Whether the selection is empty.
189    pub fn is_empty(&self) -> bool {
190        self.entities.is_empty()
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    fn e(index: u32, generation: u32) -> Entity {
199        Entity::from_raw(index, generation)
200    }
201
202    #[test]
203    fn modifiers_round_trip_through_bools() {
204        let m = PickModifiers::from_bools(true, false, true, false);
205        assert!(m.shift());
206        assert!(!m.ctrl());
207        assert!(m.alt());
208        assert!(!m.meta());
209    }
210
211    #[test]
212    fn click_replaces_selection_with_no_modifier() {
213        let mut sel = Selection::new();
214        sel.entities.insert(e(1, 0));
215        sel.entities.insert(e(2, 0));
216        sel.apply_pick(Some(e(3, 0)), None, PickModifiers::NONE);
217        assert_eq!(sel.entities.len(), 1);
218        assert!(sel.contains(e(3, 0)));
219    }
220
221    #[test]
222    fn click_on_empty_space_clears_with_no_modifier() {
223        let mut sel = Selection::new();
224        sel.entities.insert(e(1, 0));
225        sel.apply_pick(None, None, PickModifiers::NONE);
226        assert!(sel.is_empty());
227    }
228
229    #[test]
230    fn click_on_empty_space_with_modifier_is_noop() {
231        let mut sel = Selection::new();
232        sel.entities.insert(e(1, 0));
233        sel.apply_pick(None, None, PickModifiers(PickModifiers::SHIFT));
234        assert_eq!(sel.entities.len(), 1);
235    }
236
237    #[test]
238    fn shift_click_toggles_membership() {
239        let mut sel = Selection::new();
240        sel.apply_pick(Some(e(1, 0)), None, PickModifiers(PickModifiers::SHIFT));
241        sel.apply_pick(Some(e(2, 0)), None, PickModifiers(PickModifiers::SHIFT));
242        assert_eq!(sel.entities.len(), 2);
243
244        sel.apply_pick(Some(e(1, 0)), None, PickModifiers(PickModifiers::SHIFT));
245        assert!(!sel.contains(e(1, 0)));
246        assert!(sel.contains(e(2, 0)));
247    }
248
249    #[test]
250    fn ctrl_click_subtracts() {
251        let mut sel = Selection::new();
252        sel.entities.insert(e(1, 0));
253        sel.entities.insert(e(2, 0));
254        sel.apply_pick(Some(e(1, 0)), None, PickModifiers(PickModifiers::CTRL));
255        assert!(!sel.contains(e(1, 0)));
256        assert!(sel.contains(e(2, 0)));
257    }
258
259    #[test]
260    fn shift_plus_other_modifier_falls_through_to_replace() {
261        let mut sel = Selection::new();
262        sel.entities.insert(e(1, 0));
263        sel.entities.insert(e(2, 0));
264        // Shift+Ctrl is a non-plain combination: docs say it should behave
265        // like an unmodified hit (replace), not like Shift alone (toggle).
266        let bits = PickModifiers::SHIFT | PickModifiers::CTRL;
267        sel.apply_pick(Some(e(3, 0)), None, PickModifiers(bits));
268        assert_eq!(sel.entities.len(), 1);
269        assert!(sel.contains(e(3, 0)));
270    }
271
272    #[test]
273    fn shift_plus_other_modifier_on_miss_is_noop() {
274        let mut sel = Selection::new();
275        sel.entities.insert(e(1, 0));
276        let bits = PickModifiers::SHIFT | PickModifiers::ALT;
277        sel.apply_pick(None, None, PickModifiers(bits));
278        assert_eq!(sel.entities.len(), 1);
279        assert!(sel.contains(e(1, 0)));
280    }
281
282    #[test]
283    fn pick_records_last_world_point() {
284        let mut sel = Selection::new();
285        let p = PickPoint {
286            x: 1.0,
287            y: 2.0,
288            z: 3.0,
289        };
290        sel.apply_pick(Some(e(1, 0)), Some(p), PickModifiers::NONE);
291        assert_eq!(sel.last_pick, Some(p));
292    }
293
294    #[test]
295    fn rect_no_modifier_replaces() {
296        let mut sel = Selection::new();
297        sel.entities.insert(e(1, 0));
298        sel.apply_pick_rect([e(2, 0), e(3, 0)], PickModifiers::NONE);
299        assert!(!sel.contains(e(1, 0)));
300        assert!(sel.contains(e(2, 0)));
301        assert!(sel.contains(e(3, 0)));
302    }
303
304    #[test]
305    fn rect_shift_adds() {
306        let mut sel = Selection::new();
307        sel.entities.insert(e(1, 0));
308        sel.apply_pick_rect([e(2, 0), e(3, 0)], PickModifiers(PickModifiers::SHIFT));
309        assert_eq!(sel.entities.len(), 3);
310    }
311
312    #[test]
313    fn rect_ctrl_subtracts() {
314        let mut sel = Selection::new();
315        sel.entities.insert(e(1, 0));
316        sel.entities.insert(e(2, 0));
317        sel.entities.insert(e(3, 0));
318        sel.apply_pick_rect([e(2, 0), e(3, 0)], PickModifiers(PickModifiers::CTRL));
319        assert_eq!(sel.entities.len(), 1);
320        assert!(sel.contains(e(1, 0)));
321    }
322
323    #[test]
324    fn rect_alt_intersects() {
325        let mut sel = Selection::new();
326        sel.entities.insert(e(1, 0));
327        sel.entities.insert(e(2, 0));
328        sel.entities.insert(e(3, 0));
329        sel.apply_pick_rect(
330            [e(2, 0), e(3, 0), e(4, 0)],
331            PickModifiers(PickModifiers::ALT),
332        );
333        assert_eq!(sel.entities.len(), 2);
334        assert!(sel.contains(e(2, 0)));
335        assert!(sel.contains(e(3, 0)));
336        assert!(!sel.contains(e(1, 0)));
337    }
338
339    #[test]
340    fn rect_shift_plus_other_modifier_falls_through_to_replace() {
341        let mut sel = Selection::new();
342        sel.entities.insert(e(1, 0));
343        sel.entities.insert(e(2, 0));
344        // Shift+Ctrl is a non-plain combination: docs say it should behave
345        // like no-modifier (replace), not like Shift alone (add). Mirrors
346        // the click handler's multi-modifier discipline.
347        let bits = PickModifiers::SHIFT | PickModifiers::CTRL;
348        sel.apply_pick_rect([e(3, 0), e(4, 0)], PickModifiers(bits));
349        assert_eq!(sel.entities.len(), 2);
350        assert!(sel.contains(e(3, 0)));
351        assert!(sel.contains(e(4, 0)));
352        assert!(!sel.contains(e(1, 0)));
353        assert!(!sel.contains(e(2, 0)));
354    }
355
356    #[test]
357    fn rect_alt_plus_other_modifier_falls_through_to_replace() {
358        let mut sel = Selection::new();
359        sel.entities.insert(e(1, 0));
360        // Ctrl+Alt should NOT intersect (Alt-alone behaviour). Falls through
361        // to replace so the rect's contents become the new selection.
362        let bits = PickModifiers::CTRL | PickModifiers::ALT;
363        sel.apply_pick_rect([e(2, 0)], PickModifiers(bits));
364        assert_eq!(sel.entities.len(), 1);
365        assert!(sel.contains(e(2, 0)));
366    }
367}