Skip to main content

revue/widget/
theme_picker.rs

1//! Theme picker widget for runtime theme switching
2//!
3//! Provides a dropdown-style widget for selecting themes from the registered
4//! theme list. Integrates with the reactive theme system for automatic UI updates.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! use revue::widget::theme_picker;
10//!
11//! // Basic theme picker
12//! theme_picker().render(ctx);
13//!
14//! // With specific themes only
15//! theme_picker()
16//!     .themes(["dark", "light", "dracula", "nord"])
17//!     .render(ctx);
18//!
19//! // Compact mode (shows only color swatches)
20//! theme_picker()
21//!     .compact(true)
22//!     .render(ctx);
23//! ```
24
25use crate::event::{Key, KeyEvent};
26use crate::render::Cell;
27use crate::style::{get_theme, set_theme_by_id, theme_ids, use_theme, Color, Theme};
28use crate::widget::traits::{EventResult, Interactive, RenderContext, View, WidgetProps};
29use crate::{impl_props_builders, impl_styled_view};
30
31/// Theme picker widget for selecting themes
32#[derive(Clone, Debug)]
33pub struct ThemePicker {
34    /// Available theme IDs
35    themes: Vec<String>,
36    /// Currently selected index in themes list
37    selected_index: usize,
38    /// Whether dropdown is open
39    open: bool,
40    /// Show compact color swatch mode
41    compact: bool,
42    /// Show theme preview
43    show_preview: bool,
44    /// Widget width
45    width: Option<u16>,
46    /// Foreground color override
47    fg: Option<Color>,
48    /// Background color override
49    bg: Option<Color>,
50    /// CSS styling properties
51    props: WidgetProps,
52}
53
54impl ThemePicker {
55    /// Create a new theme picker with all registered themes
56    pub fn new() -> Self {
57        let all_themes = theme_ids();
58        let current = use_theme().get();
59        let selected_index = all_themes
60            .iter()
61            .position(|id| {
62                get_theme(id)
63                    .map(|t| t.name == current.name)
64                    .unwrap_or(false)
65            })
66            .unwrap_or(0);
67
68        Self {
69            themes: all_themes,
70            selected_index,
71            open: false,
72            compact: false,
73            show_preview: true,
74            width: None,
75            fg: None,
76            bg: None,
77            props: WidgetProps::new(),
78        }
79    }
80
81    /// Set specific themes to show (by ID)
82    pub fn themes<I, S>(mut self, theme_ids: I) -> Self
83    where
84        I: IntoIterator<Item = S>,
85        S: Into<String>,
86    {
87        self.themes = theme_ids.into_iter().map(|s| s.into()).collect();
88        self.selected_index = 0;
89        self
90    }
91
92    /// Enable compact mode (color swatches only)
93    pub fn compact(mut self, enable: bool) -> Self {
94        self.compact = enable;
95        self
96    }
97
98    /// Show theme preview (default: true)
99    pub fn show_preview(mut self, show: bool) -> Self {
100        self.show_preview = show;
101        self
102    }
103
104    /// Set widget width
105    pub fn width(mut self, width: u16) -> Self {
106        self.width = Some(width);
107        self
108    }
109
110    /// Set foreground color
111    pub fn fg(mut self, color: Color) -> Self {
112        self.fg = Some(color);
113        self
114    }
115
116    /// Set background color
117    pub fn bg(mut self, color: Color) -> Self {
118        self.bg = Some(color);
119        self
120    }
121
122    /// Toggle dropdown open/closed
123    pub fn toggle(&mut self) {
124        self.open = !self.open;
125    }
126
127    /// Open the dropdown
128    pub fn open(&mut self) {
129        self.open = true;
130    }
131
132    /// Close the dropdown
133    pub fn close(&mut self) {
134        self.open = false;
135    }
136
137    /// Check if dropdown is open
138    pub fn is_open(&self) -> bool {
139        self.open
140    }
141
142    /// Move selection up
143    pub fn select_prev(&mut self) {
144        if self.selected_index > 0 {
145            self.selected_index -= 1;
146        }
147    }
148
149    /// Move selection down
150    pub fn select_next(&mut self) {
151        if self.selected_index < self.themes.len().saturating_sub(1) {
152            self.selected_index += 1;
153        }
154    }
155
156    /// Apply the selected theme
157    pub fn apply_selected(&self) {
158        if let Some(id) = self.themes.get(self.selected_index) {
159            set_theme_by_id(id);
160        }
161    }
162
163    /// Get currently selected theme ID
164    pub fn selected_id(&self) -> Option<&str> {
165        self.themes.get(self.selected_index).map(|s| s.as_str())
166    }
167
168    /// Get selected theme
169    pub fn selected_theme(&self) -> Option<Theme> {
170        self.selected_id().and_then(get_theme)
171    }
172
173    /// Draw color swatch at position, returns width used
174    fn draw_swatch(&self, ctx: &mut RenderContext, x: u16, y: u16, theme: &Theme) -> u16 {
175        let swatch_colors = [
176            theme.colors.background,
177            theme.palette.primary,
178            theme.palette.success,
179            theme.palette.error,
180        ];
181
182        for (i, color) in swatch_colors.iter().enumerate() {
183            let mut cell = Cell::new(' ');
184            cell.bg = Some(*color);
185            ctx.buffer.set(x + i as u16, y, cell);
186        }
187
188        swatch_colors.len() as u16
189    }
190}
191
192impl Default for ThemePicker {
193    fn default() -> Self {
194        Self::new()
195    }
196}
197
198impl View for ThemePicker {
199    fn render(&self, ctx: &mut RenderContext) {
200        let area = ctx.area;
201        if area.width < 10 || area.height < 1 {
202            return;
203        }
204
205        let current_theme = use_theme().get();
206        let width = self.width.unwrap_or(area.width.min(35));
207
208        let fg = self.fg.unwrap_or(current_theme.colors.text);
209        let bg = self.bg.unwrap_or(current_theme.colors.surface);
210
211        if self.compact {
212            // Compact mode: just show current theme swatch
213            self.draw_swatch(ctx, area.x, area.y, &current_theme);
214
215            if self.open && !self.themes.is_empty() {
216                let mut y = area.y + 1;
217                for (i, theme_id) in self.themes.iter().enumerate() {
218                    if y >= area.y + area.height {
219                        break;
220                    }
221                    if let Some(theme) = get_theme(theme_id) {
222                        let selected = i == self.selected_index;
223
224                        // Selection indicator
225                        let indicator = if selected { '>' } else { ' ' };
226                        let mut cell = Cell::new(indicator);
227                        cell.fg = Some(theme.palette.primary);
228                        ctx.buffer.set(area.x, y, cell);
229
230                        // Swatch
231                        self.draw_swatch(ctx, area.x + 1, y, &theme);
232                        y += 1;
233                    }
234                }
235            }
236        } else {
237            // Full mode: show current theme with name
238            let mut x = area.x;
239
240            // "Theme: " label
241            let label = "Theme: ";
242            for ch in label.chars() {
243                let mut cell = Cell::new(ch);
244                cell.fg = Some(fg);
245                cell.bg = Some(bg);
246                ctx.buffer.set(x, area.y, cell);
247                x += 1;
248            }
249
250            // Theme name
251            for ch in current_theme.name.chars() {
252                if x >= area.x + width - 6 {
253                    break;
254                }
255                let mut cell = Cell::new(ch);
256                cell.fg = Some(fg);
257                cell.bg = Some(bg);
258                ctx.buffer.set(x, area.y, cell);
259                x += 1;
260            }
261
262            // Space
263            let mut cell = Cell::new(' ');
264            cell.bg = Some(bg);
265            ctx.buffer.set(x, area.y, cell);
266            x += 1;
267
268            // Swatch
269            x += self.draw_swatch(ctx, x, area.y, &current_theme);
270
271            // Dropdown indicator
272            let indicator = if self.open { " ▲" } else { " ▼" };
273            for ch in indicator.chars() {
274                let mut cell = Cell::new(ch);
275                cell.fg = Some(fg);
276                cell.bg = Some(bg);
277                ctx.buffer.set(x, area.y, cell);
278                x += 1;
279            }
280
281            // Fill remaining header
282            while x < area.x + width {
283                let mut cell = Cell::new(' ');
284                cell.bg = Some(bg);
285                ctx.buffer.set(x, area.y, cell);
286                x += 1;
287            }
288
289            // Dropdown content
290            if self.open && !self.themes.is_empty() {
291                let border_color = current_theme.colors.border;
292                let mut y = area.y + 1;
293
294                // Border top
295                if y < area.y + area.height {
296                    let mut cell = Cell::new('┌');
297                    cell.fg = Some(border_color);
298                    ctx.buffer.set(area.x, y, cell);
299
300                    for i in 1..width.saturating_sub(1) {
301                        let mut cell = Cell::new('─');
302                        cell.fg = Some(border_color);
303                        ctx.buffer.set(area.x + i, y, cell);
304                    }
305
306                    let mut cell = Cell::new('┐');
307                    cell.fg = Some(border_color);
308                    ctx.buffer.set(area.x + width - 1, y, cell);
309                    y += 1;
310                }
311
312                // Theme list
313                for (i, theme_id) in self.themes.iter().enumerate() {
314                    if y >= area.y + area.height - 1 {
315                        break;
316                    }
317                    if let Some(theme) = get_theme(theme_id) {
318                        let selected = i == self.selected_index;
319                        let item_bg = if selected {
320                            current_theme.colors.selection
321                        } else {
322                            current_theme.colors.surface
323                        };
324                        let item_fg = if selected {
325                            current_theme.colors.selection_text
326                        } else {
327                            current_theme.colors.text
328                        };
329
330                        // Left border
331                        let mut cell = Cell::new('│');
332                        cell.fg = Some(border_color);
333                        ctx.buffer.set(area.x, y, cell);
334
335                        let mut cx = area.x + 1;
336
337                        // Selection indicator
338                        let indicator = if selected { '▶' } else { ' ' };
339                        let mut cell = Cell::new(indicator);
340                        cell.fg = Some(theme.palette.primary);
341                        cell.bg = Some(item_bg);
342                        ctx.buffer.set(cx, y, cell);
343                        cx += 1;
344
345                        // Swatch
346                        cx += self.draw_swatch(ctx, cx, y, &theme);
347
348                        // Space
349                        let mut cell = Cell::new(' ');
350                        cell.bg = Some(item_bg);
351                        ctx.buffer.set(cx, y, cell);
352                        cx += 1;
353
354                        // Name
355                        let max_name_len = (width as usize).saturating_sub(9);
356                        for (j, ch) in theme.name.chars().enumerate() {
357                            if j >= max_name_len {
358                                break;
359                            }
360                            let mut cell = Cell::new(ch);
361                            cell.fg = Some(item_fg);
362                            cell.bg = Some(item_bg);
363                            ctx.buffer.set(cx, y, cell);
364                            cx += 1;
365                        }
366
367                        // Padding
368                        while cx < area.x + width - 1 {
369                            let mut cell = Cell::new(' ');
370                            cell.bg = Some(item_bg);
371                            ctx.buffer.set(cx, y, cell);
372                            cx += 1;
373                        }
374
375                        // Right border
376                        let mut cell = Cell::new('│');
377                        cell.fg = Some(border_color);
378                        ctx.buffer.set(area.x + width - 1, y, cell);
379
380                        y += 1;
381                    }
382                }
383
384                // Border bottom
385                if y < area.y + area.height {
386                    let mut cell = Cell::new('└');
387                    cell.fg = Some(border_color);
388                    ctx.buffer.set(area.x, y, cell);
389
390                    for i in 1..width.saturating_sub(1) {
391                        let mut cell = Cell::new('─');
392                        cell.fg = Some(border_color);
393                        ctx.buffer.set(area.x + i, y, cell);
394                    }
395
396                    let mut cell = Cell::new('┘');
397                    cell.fg = Some(border_color);
398                    ctx.buffer.set(area.x + width - 1, y, cell);
399                }
400            }
401        }
402    }
403}
404
405impl Interactive for ThemePicker {
406    fn handle_key(&mut self, event: &KeyEvent) -> EventResult {
407        match event.key {
408            Key::Enter | Key::Char(' ') => {
409                if self.open {
410                    self.apply_selected();
411                    self.close();
412                } else {
413                    self.open();
414                }
415                EventResult::Consumed
416            }
417            Key::Up | Key::Char('k') if self.open => {
418                self.select_prev();
419                EventResult::Consumed
420            }
421            Key::Down | Key::Char('j') if self.open => {
422                self.select_next();
423                EventResult::Consumed
424            }
425            Key::Escape if self.open => {
426                self.close();
427                EventResult::Consumed
428            }
429            Key::Tab => {
430                // Apply next theme without opening dropdown
431                self.select_next();
432                if self.selected_index == 0 && !self.themes.is_empty() {
433                    // Wrapped around, go to first
434                }
435                self.apply_selected();
436                EventResult::Consumed
437            }
438            _ => EventResult::Ignored,
439        }
440    }
441}
442
443// Implement styled view macros
444impl_styled_view!(ThemePicker);
445impl_props_builders!(ThemePicker);
446
447/// Create a new theme picker
448pub fn theme_picker() -> ThemePicker {
449    ThemePicker::new()
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    #[test]
457    fn test_theme_picker_new() {
458        let picker = ThemePicker::new();
459        assert!(!picker.themes.is_empty());
460        assert!(!picker.open);
461    }
462
463    #[test]
464    fn test_theme_picker_toggle() {
465        let mut picker = ThemePicker::new();
466        assert!(!picker.is_open());
467
468        picker.toggle();
469        assert!(picker.is_open());
470
471        picker.toggle();
472        assert!(!picker.is_open());
473    }
474
475    #[test]
476    fn test_theme_picker_selection() {
477        let mut picker = ThemePicker::new().themes(["dark", "light", "dracula"]);
478
479        assert_eq!(picker.selected_index, 0);
480
481        picker.select_next();
482        assert_eq!(picker.selected_index, 1);
483
484        picker.select_next();
485        assert_eq!(picker.selected_index, 2);
486
487        // Should not go past end
488        picker.select_next();
489        assert_eq!(picker.selected_index, 2);
490
491        picker.select_prev();
492        assert_eq!(picker.selected_index, 1);
493    }
494
495    #[test]
496    fn test_theme_picker_selected_id() {
497        let picker = ThemePicker::new().themes(["dracula", "nord"]);
498
499        assert_eq!(picker.selected_id(), Some("dracula"));
500    }
501
502    #[test]
503    fn test_theme_picker_compact() {
504        let picker = ThemePicker::new().compact(true);
505        assert!(picker.compact);
506    }
507
508    #[test]
509    fn test_theme_picker_custom_themes() {
510        let picker = ThemePicker::new().themes(["dark", "nord"]);
511
512        assert_eq!(picker.themes.len(), 2);
513        assert_eq!(picker.themes[0], "dark");
514        assert_eq!(picker.themes[1], "nord");
515    }
516
517    #[test]
518    fn test_theme_picker_width() {
519        let picker = ThemePicker::new().width(50);
520        assert_eq!(picker.width, Some(50));
521    }
522
523    #[test]
524    fn test_theme_picker_handle_key_open() {
525        let mut picker = ThemePicker::new();
526
527        let event = KeyEvent::new(Key::Enter);
528        let result = picker.handle_key(&event);
529
530        assert_eq!(result, EventResult::Consumed);
531        assert!(picker.is_open());
532    }
533
534    #[test]
535    fn test_theme_picker_handle_key_close() {
536        let mut picker = ThemePicker::new();
537        picker.open();
538
539        let event = KeyEvent::new(Key::Escape);
540        let result = picker.handle_key(&event);
541
542        assert_eq!(result, EventResult::Consumed);
543        assert!(!picker.is_open());
544    }
545
546    #[test]
547    fn test_theme_picker_handle_key_navigate() {
548        let mut picker = ThemePicker::new().themes(["dark", "light", "dracula"]);
549        picker.open();
550
551        // Down
552        let event = KeyEvent::new(Key::Down);
553        picker.handle_key(&event);
554        assert_eq!(picker.selected_index, 1);
555
556        // Up
557        let event = KeyEvent::new(Key::Up);
558        picker.handle_key(&event);
559        assert_eq!(picker.selected_index, 0);
560
561        // j (vim down)
562        let event = KeyEvent::new(Key::Char('j'));
563        picker.handle_key(&event);
564        assert_eq!(picker.selected_index, 1);
565
566        // k (vim up)
567        let event = KeyEvent::new(Key::Char('k'));
568        picker.handle_key(&event);
569        assert_eq!(picker.selected_index, 0);
570    }
571}