返回 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 //! Ocean-specific chrome (swatches, underwater surface, treatment copy) stays
6 //! here so the framework contract does not flatten visual character.
7 //!
8 //! Semantics preserved from the pre-framework picker:
9 //! - Up/Down emit a `ConfigUpdated{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 //! `ConfigUpdated{persist:false}` to restore the original theme name
13 //! that was active when the picker opened.
14
15 use std::borrow::Cow;
16 use std::cell::RefCell;
17
18 use crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};
19 use ratatui::{
20 buffer::Buffer,
21 layout::Rect,
22 style::{Color, Modifier, Style},
23 text::{Line, Span},
24 widgets::{Paragraph, Widget},
25 };
26
27 use crate::localization::{Locale, MessageId, tr};
28 use crate::palette::{SELECTABLE_THEMES, ThemeId, UiTheme};
29 use crate::tui::menu_style;
30 use crate::tui::settings_picker::{
31 PickerNavResult, SettingAvailability, SettingOption, SettingValues, SettingsPickerController,
32 SettingsPickerLayout, handle_nav_key,
33 };
34 use crate::tui::views::{
35 ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, render_modal_footer,
36 render_panel_scroll_rail, render_underwater_surface,
37 };
38
39 pub struct ThemePickerView {
40 controller: SettingsPickerController,
41 /// Cached UiTheme for `ThemeId::System`, captured once at construction
42 /// so the per-frame render doesn't re-invoke `UiTheme::detect()` (which
43 /// reads `COLORFGBG`) on every keystroke.
44 system_ui_theme: UiTheme,
45 /// Effective session treatment, reported separately from theme so the
46 /// picker never claims an ombre is active under Terminal or Flat.
47 ocean_treatment: crate::tui::ocean::OceanTreatment,
48 /// User-configured background applied on top of every named-theme preview.
49 /// Without carrying this into the picker, a customized Solarized Light
50 /// session would render ombre behind the modal but report Flat inside it.
51 background_override: Option<Color>,
52 row_hitboxes: RefCell<Vec<(Rect, usize)>>,
53 last_mouse_selected: Option<usize>,
54 /// UI locale captured from the app at construction (#4057 wave 2).
55 locale: Locale,
56 }
57
58 fn theme_options(original_name: &str) -> Vec<SettingOption> {
59 let current = original_name.trim().to_ascii_lowercase();
60 SELECTABLE_THEMES
61 .iter()
62 .copied()
63 .map(|id| {
64 let name = id.name();
65 SettingOption::builder(name, id.display_name())
66 .summary(id.tagline())
67 .detail(id.tagline())
68 .help("Pick a theme with live preview")
69 .values(SettingValues::new(
70 Cow::Owned(current.clone()),
71 Cow::Borrowed("system"),
72 Cow::Borrowed(name),
73 ))
74 .availability(SettingAvailability::Available)
75 .tab("themes")
76 .prefer_list_when_narrow(true)
77 .build()
78 })
79 .collect()
80 }
81
82 impl ThemePickerView {
83 #[cfg(test)]
84 #[must_use]
85 pub fn new(original_name: String) -> Self {
86 Self::new_with_treatment(
87 original_name,
88 crate::tui::ocean::OceanTreatment::Ombre,
89 Locale::En,
90 )
91 }
92
93 #[cfg(test)]
94 #[must_use]
95 pub fn new_with_treatment(
96 original_name: String,
97 ocean_treatment: crate::tui::ocean::OceanTreatment,
98 locale: Locale,
99 ) -> Self {
100 Self::new_with_treatment_and_background(original_name, ocean_treatment, locale, None)
101 }
102
103 fn new_with_treatment_and_background(
104 original_name: String,
105 ocean_treatment: crate::tui::ocean::OceanTreatment,
106 locale: Locale,
107 background_override: Option<Color>,
108 ) -> Self {
109 let options = theme_options(&original_name);
110 let mut controller = SettingsPickerController::new(options, original_name.clone());
111 // Land on the persisted theme when it matches a selectable id.
112 let normalized = original_name.trim().to_ascii_lowercase();
113 if let Some(source) = SELECTABLE_THEMES
114 .iter()
115 .position(|id| id.name() == normalized)
116 {
117 let _ = controller.select_source_index(source);
118 }
119 Self {
120 controller,
121 system_ui_theme: UiTheme::detect(),
122 ocean_treatment,
123 background_override,
124 row_hitboxes: RefCell::new(Vec::new()),
125 last_mouse_selected: None,
126 locale,
127 }
128 }
129
130 /// Construct behind type erasure before returning to the async event loop.
131 /// Keeping the concrete picker out of that already-large future prevents
132 /// transient modal values from inflating the main-thread stack frame.
133 #[must_use]
134 pub fn boxed_with_treatment(
135 original_name: String,
136 ocean_treatment: crate::tui::ocean::OceanTreatment,
137 locale: Locale,
138 background_override: Option<Color>,
139 ) -> Box<dyn ModalView> {
140 Box::new(Self::new_with_treatment_and_background(
141 original_name,
142 ocean_treatment,
143 locale,
144 background_override,
145 ))
146 }
147
148 fn current(&self) -> ThemeId {
149 self.controller
150 .selected_id()
151 .and_then(|name| {
152 SELECTABLE_THEMES
153 .iter()
154 .copied()
155 .find(|id| id.name() == name)
156 })
157 .unwrap_or(ThemeId::System)
158 }
159
160 #[cfg(test)]
161 fn selected(&self) -> usize {
162 self.controller.selected_source_index().unwrap_or(0)
163 }
164
165 /// Resolve a theme to a `UiTheme`, returning the cached `System`
166 /// resolution to avoid repeated env-var reads inside `render`.
167 fn ui_theme_for(&self, id: ThemeId) -> UiTheme {
168 let theme = if matches!(id, ThemeId::System) {
169 self.system_ui_theme
170 } else {
171 id.ui_theme()
172 };
173 self.background_override
174 .map_or(theme, |background| theme.with_background_color(background))
175 }
176
177 fn preview_event(&self) -> ViewAction {
178 ViewAction::Emit(ViewEvent::ConfigUpdated {
179 key: "theme".to_string(),
180 value: self.current().name().to_string(),
181 persist: false,
182 })
183 }
184
185 fn commit_event(&self) -> ViewAction {
186 ViewAction::EmitAndClose(ViewEvent::ConfigUpdated {
187 key: "theme".to_string(),
188 value: self.current().name().to_string(),
189 persist: true,
190 })
191 }
192
193 fn revert_event(&self) -> ViewAction {
194 ViewAction::EmitAndClose(ViewEvent::ConfigUpdated {
195 key: "theme".to_string(),
196 value: self.controller.original_id().to_string(),
197 persist: false,
198 })
199 }
200
201 fn action_from_nav(&self, result: PickerNavResult) -> ViewAction {
202 match result {
203 PickerNavResult::Preview => self.preview_event(),
204 PickerNavResult::Commit => self.commit_event(),
205 PickerNavResult::Cancel => self.revert_event(),
206 PickerNavResult::ItemAction | PickerNavResult::None => ViewAction::None,
207 }
208 }
209
210 fn move_up(&mut self) -> ViewAction {
211 let result = self.controller.move_up();
212 self.action_from_nav(result)
213 }
214
215 fn move_down(&mut self) -> ViewAction {
216 let result = self.controller.move_down();
217 self.action_from_nav(result)
218 }
219 }
220
221 impl ModalView for ThemePickerView {
222 fn kind(&self) -> ModalKind {
223 ModalKind::ThemePicker
224 }
225
226 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
227 self
228 }
229
230 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
231 match mouse.kind {
232 MouseEventKind::ScrollUp => {
233 self.last_mouse_selected = None;
234 self.move_up()
235 }
236 MouseEventKind::ScrollDown => {
237 self.last_mouse_selected = None;
238 self.move_down()
239 }
240 MouseEventKind::Down(MouseButton::Left) => {
241 let clicked = self.row_hitboxes.borrow().iter().find_map(|(rect, idx)| {
242 rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
243 .then_some(*idx)
244 });
245 if let Some(idx) = clicked {
246 let commit = self.last_mouse_selected == Some(idx)
247 && self.controller.selected_source_index() == Some(idx);
248 let nav = self.controller.select_source_index(idx);
249 self.last_mouse_selected = Some(idx);
250 if commit {
251 self.commit_event()
252 } else {
253 self.action_from_nav(nav)
254 }
255 } else {
256 ViewAction::None
257 }
258 }
259 _ => ViewAction::None,
260 }
261 }
262
263 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
264 // Theme picker keeps digit-jump / vim keys; search typing stays off so
265 // `j`/`k` and `1`..=`9` retain their navigation meaning.
266 let result = handle_nav_key(&mut self.controller, key, false);
267 self.action_from_nav(result)
268 }
269
270 fn render(&self, area: Rect, buf: &mut Buffer) {
271 self.row_hitboxes.borrow_mut().clear();
272 // The live theme has already been swapped under us via ConfigUpdated,
273 // so we pull the *current* preview's UiTheme from the cursor row to
274 // skin the modal chrome. That way the popup itself shifts color as
275 // the cursor moves, matching what the background will look like
276 // after Enter. We keep the live `surface_bg` (not the shared ink) and
277 // the bare `Clear` so the preview backdrop reads as intended.
278 let live = self.ui_theme_for(self.current());
279 let inner =
280 render_underwater_surface(area, buf, tr(self.locale, MessageId::ThemeSurfaceTitle));
281
282 let content = render_modal_footer(
283 inner,
284 buf,
285 &[
286 ActionHint::new("↑/↓", "preview"),
287 ActionHint::new("Enter", "save"),
288 ActionHint::new("Esc", "revert"),
289 ],
290 );
291
292 // Theme rows prefer list-when-narrow; layout still drives scroll math.
293 let _layout = SettingsPickerLayout::resolve(content, 34, self.controller.selected_option());
294
295 let mut lines: Vec<Line> = Vec::with_capacity(SELECTABLE_THEMES.len() + 3);
296 let treatment = if matches!(self.current(), ThemeId::Terminal) {
297 tr(self.locale, MessageId::ThemeTreatmentOmbreUnavailable)
298 } else if self.ocean_treatment.is_flat()
299 || crate::tui::ocean::OceanRamp::for_theme(&live).is_none()
300 {
301 tr(self.locale, MessageId::ThemeTreatmentFlatActive)
302 } else {
303 tr(self.locale, MessageId::ThemeTreatmentOmbreActive)
304 };
305 lines.push(Line::from(Span::styled(
306 treatment,
307 Style::default().fg(live.text_hint),
308 )));
309 lines.push(Line::from(""));
310
311 let header_rows = lines.len();
312 let visible_rows = usize::from(content.height)
313 .saturating_sub(header_rows)
314 .max(1);
315 let source_count = self.controller.visible().len();
316 let selected_visible = self.controller.selected_visible();
317 let max_start = source_count.saturating_sub(visible_rows);
318 let start = selected_visible
319 .saturating_sub(visible_rows.saturating_sub(1))
320 .min(max_start);
321 let content = render_panel_scroll_rail(
322 content,
323 buf,
324 source_count.saturating_add(header_rows),
325 start,
326 visible_rows,
327 true,
328 );
329
330 for (visible_idx, &source_idx) in self
331 .controller
332 .visible()
333 .iter()
334 .enumerate()
335 .skip(start)
336 .take(visible_rows)
337 {
338 let row_y = content.y.saturating_add(lines.len() as u16);
339 self.row_hitboxes
340 .borrow_mut()
341 .push((Rect::new(content.x, row_y, content.width, 1), source_idx));
342 let id = SELECTABLE_THEMES
343 .get(source_idx)
344 .copied()
345 .unwrap_or(ThemeId::System);
346 let is_selected = visible_idx == selected_visible;
347 let row_style = if is_selected {
348 menu_style::theme_selected_row_style(&live)
349 } else {
350 Style::default().fg(live.text_body)
351 };
352 let tagline_style = if is_selected {
353 Style::default().fg(live.text_muted).bg(live.selection_bg)
354 } else {
355 Style::default().fg(live.text_dim)
356 };
357 let number_style = if is_selected {
358 Style::default()
359 .fg(live.status_working)
360 .bg(live.selection_bg)
361 .add_modifier(Modifier::BOLD)
362 } else {
363 Style::default().fg(live.text_hint)
364 };
365 let pointer = crate::tui::glyphs::selection_marker(is_selected);
366
367 // 3-cell color swatch per row using the candidate theme's own
368 // accent + panel + border colors so the picker doubles as a
369 // legend. Use the cached resolver so `System` doesn't repeat
370 // `UiTheme::detect()`.
371 let row_theme = self.ui_theme_for(id);
372 let swatch = vec![
373 Span::styled(" ", Style::default().bg(row_theme.surface_bg)),
374 Span::styled(" ", Style::default().bg(row_theme.panel_bg)),
375 Span::styled(" ", Style::default().bg(row_theme.status_working)),
376 Span::styled(" ", Style::default().bg(row_theme.mode_yolo)),
377 Span::styled(" ", Style::default().bg(row_theme.mode_plan)),
378 ];
379
380 let mut spans: Vec<Span> = Vec::with_capacity(8);
381 spans.push(Span::styled(format!(" {pointer} "), row_style));
382 spans.push(Span::styled(format!("{}. ", visible_idx + 1), number_style));
383 spans.push(Span::styled(
384 format!("{:<22}", id.display_name()),
385 row_style,
386 ));
387 spans.extend(swatch);
388 spans.push(Span::raw(" "));
389
390 let prefix_width = Line::from(spans.clone()).width();
391 let tagline = crate::tui::ui_text::semantic_truncate(
392 id.tagline(),
393 usize::from(content.width).saturating_sub(prefix_width),
394 );
395 spans.push(Span::styled(tagline, tagline_style));
396
397 lines.push(Line::from(spans));
398 }
399
400 Paragraph::new(lines).render(content, buf);
401 }
402 }
403
404 #[cfg(test)]
405 mod tests {
406 use super::*;
407 use crossterm::event::{KeyCode, KeyModifiers};
408
409 fn key(code: KeyCode) -> KeyEvent {
410 KeyEvent::new(code, KeyModifiers::NONE)
411 }
412
413 fn selected_name(action: &ViewAction) -> Option<&str> {
414 match action {
415 ViewAction::Emit(ViewEvent::ConfigUpdated { key, value, .. })
416 | ViewAction::EmitAndClose(ViewEvent::ConfigUpdated { key, value, .. })
417 if key == "theme" =>
418 {
419 Some(value.as_str())
420 }
421 _ => None,
422 }
423 }
424
425 #[test]
426 fn opens_at_persisted_theme() {
427 let v = ThemePickerView::new("tokyo-night".to_string());
428 assert_eq!(v.current(), ThemeId::TokyoNight);
429 }
430
431 #[test]
432 fn unknown_persisted_name_falls_back_to_first_row() {
433 let v = ThemePickerView::new("not-a-real-theme".to_string());
434 assert_eq!(v.selected(), 0);
435 assert_eq!(v.current(), ThemeId::System);
436 }
437
438 #[test]
439 fn arrow_down_previews_next_theme() {
440 let mut v = ThemePickerView::new("system".to_string());
441 let action = v.handle_key(key(KeyCode::Down));
442 assert!(matches!(action, ViewAction::Emit(_)));
443 assert_eq!(selected_name(&action), Some(ThemeId::Terminal.name()));
444 }
445
446 #[test]
447 fn mouse_wheel_previews_and_second_row_click_commits() {
448 let mut v = ThemePickerView::new("system".to_string());
449 let wheel = v.handle_mouse(MouseEvent {
450 kind: MouseEventKind::ScrollDown,
451 column: 0,
452 row: 0,
453 modifiers: KeyModifiers::NONE,
454 });
455 assert!(matches!(wheel, ViewAction::Emit(_)));
456 assert_eq!(selected_name(&wheel), Some(ThemeId::Terminal.name()));
457
458 let area = Rect::new(0, 0, 100, 30);
459 let mut buf = Buffer::empty(area);
460 v.render(area, &mut buf);
461 let (rect, idx) = v.row_hitboxes.borrow()[2];
462 let click = MouseEvent {
463 kind: MouseEventKind::Down(MouseButton::Left),
464 column: rect.x,
465 row: rect.y,
466 modifiers: KeyModifiers::NONE,
467 };
468 let preview = v.handle_mouse(click);
469 assert!(matches!(preview, ViewAction::Emit(_)));
470 assert_eq!(v.selected(), idx);
471 let commit = v.handle_mouse(click);
472 assert!(matches!(commit, ViewAction::EmitAndClose(_)));
473 }
474
475 #[test]
476 fn arrow_navigation_wraps_at_picker_edges() {
477 let mut v = ThemePickerView::new("system".to_string());
478 let last = SELECTABLE_THEMES.last().unwrap();
479
480 let action = v.handle_key(key(KeyCode::Up));
481 assert_eq!(selected_name(&action), Some(last.name()));
482
483 let action = v.handle_key(key(KeyCode::Down));
484 assert_eq!(selected_name(&action), Some(SELECTABLE_THEMES[0].name()));
485 }
486
487 #[test]
488 fn enter_commits_with_persist_true() {
489 let mut v = ThemePickerView::new("system".to_string());
490 v.handle_key(key(KeyCode::Down));
491 v.handle_key(key(KeyCode::Down));
492 v.handle_key(key(KeyCode::Down));
493 v.handle_key(key(KeyCode::Down));
494 v.handle_key(key(KeyCode::Down)); // -> CatppuccinMocha
495 let action = v.handle_key(key(KeyCode::Enter));
496 match action {
497 ViewAction::EmitAndClose(ViewEvent::ConfigUpdated {
498 key,
499 value,
500 persist,
501 }) => {
502 assert_eq!(key, "theme");
503 assert_eq!(value, ThemeId::CatppuccinMocha.name());
504 assert!(persist);
505 }
506 other => panic!("expected commit, got {other:?}"),
507 }
508 }
509
510 #[test]
511 fn esc_reverts_to_original() {
512 let mut v = ThemePickerView::new("dracula".to_string());
513 v.handle_key(key(KeyCode::Up));
514 v.handle_key(key(KeyCode::Up));
515 let action = v.handle_key(key(KeyCode::Esc));
516 match action {
517 ViewAction::EmitAndClose(ViewEvent::ConfigUpdated {
518 key,
519 value,
520 persist,
521 }) => {
522 assert_eq!(key, "theme");
523 assert_eq!(value, "dracula");
524 assert!(!persist);
525 }
526 other => panic!("expected revert, got {other:?}"),
527 }
528 }
529
530 #[test]
531 fn digit_jumps_to_row() {
532 let mut v = ThemePickerView::new("system".to_string());
533 let action = v.handle_key(key(KeyCode::Char('6')));
534 // Row 6 (1-indexed) -> index 5 -> CatppuccinMocha
535 assert_eq!(
536 selected_name(&action),
537 Some(ThemeId::CatppuccinMocha.name())
538 );
539 }
540
541 #[test]
542 fn digit_zero_is_rejected_not_remapped_to_row_zero() {
543 let mut v = ThemePickerView::new("dracula".to_string());
544 let before = v.selected();
545 let action = v.handle_key(key(KeyCode::Char('0')));
546 assert!(matches!(action, ViewAction::None));
547 assert_eq!(v.selected(), before, "'0' should not move the cursor");
548 }
549
550 #[test]
551 fn render_does_not_panic_on_zero_sized_area() {
552 // The picker historically panicked here via .max(W).max(H) floors
553 // that produced dimensions larger than the available area, then
554 // underflowed the centering arithmetic.
555 let v = ThemePickerView::new("system".to_string());
556 let outer = ratatui::layout::Rect::new(0, 0, 10, 10);
557 let area = ratatui::layout::Rect::new(0, 0, 0, 0);
558 let mut buf = ratatui::buffer::Buffer::empty(outer);
559 v.render(area, &mut buf);
560 }
561
562 #[test]
563 fn render_does_not_panic_on_tiny_area() {
564 // 20×6 is smaller than every soft floor the picker prefers.
565 let v = ThemePickerView::new("system".to_string());
566 let area = ratatui::layout::Rect::new(0, 0, 20, 6);
567 let mut buf = ratatui::buffer::Buffer::empty(area);
568 v.render(area, &mut buf);
569 }
570
571 #[test]
572 fn treatment_report_names_effective_appearance() {
573 let area = ratatui::layout::Rect::new(0, 0, 100, 30);
574
575 let flat = ThemePickerView::new_with_treatment(
576 "dark".to_string(),
577 crate::tui::ocean::OceanTreatment::Flat,
578 Locale::En,
579 );
580 let mut flat_buf = ratatui::buffer::Buffer::empty(area);
581 flat.render(area, &mut flat_buf);
582 let flat_text = flat_buf
583 .content()
584 .iter()
585 .map(|cell| cell.symbol())
586 .collect::<String>();
587 assert!(flat_text.contains("Treatment Flat — active"));
588
589 let terminal = ThemePickerView::new_with_treatment(
590 "terminal".to_string(),
591 crate::tui::ocean::OceanTreatment::Ombre,
592 Locale::En,
593 );
594 let mut terminal_buf = ratatui::buffer::Buffer::empty(area);
595 terminal.render(area, &mut terminal_buf);
596 let terminal_text = terminal_buf
597 .content()
598 .iter()
599 .map(|cell| cell.symbol())
600 .collect::<String>();
601 assert!(terminal_text.contains("Ombre unavailable"));
602 assert!(terminal_text.contains("Terminal owns the background"));
603
604 let solarized = ThemePickerView::new_with_treatment(
605 "solarized-light".to_string(),
606 crate::tui::ocean::OceanTreatment::Ombre,
607 Locale::En,
608 );
609 let mut solarized_buf = ratatui::buffer::Buffer::empty(area);
610 solarized.render(area, &mut solarized_buf);
611 let solarized_text = solarized_buf
612 .content()
613 .iter()
614 .map(|cell| cell.symbol())
615 .collect::<String>();
616 assert!(solarized_text.contains("Treatment Flat — active"));
617 assert!(!solarized_text.contains("Treatment Ombre — active"));
618
619 let solarized_custom = ThemePickerView::new_with_treatment_and_background(
620 "solarized-light".to_string(),
621 crate::tui::ocean::OceanTreatment::Ombre,
622 Locale::En,
623 Some(Color::Rgb(0x1a, 0x1b, 0x26)),
624 );
625 let mut solarized_custom_buf = ratatui::buffer::Buffer::empty(area);
626 solarized_custom.render(area, &mut solarized_custom_buf);
627 let solarized_custom_text = solarized_custom_buf
628 .content()
629 .iter()
630 .map(|cell| cell.symbol())
631 .collect::<String>();
632 assert!(solarized_custom_text.contains("Treatment Ombre — active"));
633 assert!(!solarized_custom_text.contains("Treatment Flat — active"));
634 }
635
636 #[test]
637 fn every_selectable_theme_previews_and_renders_through_the_same_surface() {
638 let area = ratatui::layout::Rect::new(0, 0, 100, 32);
639 let mut view = ThemePickerView::new("system".to_string());
640
641 for (index, expected) in SELECTABLE_THEMES.iter().copied().enumerate() {
642 let _ = view.controller.select_source_index(index);
643 assert_eq!(view.current(), expected);
644 assert_eq!(selected_name(&view.preview_event()), Some(expected.name()));
645
646 let mut buf = ratatui::buffer::Buffer::empty(area);
647 view.render(area, &mut buf);
648 let text = buf
649 .content()
650 .iter()
651 .map(|cell| cell.symbol())
652 .collect::<String>();
653 assert!(
654 text.contains(expected.display_name()),
655 "{} was not represented in its live preview surface",
656 expected.name()
657 );
658 assert!(text.contains("Treatment"));
659 assert!(text.contains("Enter save"));
660 }
661 }
662
663 #[test]
664 fn render_semantically_truncates_taglines_at_narrow_width() {
665 let v = ThemePickerView::new("system".to_string());
666 let area = ratatui::layout::Rect::new(0, 0, 56, 12);
667 let mut buf = ratatui::buffer::Buffer::empty(area);
668 v.render(area, &mut buf);
669 let rows = (0..area.height)
670 .map(|y| {
671 (0..area.width)
672 .map(|x| buf[(x, y)].symbol())
673 .collect::<String>()
674 })
675 .collect::<Vec<_>>();
676 let text = rows.join("\n");
677
678 assert!(text.contains('…'), "{text}");
679 for (idx, row) in rows.iter().enumerate() {
680 assert!(
681 crate::tui::ui_text::text_display_width(row) <= usize::from(area.width),
682 "line {idx} overflows: {row:?}"
683 );
684 }
685 }
686
687 /// The four terminal sizes the v0.8.66 modal blocker (#3732) requires
688 /// every overlay to remain readable and fully operable at.
689 const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
690
691 #[test]
692 fn theme_picker_is_usable_and_opaque_at_blocker_sizes() {
693 use crate::tui::views::ViewStack;
694 use ratatui::{buffer::Buffer, layout::Rect};
695 use unicode_width::UnicodeWidthStr;
696
697 for (w, h) in BLOCKER_SIZES {
698 let area = Rect::new(0, 0, w, h);
699 let mut buf = Buffer::empty(area);
700 for y in 0..h {
701 for x in 0..w {
702 buf[(x, y)].set_symbol("X");
703 }
704 }
705 let mut stack = ViewStack::new();
706 stack.push(ThemePickerView::new("system".to_string()));
707 stack.render(area, &mut buf);
708
709 let rows: Vec<String> = (0..h)
710 .map(|y| {
711 (0..w)
712 .map(|x| buf[(x, y)].symbol().to_string())
713 .collect::<String>()
714 })
715 .collect();
716 let text = rows.join("\n");
717
718 for label in ["preview", "save", "revert"] {
719 assert!(text.contains(label), "{w}x{h}: missing footer '{label}'");
720 }
721 assert!(
722 !text.contains('X'),
723 "{w}x{h}: background bleed-through into modal surface"
724 );
725 // The theme picker paints the *live* theme surface (not the shared
726 // ink), so assert the center cell is painted (no surviving
727 // sentinel) rather than checking a fixed background color.
728 assert_ne!(
729 buf[(w / 2, h / 2)].symbol(),
730 "X",
731 "{w}x{h}: modal interior must be painted"
732 );
733 for (y, row) in rows.iter().enumerate() {
734 assert!(
735 UnicodeWidthStr::width(row.trim_end()) <= w as usize,
736 "{w}x{h}: row {y} overflows width: {row:?}"
737 );
738 }
739 }
740 }
741
742 #[test]
743 fn theme_picker_uses_shared_settings_controller() {
744 let v = ThemePickerView::new("dracula".to_string());
745 assert_eq!(v.controller.original_id(), "dracula");
746 assert_eq!(v.controller.selected_id(), Some("dracula"));
747 assert_eq!(v.controller.visible().len(), SELECTABLE_THEMES.len());
748 }
749 }
750
750 lines RUST