Skip to main content

kimun_notes/components/dialogs/
theme_picker.rs

1//! The **theme picker** (leader `v c`, spec §8c "+vault → config"): a small
2//! modal listing every theme; moving the selection previews it live, Enter
3//! persists, Esc reverts to the theme that was active when the picker opened.
4
5use ratatui::Frame;
6use ratatui::crossterm::event::{KeyCode, KeyEvent};
7use ratatui::layout::Rect;
8use ratatui::style::{Modifier, Style};
9use ratatui::text::{Line, Span};
10use ratatui::widgets::Paragraph;
11
12use crate::components::event_state::EventState;
13use crate::components::events::{AppEvent, AppTx};
14use crate::settings::AppSettings;
15use crate::settings::themes::Theme;
16
17pub struct ThemePickerDialog {
18    /// Themes in presentation order, fully resolved once on open — applying
19    /// a selection never goes back to disk.
20    themes: Vec<Theme>,
21    selected: usize,
22    /// Index of the theme to restore when the picker is cancelled.
23    original: usize,
24    /// Scroll offset for long lists.
25    offset: usize,
26}
27
28impl ThemePickerDialog {
29    pub fn new(settings: &AppSettings) -> Self {
30        let themes = settings.theme_list();
31        let current = settings.effective_theme_name();
32        let selected = themes.iter().position(|t| t.name == current).unwrap_or(0);
33        Self {
34            themes,
35            selected,
36            original: selected,
37            offset: 0,
38        }
39    }
40
41    fn apply(&self, index: usize, persist: bool, tx: &AppTx) {
42        if let Some(theme) = self.themes.get(index) {
43            tx.send(AppEvent::ApplyTheme {
44                theme: Box::new(theme.clone()),
45                persist,
46            })
47            .ok();
48        }
49    }
50
51    pub fn handle_key(&mut self, key: KeyEvent, tx: &AppTx) -> EventState {
52        match key.code {
53            KeyCode::Up | KeyCode::Char('k') => {
54                let prev = self.selected;
55                self.selected = self.selected.saturating_sub(1);
56                if self.selected != prev {
57                    self.apply(self.selected, false, tx);
58                }
59            }
60            KeyCode::Down | KeyCode::Char('j') => {
61                let prev = self.selected;
62                self.selected = (self.selected + 1).min(self.themes.len().saturating_sub(1));
63                if self.selected != prev {
64                    self.apply(self.selected, false, tx);
65                }
66            }
67            KeyCode::Enter => {
68                self.apply(self.selected, true, tx);
69                tx.send(AppEvent::CloseOverlay).ok();
70            }
71            KeyCode::Esc => {
72                if self.selected != self.original {
73                    self.apply(self.original, false, tx);
74                }
75                tx.send(AppEvent::CloseOverlay).ok();
76            }
77            _ => {}
78        }
79        EventState::Consumed
80    }
81
82    pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
83        let width = 40u16.min(rect.width);
84        let height = (self.themes.len() as u16 + 2)
85            .min(rect.height.saturating_sub(4))
86            .max(5);
87        let area = super::fixed_centered_rect(width, height, rect);
88        let inner = crate::components::panel::modal_chrome(
89            f,
90            area,
91            theme,
92            crate::components::panel::ModalSpec {
93                title: Some("─ Theme "),
94                border: Some(theme.border_style(focused)),
95                bg: crate::components::panel::ModalBg::Base,
96            },
97        );
98
99        // Keep the selection in the visible window.
100        let visible = inner.height as usize;
101        if self.selected < self.offset {
102            self.offset = self.selected;
103        } else if visible > 0 && self.selected >= self.offset + visible {
104            self.offset = self.selected + 1 - visible;
105        }
106
107        for (row, (i, entry)) in self
108            .themes
109            .iter()
110            .enumerate()
111            .skip(self.offset)
112            .take(visible)
113            .enumerate()
114        {
115            let name = &entry.name;
116            let style = if i == self.selected {
117                Style::default()
118                    .fg(theme.selection_fg.to_ratatui())
119                    .bg(theme.selection_bg.to_ratatui())
120                    .add_modifier(Modifier::BOLD)
121            } else {
122                Style::default().fg(theme.fg.to_ratatui())
123            };
124            let marker = if i == self.selected { "› " } else { "  " };
125            f.render_widget(
126                Paragraph::new(Line::from(Span::styled(format!("{marker}{name}"), style))),
127                Rect::new(inner.x, inner.y + row as u16, inner.width, 1),
128            );
129        }
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn enter_applies_persisted_and_closes() {
139        let settings = AppSettings::default();
140        let mut picker = ThemePickerDialog::new(&settings);
141        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
142
143        picker.handle_key(
144            KeyEvent::new(KeyCode::Down, ratatui::crossterm::event::KeyModifiers::NONE),
145            &tx,
146        );
147        picker.handle_key(
148            KeyEvent::new(
149                KeyCode::Enter,
150                ratatui::crossterm::event::KeyModifiers::NONE,
151            ),
152            &tx,
153        );
154
155        let mut applied = Vec::new();
156        let mut closed = false;
157        while let Ok(ev) = rx.try_recv() {
158            match ev {
159                AppEvent::ApplyTheme { theme, persist } => applied.push((theme.name, persist)),
160                AppEvent::CloseOverlay => closed = true,
161                _ => {}
162            }
163        }
164        // Down previews (persist=false), Enter commits (persist=true).
165        assert_eq!(applied.len(), 2);
166        assert!(!applied[0].1);
167        assert!(applied[1].1);
168        // Both carry the SAME resolved theme (the moved-to selection).
169        assert_eq!(applied[0].0, applied[1].0);
170        assert!(closed);
171    }
172
173    #[test]
174    fn esc_reverts_to_original() {
175        let mut settings = AppSettings::default();
176        settings.theme = "Gruvbox Dark".to_string();
177        let mut picker = ThemePickerDialog::new(&settings);
178        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
179
180        picker.handle_key(
181            KeyEvent::new(KeyCode::Down, ratatui::crossterm::event::KeyModifiers::NONE),
182            &tx,
183        );
184        picker.handle_key(
185            KeyEvent::new(KeyCode::Esc, ratatui::crossterm::event::KeyModifiers::NONE),
186            &tx,
187        );
188
189        let mut last_applied = None;
190        while let Ok(ev) = rx.try_recv() {
191            if let AppEvent::ApplyTheme { theme, persist } = ev {
192                last_applied = Some((theme.name, persist));
193            }
194        }
195        assert_eq!(
196            last_applied,
197            Some(("Gruvbox Dark".to_string(), false)),
198            "Esc must re-apply the original theme"
199        );
200    }
201}