Skip to main content

fret_ui_headless/
cmdk_selection.rs

1//! Headless cmdk-style "active option" selection helpers.
2//!
3//! This is intentionally small and deterministic: it only provides index math for keeping focus in
4//! the input field while moving a highlighted row in a results list.
5
6/// Returns the next active index given the current active index, disabled flags, and direction.
7///
8/// - When `current` is `None`, this picks the first/last enabled item depending on `forward`.
9/// - When `wrap` is `false`, reaching an edge keeps the current index (if valid).
10/// - If every item is disabled, returns `None`.
11pub fn next_active_index(
12    disabled: &[bool],
13    current: Option<usize>,
14    forward: bool,
15    wrap: bool,
16) -> Option<usize> {
17    let len = disabled.len();
18    if len == 0 {
19        return None;
20    }
21
22    let is_enabled = |idx: usize| disabled.get(idx).copied() == Some(false);
23    let first = disabled.iter().position(|d| !*d)?;
24    let last = disabled.iter().rposition(|d| !*d)?;
25
26    let Some(current) = current.filter(|&i| i < len && is_enabled(i)) else {
27        return Some(if forward { first } else { last });
28    };
29
30    if wrap {
31        for step in 1..=len {
32            let idx = if forward {
33                (current + step) % len
34            } else {
35                (current + len - (step % len)) % len
36            };
37            if is_enabled(idx) {
38                return Some(idx);
39            }
40        }
41        None
42    } else if forward {
43        ((current + 1)..len)
44            .find(|&i| is_enabled(i))
45            .or(Some(current))
46    } else if current > 0 {
47        (0..current)
48            .rev()
49            .find(|&i| is_enabled(i))
50            .or(Some(current))
51    } else {
52        Some(current)
53    }
54}
55
56/// Clamps an active index to a valid, enabled index.
57///
58/// If `current` is out of range or disabled, this falls back to the first enabled item.
59pub fn clamp_active_index(disabled: &[bool], current: Option<usize>) -> Option<usize> {
60    if let Some(current) = current
61        && disabled.get(current).copied() == Some(false)
62    {
63        return Some(current);
64    }
65    disabled.iter().position(|d| !*d)
66}
67
68/// Returns the first enabled index, or `None` if all items are disabled.
69pub fn first_enabled(disabled: &[bool]) -> Option<usize> {
70    disabled.iter().position(|d| !*d)
71}
72
73/// Returns the last enabled index, or `None` if all items are disabled.
74pub fn last_enabled(disabled: &[bool]) -> Option<usize> {
75    disabled.iter().rposition(|d| !*d)
76}
77
78/// Moves the active index by `amount` steps, skipping disabled items.
79///
80/// This is a convenience wrapper around `next_active_index` for PageUp/PageDown style navigation.
81pub fn advance_active_index(
82    disabled: &[bool],
83    current: Option<usize>,
84    forward: bool,
85    wrap: bool,
86    amount: usize,
87) -> Option<usize> {
88    let mut cur = clamp_active_index(disabled, current);
89    if amount == 0 {
90        return cur;
91    }
92
93    for _ in 0..amount {
94        let next = next_active_index(disabled, cur, forward, wrap);
95        if next == cur {
96            break;
97        }
98        cur = next;
99    }
100    cur
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn next_picks_first_or_last_when_none() {
109        let disabled = [true, false, false];
110        assert_eq!(next_active_index(&disabled, None, true, true), Some(1));
111        assert_eq!(next_active_index(&disabled, None, false, true), Some(2));
112    }
113
114    #[test]
115    fn next_wraps_and_skips_disabled() {
116        let disabled = [false, true, false];
117        assert_eq!(next_active_index(&disabled, Some(0), true, true), Some(2));
118        assert_eq!(next_active_index(&disabled, Some(2), true, true), Some(0));
119        assert_eq!(next_active_index(&disabled, Some(0), false, true), Some(2));
120    }
121
122    #[test]
123    fn next_does_not_wrap_and_clamps_to_edges() {
124        let disabled = [false, true, false];
125        assert_eq!(next_active_index(&disabled, Some(0), false, false), Some(0));
126        assert_eq!(next_active_index(&disabled, Some(2), true, false), Some(2));
127        assert_eq!(next_active_index(&disabled, Some(0), true, false), Some(2));
128    }
129
130    #[test]
131    fn clamp_falls_back_to_first_enabled() {
132        let disabled = [true, false, true];
133        assert_eq!(clamp_active_index(&disabled, Some(0)), Some(1));
134        assert_eq!(clamp_active_index(&disabled, Some(2)), Some(1));
135        assert_eq!(clamp_active_index(&disabled, None), Some(1));
136    }
137
138    #[test]
139    fn all_disabled_returns_none() {
140        let disabled = [true, true];
141        assert_eq!(next_active_index(&disabled, None, true, true), None);
142        assert_eq!(clamp_active_index(&disabled, Some(0)), None);
143    }
144
145    #[test]
146    fn first_and_last_enabled_work() {
147        let disabled = [true, false, true, false];
148        assert_eq!(first_enabled(&disabled), Some(1));
149        assert_eq!(last_enabled(&disabled), Some(3));
150        assert_eq!(first_enabled(&[true, true]), None);
151        assert_eq!(last_enabled(&[true, true]), None);
152    }
153
154    #[test]
155    fn advance_moves_multiple_steps() {
156        let disabled = [false, true, false, false];
157        assert_eq!(
158            advance_active_index(&disabled, Some(0), true, true, 2),
159            Some(3)
160        );
161        assert_eq!(
162            advance_active_index(&disabled, Some(3), false, true, 3),
163            Some(3)
164        );
165    }
166}