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