kimun_notes/components/preferences/
display_section.rs1use ratatui::Frame;
2use ratatui::layout::Rect;
3use ratatui::style::Style;
4use ratatui::widgets::{Block, Borders, List, ListItem, ListState};
5
6use crate::components::Component;
7use crate::components::event_state::EventState;
8use crate::components::events::{AppTx, InputEvent};
9use crate::settings::themes::Theme;
10
11const ROW_COUNT: usize = 4;
13const ROW_LEADER_TIMEOUT: usize = 3;
14
15const MAX_LEADER_TIMEOUT_MS: u64 = 2000;
16const LEADER_TIMEOUT_STEP: u64 = 50;
17
18pub struct DisplaySection {
19 pub use_nerd_fonts: bool,
20 pub update_check: bool,
22 pub mouse: bool,
25 pub leader_timeout_ms: u64,
28 list_state: ListState,
29}
30
31impl DisplaySection {
32 pub fn new(
33 use_nerd_fonts: bool,
34 update_check: bool,
35 mouse: bool,
36 leader_timeout_ms: u64,
37 ) -> Self {
38 let mut list_state = ListState::default();
39 list_state.select(Some(0));
40 Self {
41 use_nerd_fonts,
42 update_check,
43 mouse,
44 leader_timeout_ms,
45 list_state,
46 }
47 }
48
49 fn toggle_selected(&mut self) {
51 match self.list_state.selected() {
52 Some(0) => self.use_nerd_fonts = !self.use_nerd_fonts,
53 Some(1) => self.update_check = !self.update_check,
54 Some(2) => self.mouse = !self.mouse,
55 _ => {}
56 }
57 }
58
59 fn adjust_leader_timeout(&mut self, increase: bool) {
60 self.leader_timeout_ms = if increase {
61 (self.leader_timeout_ms + LEADER_TIMEOUT_STEP).min(MAX_LEADER_TIMEOUT_MS)
62 } else {
63 self.leader_timeout_ms.saturating_sub(LEADER_TIMEOUT_STEP)
64 };
65 }
66
67 fn move_selection(&mut self, delta: isize) {
68 let current = self.list_state.selected().unwrap_or(0) as isize;
69 let next = (current + delta).rem_euclid(ROW_COUNT as isize);
70 self.list_state.select(Some(next as usize));
71 }
72}
73
74impl Component for DisplaySection {
75 fn handle_input(&mut self, event: &InputEvent, _tx: &AppTx) -> EventState {
76 let InputEvent::Key(key) = event else {
77 return EventState::NotConsumed;
78 };
79 use ratatui::crossterm::event::KeyCode;
80 match key.code {
81 KeyCode::Enter | KeyCode::Char(' ') => {
82 self.toggle_selected();
83 EventState::Consumed
84 }
85 KeyCode::Up | KeyCode::Char('k') => {
86 self.move_selection(-1);
87 EventState::Consumed
88 }
89 KeyCode::Down | KeyCode::Char('j') => {
90 self.move_selection(1);
91 EventState::Consumed
92 }
93 KeyCode::Left | KeyCode::Char('h')
94 if self.list_state.selected() == Some(ROW_LEADER_TIMEOUT) =>
95 {
96 self.adjust_leader_timeout(false);
97 EventState::Consumed
98 }
99 KeyCode::Right | KeyCode::Char('l')
100 if self.list_state.selected() == Some(ROW_LEADER_TIMEOUT) =>
101 {
102 self.adjust_leader_timeout(true);
103 EventState::Consumed
104 }
105 _ => EventState::NotConsumed,
106 }
107 }
108
109 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
110 let border_style = theme.border_style(focused);
111 let block = Block::default()
112 .title("Display")
113 .borders(Borders::ALL)
114 .border_style(border_style)
115 .style(theme.base_style());
116
117 let checkbox = |on: bool| if on { "[x]" } else { "[ ]" };
118 let fg = Style::default().fg(theme.fg.to_ratatui());
119 let items = vec![
120 ListItem::new(format!(
121 " Use Nerd Fonts {}",
122 checkbox(self.use_nerd_fonts)
123 ))
124 .style(fg),
125 ListItem::new(format!(
126 " Check for updates on startup {}",
127 checkbox(self.update_check)
128 ))
129 .style(fg),
130 ListItem::new(format!(
131 " Capture mouse (restart to apply) {}",
132 checkbox(self.mouse)
133 ))
134 .style(fg),
135 ListItem::new(format!(
136 " Which-key Delay ◀ {}ms ▶ (←/→ to change)",
137 self.leader_timeout_ms
138 ))
139 .style(fg),
140 ];
141
142 let list = List::new(items)
143 .block(block)
144 .style(theme.base_style())
145 .highlight_style(
146 Style::default()
147 .fg(theme.selection_fg.to_ratatui())
148 .bg(theme.selection_bg.to_ratatui()),
149 );
150 f.render_stateful_widget(list, rect, &mut self.list_state);
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157 use crate::components::events::InputEvent;
158 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
159
160 fn key(code: KeyCode) -> InputEvent {
161 InputEvent::Key(KeyEvent {
162 code,
163 modifiers: KeyModifiers::NONE,
164 kind: KeyEventKind::Press,
165 state: KeyEventState::NONE,
166 })
167 }
168
169 #[test]
170 fn enter_toggles_nerd_fonts() {
171 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
172 let mut section = DisplaySection::new(true, true, true, 400);
173 section.handle_input(&key(KeyCode::Enter), &tx);
174 assert!(!section.use_nerd_fonts);
175 section.handle_input(&key(KeyCode::Enter), &tx);
176 assert!(section.use_nerd_fonts);
177 }
178
179 #[test]
180 fn space_toggles_nerd_fonts() {
181 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
182 let mut section = DisplaySection::new(false, true, true, 400);
183 section.handle_input(&key(KeyCode::Char(' ')), &tx);
184 assert!(section.use_nerd_fonts);
185 }
186
187 #[test]
188 fn down_then_toggle_flips_update_check_only() {
189 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
190 let mut section = DisplaySection::new(true, true, true, 400);
191 section.handle_input(&key(KeyCode::Down), &tx);
192 section.handle_input(&key(KeyCode::Enter), &tx);
193 assert!(!section.update_check, "update_check should toggle off");
194 assert!(section.use_nerd_fonts, "nerd fonts should be untouched");
195 }
196
197 #[test]
198 fn down_twice_then_toggle_flips_mouse_only() {
199 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
200 let mut section = DisplaySection::new(true, true, true, 400);
201 section.handle_input(&key(KeyCode::Down), &tx);
202 section.handle_input(&key(KeyCode::Down), &tx);
203 section.handle_input(&key(KeyCode::Enter), &tx);
204 assert!(!section.mouse, "mouse should toggle off");
205 assert!(section.use_nerd_fonts, "nerd fonts should be untouched");
206 assert!(section.update_check, "update_check should be untouched");
207 }
208
209 #[test]
210 fn down_three_times_then_right_increases_leader_timeout() {
211 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
212 let mut section = DisplaySection::new(true, true, true, 400);
213 for _ in 0..3 {
214 section.handle_input(&key(KeyCode::Down), &tx);
215 }
216 section.handle_input(&key(KeyCode::Right), &tx);
217 assert_eq!(section.leader_timeout_ms, 450);
218 }
219
220 #[test]
221 fn left_decreases_leader_timeout() {
222 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
223 let mut section = DisplaySection::new(true, true, true, 400);
224 for _ in 0..3 {
225 section.handle_input(&key(KeyCode::Down), &tx);
226 }
227 section.handle_input(&key(KeyCode::Left), &tx);
228 assert_eq!(section.leader_timeout_ms, 350);
229 }
230
231 #[test]
232 fn leader_timeout_clamps_at_zero() {
233 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
234 let mut section = DisplaySection::new(true, true, true, 20);
235 for _ in 0..3 {
236 section.handle_input(&key(KeyCode::Down), &tx);
237 }
238 section.handle_input(&key(KeyCode::Left), &tx);
239 assert_eq!(section.leader_timeout_ms, 0);
240 }
241
242 #[test]
243 fn leader_timeout_clamps_at_max() {
244 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
245 let mut section = DisplaySection::new(true, true, true, 1980);
246 for _ in 0..3 {
247 section.handle_input(&key(KeyCode::Down), &tx);
248 }
249 section.handle_input(&key(KeyCode::Right), &tx);
250 assert_eq!(section.leader_timeout_ms, 2000);
251 }
252
253 #[test]
254 fn left_right_do_nothing_off_the_leader_timeout_row() {
255 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
256 let mut section = DisplaySection::new(true, true, true, 400);
257 let state = section.handle_input(&key(KeyCode::Right), &tx);
258 assert_eq!(state, EventState::NotConsumed);
259 assert_eq!(section.leader_timeout_ms, 400);
260 }
261
262 #[test]
263 fn renders_leader_timeout_value() {
264 use ratatui::Terminal;
265 use ratatui::backend::TestBackend;
266 let backend = TestBackend::new(60, 10);
267 let mut terminal = Terminal::new(backend).unwrap();
268 let mut section = DisplaySection::new(true, true, true, 400);
269 let theme = Theme::gruvbox_dark();
270 terminal
271 .draw(|f| section.render(f, f.area(), &theme, false))
272 .unwrap();
273 let buf = terminal.backend().buffer().clone();
274 let flat: String = buf.content.iter().map(|c| c.symbol()).collect();
275 assert!(flat.contains("400ms"), "expected the delay value: {flat}");
276 }
277
278 #[test]
279 fn renders_checked_when_enabled() {
280 use ratatui::Terminal;
281 use ratatui::backend::TestBackend;
282 let backend = TestBackend::new(40, 10);
283 let mut terminal = Terminal::new(backend).unwrap();
284 let mut section = DisplaySection::new(true, true, true, 400);
285 let theme = Theme::gruvbox_dark();
286 terminal
287 .draw(|f| section.render(f, f.area(), &theme, false))
288 .unwrap();
289 let buf = terminal.backend().buffer().clone();
290 let flat: String = buf.content.iter().map(|c| c.symbol()).collect();
291 assert!(flat.contains("[x]"), "expected [x] when nerd fonts enabled");
292 }
293
294 #[test]
295 fn renders_unchecked_when_disabled() {
296 use ratatui::Terminal;
297 use ratatui::backend::TestBackend;
298 let backend = TestBackend::new(40, 10);
299 let mut terminal = Terminal::new(backend).unwrap();
300 let mut section = DisplaySection::new(false, true, true, 400);
301 let theme = Theme::gruvbox_dark();
302 terminal
303 .draw(|f| section.render(f, f.area(), &theme, false))
304 .unwrap();
305 let buf = terminal.backend().buffer().clone();
306 let flat: String = buf.content.iter().map(|c| c.symbol()).collect();
307 assert!(
308 flat.contains("[ ]"),
309 "expected [ ] when nerd fonts disabled"
310 );
311 }
312}