directional_navigation/
directional_navigation.rs

1//! Demonstrates how to set up the directional navigation system to allow for navigation between widgets.
2//!
3//! Directional navigation is generally used to move between widgets in a user interface using arrow keys or gamepad input.
4//! When compared to tab navigation, directional navigation is generally more direct, and less aware of the structure of the UI.
5//!
6//! In this example, we will set up a simple UI with a grid of buttons that can be navigated using the arrow keys or gamepad input.
7
8use std::time::Duration;
9
10use bevy::{
11    camera::NormalizedRenderTarget,
12    input_focus::{
13        directional_navigation::{
14            DirectionalNavigation, DirectionalNavigationMap, DirectionalNavigationPlugin,
15        },
16        InputDispatchPlugin, InputFocus, InputFocusVisible,
17    },
18    math::CompassOctant,
19    picking::{
20        backend::HitData,
21        pointer::{Location, PointerId},
22    },
23    platform::collections::{HashMap, HashSet},
24    prelude::*,
25};
26
27fn main() {
28    App::new()
29        // Input focus is not enabled by default, so we need to add the corresponding plugins
30        .add_plugins((
31            DefaultPlugins,
32            InputDispatchPlugin,
33            DirectionalNavigationPlugin,
34        ))
35        // This resource is canonically used to track whether or not to render a focus indicator
36        // It starts as false, but we set it to true here as we would like to see the focus indicator
37        .insert_resource(InputFocusVisible(true))
38        // We've made a simple resource to keep track of the actions that are currently being pressed for this example
39        .init_resource::<ActionState>()
40        .add_systems(Startup, setup_ui)
41        // Input is generally handled during PreUpdate
42        // We're turning inputs into actions first, then using those actions to determine navigation
43        .add_systems(PreUpdate, (process_inputs, navigate).chain())
44        .add_systems(
45            Update,
46            (
47                // We need to show which button is currently focused
48                highlight_focused_element,
49                // Pressing the "Interact" button while we have a focused element should simulate a click
50                interact_with_focused_button,
51                // We're doing a tiny animation when the button is interacted with,
52                // so we need a timer and a polling mechanism to reset it
53                reset_button_after_interaction,
54            ),
55        )
56        // This observer is added globally, so it will respond to *any* trigger of the correct type.
57        // However, we're filtering in the observer's query to only respond to button presses
58        .add_observer(universal_button_click_behavior)
59        .run();
60}
61
62const NORMAL_BUTTON: Srgba = bevy::color::palettes::tailwind::BLUE_400;
63const PRESSED_BUTTON: Srgba = bevy::color::palettes::tailwind::BLUE_500;
64const FOCUSED_BORDER: Srgba = bevy::color::palettes::tailwind::BLUE_50;
65
66// This observer will be triggered whenever a button is pressed
67// In a real project, each button would also have its own unique behavior,
68// to capture the actual intent of the user
69fn universal_button_click_behavior(
70    mut click: On<Pointer<Click>>,
71    mut button_query: Query<(&mut BackgroundColor, &mut ResetTimer)>,
72) {
73    let button_entity = click.entity;
74    if let Ok((mut color, mut reset_timer)) = button_query.get_mut(button_entity) {
75        // This would be a great place to play a little sound effect too!
76        color.0 = PRESSED_BUTTON.into();
77        reset_timer.0 = Timer::from_seconds(0.3, TimerMode::Once);
78
79        // Picking events propagate up the hierarchy,
80        // so we need to stop the propagation here now that we've handled it
81        click.propagate(false);
82    }
83}
84
85/// Resets a UI element to its default state when the timer has elapsed.
86#[derive(Component, Default, Deref, DerefMut)]
87struct ResetTimer(Timer);
88
89fn reset_button_after_interaction(
90    time: Res<Time>,
91    mut query: Query<(&mut ResetTimer, &mut BackgroundColor)>,
92) {
93    for (mut reset_timer, mut color) in query.iter_mut() {
94        reset_timer.tick(time.delta());
95        if reset_timer.just_finished() {
96            color.0 = NORMAL_BUTTON.into();
97        }
98    }
99}
100
101// We're spawning a simple grid of buttons and some instructions
102// The buttons are just colored rectangles with text displaying the button's name
103fn setup_ui(
104    mut commands: Commands,
105    mut directional_nav_map: ResMut<DirectionalNavigationMap>,
106    mut input_focus: ResMut<InputFocus>,
107) {
108    const N_ROWS: u16 = 5;
109    const N_COLS: u16 = 3;
110
111    // Rendering UI elements requires a camera
112    commands.spawn(Camera2d);
113
114    // Create a full-screen background node
115    let root_node = commands
116        .spawn(Node {
117            width: percent(100),
118            height: percent(100),
119            ..default()
120        })
121        .id();
122
123    // Add instruction to the left of the grid
124    let instructions = commands
125        .spawn((
126            Text::new("Use arrow keys or D-pad to navigate. \
127            Click the buttons, or press Enter / the South gamepad button to interact with the focused button."),
128            Node {
129                width: px(300),
130                justify_content: JustifyContent::Center,
131                align_items: AlignItems::Center,
132                margin: UiRect::all(px(12)),
133                ..default()
134            },
135        ))
136        .id();
137
138    // Set up the root entity to hold the grid
139    let grid_root_entity = commands
140        .spawn(Node {
141            display: Display::Grid,
142            // Allow the grid to take up the full height and the rest of the width of the window
143            width: percent(100),
144            height: percent(100),
145            // Set the number of rows and columns in the grid
146            // allowing the grid to automatically size the cells
147            grid_template_columns: RepeatedGridTrack::auto(N_COLS),
148            grid_template_rows: RepeatedGridTrack::auto(N_ROWS),
149            ..default()
150        })
151        .id();
152
153    // Add the instructions and grid to the root node
154    commands
155        .entity(root_node)
156        .add_children(&[instructions, grid_root_entity]);
157
158    let mut button_entities: HashMap<(u16, u16), Entity> = HashMap::default();
159    for row in 0..N_ROWS {
160        for col in 0..N_COLS {
161            let button_name = format!("Button {row}-{col}");
162
163            let button_entity = commands
164                .spawn((
165                    Button,
166                    Node {
167                        width: px(200),
168                        height: px(120),
169                        // Add a border so we can show which element is focused
170                        border: UiRect::all(px(4)),
171                        // Center the button's text label
172                        justify_content: JustifyContent::Center,
173                        align_items: AlignItems::Center,
174                        // Center the button within the grid cell
175                        align_self: AlignSelf::Center,
176                        justify_self: JustifySelf::Center,
177                        ..default()
178                    },
179                    ResetTimer::default(),
180                    BorderRadius::all(px(16)),
181                    BackgroundColor::from(NORMAL_BUTTON),
182                    Name::new(button_name.clone()),
183                ))
184                // Add a text element to the button
185                .with_child((
186                    Text::new(button_name),
187                    // And center the text if it flows onto multiple lines
188                    TextLayout {
189                        justify: Justify::Center,
190                        ..default()
191                    },
192                ))
193                .id();
194
195            // Add the button to the grid
196            commands.entity(grid_root_entity).add_child(button_entity);
197
198            // Keep track of the button entities so we can set up our navigation graph
199            button_entities.insert((row, col), button_entity);
200        }
201    }
202
203    // Connect all of the buttons in the same row to each other,
204    // looping around when the edge is reached.
205    for row in 0..N_ROWS {
206        let entities_in_row: Vec<Entity> = (0..N_COLS)
207            .map(|col| button_entities.get(&(row, col)).unwrap())
208            .copied()
209            .collect();
210        directional_nav_map.add_looping_edges(&entities_in_row, CompassOctant::East);
211    }
212
213    // Connect all of the buttons in the same column to each other,
214    // but don't loop around when the edge is reached.
215    // While looping is a very reasonable choice, we're not doing it here to demonstrate the different options.
216    for col in 0..N_COLS {
217        let entities_in_column: Vec<Entity> = (0..N_ROWS)
218            .map(|row| button_entities.get(&(row, col)).unwrap())
219            .copied()
220            .collect();
221
222        directional_nav_map.add_edges(&entities_in_column, CompassOctant::South);
223    }
224
225    // When changing scenes, remember to set an initial focus!
226    let top_left_entity = *button_entities.get(&(0, 0)).unwrap();
227    input_focus.set(top_left_entity);
228}
229
230// The indirection between inputs and actions allows us to easily remap inputs
231// and handle multiple input sources (keyboard, gamepad, etc.) in our game
232#[derive(Debug, PartialEq, Eq, Hash)]
233enum DirectionalNavigationAction {
234    Up,
235    Down,
236    Left,
237    Right,
238    Select,
239}
240
241impl DirectionalNavigationAction {
242    fn variants() -> Vec<Self> {
243        vec![
244            DirectionalNavigationAction::Up,
245            DirectionalNavigationAction::Down,
246            DirectionalNavigationAction::Left,
247            DirectionalNavigationAction::Right,
248            DirectionalNavigationAction::Select,
249        ]
250    }
251
252    fn keycode(&self) -> KeyCode {
253        match self {
254            DirectionalNavigationAction::Up => KeyCode::ArrowUp,
255            DirectionalNavigationAction::Down => KeyCode::ArrowDown,
256            DirectionalNavigationAction::Left => KeyCode::ArrowLeft,
257            DirectionalNavigationAction::Right => KeyCode::ArrowRight,
258            DirectionalNavigationAction::Select => KeyCode::Enter,
259        }
260    }
261
262    fn gamepad_button(&self) -> GamepadButton {
263        match self {
264            DirectionalNavigationAction::Up => GamepadButton::DPadUp,
265            DirectionalNavigationAction::Down => GamepadButton::DPadDown,
266            DirectionalNavigationAction::Left => GamepadButton::DPadLeft,
267            DirectionalNavigationAction::Right => GamepadButton::DPadRight,
268            // This is the "A" button on an Xbox controller,
269            // and is conventionally used as the "Select" / "Interact" button in many games
270            DirectionalNavigationAction::Select => GamepadButton::South,
271        }
272    }
273}
274
275// This keeps track of the inputs that are currently being pressed
276#[derive(Default, Resource)]
277struct ActionState {
278    pressed_actions: HashSet<DirectionalNavigationAction>,
279}
280
281fn process_inputs(
282    mut action_state: ResMut<ActionState>,
283    keyboard_input: Res<ButtonInput<KeyCode>>,
284    gamepad_input: Query<&Gamepad>,
285) {
286    // Reset the set of pressed actions each frame
287    // to ensure that we only process each action once
288    action_state.pressed_actions.clear();
289
290    for action in DirectionalNavigationAction::variants() {
291        // Use just_pressed to ensure that we only process each action once
292        // for each time it is pressed
293        if keyboard_input.just_pressed(action.keycode()) {
294            action_state.pressed_actions.insert(action);
295        }
296    }
297
298    // We're treating this like a single-player game:
299    // if multiple gamepads are connected, we don't care which one is being used
300    for gamepad in gamepad_input.iter() {
301        for action in DirectionalNavigationAction::variants() {
302            // Unlike keyboard input, gamepads are bound to a specific controller
303            if gamepad.just_pressed(action.gamepad_button()) {
304                action_state.pressed_actions.insert(action);
305            }
306        }
307    }
308}
309
310fn navigate(action_state: Res<ActionState>, mut directional_navigation: DirectionalNavigation) {
311    // If the user is pressing both left and right, or up and down,
312    // we should not move in either direction.
313    let net_east_west = action_state
314        .pressed_actions
315        .contains(&DirectionalNavigationAction::Right) as i8
316        - action_state
317            .pressed_actions
318            .contains(&DirectionalNavigationAction::Left) as i8;
319
320    let net_north_south = action_state
321        .pressed_actions
322        .contains(&DirectionalNavigationAction::Up) as i8
323        - action_state
324            .pressed_actions
325            .contains(&DirectionalNavigationAction::Down) as i8;
326
327    // Compute the direction that the user is trying to navigate in
328    let maybe_direction = match (net_east_west, net_north_south) {
329        (0, 0) => None,
330        (0, 1) => Some(CompassOctant::North),
331        (1, 1) => Some(CompassOctant::NorthEast),
332        (1, 0) => Some(CompassOctant::East),
333        (1, -1) => Some(CompassOctant::SouthEast),
334        (0, -1) => Some(CompassOctant::South),
335        (-1, -1) => Some(CompassOctant::SouthWest),
336        (-1, 0) => Some(CompassOctant::West),
337        (-1, 1) => Some(CompassOctant::NorthWest),
338        _ => None,
339    };
340
341    if let Some(direction) = maybe_direction {
342        match directional_navigation.navigate(direction) {
343            // In a real game, you would likely want to play a sound or show a visual effect
344            // on both successful and unsuccessful navigation attempts
345            Ok(entity) => {
346                println!("Navigated {direction:?} successfully. {entity} is now focused.");
347            }
348            Err(e) => println!("Navigation failed: {e}"),
349        }
350    }
351}
352
353fn highlight_focused_element(
354    input_focus: Res<InputFocus>,
355    // While this isn't strictly needed for the example,
356    // we're demonstrating how to be a good citizen by respecting the `InputFocusVisible` resource.
357    input_focus_visible: Res<InputFocusVisible>,
358    mut query: Query<(Entity, &mut BorderColor)>,
359) {
360    for (entity, mut border_color) in query.iter_mut() {
361        if input_focus.0 == Some(entity) && input_focus_visible.0 {
362            // Don't change the border size / radius here,
363            // as it would result in wiggling buttons when they are focused
364            *border_color = BorderColor::all(FOCUSED_BORDER);
365        } else {
366            *border_color = BorderColor::DEFAULT;
367        }
368    }
369}
370
371// By sending a Pointer<Click> trigger rather than directly handling button-like interactions,
372// we can unify our handling of pointer and keyboard/gamepad interactions
373fn interact_with_focused_button(
374    action_state: Res<ActionState>,
375    input_focus: Res<InputFocus>,
376    mut commands: Commands,
377) {
378    if action_state
379        .pressed_actions
380        .contains(&DirectionalNavigationAction::Select)
381        && let Some(focused_entity) = input_focus.0
382    {
383        commands.trigger(Pointer::<Click> {
384            entity: focused_entity,
385            // We're pretending that we're a mouse
386            pointer_id: PointerId::Mouse,
387            // This field isn't used, so we're just setting it to a placeholder value
388            pointer_location: Location {
389                target: NormalizedRenderTarget::None {
390                    width: 0,
391                    height: 0,
392                },
393                position: Vec2::ZERO,
394            },
395            event: Click {
396                button: PointerButton::Primary,
397                // This field isn't used, so we're just setting it to a placeholder value
398                hit: HitData {
399                    camera: Entity::PLACEHOLDER,
400                    depth: 0.0,
401                    position: None,
402                    normal: None,
403                },
404                duration: Duration::from_secs_f32(0.1),
405            },
406        });
407    }
408}