Skip to main content

kimun_notes/components/dialogs/
theme_picker.rs

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