Skip to main content

kimun_notes/components/preferences/
editor_section.rs

1use ratatui::Frame;
2use ratatui::layout::{Constraint, Direction, Layout, Rect};
3use ratatui::widgets::{Block, Borders, Paragraph};
4
5use crate::components::Component;
6use crate::components::event_state::EventState;
7use crate::components::events::{AppTx, InputEvent};
8use crate::settings::EditorBackendSetting;
9use crate::settings::themes::Theme;
10
11use super::{ClickMap, text_rect};
12use ratatui::text::{Line, Span};
13use unicode_width::UnicodeWidthStr;
14
15const MIN_AUTOSAVE_SECS: u64 = 5;
16const MAX_AUTOSAVE_SECS: u64 = 300;
17const STEP: u64 = 5;
18
19const ROW_AUTOSAVE: usize = 0;
20const ROW_BACKEND: usize = 1;
21const ROW_COUNT: usize = 2;
22
23pub struct EditorSection {
24    pub autosave_interval_secs: u64,
25    pub editor_backend: EditorBackendSetting,
26    selected_row: usize,
27    clicks: ClickMap,
28}
29
30impl EditorSection {
31    pub fn new(autosave_interval_secs: u64, editor_backend: EditorBackendSetting) -> Self {
32        Self {
33            autosave_interval_secs,
34            editor_backend,
35            selected_row: ROW_AUTOSAVE,
36            clicks: ClickMap::default(),
37        }
38    }
39
40    /// Resolve a click: a setting's label or value selects it, `◀`/`▶`
41    /// step it, re-clicking the backend cycles it. Returns the key the
42    /// click stands for.
43    pub fn handle_mouse(
44        &mut self,
45        m: &ratatui::crossterm::event::MouseEvent,
46    ) -> Option<ratatui::crossterm::event::KeyEvent> {
47        use ratatui::crossterm::event::KeyCode;
48        let activate = (self.selected_row == ROW_BACKEND).then_some(KeyCode::Enter);
49        self.clicks
50            .resolve(m, Some(self.selected_row), activate)
51            .into_key(|row| self.selected_row = row)
52    }
53
54    /// `  ◀  value  ▶` with both arrows as click targets for `row`.
55    fn stepper(&mut self, r: Rect, row: usize, value: &str, theme: &Theme) -> Line<'static> {
56        use ratatui::crossterm::event::KeyCode;
57        let right_col = format!("  ◀  {value}  ").width() as u16;
58        self.clicks.control(text_rect(r, 2, 1), row, KeyCode::Left);
59        self.clicks
60            .control(text_rect(r, right_col, 1), row, KeyCode::Right);
61        Line::from(vec![
62            Span::raw("  "),
63            Span::styled("◀", theme.action()),
64            Span::raw(format!("  {value}  ")),
65            Span::styled("▶", theme.action()),
66            Span::raw("   (←/→ to change)"),
67        ])
68    }
69
70    fn cycle_backend(b: EditorBackendSetting, forward: bool) -> EditorBackendSetting {
71        use EditorBackendSetting::*;
72        if forward {
73            match b {
74                Plain => Vim,
75                Vim => Nvim,
76                Nvim => Plain,
77            }
78        } else {
79            match b {
80                Plain => Nvim,
81                Vim => Plain,
82                Nvim => Vim,
83            }
84        }
85    }
86
87    fn backend_label(b: EditorBackendSetting) -> &'static str {
88        match b {
89            EditorBackendSetting::Plain => "Plain",
90            EditorBackendSetting::Vim => "Vim (built-in)",
91            EditorBackendSetting::Nvim => "Nvim (external)",
92        }
93    }
94
95    fn adjust_autosave(&mut self, increase: bool) {
96        self.autosave_interval_secs = if increase {
97            (self.autosave_interval_secs + STEP).min(MAX_AUTOSAVE_SECS)
98        } else {
99            self.autosave_interval_secs
100                .saturating_sub(STEP)
101                .max(MIN_AUTOSAVE_SECS)
102        };
103    }
104}
105
106impl Component for EditorSection {
107    fn handle_input(&mut self, event: &InputEvent, _tx: &AppTx) -> EventState {
108        let InputEvent::Key(key) = event else {
109            return EventState::NotConsumed;
110        };
111        use ratatui::crossterm::event::KeyCode;
112        match key.code {
113            KeyCode::Up | KeyCode::Char('k') => {
114                self.selected_row = (self.selected_row + ROW_COUNT - 1) % ROW_COUNT;
115                EventState::Consumed
116            }
117            KeyCode::Down | KeyCode::Char('j') => {
118                self.selected_row = (self.selected_row + 1) % ROW_COUNT;
119                EventState::Consumed
120            }
121            KeyCode::Left | KeyCode::Char('h') => {
122                match self.selected_row {
123                    ROW_AUTOSAVE => self.adjust_autosave(false),
124                    _ => self.editor_backend = Self::cycle_backend(self.editor_backend, false),
125                }
126                EventState::Consumed
127            }
128            KeyCode::Right | KeyCode::Char('l') => {
129                match self.selected_row {
130                    ROW_AUTOSAVE => self.adjust_autosave(true),
131                    _ => self.editor_backend = Self::cycle_backend(self.editor_backend, true),
132                }
133                EventState::Consumed
134            }
135            KeyCode::Enter | KeyCode::Char(' ') if self.selected_row == ROW_BACKEND => {
136                self.editor_backend = Self::cycle_backend(self.editor_backend, true);
137                EventState::Consumed
138            }
139            _ => EventState::NotConsumed,
140        }
141    }
142
143    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
144        let border_style = theme.border_style(focused);
145        let block = Block::default()
146            .title("Editor")
147            .borders(Borders::ALL)
148            .border_style(border_style)
149            .style(theme.base_style());
150        let inner = block.inner(rect);
151        f.render_widget(block, rect);
152
153        let rows = Layout::default()
154            .direction(Direction::Vertical)
155            .constraints([
156                Constraint::Length(1), // autosave label
157                Constraint::Length(1), // autosave value
158                Constraint::Length(1), // spacer
159                Constraint::Length(1), // backend label
160                Constraint::Length(1), // backend value
161                Constraint::Length(1), // backend hint
162                Constraint::Min(0),
163            ])
164            .split(inner);
165
166        let value_style = |row: usize| {
167            if focused && self.selected_row == row {
168                ratatui::style::Style::default()
169                    .fg(theme.accent.to_ratatui())
170                    .bg(theme.bg.to_ratatui())
171            } else {
172                ratatui::style::Style::default()
173                    .fg(theme.fg.to_ratatui())
174                    .bg(theme.bg.to_ratatui())
175            }
176        };
177
178        let autosave_style = value_style(ROW_AUTOSAVE);
179        let backend_style = value_style(ROW_BACKEND);
180        self.clicks.clear();
181        for (label_row, value_row, row) in [(0, 1, ROW_AUTOSAVE), (3, 4, ROW_BACKEND)] {
182            self.clicks.row(rows[label_row], row);
183            self.clicks.row(rows[value_row], row);
184        }
185
186        let label = Paragraph::new("Autosave Interval").style(theme.base_style());
187        f.render_widget(label, rows[0]);
188        let autosave = self.stepper(
189            rows[1],
190            ROW_AUTOSAVE,
191            &format!("{}s", self.autosave_interval_secs),
192            theme,
193        );
194        f.render_widget(Paragraph::new(autosave).style(autosave_style), rows[1]);
195
196        let label = Paragraph::new("Editor Backend").style(theme.base_style());
197        f.render_widget(label, rows[3]);
198        let backend = self.stepper(
199            rows[4],
200            ROW_BACKEND,
201            Self::backend_label(self.editor_backend),
202            theme,
203        );
204        f.render_widget(Paragraph::new(backend).style(backend_style), rows[4]);
205        let hint = Paragraph::new("  applies when a note is opened").style(
206            ratatui::style::Style::default()
207                .fg(theme.gray.to_ratatui())
208                .bg(theme.bg.to_ratatui()),
209        );
210        f.render_widget(hint, rows[5]);
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
218
219    fn key(code: KeyCode) -> InputEvent {
220        InputEvent::Key(KeyEvent {
221            code,
222            modifiers: KeyModifiers::NONE,
223            kind: KeyEventKind::Press,
224            state: KeyEventState::NONE,
225        })
226    }
227
228    fn section() -> EditorSection {
229        EditorSection::new(10, EditorBackendSetting::Plain)
230    }
231
232    #[test]
233    fn right_increases_interval_by_step() {
234        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
235        let mut section = section();
236        section.handle_input(&key(KeyCode::Right), &tx);
237        assert_eq!(section.autosave_interval_secs, 15);
238    }
239
240    #[test]
241    fn left_decreases_interval_by_step() {
242        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
243        let mut section = section();
244        section.handle_input(&key(KeyCode::Left), &tx);
245        assert_eq!(section.autosave_interval_secs, 5);
246    }
247
248    #[test]
249    fn left_clamps_at_min() {
250        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
251        let mut section = EditorSection::new(5, EditorBackendSetting::Plain);
252        section.handle_input(&key(KeyCode::Left), &tx);
253        assert_eq!(section.autosave_interval_secs, MIN_AUTOSAVE_SECS);
254    }
255
256    #[test]
257    fn right_clamps_at_max() {
258        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
259        let mut section = EditorSection::new(298, EditorBackendSetting::Plain);
260        section.handle_input(&key(KeyCode::Right), &tx);
261        assert_eq!(section.autosave_interval_secs, MAX_AUTOSAVE_SECS);
262    }
263
264    #[test]
265    fn l_key_increases_interval() {
266        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
267        let mut section = section();
268        section.handle_input(&key(KeyCode::Char('l')), &tx);
269        assert_eq!(section.autosave_interval_secs, 15);
270    }
271
272    #[test]
273    fn h_key_decreases_interval() {
274        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
275        let mut section = section();
276        section.handle_input(&key(KeyCode::Char('h')), &tx);
277        assert_eq!(section.autosave_interval_secs, 5);
278    }
279
280    #[test]
281    fn down_selects_backend_row_and_right_cycles() {
282        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
283        let mut section = section();
284        section.handle_input(&key(KeyCode::Down), &tx);
285        section.handle_input(&key(KeyCode::Right), &tx);
286        assert_eq!(section.editor_backend, EditorBackendSetting::Vim);
287        // autosave untouched while on the backend row
288        assert_eq!(section.autosave_interval_secs, 10);
289    }
290
291    #[test]
292    fn backend_cycle_wraps_forward() {
293        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
294        let mut section = section();
295        section.handle_input(&key(KeyCode::Down), &tx);
296        for expected in [
297            EditorBackendSetting::Vim,
298            EditorBackendSetting::Nvim,
299            EditorBackendSetting::Plain,
300        ] {
301            section.handle_input(&key(KeyCode::Right), &tx);
302            assert_eq!(section.editor_backend, expected);
303        }
304    }
305
306    #[test]
307    fn backend_cycle_reverses_with_left() {
308        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
309        let mut section = section();
310        section.handle_input(&key(KeyCode::Down), &tx);
311        section.handle_input(&key(KeyCode::Left), &tx);
312        assert_eq!(section.editor_backend, EditorBackendSetting::Nvim);
313    }
314
315    #[test]
316    fn enter_cycles_backend_only_on_its_row() {
317        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
318        let mut section = section();
319        // On the autosave row Enter is not consumed and changes nothing.
320        let state = section.handle_input(&key(KeyCode::Enter), &tx);
321        assert_eq!(state, EventState::NotConsumed);
322        assert_eq!(section.editor_backend, EditorBackendSetting::Plain);
323        // On the backend row it cycles.
324        section.handle_input(&key(KeyCode::Down), &tx);
325        section.handle_input(&key(KeyCode::Enter), &tx);
326        assert_eq!(section.editor_backend, EditorBackendSetting::Vim);
327    }
328
329    #[test]
330    fn row_navigation_wraps() {
331        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
332        let mut section = section();
333        section.handle_input(&key(KeyCode::Up), &tx); // wraps to backend row
334        section.handle_input(&key(KeyCode::Char(' ')), &tx);
335        assert_eq!(section.editor_backend, EditorBackendSetting::Vim);
336    }
337}