返回 CodeWhale
theme_picker.rs
根目录 / crates / tui / src / tui / theme_picker.rs
1 //! `/theme` picker with live preview.
2 //!
3 //! Built on [`crate::tui::settings_picker`]: navigation, filtering ownership,
4 //! and transactional preview/commit/rollback live in the shared controller.
5 //! Theme-specific chrome (swatches, underwater surface) stays here so the
6 //! framework contract does not flatten visual character.
7 //!
8 //! Semantics preserved from the pre-framework picker:
9 //! - Up/Down emit a `ThemeSelectionUpdated{persist:false}` so the host swaps
10 //! `app.ui_theme` immediately and the whole TUI re-paints under the modal.
11 //! - Enter persists (`persist:true`); Esc emits one more
12 //! `ThemeSelectionUpdated{persist:false}` to restore the exact theme that
13 //! was active when the picker opened.
14 //!
15 //! The option list contains compiled themes followed by valid user overlays.
16 //! `underwater` is an ordinary row: the painted ocean field is the theme, not
17 //! a treatment beside it.
18
19 use std::borrow::Cow;
20 use std::cell::RefCell;
21
22 use crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};
23 use ratatui::{
24 buffer::Buffer,
25 layout::Rect,
26 style::{Color, Modifier, Style},
27 text::{Line, Span},
28 widgets::{Paragraph, Widget},
29 };
30
31 use crate::settings::DEFAULT_TUI_THEME;
32 use crate::tui::menu_style;
33 use crate::tui::settings_picker::{
34 PickerNavResult, SettingAvailability, SettingOption, SettingValues, SettingsPickerController,
35 SettingsPickerLayout, handle_nav_key,
36 };
37 use crate::tui::views::{
38 ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, render_modal_footer,
39 render_panel_scroll_rail, render_underwater_surface,
40 };
41 use codewhale_localization::{Locale, MessageId, tr};
42 use codewhale_palette::{SELECTABLE_THEMES, ThemeId, UiTheme};
43
44 pub struct ThemePickerView {
45 controller: SettingsPickerController,
46 /// Exact opening state for Esc rollback.
47 original_theme_name: String,
48 /// Cursor index the controller settled on at open time (row 0 when the
49 /// persisted selector is not a compiled theme row). Enter without any
50 /// navigation commits the original name, never this fallback row.
51 opening_cursor: Option<usize>,
52 /// Cached UiTheme for `ThemeId::System`, captured once at construction
53 /// so the per-frame render doesn't re-invoke `UiTheme::detect()` (which
54 /// reads `COLORFGBG`) on every keystroke.
55 system_ui_theme: UiTheme,
56 /// User-configured background applied on top of every named-theme preview.
57 background_override: Option<Color>,
58 row_hitboxes: RefCell<Vec<(Rect, usize)>>,
59 last_mouse_selected: Option<usize>,
60 /// UI locale captured from the app at construction (#4057 wave 2).
61 locale: Locale,
62 /// Valid user overlays loaded once when the picker opens.
63 custom_themes: Vec<codewhale_palette::UserThemeOption>,
64 }
65
66 impl ThemePickerView {
67 #[cfg(test)]
68 #[must_use]
69 pub fn new(original_name: String) -> Self {
70 Self::new_with_background(original_name, Locale::En, None)
71 }
72
73 fn new_with_background(
74 original_name: String,
75 locale: Locale,
76 background_override: Option<Color>,
77 ) -> Self {
78 let normalized = original_name.trim().to_ascii_lowercase();
79 let (options, custom_themes) = theme_options(&normalized);
80 let controller = SettingsPickerController::new(options, normalized.clone());
81 let opening_cursor = controller.selected_source_index();
82 Self {
83 controller,
84 original_theme_name: normalized,
85 opening_cursor,
86 system_ui_theme: UiTheme::detect(),
87 background_override,
88 row_hitboxes: RefCell::new(Vec::new()),
89 last_mouse_selected: None,
90 locale,
91 custom_themes,
92 }
93 }
94
95 /// Construct behind type erasure before returning to the async event loop.
96 /// Keeping the concrete picker out of that already-large future prevents
97 /// transient modal values from inflating the main-thread stack frame.
98 #[must_use]
99 pub fn boxed(
100 original_name: String,
101 locale: Locale,
102 background_override: Option<Color>,
103 ) -> Box<dyn ModalView> {
104 Box::new(Self::new_with_background(
105 original_name,
106 locale,
107 background_override,
108 ))
109 }
110
111 fn selected_theme_name(&self) -> &str {
112 self.controller
113 .selected_id()
114 .unwrap_or(ThemeId::System.name())
115 }
116
117 fn custom_theme_for(&self, selector: &str) -> Option<UiTheme> {
118 self.custom_themes
119 .iter()
120 .find(|option| option.selector == selector)
121 .map(|option| option.theme)
122 }
123
124 #[cfg(test)]
125 fn current(&self) -> ThemeId {
126 let selected = self.selected_theme_name();
127 self.custom_themes
128 .iter()
129 .find(|option| option.selector == selected)
130 .map(|option| option.base)
131 .or_else(|| ThemeId::from_name(selected))
132 .unwrap_or(ThemeId::System)
133 }
134
135 #[cfg(test)]
136 fn selected(&self) -> usize {
137 self.controller.selected_source_index().unwrap_or(0)
138 }
139
140 /// Resolve a theme to a `UiTheme`, returning the cached `System`
141 /// resolution to avoid repeated env-var reads inside `render`.
142 fn ui_theme_for_selection(&self, selection: &str) -> UiTheme {
143 let theme = if let Some(custom) = self.custom_theme_for(selection) {
144 custom
145 } else if let Some(id) = ThemeId::from_name(selection) {
146 if matches!(id, ThemeId::System) {
147 self.system_ui_theme
148 } else {
149 id.ui_theme()
150 }
151 } else {
152 self.system_ui_theme
153 };
154 self.background_override
155 .map_or(theme, |background| theme.with_background_color(background))
156 }
157
158 fn preview_event(&self) -> ViewAction {
159 ViewAction::Emit(ViewEvent::ThemeSelectionUpdated {
160 theme: self.selected_theme_name().to_string(),
161 persist: false,
162 })
163 }
164
165 fn commit_event(&self) -> ViewAction {
166 // A commit that never moved the cursor must preserve the exact
167 // opening selector. This also protects a custom:<name> selector if
168 // its file disappears or becomes invalid while the picker is open.
169 if self.controller.selected_source_index() == self.opening_cursor {
170 return ViewAction::EmitAndClose(ViewEvent::ThemeSelectionUpdated {
171 theme: self.original_theme_name.clone(),
172 persist: true,
173 });
174 }
175 ViewAction::EmitAndClose(ViewEvent::ThemeSelectionUpdated {
176 theme: self.selected_theme_name().to_string(),
177 persist: true,
178 })
179 }
180
181 fn revert_event(&self) -> ViewAction {
182 ViewAction::EmitAndClose(ViewEvent::ThemeSelectionUpdated {
183 theme: self.original_theme_name.clone(),
184 persist: false,
185 })
186 }
187
188 fn action_from_nav(&self, result: PickerNavResult) -> ViewAction {
189 match result {
190 PickerNavResult::Preview => self.preview_event(),
191 PickerNavResult::Commit => self.commit_event(),
192 PickerNavResult::Cancel => self.revert_event(),
193 PickerNavResult::ItemAction | PickerNavResult::None => ViewAction::None,
194 }
195 }
196
197 fn move_up(&mut self) -> ViewAction {
198 let result = self.controller.move_up();
199 self.action_from_nav(result)
200 }
201
202 fn move_down(&mut self) -> ViewAction {
203 let result = self.controller.move_down();
204 self.action_from_nav(result)
205 }
206 }
207
208 fn theme_options(
209 current_name: &str,
210 ) -> (Vec<SettingOption>, Vec<codewhale_palette::UserThemeOption>) {
211 theme_options_with_custom(current_name, codewhale_palette::list_user_theme_options())
212 }
213
214 fn theme_options_with_custom(
215 current_name: &str,
216 custom_themes: Vec<codewhale_palette::UserThemeOption>,
217 ) -> (Vec<SettingOption>, Vec<codewhale_palette::UserThemeOption>) {
218 let current = current_name.trim().to_ascii_lowercase();
219 let mut options = SELECTABLE_THEMES
220 .iter()
221 .copied()
222 .map(|id| {
223 let name = id.name();
224 SettingOption::builder(name, id.display_name())
225 .summary(id.tagline())
226 .detail(id.tagline())
227 .help("Pick a theme with live preview")
228 .values(SettingValues::new(
229 Cow::Owned(current.clone()),
230 // Reset uses the same default as fresh terminal settings.
231 Cow::Borrowed(DEFAULT_TUI_THEME),
232 Cow::Borrowed(name),
233 ))
234 .availability(SettingAvailability::Available)
235 .tab("themes")
236 .prefer_list_when_narrow(true)
237 .build()
238 })
239 .collect::<Vec<_>>();
240 for custom in &custom_themes {
241 let label = custom
242 .selector
243 .strip_prefix(codewhale_palette::USER_THEME_PREFIX)
244 .map_or_else(|| custom.selector.clone(), |slug| format!("Custom: {slug}"));
245 options.push(
246 SettingOption::builder(custom.selector.clone(), label)
247 .summary(format!(
248 "User overlay · based on {}",
249 custom.base.display_name()
250 ))
251 .detail(format!(
252 "User-authored overlay based on {}",
253 custom.base.display_name()
254 ))
255 .help("Pick a user-authored theme overlay")
256 .values(SettingValues::new(
257 Cow::Owned(current.clone()),
258 Cow::Borrowed(DEFAULT_TUI_THEME),
259 Cow::Owned(custom.selector.clone()),
260 ))
261 .availability(SettingAvailability::Available)
262 .tab("themes")
263 .prefer_list_when_narrow(true)
264 .build(),
265 );
266 }
267 (options, custom_themes)
268 }
269
270 impl ModalView for ThemePickerView {
271 fn kind(&self) -> ModalKind {
272 ModalKind::ThemePicker
273 }
274
275 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
276 self
277 }
278
279 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
280 match mouse.kind {
281 MouseEventKind::Moved => {
282 // Hover-follow with live preview: the pointer highlights a
283 // row exactly like ↑/↓ does, so the surface behind the modal
284 // repaints on hover and a later Enter persists the hovered
285 // theme. Returning the preview event (not None) is what makes
286 // the highlight repaint immediately.
287 let hovered = self.row_hitboxes.borrow().iter().find_map(|(rect, idx)| {
288 rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
289 .then_some(*idx)
290 });
291 match hovered {
292 Some(idx) if self.controller.selected_source_index() != Some(idx) => {
293 let nav = self.controller.select_source_index(idx);
294 self.action_from_nav(nav)
295 }
296 _ => ViewAction::None,
297 }
298 }
299 MouseEventKind::ScrollUp => {
300 self.last_mouse_selected = None;
301 self.move_up()
302 }
303 MouseEventKind::ScrollDown => {
304 self.last_mouse_selected = None;
305 self.move_down()
306 }
307 MouseEventKind::Down(MouseButton::Left) => {
308 let clicked = self.row_hitboxes.borrow().iter().find_map(|(rect, idx)| {
309 rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
310 .then_some(*idx)
311 });
312 if let Some(idx) = clicked {
313 let commit = self.last_mouse_selected == Some(idx)
314 && self.controller.selected_source_index() == Some(idx);
315 let nav = self.controller.select_source_index(idx);
316 self.last_mouse_selected = Some(idx);
317 if commit {
318 self.commit_event()
319 } else {
320 self.action_from_nav(nav)
321 }
322 } else {
323 ViewAction::None
324 }
325 }
326 _ => ViewAction::None,
327 }
328 }
329
330 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
331 // Theme picker keeps digit-jump / vim keys; search typing stays off so
332 // `j`/`k` and `1`..=`9` retain their navigation meaning.
333 let result = handle_nav_key(&mut self.controller, key, false);
334 self.action_from_nav(result)
335 }
336
337 fn render(&self, area: Rect, buf: &mut Buffer) {
338 self.row_hitboxes.borrow_mut().clear();
339 // The live selection has already been swapped under us via
340 // ThemeSelectionUpdated.
341 // so we pull the *current* preview's UiTheme from the cursor row to
342 // skin the modal chrome. That way the popup itself shifts color as
343 // the cursor moves, matching what the background will look like
344 // after Enter. We keep the live `surface_bg` (not the shared ink) and
345 // the bare `Clear` so the preview backdrop reads as intended.
346 let live = self.ui_theme_for_selection(self.selected_theme_name());
347 let inner =
348 render_underwater_surface(area, buf, tr(self.locale, MessageId::ThemeSurfaceTitle));
349
350 let content = render_modal_footer(
351 inner,
352 buf,
353 &[
354 ActionHint::new("↑/↓", "preview"),
355 ActionHint::new("Enter", "save"),
356 ActionHint::new("Esc", "revert"),
357 ],
358 );
359
360 // Theme rows prefer list-when-narrow; layout still drives scroll math.
361 let _layout = SettingsPickerLayout::resolve(content, 34, self.controller.selected_option());
362
363 let mut lines: Vec<Line> = Vec::with_capacity(self.controller.visible().len() + 2);
364 lines.push(Line::from(""));
365
366 let header_rows = lines.len();
367 let visible_rows = usize::from(content.height)
368 .saturating_sub(header_rows)
369 .max(1);
370 let source_count = self.controller.visible().len();
371 let selected_visible = self.controller.selected_visible();
372 let max_start = source_count.saturating_sub(visible_rows);
373 let start = selected_visible
374 .saturating_sub(visible_rows.saturating_sub(1))
375 .min(max_start);
376 let content = render_panel_scroll_rail(
377 content,
378 buf,
379 source_count.saturating_add(header_rows),
380 start,
381 visible_rows,
382 true,
383 );
384
385 for (visible_idx, &source_idx) in self
386 .controller
387 .visible()
388 .iter()
389 .enumerate()
390 .skip(start)
391 .take(visible_rows)
392 {
393 let row_y = content.y.saturating_add(lines.len() as u16);
394 self.row_hitboxes
395 .borrow_mut()
396 .push((Rect::new(content.x, row_y, content.width, 1), source_idx));
397 let option = self
398 .controller
399 .options()
400 .get(source_idx)
401 .expect("visible source index must reference an option");
402 let is_selected = visible_idx == selected_visible;
403 let row_style = if is_selected {
404 menu_style::theme_selected_row_style(&live)
405 } else {
406 Style::default().fg(live.text_body)
407 };
408 let tagline_style = if is_selected {
409 Style::default().fg(live.text_muted).bg(live.selection_bg)
410 } else {
411 Style::default().fg(live.text_dim)
412 };
413 let number_style = if is_selected {
414 Style::default()
415 .fg(live.status_working)
416 .bg(live.selection_bg)
417 .add_modifier(Modifier::BOLD)
418 } else {
419 Style::default().fg(live.text_hint)
420 };
421 let pointer = crate::tui::glyphs::selection_marker(is_selected);
422
423 // 3-cell color swatch per row using the candidate theme's own
424 // accent + panel + border colors so the picker doubles as a
425 // legend. The underwater row shows its water column; use the
426 // cached resolver so `System` doesn't repeat `UiTheme::detect()`.
427 let row_theme = self.ui_theme_for_selection(option.id.as_ref());
428 let swatch_colors = match crate::tui::ocean::OceanRamp::for_theme(&row_theme) {
429 Some(ramp) => [
430 ramp.surface,
431 ramp.middle,
432 ramp.deep,
433 ramp.ambient,
434 row_theme.status_working,
435 ],
436 None => [
437 row_theme.surface_bg,
438 row_theme.panel_bg,
439 row_theme.status_working,
440 row_theme.mode_yolo,
441 row_theme.mode_plan,
442 ],
443 };
444 let swatch = swatch_colors
445 .into_iter()
446 .map(|color| Span::styled(" ", Style::default().bg(color)));
447
448 let mut spans: Vec<Span> = Vec::with_capacity(8);
449 spans.push(Span::styled(format!(" {pointer} "), row_style));
450 spans.push(Span::styled(format!("{}. ", visible_idx + 1), number_style));
451 spans.push(Span::styled(format!("{:<22}", option.label), row_style));
452 spans.extend(swatch);
453 spans.push(Span::raw(" "));
454
455 let prefix_width = Line::from(spans.clone()).width();
456 let tagline = crate::tui::ui_text::semantic_truncate(
457 option.summary.as_ref(),
458 usize::from(content.width).saturating_sub(prefix_width),
459 );
460 spans.push(Span::styled(tagline, tagline_style));
461
462 lines.push(Line::from(spans));
463 }
464
465 Paragraph::new(lines).render(content, buf);
466 }
467 }
468
469 #[cfg(test)]
470 mod tests {
471 use super::*;
472 use crossterm::event::{KeyCode, KeyModifiers};
473
474 fn key(code: KeyCode) -> KeyEvent {
475 KeyEvent::new(code, KeyModifiers::NONE)
476 }
477
478 fn selected_values(action: &ViewAction) -> Option<(&str, bool)> {
479 match action {
480 ViewAction::Emit(ViewEvent::ThemeSelectionUpdated { theme, persist })
481 | ViewAction::EmitAndClose(ViewEvent::ThemeSelectionUpdated { theme, persist }) => {
482 Some((theme.as_str(), *persist))
483 }
484 _ => None,
485 }
486 }
487
488 fn selected_name(action: &ViewAction) -> Option<&str> {
489 selected_values(action).map(|(theme, _)| theme)
490 }
491
492 #[test]
493 fn opens_at_persisted_theme() {
494 let v = ThemePickerView::new("tokyo-night".to_string());
495 assert_eq!(v.current(), ThemeId::TokyoNight);
496 }
497
498 #[test]
499 fn unknown_persisted_name_falls_back_to_first_row() {
500 let v = ThemePickerView::new("not-a-real-theme".to_string());
501 assert_eq!(v.selected(), 0);
502 assert_eq!(v.current(), ThemeId::System);
503 }
504
505 #[test]
506 fn arrow_down_previews_next_theme() {
507 let mut v = ThemePickerView::new("system".to_string());
508 let action = v.handle_key(key(KeyCode::Down));
509 assert!(matches!(action, ViewAction::Emit(_)));
510 assert_eq!(selected_name(&action), Some(ThemeId::Terminal.name()));
511 assert_eq!(selected_values(&action), Some(("terminal", false)));
512 }
513
514 #[test]
515 fn mouse_wheel_previews_and_second_underwater_click_commits() {
516 let mut v = ThemePickerView::new("system".to_string());
517 let wheel = v.handle_mouse(MouseEvent {
518 kind: MouseEventKind::ScrollDown,
519 column: 0,
520 row: 0,
521 modifiers: KeyModifiers::NONE,
522 });
523 assert!(matches!(wheel, ViewAction::Emit(_)));
524 assert_eq!(selected_name(&wheel), Some(ThemeId::Terminal.name()));
525
526 let area = Rect::new(0, 0, 100, 30);
527 let mut buf = Buffer::empty(area);
528 v.render(area, &mut buf);
529 let underwater_source = v
530 .controller
531 .options()
532 .iter()
533 .position(|option| option.id.as_ref() == ThemeId::Underwater.name())
534 .expect("Underwater row");
535 let (rect, idx) = v
536 .row_hitboxes
537 .borrow()
538 .iter()
539 .copied()
540 .find(|(_, source)| *source == underwater_source)
541 .expect("rendered Underwater hitbox");
542 let click = MouseEvent {
543 kind: MouseEventKind::Down(MouseButton::Left),
544 column: rect.x,
545 row: rect.y,
546 modifiers: KeyModifiers::NONE,
547 };
548 let preview = v.handle_mouse(click);
549 assert!(matches!(preview, ViewAction::Emit(_)));
550 assert_eq!(v.selected(), idx);
551 assert_eq!(selected_values(&preview), Some(("underwater", false)));
552 let commit = v.handle_mouse(click);
553 assert!(matches!(commit, ViewAction::EmitAndClose(_)));
554 assert_eq!(selected_values(&commit), Some(("underwater", true)));
555 }
556
557 #[test]
558 fn hover_moves_highlight_and_previews_without_persisting() {
559 let mut v = ThemePickerView::new("system".to_string());
560 let area = Rect::new(0, 0, 100, 30);
561 let mut buf = Buffer::empty(area);
562 v.render(area, &mut buf);
563 let underwater_source = v
564 .controller
565 .options()
566 .iter()
567 .position(|option| option.id.as_ref() == ThemeId::Underwater.name())
568 .expect("Underwater row");
569 let (rect, idx) = v
570 .row_hitboxes
571 .borrow()
572 .iter()
573 .copied()
574 .find(|(_, source)| *source == underwater_source)
575 .expect("rendered Underwater hitbox");
576 let hover = MouseEvent {
577 kind: MouseEventKind::Moved,
578 column: rect.x,
579 row: rect.y,
580 modifiers: KeyModifiers::NONE,
581 };
582 // Hovering a new row highlights it and previews (persist:false),
583 // exactly like keyboard navigation.
584 let action = v.handle_mouse(hover);
585 assert!(matches!(action, ViewAction::Emit(_)));
586 assert_eq!(selected_values(&action), Some(("underwater", false)));
587 assert_eq!(v.selected(), idx);
588 // Hovering the already-highlighted row is a no-op.
589 assert!(matches!(v.handle_mouse(hover), ViewAction::None));
590 // Hovering outside every row is a no-op.
591 let outside = MouseEvent {
592 kind: MouseEventKind::Moved,
593 column: 99,
594 row: 29,
595 modifiers: KeyModifiers::NONE,
596 };
597 assert!(matches!(v.handle_mouse(outside), ViewAction::None));
598 }
599
600 #[test]
601 fn arrow_navigation_wraps_at_picker_edges() {
602 let mut v = ThemePickerView::new("system".to_string());
603 let last = SELECTABLE_THEMES.last().unwrap();
604
605 let action = v.handle_key(key(KeyCode::Up));
606 assert_eq!(selected_name(&action), Some(last.name()));
607
608 let action = v.handle_key(key(KeyCode::Down));
609 assert_eq!(selected_name(&action), Some(SELECTABLE_THEMES[0].name()));
610 }
611
612 #[test]
613 fn enter_commits_with_persist_true() {
614 let mut v = ThemePickerView::new("system".to_string());
615 v.handle_key(key(KeyCode::Char('9'))); // -> Grayscale
616 let action = v.handle_key(key(KeyCode::Enter));
617 match action {
618 ViewAction::EmitAndClose(ViewEvent::ThemeSelectionUpdated { theme, persist }) => {
619 assert_eq!(theme, ThemeId::Grayscale.name());
620 assert!(persist);
621 }
622 other => panic!("expected commit, got {other:?}"),
623 }
624 }
625
626 #[test]
627 fn enter_without_navigating_preserves_a_custom_theme_selector() {
628 // The picker's rows are compiled themes only; a persisted
629 // custom:<name> selector opens on no row, and Enter without
630 // navigation must not replace it with a compiled row.
631 let mut v = ThemePickerView::new("custom:midnight".to_string());
632 let action = v.handle_key(key(KeyCode::Enter));
633 assert_eq!(
634 selected_values(&action),
635 Some(("custom:midnight", true)),
636 "committing without moving the cursor must not replace the persisted selector"
637 );
638 }
639
640 #[test]
641 fn custom_theme_rows_preview_and_commit_their_selector() {
642 let mut custom_theme = ThemeId::Whale.ui_theme();
643 custom_theme.accent_primary = Color::Rgb(0x12, 0x34, 0x56);
644 let custom = codewhale_palette::UserThemeOption {
645 selector: "custom:midnight".to_string(),
646 base: ThemeId::Whale,
647 theme: custom_theme,
648 };
649 let (options, custom_themes) = theme_options_with_custom("custom:midnight", vec![custom]);
650 let controller = SettingsPickerController::new(options, "custom:midnight");
651 let view = ThemePickerView {
652 opening_cursor: controller.selected_source_index(),
653 controller,
654 original_theme_name: "custom:midnight".to_string(),
655 system_ui_theme: UiTheme::detect(),
656 background_override: None,
657 row_hitboxes: RefCell::new(Vec::new()),
658 last_mouse_selected: None,
659 locale: Locale::En,
660 custom_themes,
661 };
662
663 assert_eq!(view.controller.selected_id(), Some("custom:midnight"));
664 assert_eq!(view.current(), ThemeId::Whale);
665 assert_eq!(
666 view.ui_theme_for_selection("custom:midnight")
667 .accent_primary,
668 Color::Rgb(0x12, 0x34, 0x56)
669 );
670 assert_eq!(
671 selected_values(&view.preview_event()),
672 Some(("custom:midnight", false))
673 );
674 assert_eq!(
675 selected_values(&view.commit_event()),
676 Some(("custom:midnight", true))
677 );
678 }
679
680 #[test]
681 fn enter_after_navigating_away_still_commits_the_chosen_option() {
682 let mut v = ThemePickerView::new("dracula".to_string());
683 v.handle_key(key(KeyCode::Down));
684 let action = v.handle_key(key(KeyCode::Enter));
685 let (theme, persist) = selected_values(&action).expect("expected a commit");
686 assert_ne!(
687 theme, "dracula",
688 "navigation should have moved off the opening row"
689 );
690 assert!(persist);
691 }
692
693 #[test]
694 fn esc_reverts_to_exact_original_theme() {
695 let mut v = ThemePickerView::new("dracula".to_string());
696 v.handle_key(key(KeyCode::Up));
697 v.handle_key(key(KeyCode::Up));
698 let action = v.handle_key(key(KeyCode::Esc));
699 match action {
700 ViewAction::EmitAndClose(ViewEvent::ThemeSelectionUpdated { theme, persist }) => {
701 assert_eq!(theme, "dracula");
702 assert!(!persist);
703 }
704 other => panic!("expected revert, got {other:?}"),
705 }
706 }
707
708 #[test]
709 fn digit_jumps_to_shoreline_and_previews() {
710 let mut v = ThemePickerView::new("system".to_string());
711 let action = v.handle_key(key(KeyCode::Char('3')));
712 // Shoreline follows System and Terminal.
713 assert_eq!(selected_values(&action), Some(("shoreline", false)));
714 }
715
716 #[test]
717 fn digit_zero_is_rejected_not_remapped_to_row_zero() {
718 let mut v = ThemePickerView::new("dracula".to_string());
719 let before = v.selected();
720 let action = v.handle_key(key(KeyCode::Char('0')));
721 assert!(matches!(action, ViewAction::None));
722 assert_eq!(v.selected(), before, "'0' should not move the cursor");
723 }
724
725 #[test]
726 fn render_does_not_panic_on_zero_sized_area() {
727 // The picker historically panicked here via .max(W).max(H) floors
728 // that produced dimensions larger than the available area, then
729 // underflowed the centering arithmetic.
730 let v = ThemePickerView::new("system".to_string());
731 let outer = ratatui::layout::Rect::new(0, 0, 10, 10);
732 let area = ratatui::layout::Rect::new(0, 0, 0, 0);
733 let mut buf = ratatui::buffer::Buffer::empty(outer);
734 v.render(area, &mut buf);
735 }
736
737 #[test]
738 fn render_does_not_panic_on_tiny_area() {
739 // 20×6 is smaller than every soft floor the picker prefers.
740 let v = ThemePickerView::new("system".to_string());
741 let area = ratatui::layout::Rect::new(0, 0, 20, 6);
742 let mut buf = ratatui::buffer::Buffer::empty(area);
743 v.render(area, &mut buf);
744 }
745
746 #[test]
747 fn every_selectable_theme_previews_and_renders_through_the_same_surface() {
748 let area = ratatui::layout::Rect::new(0, 0, 100, 32);
749 let mut view = ThemePickerView::new("system".to_string());
750
751 for expected in SELECTABLE_THEMES.iter().copied() {
752 let index = view
753 .controller
754 .options()
755 .iter()
756 .position(|option| option.id.as_ref() == expected.name())
757 .expect("selectable theme option");
758 let _ = view.controller.select_source_index(index);
759 assert_eq!(view.current(), expected);
760 assert_eq!(
761 selected_values(&view.preview_event()),
762 Some((expected.name(), false))
763 );
764
765 let mut buf = ratatui::buffer::Buffer::empty(area);
766 view.render(area, &mut buf);
767 let text = buf
768 .content()
769 .iter()
770 .map(|cell| cell.symbol())
771 .collect::<String>();
772 assert!(
773 text.contains(expected.display_name()),
774 "{} was not represented in its live preview surface",
775 expected.name()
776 );
777 assert!(text.contains("Enter save"));
778 }
779 }
780
781 #[test]
782 fn render_semantically_truncates_taglines_at_narrow_width() {
783 let v = ThemePickerView::new("system".to_string());
784 let area = ratatui::layout::Rect::new(0, 0, 56, 12);
785 let mut buf = ratatui::buffer::Buffer::empty(area);
786 v.render(area, &mut buf);
787 let rows = (0..area.height)
788 .map(|y| {
789 (0..area.width)
790 .map(|x| buf[(x, y)].symbol())
791 .collect::<String>()
792 })
793 .collect::<Vec<_>>();
794 let text = rows.join("\n");
795
796 assert!(text.contains('…'), "{text}");
797 for (idx, row) in rows.iter().enumerate() {
798 assert!(
799 crate::tui::ui_text::text_display_width(row) <= usize::from(area.width),
800 "line {idx} overflows: {row:?}"
801 );
802 }
803 }
804
805 /// The four terminal sizes the v0.8.66 modal blocker (#3732) requires
806 /// every overlay to remain readable and fully operable at.
807 const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
808
809 #[test]
810 fn theme_picker_is_usable_and_opaque_at_blocker_sizes() {
811 use crate::tui::views::ViewStack;
812 use ratatui::{buffer::Buffer, layout::Rect};
813 use unicode_width::UnicodeWidthStr;
814
815 for (w, h) in BLOCKER_SIZES {
816 let area = Rect::new(0, 0, w, h);
817 let mut buf = Buffer::empty(area);
818 for y in 0..h {
819 for x in 0..w {
820 buf[(x, y)].set_symbol("X");
821 }
822 }
823 let mut stack = ViewStack::new();
824 stack.push(ThemePickerView::new("system".to_string()));
825 stack.render(area, &mut buf);
826
827 let rows: Vec<String> = (0..h)
828 .map(|y| {
829 (0..w)
830 .map(|x| buf[(x, y)].symbol().to_string())
831 .collect::<String>()
832 })
833 .collect();
834 let text = rows.join("\n");
835
836 for label in ["preview", "save", "revert"] {
837 assert!(text.contains(label), "{w}x{h}: missing footer '{label}'");
838 }
839 assert!(
840 !text.contains('X'),
841 "{w}x{h}: background bleed-through into modal surface"
842 );
843 // The theme picker paints the *live* theme surface (not the shared
844 // ink), so assert the center cell is painted (no surviving
845 // sentinel) rather than checking a fixed background color.
846 assert_ne!(
847 buf[(w / 2, h / 2)].symbol(),
848 "X",
849 "{w}x{h}: modal interior must be painted"
850 );
851 for (y, row) in rows.iter().enumerate() {
852 assert!(
853 UnicodeWidthStr::width(row.trim_end()) <= w as usize,
854 "{w}x{h}: row {y} overflows width: {row:?}"
855 );
856 }
857 }
858 }
859
860 #[test]
861 fn theme_picker_uses_shared_settings_controller() {
862 let v = ThemePickerView::new("dracula".to_string());
863 assert_eq!(v.controller.original_id(), "dracula");
864 assert_eq!(v.controller.selected_id(), Some("dracula"));
865 // One row per selectable theme: no modifier rows beside them.
866 assert_eq!(v.controller.visible().len(), SELECTABLE_THEMES.len());
867 }
868
869 /// Goldens are stored without cell padding: every row is right-trimmed
870 /// and trailing empty rows are dropped, so `git diff --check` stays
871 /// clean.
872 fn trim_golden_rows(text: &str) -> String {
873 let mut rows: Vec<&str> = text.lines().map(str::trim_end).collect();
874 while rows.last().is_some_and(|row| row.is_empty()) {
875 rows.pop();
876 }
877 let mut out = rows.join("\n");
878 out.push('\n');
879 out
880 }
881
882 /// Slice C: cell-exact goldens for the picker surface with the default
883 /// theme selected — 14 rows plus the preview footer. A visual change
884 /// that cannot show as a golden diff did not happen. Re-bless with
885 /// `CODEWHALE_BLESS_GOLDENS=1`.
886 #[test]
887 fn theme_picker_matches_goldens_at_blocker_sizes() {
888 use crate::tui::golden_harness::{assert_matches_golden, render_golden_text};
889 for (w, h) in [(80u16, 24u16), (120u16, 32u16)] {
890 let rendered = render_golden_text(w, h, |buf| {
891 ThemePickerView::new("underwater".to_string()).render(Rect::new(0, 0, w, h), buf);
892 });
893 assert_matches_golden(
894 &format!("theme_picker_{w}x{h}"),
895 &trim_golden_rows(&rendered),
896 );
897 }
898 }
899 }
900
900 lines RUST