Skip to main content

kimun_notes/components/preferences/
theme_picker.rs

1use ratatui::Frame;
2use ratatui::layout::Rect;
3use ratatui::widgets::{Block, Borders, List, ListItem, ListState};
4
5use crate::components::Component;
6use crate::components::event_state::EventState;
7use crate::components::events::AppTx;
8use crate::components::events::InputEvent;
9use crate::settings::themes::Theme;
10
11pub struct ThemePicker {
12    themes: Vec<Theme>,
13    list_state: ListState,
14}
15
16impl ThemePicker {
17    pub fn new(themes: Vec<Theme>, active_name: &str) -> Self {
18        let idx = themes
19            .iter()
20            .position(|t| t.name == active_name)
21            .unwrap_or(0);
22        let mut list_state = ListState::default();
23        list_state.select(Some(idx));
24        Self { themes, list_state }
25    }
26
27    pub fn selected_theme_name(&self) -> &str {
28        debug_assert!(
29            !self.themes.is_empty(),
30            "ThemePicker requires at least one theme"
31        );
32        let idx = self.list_state.selected().unwrap_or(0);
33        &self.themes[idx].name
34    }
35}
36
37impl Component for ThemePicker {
38    fn handle_input(&mut self, event: &InputEvent, _tx: &AppTx) -> EventState {
39        let InputEvent::Key(key) = event else {
40            return EventState::NotConsumed;
41        };
42        let count = self.themes.len();
43        match key.code {
44            ratatui::crossterm::event::KeyCode::Down
45            | ratatui::crossterm::event::KeyCode::Char('j') => {
46                let cur = self.list_state.selected().unwrap_or(0);
47                self.list_state.select(Some((cur + 1) % count));
48                EventState::Consumed
49            }
50            ratatui::crossterm::event::KeyCode::Up
51            | ratatui::crossterm::event::KeyCode::Char('k') => {
52                let cur = self.list_state.selected().unwrap_or(0);
53                self.list_state.select(Some((cur + count - 1) % count));
54                EventState::Consumed
55            }
56            _ => EventState::NotConsumed,
57        }
58    }
59
60    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
61        let border_style = theme.border_style(focused);
62        let block = Block::default()
63            .title("Theme")
64            .borders(Borders::ALL)
65            .border_style(border_style)
66            .style(theme.base_style());
67        let items: Vec<ListItem> = self
68            .themes
69            .iter()
70            .enumerate()
71            .map(|(i, t)| {
72                let selected = self.list_state.selected() == Some(i);
73                let prefix = if selected { "● " } else { "  " };
74                ListItem::new(format!("{}{}", prefix, t.name))
75            })
76            .collect();
77        let list = List::new(items)
78            .block(block)
79            .style(theme.base_style())
80            .highlight_style(
81                ratatui::style::Style::default()
82                    .fg(theme.selection_fg.to_ratatui())
83                    .bg(theme.selection_bg.to_ratatui()),
84            );
85        f.render_stateful_widget(list, rect, &mut self.list_state);
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    fn make_themes() -> Vec<Theme> {
94        vec![
95            Theme::gruvbox_dark(),
96            Theme::gruvbox_light(),
97            Theme::catppuccin_mocha(),
98        ]
99    }
100
101    #[test]
102    fn selected_theme_name_returns_initial() {
103        let picker = ThemePicker::new(make_themes(), "Gruvbox Light");
104        assert_eq!(picker.selected_theme_name(), "Gruvbox Light");
105    }
106
107    #[test]
108    fn down_moves_selection() {
109        use ratatui::crossterm::event::{
110            KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers,
111        };
112        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
113        let mut picker = ThemePicker::new(make_themes(), "Gruvbox Dark");
114        let key = InputEvent::Key(KeyEvent {
115            code: KeyCode::Down,
116            modifiers: KeyModifiers::NONE,
117            kind: KeyEventKind::Press,
118            state: KeyEventState::NONE,
119        });
120        picker.handle_input(&key, &tx);
121        assert_eq!(picker.selected_theme_name(), "Gruvbox Light");
122    }
123
124    #[test]
125    fn up_wraps_from_first_to_last() {
126        use ratatui::crossterm::event::{
127            KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers,
128        };
129        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
130        let mut picker = ThemePicker::new(make_themes(), "Gruvbox Dark");
131        let key = InputEvent::Key(KeyEvent {
132            code: KeyCode::Up,
133            modifiers: KeyModifiers::NONE,
134            kind: KeyEventKind::Press,
135            state: KeyEventState::NONE,
136        });
137        picker.handle_input(&key, &tx);
138        assert_eq!(picker.selected_theme_name(), "Catppuccin Mocha");
139    }
140
141    #[test]
142    fn down_wraps_from_last_to_first() {
143        use crate::components::events::InputEvent;
144        use ratatui::crossterm::event::{
145            KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers,
146        };
147        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
148        let mut picker = ThemePicker::new(make_themes(), "Catppuccin Mocha");
149        let key = InputEvent::Key(KeyEvent {
150            code: KeyCode::Down,
151            modifiers: KeyModifiers::NONE,
152            kind: KeyEventKind::Press,
153            state: KeyEventState::NONE,
154        });
155        picker.handle_input(&key, &tx);
156        assert_eq!(picker.selected_theme_name(), "Gruvbox Dark");
157    }
158
159    #[test]
160    fn j_key_moves_selection_down() {
161        use crate::components::events::InputEvent;
162        use ratatui::crossterm::event::{
163            KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers,
164        };
165        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
166        let mut picker = ThemePicker::new(make_themes(), "Gruvbox Dark");
167        let key = InputEvent::Key(KeyEvent {
168            code: KeyCode::Char('j'),
169            modifiers: KeyModifiers::NONE,
170            kind: KeyEventKind::Press,
171            state: KeyEventState::NONE,
172        });
173        picker.handle_input(&key, &tx);
174        assert_eq!(picker.selected_theme_name(), "Gruvbox Light");
175    }
176
177    #[test]
178    fn k_key_wraps_from_first_to_last() {
179        use crate::components::events::InputEvent;
180        use ratatui::crossterm::event::{
181            KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers,
182        };
183        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
184        let mut picker = ThemePicker::new(make_themes(), "Gruvbox Dark");
185        let key = InputEvent::Key(KeyEvent {
186            code: KeyCode::Char('k'),
187            modifiers: KeyModifiers::NONE,
188            kind: KeyEventKind::Press,
189            state: KeyEventState::NONE,
190        });
191        picker.handle_input(&key, &tx);
192        assert_eq!(picker.selected_theme_name(), "Catppuccin Mocha");
193    }
194
195    #[test]
196    fn renders_without_panic() {
197        use ratatui::Terminal;
198        use ratatui::backend::TestBackend;
199        let backend = TestBackend::new(40, 10);
200        let mut terminal = Terminal::new(backend).unwrap();
201        let mut picker = ThemePicker::new(make_themes(), "Gruvbox Dark");
202        let theme = Theme::gruvbox_dark();
203        terminal
204            .draw(|f| {
205                picker.render(f, f.area(), &theme, false);
206            })
207            .unwrap();
208        let buffer = terminal.backend().buffer().clone();
209        let flat: String = buffer.content.iter().map(|c| c.symbol()).collect();
210        assert!(
211            flat.contains("Gruvbox Dark"),
212            "Expected theme name in rendered output"
213        );
214    }
215}