返回 CodeWhale
mod.rs
根目录 / crates / tui / src / tui / settings_picker / mod.rs
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
361 lines RUST