lumen_primitives/
cursor.rs1use 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
22pub 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#[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 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 CursorShape::Grabbing
94 } else if let Ok(e) = hovered.single() {
95 if over_scrollbar_thumb || thumbs.contains(e) {
96 CursorShape::Grab
98 } else if inputs.contains(e) {
99 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 CursorShape::Pointer
109 } else {
110 CursorShape::Default
111 }
112 } else if over_scrollbar_thumb {
113 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 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 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}