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