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