Skip to main content

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