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