Skip to main content

lumen_primitives/
cursor.rs

1//! Mouse-cursor shape selection (spec: I-beam over text inputs, pointer
2//! over clickables, grab/grabbing on scrollbar + slider thumbs).
3//!
4//! [`update_cursor_request`] derives the wanted
5//! [`CursorShape`](lumen_core::input::CursorShape) from the current
6//! [`Hovered`] entity (plus the scrollbar / drag capture FSMs, which can
7//! own the pointer while nothing is hovered) and writes it into the
8//! [`CursorRequest`](lumen_core::input::CursorRequest) resource - only
9//! on change, so the window backend's per-frame poll is a cheap
10//! equality check. `lumen-window-winit` maps the shape onto winit's
11//! `CursorIcon` and applies it to the OS window; headless runners never
12//! read the resource.
13
14use bevy_ecs::prelude::*;
15use lumen_core::input::{CursorRequest, CursorShape, ScrollbarInteraction, ScrollbarPart};
16use lumen_core::prelude::*;
17
18use crate::controls::SliderThumb;
19use crate::drag::DragState;
20use crate::hover::Interaction;
21
22/// Plugin: installs [`CursorRequest`] and registers
23/// [`update_cursor_request`] after `hit_test` so it reads this tick's
24/// hover.
25pub struct CursorPlugin;
26
27impl Plugin for CursorPlugin {
28    fn build(self, app: &mut App) {
29        app.world.init_resource::<CursorRequest>();
30        app.add_systems(
31            TickStage::Systems,
32            update_cursor_request.after(lumen_input::hit_test),
33        );
34    }
35}
36
37/// Pick the cursor shape for this tick and write it into
38/// [`CursorRequest`] (change-gated):
39///
40/// 1. **Grabbing** while a handle drag is in flight - a scrollbar thumb
41///    drag ([`ScrollbarInteraction::drag`]) or an active [`DragState`]
42///    on a slider or its thumb child.
43/// 2. **Grab** with the pointer resting on a scrollbar thumb
44///    ([`ScrollbarInteraction::hover`], thumb part) or a slider thumb.
45/// 3. **Text** (I-beam) over [`TextInput`] entities.
46/// 4. **Pointer** over clickable widgets: the hovered entity or an
47///    ancestor carries [`Interaction`] (hover/press feedback - the
48///    button class), [`Toggleable`], [`SliderValue`], or a
49///    non-negative [`TabIndex`].
50/// 5. **Default** otherwise. Disabled widgets are never [`Hovered`]
51///    (`hit_test` skips them), so they fall here automatically.
52#[allow(clippy::type_complexity, clippy::too_many_arguments)]
53pub fn update_cursor_request(
54    mut req: ResMut<CursorRequest>,
55    hovered: Query<Entity, With<Hovered>>,
56    scrollbar_ix: Option<Res<ScrollbarInteraction>>,
57    drags: Query<(Entity, &DragState)>,
58    sliders: Query<(), With<SliderValue>>,
59    thumbs: Query<(), With<SliderThumb>>,
60    inputs: Query<(), With<TextInput>>,
61    interactions: Query<(), With<Interaction>>,
62    toggles: Query<(), With<Toggleable>>,
63    tab_indices: Query<&TabIndex>,
64    parents: Query<&ChildOf>,
65) {
66    // Self-or-ancestor membership walk (matches the control-resolution
67    // walk in `crate::controls::resolve_control`).
68    let self_or_ancestor = |start: Entity, hit: &dyn Fn(Entity) -> bool| -> bool {
69        let mut cur = Some(start);
70        while let Some(e) = cur {
71            if hit(e) {
72                return true;
73            }
74            cur = parents.get(e).ok().map(|c| c.parent());
75        }
76        false
77    };
78    let is_slider_ish = |e: Entity| sliders.contains(e) || thumbs.contains(e);
79
80    let ix = scrollbar_ix.as_deref();
81    let scrollbar_dragging = ix.is_some_and(|ix| ix.drag.is_some());
82    let slider_dragging = drags.iter().any(|(e, state)| {
83        matches!(state, DragState::Active { .. }) && self_or_ancestor(e, &is_slider_ish)
84    });
85    let over_scrollbar_thumb = ix.is_some_and(|ix| {
86        ix.hover
87            .is_some_and(|(_, _, part)| matches!(part, ScrollbarPart::Thumb))
88    });
89
90    let shape = if scrollbar_dragging || slider_dragging {
91        // 1. An in-flight handle drag owns the pointer regardless of
92        //    what is hovered (capture may have moved it off the handle).
93        CursorShape::Grabbing
94    } else if let Ok(e) = hovered.single() {
95        if over_scrollbar_thumb || thumbs.contains(e) {
96            // 2. Resting on a grabbable handle.
97            CursorShape::Grab
98        } else if inputs.contains(e) {
99            // 3. Editable text.
100            CursorShape::Text
101        } else if self_or_ancestor(e, &|x| {
102            interactions.contains(x)
103                || toggles.contains(x)
104                || sliders.contains(x)
105                || tab_indices.get(x).is_ok_and(|t| t.0 >= 0)
106        }) {
107            // 4. Clickable.
108            CursorShape::Pointer
109        } else {
110            CursorShape::Default
111        }
112    } else if over_scrollbar_thumb {
113        // Overlay-scrollbar hover resolves the scroll container through
114        // the bar-target path; the thumb itself may hold no `Hovered`.
115        CursorShape::Grab
116    } else {
117        CursorShape::Default
118    };
119
120    if req.0 != shape {
121        req.0 = shape;
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    //! Cursor selection: I-beam over inputs, pointer over clickables
128    //! (self or ancestor), grab over slider thumbs, grabbing during a
129    //! slider drag, default over inert tiles and disabled widgets
130    //! (which are simply never hovered).
131    use super::*;
132    use bevy_ecs::schedule::Schedule;
133    use glam::Vec2;
134
135    fn run(world: &mut World) -> CursorShape {
136        world.init_resource::<CursorRequest>();
137        let mut s = Schedule::default();
138        s.add_systems(update_cursor_request);
139        s.run(world);
140        world.resource::<CursorRequest>().0
141    }
142
143    #[test]
144    fn text_input_wants_ibeam() {
145        let mut world = World::new();
146        world.spawn((Hovered, TextInput::default()));
147        assert_eq!(run(&mut world), CursorShape::Text);
148    }
149
150    #[test]
151    fn clickable_wants_pointer_inert_wants_default() {
152        let mut world = World::new();
153        let e = world.spawn((Hovered, Interaction::default())).id();
154        assert_eq!(run(&mut world), CursorShape::Pointer);
155        world.entity_mut(e).remove::<(Hovered, Interaction)>();
156        world.spawn(Hovered);
157        assert_eq!(run(&mut world), CursorShape::Default);
158    }
159
160    #[test]
161    fn clickable_ancestor_makes_child_pointer() {
162        let mut world = World::new();
163        let button = world.spawn(Interaction::default()).id();
164        world.spawn((Hovered, ChildOf(button)));
165        assert_eq!(run(&mut world), CursorShape::Pointer);
166    }
167
168    #[test]
169    fn slider_thumb_wants_grab_and_drag_wants_grabbing() {
170        let mut world = World::new();
171        let slider = world
172            .spawn(SliderValue {
173                value: 0.0,
174                min: 0.0,
175                max: 100.0,
176                step: None,
177            })
178            .id();
179        let thumb = world.spawn((SliderThumb, ChildOf(slider), Hovered)).id();
180        assert_eq!(run(&mut world), CursorShape::Grab, "resting on the thumb");
181
182        world.entity_mut(thumb).insert(DragState::Active {
183            start: Vec2::ZERO,
184            last: Vec2::new(10.0, 0.0),
185        });
186        assert_eq!(run(&mut world), CursorShape::Grabbing, "thumb drag");
187
188        // Dragged off the thumb (hover confined by pointer capture ->
189        // nothing hovered): still grabbing.
190        world.entity_mut(thumb).remove::<Hovered>();
191        assert_eq!(run(&mut world), CursorShape::Grabbing, "capture keeps it");
192    }
193
194    #[test]
195    fn generic_draggable_tile_does_not_grab() {
196        let mut world = World::new();
197        world.spawn((
198            Hovered,
199            DragState::Active {
200                start: Vec2::ZERO,
201                last: Vec2::ZERO,
202            },
203        ));
204        assert_eq!(
205            run(&mut world),
206            CursorShape::Default,
207            "only slider/scrollbar handles claim the grab cursors"
208        );
209    }
210
211    #[test]
212    fn tab_index_negative_is_not_clickable() {
213        let mut world = World::new();
214        world.spawn((Hovered, TabIndex(-1)));
215        assert_eq!(run(&mut world), CursorShape::Default);
216    }
217
218    #[test]
219    fn nothing_hovered_is_default() {
220        let mut world = World::new();
221        assert_eq!(run(&mut world), CursorShape::Default);
222    }
223}