Skip to main content

fret_ui_headless/
menu_nav.rs

1//! Menu/list navigation helpers (APG-aligned index math).
2//!
3//! This module is intentionally headless and deterministic: it provides index selection math for
4//! keyboard navigation and leaves event wiring / focus requests to the UI runtime.
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum NavAction {
8    Prev,
9    Next,
10    Home,
11    End,
12}
13
14pub fn next_enabled_index(
15    disabled: &[bool],
16    current: Option<usize>,
17    action: NavAction,
18    wrap: bool,
19) -> Option<usize> {
20    let len = disabled.len();
21    if len == 0 {
22        return None;
23    }
24
25    let is_disabled = |idx: usize| disabled.get(idx).copied().unwrap_or(false);
26    let first = || (0..len).find(|&i| !is_disabled(i));
27    let last = || (0..len).rev().find(|&i| !is_disabled(i));
28
29    match action {
30        NavAction::Home => first(),
31        NavAction::End => last(),
32        NavAction::Next => {
33            let Some(cur) = current else {
34                return first();
35            };
36            if cur >= len {
37                return first();
38            }
39            if wrap {
40                for step in 1..=len {
41                    let idx = (cur + step) % len;
42                    if !is_disabled(idx) {
43                        return Some(idx);
44                    }
45                }
46                None
47            } else {
48                ((cur + 1)..len).find(|&i| !is_disabled(i))
49            }
50        }
51        NavAction::Prev => {
52            let Some(cur) = current else {
53                return last();
54            };
55            if cur >= len {
56                return last();
57            }
58            if wrap {
59                for step in 1..=len {
60                    let idx = (cur + len - (step % len)) % len;
61                    if !is_disabled(idx) {
62                        return Some(idx);
63                    }
64                }
65                None
66            } else if cur > 0 {
67                (0..cur).rev().find(|&i| !is_disabled(i))
68            } else {
69                None
70            }
71        }
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn wraps_and_skips_disabled() {
81        let disabled = [false, true, false];
82        assert_eq!(
83            next_enabled_index(&disabled, Some(0), NavAction::Next, true),
84            Some(2)
85        );
86        assert_eq!(
87            next_enabled_index(&disabled, Some(2), NavAction::Next, true),
88            Some(0)
89        );
90        assert_eq!(
91            next_enabled_index(&disabled, Some(0), NavAction::Prev, true),
92            Some(2)
93        );
94    }
95
96    #[test]
97    fn non_wrapping_stops_at_edges() {
98        let disabled = [false, false, false];
99        assert_eq!(
100            next_enabled_index(&disabled, Some(2), NavAction::Next, false),
101            None
102        );
103        assert_eq!(
104            next_enabled_index(&disabled, Some(0), NavAction::Prev, false),
105            None
106        );
107    }
108
109    #[test]
110    fn home_end_pick_first_last_enabled() {
111        let disabled = [true, false, true, false];
112        assert_eq!(
113            next_enabled_index(&disabled, None, NavAction::Home, true),
114            Some(1)
115        );
116        assert_eq!(
117            next_enabled_index(&disabled, None, NavAction::End, true),
118            Some(3)
119        );
120    }
121}