| 1 | //! Shared settings-picker framework. |
| 2 | //! |
| 3 | //! # Contract |
| 4 | //! |
| 5 | //! Concrete pickers (theme, model, provider, config) sit *on top* of this |
| 6 | //! module. The framework owns: |
| 7 | //! |
| 8 | //! - option catalog + tab/search filtering with stable visible indices |
| 9 | //! - keyboard navigation (↑/↓/Home/End/digits), disabled rows with reasons |
| 10 | //! - optional per-item actions |
| 11 | //! - nav-level preview / commit / cancel lifecycle via [`PickerNavResult`] |
| 12 | //! - responsive list↔detail layout (side-by-side when wide; stacked or |
| 13 | //! list-only narrow fallback per option) |
| 14 | //! |
| 15 | //! Ocean chrome (swatches, underwater surface paint, locale strings) stays in |
| 16 | //! the concrete picker so shared *contracts* do not flatten visual character. |
| 17 | //! |
| 18 | //! # Integration hooks (model / provider / Fleet) |
| 19 | //! |
| 20 | //! - **Theme**: nav/layout migrated — [`crate::tui::theme_picker`] builds |
| 21 | //! options and drives [`SettingsPickerController`] for navigation. Theme |
| 22 | //! preview/revert flows through its existing `ViewAction` path; hosts map |
| 23 | //! [`PickerNavResult`] into their own actions. |
| 24 | //! - **Model / provider**: leave full migration to the TUI-DOG-009 sibling. |
| 25 | //! Call `SettingsPickerController::new(options, original_id)` and map |
| 26 | //! [`PickerNavResult`] into existing `ViewAction`s; reuse |
| 27 | //! [`SettingsPickerLayout::resolve`] instead of ad-hoc splits. |
| 28 | //! - **Fleet setup**: framework only — billing/Fleet UX sibling owns flow |
| 29 | //! rewrites; plug drafts into the controller when ready. |
| 30 | //! |
| 31 | //! See `docs/SETTINGS_PICKER_FRAMEWORK.md` for the short integration note. |
| 32 | |
| 33 | pub mod controller; |
| 34 | pub mod layout; |
| 35 | pub mod option; |
| 36 | |
| 37 | pub use controller::{PickerNavResult, SettingsPickerController}; |
| 38 | pub use layout::SettingsPickerLayout; |
| 39 | #[allow(unused_imports)] // public API surface for host pickers |
| 40 | pub use option::{ |
| 41 | SettingAvailability, SettingItemAction, SettingOption, SettingOptionBuilder, SettingValues, |
| 42 | }; |
| 43 | |
| 44 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; |
| 45 | |
| 46 | /// Map a key event onto the shared picker navigation contract. |
| 47 | /// |
| 48 | /// Search typing (`Char` that is not a digit shortcut / vim key) is left to |
| 49 | /// the host when `allow_search_typing` is true so theme-style digit jumps stay |
| 50 | /// intact for non-search pickers. |
| 51 | pub fn handle_nav_key( |
| 52 | controller: &mut SettingsPickerController, |
| 53 | key: KeyEvent, |
| 54 | allow_search_typing: bool, |
| 55 | ) -> PickerNavResult { |
| 56 | match key.code { |
| 57 | KeyCode::Esc => controller.request_cancel(), |
| 58 | KeyCode::Enter => controller.request_commit(), |
| 59 | KeyCode::BackTab => { |
| 60 | controller.prev_tab(); |
| 61 | PickerNavResult::Preview |
| 62 | } |
| 63 | KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => { |
| 64 | controller.prev_tab(); |
| 65 | PickerNavResult::Preview |
| 66 | } |
| 67 | KeyCode::Tab => { |
| 68 | controller.next_tab(); |
| 69 | PickerNavResult::Preview |
| 70 | } |
| 71 | KeyCode::Up | KeyCode::Char('k') |
| 72 | if !key.modifiers.contains(KeyModifiers::CONTROL) |
| 73 | && !key.modifiers.contains(KeyModifiers::ALT) => |
| 74 | { |
| 75 | controller.move_up() |
| 76 | } |
| 77 | KeyCode::Down | KeyCode::Char('j') |
| 78 | if !key.modifiers.contains(KeyModifiers::CONTROL) |
| 79 | && !key.modifiers.contains(KeyModifiers::ALT) => |
| 80 | { |
| 81 | controller.move_down() |
| 82 | } |
| 83 | KeyCode::Home => controller.jump_home(), |
| 84 | KeyCode::End => controller.jump_end(), |
| 85 | KeyCode::Backspace if allow_search_typing => { |
| 86 | controller.pop_query_char(); |
| 87 | PickerNavResult::None |
| 88 | } |
| 89 | KeyCode::Char('u') |
| 90 | if key.modifiers.contains(KeyModifiers::CONTROL) && allow_search_typing => |
| 91 | { |
| 92 | controller.clear_query(); |
| 93 | PickerNavResult::None |
| 94 | } |
| 95 | KeyCode::Char(c) |
| 96 | if allow_search_typing |
| 97 | && !key.modifiers.contains(KeyModifiers::CONTROL) |
| 98 | && !key.modifiers.contains(KeyModifiers::ALT) |
| 99 | && !matches!(c, '1'..='9' | 'j' | 'k') => |
| 100 | { |
| 101 | controller.push_query_char(c); |
| 102 | PickerNavResult::None |
| 103 | } |
| 104 | KeyCode::Char(c) |
| 105 | if matches!(c, '1'..='9') |
| 106 | && !key.modifiers.contains(KeyModifiers::CONTROL) |
| 107 | && !key.modifiers.contains(KeyModifiers::ALT) => |
| 108 | { |
| 109 | controller.jump_digit(c as u8 - b'0') |
| 110 | } |
| 111 | KeyCode::Char(' ') => controller.request_item_action(), |
| 112 | _ => PickerNavResult::None, |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | #[cfg(test)] |
| 117 | mod tests { |
| 118 | use super::*; |
| 119 | use ratatui::layout::Rect; |
| 120 | use std::borrow::Cow; |
| 121 | |
| 122 | fn sample_options() -> Vec<SettingOption> { |
| 123 | vec![ |
| 124 | SettingOption::builder("system", "System") |
| 125 | .summary("Follow the terminal") |
| 126 | .detail("System resolves from COLORFGBG at session start.") |
| 127 | .help("Default theme selection") |
| 128 | .values(SettingValues::new( |
| 129 | Cow::Borrowed("system"), |
| 130 | Cow::Borrowed("system"), |
| 131 | Cow::Borrowed("system"), |
| 132 | )) |
| 133 | .tab("core") |
| 134 | .build(), |
| 135 | SettingOption::builder("terminal", "Terminal") |
| 136 | .summary("Terminal-owned background") |
| 137 | .detail("Terminal owns the background; ombre is unavailable.") |
| 138 | .help("No painted ocean field") |
| 139 | .values(SettingValues::new( |
| 140 | Cow::Borrowed("terminal"), |
| 141 | Cow::Borrowed("system"), |
| 142 | Cow::Borrowed("terminal"), |
| 143 | )) |
| 144 | .tab("core") |
| 145 | .build(), |
| 146 | SettingOption::builder("locked", "Locked Theme") |
| 147 | .summary("Unavailable in this build") |
| 148 | .detail("Disabled for matrix coverage.") |
| 149 | .help("Shows disabled reason in detail") |
| 150 | .values(SettingValues::new( |
| 151 | Cow::Borrowed("locked"), |
| 152 | Cow::Borrowed("system"), |
| 153 | Cow::Borrowed("system"), |
| 154 | )) |
| 155 | .availability(SettingAvailability::Disabled { |
| 156 | reason: Cow::Borrowed("requires fancy_animations"), |
| 157 | }) |
| 158 | .tab("extra") |
| 159 | .prefer_list_when_narrow(true) |
| 160 | .build(), |
| 161 | SettingOption::builder("dracula", "Dracula") |
| 162 | .summary("Purple night") |
| 163 | .detail("Classic Dracula palette.") |
| 164 | .help("Popular dark theme") |
| 165 | .values(SettingValues::new( |
| 166 | Cow::Borrowed("dracula"), |
| 167 | Cow::Borrowed("system"), |
| 168 | Cow::Borrowed("dracula"), |
| 169 | )) |
| 170 | .tab("extra") |
| 171 | .action(SettingItemAction { |
| 172 | id: Cow::Borrowed("swatch"), |
| 173 | label: Cow::Borrowed("Show swatch"), |
| 174 | }) |
| 175 | .build(), |
| 176 | ] |
| 177 | } |
| 178 | |
| 179 | fn matrix_snapshot(controller: &SettingsPickerController, area: Rect) -> String { |
| 180 | let focused = controller.selected_option(); |
| 181 | let layout = SettingsPickerLayout::resolve(area, 34, focused); |
| 182 | let mut lines = Vec::new(); |
| 183 | lines.push(format!( |
| 184 | "tab={} query={:?} selected={:?} visible={} narrow={} stacked={} detail={}", |
| 185 | controller.active_tab_name(), |
| 186 | controller.query(), |
| 187 | controller.selected_id(), |
| 188 | controller.visible().len(), |
| 189 | layout.narrow, |
| 190 | layout.stacked, |
| 191 | layout.detail.is_some(), |
| 192 | )); |
| 193 | for (visible_idx, &source) in controller.visible().iter().enumerate() { |
| 194 | let option = &controller.options()[source]; |
| 195 | let marker = if visible_idx == controller.selected_visible() { |
| 196 | ">" |
| 197 | } else { |
| 198 | " " |
| 199 | }; |
| 200 | let disabled = option |
| 201 | .availability |
| 202 | .disabled_reason() |
| 203 | .map(|reason| format!(" [disabled: {reason}]")) |
| 204 | .unwrap_or_default(); |
| 205 | lines.push(format!( |
| 206 | "{marker}{}. {} ({}){}", |
| 207 | visible_idx + 1, |
| 208 | option.label, |
| 209 | option.id, |
| 210 | disabled |
| 211 | )); |
| 212 | } |
| 213 | if let Some(option) = focused { |
| 214 | lines.push(format!( |
| 215 | "detail: current={} default={} effective={}", |
| 216 | option.values.current, option.values.default, option.values.effective |
| 217 | )); |
| 218 | lines.push(format!("help: {}", option.help)); |
| 219 | if let Some(reason) = option.availability.disabled_reason() { |
| 220 | lines.push(format!("reason: {reason}")); |
| 221 | } |
| 222 | } |
| 223 | lines.join("\n") |
| 224 | } |
| 225 | |
| 226 | #[test] |
| 227 | fn matrix_normal_layout_is_side_by_side() { |
| 228 | let controller = SettingsPickerController::new(sample_options(), "system"); |
| 229 | let snap = matrix_snapshot(&controller, Rect::new(0, 0, 120, 30)); |
| 230 | assert!(snap.contains("narrow=false")); |
| 231 | assert!(snap.contains("detail=true")); |
| 232 | assert!(snap.contains(">1. System (system)")); |
| 233 | assert!(snap.contains("detail: current=system")); |
| 234 | } |
| 235 | |
| 236 | #[test] |
| 237 | fn matrix_narrow_falls_back_to_list_only_when_preferred() { |
| 238 | let mut controller = SettingsPickerController::new(sample_options(), "system"); |
| 239 | // Move to locked which prefers list-when-narrow (on the extra tab). |
| 240 | controller.set_active_tab( |
| 241 | controller |
| 242 | .tabs() |
| 243 | .iter() |
| 244 | .position(|tab| tab == "extra") |
| 245 | .expect("extra tab"), |
| 246 | ); |
| 247 | let _ = controller.jump_home(); |
| 248 | let snap = matrix_snapshot(&controller, Rect::new(0, 0, 60, 16)); |
| 249 | assert!(snap.contains("narrow=true")); |
| 250 | assert!( |
| 251 | snap.contains("detail=false"), |
| 252 | "narrow + prefer_list should drop detail: {snap}" |
| 253 | ); |
| 254 | } |
| 255 | |
| 256 | #[test] |
| 257 | fn matrix_disabled_row_blocks_preview_and_commit() { |
| 258 | let mut controller = SettingsPickerController::new(sample_options(), "system"); |
| 259 | controller.set_query("locked"); |
| 260 | assert_eq!(controller.visible().len(), 1); |
| 261 | assert_eq!(controller.move_down(), PickerNavResult::None); |
| 262 | assert_eq!(controller.request_commit(), PickerNavResult::None); |
| 263 | let snap = matrix_snapshot(&controller, Rect::new(0, 0, 100, 24)); |
| 264 | assert!(snap.contains("[disabled: requires fancy_animations]")); |
| 265 | assert!(snap.contains("reason: requires fancy_animations")); |
| 266 | } |
| 267 | |
| 268 | #[test] |
| 269 | fn matrix_filtered_preserves_selection_identity() { |
| 270 | let mut controller = SettingsPickerController::new(sample_options(), "system"); |
| 271 | assert_eq!(controller.move_down(), PickerNavResult::Preview); |
| 272 | assert_eq!(controller.selected_id(), Some("terminal")); |
| 273 | // Specific enough that the System row's "terminal" summary does not |
| 274 | // also match — we want identity preservation on a single hit. |
| 275 | controller.set_query("owns the background"); |
| 276 | assert_eq!(controller.selected_id(), Some("terminal")); |
| 277 | assert_eq!(controller.visible().len(), 1); |
| 278 | let snap = matrix_snapshot(&controller, Rect::new(0, 0, 100, 24)); |
| 279 | assert!(snap.contains("query=\"owns the background\"")); |
| 280 | assert!(snap.contains(">1. Terminal (terminal)")); |
| 281 | } |
| 282 | |
| 283 | #[test] |
| 284 | fn matrix_preview_commit_and_revert_sequence() { |
| 285 | let mut controller = SettingsPickerController::new(sample_options(), "system"); |
| 286 | |
| 287 | let preview = handle_nav_key( |
| 288 | &mut controller, |
| 289 | KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), |
| 290 | false, |
| 291 | ); |
| 292 | assert_eq!(preview, PickerNavResult::Preview); |
| 293 | assert_eq!(controller.selected_id(), Some("terminal")); |
| 294 | |
| 295 | let commit = handle_nav_key( |
| 296 | &mut controller, |
| 297 | KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), |
| 298 | false, |
| 299 | ); |
| 300 | assert_eq!(commit, PickerNavResult::Commit); |
| 301 | assert_eq!(controller.selected_id(), Some("terminal")); |
| 302 | |
| 303 | // Re-open semantics: cancel restores the original id via rollback. |
| 304 | let mut controller = SettingsPickerController::new(sample_options(), "system"); |
| 305 | let _ = controller.move_down(); |
| 306 | let cancel = handle_nav_key( |
| 307 | &mut controller, |
| 308 | KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), |
| 309 | false, |
| 310 | ); |
| 311 | assert_eq!(cancel, PickerNavResult::Cancel); |
| 312 | assert_eq!(controller.original_id(), "system"); |
| 313 | } |
| 314 | |
| 315 | #[test] |
| 316 | fn digit_zero_does_not_jump() { |
| 317 | let mut controller = SettingsPickerController::new(sample_options(), "dracula"); |
| 318 | let before = controller.selected_id().map(str::to_string); |
| 319 | let result = handle_nav_key( |
| 320 | &mut controller, |
| 321 | KeyEvent::new(KeyCode::Char('0'), KeyModifiers::NONE), |
| 322 | false, |
| 323 | ); |
| 324 | assert_eq!(result, PickerNavResult::None); |
| 325 | assert_eq!(controller.selected_id().map(str::to_string), before); |
| 326 | } |
| 327 | |
| 328 | #[test] |
| 329 | fn backtab_uses_the_same_previous_tab_path_as_shift_tab() { |
| 330 | let mut controller = SettingsPickerController::new(sample_options(), "system"); |
| 331 | assert_eq!(controller.active_tab(), 0); |
| 332 | |
| 333 | let result = handle_nav_key( |
| 334 | &mut controller, |
| 335 | KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE), |
| 336 | false, |
| 337 | ); |
| 338 | |
| 339 | assert_eq!(result, PickerNavResult::Preview); |
| 340 | assert_eq!(controller.active_tab(), controller.tabs().len() - 1); |
| 341 | } |
| 342 | |
| 343 | #[test] |
| 344 | fn item_action_fires_on_space() { |
| 345 | let mut controller = SettingsPickerController::new(sample_options(), "system"); |
| 346 | controller.set_query("dracula"); |
| 347 | let result = handle_nav_key( |
| 348 | &mut controller, |
| 349 | KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE), |
| 350 | false, |
| 351 | ); |
| 352 | assert_eq!(result, PickerNavResult::ItemAction); |
| 353 | let option = controller.selected_option().expect("dracula selected"); |
| 354 | assert_eq!(option.id, "dracula"); |
| 355 | assert_eq!( |
| 356 | option.action.as_ref().map(|action| action.id.as_ref()), |
| 357 | Some("swatch") |
| 358 | ); |
| 359 | } |
| 360 | } |
| 361 |