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#[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#[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#[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}