1use 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 .add_plugins((
31 DefaultPlugins,
32 InputDispatchPlugin,
33 DirectionalNavigationPlugin,
34 ))
35 .insert_resource(InputFocusVisible(true))
38 .init_resource::<ActionState>()
40 .add_systems(Startup, setup_ui)
41 .add_systems(PreUpdate, (process_inputs, navigate).chain())
44 .add_systems(
45 Update,
46 (
47 highlight_focused_element,
49 interact_with_focused_button,
51 reset_button_after_interaction,
54 ),
55 )
56 .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
66fn 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 color.0 = PRESSED_BUTTON.into();
77 reset_timer.0 = Timer::from_seconds(0.3, TimerMode::Once);
78
79 click.propagate(false);
82 }
83}
84
85#[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
101fn 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 commands.spawn(Camera2d);
113
114 let root_node = commands
116 .spawn(Node {
117 width: percent(100),
118 height: percent(100),
119 ..default()
120 })
121 .id();
122
123 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 let grid_root_entity = commands
140 .spawn(Node {
141 display: Display::Grid,
142 width: percent(100),
144 height: percent(100),
145 grid_template_columns: RepeatedGridTrack::auto(N_COLS),
148 grid_template_rows: RepeatedGridTrack::auto(N_ROWS),
149 ..default()
150 })
151 .id();
152
153 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 border: UiRect::all(px(4)),
171 justify_content: JustifyContent::Center,
173 align_items: AlignItems::Center,
174 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 .with_child((
186 Text::new(button_name),
187 TextLayout {
189 justify: Justify::Center,
190 ..default()
191 },
192 ))
193 .id();
194
195 commands.entity(grid_root_entity).add_child(button_entity);
197
198 button_entities.insert((row, col), button_entity);
200 }
201 }
202
203 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 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 let top_left_entity = *button_entities.get(&(0, 0)).unwrap();
227 input_focus.set(top_left_entity);
228}
229
230#[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 DirectionalNavigationAction::Select => GamepadButton::South,
271 }
272 }
273}
274
275#[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 action_state.pressed_actions.clear();
289
290 for action in DirectionalNavigationAction::variants() {
291 if keyboard_input.just_pressed(action.keycode()) {
294 action_state.pressed_actions.insert(action);
295 }
296 }
297
298 for gamepad in gamepad_input.iter() {
301 for action in DirectionalNavigationAction::variants() {
302 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 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 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 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 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 *border_color = BorderColor::all(FOCUSED_BORDER);
365 } else {
366 *border_color = BorderColor::DEFAULT;
367 }
368 }
369}
370
371fn 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 pointer_id: PointerId::Mouse,
387 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 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}