Skip to main content

kimun_notes/components/text_editor/
nvim_host.rs

1//! Host-side glue for the Neovim backend.
2//!
3//! The backend (`NvimBackend`) owns the nvim process and its snapshot. This
4//! module owns the *host policy* that sits between that backend and the app:
5//! the `ZZ`/`ZQ` and `:wq`/`:q` quit intercepts, and the per-frame
6//! quit-command policy. The `content_gen` → revision derivation lives in
7//! `snapshot_from_backend`; the editor adopts the snapshot's value.
8//!
9//! As with the [decode seam](super::nvim_decode), the fragile part is pulled
10//! out as a pure decision ([`classify_nvim_key`]) that is fully testable with
11//! no nvim process: given the pending-Z state, the key, the mode and the
12//! command line, it returns *what to do*. [`NvimHost`] is the thin stateful
13//! shell that applies the decision — forwarding to nvim and emitting app events.
14
15use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
16
17use super::backend::NvimBackend;
18use super::snapshot::EditorMode;
19use crate::components::events::{AppEvent, AppTx};
20
21/// Logical (row, char-col) selection span, as carried on the snapshot.
22type Selection = ((usize, usize), (usize, usize));
23
24/// Where a quit came from. Both side effects the host performs — whether to
25/// autosave, and whether to `<Esc>` nvim out of its current mode first — are
26/// *derived* from this, so a caller never has to coordinate two booleans.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum QuitKind {
29    /// `ZZ` from Normal mode: write + quit. No `<Esc>` (nvim never left Normal —
30    /// the first `Z` was buffered, not forwarded).
31    WriteQuit,
32    /// `ZQ` from Normal mode: quit without saving. No `<Esc>`.
33    DiscardQuit,
34    /// A `:`-command quit (`:wq`, `:q`, …). Nvim is in command-line mode, so it
35    /// must be `<Esc>`-ed out first. `save` is whether the command writes.
36    Command { save: bool },
37}
38
39impl QuitKind {
40    /// Whether to autosave before leaving the editor.
41    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    /// Whether nvim must be `<Esc>`-ed out of its current mode before quitting.
50    pub fn needs_escape(self) -> bool {
51        matches!(self, QuitKind::Command { .. })
52    }
53}
54
55/// What a single key should do on the Nvim backend. Pure data — no I/O.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum NvimKeyDecision {
58    /// First `Z` of a possible `ZZ`/`ZQ` in Normal mode: swallow and wait.
59    BufferZ,
60    /// A quit/write-quit. The side effects are derived from the [`QuitKind`].
61    Quit(QuitKind),
62    /// A buffered `Z` was not followed by `Z`/`Q`: replay the `Z`, then
63    /// forward the current key.
64    ReplayZThenForward,
65    /// Nothing special — forward the key to nvim.
66    Forward,
67}
68
69/// Decide what a key does on the Nvim backend. Pure: depends only on the
70/// pending-Z flag, the key, the current mode, and the command line.
71pub fn classify_nvim_key(
72    pending_z: bool,
73    key: &KeyEvent,
74    mode: &EditorMode,
75    cmdline: Option<&str>,
76) -> NvimKeyDecision {
77    // Second key after a buffered `Z`.
78    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    // First `Z` in Normal mode — buffer it.
87    if key.code == KeyCode::Char('Z') && *mode == EditorMode::Normal {
88        return NvimKeyDecision::BufferZ;
89    }
90
91    // `<CR>` while in command-line mode: intercept quit/write-quit so they
92    // don't kill the embedded nvim process. Match the leading command *word*
93    // so `:w report.md`, `:wq | echo`, `: wq` and trailing whitespace are all
94    // recognised. The app has no save-as, so any write/quit verb — with or
95    // without arguments — means "save and leave"; the arguments are ignored.
96    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
112/// Whether [`classify_nvim_key`] consults `mode`/`cmdline` for this input. When
113/// `false`, the caller may skip locking the snapshot entirely: the pending-Z
114/// branch decides on `key.code` alone, and any non-`Z`/non-`Enter` key in the
115/// non-pending case short-circuits to `Forward` before `mode` is read.
116fn needs_snapshot(pending_z: bool, key: &KeyEvent) -> bool {
117    !pending_z && matches!(key.code, KeyCode::Char('Z') | KeyCode::Enter)
118}
119
120/// Host-side Nvim state: the only thing the host must track itself is the
121/// pending-`Z` flag for the `ZZ`/`ZQ` two-key sequence.
122#[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    /// Apply one key to the Nvim backend: classify, update the pending-Z flag,
133    /// then forward / emit as the decision dictates.
134    ///
135    /// The snapshot Mutex (shared with the reverse-refresh task) is locked only
136    /// when the decision actually consults mode/cmdline — see [`needs_snapshot`].
137    /// Ordinary keystrokes (insert-mode typing, the pending-Z second key) take
138    /// the lock-free path: no lock, no clone.
139    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 ignores mode/cmdline on this path (that is exactly what
145            // `needs_snapshot` returning false means), so the placeholders are
146            // never read.
147            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                    // Leave command-line mode so the intercept doesn't strand
156                    // nvim mid-command.
157                    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    /// Per-frame sync: resize nvim to the editor area, then read the
178    /// snapshot's active visual selection.
179    ///
180    /// The revision is deliberately NOT read here. `content_gen` is owned
181    /// by the reverse-refresh task in `backend.rs`, which bumps it *only*
182    /// when `snap.lines` actually diffs; the frame snapshot
183    /// (`snapshot_from_backend`) derives the editor's revision from it
184    /// under a single lock, and the editor's `Revisions` adopts that
185    /// value. Navigation keystrokes don't change `lines`, so they don't
186    /// bump `content_gen` — an in-flight autosave's revision token stays
187    /// valid across cursor movement.
188    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        // Characterises current behaviour: `:w` is in the saves set, and the
272        // quit set is a superset of saves, so `:w<CR>` saves *and* leaves the
273        // editor. (Not changed by this refactor.)
274        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        // `:w report.md` — leading verb `w` is matched, the argument ignored.
283        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        // Ordinary keys: lock-free path.
330        assert!(!needs_snapshot(false, &key('a')));
331        assert!(!needs_snapshot(false, &key('Q')));
332        // Pending-Z second key never needs the snapshot.
333        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}