kimun_notes/components/text_editor/
nvim_host.rs1use std::num::NonZeroU64;
15
16use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
17
18use super::backend::NvimBackend;
19use super::snapshot::EditorMode;
20use crate::components::events::{AppEvent, AppTx};
21
22type Selection = ((usize, usize), (usize, usize));
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum QuitKind {
30 WriteQuit,
33 DiscardQuit,
35 Command { save: bool },
38}
39
40impl QuitKind {
41 pub fn saves(self) -> bool {
43 match self {
44 QuitKind::WriteQuit => true,
45 QuitKind::DiscardQuit => false,
46 QuitKind::Command { save } => save,
47 }
48 }
49
50 pub fn needs_escape(self) -> bool {
52 matches!(self, QuitKind::Command { .. })
53 }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum NvimKeyDecision {
59 BufferZ,
61 Quit(QuitKind),
63 ReplayZThenForward,
66 Forward,
68}
69
70pub fn classify_nvim_key(
73 pending_z: bool,
74 key: &KeyEvent,
75 mode: &EditorMode,
76 cmdline: Option<&str>,
77) -> NvimKeyDecision {
78 if pending_z {
80 return match key.code {
81 KeyCode::Char('Z') => NvimKeyDecision::Quit(QuitKind::WriteQuit),
82 KeyCode::Char('Q') => NvimKeyDecision::Quit(QuitKind::DiscardQuit),
83 _ => NvimKeyDecision::ReplayZThenForward,
84 };
85 }
86
87 if key.code == KeyCode::Char('Z') && *mode == EditorMode::Normal {
89 return NvimKeyDecision::BufferZ;
90 }
91
92 if key.code == KeyCode::Enter && *mode == EditorMode::Command {
98 let cmd = cmdline.unwrap_or("").trim_start_matches(':').trim();
99 let word = cmd.split([' ', '\t', '|']).next().unwrap_or("");
100 let saves = matches!(
101 word,
102 "w" | "wq" | "wq!" | "wqa" | "wqa!" | "x" | "xa" | "x!"
103 );
104 let quits = saves || matches!(word, "q" | "q!" | "qa" | "qa!" | "cq" | "cq!");
105 if quits {
106 return NvimKeyDecision::Quit(QuitKind::Command { save: saves });
107 }
108 }
109
110 NvimKeyDecision::Forward
111}
112
113fn needs_snapshot(pending_z: bool, key: &KeyEvent) -> bool {
118 !pending_z && matches!(key.code, KeyCode::Char('Z') | KeyCode::Enter)
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum NvimKeyResult {
125 Consumed,
127 Forwarded,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub struct FrameSync {
134 pub rev: Option<NonZeroU64>,
137 pub selection: Option<Selection>,
139}
140
141#[derive(Debug, Default)]
144pub struct NvimHost {
145 pending_z: bool,
146}
147
148impl NvimHost {
149 pub fn new() -> Self {
150 Self::default()
151 }
152
153 pub fn handle_key(&mut self, nvim: &NvimBackend, key: &KeyEvent, tx: &AppTx) -> NvimKeyResult {
161 let decision = if needs_snapshot(self.pending_z, key) {
162 let snap = nvim.snapshot();
163 classify_nvim_key(self.pending_z, key, &snap.mode, snap.cmdline.as_deref())
164 } else {
165 classify_nvim_key(self.pending_z, key, &EditorMode::Normal, None)
169 };
170 self.pending_z = matches!(decision, NvimKeyDecision::BufferZ);
171
172 match decision {
173 NvimKeyDecision::BufferZ => NvimKeyResult::Consumed,
174 NvimKeyDecision::Quit(kind) => {
175 if kind.needs_escape() {
176 nvim.handle_key(&KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), tx.clone());
179 }
180 if kind.saves() {
181 tx.send(AppEvent::Autosave).ok();
182 }
183 tx.send(AppEvent::FocusSidebar).ok();
184 NvimKeyResult::Consumed
185 }
186 NvimKeyDecision::ReplayZThenForward => {
187 nvim.handle_key(
188 &KeyEvent::new(KeyCode::Char('Z'), KeyModifiers::NONE),
189 tx.clone(),
190 );
191 nvim.handle_key(key, tx.clone());
192 NvimKeyResult::Forwarded
193 }
194 NvimKeyDecision::Forward => {
195 nvim.handle_key(key, tx.clone());
196 NvimKeyResult::Forwarded
197 }
198 }
199 }
200
201 pub fn frame_sync(&self, nvim: &NvimBackend, width: u16, height: u16) -> FrameSync {
215 nvim.maybe_resize(width, height);
216 let snap = nvim.snapshot();
217 let selection = snap.visual_selection;
218 let content_gen = snap.content_gen;
219 drop(snap);
220 FrameSync {
221 rev: NonZeroU64::new(content_gen.saturating_add(1)),
222 selection,
223 }
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230
231 fn key(c: char) -> KeyEvent {
232 KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
233 }
234 fn enter() -> KeyEvent {
235 KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
236 }
237
238 #[test]
239 fn pending_z_then_z_is_write_quit_no_esc() {
240 assert_eq!(
241 classify_nvim_key(true, &key('Z'), &EditorMode::Normal, None),
242 NvimKeyDecision::Quit(QuitKind::WriteQuit)
243 );
244 }
245
246 #[test]
247 fn pending_z_then_q_is_quit_no_save() {
248 assert_eq!(
249 classify_nvim_key(true, &key('Q'), &EditorMode::Normal, None),
250 NvimKeyDecision::Quit(QuitKind::DiscardQuit)
251 );
252 }
253
254 #[test]
255 fn pending_z_then_other_replays() {
256 assert_eq!(
257 classify_nvim_key(true, &key('x'), &EditorMode::Normal, None),
258 NvimKeyDecision::ReplayZThenForward
259 );
260 }
261
262 #[test]
263 fn z_in_normal_buffers() {
264 assert_eq!(
265 classify_nvim_key(false, &key('Z'), &EditorMode::Normal, None),
266 NvimKeyDecision::BufferZ
267 );
268 }
269
270 #[test]
271 fn z_in_insert_forwards() {
272 assert_eq!(
273 classify_nvim_key(false, &key('Z'), &EditorMode::Insert, None),
274 NvimKeyDecision::Forward
275 );
276 }
277
278 #[test]
279 fn command_wq_saves_and_quits_with_esc() {
280 assert_eq!(
281 classify_nvim_key(false, &enter(), &EditorMode::Command, Some(":wq")),
282 NvimKeyDecision::Quit(QuitKind::Command { save: true })
283 );
284 }
285
286 #[test]
287 fn command_q_quits_no_save_with_esc() {
288 assert_eq!(
289 classify_nvim_key(false, &enter(), &EditorMode::Command, Some(":q")),
290 NvimKeyDecision::Quit(QuitKind::Command { save: false })
291 );
292 }
293
294 #[test]
295 fn command_q_bang_quits() {
296 assert_eq!(
297 classify_nvim_key(false, &enter(), &EditorMode::Command, Some(":q!")),
298 NvimKeyDecision::Quit(QuitKind::Command { save: false })
299 );
300 }
301
302 #[test]
303 fn command_bare_w_saves_and_quits() {
304 assert_eq!(
308 classify_nvim_key(false, &enter(), &EditorMode::Command, Some(":w")),
309 NvimKeyDecision::Quit(QuitKind::Command { save: true })
310 );
311 }
312
313 #[test]
314 fn command_write_with_filename_saves_and_quits() {
315 assert_eq!(
317 classify_nvim_key(false, &enter(), &EditorMode::Command, Some(":w report.md")),
318 NvimKeyDecision::Quit(QuitKind::Command { save: true })
319 );
320 }
321
322 #[test]
323 fn command_wq_with_bar_and_trailing_space() {
324 assert_eq!(
325 classify_nvim_key(false, &enter(), &EditorMode::Command, Some(":wq | echo hi")),
326 NvimKeyDecision::Quit(QuitKind::Command { save: true })
327 );
328 assert_eq!(
329 classify_nvim_key(false, &enter(), &EditorMode::Command, Some(":q ")),
330 NvimKeyDecision::Quit(QuitKind::Command { save: false })
331 );
332 }
333
334 #[test]
335 fn command_space_after_colon() {
336 assert_eq!(
337 classify_nvim_key(false, &enter(), &EditorMode::Command, Some(": wq")),
338 NvimKeyDecision::Quit(QuitKind::Command { save: true })
339 );
340 }
341
342 #[test]
343 fn command_unknown_forwards() {
344 assert_eq!(
345 classify_nvim_key(false, &enter(), &EditorMode::Command, Some(":noh")),
346 NvimKeyDecision::Forward
347 );
348 }
349
350 #[test]
351 fn enter_in_normal_forwards() {
352 assert_eq!(
353 classify_nvim_key(false, &enter(), &EditorMode::Normal, None),
354 NvimKeyDecision::Forward
355 );
356 }
357
358 #[test]
359 fn needs_snapshot_only_for_z_and_enter_when_not_pending() {
360 assert!(needs_snapshot(false, &key('Z')));
361 assert!(needs_snapshot(false, &enter()));
362 assert!(!needs_snapshot(false, &key('a')));
364 assert!(!needs_snapshot(false, &key('Q')));
365 assert!(!needs_snapshot(true, &key('Z')));
367 assert!(!needs_snapshot(true, &enter()));
368 assert!(!needs_snapshot(true, &key('x')));
369 }
370
371 #[test]
372 fn regular_char_forwards() {
373 assert_eq!(
374 classify_nvim_key(false, &key('a'), &EditorMode::Insert, None),
375 NvimKeyDecision::Forward
376 );
377 }
378}