Skip to main content

repame_ui/
nav.rs

1//! Focus traversal over kit controls, built on repose's own focus machinery.
2//!
3//! Repose already implements spatial traversal (`repose_core::runtime::
4//! spatial_focus_next`, the function behind `focus.rs`'s action handling),
5//! `FocusRequester` with the platform's request queue and capture lock, and
6//! focus groups. What the games are missing is three things this module
7//! supplies, without re-implementing any of it:
8//!
9//! 1. **A layout to traverse.** [`NavMap`] collects the controls the kit
10//!    composes — their view ids, design-space rectangles and focus groups — from
11//!    `Modifier::on_globally_positioned`, i.e. from the layout pass that already
12//!    hit-tests pointers. No second hit test, no second rectangle. The layout
13//!    pass reports a rectangle before the paint pass assigns the control's view
14//!    id, so each control's first rectangle is held for one frame and bound to
15//!    its id by [`NavMap::register`]; controls are navigable from their second
16//!    frame on.
17//! 2. **An entry point.** Nothing ever moves focus *to* a control in the
18//!    template games: the overlay root asks for focus for itself and keeps it.
19//!    [`NavDriver::enter`] hands focus to the first control when nothing in the
20//!    map is focused.
21//! 3. **Edge behaviour and analog input.** [`Wrap`] decides whether a
22//!    directional press at the layout edge wraps around the panel or stays put;
23//!    [`StickNav`] turns an analog stick into discrete presses with a repeat
24//!    delay, reusing `repame_input`'s dead zone.
25//!
26//! Focus moves by *requesting* it (`FocusRequester::try_request_focus`), so the
27//! platform's capture lock keeps working: a modal that captured focus keeps it.
28//! Keyboard (Tab/arrows, Space/Enter to activate) and gamepad D-pad/buttons
29//! already reach a focused control through the platform's key dispatch; analog
30//! sticks do not, which is why [`StickNav`] exists.
31//!
32//! # The frame
33//!
34//! ```ignore
35//! // Once per frame, before composing:
36//! kit.begin_frame();
37//! // Compose panels. Every kit button registers itself while composing, and the
38//! // layout pass that follows fills in its rectangle.
39//! let root = overlay_root((hud, panel));
40//! // Traversal, anywhere in the frame: it resolves against the controls of the
41//! // last frame that were composed (this frame's, once the panels are built).
42//! let driver = kit.driver();
43//! driver.enter(sched);
44//! for dir in dpad_dirs(&pad).into_iter().chain(stick.step(pad.left_stick, dt)) {
45//!     driver.drive(sched, dir);
46//! }
47//! ```
48//!
49//! Nothing in the kit touches `Scheduler::focused` directly: the request goes
50//! on the platform's focus queue, which the platform drains at the end of the
51//! frame's layout, so the focused control is the one the player sees that frame.
52
53use std::cell::RefCell;
54use std::collections::HashMap;
55use std::rc::Rc;
56
57use glam::Vec2;
58use repose_core::Rect;
59use repose_core::prelude::{FocusDirection, FocusRequester, Scheduler};
60use repose_core::runtime::{FocusManager, HitRegion, spatial_focus_next};
61
62/// A traversal request in kit terms.
63#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
64pub enum NavDir {
65    /// Next control in chain order: Tab.
66    Next,
67    /// Previous control in chain order: Shift-Tab.
68    Previous,
69    Left,
70    Right,
71    Up,
72    Down,
73}
74
75impl NavDir {
76    /// Every direction, chain order first.
77    pub const ALL: [NavDir; 6] = [
78        NavDir::Next,
79        NavDir::Previous,
80        NavDir::Left,
81        NavDir::Right,
82        NavDir::Up,
83        NavDir::Down,
84    ];
85
86    /// The repose focus direction this request runs as.
87    pub fn focus_direction(self) -> FocusDirection {
88        match self {
89            NavDir::Next => FocusDirection::Next,
90            NavDir::Previous => FocusDirection::Previous,
91            NavDir::Left => FocusDirection::Left,
92            NavDir::Right => FocusDirection::Right,
93            NavDir::Up => FocusDirection::Up,
94            NavDir::Down => FocusDirection::Down,
95        }
96    }
97
98    /// The opposite direction.
99    pub fn reverse(self) -> Self {
100        match self {
101            NavDir::Next => NavDir::Previous,
102            NavDir::Previous => NavDir::Next,
103            NavDir::Left => NavDir::Right,
104            NavDir::Right => NavDir::Left,
105            NavDir::Up => NavDir::Down,
106            NavDir::Down => NavDir::Up,
107        }
108    }
109
110    /// Whether the direction moves along the chain rather than through the
111    /// layout.
112    pub fn is_chain(self) -> bool {
113        matches!(self, NavDir::Next | NavDir::Previous)
114    }
115
116    /// The axis a wrapped press travels along, and its sign.
117    fn axis(self) -> Option<(Axis, f32)> {
118        match self {
119            NavDir::Left => Some((Axis::X, -1.0)),
120            NavDir::Right => Some((Axis::X, 1.0)),
121            NavDir::Up => Some((Axis::Y, -1.0)),
122            NavDir::Down => Some((Axis::Y, 1.0)),
123            NavDir::Next | NavDir::Previous => None,
124        }
125    }
126}
127
128#[derive(Clone, Copy, Debug, PartialEq, Eq)]
129enum Axis {
130    X,
131    Y,
132}
133
134/// A focus group: controls that traversal stays inside once one of them is
135/// focused.
136///
137/// This mirrors repose's `focus_group_id` (the same field travels in the hit
138/// regions handed to `spatial_focus_next`), so every control of a modal panel
139/// carrying one group traps focus in that panel without interception logic.
140/// Tag groups by name so behaviour does not depend on build order.
141#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
142pub struct GroupId(u64);
143
144impl GroupId {
145    /// A group id derived from a stable name: FNV-1a over the tag's bytes.
146    pub fn named(tag: &str) -> Self {
147        let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
148        for byte in tag.as_bytes() {
149            hash ^= u64::from(*byte);
150            hash = hash.wrapping_mul(0x0000_0100_0000_01B3);
151        }
152        Self(hash)
153    }
154
155    /// A group id from a value the game already has.
156    pub const fn from_raw(raw: u64) -> Self {
157        Self(raw)
158    }
159
160    pub const fn raw(self) -> u64 {
161        self.0
162    }
163}
164
165/// What happens at the layout edge when a directional press finds nothing.
166#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
167pub enum Wrap {
168    /// Focus stays put: the panel reads as a bounded box.
169    Stay,
170    /// Focus wraps around the panel, keeping the direction of travel (right at
171    /// the right edge lands on the leftmost control of the nearest row).
172    #[default]
173    Cycle,
174}
175
176/// What a traversal request did.
177#[derive(Clone, Copy, Debug, PartialEq, Eq)]
178pub enum NavStep {
179    /// Focus moved to this control.
180    Moved(u64),
181    /// Nothing was focused, so the first control took focus: the entry point.
182    Entered(u64),
183    /// Focus was already on a control of this map.
184    Already(u64),
185    /// The press found nowhere to go: a layout edge under [`Wrap::Stay`], or a
186    /// panel with a single control.
187    Edge,
188    /// The platform refused the request: another layer captured focus.
189    Held,
190    /// No control is in the map.
191    Empty,
192}
193
194impl NavStep {
195    /// The control this step ended on, when it ended on one.
196    pub fn target(self) -> Option<u64> {
197        match self {
198            NavStep::Moved(id) | NavStep::Entered(id) | NavStep::Already(id) => Some(id),
199            NavStep::Edge | NavStep::Held | NavStep::Empty => None,
200        }
201    }
202}
203
204/// One focusable control as the layout pass placed it.
205#[derive(Clone)]
206pub struct NavEntry {
207    id: u64,
208    rect: Rect,
209    group: Option<GroupId>,
210    requester: FocusRequester,
211}
212
213impl NavEntry {
214    /// The control's view id: the same id repose puts in its focus chain.
215    pub fn id(&self) -> u64 {
216        self.id
217    }
218
219    /// The control's rectangle in design units.
220    pub fn rect(&self) -> Rect {
221        self.rect
222    }
223
224    pub fn group(&self) -> Option<GroupId> {
225        self.group
226    }
227
228    /// Ask the platform for this control's focus.
229    pub fn request_focus(&self) -> bool {
230        self.requester.try_request_focus()
231    }
232}
233
234struct Entry {
235    rect: Option<Rect>,
236    group: Option<GroupId>,
237    requester: FocusRequester,
238    /// Frame stamp: an entry stays alive by being marked or placed again.
239    frame: u64,
240}
241
242/// A rectangle observed before its control had a view id.
243///
244/// The paint pass reports `on_globally_positioned` before `set_focus_requester`
245/// assigns the control's view id, so a control's very first observation arrives
246/// anonymous. It is held until the control registers, keyed by the
247/// `FocusRequester` the control owns: one allocation for the life of the
248/// control, so it identifies it across frames. The focus group is not carried
249/// here: registration supplies it.
250struct Pending {
251    key: usize,
252    rect: Rect,
253}
254
255/// How many anonymous rectangles are held. A control that never registers (its
256/// panel closed before the paint pass bound it) would otherwise hold one
257/// forever, and a broken game could push one per frame.
258const PENDING_CAP: usize = 256;
259
260/// Identity of a control's focus requester, stable across frames.
261fn requester_key(requester: &FocusRequester) -> usize {
262    Rc::as_ptr(&requester.target) as usize
263}
264
265/// A capture this map took, kept past the entry: the platform's lock is global
266/// and outlives the control, so releasing it cannot need the entry to be live.
267struct Capture {
268    id: u64,
269    requester: FocusRequester,
270}
271
272#[derive(Default)]
273struct Registry {
274    frame: u64,
275    order: Vec<u64>,
276    entries: HashMap<u64, Entry>,
277    pending: Vec<Pending>,
278    capture: Option<Capture>,
279}
280
281/// The controls the kit composed this frame, with their laid-out rectangles.
282///
283/// Clone the map into every control: it is one shared registry behind an `Rc`.
284/// The game calls [`NavMap::begin_frame`] once per frame before composing, which
285/// retires controls that were not recomposed: a panel stays navigable through
286/// the frame after its last composition, so traversal made before composing
287/// still finds the layout the player was looking at, and leaves the map at the
288/// `begin_frame` after that.
289///
290/// A control registers itself while composing ([`NavMap::register`]) and reports
291/// its rectangle from the layout callback ([`NavMap::place`]). Traversal
292/// resolves against whatever is registered.
293#[derive(Clone, Default)]
294pub struct NavMap {
295    registry: Rc<RefCell<Registry>>,
296}
297
298impl NavMap {
299    pub fn new() -> Self {
300        Self::default()
301    }
302
303    /// Start a frame: every control must be marked or placed again to stay in
304    /// the map. Call before composing the panel.
305    pub fn begin_frame(&self) {
306        let mut registry = self.registry.borrow_mut();
307        registry.frame += 1;
308        let frame = registry.frame;
309        registry.entries.retain(|_, entry| entry.frame + 1 >= frame);
310        let mut order = std::mem::take(&mut registry.order);
311        order.retain(|id| registry.entries.contains_key(id));
312        registry.order = order;
313    }
314
315    /// Forget every control, including the ones marked this frame.
316    pub fn clear(&self) {
317        let mut registry = self.registry.borrow_mut();
318        registry.entries.clear();
319        registry.order.clear();
320        registry.pending.clear();
321    }
322
323    /// Register a control for this frame, deriving its view id from its focus
324    /// requester.
325    ///
326    /// This is what a kit control calls while composing. It returns `false` on
327    /// the control's first frame, before the first layout pass has assigned the
328    /// id; the rectangle that layout reported is kept until the control
329    /// registers with an id, so a control becomes navigable as soon as its id
330    /// arrives, whether or not the panel composed in the frames between.
331    pub fn register(&self, requester: &FocusRequester, group: Option<GroupId>) -> bool {
332        let Some(id) = *requester.target.borrow() else {
333            return false;
334        };
335        let mut registry = self.registry.borrow_mut();
336        let frame = registry.frame;
337        let key = requester_key(requester);
338        let rect = match registry.pending.iter().position(|pending| pending.key == key) {
339            Some(index) => Some(registry.pending.remove(index).rect),
340            None => None,
341        };
342        match registry.entries.get_mut(&id) {
343            Some(entry) => {
344                entry.group = group;
345                entry.requester = requester.clone();
346                entry.frame = frame;
347                if let Some(rect) = rect {
348                    entry.rect = Some(rect);
349                }
350            }
351            None => {
352                registry.order.push(id);
353                registry.entries.insert(
354                    id,
355                    Entry {
356                        rect,
357                        group,
358                        requester: requester.clone(),
359                        frame,
360                    },
361                );
362            }
363        }
364        true
365    }
366
367    /// Mark a control whose view id the caller already has.
368    ///
369    /// Kit controls go through [`register`](Self::register); this is for a game
370    /// that composes its own control and reads the id from the layout.
371    pub fn mark(&self, id: u64, group: Option<GroupId>, requester: FocusRequester) {
372        let mut registry = self.registry.borrow_mut();
373        let frame = registry.frame;
374        match registry.entries.get_mut(&id) {
375            Some(entry) => {
376                entry.group = group;
377                entry.requester = requester;
378                entry.frame = frame;
379            }
380            None => {
381                registry.order.push(id);
382                registry.entries.insert(
383                    id,
384                    Entry {
385                        rect: None,
386                        group,
387                        requester,
388                        frame,
389                    },
390                );
391            }
392        }
393    }
394
395    /// Record a control's laid-out rectangle, from
396    /// `Modifier::on_globally_positioned`.
397    ///
398    /// A rectangle that arrives before the control's view id is known is held
399    /// for it until the control registers with that id; after that, the callback
400    /// only fires when the rectangle changes, so this is a cheap update. A
401    /// `group` of `None` leaves the group the control already has alone: a
402    /// caller that only knows the rectangle must not clear a modal trap.
403    /// Returns whether the rectangle was recorded against a control.
404    pub fn place(&self, requester: &FocusRequester, rect: Rect, group: Option<GroupId>) -> bool {
405        let mut registry = self.registry.borrow_mut();
406        let frame = registry.frame;
407        let Some(id) = *requester.target.borrow() else {
408            let key = requester_key(requester);
409            match registry.pending.iter_mut().find(|pending| pending.key == key) {
410                Some(pending) => pending.rect = rect,
411                None => {
412                    if registry.pending.len() >= PENDING_CAP {
413                        registry.pending.remove(0);
414                    }
415                    registry.pending.push(Pending { key, rect });
416                }
417            }
418            return false;
419        };
420        match registry.entries.get_mut(&id) {
421            Some(entry) => {
422                entry.rect = Some(rect);
423                if group.is_some() {
424                    entry.group = group;
425                }
426                entry.frame = frame;
427            }
428            None => {
429                registry.order.push(id);
430                registry.entries.insert(
431                    id,
432                    Entry {
433                        rect: Some(rect),
434                        group,
435                        requester: requester.clone(),
436                        frame,
437                    },
438                );
439            }
440        }
441        let key = requester_key(requester);
442        registry.pending.retain(|pending| pending.key != key);
443        true
444    }
445
446    /// Controls composed and laid out this frame, in composition order.
447    pub fn active(&self) -> Vec<NavEntry> {
448        let registry = self.registry.borrow();
449        registry
450            .order
451            .iter()
452            .filter_map(|id| {
453                let entry = registry.entries.get(id)?;
454                Some(NavEntry {
455                    id: *id,
456                    rect: entry.rect?,
457                    group: entry.group,
458                    requester: entry.requester.clone(),
459                })
460            })
461            .collect()
462    }
463
464    /// The first control in composition order.
465    pub fn first(&self) -> Option<u64> {
466        self.active().first().map(NavEntry::id)
467    }
468
469    pub fn len(&self) -> usize {
470        self.active().len()
471    }
472
473    pub fn is_empty(&self) -> bool {
474        self.active().is_empty()
475    }
476
477    /// Whether `id` is a control on screen this frame.
478    pub fn contains(&self, id: u64) -> bool {
479        self.active().iter().any(|entry| entry.id() == id)
480    }
481
482    /// Ask the platform to focus `id`. `false` when there is no such control or
483    /// the request was refused (captured focus, not laid out yet).
484    pub fn request(&self, id: u64) -> bool {
485        self.entry(id).is_some_and(|entry| entry.request_focus())
486    }
487
488    /// Pin focus to `id` until [`release`](Self::release): what a modal panel
489    /// does when it opens, so traversal cannot escape to what is underneath.
490    ///
491    /// The requester is remembered with the capture, so the release does not
492    /// depend on the control still being in the map.
493    pub fn capture(&self, id: u64) -> bool {
494        let Some(entry) = self.entry(id) else {
495            return false;
496        };
497        let requester = entry.requester;
498        if !requester.capture_focus() {
499            return false;
500        }
501        self.registry.borrow_mut().capture = Some(Capture { id, requester });
502        true
503    }
504
505    /// Release a capture taken by [`capture`](Self::capture).
506    ///
507    /// Focus is cleared, so the caller asks for the next panel's focus with
508    /// [`NavDriver::enter`] once the modal is gone. A capture the map took is
509    /// freed even after its control left the map, because the platform's lock
510    /// would otherwise pin focus to an id nothing can request again.
511    pub fn release(&self, id: u64) -> bool {
512        let mut registry = self.registry.borrow_mut();
513        if registry.capture.as_ref().is_some_and(|capture| capture.id == id) {
514            let capture = registry.capture.take();
515            if let Some(capture) = capture {
516                capture.requester.free_focus();
517            }
518            return true;
519        }
520        match registry.entries.get(&id) {
521            Some(entry) if entry.rect.is_some() => {
522                entry.requester.free_focus();
523                true
524            }
525            _ => false,
526        }
527    }
528
529    /// Free a capture this map took for a control that has left it: the lock
530    /// would otherwise refuse every later request. Returns whether a stale lock
531    /// was cleared.
532    fn release_stale_capture(&self) -> bool {
533        let mut registry = self.registry.borrow_mut();
534        let Some(capture) = registry.capture.as_ref() else {
535            return false;
536        };
537        let id = capture.id;
538        if registry
539            .entries
540            .get(&id)
541            .is_some_and(|entry| entry.rect.is_some())
542        {
543            return false;
544        }
545        let capture = registry.capture.take();
546        let Some(capture) = capture else {
547            return false;
548        };
549        if repose_core::runtime::focus_capture() == Some(id) {
550            capture.requester.free_focus();
551            return true;
552        }
553        false
554    }
555
556    fn entry(&self, id: u64) -> Option<NavEntry> {
557        self.active().into_iter().find(|entry| entry.id == id)
558    }
559
560    /// Resolve one traversal request.
561    pub fn resolve(&self, from: Option<u64>, dir: NavDir, wrap: Wrap) -> NavStep {
562        let entries = self.active();
563        if entries.is_empty() {
564            return NavStep::Empty;
565        }
566
567        // No focus to move from, or focus outside the map (a root that grabbed
568        // it, a control of a panel that just closed): enter at the first control
569        // instead of doing nothing.
570        let Some(current) = from.filter(|id| entries.iter().any(|entry| entry.id == *id)) else {
571            return NavStep::Entered(entries[0].id);
572        };
573
574        let (chain, regions) = regions_of(&entries);
575        let step = |next: u64| {
576            if next == current {
577                NavStep::Edge
578            } else {
579                NavStep::Moved(next)
580            }
581        };
582        if let Some(next) = spatial_focus_next(&chain, &regions, Some(current), dir.focus_direction())
583        {
584            return step(next);
585        }
586        if dir.is_chain() {
587            // Chain order already wraps; reaching here means the chain holds one
588            // control.
589            return NavStep::Edge;
590        }
591        match wrap {
592            Wrap::Stay => NavStep::Edge,
593            Wrap::Cycle => self.wrapped(&entries, current, dir).map_or(NavStep::Edge, step),
594        }
595    }
596
597    /// Retry a directional press as if the panel repeated past the edge.
598    ///
599    /// The candidates that are *not* ahead of the focus in the direction of
600    /// travel are translated across the panel by its span, so right at the right
601    /// edge sees the leftmost controls of the nearest row through the same
602    /// spatial scoring and perpendicular tolerance the unwrapped pass uses. The
603    /// focused control's own rectangle is the reference the scoring measures
604    /// from, so it is left where it is.
605    ///
606    /// If the unwrapped pass found nothing, no candidate was in the direction
607    /// *and* inside the perpendicular band, so translating the ones that are
608    /// behind cannot hide a nearer candidate.
609    fn wrapped(&self, entries: &[NavEntry], current: u64, dir: NavDir) -> Option<u64> {
610        let (axis, sign) = dir.axis()?;
611        let span = self.span(entries, axis);
612        if span <= 0.0 {
613            return None;
614        }
615        let (chain, mut regions) = regions_of(entries);
616        for (region, entry) in regions.iter_mut().zip(entries) {
617            if entry.id != current && !is_ahead(entry.rect, entries, current, axis, sign) {
618                match axis {
619                    Axis::X => region.rect.x += sign * span,
620                    Axis::Y => region.rect.y += sign * span,
621                }
622            }
623        }
624        spatial_focus_next(&chain, &regions, Some(current), dir.focus_direction())
625    }
626
627    /// Extent of the panel along `axis`.
628    fn span(&self, entries: &[NavEntry], axis: Axis) -> f32 {
629        let (min, max) = match axis {
630            Axis::X => (
631                entries.iter().map(|entry| entry.rect.x).fold(f32::INFINITY, f32::min),
632                entries
633                    .iter()
634                    .map(|entry| entry.rect.x + entry.rect.w)
635                    .fold(f32::NEG_INFINITY, f32::max),
636            ),
637            Axis::Y => (
638                entries.iter().map(|entry| entry.rect.y).fold(f32::INFINITY, f32::min),
639                entries
640                    .iter()
641                    .map(|entry| entry.rect.y + entry.rect.h)
642                    .fold(f32::NEG_INFINITY, f32::max),
643            ),
644        };
645        max - min
646    }
647}
648
649fn regions_of(entries: &[NavEntry]) -> (Vec<u64>, Vec<HitRegion>) {
650    let chain = entries.iter().map(NavEntry::id).collect();
651    let regions = entries
652        .iter()
653        .map(|entry| HitRegion {
654            id: entry.id,
655            rect: entry.rect,
656            focusable: true,
657            focus_group_id: entry.group.map(GroupId::raw),
658            ..HitRegion::default()
659        })
660        .collect();
661    (chain, regions)
662}
663
664/// Whether `rect` sits ahead of the focused control in the direction of travel,
665/// by centre and along the travel axis.
666fn is_ahead(rect: Rect, entries: &[NavEntry], current: u64, axis: Axis, sign: f32) -> bool {
667    let Some(focused) = entries.iter().find(|entry| entry.id == current) else {
668        return false;
669    };
670    let (ahead, focus) = match axis {
671        Axis::X => (rect.x + rect.w / 2.0, focused.rect.x + focused.rect.w / 2.0),
672        Axis::Y => (rect.y + rect.h / 2.0, focused.rect.y + focused.rect.h / 2.0),
673    };
674    (ahead - focus) * sign > 0.0
675}
676
677/// Focus traversal paired with a map and a wrap policy: what a game's frame
678/// calls once per nav press.
679#[derive(Clone)]
680pub struct NavDriver {
681    map: NavMap,
682    wrap: Wrap,
683}
684
685impl NavDriver {
686    pub fn new(map: NavMap) -> Self {
687        Self {
688            map,
689            wrap: Wrap::default(),
690        }
691    }
692
693    pub fn with_wrap(mut self, wrap: Wrap) -> Self {
694        self.wrap = wrap;
695        self
696    }
697
698    pub fn wrap(&self) -> Wrap {
699        self.wrap
700    }
701
702    pub fn map(&self) -> &NavMap {
703        &self.map
704    }
705
706    /// Resolve `dir` from the scheduler's current focus and request the move.
707    ///
708    /// The scheduler keeps its old focus until the platform drains the request
709    /// queue after this frame's layout, which is the same cadence keyboard focus
710    /// has, so one direction never resolves twice against a stale focus. A
711    /// refusal by a capture whose control has left the map is cleared and the
712    /// request retried: a lock nothing can release would otherwise refuse every
713    /// later move, and a pause menu could never take focus again.
714    pub fn drive(&self, sched: &Scheduler, dir: NavDir) -> NavStep {
715        let step = self.map.resolve(sched.focused, dir, self.wrap);
716        match step.target() {
717            Some(id) if self.map.request(id) => step,
718            Some(id) => {
719                if self.map.release_stale_capture() && self.map.request(id) {
720                    step
721                } else {
722                    NavStep::Held
723                }
724            }
725            None => step,
726        }
727    }
728
729    /// Enter the panel: pause, menu and modal screens call this on the frame
730    /// they open, and safely every frame after.
731    ///
732    /// Focus already on a control of the map is left alone
733    /// ([`NavStep::Already`]), so this never fights the player's own traversal.
734    pub fn enter(&self, sched: &Scheduler) -> NavStep {
735        if let Some(id) = sched.focused.filter(|id| self.map.contains(*id)) {
736            return NavStep::Already(id);
737        }
738        self.drive(sched, NavDir::Next)
739    }
740}
741
742/// Analog stick to discrete presses, with a first-press then repeat cadence.
743///
744/// Pads reach the platform as buttons, so the D-pad and the face buttons
745/// already work; the analog stick does not, and a menu navigated with a stick
746/// needs edges rather than a level. Deflection is read through
747/// [`repame_input::apply_stick`], so the kit's stick and the gameplay sticks
748/// share one dead zone.
749#[derive(Clone, Copy, Debug)]
750pub struct StickNav {
751    /// Deflection, after the dead zone, a stick must reach to count as a press.
752    threshold: f32,
753    /// Delay before the press repeats, in seconds.
754    first_delay: f32,
755    /// Delay between repeats, in seconds.
756    interval: f32,
757    held: Option<NavDir>,
758    timer: f32,
759}
760
761impl Default for StickNav {
762    fn default() -> Self {
763        Self::new()
764    }
765}
766
767impl StickNav {
768    pub fn new() -> Self {
769        Self {
770            threshold: 0.55,
771            first_delay: 0.35,
772            interval: 0.14,
773            held: None,
774            timer: 0.0,
775        }
776    }
777
778    /// Set the press threshold, the first-repeat delay and the repeat interval.
779    ///
780    /// The threshold is clamped to `0.05..=1.0` and the interval to a minimum of
781    /// one step at 240 Hz, so a mis-set value cannot make the stick emit twice
782    /// per frame.
783    pub fn with_cadence(mut self, threshold: f32, first_delay: f32, interval: f32) -> Self {
784        self.threshold = if threshold.is_finite() {
785            threshold.clamp(0.05, 1.0)
786        } else {
787            0.55
788        };
789        self.first_delay = if first_delay.is_finite() {
790            first_delay.max(0.0)
791        } else {
792            0.35
793        };
794        self.interval = if interval.is_finite() {
795            interval.max(1.0 / 240.0)
796        } else {
797            0.14
798        };
799        self
800    }
801
802    /// The direction the stick is holding down, if any.
803    pub fn held(&self) -> Option<NavDir> {
804        self.held
805    }
806
807    /// Forget the held direction: call when a panel opens or closes so the next
808    /// frame starts from a clean edge.
809    pub fn release(&mut self) {
810        self.held = None;
811        self.timer = 0.0;
812    }
813
814    /// Advance by `dt_secs` and return a press, if this frame produced one.
815    ///
816    /// `raw` is the stick's raw axis pair. A diagonal picks the dominant axis;
817    /// ties break horizontally.
818    pub fn step(&mut self, raw: Vec2, dt_secs: f32) -> Option<NavDir> {
819        let dir = direction_of(repame_input::apply_stick(raw), self.threshold);
820        match (self.held, dir) {
821            (Some(held), Some(dir)) if held == dir => {
822                self.timer -= dt_secs.max(0.0);
823                if self.timer > 0.0 {
824                    return None;
825                }
826                // Re-arm from now rather than banking presses, so a long frame
827                // moves focus once.
828                self.timer = self.interval;
829                Some(dir)
830            }
831            (_, None) => {
832                self.held = None;
833                self.timer = 0.0;
834                None
835            }
836            (_, Some(dir)) => {
837                self.held = Some(dir);
838                self.timer = self.first_delay;
839                Some(dir)
840            }
841        }
842    }
843}
844
845fn direction_of(stick: Vec2, threshold: f32) -> Option<NavDir> {
846    if stick.length() < threshold {
847        return None;
848    }
849    if stick.x.abs() >= stick.y.abs() {
850        Some(if stick.x > 0.0 { NavDir::Right } else { NavDir::Left })
851    } else {
852        Some(if stick.y > 0.0 { NavDir::Down } else { NavDir::Up })
853    }
854}
855
856/// The directions a pad's D-pad edges asked for this frame.
857pub fn dpad_dirs(pad: &repame_input::GamepadState) -> Vec<NavDir> {
858    let mut dirs = Vec::new();
859    if pad.dpad_left_pressed {
860        dirs.push(NavDir::Left);
861    }
862    if pad.dpad_right_pressed {
863        dirs.push(NavDir::Right);
864    }
865    if pad.dpad_up_pressed {
866        dirs.push(NavDir::Up);
867    }
868    if pad.dpad_down_pressed {
869        dirs.push(NavDir::Down);
870    }
871    dirs
872}
873
874/// Whether the pad's confirm button was pressed this frame.
875///
876/// The platform maps the south button to keyboard activation (Space), so a game
877/// that feeds its pad to the platform does not need this. A game driving its own
878/// pad state uses it to activate the focused control.
879pub fn pad_confirm(pad: &repame_input::GamepadState) -> bool {
880    pad.south_pressed
881}
882
883/// The focus chain a map would hand the platform, in order.
884pub fn chain_of(map: &NavMap) -> Vec<u64> {
885    map.active().iter().map(NavEntry::id).collect()
886}
887
888/// Whether `id` is inside the same focus group as `anchor`.
889pub fn same_group(map: &NavMap, anchor: u64, id: u64) -> bool {
890    let entries = map.active();
891    let group_of =
892        |id: u64| entries.iter().find(|entry| entry.id == id).and_then(NavEntry::group);
893    match (group_of(anchor), group_of(id)) {
894        (Some(a), Some(b)) => a == b,
895        _ => false,
896    }
897}
898
899/// The traversal repose would perform over a plain chain and rectangle list:
900/// the raw entry point, for a game with its own layout.
901pub fn resolve_in(
902    chain: &[u64],
903    regions: &[HitRegion],
904    from: Option<u64>,
905    dir: NavDir,
906) -> Option<u64> {
907    spatial_focus_next(chain, regions, from, dir.focus_direction())
908}
909
910/// Chain-order traversal with wrap, the rule repose's tab handling uses.
911pub fn chain_step(chain: &[u64], from: Option<u64>, dir: NavDir) -> Option<u64> {
912    if chain.is_empty() {
913        return None;
914    }
915    let mut manager = FocusManager::new(chain.to_vec(), from);
916    manager.move_tab(dir == NavDir::Previous)
917}
918
919#[cfg(test)]
920mod tests {
921    use super::*;
922
923    /// Three columns by two rows, 200 x 100 each, 10 apart.
924    fn grid() -> NavMap {
925        let map = NavMap::new();
926        map.begin_frame();
927        for row in 0..2u64 {
928            for column in 0..3u64 {
929                let id = row * 10 + column + 1;
930                let requester = FocusRequester::new();
931                *requester.target.borrow_mut() = Some(id);
932                map.mark(id, None, requester.clone());
933                map.place(
934                    &requester,
935                    Rect {
936                        x: column as f32 * 210.0,
937                        y: row as f32 * 110.0,
938                        w: 200.0,
939                        h: 100.0,
940                    },
941                    None,
942                );
943            }
944        }
945        map
946    }
947
948    fn ids(map: &NavMap) -> Vec<u64> {
949        chain_of(map)
950    }
951
952    fn drain_requests() {
953        while repose_core::runtime::take_focus_request().is_some() {}
954    }
955
956    #[test]
957    fn controls_are_navigable_in_composition_order() {
958        let map = grid();
959        assert_eq!(ids(&map), [1, 2, 3, 11, 12, 13]);
960        assert_eq!(map.first(), Some(1));
961        assert_eq!(map.len(), 6);
962    }
963
964    #[test]
965    fn nothing_focused_enters_at_the_first_control() {
966        let map = grid();
967        assert_eq!(map.resolve(None, NavDir::Right, Wrap::Stay), NavStep::Entered(1));
968        assert_eq!(map.resolve(None, NavDir::Next, Wrap::Stay), NavStep::Entered(1));
969        assert_eq!(map.resolve(None, NavDir::Up, Wrap::Stay), NavStep::Entered(1));
970    }
971
972    #[test]
973    fn focus_outside_the_map_enters_instead_of_stalling() {
974        let map = grid();
975        // 999 is the game's overlay root: focusable, in repose's chain, and not
976        // a kit control.
977        assert_eq!(
978            map.resolve(Some(999), NavDir::Right, Wrap::Stay),
979            NavStep::Entered(1)
980        );
981    }
982
983    #[test]
984    fn spatial_moves_follow_the_layout() {
985        let map = grid();
986        assert_eq!(map.resolve(Some(1), NavDir::Right, Wrap::Stay), NavStep::Moved(2));
987        assert_eq!(map.resolve(Some(2), NavDir::Right, Wrap::Stay), NavStep::Moved(3));
988        assert_eq!(map.resolve(Some(1), NavDir::Down, Wrap::Stay), NavStep::Moved(11));
989        assert_eq!(map.resolve(Some(12), NavDir::Up, Wrap::Stay), NavStep::Moved(2));
990        assert_eq!(map.resolve(Some(3), NavDir::Left, Wrap::Stay), NavStep::Moved(2));
991        assert_eq!(map.resolve(Some(11), NavDir::Right, Wrap::Stay), NavStep::Moved(12));
992    }
993
994    #[test]
995    fn the_layout_edge_stays_put_when_wrap_is_stay() {
996        let map = grid();
997        assert_eq!(map.resolve(Some(3), NavDir::Right, Wrap::Stay), NavStep::Edge);
998        assert_eq!(map.resolve(Some(1), NavDir::Left, Wrap::Stay), NavStep::Edge);
999        assert_eq!(map.resolve(Some(1), NavDir::Up, Wrap::Stay), NavStep::Edge);
1000        assert_eq!(map.resolve(Some(11), NavDir::Down, Wrap::Stay), NavStep::Edge);
1001    }
1002
1003    #[test]
1004    fn horizontal_wrap_stays_in_the_nearest_row() {
1005        let map = grid();
1006        assert_eq!(map.resolve(Some(3), NavDir::Right, Wrap::Cycle), NavStep::Moved(1));
1007        assert_eq!(map.resolve(Some(1), NavDir::Left, Wrap::Cycle), NavStep::Moved(3));
1008        assert_eq!(map.resolve(Some(13), NavDir::Right, Wrap::Cycle), NavStep::Moved(11));
1009        assert_eq!(map.resolve(Some(11), NavDir::Left, Wrap::Cycle), NavStep::Moved(13));
1010        // From the middle of a row the wrapped pass is not reached: the
1011        // neighbours are there.
1012        assert_eq!(map.resolve(Some(2), NavDir::Left, Wrap::Cycle), NavStep::Moved(1));
1013    }
1014
1015    #[test]
1016    fn vertical_wrap_stays_in_the_column() {
1017        let map = grid();
1018        assert_eq!(map.resolve(Some(1), NavDir::Up, Wrap::Cycle), NavStep::Moved(11));
1019        assert_eq!(map.resolve(Some(11), NavDir::Down, Wrap::Cycle), NavStep::Moved(1));
1020        assert_eq!(map.resolve(Some(13), NavDir::Down, Wrap::Cycle), NavStep::Moved(3));
1021        assert_eq!(map.resolve(Some(3), NavDir::Up, Wrap::Cycle), NavStep::Moved(13));
1022    }
1023
1024    #[test]
1025    fn a_single_control_has_nowhere_to_go() {
1026        let map = NavMap::new();
1027        map.begin_frame();
1028        let requester = FocusRequester::new();
1029        *requester.target.borrow_mut() = Some(7);
1030        map.mark(7, None, requester.clone());
1031        map.place(&requester, Rect { x: 0.0, y: 0.0, w: 100.0, h: 40.0 }, None);
1032        assert_eq!(map.resolve(Some(7), NavDir::Right, Wrap::Cycle), NavStep::Edge);
1033        assert_eq!(map.resolve(Some(7), NavDir::Down, Wrap::Cycle), NavStep::Edge);
1034        assert_eq!(map.resolve(Some(7), NavDir::Next, Wrap::Cycle), NavStep::Edge);
1035        assert_eq!(map.resolve(Some(7), NavDir::Previous, Wrap::Cycle), NavStep::Edge);
1036    }
1037
1038    #[test]
1039    fn chain_order_wraps_for_tab_and_shift_tab() {
1040        let map = grid();
1041        assert_eq!(map.resolve(Some(1), NavDir::Next, Wrap::Stay), NavStep::Moved(2));
1042        assert_eq!(map.resolve(Some(13), NavDir::Next, Wrap::Stay), NavStep::Moved(1));
1043        assert_eq!(map.resolve(Some(1), NavDir::Previous, Wrap::Stay), NavStep::Moved(13));
1044        assert_eq!(map.resolve(Some(2), NavDir::Previous, Wrap::Stay), NavStep::Moved(1));
1045    }
1046
1047    #[test]
1048    fn a_focus_group_traps_traversal() {
1049        let map = NavMap::new();
1050        map.begin_frame();
1051        let pause = GroupId::named("pause");
1052        // A HUD button outside the panel, then two panel buttons in a group.
1053        for (id, x, group) in [
1054            (1u64, 0.0f32, None),
1055            (2, 300.0, Some(pause)),
1056            (3, 600.0, Some(pause)),
1057        ] {
1058            let requester = FocusRequester::new();
1059            *requester.target.borrow_mut() = Some(id);
1060            map.mark(id, group, requester.clone());
1061            map.place(&requester, Rect { x, y: 0.0, w: 200.0, h: 100.0 }, group);
1062        }
1063        assert_eq!(map.resolve(Some(2), NavDir::Right, Wrap::Stay), NavStep::Moved(3));
1064        assert_eq!(
1065            map.resolve(Some(3), NavDir::Right, Wrap::Stay),
1066            NavStep::Edge,
1067            "focus does not escape the modal group"
1068        );
1069        assert_eq!(map.resolve(Some(3), NavDir::Next, Wrap::Stay), NavStep::Moved(2));
1070        assert!(same_group(&map, 2, 3));
1071        assert!(!same_group(&map, 1, 2));
1072    }
1073
1074    #[test]
1075    fn a_closed_panel_is_no_longer_navigable() {
1076        let map = grid();
1077        // The panel is not composed this frame: its layout is still the one the
1078        // player is looking at, so traversal made before composition finds it.
1079        map.begin_frame();
1080        let requester = FocusRequester::new();
1081        *requester.target.borrow_mut() = Some(50);
1082        map.mark(50, None, requester.clone());
1083        map.place(&requester, Rect { x: 0.0, y: 0.0, w: 40.0, h: 40.0 }, None);
1084        assert!(ids(&map).contains(&1), "the previous layout is still resolvable");
1085
1086        // A frame later the grid is two frames old and leaves the map.
1087        map.begin_frame();
1088        assert_eq!(ids(&map), [50]);
1089        assert_eq!(
1090            map.resolve(Some(1), NavDir::Right, Wrap::Stay),
1091            NavStep::Entered(50)
1092        );
1093    }
1094
1095    #[test]
1096    fn an_empty_map_reports_empty() {
1097        let map = NavMap::new();
1098        map.begin_frame();
1099        assert_eq!(map.resolve(None, NavDir::Next, Wrap::Cycle), NavStep::Empty);
1100        assert_eq!(map.len(), 0);
1101        assert!(map.is_empty());
1102        assert!(!map.request(1));
1103        assert!(!map.capture(1));
1104        assert!(!map.release(1));
1105    }
1106
1107    #[test]
1108    fn an_unbound_control_is_not_navigable() {
1109        let map = NavMap::new();
1110        map.begin_frame();
1111        let requester = FocusRequester::new();
1112        assert!(!map.register(&requester, None), "no view id yet");
1113        assert!(!map.place(&requester, Rect { x: 0.0, y: 0.0, w: 10.0, h: 10.0 }, None));
1114        assert!(map.is_empty(), "an unbound rectangle is not a control yet");
1115    }
1116
1117    #[test]
1118    fn the_first_rectangle_is_bound_when_the_view_id_arrives() {
1119        let map = NavMap::new();
1120        let requester = FocusRequester::new();
1121        let rect = Rect { x: 30.0, y: 40.0, w: 200.0, h: 60.0 };
1122
1123        // Frame 1: layout reports the rectangle before paint assigns the id.
1124        map.begin_frame();
1125        assert!(!map.place(&requester, rect, None));
1126        assert!(!map.register(&requester, None));
1127        assert!(map.is_empty());
1128
1129        // Frame 2: the id is known, and the observed rectangle comes with it.
1130        map.begin_frame();
1131        *requester.target.borrow_mut() = Some(4);
1132        assert!(map.place(&requester, rect, None));
1133        assert!(map.register(&requester, None));
1134        assert_eq!(ids(&map), [4]);
1135        assert_eq!(map.active()[0].rect(), rect);
1136
1137        // Frame 3: layout only reports a change, and registration keeps it.
1138        map.begin_frame();
1139        assert!(map.register(&requester, None));
1140        assert_eq!(map.active()[0].rect(), rect);
1141        let moved = Rect { x: 60.0, y: 40.0, w: 200.0, h: 60.0 };
1142        assert!(map.place(&requester, moved, None));
1143        assert_eq!(map.active()[0].rect(), moved);
1144    }
1145
1146    #[test]
1147    fn a_pending_rectangle_survives_the_frames_a_panel_skips() {
1148        let map = NavMap::new();
1149        let requester = FocusRequester::new();
1150        let rect = Rect { x: 10.0, y: 20.0, w: 30.0, h: 40.0 };
1151        map.begin_frame();
1152        assert!(!map.place(&requester, rect, None));
1153        // Two frames without the control, then its id: the rectangle observed
1154        // on its first frame is still the one bound to it, so a panel composed
1155        // on alternating frames is not left unreachable.
1156        map.begin_frame();
1157        map.begin_frame();
1158        *requester.target.borrow_mut() = Some(9);
1159        assert!(map.register(&requester, None));
1160        assert_eq!(ids(&map), [9]);
1161        assert_eq!(map.active()[0].rect(), rect, "the held rectangle was bound");
1162    }
1163
1164    #[test]
1165    fn a_place_without_a_group_keeps_the_one_the_control_has() {
1166        let map = NavMap::new();
1167        map.begin_frame();
1168        let pause = GroupId::named("pause");
1169        let requester = FocusRequester::new();
1170        *requester.target.borrow_mut() = Some(5);
1171        map.mark(5, Some(pause), requester.clone());
1172        map.place(&requester, Rect { x: 0.0, y: 0.0, w: 200.0, h: 40.0 }, None);
1173        assert_eq!(
1174            map.active()[0].group(),
1175            Some(pause),
1176            "a rectangle-only place must not clear the modal trap"
1177        );
1178    }
1179
1180    #[test]
1181    fn requesting_focus_queues_the_platform_request() {
1182        drain_requests();
1183        let map = grid();
1184        assert!(map.request(2));
1185        assert!(!map.request(99), "an unknown control is not requested");
1186        assert_eq!(
1187            repose_core::runtime::take_focus_request(),
1188            Some(2),
1189            "the request reaches the platform's queue"
1190        );
1191        drain_requests();
1192    }
1193
1194    #[test]
1195    fn a_captured_modal_refuses_other_targets() {
1196        drain_requests();
1197        let map = grid();
1198        assert!(map.capture(2));
1199        assert_eq!(map.resolve(Some(2), NavDir::Left, Wrap::Stay), NavStep::Moved(1));
1200        let driver = NavDriver::new(map.clone());
1201        assert_eq!(
1202            driver.drive(&scheduler_focused_on(2), NavDir::Left),
1203            NavStep::Held,
1204            "focus is pinned to the captured control"
1205        );
1206        assert!(map.release(2));
1207        drain_requests();
1208    }
1209
1210    #[test]
1211    fn a_capture_can_be_released_after_its_control_retires() {
1212        drain_requests();
1213        let map = NavMap::new();
1214        map.begin_frame();
1215        let modal = FocusRequester::new();
1216        *modal.target.borrow_mut() = Some(2);
1217        map.mark(2, None, modal.clone());
1218        map.place(&modal, Rect { x: 0.0, y: 0.0, w: 200.0, h: 40.0 }, None);
1219        assert!(map.capture(2));
1220        assert_eq!(repose_core::runtime::focus_capture(), Some(2));
1221
1222        // The modal closed before the release landed: the entry is gone, and
1223        // the platform's lock still points at it.
1224        map.begin_frame();
1225        map.begin_frame();
1226        assert!(map.is_empty(), "the panel retired");
1227        assert_eq!(repose_core::runtime::focus_capture(), Some(2));
1228
1229        assert!(
1230            map.release(2),
1231            "the remembered capture is freed with the entry gone"
1232        );
1233        assert_eq!(repose_core::runtime::focus_capture(), None);
1234        drain_requests();
1235    }
1236
1237    #[test]
1238    fn a_held_move_clears_a_capture_left_by_a_retired_control() {
1239        drain_requests();
1240        let map = NavMap::new();
1241        map.begin_frame();
1242        let modal = FocusRequester::new();
1243        *modal.target.borrow_mut() = Some(2);
1244        map.mark(2, None, modal.clone());
1245        map.place(&modal, Rect { x: 0.0, y: 0.0, w: 200.0, h: 40.0 }, None);
1246        assert!(map.capture(2));
1247
1248        map.begin_frame();
1249        map.begin_frame();
1250        let menu = FocusRequester::new();
1251        *menu.target.borrow_mut() = Some(7);
1252        map.mark(7, None, menu.clone());
1253        map.place(&menu, Rect { x: 300.0, y: 0.0, w: 200.0, h: 40.0 }, None);
1254
1255        // The pause menu asks for focus: without clearing the stale lock every
1256        // request would be refused Held for the rest of the session.
1257        let driver = NavDriver::new(map);
1258        assert_eq!(
1259            driver.enter(&Scheduler::default()),
1260            NavStep::Entered(7),
1261            "the stale lock is cleared and the request goes through"
1262        );
1263        assert_eq!(repose_core::runtime::focus_capture(), None);
1264        drain_requests();
1265    }
1266
1267    fn scheduler_focused_on(id: u64) -> Scheduler {
1268        let mut sched = Scheduler::default();
1269        sched.focused = Some(id);
1270        sched
1271    }
1272
1273    #[test]
1274    fn the_driver_enters_and_drives_against_a_live_scheduler() {
1275        drain_requests();
1276        let driver = NavDriver::new(grid()).with_wrap(Wrap::Stay);
1277        let mut sched = Scheduler::default();
1278
1279        assert_eq!(driver.enter(&sched), NavStep::Entered(1));
1280        // The platform applies the request at the next frame head.
1281        sched.focused = Some(1);
1282        assert_eq!(driver.enter(&sched), NavStep::Already(1));
1283        assert_eq!(driver.drive(&sched, NavDir::Right), NavStep::Moved(2));
1284
1285        sched.focused = Some(2);
1286        assert_eq!(driver.drive(&sched, NavDir::Down), NavStep::Moved(12));
1287
1288        sched.focused = Some(12);
1289        assert_eq!(driver.drive(&sched, NavDir::Left), NavStep::Moved(11));
1290        sched.focused = Some(11);
1291        assert_eq!(driver.drive(&sched, NavDir::Up), NavStep::Moved(1));
1292        assert_eq!(NavStep::Moved(1).target(), Some(1));
1293        assert_eq!(NavStep::Edge.target(), None);
1294        drain_requests();
1295    }
1296
1297    #[test]
1298    fn stick_nav_ignores_rest_and_small_deflection() {
1299        let mut nav = StickNav::new();
1300        assert_eq!(nav.step(Vec2::ZERO, 0.1), None);
1301        assert_eq!(nav.step(Vec2::new(0.15, 0.0), 0.1), None, "inside the dead zone");
1302        assert_eq!(nav.step(Vec2::new(0.35, 0.0), 0.1), None, "below the press threshold");
1303        assert_eq!(nav.held(), None);
1304    }
1305
1306    #[test]
1307    fn stick_nav_presses_once_then_repeats() {
1308        let mut nav = StickNav::new().with_cadence(0.5, 0.35, 0.14);
1309        assert_eq!(nav.step(Vec2::new(1.0, 0.0), 0.016), Some(NavDir::Right));
1310        for _ in 0..20 {
1311            assert_eq!(nav.step(Vec2::new(1.0, 0.0), 0.016), None);
1312        }
1313        assert_eq!(nav.step(Vec2::new(1.0, 0.0), 0.05), Some(NavDir::Right));
1314        assert_eq!(nav.step(Vec2::new(1.0, 0.0), 0.02), None);
1315        assert_eq!(nav.step(Vec2::new(1.0, 0.0), 0.14), Some(NavDir::Right));
1316        assert_eq!(nav.step(Vec2::ZERO, 0.5), None);
1317        assert_eq!(nav.held(), None);
1318        assert_eq!(nav.step(Vec2::new(1.0, 0.0), 0.016), Some(NavDir::Right));
1319    }
1320
1321    #[test]
1322    fn stick_nav_picks_a_side_and_reverses_on_change() {
1323        let mut nav = StickNav::new();
1324        assert_eq!(nav.step(Vec2::new(0.0, -1.0), 0.016), Some(NavDir::Up));
1325        assert_eq!(nav.step(Vec2::new(0.0, 1.0), 0.016), Some(NavDir::Down));
1326        assert_eq!(nav.step(Vec2::new(-1.0, 0.0), 0.016), Some(NavDir::Left));
1327        assert_eq!(nav.step(Vec2::new(1.0, 0.0), 0.016), Some(NavDir::Right));
1328        // Center the stick, or the next press is a repeat that is not due yet.
1329        assert_eq!(nav.step(Vec2::ZERO, 0.016), None);
1330        assert_eq!(nav.step(Vec2::new(1.0, 0.6), 0.016), Some(NavDir::Right));
1331        assert_eq!(nav.step(Vec2::ZERO, 0.016), None);
1332        assert_eq!(nav.step(Vec2::new(0.2, 1.0), 0.016), Some(NavDir::Down));
1333    }
1334
1335    #[test]
1336    fn stick_nav_release_forgets_the_held_direction() {
1337        let mut nav = StickNav::new();
1338        assert_eq!(nav.step(Vec2::new(1.0, 0.0), 0.016), Some(NavDir::Right));
1339        nav.release();
1340        assert_eq!(nav.held(), None);
1341        assert_eq!(nav.step(Vec2::new(1.0, 0.0), 0.016), Some(NavDir::Right));
1342    }
1343
1344    #[test]
1345    fn a_mis_set_cadence_is_clamped_into_a_sane_band() {
1346        let mut nav = StickNav::new().with_cadence(9.0, -5.0, 0.0);
1347        assert_eq!(nav.step(Vec2::new(1.0, 0.0), 0.0), Some(NavDir::Right));
1348        assert_eq!(
1349            nav.step(Vec2::new(1.0, 0.0), 0.0),
1350            Some(NavDir::Right),
1351            "the repeat interval clamps to one step at 240 Hz, so a held stick repeats"
1352        );
1353        assert_eq!(
1354            nav.step(Vec2::new(0.9, 0.0), 0.0),
1355            None,
1356            "the threshold clamps to full deflection"
1357        );
1358    }
1359
1360    #[test]
1361    fn dpad_edges_map_straight_through() {
1362        let mut pad = repame_input::GamepadState::default();
1363        assert!(dpad_dirs(&pad).is_empty());
1364        assert!(!pad_confirm(&pad));
1365        pad.dpad_left_pressed = true;
1366        pad.dpad_up_pressed = true;
1367        pad.south_pressed = true;
1368        assert_eq!(dpad_dirs(&pad), [NavDir::Left, NavDir::Up]);
1369        assert!(pad_confirm(&pad));
1370    }
1371
1372    #[test]
1373    fn group_ids_are_stable_and_name_derived() {
1374        assert_eq!(GroupId::named("pause"), GroupId::named("pause"));
1375        assert_ne!(GroupId::named("pause"), GroupId::named("death"));
1376        assert_eq!(GroupId::from_raw(7).raw(), 7);
1377        // FNV-1a of the empty string is the offset basis.
1378        assert_eq!(GroupId::named("").raw(), 0xcbf2_9ce4_8422_2325);
1379    }
1380
1381    #[test]
1382    fn directions_round_trip() {
1383        for dir in NavDir::ALL {
1384            assert_eq!(dir.reverse().reverse(), dir);
1385        }
1386        assert_eq!(NavDir::Next.reverse(), NavDir::Previous);
1387        assert_eq!(NavDir::Previous.reverse(), NavDir::Next);
1388        assert_eq!(NavDir::Left.reverse(), NavDir::Right);
1389        assert_eq!(NavDir::Up.reverse(), NavDir::Down);
1390        assert!(NavDir::Next.is_chain());
1391        assert!(!NavDir::Down.is_chain());
1392    }
1393
1394    #[test]
1395    fn the_raw_repose_helpers_are_usable_without_a_map() {
1396        let chain = [1, 2, 3];
1397        let regions: Vec<HitRegion> = (0..3)
1398            .map(|index| HitRegion {
1399                id: index + 1,
1400                rect: Rect {
1401                    x: index as f32 * 100.0,
1402                    y: 0.0,
1403                    w: 90.0,
1404                    h: 40.0,
1405                },
1406                focusable: true,
1407                ..HitRegion::default()
1408            })
1409            .collect();
1410        assert_eq!(resolve_in(&chain, &regions, Some(1), NavDir::Right), Some(2));
1411        assert_eq!(chain_step(&chain, Some(1), NavDir::Next), Some(2));
1412        assert_eq!(chain_step(&chain, Some(3), NavDir::Next), Some(1));
1413        assert_eq!(chain_step(&[], Some(1), NavDir::Next), None);
1414    }
1415}