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 = 3;
13
14pub struct DisplaySection {
15    pub use_nerd_fonts: bool,
16    /// Whether kimün checks GitHub for a newer release on startup.
17    pub update_check: bool,
18    /// Whether kimün captures the mouse for in-app use. Read only at startup, so
19    /// toggling here applies on the next launch (the row says so).
20    pub mouse: bool,
21    list_state: ListState,
22}
23
24impl DisplaySection {
25    pub fn new(use_nerd_fonts: bool, update_check: bool, mouse: bool) -> Self {
26        let mut list_state = ListState::default();
27        list_state.select(Some(0));
28        Self {
29            use_nerd_fonts,
30            update_check,
31            mouse,
32            list_state,
33        }
34    }
35
36    /// Toggle the currently selected row.
37    fn toggle_selected(&mut self) {
38        match self.list_state.selected() {
39            Some(0) => self.use_nerd_fonts = !self.use_nerd_fonts,
40            Some(1) => self.update_check = !self.update_check,
41            Some(2) => self.mouse = !self.mouse,
42            _ => {}
43        }
44    }
45
46    fn move_selection(&mut self, delta: isize) {
47        let current = self.list_state.selected().unwrap_or(0) as isize;
48        let next = (current + delta).rem_euclid(ROW_COUNT as isize);
49        self.list_state.select(Some(next as usize));
50    }
51}
52
53impl Component for DisplaySection {
54    fn handle_input(&mut self, event: &InputEvent, _tx: &AppTx) -> EventState {
55        let InputEvent::Key(key) = event else {
56            return EventState::NotConsumed;
57        };
58        use ratatui::crossterm::event::KeyCode;
59        match key.code {
60            KeyCode::Enter | KeyCode::Char(' ') => {
61                self.toggle_selected();
62                EventState::Consumed
63            }
64            KeyCode::Up | KeyCode::Char('k') => {
65                self.move_selection(-1);
66                EventState::Consumed
67            }
68            KeyCode::Down | KeyCode::Char('j') => {
69                self.move_selection(1);
70                EventState::Consumed
71            }
72            _ => EventState::NotConsumed,
73        }
74    }
75
76    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
77        let border_style = theme.border_style(focused);
78        let block = Block::default()
79            .title("Display")
80            .borders(Borders::ALL)
81            .border_style(border_style)
82            .style(theme.base_style());
83
84        let checkbox = |on: bool| if on { "[x]" } else { "[ ]" };
85        let fg = Style::default().fg(theme.fg.to_ratatui());
86        let items = vec![
87            ListItem::new(format!(
88                "  Use Nerd Fonts  {}",
89                checkbox(self.use_nerd_fonts)
90            ))
91            .style(fg),
92            ListItem::new(format!(
93                "  Check for updates on startup  {}",
94                checkbox(self.update_check)
95            ))
96            .style(fg),
97            ListItem::new(format!(
98                "  Capture mouse (restart to apply)  {}",
99                checkbox(self.mouse)
100            ))
101            .style(fg),
102        ];
103
104        let list = List::new(items)
105            .block(block)
106            .style(theme.base_style())
107            .highlight_style(
108                Style::default()
109                    .fg(theme.selection_fg.to_ratatui())
110                    .bg(theme.selection_bg.to_ratatui()),
111            );
112        f.render_stateful_widget(list, rect, &mut self.list_state);
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::components::events::InputEvent;
120    use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
121
122    fn key(code: KeyCode) -> InputEvent {
123        InputEvent::Key(KeyEvent {
124            code,
125            modifiers: KeyModifiers::NONE,
126            kind: KeyEventKind::Press,
127            state: KeyEventState::NONE,
128        })
129    }
130
131    #[test]
132    fn enter_toggles_nerd_fonts() {
133        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
134        let mut section = DisplaySection::new(true, true, true);
135        section.handle_input(&key(KeyCode::Enter), &tx);
136        assert!(!section.use_nerd_fonts);
137        section.handle_input(&key(KeyCode::Enter), &tx);
138        assert!(section.use_nerd_fonts);
139    }
140
141    #[test]
142    fn space_toggles_nerd_fonts() {
143        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
144        let mut section = DisplaySection::new(false, true, true);
145        section.handle_input(&key(KeyCode::Char(' ')), &tx);
146        assert!(section.use_nerd_fonts);
147    }
148
149    #[test]
150    fn down_then_toggle_flips_update_check_only() {
151        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
152        let mut section = DisplaySection::new(true, true, true);
153        section.handle_input(&key(KeyCode::Down), &tx);
154        section.handle_input(&key(KeyCode::Enter), &tx);
155        assert!(!section.update_check, "update_check should toggle off");
156        assert!(section.use_nerd_fonts, "nerd fonts should be untouched");
157    }
158
159    #[test]
160    fn down_twice_then_toggle_flips_mouse_only() {
161        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
162        let mut section = DisplaySection::new(true, true, true);
163        section.handle_input(&key(KeyCode::Down), &tx);
164        section.handle_input(&key(KeyCode::Down), &tx);
165        section.handle_input(&key(KeyCode::Enter), &tx);
166        assert!(!section.mouse, "mouse should toggle off");
167        assert!(section.use_nerd_fonts, "nerd fonts should be untouched");
168        assert!(section.update_check, "update_check should be untouched");
169    }
170
171    #[test]
172    fn renders_checked_when_enabled() {
173        use ratatui::Terminal;
174        use ratatui::backend::TestBackend;
175        let backend = TestBackend::new(40, 10);
176        let mut terminal = Terminal::new(backend).unwrap();
177        let mut section = DisplaySection::new(true, true, true);
178        let theme = Theme::gruvbox_dark();
179        terminal
180            .draw(|f| section.render(f, f.area(), &theme, false))
181            .unwrap();
182        let buf = terminal.backend().buffer().clone();
183        let flat: String = buf.content.iter().map(|c| c.symbol()).collect();
184        assert!(flat.contains("[x]"), "expected [x] when nerd fonts enabled");
185    }
186
187    #[test]
188    fn renders_unchecked_when_disabled() {
189        use ratatui::Terminal;
190        use ratatui::backend::TestBackend;
191        let backend = TestBackend::new(40, 10);
192        let mut terminal = Terminal::new(backend).unwrap();
193        let mut section = DisplaySection::new(false, true, true);
194        let theme = Theme::gruvbox_dark();
195        terminal
196            .draw(|f| section.render(f, f.area(), &theme, false))
197            .unwrap();
198        let buf = terminal.backend().buffer().clone();
199        let flat: String = buf.content.iter().map(|c| c.symbol()).collect();
200        assert!(
201            flat.contains("[ ]"),
202            "expected [ ] when nerd fonts disabled"
203        );
204    }
205}