返回 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 // A tab switch lands on whatever row the new tab keeps selected; it
60 // previews that row only when it is available, like ↑/↓ do.
61 KeyCode::BackTab => controller.prev_tab(),
62 KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => controller.prev_tab(),
63 KeyCode::Tab => controller.next_tab(),
64 KeyCode::Up | KeyCode::Char('k')
65 if !key.modifiers.contains(KeyModifiers::CONTROL)
66 && !key.modifiers.contains(KeyModifiers::ALT) =>
67 {
68 controller.move_up()
69 }
70 KeyCode::Down | KeyCode::Char('j')
71 if !key.modifiers.contains(KeyModifiers::CONTROL)
72 && !key.modifiers.contains(KeyModifiers::ALT) =>
73 {
74 controller.move_down()
75 }
76 KeyCode::Home => controller.jump_home(),
77 KeyCode::End => controller.jump_end(),
78 KeyCode::Backspace if allow_search_typing => {
79 controller.pop_query_char();
80 PickerNavResult::None
81 }
82 KeyCode::Char('u')
83 if key.modifiers.contains(KeyModifiers::CONTROL) && allow_search_typing =>
84 {
85 controller.clear_query();
86 PickerNavResult::None
87 }
88 KeyCode::Char(c)
89 if allow_search_typing
90 && !key.modifiers.contains(KeyModifiers::CONTROL)
91 && !key.modifiers.contains(KeyModifiers::ALT)
92 && !matches!(c, '1'..='9' | 'j' | 'k') =>
93 {
94 controller.push_query_char(c);
95 PickerNavResult::None
96 }
97 KeyCode::Char(c)
98 if matches!(c, '1'..='9')
99 && !key.modifiers.contains(KeyModifiers::CONTROL)
100 && !key.modifiers.contains(KeyModifiers::ALT) =>
101 {
102 controller.jump_digit(c as u8 - b'0')
103 }
104 KeyCode::Char(' ') => controller.request_item_action(),
105 _ => PickerNavResult::None,
106 }
107 }
108
109 #[cfg(test)]
110 mod tests {
111 use super::*;
112 use ratatui::layout::Rect;
113 use std::borrow::Cow;
114
115 fn sample_options() -> Vec<SettingOption> {
116 vec![
117 SettingOption::builder("system", "System")
118 .summary("Follow the terminal")
119 .detail("System resolves from COLORFGBG at session start.")
120 .help("Default theme selection")
121 .values(SettingValues::new(
122 Cow::Borrowed("system"),
123 Cow::Borrowed("system"),
124 Cow::Borrowed("system"),
125 ))
126 .tab("core")
127 .build(),
128 SettingOption::builder("terminal", "Terminal")
129 .summary("Terminal-owned background")
130 .detail("Terminal owns the background; Deepsea is unavailable.")
131 .help("No painted ocean field")
132 .values(SettingValues::new(
133 Cow::Borrowed("terminal"),
134 Cow::Borrowed("system"),
135 Cow::Borrowed("terminal"),
136 ))
137 .tab("core")
138 .build(),
139 SettingOption::builder("locked", "Locked Theme")
140 .summary("Unavailable in this build")
141 .detail("Disabled for matrix coverage.")
142 .help("Shows disabled reason in detail")
143 .values(SettingValues::new(
144 Cow::Borrowed("locked"),
145 Cow::Borrowed("system"),
146 Cow::Borrowed("system"),
147 ))
148 .availability(SettingAvailability::Disabled {
149 reason: Cow::Borrowed("requires fancy_animations"),
150 })
151 .tab("extra")
152 .prefer_list_when_narrow(true)
153 .build(),
154 SettingOption::builder("dracula", "Dracula")
155 .summary("Purple night")
156 .detail("Classic Dracula palette.")
157 .help("Popular dark theme")
158 .values(SettingValues::new(
159 Cow::Borrowed("dracula"),
160 Cow::Borrowed("system"),
161 Cow::Borrowed("dracula"),
162 ))
163 .tab("extra")
164 .action(SettingItemAction {
165 id: Cow::Borrowed("swatch"),
166 label: Cow::Borrowed("Show swatch"),
167 })
168 .build(),
169 ]
170 }
171
172 fn matrix_snapshot(controller: &SettingsPickerController, area: Rect) -> String {
173 let focused = controller.selected_option();
174 let layout = SettingsPickerLayout::resolve(area, 34, focused);
175 let mut lines = Vec::new();
176 lines.push(format!(
177 "tab={} query={:?} selected={:?} visible={} narrow={} stacked={} detail={}",
178 controller.active_tab_name(),
179 controller.query(),
180 controller.selected_id(),
181 controller.visible().len(),
182 layout.narrow,
183 layout.stacked,
184 layout.detail.is_some(),
185 ));
186 for (visible_idx, &source) in controller.visible().iter().enumerate() {
187 let option = &controller.options()[source];
188 let marker = if visible_idx == controller.selected_visible() {
189 ">"
190 } else {
191 " "
192 };
193 let disabled = option
194 .availability
195 .disabled_reason()
196 .map(|reason| format!(" [disabled: {reason}]"))
197 .unwrap_or_default();
198 lines.push(format!(
199 "{marker}{}. {} ({}){}",
200 visible_idx + 1,
201 option.label,
202 option.id,
203 disabled
204 ));
205 }
206 if let Some(option) = focused {
207 lines.push(format!(
208 "detail: current={} default={} effective={}",
209 option.values.current, option.values.default, option.values.effective
210 ));
211 lines.push(format!("help: {}", option.help));
212 if let Some(reason) = option.availability.disabled_reason() {
213 lines.push(format!("reason: {reason}"));
214 }
215 }
216 lines.join("\n")
217 }
218
219 #[test]
220 fn matrix_normal_layout_is_side_by_side() {
221 let controller = SettingsPickerController::new(sample_options(), "system");
222 let snap = matrix_snapshot(&controller, Rect::new(0, 0, 120, 30));
223 assert!(snap.contains("narrow=false"));
224 assert!(snap.contains("detail=true"));
225 assert!(snap.contains(">1. System (system)"));
226 assert!(snap.contains("detail: current=system"));
227 }
228
229 #[test]
230 fn matrix_narrow_falls_back_to_list_only_when_preferred() {
231 let mut controller = SettingsPickerController::new(sample_options(), "system");
232 // Move to locked which prefers list-when-narrow (on the extra tab).
233 controller.set_active_tab(
234 controller
235 .tabs()
236 .iter()
237 .position(|tab| tab == "extra")
238 .expect("extra tab"),
239 );
240 let _ = controller.jump_home();
241 let snap = matrix_snapshot(&controller, Rect::new(0, 0, 60, 16));
242 assert!(snap.contains("narrow=true"));
243 assert!(
244 snap.contains("detail=false"),
245 "narrow + prefer_list should drop detail: {snap}"
246 );
247 }
248
249 #[test]
250 fn matrix_disabled_row_blocks_preview_and_commit() {
251 let mut controller = SettingsPickerController::new(sample_options(), "system");
252 controller.set_query("locked");
253 assert_eq!(controller.visible().len(), 1);
254 assert_eq!(controller.move_down(), PickerNavResult::None);
255 assert_eq!(controller.request_commit(), PickerNavResult::None);
256 let snap = matrix_snapshot(&controller, Rect::new(0, 0, 100, 24));
257 assert!(snap.contains("[disabled: requires fancy_animations]"));
258 assert!(snap.contains("reason: requires fancy_animations"));
259 }
260
261 #[test]
262 fn matrix_filtered_preserves_selection_identity() {
263 let mut controller = SettingsPickerController::new(sample_options(), "system");
264 assert_eq!(controller.move_down(), PickerNavResult::Preview);
265 assert_eq!(controller.selected_id(), Some("terminal"));
266 // Specific enough that the System row's "terminal" summary does not
267 // also match — we want identity preservation on a single hit.
268 controller.set_query("owns the background");
269 assert_eq!(controller.selected_id(), Some("terminal"));
270 assert_eq!(controller.visible().len(), 1);
271 let snap = matrix_snapshot(&controller, Rect::new(0, 0, 100, 24));
272 assert!(snap.contains("query=\"owns the background\""));
273 assert!(snap.contains(">1. Terminal (terminal)"));
274 }
275
276 #[test]
277 fn matrix_preview_commit_and_revert_sequence() {
278 let mut controller = SettingsPickerController::new(sample_options(), "system");
279
280 let preview = handle_nav_key(
281 &mut controller,
282 KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
283 false,
284 );
285 assert_eq!(preview, PickerNavResult::Preview);
286 assert_eq!(controller.selected_id(), Some("terminal"));
287
288 let commit = handle_nav_key(
289 &mut controller,
290 KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
291 false,
292 );
293 assert_eq!(commit, PickerNavResult::Commit);
294 assert_eq!(controller.selected_id(), Some("terminal"));
295
296 // Re-open semantics: cancel restores the original id via rollback.
297 let mut controller = SettingsPickerController::new(sample_options(), "system");
298 let _ = controller.move_down();
299 let cancel = handle_nav_key(
300 &mut controller,
301 KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
302 false,
303 );
304 assert_eq!(cancel, PickerNavResult::Cancel);
305 assert_eq!(controller.original_id(), "system");
306 }
307
308 #[test]
309 fn digit_zero_does_not_jump() {
310 let mut controller = SettingsPickerController::new(sample_options(), "dracula");
311 let before = controller.selected_id().map(str::to_string);
312 let result = handle_nav_key(
313 &mut controller,
314 KeyEvent::new(KeyCode::Char('0'), KeyModifiers::NONE),
315 false,
316 );
317 assert_eq!(result, PickerNavResult::None);
318 assert_eq!(controller.selected_id().map(str::to_string), before);
319 }
320
321 #[test]
322 fn backtab_uses_the_same_previous_tab_path_as_shift_tab() {
323 let mut controller = SettingsPickerController::new(sample_options(), "system");
324 assert_eq!(controller.active_tab(), 0);
325
326 let result = handle_nav_key(
327 &mut controller,
328 KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE),
329 false,
330 );
331
332 // U09-m2: the last ("extra") tab lands on its disabled first row,
333 // which must not preview.
334 assert_eq!(controller.active_tab(), controller.tabs().len() - 1);
335 assert_eq!(controller.selected_id(), Some("locked"));
336 assert_eq!(result, PickerNavResult::None);
337
338 assert_eq!(controller.move_down(), PickerNavResult::Preview);
339 assert_eq!(controller.selected_id(), Some("dracula"));
340 let result = handle_nav_key(
341 &mut controller,
342 KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE),
343 false,
344 );
345 assert_eq!(controller.active_tab_name(), "core");
346 assert_eq!(controller.selected_id(), Some("terminal"));
347 assert_eq!(
348 result,
349 PickerNavResult::Preview,
350 "an available row previews"
351 );
352 }
353
354 #[test]
355 fn item_action_fires_on_space() {
356 let mut controller = SettingsPickerController::new(sample_options(), "system");
357 controller.set_query("dracula");
358 let result = handle_nav_key(
359 &mut controller,
360 KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE),
361 false,
362 );
363 assert_eq!(result, PickerNavResult::ItemAction);
364 let option = controller.selected_option().expect("dracula selected");
365 assert_eq!(option.id, "dracula");
366 assert_eq!(
367 option.action.as_ref().map(|action| action.id.as_ref()),
368 Some("swatch")
369 );
370 }
371 }
372
372 lines RUST