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