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//! `content_gen` → `content_revision` mirror.
7//!
8//! As with the [decode seam](super::nvim_decode), the fragile part is pulled
9//! out as a pure decision ([`classify_nvim_key`]) that is fully testable with
10//! no nvim process: given the pending-Z state, the key, the mode and the
11//! command line, it returns *what to do*. [`NvimHost`] is the thin stateful
12//! shell that applies the decision — forwarding to nvim and emitting app events.
13
14use 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
22/// Logical (row, char-col) selection span, as carried on the snapshot.
23type Selection = ((usize, usize), (usize, usize));
24
25/// Where a quit came from. Both side effects the host performs — whether to
26/// autosave, and whether to `<Esc>` nvim out of its current mode first — are
27/// *derived* from this, so a caller never has to coordinate two booleans.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum QuitKind {
30    /// `ZZ` from Normal mode: write + quit. No `<Esc>` (nvim never left Normal —
31    /// the first `Z` was buffered, not forwarded).
32    WriteQuit,
33    /// `ZQ` from Normal mode: quit without saving. No `<Esc>`.
34    DiscardQuit,
35    /// A `:`-command quit (`:wq`, `:q`, …). Nvim is in command-line mode, so it
36    /// must be `<Esc>`-ed out first. `save` is whether the command writes.
37    Command { save: bool },
38}
39
40impl QuitKind {
41    /// Whether to autosave before leaving the editor.
42    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    /// Whether nvim must be `<Esc>`-ed out of its current mode before quitting.
51    pub fn needs_escape(self) -> bool {
52        matches!(self, QuitKind::Command { .. })
53    }
54}
55
56/// What a single key should do on the Nvim backend. Pure data — no I/O.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum NvimKeyDecision {
59    /// First `Z` of a possible `ZZ`/`ZQ` in Normal mode: swallow and wait.
60    BufferZ,
61    /// A quit/write-quit. The side effects are derived from the [`QuitKind`].
62    Quit(QuitKind),
63    /// A buffered `Z` was not followed by `Z`/`Q`: replay the `Z`, then
64    /// forward the current key.
65    ReplayZThenForward,
66    /// Nothing special — forward the key to nvim.
67    Forward,
68}
69
70/// Decide what a key does on the Nvim backend. Pure: depends only on the
71/// pending-Z flag, the key, the current mode, and the command line.
72pub fn classify_nvim_key(
73    pending_z: bool,
74    key: &KeyEvent,
75    mode: &EditorMode,
76    cmdline: Option<&str>,
77) -> NvimKeyDecision {
78    // Second key after a buffered `Z`.
79    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    // First `Z` in Normal mode — buffer it.
88    if key.code == KeyCode::Char('Z') && *mode == EditorMode::Normal {
89        return NvimKeyDecision::BufferZ;
90    }
91
92    // `<CR>` while in command-line mode: intercept quit/write-quit so they
93    // don't kill the embedded nvim process. Match the leading command *word*
94    // so `:w report.md`, `:wq | echo`, `: wq` and trailing whitespace are all
95    // recognised. The app has no save-as, so any write/quit verb — with or
96    // without arguments — means "save and leave"; the arguments are ignored.
97    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
113/// Whether [`classify_nvim_key`] consults `mode`/`cmdline` for this input. When
114/// `false`, the caller may skip locking the snapshot entirely: the pending-Z
115/// branch decides on `key.code` alone, and any non-`Z`/non-`Enter` key in the
116/// non-pending case short-circuits to `Forward` before `mode` is read.
117fn needs_snapshot(pending_z: bool, key: &KeyEvent) -> bool {
118    !pending_z && matches!(key.code, KeyCode::Char('Z') | KeyCode::Enter)
119}
120
121/// Outcome of applying a key on the Nvim backend. The host bumps the cursor
122/// generation only when a key was actually forwarded to nvim.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum NvimKeyResult {
125    /// Key was handled without forwarding (buffered Z, or a quit intercept).
126    Consumed,
127    /// Key (and possibly a replayed `Z`) was forwarded to nvim.
128    Forwarded,
129}
130
131/// Values the render loop needs from the Nvim backend each frame.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub struct FrameSync {
134    /// `content_revision` to mirror, if the refresh task saw a content change.
135    /// `None` leaves the host's revision untouched.
136    pub rev: Option<NonZeroU64>,
137    /// Active visual selection to render.
138    pub selection: Option<Selection>,
139}
140
141/// Host-side Nvim state: the only thing the host must track itself is the
142/// pending-`Z` flag for the `ZZ`/`ZQ` two-key sequence.
143#[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    /// Apply one key to the Nvim backend: classify, update the pending-Z flag,
154    /// then forward / emit as the decision dictates.
155    ///
156    /// The snapshot Mutex (shared with the reverse-refresh task) is locked only
157    /// when the decision actually consults mode/cmdline — see [`needs_snapshot`].
158    /// Ordinary keystrokes (insert-mode typing, the pending-Z second key) take
159    /// the lock-free path: no lock, no clone.
160    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 ignores mode/cmdline on this path (that is exactly what
166            // `needs_snapshot` returning false means), so the placeholders are
167            // never read.
168            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                    // Leave command-line mode so the intercept doesn't strand
177                    // nvim mid-command.
178                    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    /// Per-frame sync: resize nvim to the editor area, then read the snapshot's
202    /// `content_gen` and active visual selection.
203    ///
204    /// Canonical explanation of the revision mirror (referenced from the host's
205    /// key handler): the editor tracks two independent counters. `edit_generation`
206    /// (bumped by every forwarded key) invalidates the view cache; `content_gen`
207    /// is owned by the reverse-refresh task in `backend.rs`, which bumps it *only*
208    /// when `snap.lines` actually diffs. We mirror `content_gen` into the host's
209    /// `content_revision` here. Because navigation keystrokes don't change `lines`,
210    /// they don't bump `content_gen`, so an in-flight autosave's revision token
211    /// stays valid across cursor movement. `NonZeroU64::new(gen + 1)` maps the
212    /// initial `gen == 0` to "no change yet" (`None` leaves the host's revision
213    /// untouched).
214    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        // Characterises current behaviour: `:w` is in the saves set, and the
305        // quit set is a superset of saves, so `:w<CR>` saves *and* leaves the
306        // editor. (Not changed by this refactor.)
307        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        // `:w report.md` — leading verb `w` is matched, the argument ignored.
316        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        // Ordinary keys: lock-free path.
363        assert!(!needs_snapshot(false, &key('a')));
364        assert!(!needs_snapshot(false, &key('Q')));
365        // Pending-Z second key never needs the snapshot.
366        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}