返回 CodeWhale
controller.rs
根目录 / crates / tui / src / tui / settings_picker / controller.rs
1 //! Stable filter/index ownership for transactional settings pickers.
2 //!
3 //! The controller owns the option catalog, search query, tab filter, and the
4 //! mapping from visible row → source index. Callers never re-filter ad hoc
5 //! during render; they read [`SettingsPickerController::visible`] instead.
6
7 use super::option::{SettingAvailability, SettingOption};
8
9 /// Outcome of a navigation or commit attempt.
10 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
11 pub enum PickerNavResult {
12 /// Selection changed; host should run preview.
13 Preview,
14 /// Enter on an available option; host should commit.
15 Commit,
16 /// Esc / explicit cancel; host should rollback then close.
17 Cancel,
18 /// Secondary action on the focused row.
19 ItemAction,
20 /// No-op (disabled row, empty list, unrecognized key).
21 None,
22 }
23
24 /// Tab + search + selection state with stable filtered indices.
25 #[derive(Debug, Clone)]
26 pub struct SettingsPickerController {
27 options: Vec<SettingOption>,
28 tabs: Vec<String>,
29 active_tab: usize,
30 query: String,
31 /// Indices into `options` that pass the current tab + search filter.
32 filtered: Vec<usize>,
33 /// Index into `filtered` (not into `options`).
34 selected_visible: usize,
35 /// Snapshot of the selection id when the picker opened (for rollback).
36 original_id: String,
37 }
38
39 impl SettingsPickerController {
40 #[must_use]
41 pub fn new(options: Vec<SettingOption>, original_id: impl Into<String>) -> Self {
42 let mut tabs = vec!["all".to_string()];
43 for option in &options {
44 let tab = option.tab.as_ref();
45 if tab != "all" && !tabs.iter().any(|existing| existing == tab) {
46 tabs.push(tab.to_string());
47 }
48 }
49 let mut controller = Self {
50 options,
51 tabs,
52 active_tab: 0,
53 query: String::new(),
54 filtered: Vec::new(),
55 selected_visible: 0,
56 original_id: original_id.into(),
57 };
58 controller.recompute_filter(None);
59 // Prefer landing on the original id when it exists.
60 if !controller.original_id.is_empty() {
61 let original = controller.original_id.clone();
62 controller.recompute_filter(Some(&original));
63 }
64 controller
65 }
66
67 #[must_use]
68 pub fn options(&self) -> &[SettingOption] {
69 &self.options
70 }
71
72 #[must_use]
73 #[allow(dead_code)] // tab strip for multi-tab pickers (TUI-DOG-009)
74 pub fn tabs(&self) -> &[String] {
75 &self.tabs
76 }
77
78 #[must_use]
79 #[allow(dead_code)] // active tab index for hosts that paint the strip (TUI-DOG-009)
80 pub fn active_tab(&self) -> usize {
81 self.active_tab
82 }
83
84 #[must_use]
85 pub fn active_tab_name(&self) -> &str {
86 self.tabs
87 .get(self.active_tab)
88 .map(String::as_str)
89 .unwrap_or("all")
90 }
91
92 #[must_use]
93 #[allow(dead_code)] // search query accessor for host chrome (TUI-DOG-009)
94 pub fn query(&self) -> &str {
95 &self.query
96 }
97
98 #[must_use]
99 #[allow(dead_code)] // identity accessor for the theme-picker controller test
100 pub fn original_id(&self) -> &str {
101 &self.original_id
102 }
103
104 /// Visible source indices after tab + search filtering.
105 #[must_use]
106 pub fn visible(&self) -> &[usize] {
107 &self.filtered
108 }
109
110 #[must_use]
111 pub fn selected_visible(&self) -> usize {
112 self.selected_visible
113 }
114
115 #[must_use]
116 pub fn selected_source_index(&self) -> Option<usize> {
117 self.filtered.get(self.selected_visible).copied()
118 }
119
120 #[must_use]
121 pub fn selected_option(&self) -> Option<&SettingOption> {
122 self.selected_source_index()
123 .and_then(|idx| self.options.get(idx))
124 }
125
126 #[must_use]
127 pub fn selected_id(&self) -> Option<&str> {
128 self.selected_option().map(|option| option.id.as_ref())
129 }
130
131 /// Recompute the filtered index list, optionally preserving a source id.
132 ///
133 /// A non-empty search query searches across every tab so operators are not
134 /// trapped inside the active tab while typing.
135 pub fn recompute_filter(&mut self, prefer_source_id: Option<&str>) {
136 let tab = self.active_tab_name().to_string();
137 let query = self.query.to_ascii_lowercase();
138 let search_all_tabs = !query.is_empty();
139 self.filtered = self
140 .options
141 .iter()
142 .enumerate()
143 .filter(|(_, option)| {
144 (search_all_tabs || tab == "all" || option.tab.as_ref() == tab)
145 && (query.is_empty()
146 || option.id.to_ascii_lowercase().contains(&query)
147 || option.label.to_ascii_lowercase().contains(&query)
148 || option.summary.to_ascii_lowercase().contains(&query)
149 || option.detail.to_ascii_lowercase().contains(&query))
150 })
151 .map(|(idx, _)| idx)
152 .collect();
153
154 if let Some(id) = prefer_source_id
155 && let Some(visible) = self.filtered.iter().position(|&source| {
156 self.options
157 .get(source)
158 .is_some_and(|o| o.id.as_ref() == id)
159 })
160 {
161 self.selected_visible = visible;
162 return;
163 }
164
165 if self.filtered.is_empty() {
166 self.selected_visible = 0;
167 } else {
168 self.selected_visible = self
169 .selected_visible
170 .min(self.filtered.len().saturating_sub(1));
171 }
172 }
173
174 #[allow(dead_code)] // bulk query replace for search hosts (TUI-DOG-009)
175 pub fn set_query(&mut self, query: impl Into<String>) {
176 let keep = self.selected_id().map(str::to_string);
177 self.query = query.into();
178 self.recompute_filter(keep.as_deref());
179 }
180
181 pub fn push_query_char(&mut self, ch: char) {
182 let keep = self.selected_id().map(str::to_string);
183 self.query.push(ch);
184 self.recompute_filter(keep.as_deref());
185 }
186
187 pub fn pop_query_char(&mut self) {
188 let keep = self.selected_id().map(str::to_string);
189 self.query.pop();
190 self.recompute_filter(keep.as_deref());
191 }
192
193 pub fn clear_query(&mut self) {
194 let keep = self.selected_id().map(str::to_string);
195 self.query.clear();
196 self.recompute_filter(keep.as_deref());
197 }
198
199 pub fn set_active_tab(&mut self, tab_idx: usize) {
200 if tab_idx >= self.tabs.len() {
201 return;
202 }
203 let keep = self.selected_id().map(str::to_string);
204 self.active_tab = tab_idx;
205 self.recompute_filter(keep.as_deref());
206 }
207
208 /// Switch to the next tab. Previews the row it lands on only when that
209 /// row is available — a disabled row is shown, never previewed.
210 pub fn next_tab(&mut self) -> PickerNavResult {
211 if self.tabs.is_empty() {
212 return PickerNavResult::None;
213 }
214 let next = (self.active_tab + 1) % self.tabs.len();
215 self.set_active_tab(next);
216 self.preview_if_available()
217 }
218
219 /// Switch to the previous tab; same preview rule as [`Self::next_tab`].
220 pub fn prev_tab(&mut self) -> PickerNavResult {
221 if self.tabs.is_empty() {
222 return PickerNavResult::None;
223 }
224 let prev = (self.active_tab + self.tabs.len() - 1) % self.tabs.len();
225 self.set_active_tab(prev);
226 self.preview_if_available()
227 }
228
229 /// Select by source index when that option is currently visible.
230 pub fn select_source_index(&mut self, source: usize) -> PickerNavResult {
231 let Some(visible) = self.filtered.iter().position(|&idx| idx == source) else {
232 return PickerNavResult::None;
233 };
234 self.selected_visible = visible;
235 if self
236 .selected_option()
237 .is_some_and(|o| o.availability.is_available())
238 {
239 PickerNavResult::Preview
240 } else {
241 PickerNavResult::None
242 }
243 }
244
245 pub fn move_up(&mut self) -> PickerNavResult {
246 if self.filtered.is_empty() {
247 return PickerNavResult::None;
248 }
249 self.selected_visible =
250 (self.selected_visible + self.filtered.len() - 1) % self.filtered.len();
251 self.preview_if_available()
252 }
253
254 pub fn move_down(&mut self) -> PickerNavResult {
255 if self.filtered.is_empty() {
256 return PickerNavResult::None;
257 }
258 self.selected_visible = (self.selected_visible + 1) % self.filtered.len();
259 self.preview_if_available()
260 }
261
262 pub fn jump_home(&mut self) -> PickerNavResult {
263 if self.filtered.is_empty() {
264 return PickerNavResult::None;
265 }
266 self.selected_visible = 0;
267 self.preview_if_available()
268 }
269
270 pub fn jump_end(&mut self) -> PickerNavResult {
271 if self.filtered.is_empty() {
272 return PickerNavResult::None;
273 }
274 self.selected_visible = self.filtered.len().saturating_sub(1);
275 self.preview_if_available()
276 }
277
278 /// 1-indexed digit jump into the *visible* list.
279 pub fn jump_digit(&mut self, digit: u8) -> PickerNavResult {
280 if !(1..=9).contains(&digit) || self.filtered.is_empty() {
281 return PickerNavResult::None;
282 }
283 let idx = usize::from(digit - 1);
284 if idx >= self.filtered.len() {
285 return PickerNavResult::None;
286 }
287 self.selected_visible = idx;
288 self.preview_if_available()
289 }
290
291 pub fn request_commit(&self) -> PickerNavResult {
292 match self.selected_option() {
293 Some(option) if option.availability.is_available() => PickerNavResult::Commit,
294 _ => PickerNavResult::None,
295 }
296 }
297
298 pub fn request_cancel(&self) -> PickerNavResult {
299 PickerNavResult::Cancel
300 }
301
302 pub fn request_item_action(&self) -> PickerNavResult {
303 match self.selected_option() {
304 Some(option) if option.action.is_some() && option.availability.is_available() => {
305 PickerNavResult::ItemAction
306 }
307 _ => PickerNavResult::None,
308 }
309 }
310
311 fn preview_if_available(&self) -> PickerNavResult {
312 match self.selected_option() {
313 Some(option) if option.availability.is_available() => PickerNavResult::Preview,
314 Some(SettingOption {
315 availability: SettingAvailability::Disabled { .. },
316 ..
317 }) => PickerNavResult::None,
318 _ => PickerNavResult::None,
319 }
320 }
321 }
322
322 lines RUST