| 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 |