返回 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 #[allow(dead_code)] // catalog accessors for model/provider migration (TUI-DOG-009)
69 pub fn options(&self) -> &[SettingOption] {
70 &self.options
71 }
72
73 #[must_use]
74 #[allow(dead_code)] // tab strip for multi-tab pickers (TUI-DOG-009)
75 pub fn tabs(&self) -> &[String] {
76 &self.tabs
77 }
78
79 #[must_use]
80 #[allow(dead_code)] // active tab index for hosts that paint the strip (TUI-DOG-009)
81 pub fn active_tab(&self) -> usize {
82 self.active_tab
83 }
84
85 #[must_use]
86 pub fn active_tab_name(&self) -> &str {
87 self.tabs
88 .get(self.active_tab)
89 .map(String::as_str)
90 .unwrap_or("all")
91 }
92
93 #[must_use]
94 #[allow(dead_code)] // search query accessor for host chrome (TUI-DOG-009)
95 pub fn query(&self) -> &str {
96 &self.query
97 }
98
99 #[must_use]
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 pub fn next_tab(&mut self) {
209 if self.tabs.is_empty() {
210 return;
211 }
212 let next = (self.active_tab + 1) % self.tabs.len();
213 self.set_active_tab(next);
214 }
215
216 pub fn prev_tab(&mut self) {
217 if self.tabs.is_empty() {
218 return;
219 }
220 let prev = (self.active_tab + self.tabs.len() - 1) % self.tabs.len();
221 self.set_active_tab(prev);
222 }
223
224 /// Select by source index when that option is currently visible.
225 pub fn select_source_index(&mut self, source: usize) -> PickerNavResult {
226 let Some(visible) = self.filtered.iter().position(|&idx| idx == source) else {
227 return PickerNavResult::None;
228 };
229 self.selected_visible = visible;
230 if self
231 .selected_option()
232 .is_some_and(|o| o.availability.is_available())
233 {
234 PickerNavResult::Preview
235 } else {
236 PickerNavResult::None
237 }
238 }
239
240 pub fn move_up(&mut self) -> PickerNavResult {
241 if self.filtered.is_empty() {
242 return PickerNavResult::None;
243 }
244 self.selected_visible =
245 (self.selected_visible + self.filtered.len() - 1) % self.filtered.len();
246 self.preview_if_available()
247 }
248
249 pub fn move_down(&mut self) -> PickerNavResult {
250 if self.filtered.is_empty() {
251 return PickerNavResult::None;
252 }
253 self.selected_visible = (self.selected_visible + 1) % self.filtered.len();
254 self.preview_if_available()
255 }
256
257 pub fn jump_home(&mut self) -> PickerNavResult {
258 if self.filtered.is_empty() {
259 return PickerNavResult::None;
260 }
261 self.selected_visible = 0;
262 self.preview_if_available()
263 }
264
265 pub fn jump_end(&mut self) -> PickerNavResult {
266 if self.filtered.is_empty() {
267 return PickerNavResult::None;
268 }
269 self.selected_visible = self.filtered.len().saturating_sub(1);
270 self.preview_if_available()
271 }
272
273 /// 1-indexed digit jump into the *visible* list.
274 pub fn jump_digit(&mut self, digit: u8) -> PickerNavResult {
275 if !(1..=9).contains(&digit) || self.filtered.is_empty() {
276 return PickerNavResult::None;
277 }
278 let idx = usize::from(digit - 1);
279 if idx >= self.filtered.len() {
280 return PickerNavResult::None;
281 }
282 self.selected_visible = idx;
283 self.preview_if_available()
284 }
285
286 pub fn request_commit(&self) -> PickerNavResult {
287 match self.selected_option() {
288 Some(option) if option.availability.is_available() => PickerNavResult::Commit,
289 _ => PickerNavResult::None,
290 }
291 }
292
293 pub fn request_cancel(&self) -> PickerNavResult {
294 PickerNavResult::Cancel
295 }
296
297 pub fn request_item_action(&self) -> PickerNavResult {
298 match self.selected_option() {
299 Some(option) if option.action.is_some() && option.availability.is_available() => {
300 PickerNavResult::ItemAction
301 }
302 _ => PickerNavResult::None,
303 }
304 }
305
306 fn preview_if_available(&self) -> PickerNavResult {
307 match self.selected_option() {
308 Some(option) if option.availability.is_available() => PickerNavResult::Preview,
309 Some(SettingOption {
310 availability: SettingAvailability::Disabled { .. },
311 ..
312 }) => PickerNavResult::None,
313 _ => PickerNavResult::None,
314 }
315 }
316 }
317
317 lines RUST