Skip to main content

kimun_notes/components/preferences/
display_section.rs

1use 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
11/// Number of selectable rows in this section.
12const 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    /// Whether kimün checks GitHub for a newer release on startup.
21    pub update_check: bool,
22    /// Whether kimün captures the mouse for in-app use. Read only at startup, so
23    /// toggling here applies on the next launch (the row says so).
24    pub mouse: bool,
25    /// Hesitation before the which-key overlay reveals itself. 0 shows it
26    /// the instant the leader sequence arms.
27    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    /// Toggle the currently selected row.
50    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}