kimun_notes/components/preferences/
editor_section.rs1use 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), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), 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 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 let state = section.handle_input(&key(KeyCode::Enter), &tx);
276 assert_eq!(state, EventState::NotConsumed);
277 assert_eq!(section.editor_backend, EditorBackendSetting::Textarea);
278 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); section.handle_input(&key(KeyCode::Char(' ')), &tx);
290 assert_eq!(section.editor_backend, EditorBackendSetting::Vim);
291 }
292}