kimun_notes/components/dialogs/
theme_picker.rs1use 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: Vec<Theme>,
22 selected: usize,
23 original: usize,
25 offset: usize,
27 popup_rect: Rect,
29 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 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 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 assert_eq!(applied.len(), 2);
199 assert!(!applied[0].1);
200 assert!(applied[1].1);
201 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}