agent_core/tui/themes/
theme_picker.rs

1// Theme picker widget for selecting themes with live preview
2//
3// Full-screen overlay that displays available themes on the left
4// and a live preview on the right.
5
6use ratatui::{
7    layout::{Constraint, Layout, Rect},
8    text::{Line, Span},
9    widgets::{Block, Borders, Clear, Paragraph},
10    Frame,
11};
12
13use super::theme::{set_theme, Theme};
14use super::themes::{get_theme, THEMES};
15
16/// State for the theme picker
17pub struct ThemePickerState {
18    /// Whether the picker is currently visible
19    pub active: bool,
20    /// Index of the currently selected theme
21    pub selected_index: usize,
22    /// Original theme name (to restore on cancel)
23    original_theme_name: String,
24    /// Original theme (to restore on cancel)
25    original_theme: Option<Theme>,
26}
27
28impl ThemePickerState {
29    /// Create a new theme picker in inactive state.
30    pub fn new() -> Self {
31        Self {
32            active: false,
33            selected_index: 0,
34            original_theme_name: String::new(),
35            original_theme: None,
36        }
37    }
38
39    /// Activate the picker and save current theme for potential restore
40    pub fn activate(&mut self, current_theme_name: &str, current_theme: Theme) {
41        self.active = true;
42        self.original_theme_name = current_theme_name.to_string();
43        self.original_theme = Some(current_theme);
44
45        // Find and select current theme in list
46        self.selected_index = THEMES
47            .iter()
48            .position(|t| t.name == current_theme_name)
49            .unwrap_or(0);
50
51        // Apply selected theme for preview
52        self.apply_preview();
53    }
54
55    /// Deactivate and restore original theme (cancel)
56    pub fn cancel(&mut self) {
57        if let Some(theme) = self.original_theme.take() {
58            set_theme(&self.original_theme_name, theme);
59        }
60        self.active = false;
61    }
62
63    /// Deactivate and keep current theme (confirm)
64    pub fn confirm(&mut self) {
65        self.active = false;
66        self.original_theme = None;
67    }
68
69    /// Move selection up
70    pub fn select_previous(&mut self) {
71        if THEMES.is_empty() {
72            return;
73        }
74        if self.selected_index == 0 {
75            self.selected_index = THEMES.len() - 1;
76        } else {
77            self.selected_index -= 1;
78        }
79        self.apply_preview();
80    }
81
82    /// Move selection down
83    pub fn select_next(&mut self) {
84        if THEMES.is_empty() {
85            return;
86        }
87        self.selected_index = (self.selected_index + 1) % THEMES.len();
88        self.apply_preview();
89    }
90
91    /// Apply the currently selected theme for preview
92    fn apply_preview(&self) {
93        if let Some(info) = THEMES.get(self.selected_index) {
94            if let Some(theme) = get_theme(info.name) {
95                set_theme(info.name, theme);
96            }
97        }
98    }
99
100    /// Get the currently selected theme name
101    pub fn selected_theme_name(&self) -> Option<&'static str> {
102        THEMES.get(self.selected_index).map(|t| t.name)
103    }
104}
105
106impl Default for ThemePickerState {
107    fn default() -> Self {
108        Self::new()
109    }
110}
111
112// --- Widget trait implementation ---
113
114use std::any::Any;
115use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
116use crate::tui::widgets::{widget_ids, Widget, WidgetAction, WidgetKeyContext, WidgetKeyResult};
117
118/// Result of handling a key event in the theme picker
119#[derive(Debug, Clone, PartialEq)]
120pub enum ThemeKeyAction {
121    /// No action taken
122    None,
123    /// Navigation (up/down) handled
124    Navigated,
125    /// Theme confirmed
126    Confirmed,
127    /// Picker was cancelled
128    Cancelled,
129}
130
131impl ThemePickerState {
132    /// Handle a key event
133    pub fn process_key(&mut self, key: KeyEvent) -> ThemeKeyAction {
134        if !self.active {
135            return ThemeKeyAction::None;
136        }
137
138        match key.code {
139            KeyCode::Up => {
140                self.select_previous();
141                ThemeKeyAction::Navigated
142            }
143            KeyCode::Down => {
144                self.select_next();
145                ThemeKeyAction::Navigated
146            }
147            KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
148                self.select_previous();
149                ThemeKeyAction::Navigated
150            }
151            KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
152                self.select_next();
153                ThemeKeyAction::Navigated
154            }
155            KeyCode::Enter => {
156                self.confirm();
157                ThemeKeyAction::Confirmed
158            }
159            KeyCode::Esc => {
160                self.cancel();
161                ThemeKeyAction::Cancelled
162            }
163            _ => ThemeKeyAction::None,
164        }
165    }
166}
167
168impl Widget for ThemePickerState {
169    fn id(&self) -> &'static str {
170        widget_ids::THEME_PICKER
171    }
172
173    fn priority(&self) -> u8 {
174        250 // Very high priority - overlay
175    }
176
177    fn is_active(&self) -> bool {
178        self.active
179    }
180
181    fn handle_key(&mut self, key: KeyEvent, ctx: &WidgetKeyContext) -> WidgetKeyResult {
182        if !self.active {
183            return WidgetKeyResult::NotHandled;
184        }
185
186        // Use NavigationHelper for key bindings
187        if ctx.nav.is_move_up(&key) {
188            self.select_previous();
189            return WidgetKeyResult::Handled;
190        }
191        if ctx.nav.is_move_down(&key) {
192            self.select_next();
193            return WidgetKeyResult::Handled;
194        }
195        if ctx.nav.is_select(&key) {
196            self.confirm();
197            return WidgetKeyResult::Action(WidgetAction::Close);
198        }
199        if ctx.nav.is_cancel(&key) {
200            self.cancel();
201            return WidgetKeyResult::Action(WidgetAction::Close);
202        }
203
204        WidgetKeyResult::Handled
205    }
206
207    fn render(&mut self, frame: &mut Frame, area: Rect, _theme: &Theme) {
208        render_theme_picker(self, frame, area);
209    }
210
211    fn required_height(&self, _available: u16) -> u16 {
212        0 // Overlay widget - doesn't need dedicated height
213    }
214
215    fn blocks_input(&self) -> bool {
216        self.active
217    }
218
219    fn is_overlay(&self) -> bool {
220        true
221    }
222
223    fn as_any(&self) -> &dyn Any {
224        self
225    }
226
227    fn as_any_mut(&mut self) -> &mut dyn Any {
228        self
229    }
230
231    fn into_any(self: Box<Self>) -> Box<dyn Any> {
232        self
233    }
234}
235
236/// Render the theme picker
237pub fn render_theme_picker(state: &ThemePickerState, frame: &mut Frame, area: Rect) {
238    if !state.active {
239        return;
240    }
241
242    let theme = super::theme::theme();
243
244    // Clear the area first
245    frame.render_widget(Clear, area);
246
247    // Split into main area and bottom help bar
248    let main_chunks = Layout::vertical([Constraint::Min(0), Constraint::Length(2)])
249        .split(area);
250
251    // Split main area into left (theme list) and right (preview)
252    let chunks = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
253        .split(main_chunks[0]);
254
255    // Left panel: theme list
256    render_theme_list(state, frame, chunks[0], &theme);
257
258    // Right panel: preview
259    render_preview(frame, chunks[1], &theme);
260
261    // Bottom help bar
262    render_help_bar(frame, main_chunks[1], &theme);
263}
264
265/// Render the theme list panel
266fn render_theme_list(state: &ThemePickerState, frame: &mut Frame, area: Rect, theme: &Theme) {
267    let mut lines = Vec::new();
268
269    // Header
270    lines.push(Line::from(""));
271
272    for (idx, info) in THEMES.iter().enumerate() {
273        let is_selected = idx == state.selected_index;
274        let is_current = info.name == state.original_theme_name;
275
276        let marker = if is_current { "* " } else { "  " };
277        let prefix = if is_selected { " > " } else { "   " };
278        let text = format!("{}{}{}", prefix, marker, info.display_name);
279
280        let style = if is_selected {
281            theme.popup_selected_bg.patch(theme.popup_item_selected)
282        } else {
283            theme.popup_item
284        };
285
286        // Pad to full width for selection highlight
287        let inner_width = area.width.saturating_sub(2) as usize;
288        let padded = format!("{:<width$}", text, width = inner_width);
289        lines.push(Line::from(Span::styled(padded, style)));
290    }
291
292    let block = Block::default()
293        .title(" Select Theme ")
294        .borders(Borders::ALL)
295        .border_style(theme.popup_border);
296
297    let list = Paragraph::new(lines)
298        .block(block)
299        .style(theme.background.patch(theme.text));
300
301    frame.render_widget(list, area);
302}
303
304/// Render the preview panel
305fn render_preview(frame: &mut Frame, area: Rect, theme: &Theme) {
306    let mut lines = Vec::new();
307
308    // Preview header
309    lines.push(Line::from(""));
310    lines.push(Line::from(Span::styled(
311        " # Preview",
312        theme.heading_1,
313    )));
314    lines.push(Line::from(""));
315
316    // User message example
317    lines.push(Line::from(Span::styled(" > User message example", theme.user_prefix)));
318    lines.push(Line::from(Span::styled("   - 10:30:00 AM", theme.timestamp)));
319    lines.push(Line::from(""));
320
321    // Markdown examples
322    lines.push(Line::from(vec![
323        Span::raw(" This is "),
324        Span::styled("bold", theme.text.add_modifier(theme.bold)),
325        Span::raw(" and "),
326        Span::styled("italic", theme.text.add_modifier(theme.italic)),
327        Span::raw(" text."),
328    ]));
329    lines.push(Line::from(vec![
330        Span::raw(" Here is "),
331        Span::styled("inline code", theme.inline_code),
332        Span::raw(" and a "),
333        Span::styled("link", theme.link_text),
334        Span::raw("."),
335    ]));
336    lines.push(Line::from(""));
337
338    // Heading examples
339    lines.push(Line::from(Span::styled(" ## Heading 2", theme.heading_2)));
340    lines.push(Line::from(Span::styled(" ### Heading 3", theme.heading_3)));
341    lines.push(Line::from(""));
342
343    // Code block example
344    lines.push(Line::from(Span::styled(" ```rust", theme.code_block)));
345    lines.push(Line::from(Span::styled(" fn main() { }", theme.code_block)));
346    lines.push(Line::from(Span::styled(" ```", theme.code_block)));
347    lines.push(Line::from(""));
348
349    // Table example
350    lines.push(Line::from(vec![
351        Span::styled(" ", theme.table_border),
352        Span::styled("Col1", theme.table_header),
353        Span::styled(" | ", theme.table_border),
354        Span::styled("Col2", theme.table_header),
355    ]));
356    lines.push(Line::from(Span::styled(" -----|-----", theme.table_border)));
357    lines.push(Line::from(vec![
358        Span::styled(" ", theme.table_border),
359        Span::styled("A", theme.table_cell),
360        Span::styled("    | ", theme.table_border),
361        Span::styled("B", theme.table_cell),
362    ]));
363    lines.push(Line::from(""));
364
365    // Tool status examples
366    lines.push(Line::from(Span::styled(" Tool executing...", theme.tool_executing)));
367    lines.push(Line::from(Span::styled(" Tool completed", theme.tool_completed)));
368    lines.push(Line::from(Span::styled(" Tool failed", theme.tool_failed)));
369    lines.push(Line::from(""));
370
371    let block = Block::default()
372        .title(" Preview ")
373        .borders(Borders::ALL)
374        .border_style(theme.popup_border);
375
376    let preview = Paragraph::new(lines)
377        .block(block)
378        .style(theme.background.patch(theme.text));
379
380    frame.render_widget(preview, area);
381}
382
383/// Render the help bar at the bottom
384fn render_help_bar(frame: &mut Frame, area: Rect, theme: &Theme) {
385    let help_text = " Arrow keys to navigate | Enter to accept | Esc to cancel | * = current theme";
386    let help = Paragraph::new(help_text)
387        .style(theme.status_help);
388    frame.render_widget(help, area);
389}