Skip to main content

rae/
state.rs

1#[cfg(feature = "serde")]
2use serde::{Deserialize, Serialize};
3
4use crate::sanitize::sanitize_str;
5
6#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
7#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
8#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
9pub enum PaneId {
10    #[default]
11    Main,
12    Sidebar,
13    Prompt,
14}
15
16impl PaneId {
17    pub fn next(self) -> Self {
18        match self {
19            Self::Main => Self::Sidebar,
20            Self::Sidebar => Self::Prompt,
21            Self::Prompt => Self::Main,
22        }
23    }
24}
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
28#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
29pub enum OverlayKind {
30    Help,
31    CommandPalette,
32    Search,
33    Inspector,
34    Settings,
35    ThemeEditor,
36}
37
38/// Direction used when traversing a renderer-owned focus order.
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
41#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
42pub enum FocusDirection {
43    Forward,
44    Backward,
45}
46
47/// A named focus stop in a renderer-neutral focus chain.
48#[derive(Clone, Debug, PartialEq, Eq)]
49#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
50pub struct FocusTarget {
51    pub id: String,
52    pub disabled: bool,
53}
54
55impl FocusTarget {
56    pub fn new(id: impl Into<String>) -> Self {
57        Self {
58            id: sanitize_str(&id.into(), 120),
59            disabled: false,
60        }
61    }
62
63    pub fn disabled(mut self, disabled: bool) -> Self {
64        self.disabled = disabled;
65        self
66    }
67}
68
69/// Ordered focus targets with wrapping traversal and disabled-item skipping.
70#[derive(Clone, Debug, Default, PartialEq, Eq)]
71#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
72pub struct FocusChain {
73    pub targets: Vec<FocusTarget>,
74    pub active_index: Option<usize>,
75}
76
77impl FocusChain {
78    pub fn new(targets: impl IntoIterator<Item = FocusTarget>) -> Self {
79        let targets = targets.into_iter().collect::<Vec<_>>();
80        let active_index = targets.iter().position(|target| !target.disabled);
81        Self {
82            targets,
83            active_index,
84        }
85    }
86
87    pub fn active(&self) -> Option<&FocusTarget> {
88        self.active_index
89            .and_then(|index| self.targets.get(index))
90            .filter(|target| !target.disabled)
91    }
92
93    pub fn active_id(&self) -> Option<&str> {
94        self.active().map(|target| target.id.as_str())
95    }
96
97    pub fn set_active(&mut self, id: &str) -> bool {
98        let id = sanitize_str(id, 120);
99        if let Some(index) = self
100            .targets
101            .iter()
102            .position(|target| target.id == id && !target.disabled)
103        {
104            self.active_index = Some(index);
105            true
106        } else {
107            false
108        }
109    }
110
111    pub fn move_focus(&mut self, direction: FocusDirection) -> Option<&FocusTarget> {
112        let len = self.targets.len();
113        if len == 0 || self.targets.iter().all(|target| target.disabled) {
114            self.active_index = None;
115            return None;
116        }
117
118        let base = self
119            .active_index
120            .filter(|index| *index < len)
121            .unwrap_or(match direction {
122                FocusDirection::Forward => len - 1,
123                FocusDirection::Backward => 0,
124            });
125        for step in 1..=len {
126            let index = match direction {
127                FocusDirection::Forward => (base + step) % len,
128                FocusDirection::Backward => (base + len - step % len) % len,
129            };
130            if !self.targets[index].disabled {
131                self.active_index = Some(index);
132                return self.active();
133            }
134        }
135        None
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn focus_chain_skips_disabled_targets_and_wraps() {
145        let mut chain = FocusChain::new([
146            FocusTarget::new("editor"),
147            FocusTarget::new("disabled").disabled(true),
148            FocusTarget::new("prompt"),
149        ]);
150
151        assert_eq!(chain.active_id(), Some("editor"));
152        assert_eq!(
153            chain
154                .move_focus(FocusDirection::Forward)
155                .map(|target| target.id.as_str()),
156            Some("prompt")
157        );
158        assert_eq!(
159            chain
160                .move_focus(FocusDirection::Forward)
161                .map(|target| target.id.as_str()),
162            Some("editor")
163        );
164        assert_eq!(
165            chain
166                .move_focus(FocusDirection::Backward)
167                .map(|target| target.id.as_str()),
168            Some("prompt")
169        );
170    }
171
172    #[test]
173    fn focus_chain_refuses_disabled_or_missing_active_targets() {
174        let mut chain = FocusChain::new([
175            FocusTarget::new("one").disabled(true),
176            FocusTarget::new("two"),
177        ]);
178
179        assert_eq!(chain.active_id(), Some("two"));
180        assert!(!chain.set_active("one"));
181        assert!(!chain.set_active("missing"));
182        assert!(chain.set_active("two"));
183        assert_eq!(chain.active_id(), Some("two"));
184    }
185
186    #[test]
187    fn focus_chain_reports_none_when_all_targets_are_disabled() {
188        let mut chain = FocusChain::new([
189            FocusTarget::new("one").disabled(true),
190            FocusTarget::new("two").disabled(true),
191        ]);
192
193        assert_eq!(chain.active_id(), None);
194        assert_eq!(chain.move_focus(FocusDirection::Forward), None);
195        assert_eq!(chain.active_index, None);
196    }
197
198    #[test]
199    fn focus_chain_recovers_from_out_of_range_public_active_index() {
200        let mut chain = FocusChain {
201            targets: vec![FocusTarget::new("first"), FocusTarget::new("second")],
202            active_index: Some(usize::MAX),
203        };
204
205        assert_eq!(chain.active_id(), None);
206        assert_eq!(
207            chain
208                .move_focus(FocusDirection::Forward)
209                .map(|target| target.id.as_str()),
210            Some("first")
211        );
212    }
213
214    #[test]
215    fn focus_chain_set_active_sanitizes_lookup_id() {
216        let mut chain = FocusChain::new([FocusTarget::new("pane\u{200f}\x1b[31m")]);
217
218        assert_eq!(chain.active_id(), Some("pane"));
219        chain.active_index = None;
220        assert!(chain.set_active("pane\u{200f}\x1b[31m"));
221        assert_eq!(chain.active_id(), Some("pane"));
222    }
223}