1use 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#[derive(Resource)]
15#[cfg_attr(feature = "bevy_reflect", derive(Reflect), reflect(Resource))]
16pub struct InputMapping {
17 pub keyboard_navigation: bool,
19 pub gamepads: Vec<Entity>,
21 pub joystick_ui_deadzone: f32,
23 pub move_x: GamepadAxis,
25 pub move_y: GamepadAxis,
27 pub left_button: GamepadButton,
29 pub right_button: GamepadButton,
31 pub up_button: GamepadButton,
33 pub down_button: GamepadButton,
35 pub action_button: GamepadButton,
37 pub cancel_button: GamepadButton,
39 pub previous_button: GamepadButton,
41 pub next_button: GamepadButton,
43 pub free_button: GamepadButton,
45 pub key_left: KeyCode,
47 pub key_right: KeyCode,
49 pub key_up: KeyCode,
51 pub key_down: KeyCode,
53 pub key_left_alt: KeyCode,
55 pub key_right_alt: KeyCode,
57 pub key_up_alt: KeyCode,
59 pub key_down_alt: KeyCode,
61 pub key_action: KeyCode,
63 pub key_cancel: KeyCode,
65 pub key_next: KeyCode,
67 pub key_next_alt: KeyCode,
69 pub key_previous: KeyCode,
71 pub key_free: KeyCode,
73 pub mouse_action: MouseButton,
75 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
115macro_rules! mapping {
117 ($($from:expr => $to:expr),* ) => ([$( ( $from, $to ) ),*])
118}
119
120pub 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 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
190pub 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 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#[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
277pub 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 .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
302pub trait ScreenSize {
306 fn size(&self) -> Vec2;
308}
309
310impl ScreenSize for ComputedNode {
311 fn size(&self) -> Vec2 {
312 self.size()
313 }
314}
315
316#[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#[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 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 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 let hovering = focused.is_ok_and(hovering_focused);
407 let set_focused = (pressed || released) && !hovering;
408 if set_focused {
409 let under_mouse = focusables
412 .ui_stack
413 .uinodes
414 .iter()
415 .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#[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 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
480pub 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}