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. Typing fuzzy-filters the list, like the command
3//! palette; moving the selection previews the theme live, Enter persists, Esc
4//! reverts to the theme that was active when the picker opened.
5
6use std::sync::Arc;
7
8use ratatui::Frame;
9use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseEvent, MouseEventKind};
10use ratatui::layout::{Constraint, Direction, Layout, Rect};
11use ratatui::style::Style;
12use ratatui::widgets::{ListItem, Paragraph};
13
14use super::ModalShell;
15use crate::components::event_state::EventState;
16use crate::components::events::{AppEvent, AppTx};
17use crate::components::hint_row::HintRow;
18use crate::components::rich_row::RichRow;
19use crate::components::search_list::{
20    Filter, KeyReaction, SearchList, SearchMouse, SearchRow, StaticRowSource,
21};
22use crate::settings::AppSettings;
23use crate::settings::icons::Icons;
24use crate::settings::themes::Theme;
25
26/// One picker row: a theme's name and its index into the resolved theme list.
27#[derive(Clone)]
28struct ThemeRow {
29    name: String,
30    index: usize,
31    /// The theme active when the picker opened.
32    current: bool,
33}
34
35impl SearchRow for ThemeRow {
36    fn to_list_item(&self, theme: &Theme, _icons: &Icons, _selected: bool) -> ListItem<'static> {
37        let glyph = if self.current { "●" } else { " " };
38        RichRow::new(glyph, self.name.clone())
39            .glyph_style(Style::default().fg(theme.gray.to_ratatui()))
40            .into_list_item(theme)
41    }
42
43    fn match_text(&self) -> Option<&str> {
44        Some(&self.name)
45    }
46
47    fn visual_height(&self) -> u16 {
48        1
49    }
50}
51
52pub struct ThemePickerDialog {
53    /// Themes in presentation order, fully resolved once on open — applying
54    /// a selection never goes back to disk.
55    themes: Vec<Theme>,
56    list: SearchList<ThemeRow>,
57    /// Index of the theme to restore when the picker is cancelled.
58    original: usize,
59    /// Index of the theme currently applied as a preview.
60    previewed: usize,
61    /// Dismiss-on-outside-press and hint-chip clicks (see [`ModalShell`]).
62    shell: ModalShell,
63    hints: HintRow,
64}
65
66impl ThemePickerDialog {
67    pub fn new(settings: &AppSettings) -> Self {
68        let themes = settings.theme_list();
69        let current = settings.effective_theme_name();
70        let original = themes.iter().position(|t| t.name == current).unwrap_or(0);
71        let rows: Vec<ThemeRow> = themes
72            .iter()
73            .enumerate()
74            .map(|(index, t)| ThemeRow {
75                name: t.name.clone(),
76                index,
77                current: index == original,
78            })
79            .collect();
80        // Static, in-memory rows: built synchronously (the redraw callback is
81        // never fired on this path).
82        let mut list = SearchList::builder(StaticRowSource, Arc::new(|| {}))
83            .filter(Filter::Fuzzy)
84            .build_with_rows(rows);
85        list.select(original);
86        Self {
87            themes,
88            list,
89            original,
90            previewed: original,
91            shell: ModalShell::default(),
92            hints: HintRow::new(&[
93                (KeyCode::Null, "type", "Filter"),
94                (KeyCode::Null, "↑↓", "Move"),
95                (KeyCode::Enter, "⏎", "Apply"),
96                (KeyCode::Esc, "Esc", "Cancel"),
97            ])
98            .with_indent(0),
99        }
100    }
101
102    /// Click a theme to preview it, click the previewed one to keep it; the
103    /// wheel steps through themes; a press outside reverts and closes, like
104    /// Esc. Modal: every mouse event is consumed.
105    pub fn handle_mouse(&mut self, m: &MouseEvent, tx: &AppTx) -> EventState {
106        if let Some(key) = self.shell.pointer_key(m, &[&self.hints]) {
107            return self.handle_key(key, tx);
108        }
109        // The wheel steps the selection (previewing each theme) rather than
110        // scrolling the viewport, which would be a no-op on a short list.
111        match m.kind {
112            MouseEventKind::ScrollUp => return self.handle_key(KeyEvent::from(KeyCode::Up), tx),
113            MouseEventKind::ScrollDown => {
114                return self.handle_key(KeyEvent::from(KeyCode::Down), tx);
115            }
116            _ => {}
117        }
118        match self.list.handle_mouse(m) {
119            SearchMouse::Activated(_) | SearchMouse::DoubleClicked { repeat: false, .. } => {
120                return self.handle_key(KeyEvent::from(KeyCode::Enter), tx);
121            }
122            _ => self.sync_preview(tx),
123        }
124        EventState::Consumed
125    }
126
127    fn apply(&self, index: usize, persist: bool, tx: &AppTx) {
128        if let Some(theme) = self.themes.get(index) {
129            tx.send(AppEvent::ApplyTheme {
130                theme: Box::new(theme.clone()),
131                persist,
132            })
133            .ok();
134        }
135    }
136
137    /// Preview whichever theme the selection landed on, if it changed. With
138    /// nothing selected (the filter matches no theme) fall back to the theme
139    /// the picker opened on, so the screen never shows a theme that isn't
140    /// highlighted.
141    fn sync_preview(&mut self, tx: &AppTx) {
142        let target = self.list.selected_row().map_or(self.original, |r| r.index);
143        if target != self.previewed {
144            self.previewed = target;
145            self.apply(target, false, tx);
146        }
147    }
148
149    pub fn handle_key(&mut self, key: KeyEvent, tx: &AppTx) -> EventState {
150        match self.list.handle_key(&key) {
151            KeyReaction::Submit => {
152                // Nothing matches the filter: stay open rather than commit a
153                // theme the user can't see selected.
154                if let Some(index) = self.list.selected_row().map(|r| r.index) {
155                    self.apply(index, true, tx);
156                    tx.send(AppEvent::CloseOverlay).ok();
157                }
158            }
159            KeyReaction::Cancel => {
160                if self.previewed != self.original {
161                    self.apply(self.original, false, tx);
162                }
163                tx.send(AppEvent::CloseOverlay).ok();
164            }
165            _ => self.sync_preview(tx),
166        }
167        EventState::Consumed
168    }
169
170    pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
171        let width = 52u16.min(rect.width);
172        // Border (2) + query row + hint row, plus one row per theme.
173        let height = (self.themes.len() as u16 + 4)
174            .min(rect.height.saturating_sub(4))
175            .max(7);
176        let area = super::fixed_centered_rect(width, height, rect);
177        self.shell.set(area);
178        let inner = crate::components::panel::modal_chrome(
179            f,
180            area,
181            theme,
182            crate::components::panel::ModalSpec {
183                title: Some("─ Theme "),
184                border: Some(theme.border_style(focused)),
185                bg: crate::components::panel::ModalBg::Base,
186            },
187        );
188
189        let rows = Layout::default()
190            .direction(Direction::Vertical)
191            .constraints([
192                Constraint::Length(1),
193                Constraint::Min(0),
194                Constraint::Length(1),
195            ])
196            .split(inner);
197
198        f.render_widget(
199            Paragraph::new("› ").style(Style::default().fg(theme.yellow.to_ratatui())),
200            rows[0],
201        );
202        let input_rect = Rect {
203            x: rows[0].x + 2,
204            width: rows[0].width.saturating_sub(2),
205            ..rows[0]
206        };
207        self.list.render_query(f, input_rect, theme, true);
208
209        self.list.render(f, rows[1], theme, true);
210        self.list.set_panel_rect(area);
211
212        self.hints.render(
213            f,
214            rows[2],
215            Style::default().fg(theme.gray.to_ratatui()),
216            theme,
217        );
218    }
219}
220
221impl_dialog!(ThemePickerDialog);
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn enter_applies_persisted_and_closes() {
229        let settings = AppSettings::default();
230        let mut picker = ThemePickerDialog::new(&settings);
231        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
232
233        picker.handle_key(
234            KeyEvent::new(KeyCode::Down, ratatui::crossterm::event::KeyModifiers::NONE),
235            &tx,
236        );
237        picker.handle_key(
238            KeyEvent::new(
239                KeyCode::Enter,
240                ratatui::crossterm::event::KeyModifiers::NONE,
241            ),
242            &tx,
243        );
244
245        let mut applied = Vec::new();
246        let mut closed = false;
247        while let Ok(ev) = rx.try_recv() {
248            match ev {
249                AppEvent::ApplyTheme { theme, persist } => applied.push((theme.name, persist)),
250                AppEvent::CloseOverlay => closed = true,
251                _ => {}
252            }
253        }
254        // Down previews (persist=false), Enter commits (persist=true).
255        assert_eq!(applied.len(), 2);
256        assert!(!applied[0].1);
257        assert!(applied[1].1);
258        // Both carry the SAME resolved theme (the moved-to selection).
259        assert_eq!(applied[0].0, applied[1].0);
260        assert!(closed);
261    }
262
263    #[test]
264    fn esc_reverts_to_original() {
265        let mut settings = AppSettings::default();
266        settings.theme = "Gruvbox Dark".to_string();
267        let mut picker = ThemePickerDialog::new(&settings);
268        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
269
270        picker.handle_key(
271            KeyEvent::new(KeyCode::Down, ratatui::crossterm::event::KeyModifiers::NONE),
272            &tx,
273        );
274        picker.handle_key(
275            KeyEvent::new(KeyCode::Esc, ratatui::crossterm::event::KeyModifiers::NONE),
276            &tx,
277        );
278
279        let mut last_applied = None;
280        while let Ok(ev) = rx.try_recv() {
281            if let AppEvent::ApplyTheme { theme, persist } = ev {
282                last_applied = Some((theme.name, persist));
283            }
284        }
285        assert_eq!(
286            last_applied,
287            Some(("Gruvbox Dark".to_string(), false)),
288            "Esc must re-apply the original theme"
289        );
290    }
291
292    fn type_str(picker: &mut ThemePickerDialog, text: &str, tx: &AppTx) {
293        for c in text.chars() {
294            picker.handle_key(KeyEvent::from(KeyCode::Char(c)), tx);
295        }
296    }
297
298    #[test]
299    fn typing_filters_and_previews_the_match() {
300        let settings = AppSettings::default();
301        let mut picker = ThemePickerDialog::new(&settings);
302        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
303
304        type_str(&mut picker, "flexoki lig", &tx);
305        let visible: Vec<_> = picker
306            .list
307            .visible_rows()
308            .iter()
309            .map(|r| r.name.clone())
310            .collect();
311        assert!(
312            visible.contains(&"Flexoki Light".to_string()),
313            "{visible:?}"
314        );
315        assert!(visible.len() < picker.themes.len(), "list must be filtered");
316
317        picker.handle_key(KeyEvent::from(KeyCode::Enter), &tx);
318        let mut last = None;
319        while let Ok(ev) = rx.try_recv() {
320            if let AppEvent::ApplyTheme { theme, persist } = ev {
321                last = Some((theme.name, persist));
322            }
323        }
324        assert_eq!(last, Some(("Flexoki Light".to_string(), true)));
325    }
326
327    #[test]
328    fn no_match_reverts_preview_to_original() {
329        let settings = AppSettings::default();
330        let mut picker = ThemePickerDialog::new(&settings);
331        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
332
333        type_str(&mut picker, "flex", &tx);
334        type_str(&mut picker, "zzzz", &tx);
335        let mut last = None;
336        while let Ok(ev) = rx.try_recv() {
337            if let AppEvent::ApplyTheme { theme, persist } = ev {
338                last = Some((theme.name, persist));
339            }
340        }
341        assert_eq!(
342            last,
343            Some((settings.effective_theme_name().to_string(), false))
344        );
345    }
346
347    #[test]
348    fn wheel_steps_the_selection() {
349        use ratatui::crossterm::event::KeyModifiers;
350        let settings = AppSettings::default();
351        let mut picker = ThemePickerDialog::new(&settings);
352        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
353        // Short filtered list: the viewport can't scroll, the selection must.
354        type_str(&mut picker, "flexoki", &tx);
355        while rx.try_recv().is_ok() {}
356        let wheel = |kind| MouseEvent {
357            kind,
358            column: 0,
359            row: 0,
360            modifiers: KeyModifiers::NONE,
361        };
362        let before = picker.list.selected_row().map(|r| r.index);
363        // One direction is blocked by the list end; the other must move.
364        picker.handle_mouse(&wheel(MouseEventKind::ScrollDown), &tx);
365        picker.handle_mouse(&wheel(MouseEventKind::ScrollUp), &tx);
366        picker.handle_mouse(&wheel(MouseEventKind::ScrollUp), &tx);
367        assert_ne!(picker.list.selected_row().map(|r| r.index), before);
368        assert!(matches!(
369            rx.try_recv(),
370            Ok(AppEvent::ApplyTheme { persist: false, .. })
371        ));
372    }
373
374    #[test]
375    fn enter_with_no_match_stays_open() {
376        let settings = AppSettings::default();
377        let mut picker = ThemePickerDialog::new(&settings);
378        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
379
380        type_str(&mut picker, "zzzzqqqq", &tx);
381        picker.handle_key(KeyEvent::from(KeyCode::Enter), &tx);
382        while let Ok(ev) = rx.try_recv() {
383            assert!(!matches!(ev, AppEvent::CloseOverlay), "must not close");
384            if let AppEvent::ApplyTheme { persist, .. } = ev {
385                assert!(!persist, "must not persist anything");
386            }
387        }
388    }
389}