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
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 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 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), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), 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 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 let state = section.handle_input(&key(KeyCode::Enter), &tx);
321 assert_eq!(state, EventState::NotConsumed);
322 assert_eq!(section.editor_backend, EditorBackendSetting::Plain);
323 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); section.handle_input(&key(KeyCode::Char(' ')), &tx);
335 assert_eq!(section.editor_backend, EditorBackendSetting::Vim);
336 }
337}