1use 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#[derive(Clone)]
28struct ThemeRow {
29 name: String,
30 index: usize,
31 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: Vec<Theme>,
56 list: SearchList<ThemeRow>,
57 original: usize,
59 previewed: usize,
61 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 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 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 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 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 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 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 assert_eq!(applied.len(), 2);
256 assert!(!applied[0].1);
257 assert!(applied[1].1);
258 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 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 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}