kimun_notes/components/text_editor/
nvim_host.rs1use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
16
17use super::backend::NvimBackend;
18use super::snapshot::EditorMode;
19use crate::components::events::{AppEvent, AppTx};
20
21type Selection = ((usize, usize), (usize, usize));
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum QuitKind {
29 WriteQuit,
32 DiscardQuit,
34 Command { save: bool },
37}
38
39impl QuitKind {
40 pub fn saves(self) -> bool {
42 match self {
43 QuitKind::WriteQuit => true,
44 QuitKind::DiscardQuit => false,
45 QuitKind::Command { save } => save,
46 }
47 }
48
49 pub fn needs_escape(self) -> bool {
51 matches!(self, QuitKind::Command { .. })
52 }
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum NvimKeyDecision {
58 BufferZ,
60 Quit(QuitKind),
62 ReplayZThenForward,
65 Forward,
67}
68
69pub fn classify_nvim_key(
72 pending_z: bool,
73 key: &KeyEvent,
74 mode: &EditorMode,
75 cmdline: Option<&str>,
76) -> NvimKeyDecision {
77 if pending_z {
79 return match key.code {
80 KeyCode::Char('Z') => NvimKeyDecision::Quit(QuitKind::WriteQuit),
81 KeyCode::Char('Q') => NvimKeyDecision::Quit(QuitKind::DiscardQuit),
82 _ => NvimKeyDecision::ReplayZThenForward,
83 };
84 }
85
86 if key.code == KeyCode::Char('Z') && *mode == EditorMode::Normal {
88 return NvimKeyDecision::BufferZ;
89 }
90
91 if key.code == KeyCode::Enter && *mode == EditorMode::Command {
97 let cmd = cmdline.unwrap_or("").trim_start_matches(':').trim();
98 let word = cmd.split([' ', '\t', '|']).next().unwrap_or("");
99 let saves = matches!(
100 word,
101 "w" | "wq" | "wq!" | "wqa" | "wqa!" | "x" | "xa" | "x!"
102 );
103 let quits = saves || matches!(word, "q" | "q!" | "qa" | "qa!" | "cq" | "cq!");
104 if quits {
105 return NvimKeyDecision::Quit(QuitKind::Command { save: saves });
106 }
107 }
108
109 NvimKeyDecision::Forward
110}
111
112fn needs_snapshot(pending_z: bool, key: &KeyEvent) -> bool {
117 !pending_z && matches!(key.code, KeyCode::Char('Z') | KeyCode::Enter)
118}
119
120#[derive(Debug, Default)]
123pub struct NvimHost {
124 pending_z: bool,
125}
126
127impl NvimHost {
128 pub fn new() -> Self {
129 Self::default()
130 }
131
132 pub fn handle_key(&mut self, nvim: &NvimBackend, key: &KeyEvent, tx: &AppTx) {
140 let decision = if needs_snapshot(self.pending_z, key) {
141 let snap = nvim.snapshot();
142 classify_nvim_key(self.pending_z, key, &snap.mode, snap.cmdline.as_deref())
143 } else {
144 classify_nvim_key(self.pending_z, key, &EditorMode::Normal, None)
148 };
149 self.pending_z = matches!(decision, NvimKeyDecision::BufferZ);
150
151 match decision {
152 NvimKeyDecision::BufferZ => {}
153 NvimKeyDecision::Quit(kind) => {
154 if kind.needs_escape() {
155 nvim.handle_key(&KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), tx.clone());
158 }
159 if kind.saves() {
160 tx.send(AppEvent::Autosave).ok();
161 }
162 tx.send(AppEvent::FocusSidebar).ok();
163 }
164 NvimKeyDecision::ReplayZThenForward => {
165 nvim.handle_key(
166 &KeyEvent::new(KeyCode::Char('Z'), KeyModifiers::NONE),
167 tx.clone(),
168 );
169 nvim.handle_key(key, tx.clone());
170 }
171 NvimKeyDecision::Forward => {
172 nvim.handle_key(key, tx.clone());
173 }
174 }
175 }
176
177 pub fn frame_sync(&self, nvim: &NvimBackend, width: u16, height: u16) -> Option<Selection> {
189 nvim.maybe_resize(width, height);
190 nvim.snapshot().visual_selection
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 fn key(c: char) -> KeyEvent {
199 KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
200 }
201 fn enter() -> KeyEvent {
202 KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
203 }
204
205 #[test]
206 fn pending_z_then_z_is_write_quit_no_esc() {
207 assert_eq!(
208 classify_nvim_key(true, &key('Z'), &EditorMode::Normal, None),
209 NvimKeyDecision::Quit(QuitKind::WriteQuit)
210 );
211 }
212
213 #[test]
214 fn pending_z_then_q_is_quit_no_save() {
215 assert_eq!(
216 classify_nvim_key(true, &key('Q'), &EditorMode::Normal, None),
217 NvimKeyDecision::Quit(QuitKind::DiscardQuit)
218 );
219 }
220
221 #[test]
222 fn pending_z_then_other_replays() {
223 assert_eq!(
224 classify_nvim_key(true, &key('x'), &EditorMode::Normal, None),
225 NvimKeyDecision::ReplayZThenForward
226 );
227 }
228
229 #[test]
230 fn z_in_normal_buffers() {
231 assert_eq!(
232 classify_nvim_key(false, &key('Z'), &EditorMode::Normal, None),
233 NvimKeyDecision::BufferZ
234 );
235 }
236
237 #[test]
238 fn z_in_insert_forwards() {
239 assert_eq!(
240 classify_nvim_key(false, &key('Z'), &EditorMode::Insert, None),
241 NvimKeyDecision::Forward
242 );
243 }
244
245 #[test]
246 fn command_wq_saves_and_quits_with_esc() {
247 assert_eq!(
248 classify_nvim_key(false, &enter(), &EditorMode::Command, Some(":wq")),
249 NvimKeyDecision::Quit(QuitKind::Command { save: true })
250 );
251 }
252
253 #[test]
254 fn command_q_quits_no_save_with_esc() {
255 assert_eq!(
256 classify_nvim_key(false, &enter(), &EditorMode::Command, Some(":q")),
257 NvimKeyDecision::Quit(QuitKind::Command { save: false })
258 );
259 }
260
261 #[test]
262 fn command_q_bang_quits() {
263 assert_eq!(
264 classify_nvim_key(false, &enter(), &EditorMode::Command, Some(":q!")),
265 NvimKeyDecision::Quit(QuitKind::Command { save: false })
266 );
267 }
268
269 #[test]
270 fn command_bare_w_saves_and_quits() {
271 assert_eq!(
275 classify_nvim_key(false, &enter(), &EditorMode::Command, Some(":w")),
276 NvimKeyDecision::Quit(QuitKind::Command { save: true })
277 );
278 }
279
280 #[test]
281 fn command_write_with_filename_saves_and_quits() {
282 assert_eq!(
284 classify_nvim_key(false, &enter(), &EditorMode::Command, Some(":w report.md")),
285 NvimKeyDecision::Quit(QuitKind::Command { save: true })
286 );
287 }
288
289 #[test]
290 fn command_wq_with_bar_and_trailing_space() {
291 assert_eq!(
292 classify_nvim_key(false, &enter(), &EditorMode::Command, Some(":wq | echo hi")),
293 NvimKeyDecision::Quit(QuitKind::Command { save: true })
294 );
295 assert_eq!(
296 classify_nvim_key(false, &enter(), &EditorMode::Command, Some(":q ")),
297 NvimKeyDecision::Quit(QuitKind::Command { save: false })
298 );
299 }
300
301 #[test]
302 fn command_space_after_colon() {
303 assert_eq!(
304 classify_nvim_key(false, &enter(), &EditorMode::Command, Some(": wq")),
305 NvimKeyDecision::Quit(QuitKind::Command { save: true })
306 );
307 }
308
309 #[test]
310 fn command_unknown_forwards() {
311 assert_eq!(
312 classify_nvim_key(false, &enter(), &EditorMode::Command, Some(":noh")),
313 NvimKeyDecision::Forward
314 );
315 }
316
317 #[test]
318 fn enter_in_normal_forwards() {
319 assert_eq!(
320 classify_nvim_key(false, &enter(), &EditorMode::Normal, None),
321 NvimKeyDecision::Forward
322 );
323 }
324
325 #[test]
326 fn needs_snapshot_only_for_z_and_enter_when_not_pending() {
327 assert!(needs_snapshot(false, &key('Z')));
328 assert!(needs_snapshot(false, &enter()));
329 assert!(!needs_snapshot(false, &key('a')));
331 assert!(!needs_snapshot(false, &key('Q')));
332 assert!(!needs_snapshot(true, &key('Z')));
334 assert!(!needs_snapshot(true, &enter()));
335 assert!(!needs_snapshot(true, &key('x')));
336 }
337
338 #[test]
339 fn regular_char_forwards() {
340 assert_eq!(
341 classify_nvim_key(false, &key('a'), &EditorMode::Insert, None),
342 NvimKeyDecision::Forward
343 );
344 }
345}