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