| 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 |