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}