diff --git a/src/cortex-tui/src/app/methods.rs b/src/cortex-tui/src/app/methods.rs index d29c936..6156596 100644 --- a/src/cortex-tui/src/app/methods.rs +++ b/src/cortex-tui/src/app/methods.rs @@ -631,6 +631,43 @@ impl AppState { } } +// ============================================================================ +// APPSTATE METHODS - Theme Preview +// ============================================================================ + +impl AppState { + /// Start previewing a theme. + /// + /// This updates the cached theme colors to show the preview theme, + /// without changing the active theme. + pub fn start_theme_preview(&mut self, theme_name: &str) { + self.set_preview_theme(Some(theme_name.to_string())); + } + + /// Cancel theme preview and revert to the original (active) theme. + /// + /// Restores the cached colors to the active theme. + pub fn cancel_theme_preview(&mut self) { + self.set_preview_theme(None); + } + + /// Confirm the previewed theme as the active theme. + /// + /// Makes the preview theme the new active theme and clears the preview state. + pub fn confirm_theme_preview(&mut self) { + if let Some(preview) = self.preview_theme.take() { + self.active_theme = preview.clone(); + // Colors are already set to the preview theme, just need to clear preview state + self.preview_theme = None; + } + } + + /// Check if a theme preview is active. + pub fn has_theme_preview(&self) -> bool { + self.preview_theme.is_some() + } +} + // ============================================================================ // APPSTATE METHODS - Operation Mode // ============================================================================ diff --git a/src/cortex-tui/src/app/state.rs b/src/cortex-tui/src/app/state.rs index d79a5f9..cb63e4d 100644 --- a/src/cortex-tui/src/app/state.rs +++ b/src/cortex-tui/src/app/state.rs @@ -106,6 +106,8 @@ pub struct AppState { pub input_mode: crate::interactive::InputMode, /// Active theme name pub active_theme: String, + /// Preview theme name (for live theme preview in selector) + pub preview_theme: Option, /// Cached theme colors for quick access pub theme_colors: ThemeColors, /// Cached markdown theme for quick access @@ -238,6 +240,7 @@ impl AppState { diff_scroll: 0, input_mode: crate::interactive::InputMode::Normal, active_theme: "dark".to_string(), + preview_theme: None, theme_colors: ThemeColors::dark(), markdown_theme: MarkdownTheme::default(), compact_mode: false, @@ -372,10 +375,32 @@ impl AppState { /// Change the active theme pub fn set_theme(&mut self, name: &str) { self.active_theme = name.to_string(); + self.preview_theme = None; // Clear any preview when setting the theme self.theme_colors = ThemeColors::from_name(name); self.markdown_theme = MarkdownTheme::from_name(name); } + /// Set a preview theme for live preview functionality + /// + /// Updates the cached theme_colors to the preview theme colors. + pub fn set_preview_theme(&mut self, theme: Option) { + self.preview_theme = theme.clone(); + // Update cached colors based on preview or active theme + let effective_theme = theme.as_deref().unwrap_or(&self.active_theme); + self.theme_colors = ThemeColors::from_name(effective_theme); + self.markdown_theme = MarkdownTheme::from_name(effective_theme); + } + + /// Get the effective theme colors (preview if set, otherwise active) + pub fn get_effective_theme_colors(&self) -> &ThemeColors { + &self.theme_colors + } + + /// Get the name of the effective theme (preview if set, otherwise active) + pub fn get_effective_theme_name(&self) -> &str { + self.preview_theme.as_deref().unwrap_or(&self.active_theme) + } + /// Get AdaptiveColors from the current theme pub fn adaptive_colors(&self) -> crate::ui::AdaptiveColors { crate::ui::AdaptiveColors::from_theme_colors(&self.theme_colors) diff --git a/src/cortex-tui/src/modal/mod.rs b/src/cortex-tui/src/modal/mod.rs index b0ac72d..7478936 100644 --- a/src/cortex-tui/src/modal/mod.rs +++ b/src/cortex-tui/src/modal/mod.rs @@ -97,6 +97,8 @@ pub enum ModalResult { Close, /// Perform an action and close Action(ModalAction), + /// Perform an action but keep the modal open (for live preview) + ActionContinue(ModalAction), /// Push a new modal on top of this one Push(Box), /// Replace this modal with another @@ -172,6 +174,14 @@ pub enum ModalAction { api_key: String, }, + // Theme Preview Actions + /// Preview a theme without applying it permanently + PreviewTheme(String), + /// Revert to the original theme (cancel preview) + RevertTheme, + /// Confirm and apply the previewed theme + ConfirmTheme(String), + // Generic/Custom Custom(String), } @@ -253,6 +263,10 @@ impl ModalStack { self.pop(); ModalResult::Action(action) } + ModalResult::ActionContinue(action) => { + // Return the action but keep the modal open (for live preview) + ModalResult::ActionContinue(action) + } other => other, } } else { diff --git a/src/cortex-tui/src/modal/theme.rs b/src/cortex-tui/src/modal/theme.rs index 80c7164..bf1c012 100644 --- a/src/cortex-tui/src/modal/theme.rs +++ b/src/cortex-tui/src/modal/theme.rs @@ -1,12 +1,14 @@ //! Theme Selector Modal //! //! A modal for selecting the application theme (dark, light, ocean_dark, monokai). +//! Supports live preview: as the user navigates up/down, the entire TUI updates +//! to show the selected theme's colors before confirming with Enter. -use cortex_core::style::{CYAN_PRIMARY, SURFACE_0, TEXT, TEXT_DIM, VOID}; +use cortex_core::style::{CYAN_PRIMARY, SURFACE_0, TEXT, TEXT_DIM, ThemeColors, VOID}; use crossterm::event::{KeyCode, KeyEvent}; use ratatui::buffer::Buffer; use ratatui::layout::Rect; -use ratatui::style::Style; +use ratatui::style::{Color, Style}; use ratatui::widgets::Widget; use crate::widgets::ActionBar; @@ -52,11 +54,18 @@ const THEMES: &[ThemeDef] = &[ // THEME SELECTOR MODAL // ============================================================================ -/// A modal for selecting the application theme. +/// A modal for selecting the application theme with live preview support. +/// +/// When the user navigates through themes, the modal emits preview actions +/// that update the TUI colors in real-time. On confirm (Enter), the theme +/// is persisted. On cancel (Esc), the original theme is restored. pub struct ThemeSelectorModal { /// Index of the currently selected theme in the list. selected_index: usize, - /// Current theme name for highlighting. + /// Original theme name stored for reference (restored via RevertTheme action). + #[allow(dead_code)] + original_theme: String, + /// Current theme name for highlighting (tracks the active/confirmed theme). current_theme: String, } @@ -64,6 +73,7 @@ impl ThemeSelectorModal { /// Create a new ThemeSelectorModal. /// /// The modal pre-selects the current theme so users can see which theme is active. + /// The original theme is stored for potential revert on cancel. pub fn new(current_theme: &str) -> Self { // Find the current theme's index to pre-select it let selected_index = THEMES @@ -73,6 +83,7 @@ impl ThemeSelectorModal { Self { selected_index, + original_theme: current_theme.to_string(), current_theme: current_theme.to_string(), } } @@ -87,21 +98,33 @@ impl ThemeSelectorModal { ActionBar::new().with_standard_hints() } - /// Navigate up in the list. - fn navigate_up(&mut self) { + /// Navigate up in the list and return the new theme for preview. + fn navigate_up(&mut self) -> Option<&'static str> { if self.selected_index > 0 { self.selected_index -= 1; + self.selected_theme_id() + } else { + None } } - /// Navigate down in the list. - fn navigate_down(&mut self) { + /// Navigate down in the list and return the new theme for preview. + fn navigate_down(&mut self) -> Option<&'static str> { if self.selected_index < THEMES.len().saturating_sub(1) { self.selected_index += 1; + self.selected_theme_id() + } else { + None } } - /// Render a theme row. + /// Get color swatch for a theme (shows primary color sample). + fn get_theme_swatch_color(theme_id: &str) -> Color { + let colors = ThemeColors::from_name(theme_id); + colors.primary + } + + /// Render a theme row with color swatch. fn render_theme_row( &self, x: u16, @@ -135,6 +158,11 @@ impl ThemeSelectorModal { buf.set_string(col, y, marker, Style::default().fg(marker_fg).bg(bg)); col += 2; + // Color swatch (shows theme's primary color) + let swatch_color = Self::get_theme_swatch_color(theme.id); + buf.set_string(col, y, "■", Style::default().fg(swatch_color).bg(bg)); + col += 2; + // Theme label buf.set_string(col, y, theme.label, Style::default().fg(fg).bg(bg)); col += theme.label.len() as u16 + 2; @@ -215,21 +243,33 @@ impl Modal for ThemeSelectorModal { fn handle_key(&mut self, key: KeyEvent) -> ModalResult { match key.code { - KeyCode::Esc => ModalResult::Close, + KeyCode::Esc => { + // Revert to original theme on cancel + ModalResult::Action(ModalAction::RevertTheme) + } KeyCode::Enter => { + // Confirm the currently selected theme if let Some(theme_id) = self.selected_theme_id() { - ModalResult::Action(ModalAction::Custom(format!("theme:{}", theme_id))) + ModalResult::Action(ModalAction::ConfirmTheme(theme_id.to_string())) } else { - ModalResult::Close + ModalResult::Action(ModalAction::RevertTheme) } } KeyCode::Up | KeyCode::Char('k') => { - self.navigate_up(); - ModalResult::Continue + // Navigate up and trigger live preview + if let Some(theme_id) = self.navigate_up() { + ModalResult::ActionContinue(ModalAction::PreviewTheme(theme_id.to_string())) + } else { + ModalResult::Continue + } } KeyCode::Down | KeyCode::Char('j') => { - self.navigate_down(); - ModalResult::Continue + // Navigate down and trigger live preview + if let Some(theme_id) = self.navigate_down() { + ModalResult::ActionContinue(ModalAction::PreviewTheme(theme_id.to_string())) + } else { + ModalResult::Continue + } } _ => ModalResult::Continue, } @@ -258,6 +298,7 @@ mod tests { let modal = ThemeSelectorModal::new("dark"); assert_eq!(modal.title(), "Select Theme"); assert_eq!(modal.current_theme, "dark"); + assert_eq!(modal.original_theme, "dark"); } #[test] @@ -290,85 +331,118 @@ mod tests { } #[test] - fn test_enter_returns_action() { + fn test_enter_returns_confirm_action() { let mut modal = ThemeSelectorModal::new("dark"); let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); let result = modal.handle_key(key); - if let ModalResult::Action(ModalAction::Custom(action)) = result { - assert_eq!(action, "theme:dark"); + if let ModalResult::Action(ModalAction::ConfirmTheme(theme)) = result { + assert_eq!(theme, "dark"); } else { - panic!("Expected Custom action"); + panic!("Expected ConfirmTheme action"); } } #[test] - fn test_escape_closes() { + fn test_escape_reverts_theme() { let mut modal = ThemeSelectorModal::new("dark"); let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); let result = modal.handle_key(key); - assert!(matches!(result, ModalResult::Close)); + assert!(matches!( + result, + ModalResult::Action(ModalAction::RevertTheme) + )); } #[test] - fn test_navigate_down() { + fn test_navigate_down_triggers_preview() { let mut modal = ThemeSelectorModal::new("dark"); assert_eq!(modal.selected_index, 0); let down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE); - modal.handle_key(down); + let result = modal.handle_key(down); + assert_eq!(modal.selected_index, 1); assert_eq!(modal.selected_theme_id(), Some("light")); + + // Should return ActionContinue with preview + if let ModalResult::ActionContinue(ModalAction::PreviewTheme(theme)) = result { + assert_eq!(theme, "light"); + } else { + panic!("Expected ActionContinue with PreviewTheme"); + } } #[test] - fn test_navigate_up() { + fn test_navigate_up_triggers_preview() { let mut modal = ThemeSelectorModal::new("light"); assert_eq!(modal.selected_index, 1); let up = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE); - modal.handle_key(up); + let result = modal.handle_key(up); + assert_eq!(modal.selected_index, 0); assert_eq!(modal.selected_theme_id(), Some("dark")); + + // Should return ActionContinue with preview + if let ModalResult::ActionContinue(ModalAction::PreviewTheme(theme)) = result { + assert_eq!(theme, "dark"); + } else { + panic!("Expected ActionContinue with PreviewTheme"); + } } #[test] - fn test_navigate_up_at_top() { + fn test_navigate_up_at_top_no_preview() { let mut modal = ThemeSelectorModal::new("dark"); assert_eq!(modal.selected_index, 0); let up = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE); - modal.handle_key(up); + let result = modal.handle_key(up); + assert_eq!(modal.selected_index, 0); // Should stay at 0 + // No preview action since we didn't move + assert!(matches!(result, ModalResult::Continue)); } #[test] - fn test_navigate_down_at_bottom() { + fn test_navigate_down_at_bottom_no_preview() { let mut modal = ThemeSelectorModal::new("monokai"); assert_eq!(modal.selected_index, 3); let down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE); - modal.handle_key(down); + let result = modal.handle_key(down); + assert_eq!(modal.selected_index, 3); // Should stay at 3 + // No preview action since we didn't move + assert!(matches!(result, ModalResult::Continue)); } #[test] - fn test_vim_navigation() { + fn test_vim_navigation_with_preview() { let mut modal = ThemeSelectorModal::new("dark"); - // j moves down + // j moves down and triggers preview let j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE); - modal.handle_key(j); + let result = modal.handle_key(j); assert_eq!(modal.selected_index, 1); + assert!(matches!( + result, + ModalResult::ActionContinue(ModalAction::PreviewTheme(_)) + )); - // k moves up + // k moves up and triggers preview let k = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE); - modal.handle_key(k); + let result = modal.handle_key(k); assert_eq!(modal.selected_index, 0); + assert!(matches!( + result, + ModalResult::ActionContinue(ModalAction::PreviewTheme(_)) + )); } #[test] - fn test_select_different_theme() { + fn test_select_different_theme_with_confirm() { let mut modal = ThemeSelectorModal::new("dark"); // Navigate to ocean_dark (index 2) @@ -377,17 +451,24 @@ mod tests { modal.handle_key(down); assert_eq!(modal.selected_theme_id(), Some("ocean_dark")); - // Select it + // Confirm it let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); let result = modal.handle_key(enter); - if let ModalResult::Action(ModalAction::Custom(action)) = result { - assert_eq!(action, "theme:ocean_dark"); + if let ModalResult::Action(ModalAction::ConfirmTheme(theme)) = result { + assert_eq!(theme, "ocean_dark"); } else { - panic!("Expected Custom action with ocean_dark theme"); + panic!("Expected ConfirmTheme action with ocean_dark theme"); } } + #[test] + fn test_original_theme_stored() { + let modal = ThemeSelectorModal::new("monokai"); + assert_eq!(modal.original_theme, "monokai"); + assert_eq!(modal.current_theme, "monokai"); + } + #[test] fn test_on_cancel() { let mut modal = ThemeSelectorModal::new("dark"); @@ -404,4 +485,17 @@ mod tests { assert!(height >= 6); assert!(height <= 12); } + + #[test] + fn test_theme_swatch_colors() { + // Verify we can get swatch colors for each theme + for theme in THEMES { + let color = ThemeSelectorModal::get_theme_swatch_color(theme.id); + // Just verify it doesn't panic and returns a valid color + match color { + Color::Rgb(_, _, _) => {} + _ => panic!("Expected RGB color for theme {}", theme.id), + } + } + } } diff --git a/src/cortex-tui/src/runner/event_loop/commands.rs b/src/cortex-tui/src/runner/event_loop/commands.rs index 3c8ce44..5fb69ef 100644 --- a/src/cortex-tui/src/runner/event_loop/commands.rs +++ b/src/cortex-tui/src/runner/event_loop/commands.rs @@ -303,9 +303,11 @@ impl EventLoop { .info("Timeline: Use scroll to navigate messages"); } ModalType::ThemePicker => { - let current = self.app_state.settings.get("theme").map(|s| s.as_str()); - let interactive = crate::interactive::builders::build_theme_selector(current); - self.app_state.enter_interactive_mode(interactive); + use crate::modal::ThemeSelectorModal; + // Use the effective theme (preview or active) for the modal + let current_theme = self.app_state.get_effective_theme_name().to_string(); + self.modal_stack + .push(Box::new(ThemeSelectorModal::new(¤t_theme))); } ModalType::Fork => { if let Some(ref session) = self.cortex_session { diff --git a/src/cortex-tui/src/runner/event_loop/input.rs b/src/cortex-tui/src/runner/event_loop/input.rs index 126b0d4..c3a5468 100644 --- a/src/cortex-tui/src/runner/event_loop/input.rs +++ b/src/cortex-tui/src/runner/event_loop/input.rs @@ -146,8 +146,16 @@ impl EventLoop { // Check modal stack first (new unified modal system) if self.modal_stack.is_active() { let result = self.modal_stack.handle_key(key_event); - if let ModalResult::Action(action) = result { - self.process_modal_action(action).await; + match result { + ModalResult::Action(action) => { + // Action closes the modal + self.process_modal_action(action).await; + } + ModalResult::ActionContinue(action) => { + // Action keeps the modal open (for live preview) + self.process_modal_action(action).await; + } + _ => {} } self.render(terminal)?; return Ok(()); diff --git a/src/cortex-tui/src/runner/event_loop/modal.rs b/src/cortex-tui/src/runner/event_loop/modal.rs index d799fe3..6cad672 100644 --- a/src/cortex-tui/src/runner/event_loop/modal.rs +++ b/src/cortex-tui/src/runner/event_loop/modal.rs @@ -342,8 +342,27 @@ impl EventLoop { self.app_state.new_session(); self.add_system_message("New session started"); } + ModalAction::PreviewTheme(theme_name) => { + // Live preview: update colors temporarily without persisting + self.app_state.start_theme_preview(&theme_name); + } + ModalAction::RevertTheme => { + // Cancel preview and revert to the original theme + self.app_state.cancel_theme_preview(); + } + ModalAction::ConfirmTheme(theme_name) => { + // Confirm and persist the theme selection + self.app_state.set_theme(&theme_name); + // Persist theme preference to config + if let Ok(mut config) = crate::providers::config::CortexConfig::load() { + let _ = config.save_last_theme(&theme_name); + } + self.app_state + .toasts + .success(format!("Theme changed to: {}", theme_name)); + } ModalAction::Custom(data) => { - // Handle theme selection from ThemeSelectorModal + // Handle legacy theme selection from interactive builder if let Some(theme_name) = data.strip_prefix("theme:") { self.app_state.set_theme(theme_name); // Persist theme preference to config