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