Skip to main content

kimun_notes/components/preferences/
display_section.rs

1use ratatui::Frame;
2use ratatui::layout::Rect;
3use ratatui::style::Style;
4use ratatui::text::{Line, Span};
5use ratatui::widgets::{Block, Borders, List, ListItem, ListState};
6use unicode_width::UnicodeWidthStr;
7
8use crate::components::Component;
9use crate::components::event_state::EventState;
10use crate::components::events::{AppTx, InputEvent};
11use crate::settings::themes::Theme;
12
13use super::{ClickMap, text_rect};
14
15/// Number of selectable rows in this section.
16const ROW_COUNT: usize = 4;
17const ROW_LEADER_TIMEOUT: usize = 3;
18
19const MAX_LEADER_TIMEOUT_MS: u64 = 2000;
20const LEADER_TIMEOUT_STEP: u64 = 50;
21
22pub struct DisplaySection {
23    pub use_nerd_fonts: bool,
24    /// Whether kimün checks GitHub for a newer release on startup.
25    pub update_check: bool,
26    /// Whether kimün captures the mouse for in-app use. Read only at startup, so
27    /// toggling here applies on the next launch (the row says so).
28    pub mouse: bool,
29    /// Hesitation before the which-key overlay reveals itself. 0 shows it
30    /// the instant the leader sequence arms.
31    pub leader_timeout_ms: u64,
32    list_state: ListState,
33    clicks: ClickMap,
34}
35
36impl DisplaySection {
37    pub fn new(
38        use_nerd_fonts: bool,
39        update_check: bool,
40        mouse: bool,
41        leader_timeout_ms: u64,
42    ) -> Self {
43        let mut list_state = ListState::default();
44        list_state.select(Some(0));
45        Self {
46            use_nerd_fonts,
47            update_check,
48            mouse,
49            leader_timeout_ms,
50            list_state,
51            clicks: ClickMap::default(),
52        }
53    }
54
55    /// Resolve a click: a row selects it, a checkbox toggles, `◀`/`▶` step
56    /// the delay, clicking the selected row toggles it. Returns the key the
57    /// click stands for, for the screen to run through its key path.
58    pub fn handle_mouse(
59        &mut self,
60        m: &ratatui::crossterm::event::MouseEvent,
61    ) -> Option<ratatui::crossterm::event::KeyEvent> {
62        use ratatui::crossterm::event::KeyCode;
63        let selected = self.list_state.selected();
64        let activate = (selected != Some(ROW_LEADER_TIMEOUT)).then_some(KeyCode::Enter);
65        self.clicks
66            .resolve(m, selected, activate)
67            .into_key(|row| self.list_state.select(Some(row)))
68    }
69
70    /// Toggle the currently selected row.
71    fn toggle_selected(&mut self) {
72        match self.list_state.selected() {
73            Some(0) => self.use_nerd_fonts = !self.use_nerd_fonts,
74            Some(1) => self.update_check = !self.update_check,
75            Some(2) => self.mouse = !self.mouse,
76            _ => {}
77        }
78    }
79
80    fn adjust_leader_timeout(&mut self, increase: bool) {
81        self.leader_timeout_ms = if increase {
82            (self.leader_timeout_ms + LEADER_TIMEOUT_STEP).min(MAX_LEADER_TIMEOUT_MS)
83        } else {
84            self.leader_timeout_ms.saturating_sub(LEADER_TIMEOUT_STEP)
85        };
86    }
87
88    fn move_selection(&mut self, delta: isize) {
89        let current = self.list_state.selected().unwrap_or(0) as isize;
90        let next = (current + delta).rem_euclid(ROW_COUNT as isize);
91        self.list_state.select(Some(next as usize));
92    }
93}
94
95impl Component for DisplaySection {
96    fn handle_input(&mut self, event: &InputEvent, _tx: &AppTx) -> EventState {
97        let InputEvent::Key(key) = event else {
98            return EventState::NotConsumed;
99        };
100        use ratatui::crossterm::event::KeyCode;
101        match key.code {
102            KeyCode::Enter | KeyCode::Char(' ') => {
103                self.toggle_selected();
104                EventState::Consumed
105            }
106            KeyCode::Up | KeyCode::Char('k') => {
107                self.move_selection(-1);
108                EventState::Consumed
109            }
110            KeyCode::Down | KeyCode::Char('j') => {
111                self.move_selection(1);
112                EventState::Consumed
113            }
114            KeyCode::Left | KeyCode::Char('h')
115                if self.list_state.selected() == Some(ROW_LEADER_TIMEOUT) =>
116            {
117                self.adjust_leader_timeout(false);
118                EventState::Consumed
119            }
120            KeyCode::Right | KeyCode::Char('l')
121                if self.list_state.selected() == Some(ROW_LEADER_TIMEOUT) =>
122            {
123                self.adjust_leader_timeout(true);
124                EventState::Consumed
125            }
126            _ => EventState::NotConsumed,
127        }
128    }
129
130    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
131        let border_style = theme.border_style(focused);
132        let block = Block::default()
133            .title("Display")
134            .borders(Borders::ALL)
135            .border_style(border_style)
136            .style(theme.base_style());
137
138        use ratatui::crossterm::event::KeyCode;
139        let checkbox = |on: bool| if on { "[x]" } else { "[ ]" };
140        let fg = Style::default().fg(theme.fg.to_ratatui());
141        let action = theme.action();
142        let inner = block.inner(rect);
143        // Controls per row, as (row, column, width, key) — mapped to screen
144        // rects after rendering, once the list has settled its offset.
145        let mut controls: Vec<(usize, u16, u16, KeyCode)> = Vec::new();
146        let mut items = Vec::new();
147        // Checkbox rows: label, then a clickable `[x]`.
148        for (row, label, on) in [
149            (0, "  Use Nerd Fonts  ", self.use_nerd_fonts),
150            (1, "  Check for updates on startup  ", self.update_check),
151            (2, "  Capture mouse (restart to apply)  ", self.mouse),
152        ] {
153            controls.push((row, label.width() as u16, 3, KeyCode::Enter));
154            items.push(
155                ListItem::new(Line::from(vec![
156                    Span::raw(label),
157                    Span::styled(checkbox(on), action),
158                ]))
159                .style(fg),
160            );
161        }
162        // Stepper row: `◀` and `▶` step the delay.
163        let label = "  Which-key Delay  ";
164        let value = format!(" {}ms ", self.leader_timeout_ms);
165        let left_col = label.width() as u16;
166        let right_col = left_col + 1 + value.width() as u16;
167        controls.push((ROW_LEADER_TIMEOUT, left_col, 1, KeyCode::Left));
168        controls.push((ROW_LEADER_TIMEOUT, right_col, 1, KeyCode::Right));
169        items.push(
170            ListItem::new(Line::from(vec![
171                Span::raw(label),
172                Span::styled("◀", action),
173                Span::raw(value),
174                Span::styled("▶", action),
175                Span::raw("  (←/→ to change)"),
176            ]))
177            .style(fg),
178        );
179
180        let list = List::new(items)
181            .block(block)
182            .style(theme.base_style())
183            .highlight_style(
184                Style::default()
185                    .fg(theme.selection_fg.to_ratatui())
186                    .bg(theme.selection_bg.to_ratatui()),
187            );
188        f.render_stateful_widget(list, rect, &mut self.list_state);
189
190        // Rows map from the offset the list settled on while rendering.
191        self.clicks.clear();
192        let offset = self.list_state.offset();
193        let row_rect = |row: usize| {
194            let y = inner.y as usize + row.saturating_sub(offset);
195            if row < offset || y >= inner.bottom() as usize {
196                Rect::default()
197            } else {
198                Rect::new(inner.x, y as u16, inner.width, 1)
199            }
200        };
201        for row in 0..ROW_COUNT {
202            self.clicks.row(row_rect(row), row);
203        }
204        for (row, col, width, key) in controls {
205            self.clicks
206                .control(text_rect(row_rect(row), col, width), row, key);
207        }
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::components::events::InputEvent;
215    use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
216
217    fn key(code: KeyCode) -> InputEvent {
218        InputEvent::Key(KeyEvent {
219            code,
220            modifiers: KeyModifiers::NONE,
221            kind: KeyEventKind::Press,
222            state: KeyEventState::NONE,
223        })
224    }
225
226    #[test]
227    fn enter_toggles_nerd_fonts() {
228        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
229        let mut section = DisplaySection::new(true, true, true, 400);
230        section.handle_input(&key(KeyCode::Enter), &tx);
231        assert!(!section.use_nerd_fonts);
232        section.handle_input(&key(KeyCode::Enter), &tx);
233        assert!(section.use_nerd_fonts);
234    }
235
236    #[test]
237    fn space_toggles_nerd_fonts() {
238        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
239        let mut section = DisplaySection::new(false, true, true, 400);
240        section.handle_input(&key(KeyCode::Char(' ')), &tx);
241        assert!(section.use_nerd_fonts);
242    }
243
244    #[test]
245    fn down_then_toggle_flips_update_check_only() {
246        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
247        let mut section = DisplaySection::new(true, true, true, 400);
248        section.handle_input(&key(KeyCode::Down), &tx);
249        section.handle_input(&key(KeyCode::Enter), &tx);
250        assert!(!section.update_check, "update_check should toggle off");
251        assert!(section.use_nerd_fonts, "nerd fonts should be untouched");
252    }
253
254    #[test]
255    fn down_twice_then_toggle_flips_mouse_only() {
256        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
257        let mut section = DisplaySection::new(true, true, true, 400);
258        section.handle_input(&key(KeyCode::Down), &tx);
259        section.handle_input(&key(KeyCode::Down), &tx);
260        section.handle_input(&key(KeyCode::Enter), &tx);
261        assert!(!section.mouse, "mouse should toggle off");
262        assert!(section.use_nerd_fonts, "nerd fonts should be untouched");
263        assert!(section.update_check, "update_check should be untouched");
264    }
265
266    #[test]
267    fn down_three_times_then_right_increases_leader_timeout() {
268        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
269        let mut section = DisplaySection::new(true, true, true, 400);
270        for _ in 0..3 {
271            section.handle_input(&key(KeyCode::Down), &tx);
272        }
273        section.handle_input(&key(KeyCode::Right), &tx);
274        assert_eq!(section.leader_timeout_ms, 450);
275    }
276
277    #[test]
278    fn left_decreases_leader_timeout() {
279        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
280        let mut section = DisplaySection::new(true, true, true, 400);
281        for _ in 0..3 {
282            section.handle_input(&key(KeyCode::Down), &tx);
283        }
284        section.handle_input(&key(KeyCode::Left), &tx);
285        assert_eq!(section.leader_timeout_ms, 350);
286    }
287
288    #[test]
289    fn leader_timeout_clamps_at_zero() {
290        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
291        let mut section = DisplaySection::new(true, true, true, 20);
292        for _ in 0..3 {
293            section.handle_input(&key(KeyCode::Down), &tx);
294        }
295        section.handle_input(&key(KeyCode::Left), &tx);
296        assert_eq!(section.leader_timeout_ms, 0);
297    }
298
299    #[test]
300    fn leader_timeout_clamps_at_max() {
301        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
302        let mut section = DisplaySection::new(true, true, true, 1980);
303        for _ in 0..3 {
304            section.handle_input(&key(KeyCode::Down), &tx);
305        }
306        section.handle_input(&key(KeyCode::Right), &tx);
307        assert_eq!(section.leader_timeout_ms, 2000);
308    }
309
310    #[test]
311    fn left_right_do_nothing_off_the_leader_timeout_row() {
312        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
313        let mut section = DisplaySection::new(true, true, true, 400);
314        let state = section.handle_input(&key(KeyCode::Right), &tx);
315        assert_eq!(state, EventState::NotConsumed);
316        assert_eq!(section.leader_timeout_ms, 400);
317    }
318
319    #[test]
320    fn renders_leader_timeout_value() {
321        use ratatui::Terminal;
322        use ratatui::backend::TestBackend;
323        let backend = TestBackend::new(60, 10);
324        let mut terminal = Terminal::new(backend).unwrap();
325        let mut section = DisplaySection::new(true, true, true, 400);
326        let theme = Theme::gruvbox_dark();
327        terminal
328            .draw(|f| section.render(f, f.area(), &theme, false))
329            .unwrap();
330        let buf = terminal.backend().buffer().clone();
331        let flat: String = buf.content.iter().map(|c| c.symbol()).collect();
332        assert!(flat.contains("400ms"), "expected the delay value: {flat}");
333    }
334
335    #[test]
336    fn renders_checked_when_enabled() {
337        use ratatui::Terminal;
338        use ratatui::backend::TestBackend;
339        let backend = TestBackend::new(40, 10);
340        let mut terminal = Terminal::new(backend).unwrap();
341        let mut section = DisplaySection::new(true, true, true, 400);
342        let theme = Theme::gruvbox_dark();
343        terminal
344            .draw(|f| section.render(f, f.area(), &theme, false))
345            .unwrap();
346        let buf = terminal.backend().buffer().clone();
347        let flat: String = buf.content.iter().map(|c| c.symbol()).collect();
348        assert!(flat.contains("[x]"), "expected [x] when nerd fonts enabled");
349    }
350
351    #[test]
352    fn renders_unchecked_when_disabled() {
353        use ratatui::Terminal;
354        use ratatui::backend::TestBackend;
355        let backend = TestBackend::new(40, 10);
356        let mut terminal = Terminal::new(backend).unwrap();
357        let mut section = DisplaySection::new(false, true, true, 400);
358        let theme = Theme::gruvbox_dark();
359        terminal
360            .draw(|f| section.render(f, f.area(), &theme, false))
361            .unwrap();
362        let buf = terminal.backend().buffer().clone();
363        let flat: String = buf.content.iter().map(|c| c.symbol()).collect();
364        assert!(
365            flat.contains("[ ]"),
366            "expected [ ] when nerd fonts disabled"
367        );
368    }
369}