bevy_input_focus/directional_navigation.rs
1//! A navigation framework for moving between focusable elements based on directional input.
2//!
3//! While virtual cursors are a common way to navigate UIs with a gamepad (or arrow keys!),
4//! they are generally both slow and frustrating to use.
5//! Instead, directional inputs should provide a direct way to snap between focusable elements.
6//!
7//! Like the rest of this crate, the [`InputFocus`] resource is manipulated to track
8//! the current focus.
9//!
10//! Navigating between focusable entities (commonly UI nodes) is done by
11//! passing a [`CompassOctant`] into the [`navigate`](DirectionalNavigation::navigate) method
12//! from the [`DirectionalNavigation`] system parameter. Under the hood, an entity is found
13//! automatically via brute force search in the desired [`CompassOctant`] direction.
14//!
15//! If some manual navigation is desired, a [`DirectionalNavigationMap`] will override the brute force
16//! search in a direction for a given entity. The [`DirectionalNavigationMap`] stores a directed graph
17//! of focusable entities. Each entity can have up to 8 neighbors, one for each [`CompassOctant`],
18//! balancing flexibility and required precision.
19//!
20//! # Setting up Directional Navigation
21//!
22//! ## Automatic Navigation (Recommended)
23//!
24//! The easiest way to set up navigation is to add the `AutoDirectionalNavigation` component
25//! to your UI entities. This component is available in the `bevy_ui` crate. If you choose to
26//! include automatic navigation, you should also use the `AutoDirectionalNavigator` system parameter
27//! in that crate instead of [`DirectionalNavigation`].
28//!
29//! ## Manual Navigation
30//!
31//! You can also manually define navigation connections using methods like
32//! [`add_edge`](DirectionalNavigationMap::add_edge) and
33//! [`add_looping_edges`](DirectionalNavigationMap::add_looping_edges).
34//!
35//! ## Combining Automatic and Manual
36//!
37//! Following manual edges always take precedence, allowing you to use
38//! automatic navigation for most UI elements while overriding specific connections for
39//! special cases like wrapping menus or cross-layer navigation.
40//!
41//! ## When to Use Manual Navigation
42//!
43//! While automatic navigation is recommended for most use cases, manual navigation provides:
44//!
45//! - **Precise control**: Define exact navigation flow, including non-obvious connections like looping edges
46//! - **Cross-layer navigation**: Connect elements across different UI layers or z-index levels
47//! - **Custom behavior**: Implement domain-specific navigation patterns (e.g., spreadsheet-style wrapping)
48
49use crate::{navigator::find_best_candidate, InputFocus};
50use bevy_app::prelude::*;
51use bevy_ecs::{
52 entity::{EntityHashMap, EntityHashSet},
53 prelude::*,
54 system::SystemParam,
55};
56use bevy_math::{CompassOctant, Vec2};
57use thiserror::Error;
58
59#[cfg(feature = "bevy_reflect")]
60use bevy_reflect::{prelude::*, Reflect};
61
62/// A plugin that sets up the directional navigation resources.
63#[derive(Default)]
64pub struct DirectionalNavigationPlugin;
65
66impl Plugin for DirectionalNavigationPlugin {
67 fn build(&self, app: &mut App) {
68 app.init_resource::<DirectionalNavigationMap>()
69 .init_resource::<AutoNavigationConfig>();
70 }
71}
72
73/// Configuration resource for automatic directional navigation and for generating manual
74/// navigation edges via [`auto_generate_navigation_edges`]
75///
76/// This resource controls how nodes should be automatically connected in each direction.
77#[derive(Resource, Debug, Clone, PartialEq)]
78#[cfg_attr(
79 feature = "bevy_reflect",
80 derive(Reflect),
81 reflect(Resource, Debug, PartialEq, Clone)
82)]
83pub struct AutoNavigationConfig {
84 /// Minimum overlap ratio (0.0-1.0) required along the perpendicular axis for cardinal directions.
85 ///
86 /// This parameter controls how much two UI elements must overlap in the perpendicular direction
87 /// to be considered reachable neighbors. It only applies to cardinal directions (`North`, `South`, `East`, `West`);
88 /// diagonal directions (`NorthEast`, `SouthEast`, etc.) ignore this requirement entirely.
89 ///
90 /// # Calculation
91 ///
92 /// The overlap factor is calculated as:
93 /// ```text
94 /// overlap_factor = actual_overlap / min(origin_size, candidate_size)
95 /// ```
96 ///
97 /// For East/West navigation, this measures vertical overlap:
98 /// - `actual_overlap` = overlapping height between the two elements
99 /// - Sizes are the heights of the origin and candidate
100 ///
101 /// For North/South navigation, this measures horizontal overlap:
102 /// - `actual_overlap` = overlapping width between the two elements
103 /// - Sizes are the widths of the origin and candidate
104 ///
105 /// # Examples
106 ///
107 /// - `0.0` (default): Any overlap is sufficient. Even if elements barely touch, they can be neighbors.
108 /// - `0.5`: Elements must overlap by at least 50% of the smaller element's size.
109 /// - `1.0`: Perfect alignment required. The smaller element must be completely within the bounds
110 /// of the larger element along the perpendicular axis.
111 ///
112 /// # Use Cases
113 ///
114 /// - **Sparse/irregular layouts** (e.g., star constellations): Use `0.0` to allow navigation
115 /// between elements that don't directly align.
116 /// - **Grid layouts**: Use `0.5` or higher to ensure navigation only connects elements in
117 /// the same row or column.
118 /// - **Strict alignment**: Use `1.0` to require perfect alignment, though this may result
119 /// in disconnected navigation graphs if elements aren't precisely aligned.
120 pub min_alignment_factor: f32,
121
122 /// Maximum search distance in logical pixels.
123 ///
124 /// Nodes beyond this distance won't be connected. `None` means unlimited.
125 pub max_search_distance: Option<f32>,
126
127 /// Whether to prefer nodes that are more aligned with the exact direction.
128 ///
129 /// When `true`, nodes that are more directly in line with the requested direction
130 /// will be strongly preferred over nodes at an angle.
131 pub prefer_aligned: bool,
132}
133
134impl Default for AutoNavigationConfig {
135 fn default() -> Self {
136 Self {
137 min_alignment_factor: 0.0, // Any overlap is acceptable
138 max_search_distance: None, // No distance limit
139 prefer_aligned: true, // Prefer well-aligned nodes
140 }
141 }
142}
143
144/// The up-to-eight neighbors of a focusable entity, one for each [`CompassOctant`].
145#[derive(Default, Debug, Clone, PartialEq)]
146#[cfg_attr(
147 feature = "bevy_reflect",
148 derive(Reflect),
149 reflect(Default, Debug, PartialEq, Clone)
150)]
151pub struct NavNeighbors {
152 /// The array of neighbors, one for each [`CompassOctant`].
153 /// The mapping between array elements and directions is determined by [`CompassOctant::to_index`].
154 ///
155 /// If no neighbor exists in a given direction, the value will be [`None`].
156 /// In most cases, using [`NavNeighbors::set`] and [`NavNeighbors::get`]
157 /// will be more ergonomic than directly accessing this array.
158 pub neighbors: [Option<Entity>; 8],
159}
160
161impl NavNeighbors {
162 /// An empty set of neighbors.
163 pub const EMPTY: NavNeighbors = NavNeighbors {
164 neighbors: [None; 8],
165 };
166
167 /// Get the neighbor for a given [`CompassOctant`].
168 pub const fn get(&self, octant: CompassOctant) -> Option<Entity> {
169 self.neighbors[octant.to_index()]
170 }
171
172 /// Set the neighbor for a given [`CompassOctant`].
173 pub const fn set(&mut self, octant: CompassOctant, entity: Entity) {
174 self.neighbors[octant.to_index()] = Some(entity);
175 }
176}
177
178/// A resource that stores the manually specified traversable graph of focusable entities.
179///
180/// Each entity can have up to 8 neighbors, one for each [`CompassOctant`].
181///
182/// To ensure that your graph is intuitive to navigate and generally works correctly, it should be:
183///
184/// - **Connected**: Every focusable entity should be reachable from every other focusable entity.
185/// - **Symmetric**: If entity A is a neighbor of entity B, then entity B should be a neighbor of entity A, ideally in the reverse direction.
186/// - **Physical**: The direction of navigation should match the layout of the entities when possible,
187/// although looping around the edges of the screen is also acceptable.
188/// - **Not self-connected**: An entity should not be a neighbor of itself; use [`None`] instead.
189///
190/// This graph must be built and maintained manually, and the developer is responsible for ensuring that it meets the above criteria.
191/// Notably, if the developer adds or removes the navigability of an entity, the developer should update the map as necessary.
192#[derive(Resource, Debug, Default, Clone, PartialEq)]
193#[cfg_attr(
194 feature = "bevy_reflect",
195 derive(Reflect),
196 reflect(Resource, Debug, Default, PartialEq, Clone)
197)]
198pub struct DirectionalNavigationMap {
199 /// A directed graph of focusable entities.
200 ///
201 /// Pass in the current focus as a key, and get back a collection of up to 8 neighbors,
202 /// each keyed by a [`CompassOctant`].
203 pub neighbors: EntityHashMap<NavNeighbors>,
204}
205
206impl DirectionalNavigationMap {
207 /// Removes an entity from the navigation map, including all connections to and from it.
208 ///
209 /// Note that this is an O(n) operation, where n is the number of entities in the map,
210 /// as we must iterate over each entity to check for connections to the removed entity.
211 ///
212 /// If you are removing multiple entities, consider using [`remove_multiple`](Self::remove_multiple) instead.
213 pub fn remove(&mut self, entity: Entity) {
214 self.neighbors.remove(&entity);
215
216 for node in self.neighbors.values_mut() {
217 for neighbor in node.neighbors.iter_mut() {
218 if *neighbor == Some(entity) {
219 *neighbor = None;
220 }
221 }
222 }
223 }
224
225 /// Removes a collection of entities from the navigation map.
226 ///
227 /// While this is still an O(n) operation, where n is the number of entities in the map,
228 /// it is more efficient than calling [`remove`](Self::remove) multiple times,
229 /// as we can check for connections to all removed entities in a single pass.
230 ///
231 /// An [`EntityHashSet`] must be provided as it is noticeably faster than the standard hasher or a [`Vec`](`alloc::vec::Vec`).
232 pub fn remove_multiple(&mut self, entities: EntityHashSet) {
233 for entity in &entities {
234 self.neighbors.remove(entity);
235 }
236
237 for node in self.neighbors.values_mut() {
238 for neighbor in node.neighbors.iter_mut() {
239 if let Some(entity) = *neighbor {
240 if entities.contains(&entity) {
241 *neighbor = None;
242 }
243 }
244 }
245 }
246 }
247
248 /// Completely clears the navigation map, removing all entities and connections.
249 pub fn clear(&mut self) {
250 self.neighbors.clear();
251 }
252
253 /// Adds an edge between two entities in the navigation map.
254 /// Any existing edge from A in the provided direction will be overwritten.
255 ///
256 /// The reverse edge will not be added, so navigation will only be possible in one direction.
257 /// If you want to add a symmetrical edge, use [`add_symmetrical_edge`](Self::add_symmetrical_edge) instead.
258 pub fn add_edge(&mut self, a: Entity, b: Entity, direction: CompassOctant) {
259 self.neighbors
260 .entry(a)
261 .or_insert(NavNeighbors::EMPTY)
262 .set(direction, b);
263 }
264
265 /// Adds a symmetrical edge between two entities in the navigation map.
266 /// The A -> B path will use the provided direction, while B -> A will use the [`CompassOctant::opposite`] variant.
267 ///
268 /// Any existing connections between the two entities will be overwritten.
269 pub fn add_symmetrical_edge(&mut self, a: Entity, b: Entity, direction: CompassOctant) {
270 self.add_edge(a, b, direction);
271 self.add_edge(b, a, direction.opposite());
272 }
273
274 /// Add symmetrical edges between each consecutive pair of entities in the provided slice.
275 ///
276 /// Unlike [`add_looping_edges`](Self::add_looping_edges), this method does not loop back to the first entity.
277 pub fn add_edges(&mut self, entities: &[Entity], direction: CompassOctant) {
278 for pair in entities.windows(2) {
279 self.add_symmetrical_edge(pair[0], pair[1], direction);
280 }
281 }
282
283 /// Add symmetrical edges between each consecutive pair of entities in the provided slice, looping back to the first entity at the end.
284 ///
285 /// This is useful for creating a circular navigation path between a set of entities, such as a menu.
286 pub fn add_looping_edges(&mut self, entities: &[Entity], direction: CompassOctant) {
287 self.add_edges(entities, direction);
288 if let Some((first_entity, rest)) = entities.split_first() {
289 if let Some(last_entity) = rest.last() {
290 self.add_symmetrical_edge(*last_entity, *first_entity, direction);
291 }
292 }
293 }
294
295 /// Gets the entity in a given direction from the current focus, if any.
296 pub fn get_neighbor(&self, focus: Entity, octant: CompassOctant) -> Option<Entity> {
297 self.neighbors
298 .get(&focus)
299 .and_then(|neighbors| neighbors.get(octant))
300 }
301
302 /// Looks up the neighbors of a given entity.
303 ///
304 /// If the entity is not in the map, [`None`] will be returned.
305 /// Note that the set of neighbors is not guaranteed to be non-empty though!
306 pub fn get_neighbors(&self, entity: Entity) -> Option<&NavNeighbors> {
307 self.neighbors.get(&entity)
308 }
309}
310
311/// A system parameter for navigating between focusable entities in a directional way.
312#[derive(SystemParam, Debug)]
313pub struct DirectionalNavigation<'w> {
314 /// The currently focused entity.
315 pub focus: ResMut<'w, InputFocus>,
316 /// The directional navigation map containing manually defined connections between entities.
317 pub map: Res<'w, DirectionalNavigationMap>,
318}
319
320impl<'w> DirectionalNavigation<'w> {
321 /// Navigates to the neighbor in a given direction from the current focus, if any.
322 ///
323 /// Returns the new focus if successful.
324 /// Returns an error if there is no focus set or if there is no neighbor in the requested direction.
325 ///
326 /// If the result was `Ok`, the [`InputFocus`] resource is updated to the new focus as part of this method call.
327 pub fn navigate(
328 &mut self,
329 direction: CompassOctant,
330 ) -> Result<Entity, DirectionalNavigationError> {
331 if let Some(current_focus) = self.focus.0 {
332 // Respect manual edges first
333 if let Some(new_focus) = self.map.get_neighbor(current_focus, direction) {
334 self.focus.set(new_focus);
335 Ok(new_focus)
336 } else {
337 Err(DirectionalNavigationError::NoNeighborInDirection {
338 current_focus,
339 direction,
340 })
341 }
342 } else {
343 Err(DirectionalNavigationError::NoFocus)
344 }
345 }
346}
347
348/// An error that can occur when navigating between focusable entities using [directional navigation](crate::directional_navigation).
349#[derive(Debug, PartialEq, Clone, Error)]
350pub enum DirectionalNavigationError {
351 /// No focusable entity is currently set.
352 #[error("No focusable entity is currently set.")]
353 NoFocus,
354 /// No neighbor in the requested direction.
355 #[error("No neighbor from {current_focus} in the {direction:?} direction.")]
356 NoNeighborInDirection {
357 /// The entity that was the focus when the error occurred.
358 current_focus: Entity,
359 /// The direction in which the navigation was attempted.
360 direction: CompassOctant,
361 },
362}
363
364/// A focusable area with position and size information.
365///
366/// This struct represents a UI element used during directional navigation,
367/// containing its entity ID, center position, and size for spatial navigation calculations.
368///
369/// The term "focusable area" avoids confusion with UI `Node` components in `bevy_ui`.
370#[derive(Debug, Clone, Copy, PartialEq)]
371#[cfg_attr(
372 feature = "bevy_reflect",
373 derive(Reflect),
374 reflect(Debug, PartialEq, Clone)
375)]
376pub struct FocusableArea {
377 /// The entity identifier for this focusable area.
378 pub entity: Entity,
379 /// The center position in global coordinates.
380 pub position: Vec2,
381 /// The size (width, height) of the area.
382 pub size: Vec2,
383}
384
385/// Trait for extracting position and size from navigable UI components.
386///
387/// This allows the auto-navigation system to work with different UI implementations
388/// as long as they can provide position and size information.
389pub trait Navigable {
390 /// Returns the center position and size in global coordinates.
391 fn get_bounds(&self) -> (Vec2, Vec2);
392}
393
394/// Automatically generates directional navigation edges for a collection of nodes.
395///
396/// This function takes a slice of navigation nodes with their positions and sizes, and populates
397/// the navigation map with edges to the nearest neighbor in each compass direction.
398/// Manual edges already in the map are preserved and not overwritten.
399///
400/// # Arguments
401///
402/// * `nav_map` - The navigation map to populate
403/// * `nodes` - A slice of [`FocusableArea`] structs containing entity, position, and size data
404/// * `config` - Configuration for the auto-generation algorithm
405///
406/// # Example
407///
408/// ```rust
409/// # use bevy_input_focus::directional_navigation::*;
410/// # use bevy_ecs::entity::Entity;
411/// # use bevy_math::Vec2;
412/// let mut nav_map = DirectionalNavigationMap::default();
413/// let config = AutoNavigationConfig::default();
414///
415/// let nodes = vec![
416/// FocusableArea { entity: Entity::PLACEHOLDER, position: Vec2::new(100.0, 100.0), size: Vec2::new(50.0, 50.0) },
417/// FocusableArea { entity: Entity::PLACEHOLDER, position: Vec2::new(200.0, 100.0), size: Vec2::new(50.0, 50.0) },
418/// ];
419///
420/// auto_generate_navigation_edges(&mut nav_map, &nodes, &config);
421/// ```
422pub fn auto_generate_navigation_edges(
423 nav_map: &mut DirectionalNavigationMap,
424 nodes: &[FocusableArea],
425 config: &AutoNavigationConfig,
426) {
427 // For each node, find best neighbor in each direction
428 for origin in nodes {
429 for octant in [
430 CompassOctant::North,
431 CompassOctant::NorthEast,
432 CompassOctant::East,
433 CompassOctant::SouthEast,
434 CompassOctant::South,
435 CompassOctant::SouthWest,
436 CompassOctant::West,
437 CompassOctant::NorthWest,
438 ] {
439 // Skip if manual edge already exists (check inline to avoid borrow issues)
440 if nav_map
441 .get_neighbors(origin.entity)
442 .and_then(|neighbors| neighbors.get(octant))
443 .is_some()
444 {
445 continue; // Respect manual override
446 }
447
448 // Find best candidate in this direction
449 let best_candidate = find_best_candidate(origin, octant, nodes, config);
450
451 // Add edge if we found a valid candidate
452 if let Some(neighbor) = best_candidate {
453 nav_map.add_edge(origin.entity, neighbor, octant);
454 }
455 }
456 }
457}
458
459#[cfg(test)]
460mod tests {
461 use alloc::vec;
462 use bevy_ecs::system::RunSystemOnce;
463
464 use super::*;
465
466 #[test]
467 fn setting_and_getting_nav_neighbors() {
468 let mut neighbors = NavNeighbors::EMPTY;
469 assert_eq!(neighbors.get(CompassOctant::SouthEast), None);
470
471 neighbors.set(CompassOctant::SouthEast, Entity::PLACEHOLDER);
472
473 for i in 0..8 {
474 if i == CompassOctant::SouthEast.to_index() {
475 assert_eq!(
476 neighbors.get(CompassOctant::SouthEast),
477 Some(Entity::PLACEHOLDER)
478 );
479 } else {
480 assert_eq!(neighbors.get(CompassOctant::from_index(i).unwrap()), None);
481 }
482 }
483 }
484
485 #[test]
486 fn simple_set_and_get_navmap() {
487 let mut world = World::new();
488 let a = world.spawn_empty().id();
489 let b = world.spawn_empty().id();
490
491 let mut map = DirectionalNavigationMap::default();
492 map.add_edge(a, b, CompassOctant::SouthEast);
493
494 assert_eq!(map.get_neighbor(a, CompassOctant::SouthEast), Some(b));
495 assert_eq!(
496 map.get_neighbor(b, CompassOctant::SouthEast.opposite()),
497 None
498 );
499 }
500
501 #[test]
502 fn symmetrical_edges() {
503 let mut world = World::new();
504 let a = world.spawn_empty().id();
505 let b = world.spawn_empty().id();
506
507 let mut map = DirectionalNavigationMap::default();
508 map.add_symmetrical_edge(a, b, CompassOctant::North);
509
510 assert_eq!(map.get_neighbor(a, CompassOctant::North), Some(b));
511 assert_eq!(map.get_neighbor(b, CompassOctant::South), Some(a));
512 }
513
514 #[test]
515 fn remove_nodes() {
516 let mut world = World::new();
517 let a = world.spawn_empty().id();
518 let b = world.spawn_empty().id();
519
520 let mut map = DirectionalNavigationMap::default();
521 map.add_edge(a, b, CompassOctant::North);
522 map.add_edge(b, a, CompassOctant::South);
523
524 assert_eq!(map.get_neighbor(a, CompassOctant::North), Some(b));
525 assert_eq!(map.get_neighbor(b, CompassOctant::South), Some(a));
526
527 map.remove(b);
528
529 assert_eq!(map.get_neighbor(a, CompassOctant::North), None);
530 assert_eq!(map.get_neighbor(b, CompassOctant::South), None);
531 }
532
533 #[test]
534 fn remove_multiple_nodes() {
535 let mut world = World::new();
536 let a = world.spawn_empty().id();
537 let b = world.spawn_empty().id();
538 let c = world.spawn_empty().id();
539
540 let mut map = DirectionalNavigationMap::default();
541 map.add_edge(a, b, CompassOctant::North);
542 map.add_edge(b, a, CompassOctant::South);
543 map.add_edge(b, c, CompassOctant::East);
544 map.add_edge(c, b, CompassOctant::West);
545
546 let mut to_remove = EntityHashSet::default();
547 to_remove.insert(b);
548 to_remove.insert(c);
549
550 map.remove_multiple(to_remove);
551
552 assert_eq!(map.get_neighbor(a, CompassOctant::North), None);
553 assert_eq!(map.get_neighbor(b, CompassOctant::South), None);
554 assert_eq!(map.get_neighbor(b, CompassOctant::East), None);
555 assert_eq!(map.get_neighbor(c, CompassOctant::West), None);
556 }
557
558 #[test]
559 fn edges() {
560 let mut world = World::new();
561 let a = world.spawn_empty().id();
562 let b = world.spawn_empty().id();
563 let c = world.spawn_empty().id();
564
565 let mut map = DirectionalNavigationMap::default();
566 map.add_edges(&[a, b, c], CompassOctant::East);
567
568 assert_eq!(map.get_neighbor(a, CompassOctant::East), Some(b));
569 assert_eq!(map.get_neighbor(b, CompassOctant::East), Some(c));
570 assert_eq!(map.get_neighbor(c, CompassOctant::East), None);
571
572 assert_eq!(map.get_neighbor(a, CompassOctant::West), None);
573 assert_eq!(map.get_neighbor(b, CompassOctant::West), Some(a));
574 assert_eq!(map.get_neighbor(c, CompassOctant::West), Some(b));
575 }
576
577 #[test]
578 fn looping_edges() {
579 let mut world = World::new();
580 let a = world.spawn_empty().id();
581 let b = world.spawn_empty().id();
582 let c = world.spawn_empty().id();
583
584 let mut map = DirectionalNavigationMap::default();
585 map.add_looping_edges(&[a, b, c], CompassOctant::East);
586
587 assert_eq!(map.get_neighbor(a, CompassOctant::East), Some(b));
588 assert_eq!(map.get_neighbor(b, CompassOctant::East), Some(c));
589 assert_eq!(map.get_neighbor(c, CompassOctant::East), Some(a));
590
591 assert_eq!(map.get_neighbor(a, CompassOctant::West), Some(c));
592 assert_eq!(map.get_neighbor(b, CompassOctant::West), Some(a));
593 assert_eq!(map.get_neighbor(c, CompassOctant::West), Some(b));
594 }
595
596 #[test]
597 fn manual_nav_with_system_param() {
598 let mut world = World::new();
599 let a = world.spawn_empty().id();
600 let b = world.spawn_empty().id();
601 let c = world.spawn_empty().id();
602
603 let mut map = DirectionalNavigationMap::default();
604 map.add_looping_edges(&[a, b, c], CompassOctant::East);
605
606 world.insert_resource(map);
607
608 let mut focus = InputFocus::default();
609 focus.set(a);
610 world.insert_resource(focus);
611
612 let config = AutoNavigationConfig::default();
613 world.insert_resource(config);
614
615 assert_eq!(world.resource::<InputFocus>().get(), Some(a));
616
617 fn navigate_east(mut nav: DirectionalNavigation) {
618 nav.navigate(CompassOctant::East).unwrap();
619 }
620
621 world.run_system_once(navigate_east).unwrap();
622 assert_eq!(world.resource::<InputFocus>().get(), Some(b));
623
624 world.run_system_once(navigate_east).unwrap();
625 assert_eq!(world.resource::<InputFocus>().get(), Some(c));
626
627 world.run_system_once(navigate_east).unwrap();
628 assert_eq!(world.resource::<InputFocus>().get(), Some(a));
629 }
630
631 #[test]
632 fn test_auto_generate_navigation_edges() {
633 let mut nav_map = DirectionalNavigationMap::default();
634 let config = AutoNavigationConfig::default();
635
636 // Create a 2x2 grid of nodes (using UI coordinates: smaller Y = higher on screen)
637 let node_a = Entity::from_bits(1); // Top-left
638 let node_b = Entity::from_bits(2); // Top-right
639 let node_c = Entity::from_bits(3); // Bottom-left
640 let node_d = Entity::from_bits(4); // Bottom-right
641
642 let nodes = vec![
643 FocusableArea {
644 entity: node_a,
645 position: Vec2::new(0.0, 0.0),
646 size: Vec2::new(50.0, 50.0),
647 }, // Top-left
648 FocusableArea {
649 entity: node_b,
650 position: Vec2::new(100.0, 0.0),
651 size: Vec2::new(50.0, 50.0),
652 }, // Top-right
653 FocusableArea {
654 entity: node_c,
655 position: Vec2::new(0.0, 100.0),
656 size: Vec2::new(50.0, 50.0),
657 }, // Bottom-left
658 FocusableArea {
659 entity: node_d,
660 position: Vec2::new(100.0, 100.0),
661 size: Vec2::new(50.0, 50.0),
662 }, // Bottom-right
663 ];
664
665 auto_generate_navigation_edges(&mut nav_map, &nodes, &config);
666
667 // Test horizontal navigation
668 assert_eq!(
669 nav_map.get_neighbor(node_a, CompassOctant::East),
670 Some(node_b)
671 );
672 assert_eq!(
673 nav_map.get_neighbor(node_b, CompassOctant::West),
674 Some(node_a)
675 );
676
677 // Test vertical navigation
678 assert_eq!(
679 nav_map.get_neighbor(node_a, CompassOctant::South),
680 Some(node_c)
681 );
682 assert_eq!(
683 nav_map.get_neighbor(node_c, CompassOctant::North),
684 Some(node_a)
685 );
686
687 // Test diagonal navigation
688 assert_eq!(
689 nav_map.get_neighbor(node_a, CompassOctant::SouthEast),
690 Some(node_d)
691 );
692 }
693
694 #[test]
695 fn test_auto_generate_respects_manual_edges() {
696 let mut nav_map = DirectionalNavigationMap::default();
697 let config = AutoNavigationConfig::default();
698
699 let node_a = Entity::from_bits(1);
700 let node_b = Entity::from_bits(2);
701 let node_c = Entity::from_bits(3);
702
703 // Manually set an edge from A to C (skipping B)
704 nav_map.add_edge(node_a, node_c, CompassOctant::East);
705
706 let nodes = vec![
707 FocusableArea {
708 entity: node_a,
709 position: Vec2::new(0.0, 0.0),
710 size: Vec2::new(50.0, 50.0),
711 },
712 FocusableArea {
713 entity: node_b,
714 position: Vec2::new(50.0, 0.0),
715 size: Vec2::new(50.0, 50.0),
716 }, // Closer
717 FocusableArea {
718 entity: node_c,
719 position: Vec2::new(100.0, 0.0),
720 size: Vec2::new(50.0, 50.0),
721 },
722 ];
723
724 auto_generate_navigation_edges(&mut nav_map, &nodes, &config);
725
726 // The manual edge should be preserved, even though B is closer
727 assert_eq!(
728 nav_map.get_neighbor(node_a, CompassOctant::East),
729 Some(node_c)
730 );
731 }
732
733 #[test]
734 fn test_edge_distance_vs_center_distance() {
735 let mut nav_map = DirectionalNavigationMap::default();
736 let config = AutoNavigationConfig::default();
737
738 let left = Entity::from_bits(1);
739 let wide_top = Entity::from_bits(2);
740 let bottom = Entity::from_bits(3);
741
742 let left_node = FocusableArea {
743 entity: left,
744 position: Vec2::new(100.0, 200.0),
745 size: Vec2::new(100.0, 100.0),
746 };
747
748 let wide_top_node = FocusableArea {
749 entity: wide_top,
750 position: Vec2::new(350.0, 150.0),
751 size: Vec2::new(300.0, 80.0),
752 };
753
754 let bottom_node = FocusableArea {
755 entity: bottom,
756 position: Vec2::new(270.0, 300.0),
757 size: Vec2::new(100.0, 80.0),
758 };
759
760 let nodes = vec![left_node, wide_top_node, bottom_node];
761
762 auto_generate_navigation_edges(&mut nav_map, &nodes, &config);
763
764 assert_eq!(
765 nav_map.get_neighbor(left, CompassOctant::East),
766 Some(wide_top),
767 "Should navigate to wide_top not bottom, even though bottom's center is closer."
768 );
769 }
770}