kimun_notes/components/settings/
theme_picker.rs1use 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.fg_selected.to_ratatui())
83 .bg(theme.bg_selected.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}