bevy_alt_ui_navigation_lite/
systems.rs

1//! System for the navigation tree and default input systems to get started.
2use crate::{
3    events::{Direction, NavRequest, ScopeDirection},
4    resolve::{FocusState, Focusable, Focused, ScreenBoundaries},
5};
6
7use bevy::ui::UiStack;
8use bevy::window::PrimaryWindow;
9#[cfg(feature = "bevy_reflect")]
10use bevy::{ecs::reflect::ReflectResource, reflect::Reflect};
11use bevy::{ecs::system::SystemParam, prelude::*};
12
13/// Control default ui navigation input buttons
14#[derive(Resource)]
15#[cfg_attr(feature = "bevy_reflect", derive(Reflect), reflect(Resource))]
16pub struct InputMapping {
17    /// Whether to use keybaord keys for navigation (instead of just actions).
18    pub keyboard_navigation: bool,
19    /// The gamepads to use for the UI. If empty, default to gamepad 0
20    pub gamepads: Vec<Entity>,
21    /// Deadzone on the gamepad left stick for ui navigation
22    pub joystick_ui_deadzone: f32,
23    /// X axis of gamepad stick
24    pub move_x: GamepadAxis,
25    /// Y axis of gamepad stick
26    pub move_y: GamepadAxis,
27    /// Gamepad button for [`Direction::West`] [`NavRequest::Move`]
28    pub left_button: GamepadButton,
29    /// Gamepad button for [`Direction::East`] [`NavRequest::Move`]
30    pub right_button: GamepadButton,
31    /// Gamepad button for [`Direction::North`] [`NavRequest::Move`]
32    pub up_button: GamepadButton,
33    /// Gamepad button for [`Direction::South`] [`NavRequest::Move`]
34    pub down_button: GamepadButton,
35    /// Gamepad button for [`NavRequest::Action`]
36    pub action_button: GamepadButton,
37    /// Gamepad button for [`NavRequest::Cancel`]
38    pub cancel_button: GamepadButton,
39    /// Gamepad button for [`ScopeDirection::Previous`] [`NavRequest::ScopeMove`]
40    pub previous_button: GamepadButton,
41    /// Gamepad button for [`ScopeDirection::Next`] [`NavRequest::ScopeMove`]
42    pub next_button: GamepadButton,
43    /// Gamepad button for [`NavRequest::Unlock`]
44    pub free_button: GamepadButton,
45    /// Keyboard key for [`Direction::West`] [`NavRequest::Move`]
46    pub key_left: KeyCode,
47    /// Keyboard key for [`Direction::East`] [`NavRequest::Move`]
48    pub key_right: KeyCode,
49    /// Keyboard key for [`Direction::North`] [`NavRequest::Move`]
50    pub key_up: KeyCode,
51    /// Keyboard key for [`Direction::South`] [`NavRequest::Move`]
52    pub key_down: KeyCode,
53    /// Alternative keyboard key for [`Direction::West`] [`NavRequest::Move`]
54    pub key_left_alt: KeyCode,
55    /// Alternative keyboard key for [`Direction::East`] [`NavRequest::Move`]
56    pub key_right_alt: KeyCode,
57    /// Alternative keyboard key for [`Direction::North`] [`NavRequest::Move`]
58    pub key_up_alt: KeyCode,
59    /// Alternative keyboard key for [`Direction::South`] [`NavRequest::Move`]
60    pub key_down_alt: KeyCode,
61    /// Keyboard key for [`NavRequest::Action`]
62    pub key_action: KeyCode,
63    /// Keyboard key for [`NavRequest::Cancel`]
64    pub key_cancel: KeyCode,
65    /// Keyboard key for [`ScopeDirection::Next`] [`NavRequest::ScopeMove`]
66    pub key_next: KeyCode,
67    /// Alternative keyboard key for [`ScopeDirection::Next`] [`NavRequest::ScopeMove`]
68    pub key_next_alt: KeyCode,
69    /// Keyboard key for [`ScopeDirection::Previous`] [`NavRequest::ScopeMove`]
70    pub key_previous: KeyCode,
71    /// Keyboard key for [`NavRequest::Unlock`]
72    pub key_free: KeyCode,
73    /// Mouse button for [`NavRequest::Action`]
74    pub mouse_action: MouseButton,
75    /// Whether mouse hover gives focus to [`Focusable`] elements.
76    pub focus_follows_mouse: bool,
77}
78impl Default for InputMapping {
79    fn default() -> Self {
80        InputMapping {
81            keyboard_navigation: false,
82            gamepads: vec![],
83            joystick_ui_deadzone: 0.36,
84            move_x: GamepadAxis::LeftStickX,
85            move_y: GamepadAxis::LeftStickY,
86            left_button: GamepadButton::DPadLeft,
87            right_button: GamepadButton::DPadRight,
88            up_button: GamepadButton::DPadUp,
89            down_button: GamepadButton::DPadDown,
90            action_button: GamepadButton::South,
91            cancel_button: GamepadButton::East,
92            previous_button: GamepadButton::LeftTrigger,
93            next_button: GamepadButton::RightTrigger,
94            free_button: GamepadButton::Start,
95            key_left: KeyCode::KeyA,
96            key_right: KeyCode::KeyD,
97            key_up: KeyCode::KeyW,
98            key_down: KeyCode::KeyS,
99            key_left_alt: KeyCode::ArrowLeft,
100            key_right_alt: KeyCode::ArrowRight,
101            key_up_alt: KeyCode::ArrowUp,
102            key_down_alt: KeyCode::ArrowDown,
103            key_action: KeyCode::Space,
104            key_cancel: KeyCode::Backspace,
105            key_next: KeyCode::KeyE,
106            key_next_alt: KeyCode::Tab,
107            key_previous: KeyCode::KeyQ,
108            key_free: KeyCode::Escape,
109            mouse_action: MouseButton::Left,
110            focus_follows_mouse: false,
111        }
112    }
113}
114
115/// `mapping { XYZ::X => ABC::A, XYZ::Y => ABC::B, XYZ::Z => ABC::C }: [(XYZ, ABC)]`
116macro_rules! mapping {
117    ($($from:expr => $to:expr),* ) => ([$( ( $from, $to ) ),*])
118}
119
120/// A system to send gamepad control events to the focus system
121///
122/// Dpad and left stick for movement, `LT` and `RT` for scopped menus, `A` `B`
123/// for selection and cancel.
124///
125/// The button mapping may be controlled through the [`InputMapping`] resource.
126/// You may however need to customize the behavior of this system (typically
127/// when integrating in the game) in this case, you should write your own
128/// system that sends [`NavRequest`] events
129pub fn default_gamepad_input(
130    mut nav_cmds: MessageWriter<NavRequest>,
131    has_focused: Query<(), With<Focused>>,
132    input_mapping: Res<InputMapping>,
133    gamepads: Query<(Entity, &Gamepad)>,
134    mut ui_input_status: Local<bool>,
135) {
136    use Direction::*;
137    use NavRequest::{Action, Cancel, Move, ScopeMove, Unlock};
138
139    if has_focused.is_empty() {
140        // Do not compute navigation if there is no focus to change
141        return;
142    }
143
144    for (entity, gamepad) in &gamepads {
145        if !input_mapping.gamepads.is_empty() && !input_mapping.gamepads.contains(&entity) {
146            continue;
147        }
148
149        macro_rules! axis_delta {
150            ($dir:ident, $axis:ident) => {{
151                gamepad
152                    .get(input_mapping.$axis)
153                    .map_or(Vec2::ZERO, |v| Vec2::$dir * v)
154            }};
155        }
156
157        let delta = axis_delta!(Y, move_y) + axis_delta!(X, move_x);
158        if delta.length_squared() > input_mapping.joystick_ui_deadzone && !*ui_input_status {
159            let direction = match () {
160                () if delta.y < delta.x && delta.y < -delta.x => South,
161                () if delta.y < delta.x => East,
162                () if delta.y >= delta.x && delta.y > -delta.x => North,
163                () => West,
164            };
165            nav_cmds.write(Move(direction));
166            *ui_input_status = true;
167        } else if delta.length_squared() <= input_mapping.joystick_ui_deadzone {
168            *ui_input_status = false;
169        }
170
171        let command_mapping = mapping! {
172            input_mapping.action_button => Action,
173            input_mapping.cancel_button => Cancel,
174            input_mapping.left_button => Move(Direction::West),
175            input_mapping.right_button => Move(Direction::East),
176            input_mapping.up_button => Move(Direction::North),
177            input_mapping.down_button => Move(Direction::South),
178            input_mapping.next_button => ScopeMove(ScopeDirection::Next),
179            input_mapping.free_button => Unlock,
180            input_mapping.previous_button => ScopeMove(ScopeDirection::Previous)
181        };
182        for (button_type, request) in command_mapping {
183            if gamepad.just_pressed(button_type) {
184                nav_cmds.write(request);
185            }
186        }
187    }
188}
189
190/// A system to send keyboard control events to the focus system.
191///
192/// supports `WASD` and arrow keys for the directions, `E`, `Q` and `Tab` for
193/// scopped menus, `Backspace` and `Enter` for cancel and selection.
194///
195/// The button mapping may be controlled through the [`InputMapping`] resource.
196/// You may however need to customize the behavior of this system (typically
197/// when integrating in the game) in this case, you should write your own
198/// system that sends [`NavRequest`] events.
199pub fn default_keyboard_input(
200    has_focused: Query<(), With<Focused>>,
201    keyboard: Res<ButtonInput<KeyCode>>,
202    input_mapping: Res<InputMapping>,
203    mut nav_cmds: MessageWriter<NavRequest>,
204) {
205    use Direction::*;
206    use NavRequest::*;
207
208    if has_focused.is_empty() {
209        // Do not compute navigation if there is no focus to change
210        return;
211    }
212
213    let with_movement = mapping! {
214        input_mapping.key_up => Move(North),
215        input_mapping.key_down => Move(South),
216        input_mapping.key_left => Move(West),
217        input_mapping.key_right => Move(East),
218        input_mapping.key_up_alt => Move(North),
219        input_mapping.key_down_alt => Move(South),
220        input_mapping.key_left_alt => Move(West),
221        input_mapping.key_right_alt => Move(East)
222    };
223    let without_movement = mapping! {
224        input_mapping.key_action => Action,
225        input_mapping.key_cancel => Cancel,
226        input_mapping.key_next => ScopeMove(ScopeDirection::Next),
227        input_mapping.key_next_alt => ScopeMove(ScopeDirection::Next),
228        input_mapping.key_free => Unlock,
229        input_mapping.key_previous => ScopeMove(ScopeDirection::Previous)
230    };
231    let mut send_command = |&(key, request)| {
232        if keyboard.just_pressed(key) {
233            nav_cmds.write(request);
234        }
235    };
236    if input_mapping.keyboard_navigation {
237        with_movement.iter().for_each(&mut send_command);
238    }
239    without_movement.iter().for_each(send_command);
240}
241
242/// [`SystemParam`](https://docs.rs/bevy/0.9.0/bevy/ecs/system/trait.SystemParam.html)
243/// used to compute UI focusable physical positions in mouse input systems.
244#[derive(SystemParam)]
245pub struct NodePosQuery<'w, 's, T: Component> {
246    entities: Query<
247        'w,
248        's,
249        (
250            Entity,
251            &'static T,
252            &'static UiGlobalTransform,
253            &'static Focusable,
254        ),
255    >,
256    boundaries: Option<Res<'w, ScreenBoundaries>>,
257    ui_stack: Res<'w, UiStack>,
258}
259impl<T: Component> NodePosQuery<'_, '_, T> {
260    fn cursor_pos(&self, at: Vec2) -> Option<Vec2> {
261        let boundaries = self.boundaries.as_ref()?;
262        Some(at * boundaries.scale + boundaries.position)
263    }
264}
265
266fn is_in_node<T: ScreenSize>(
267    at: Vec2,
268    (_, node, trans, _): &(Entity, &T, &UiGlobalTransform, &Focusable),
269) -> bool {
270    let ui_pos = trans.translation;
271    let node_half_size = node.size() / 2.0;
272    let min = ui_pos - node_half_size;
273    let max = ui_pos + node_half_size;
274    (min.x..max.x).contains(&at.x) && (min.y..max.y).contains(&at.y)
275}
276
277/// Check which [`Focusable`] is at position `at` if any.
278///
279/// NOTE: returns `None` if there is no [`ScreenBoundaries`] resource.
280pub fn ui_focusable_at<T>(at: Vec2, query: &NodePosQuery<T>) -> Option<Entity>
281where
282    T: ScreenSize + Component,
283{
284    let world_at = query.cursor_pos(at)?;
285
286    query
287        .ui_stack
288        .uinodes
289        .iter()
290        // reverse the iterator to traverse the tree from closest nodes to furthest
291        .rev()
292        .filter_map(|entity| query.entities.get(*entity).ok())
293        .filter(|query_elem| is_in_node(world_at, query_elem))
294        .map(|elem| elem.0)
295        .next()
296}
297
298fn cursor_pos(window: &Window) -> Option<Vec2> {
299    window.physical_cursor_position()
300}
301
302/// Something that has a size on screen.
303///
304/// Used for default mouse picking behavior on `bevy_ui`.
305pub trait ScreenSize {
306    /// The size of the thing on screen.
307    fn size(&self) -> Vec2;
308}
309
310impl ScreenSize for ComputedNode {
311    fn size(&self) -> Vec2 {
312        self.size()
313    }
314}
315
316/// A system to send mouse control events to the focus system
317///
318/// Unlike [`generic_default_mouse_input`], this system is gated by the
319/// `bevy_ui` feature. It relies on bevy/render specific types:
320/// `bevy::render::Camera` and `bevy::ui::Node`.
321///
322/// Which button to press to cause an action event is specified in the
323/// [`InputMapping`] resource.
324///
325/// You may however need to customize the behavior of this system (typically
326/// when integrating in the game) in this case, you should write your own
327/// system that sends [`NavRequest`] events. You may use
328/// [`ui_focusable_at`] to tell which focusable is currently being hovered.
329#[allow(clippy::too_many_arguments)]
330pub fn default_mouse_input(
331    input_mapping: Res<InputMapping>,
332    windows: Query<&Window, With<PrimaryWindow>>,
333    mouse: Res<ButtonInput<MouseButton>>,
334    focusables: NodePosQuery<ComputedNode>,
335    focused: Query<Entity, With<Focused>>,
336    nav_cmds: MessageWriter<NavRequest>,
337    last_pos: Local<Vec2>,
338) {
339    generic_default_mouse_input(
340        input_mapping,
341        windows,
342        mouse,
343        focusables,
344        focused,
345        nav_cmds,
346        last_pos,
347    );
348}
349
350/// A generic system to send mouse control events to the focus system
351///
352/// `T` must be a component assigned to `Focusable` elements that implements
353/// the [`ScreenSize`] trait.
354///
355/// Which button to press to cause an action event is specified in the
356/// [`InputMapping`] resource.
357///
358/// You may however need to customize the behavior of this system (typically
359/// when integrating in the game) in this case, you should write your own
360/// system that sends [`NavRequest`] events. You may use
361/// [`ui_focusable_at`] to tell which focusable is currently being hovered.
362#[allow(clippy::too_many_arguments)]
363pub fn generic_default_mouse_input<T: ScreenSize + Component>(
364    input_mapping: Res<InputMapping>,
365    primary_window: Query<&Window, With<PrimaryWindow>>,
366    mouse: Res<ButtonInput<MouseButton>>,
367    focusables: NodePosQuery<T>,
368    focused: Query<Entity, With<Focused>>,
369    mut nav_cmds: MessageWriter<NavRequest>,
370    mut last_pos: Local<Vec2>,
371) {
372    let no_focusable_msg = "Entity with `Focused` component must also have a `Focusable` component";
373    let Ok(window) = primary_window.single() else {
374        return;
375    };
376    let cursor_pos = match cursor_pos(window) {
377        Some(c) => c,
378        None => return,
379    };
380    let world_cursor_pos = match focusables.cursor_pos(cursor_pos) {
381        Some(c) => c,
382        None => return,
383    };
384    let released = mouse.just_released(input_mapping.mouse_action);
385    let pressed = mouse.pressed(input_mapping.mouse_action);
386    let focused = focused.single();
387
388    // Return early if cursor didn't move since last call
389    let camera_moved = focusables.boundaries.is_some_and(|b| b.is_changed());
390    let mouse_moved = *last_pos != cursor_pos;
391    if (!released && !pressed) && !mouse_moved && !camera_moved {
392        return;
393    } else {
394        *last_pos = cursor_pos;
395    }
396    // we didn't do it earlier so that we can leave early when the camera didn't move
397    let pressed = input_mapping.focus_follows_mouse || pressed;
398
399    let hovering_focused = |focused| {
400        let focused = focusables.entities.get(focused).expect(no_focusable_msg);
401        is_in_node(world_cursor_pos, &focused)
402    };
403    // If the currently hovered node is the focused one, there is no need to
404    // find which node we are hovering and to switch focus to it (since we are
405    // already focused on it)
406    let hovering = focused.is_ok_and(hovering_focused);
407    let set_focused = (pressed || released) && !hovering;
408    if set_focused {
409        // We only run this code when we really need it because we iterate over all
410        // focusables, which can eat a lot of CPU.
411        let under_mouse = focusables
412            .ui_stack
413            .uinodes
414            .iter()
415            // reverse the iterator to traverse the tree from closest nodes to furthest
416            .rev()
417            .filter_map(|entity| focusables.entities.get(*entity).ok())
418            .filter(|query_elem| query_elem.3.state() != FocusState::Blocked)
419            .filter(|query_elem| is_in_node(world_cursor_pos, query_elem))
420            .map(|elem| elem.0)
421            .next();
422
423        let to_target = match under_mouse {
424            Some(c) => c,
425            None => return,
426        };
427        nav_cmds.write(NavRequest::FocusOn(to_target));
428    }
429    if released && (set_focused || hovering) {
430        nav_cmds.write(NavRequest::Action);
431    }
432}
433
434/// Update [`ScreenBoundaries`] resource when the UI camera change
435/// (assuming there is a unique one).
436///
437/// See [`ScreenBoundaries`] doc for details.
438#[allow(clippy::type_complexity)]
439pub fn update_boundaries(
440    mut commands: Commands,
441    mut boundaries: Option<ResMut<ScreenBoundaries>>,
442    cameras: Query<Ref<Camera>>,
443    default_ui_camera: DefaultUiCamera,
444    default_ui_camera_changed: Query<(), Added<IsDefaultUiCamera>>,
445) {
446    // TODO this assumes there is only a single UI camera.
447
448    let Some(cam_entity) = default_ui_camera.get() else {
449        return;
450    };
451
452    let Ok(cam) = cameras.get(cam_entity) else {
453        return;
454    };
455
456    if default_ui_camera_changed.is_empty() && !cam.is_changed() {
457        return;
458    };
459
460    let Some(physical_size) = cam.physical_viewport_size() else {
461        return;
462    };
463
464    let new_boundaries = ScreenBoundaries {
465        position: Vec2::ZERO,
466        screen_edge: crate::resolve::Rect {
467            max: physical_size.as_vec2(),
468            min: Vec2::ZERO,
469        },
470        scale: 1.0,
471    };
472
473    if let Some(boundaries) = boundaries.as_mut() {
474        **boundaries = new_boundaries;
475    } else {
476        commands.insert_resource(new_boundaries);
477    }
478}
479
480/// Default input systems for ui navigation.
481pub struct DefaultNavigationSystems;
482impl Plugin for DefaultNavigationSystems {
483    fn build(&self, app: &mut App) {
484        use crate::NavRequestSystem;
485        app.init_resource::<InputMapping>().add_systems(
486            Update,
487            (
488                update_boundaries.before(default_mouse_input),
489                default_mouse_input,
490                default_gamepad_input,
491                default_keyboard_input,
492            )
493                .before(NavRequestSystem),
494        );
495    }
496}