Skip to main content

directional_navigation/
directional_navigation.rs

1//! Demonstrates automatic directional navigation.
2//!
3//! This shows how to use automatic navigation by simply adding the [`AutoDirectionalNavigation`]
4//! component to UI elements. Navigation is automatically calculated based on screen positions.
5//!
6//! This is especially useful for:
7//! - Dynamic UIs where elements may be added, removed, or repositioned
8//! - Irregular layouts that don't fit a simple grid pattern
9//! - Prototyping where you want navigation without tedious manual setup
10//!
11//! The automatic system finds the nearest neighbor in each compass direction for every node,
12//! completely eliminating the need to manually specify navigation relationships.
13//!
14//! For an example that demonstrates automatic directional navigation with manual overrides,
15//! refer to the `directional_navigation_overrides` example.
16
17use core::time::Duration;
18
19use bevy::{
20    camera::NormalizedRenderTarget,
21    input_focus::{
22        directional_navigation::{AutoNavigationConfig, DirectionalNavigationPlugin},
23        tab_navigation::TabIndex,
24        AutoFocus, InputFocus, InputFocusVisible,
25    },
26    math::{CompassOctant, Dir2, Rot2},
27    picking::{
28        backend::HitData,
29        pointer::{Location, PointerId},
30    },
31    platform::collections::HashSet,
32    prelude::*,
33    ui::auto_directional_navigation::{AutoDirectionalNavigation, AutoDirectionalNavigator},
34    ui_widgets::Button,
35};
36
37fn main() {
38    App::new()
39        .add_plugins((DefaultPlugins, DirectionalNavigationPlugin))
40        // This resource is canonically used to track whether or not to render a focus indicator
41        // It starts as false, but we set it to true here as we would like to see the focus indicator
42        .insert_resource(InputFocusVisible(true))
43        // Configure auto-navigation behavior
44        .insert_resource(AutoNavigationConfig {
45            // Require at least 10% overlap in perpendicular axis for cardinal directions
46            min_alignment_factor: 0.1,
47            // Don't connect nodes more than 500 pixels apart between their closest edges
48            max_search_distance: Some(500.0),
49            // Prefer nodes that are well-aligned
50            prefer_aligned: true,
51        })
52        .init_resource::<ActionState>()
53        .add_systems(Startup, (setup_scattered_ui, init_focus).chain())
54        // No manual system needed - just add AutoDirectionalNavigation to entities.
55        // Input is generally handled during PreUpdate
56        .add_systems(PreUpdate, (process_inputs, navigate).chain())
57        .add_systems(
58            Update,
59            (
60                highlight_focused_element,
61                interact_with_focused_button,
62                reset_button_after_interaction,
63                update_focus_display
64                    .run_if(|input_focus: Res<InputFocus>| input_focus.is_changed()),
65                update_key_display,
66            ),
67        )
68        .add_observer(universal_button_click_behavior)
69        .run();
70}
71
72const NORMAL_BUTTON: Srgba = bevy::color::palettes::tailwind::BLUE_400;
73const PRESSED_BUTTON: Srgba = bevy::color::palettes::tailwind::BLUE_500;
74const FOCUSED_BORDER: Srgba = bevy::color::palettes::tailwind::BLUE_50;
75
76/// Marker component for the text that displays the currently focused button
77#[derive(Component, Clone, Default)]
78struct FocusDisplay;
79
80/// Marker component for the text that displays the last key pressed
81#[derive(Component, Clone, Default)]
82struct KeyDisplay;
83
84// Observer for button clicks. Clicking on a button is considered navigation to that button.
85// This is also triggered when `interact_with_focused_button` simulates a button click with a
86// manually sent pointer click event
87fn universal_button_click_behavior(
88    mut click: On<PointerClick>,
89    mut button_query: Query<(&mut BackgroundColor, &mut ResetTimer)>,
90    mut focus_visible: ResMut<InputFocusVisible>,
91) {
92    let button_entity = click.entity;
93    if let Ok((mut color, mut reset_timer)) = button_query.get_mut(button_entity) {
94        color.0 = PRESSED_BUTTON.into();
95        reset_timer.0 = Timer::from_seconds(0.3, TimerMode::Once);
96        click.propagate(false);
97        // The focus should be visible because a button was clicked.
98        // It may have been set to false by the `PointerFocusPlugin`
99        focus_visible.0 = true;
100    }
101}
102
103#[derive(Clone, Component, Default, Deref, DerefMut)]
104struct ResetTimer(Timer);
105
106fn reset_button_after_interaction(
107    time: Res<Time>,
108    mut query: Query<(&mut ResetTimer, &mut BackgroundColor)>,
109) {
110    for (mut reset_timer, mut color) in query.iter_mut() {
111        reset_timer.tick(time.delta());
112        if reset_timer.just_finished() {
113            color.0 = NORMAL_BUTTON.into();
114        }
115    }
116}
117
118/// Spawn a scattered layout of buttons to demonstrate automatic navigation.
119///
120/// Unlike a regular grid, these buttons are irregularly positioned,
121/// but auto-navigation will still figure out the correct connections!
122fn setup_scattered_ui(mut commands: Commands) {
123    commands.spawn(Camera2d);
124
125    commands.spawn_scene(bsn! {
126        Node {
127            width: percent(100),
128            height: percent(100),
129        }
130        Children [
131            @instructions_scene()
132            --
133            @focus_display_scene()
134            --
135            @key_display_scene()
136            --
137            { buttons_scene() }
138        ]
139    });
140}
141
142/// Initializes focus to "Button 1" by giving it the `AutoFocus` component.
143fn init_focus(mut commands: Commands, button_query: Query<(Entity, &Name), With<Button>>) {
144    for (entity, name) in button_query.iter() {
145        if name.as_str() == "Button 1" {
146            commands.entity(entity).insert(AutoFocus);
147            break;
148        }
149    }
150}
151
152fn instructions_scene() -> impl Scene {
153    bsn! {
154        Node {
155            position_type: PositionType::Absolute,
156            left: px(20),
157            top: px(20),
158            width: px(280),
159            padding: UiRect::all(px(12)),
160            border_radius: BorderRadius::all(px(8)),
161        }
162        BackgroundColor(Color::srgba(0.1, 0.1, 0.1, 0.8))
163        Children [
164            Text(
165                "Directional Navigation Demo\n\n\
166                 Use arrow keys or D-pad to navigate.\n\
167                 Press Enter or A button to interact.\n\n\
168                 Buttons are scattered irregularly,\n\
169                 but navigation is automatic!",
170            )
171        ]
172    }
173}
174
175fn focus_display_scene() -> impl Scene {
176    bsn! {
177        Node {
178            position_type: PositionType::Absolute,
179            left: px(20),
180            bottom: px(80),
181            width: px(280),
182            padding: UiRect::all(px(12)),
183            border_radius: BorderRadius::all(px(8)),
184        }
185        BackgroundColor(Color::srgba(0.1, 0.5, 0.1, 0.8))
186        Children [
187            FocusDisplay
188            Text("Focused: None")
189            TextFont {
190                font_size: FontSize::Px(20.0),
191            }
192        ]
193    }
194}
195
196fn key_display_scene() -> impl Scene {
197    bsn! {
198        Node {
199            position_type: PositionType::Absolute,
200            left: px(20),
201            bottom: px(20),
202            width: px(280),
203            padding: UiRect::all(px(12)),
204            border_radius: BorderRadius::all(px(8)),
205        }
206        BackgroundColor(Color::srgba(0.5, 0.1, 0.5, 0.8))
207        Children [
208            KeyDisplay
209            Text("Last Key: None")
210            TextFont {
211                font_size: FontSize::Px(20.0),
212            }
213        ]
214    }
215}
216
217fn buttons_scene() -> impl SceneList {
218    // Spawn buttons in a scattered/irregular pattern
219    // The auto-navigation system will figure out the connections!
220    let button_positions = [
221        // Top row (irregular spacing)
222        (350.0, 100.0),
223        (520.0, 120.0),
224        (700.0, 90.0),
225        // Middle-top row
226        (380.0, 220.0),
227        (600.0, 240.0),
228        // Center
229        (450.0, 340.0),
230        (620.0, 360.0),
231        // Lower row
232        (360.0, 480.0),
233        (540.0, 460.0),
234        (720.0, 490.0),
235    ];
236
237    button_positions
238        .iter()
239        .enumerate()
240        .map(|(i, (x, y))| {
241            let transform = if i == 4 {
242                UiTransform {
243                    scale: Vec2::splat(1.2),
244                    rotation: Rot2::FRAC_PI_2,
245                    ..default()
246                }
247            } else {
248                UiTransform::IDENTITY
249            };
250            Box::new(bsn! {
251                Name::new(format!("Button {}", i + 1))
252                Button
253                // This ensures that navigating to this button via a click ensures the
254                // focus is correctly set to this button.
255                TabIndex(0)
256                Node {
257                    position_type: PositionType::Absolute,
258                    left: px(*x),
259                    top: px(*y),
260                    width: px(140),
261                    height: px(80),
262                    border: UiRect::all(px(4)),
263                    justify_content: JustifyContent::Center,
264                    align_items: AlignItems::Center,
265                    border_radius: BorderRadius::all(px(12)),
266                }
267                transform
268                // This is the key: just add this component for automatic navigation!
269                AutoDirectionalNavigation::default()
270                ResetTimer::default()
271                BackgroundColor::from(NORMAL_BUTTON)
272                Children [
273                    Text(format!("Button {}", i + 1))
274                    TextLayout {
275                        justify: Justify::Center,
276                    }
277                ]
278            })
279        })
280        .collect::<Vec<_>>()
281}
282
283// Action state and input handling
284#[derive(Debug, PartialEq, Eq, Hash)]
285enum DirectionalNavigationAction {
286    Up,
287    Down,
288    Left,
289    Right,
290    Select,
291}
292
293impl DirectionalNavigationAction {
294    fn variants() -> Vec<Self> {
295        vec![
296            DirectionalNavigationAction::Up,
297            DirectionalNavigationAction::Down,
298            DirectionalNavigationAction::Left,
299            DirectionalNavigationAction::Right,
300            DirectionalNavigationAction::Select,
301        ]
302    }
303
304    fn keycode(&self) -> KeyCode {
305        match self {
306            DirectionalNavigationAction::Up => KeyCode::ArrowUp,
307            DirectionalNavigationAction::Down => KeyCode::ArrowDown,
308            DirectionalNavigationAction::Left => KeyCode::ArrowLeft,
309            DirectionalNavigationAction::Right => KeyCode::ArrowRight,
310            DirectionalNavigationAction::Select => KeyCode::Enter,
311        }
312    }
313
314    fn gamepad_button(&self) -> GamepadButton {
315        match self {
316            DirectionalNavigationAction::Up => GamepadButton::DPadUp,
317            DirectionalNavigationAction::Down => GamepadButton::DPadDown,
318            DirectionalNavigationAction::Left => GamepadButton::DPadLeft,
319            DirectionalNavigationAction::Right => GamepadButton::DPadRight,
320            DirectionalNavigationAction::Select => GamepadButton::South,
321        }
322    }
323}
324
325#[derive(Default, Resource)]
326struct ActionState {
327    pressed_actions: HashSet<DirectionalNavigationAction>,
328}
329
330fn process_inputs(
331    mut action_state: ResMut<ActionState>,
332    keyboard_input: Res<ButtonInput<KeyCode>>,
333    gamepad_input: Query<&Gamepad>,
334) {
335    action_state.pressed_actions.clear();
336
337    for action in DirectionalNavigationAction::variants() {
338        if keyboard_input.just_pressed(action.keycode()) {
339            action_state.pressed_actions.insert(action);
340        }
341    }
342
343    for gamepad in gamepad_input.iter() {
344        for action in DirectionalNavigationAction::variants() {
345            if gamepad.just_pressed(action.gamepad_button()) {
346                action_state.pressed_actions.insert(action);
347            }
348        }
349    }
350}
351
352fn navigate(
353    action_state: Res<ActionState>,
354    mut auto_directional_navigator: AutoDirectionalNavigator,
355) {
356    let net_east_west = action_state
357        .pressed_actions
358        .contains(&DirectionalNavigationAction::Right) as i8
359        - action_state
360            .pressed_actions
361            .contains(&DirectionalNavigationAction::Left) as i8;
362
363    let net_north_south = action_state
364        .pressed_actions
365        .contains(&DirectionalNavigationAction::Up) as i8
366        - action_state
367            .pressed_actions
368            .contains(&DirectionalNavigationAction::Down) as i8;
369
370    // Use Dir2::from_xy to convert input to direction, then convert to CompassOctant
371    let maybe_direction = Dir2::from_xy(net_east_west as f32, net_north_south as f32)
372        .ok()
373        .map(CompassOctant::from);
374
375    if let Some(direction) = maybe_direction {
376        match auto_directional_navigator.navigate(direction) {
377            Ok(_entity) => {
378                // Successfully navigated
379            }
380            Err(_e) => {
381                // Navigation failed (no neighbor in that direction)
382            }
383        }
384    }
385}
386
387fn update_focus_display(
388    input_focus: Res<InputFocus>,
389    button_query: Query<&Name, With<Button>>,
390    mut display_query: Query<&mut Text, With<FocusDisplay>>,
391) {
392    if let Ok(mut text) = display_query.single_mut() {
393        if let Some(focused_entity) = input_focus.get() {
394            if let Ok(name) = button_query.get(focused_entity) {
395                **text = format!("Focused: {}", name);
396            } else {
397                **text = "Focused: Unknown".to_string();
398            }
399        } else {
400            **text = "Focused: None".to_string();
401        }
402    }
403}
404
405fn update_key_display(
406    keyboard_input: Res<ButtonInput<KeyCode>>,
407    gamepad_input: Query<&Gamepad>,
408    mut display_query: Query<&mut Text, With<KeyDisplay>>,
409) {
410    if let Ok(mut text) = display_query.single_mut() {
411        // Check for keyboard inputs
412        for action in DirectionalNavigationAction::variants() {
413            if keyboard_input.just_pressed(action.keycode()) {
414                let key_name = match action {
415                    DirectionalNavigationAction::Up => "Up Arrow",
416                    DirectionalNavigationAction::Down => "Down Arrow",
417                    DirectionalNavigationAction::Left => "Left Arrow",
418                    DirectionalNavigationAction::Right => "Right Arrow",
419                    DirectionalNavigationAction::Select => "Enter",
420                };
421                **text = format!("Last Key: {}", key_name);
422                return;
423            }
424        }
425
426        // Check for gamepad inputs
427        for gamepad in gamepad_input.iter() {
428            for action in DirectionalNavigationAction::variants() {
429                if gamepad.just_pressed(action.gamepad_button()) {
430                    let button_name = match action {
431                        DirectionalNavigationAction::Up => "D-Pad Up",
432                        DirectionalNavigationAction::Down => "D-Pad Down",
433                        DirectionalNavigationAction::Left => "D-Pad Left",
434                        DirectionalNavigationAction::Right => "D-Pad Right",
435                        DirectionalNavigationAction::Select => "A Button",
436                    };
437                    **text = format!("Last Key: {}", button_name);
438                    return;
439                }
440            }
441        }
442    }
443}
444
445fn highlight_focused_element(
446    input_focus: Res<InputFocus>,
447    input_focus_visible: Res<InputFocusVisible>,
448    mut query: Query<(Entity, &mut BorderColor)>,
449) {
450    for (entity, mut border_color) in query.iter_mut() {
451        if input_focus.get() == Some(entity) && input_focus_visible.0 {
452            *border_color = BorderColor::all(FOCUSED_BORDER);
453        } else {
454            *border_color = BorderColor::DEFAULT;
455        }
456    }
457}
458
459fn interact_with_focused_button(
460    action_state: Res<ActionState>,
461    input_focus: Res<InputFocus>,
462    mut commands: Commands,
463) {
464    if action_state
465        .pressed_actions
466        .contains(&DirectionalNavigationAction::Select)
467        && let Some(focused_entity) = input_focus.get()
468    {
469        commands.trigger(PointerClick {
470            entity: focused_entity,
471            pointer: Pointer::new(
472                PointerId::Mouse,
473                Location {
474                    target: NormalizedRenderTarget::None {
475                        width: 0,
476                        height: 0,
477                    },
478                    position: Vec2::ZERO,
479                },
480            ),
481            button: PointerButton::Primary,
482            hit: HitData {
483                camera: Entity::PLACEHOLDER,
484                depth: 0.0,
485                position: None,
486                normal: None,
487                extra: None,
488            },
489            count: 1,
490            duration: Duration::from_secs_f32(0.1),
491        });
492    }
493}