Skip to main content

kimun_notes/components/text_editor/
backend.rs

1use std::path::PathBuf;
2use std::sync::atomic::{AtomicBool, Ordering};
3use std::sync::{Arc, Mutex};
4use std::time::Duration;
5
6use tokio::process::ChildStdin;
7use tokio_util::compat::Compat;
8
9use super::rope_buffer::RopeBuffer;
10use nvim_rs::{Handler, Neovim, UiAttachOptions, create::tokio::new_child_cmd, error::LoopError};
11
12use super::nvim_decode::{DecodedState, decode};
13use super::nvim_rpc::key_event_to_nvim_string;
14use super::snapshot::{EditorMode, NvimSnapshot};
15use super::vim::VimEngine;
16use crate::components::events::{AppEvent, AppTx};
17use crate::settings::EditorBackendSetting;
18
19type NvimWriter = Compat<ChildStdin>;
20type NvimClient = Neovim<NvimWriter>;
21
22// ---------------------------------------------------------------------------
23// Lua snippet: fetch all editor state in one round-trip.
24//
25// Command mode  → [mode, cmdtype, cmdline]
26// Other modes   → [mode, lines, cursor, vpos]
27// ---------------------------------------------------------------------------
28const STATE_QUERY_LUA: &str = r#"
29local m = vim.api.nvim_get_mode().mode
30if m == 'c' then
31  return {m, vim.fn.getcmdtype(), vim.fn.getcmdline()}
32else
33  local lines  = vim.api.nvim_buf_get_lines(0, 0, -1, false)
34  local cursor = vim.api.nvim_win_get_cursor(0)
35  local vpos   = vim.fn.getpos('v')
36  return {m, lines, cursor, vpos}
37end
38"#;
39
40// ---------------------------------------------------------------------------
41// Handler — increments flush_tx counter on every "flush" redraw event.
42// ---------------------------------------------------------------------------
43
44#[derive(Clone)]
45struct NvimHandler {
46    flush_tx: tokio::sync::watch::Sender<u64>,
47}
48
49#[async_trait::async_trait]
50impl Handler for NvimHandler {
51    type Writer = NvimWriter;
52
53    async fn handle_notify(&self, name: String, args: Vec<nvim_rs::Value>, _neovim: NvimClient) {
54        if name != "redraw" {
55            return;
56        }
57        for arg in &args {
58            if let Some(events) = arg.as_array() {
59                for event in events {
60                    if let Some(ea) = event.as_array()
61                        && ea.first().and_then(|v| v.as_str()) == Some("flush")
62                    {
63                        self.flush_tx.send_modify(|v| *v = v.wrapping_add(1));
64                        return;
65                    }
66                }
67            }
68        }
69    }
70}
71
72// ---------------------------------------------------------------------------
73// InputInterpreter + TextareaBackend
74// ---------------------------------------------------------------------------
75
76/// How key events are translated into edits on the **rope buffer**.
77/// The engine is boxed so the `Direct` arm doesn't pay the engine's size
78/// (registers, dot-repeat state, replace stack — ~230 bytes).
79#[derive(Debug, Default)]
80pub enum InputInterpreter {
81    /// Today's behavior: keys go straight to the textarea.
82    #[default]
83    Direct,
84    /// Built-in vim emulation.
85    Vim(Box<VimEngine>),
86}
87
88/// The in-process textarea storage plus its input interpreter.
89#[derive(Debug)]
90pub struct TextareaBackend {
91    /// Which keystrokes are sharing an undo group. The **plain** backend's
92    /// policy; the **vim** engine has its own and leaves this alone.
93    pub typing: super::typing_run::TypingRun,
94    /// The open note's text and its edit history. Mutations go
95    /// through `RopeBuffer::edit`.
96    pub ta: RopeBuffer,
97    pub input: InputInterpreter,
98}
99
100impl TextareaBackend {
101    pub fn direct(text: crate::ropetext::Text) -> Self {
102        Self {
103            ta: RopeBuffer::new(text),
104            typing: super::typing_run::TypingRun::default(),
105            input: InputInterpreter::Direct,
106        }
107    }
108    pub fn vim(text: crate::ropetext::Text) -> Self {
109        Self {
110            ta: RopeBuffer::new(text),
111            typing: super::typing_run::TypingRun::default(),
112            input: InputInterpreter::Vim(Box::default()),
113        }
114    }
115}
116
117// ---------------------------------------------------------------------------
118// BackendState
119// ---------------------------------------------------------------------------
120
121#[allow(clippy::large_enum_variant)]
122pub enum BackendState {
123    Textarea(TextareaBackend),
124    Nvim(NvimBackend),
125}
126
127impl BackendState {
128    /// Whether the textarea backend is active — the named form of the
129    /// structural guard, for sites that only need the yes/no.
130    pub fn is_textarea(&self) -> bool {
131        matches!(self, BackendState::Textarea(_))
132    }
133
134    /// True when the active backend is the built-in vim interpreter (any mode).
135    pub fn is_vim(&self) -> bool {
136        matches!(
137            self,
138            BackendState::Textarea(TextareaBackend {
139                input: InputInterpreter::Vim(_),
140                ..
141            })
142        )
143    }
144
145    /// The textarea, when it is the active backend. Textarea-only features
146    /// (autocomplete, smart edits, mouse selection) guard on this.
147    pub fn as_textarea(&self) -> Option<&RopeBuffer> {
148        match self {
149            BackendState::Textarea(tb) => Some(&tb.ta),
150            BackendState::Nvim(_) => None,
151        }
152    }
153
154    /// The buffer and its typing run together, for the key path that needs both.
155    pub fn as_textarea_parts_mut(
156        &mut self,
157    ) -> Option<(&mut RopeBuffer, &mut super::typing_run::TypingRun)> {
158        match self {
159            BackendState::Textarea(tb) => Some((&mut tb.ta, &mut tb.typing)),
160            BackendState::Nvim(_) => None,
161        }
162    }
163
164    pub fn as_textarea_mut(&mut self) -> Option<&mut RopeBuffer> {
165        match self {
166            BackendState::Textarea(tb) => Some(&mut tb.ta),
167            BackendState::Nvim(_) => None,
168        }
169    }
170
171    /// The nvim backend, when it is the active one.
172    pub fn as_nvim(&self) -> Option<&NvimBackend> {
173        match self {
174            BackendState::Textarea(_) => None,
175            BackendState::Nvim(nvim) => Some(nvim),
176        }
177    }
178
179    /// The whole buffer as one string, whichever backend holds it.
180    pub fn text(&self) -> String {
181        match self {
182            BackendState::Textarea(tb) => tb.ta.text().to_string(),
183            BackendState::Nvim(nvim) => nvim.snapshot().lines.join("\n"),
184        }
185    }
186
187    /// The cursor's (row, col), cheap on both backends — no line cloning.
188    /// The nvim row is clamped to the mirrored line count (the mirror can
189    /// lag the real cursor for a frame), matching the snapshot path.
190    pub fn cursor(&self) -> (usize, usize) {
191        match self {
192            BackendState::Textarea(tb) => tb.ta.cursor(),
193            BackendState::Nvim(nvim) => {
194                let snap = nvim.snapshot();
195                let max_row = snap.lines.len().saturating_sub(1);
196                (snap.cursor.0.min(max_row), snap.cursor.1)
197            }
198        }
199    }
200
201    /// If the nvim backend's process has died, replace it with a textarea
202    /// holding the last mirrored buffer, and report that it happened so the
203    /// host can re-arm textarea-only features.
204    pub fn recover_from_dead_nvim(&mut self) -> bool {
205        let fallback_text = match self.as_nvim() {
206            Some(nvim) if nvim.is_dead() => nvim.snapshot().lines.join("\n"),
207            _ => return false,
208        };
209        tracing::warn!("nvim process died; falling back to textarea backend");
210        *self = BackendState::Textarea(TextareaBackend::direct(crate::ropetext::Text::from(
211            fallback_text.as_str(),
212        )));
213        true
214    }
215
216    /// Reconcile the active input interpreter with a host-driven mouse
217    /// selection change. The vim interpreter tracks it modally (a new
218    /// selection enters Visual, a cleared one returns to Normal); the other
219    /// backends have nothing to reconcile.
220    pub fn sync_mouse_selection(&mut self, has_selection: bool) {
221        if let BackendState::Textarea(TextareaBackend {
222            input: InputInterpreter::Vim(e),
223            ..
224        }) = self
225        {
226            e.sync_mouse_selection(has_selection);
227        }
228    }
229
230    /// True when a bare Space should start the leader sequence. Only the vim
231    /// interpreter ever says yes (Normal mode, empty pending state); for every
232    /// other backend Space is just typing.
233    pub fn space_leads(&self) -> bool {
234        matches!(self,
235            BackendState::Textarea(TextareaBackend { input: InputInterpreter::Vim(e), .. })
236            if e.space_leads())
237    }
238
239    /// True when the current selection visually includes the char under the
240    /// cursor, so the highlight path extends the end col by one. Only the vim
241    /// interpreter's charwise Visual mode (not VisualLine) selects this way.
242    pub fn selection_includes_cursor(&self) -> bool {
243        matches!(self,
244            BackendState::Textarea(TextareaBackend { input: InputInterpreter::Vim(e), .. })
245            if *e.mode() == EditorMode::Visual)
246    }
247
248    /// True when the vim interpreter is in linewise Visual (`V`). The
249    /// highlight there must always run the full width of every selected
250    /// row, regardless of the cursor's column.
251    pub fn is_visual_line(&self) -> bool {
252        matches!(self,
253            BackendState::Textarea(TextareaBackend { input: InputInterpreter::Vim(e), .. })
254            if *e.mode() == EditorMode::VisualLine)
255    }
256
257    /// Reset any transient input-interpreter state for a freshly loaded note
258    /// (the vim interpreter returns to Normal; the other backends carry no
259    /// such state).
260    pub fn reset_input_state(&mut self) {
261        if let BackendState::Textarea(TextareaBackend {
262            input: InputInterpreter::Vim(engine),
263            ..
264        }) = self
265        {
266            engine.reset_to_normal();
267        }
268    }
269
270    /// If the active backend is the vim interpreter, run it for this key and
271    /// return the outcome. Returns `None` for Direct / Nvim backends.
272    pub fn vim_handle_key(
273        &mut self,
274        key: &ratatui::crossterm::event::KeyEvent,
275    ) -> Option<super::vim::VimKeyOutcome> {
276        match self {
277            BackendState::Textarea(TextareaBackend {
278                ta,
279                input: InputInterpreter::Vim(engine),
280                ..
281            }) => Some(engine.handle_key(key, ta)),
282            _ => None,
283        }
284    }
285
286    /// The in-progress input-command hint for the footer (the vim
287    /// interpreter's pending count/operator/find/g sequence). `None` when the
288    /// active backend has no pending sequence.
289    pub fn pending_input_hint(&self) -> Option<String> {
290        match self {
291            BackendState::Textarea(TextareaBackend {
292                input: InputInterpreter::Vim(e),
293                ..
294            }) => e.pending_hint(),
295            _ => None,
296        }
297    }
298
299    /// The footer modal-mode label, when the backend has one (nvim, or the
300    /// vim interpreter). `None` for the plain Direct textarea.
301    pub fn mode_label(&self) -> Option<String> {
302        match self {
303            BackendState::Textarea(TextareaBackend {
304                input: InputInterpreter::Vim(engine),
305                ..
306            }) => Some(engine.mode_label()),
307            BackendState::Textarea(_) => None,
308            BackendState::Nvim(nvim) => Some(nvim.snapshot().footer_label()),
309        }
310    }
311
312    /// Alloc-free cursor-shape classifier for the render path.
313    /// `None` = non-modal backend (Direct textarea — leave terminal cursor as-is).
314    /// `Some(true)` = Insert mode (bar cursor).
315    /// `Some(false)` = other modal mode (block cursor).
316    pub fn modal_is_insert(&self) -> Option<bool> {
317        match self {
318            BackendState::Textarea(TextareaBackend {
319                input: InputInterpreter::Vim(e),
320                ..
321            }) => Some(*e.mode() == EditorMode::Insert),
322            BackendState::Textarea(_) => None,
323            BackendState::Nvim(nvim) => Some(nvim.snapshot().mode == EditorMode::Insert),
324        }
325    }
326
327    pub fn from_settings(
328        editor_backend: &EditorBackendSetting,
329        nvim_path: Option<&PathBuf>,
330    ) -> Self {
331        if matches!(editor_backend, EditorBackendSetting::Nvim) {
332            match NvimBackend::new(nvim_path) {
333                Ok(backend) => return BackendState::Nvim(backend),
334                Err(e) => {
335                    tracing::warn!("nvim backend unavailable, falling back to textarea: {e}")
336                }
337            }
338        }
339        let tb = match editor_backend {
340            EditorBackendSetting::Vim => TextareaBackend::vim(crate::ropetext::Text::new()),
341            // Nvim is handled by the early return above; Textarea and any
342            // future non-modal setting use the direct interpreter.
343            EditorBackendSetting::Plain | EditorBackendSetting::Nvim => {
344                TextareaBackend::direct(crate::ropetext::Text::new())
345            }
346        };
347        BackendState::Textarea(tb)
348    }
349}
350
351// ---------------------------------------------------------------------------
352// NvimBackend
353// ---------------------------------------------------------------------------
354
355pub struct NvimBackend {
356    nvim: NvimClient,
357    snapshot: Arc<Mutex<NvimSnapshot>>,
358    is_dead: Arc<AtomicBool>,
359    /// Set while a `buf_set_lines` call spawned by `set_text` is in flight.
360    /// The refresh task skips line/dirty updates while this is `true` to avoid
361    /// overwriting the pre-populated snapshot with stale nvim state.
362    set_text_in_flight: Arc<AtomicBool>,
363    /// Incremented by the handler on every flush event.
364    flush_rx: tokio::sync::watch::Receiver<u64>,
365    /// Incremented by handle_key after each successful nvim_input call.
366    /// Gives the refresh task a wakeup path even when nvim doesn't send flush.
367    key_tx: tokio::sync::watch::Sender<u64>,
368    /// Stored until the refresh task is started on the first handle_key call.
369    pending_key_rx: Mutex<Option<tokio::sync::watch::Receiver<u64>>>,
370    /// Tracks the last size passed to `ui_attach`/`ui_try_resize` so we only
371    /// send a resize RPC when the terminal rect actually changes.
372    last_ui_size: Mutex<(u16, u16)>,
373    io_handle: tokio::task::JoinHandle<Result<(), Box<LoopError>>>,
374    child: Option<tokio::process::Child>,
375}
376
377impl Drop for NvimBackend {
378    fn drop(&mut self) {
379        // Abort the IO loop first so it stops sending on flush_tx,
380        // which lets the refresh task's flush_rx.changed() return Err and exit.
381        self.io_handle.abort();
382        if let Some(ref mut child) = self.child {
383            let _ = child.start_kill();
384        }
385    }
386}
387
388impl NvimBackend {
389    /// Locked view of the mirrored nvim state (cursor, lines, mode, dirty…).
390    /// Poison-recovering: a panicked refresh task never wedges the UI.
391    pub fn snapshot(&self) -> std::sync::MutexGuard<'_, NvimSnapshot> {
392        self.snapshot.lock().unwrap_or_else(|p| p.into_inner())
393    }
394
395    /// Whether the nvim process / IO loop has died (the host falls back to
396    /// the textarea backend when it has).
397    pub fn is_dead(&self) -> bool {
398        self.is_dead.load(std::sync::atomic::Ordering::SeqCst)
399    }
400
401    /// Clear the mirrored dirty flag — the buffer was just persisted.
402    pub fn mark_clean(&self) {
403        self.snapshot().dirty = false;
404    }
405
406    pub fn new(nvim_path: Option<&PathBuf>) -> Result<Self, String> {
407        tokio::task::block_in_place(|| {
408            tokio::runtime::Handle::current().block_on(Self::new_async(nvim_path))
409        })
410    }
411
412    async fn new_async(nvim_path: Option<&PathBuf>) -> Result<Self, String> {
413        let binary = nvim_path
414            .map(|p| p.to_string_lossy().into_owned())
415            .unwrap_or_else(|| "nvim".to_string());
416
417        let (flush_tx, flush_rx) = tokio::sync::watch::channel(0u64);
418        let (key_tx, key_rx) = tokio::sync::watch::channel(0u64);
419        let handler = NvimHandler { flush_tx };
420
421        let mut cmd = tokio::process::Command::new(&binary);
422        cmd.arg("--embed").stderr(std::process::Stdio::null());
423
424        let (nvim, io_handle, child) = new_child_cmd(&mut cmd, handler)
425            .await
426            .map_err(|e| format!("failed to spawn {binary}: {e}"))?;
427
428        let mut ui_opts = UiAttachOptions::new();
429        ui_opts.set_rgb(false);
430        nvim.ui_attach(80, 24, &ui_opts)
431            .await
432            .map_err(|e| format!("nvim_ui_attach failed: {e}"))?;
433
434        let _ = nvim.command("set noswapfile").await;
435        let _ = nvim.command("set buftype=nofile").await;
436        let _ = nvim.command("set nomodeline").await;
437        let _ = nvim.command("set expandtab").await;
438        // Pin nvim's tabstop to the renderer's TAB_STOP so tab-column math and
439        // cursor placement can never desync.
440        let _ = nvim
441            .command(&format!("set tabstop={}", super::markdown::TAB_STOP))
442            .await;
443
444        Ok(Self {
445            nvim,
446            snapshot: Arc::new(Mutex::new(NvimSnapshot::default())),
447            is_dead: Arc::new(AtomicBool::new(false)),
448            set_text_in_flight: Arc::new(AtomicBool::new(false)),
449            flush_rx,
450            key_tx,
451            pending_key_rx: Mutex::new(Some(key_rx)),
452            last_ui_size: Mutex::new((80, 24)),
453            io_handle,
454            child: Some(child),
455        })
456    }
457
458    /// Start the long-running refresh task on the first call; no-op afterwards.
459    fn ensure_refresh_task(&self, tx: &AppTx) {
460        let mut guard = self
461            .pending_key_rx
462            .lock()
463            .unwrap_or_else(|p| p.into_inner());
464        let Some(key_rx) = guard.take() else { return };
465
466        let nvim = self.nvim.clone();
467        let snapshot = self.snapshot.clone();
468        let is_dead = self.is_dead.clone();
469        let in_flight = self.set_text_in_flight.clone();
470        let flush_rx = self.flush_rx.clone();
471        let tx = tx.clone();
472
473        tokio::spawn(async move {
474            let mut key_rx = key_rx;
475            let mut flush_rx = flush_rx;
476
477            loop {
478                // Wake on either:
479                //  • flush event (nvim finished processing input — best path)
480                //  • key signal  (nvim_input returned; give nvim 30 ms to flush first)
481                tokio::select! {
482                    res = flush_rx.changed() => {
483                        if res.is_err() {
484                            // Sender dropped — nvim IO loop ended.
485                            is_dead.store(true, Ordering::SeqCst);
486                            tx.send(AppEvent::Redraw).ok();
487                            break;
488                        }
489                        // Flush arrived — state is fresh, query immediately.
490                    }
491                    res = key_rx.changed() => {
492                        if res.is_err() { break; }
493                        // nvim_input returned. Wait up to 30 ms for flush before
494                        // querying; proceed regardless so nothing is ever stuck.
495                        tokio::time::timeout(
496                            Duration::from_millis(30),
497                            flush_rx.changed(),
498                        ).await.ok();
499                    }
500                }
501
502                match nvim.exec_lua(STATE_QUERY_LUA, vec![]).await {
503                    Ok(value) => {
504                        apply_lua_state(&snapshot, &in_flight, value);
505                        tx.send(AppEvent::Redraw).ok();
506                    }
507                    Err(e) => {
508                        if e.is_channel_closed() {
509                            is_dead.store(true, Ordering::SeqCst);
510                            tx.send(AppEvent::Redraw).ok();
511                            break;
512                        }
513                        // Non-fatal (e.g. transient Lua error): log and continue.
514                        tracing::debug!("exec_lua error: {e}");
515                    }
516                }
517            }
518        });
519    }
520
521    /// Load content into the nvim buffer and pre-populate the snapshot.
522    ///
523    /// Contract: the synchronous snapshot pre-populate (lines + cursor +
524    /// dirty=false + content_gen bump) happens BEFORE `in_flight` is set
525    /// and the buf_set_lines RPC is spawned. A keystroke arriving between
526    /// the synchronous return of `set_text` and the spawned task actually
527    /// reaching nvim ends up routed via `handle_key`, and the refresh task
528    /// will skip snapshot updates while `in_flight=true` (see
529    /// `apply_lua_state`). Once the spawned RPC completes and `in_flight`
530    /// flips back to false, the refresh task will observe whatever buffer
531    /// state nvim has — including both the loaded content AND any keys the
532    /// user pressed in the interim. `snap.lines != new_lines` will then
533    /// re-set `dirty=true`. The window where `dirty=false` after a
534    /// concurrent keystroke is bounded by one refresh cycle (~30 ms).
535    /// Do NOT move the `in_flight.store(true)` earlier or clear it
536    /// before the RPC actually completes — both invariants are load-bearing.
537    pub fn set_text(&self, text: &str) {
538        let lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
539
540        {
541            let mut snap = self.snapshot.lock().unwrap_or_else(|p| p.into_inner());
542            snap.lines = if lines.is_empty() {
543                vec![String::new()]
544            } else {
545                lines.clone()
546            };
547            snap.cursor = (0, 0);
548            snap.dirty = false;
549            snap.content_gen = snap.content_gen.wrapping_add(1);
550        }
551
552        let nvim = self.nvim.clone();
553        let is_dead = self.is_dead.clone();
554        let in_flight = self.set_text_in_flight.clone();
555        in_flight.store(true, Ordering::SeqCst);
556        tokio::spawn(async move {
557            let buf = match nvim.get_current_buf().await {
558                Ok(b) => b,
559                Err(e) => {
560                    in_flight.store(false, Ordering::SeqCst);
561                    if e.is_channel_closed() {
562                        is_dead.store(true, Ordering::SeqCst);
563                    }
564                    tracing::warn!("set_text get_current_buf: {e}");
565                    return;
566                }
567            };
568            if let Err(e) = buf.set_lines(0, -1, false, lines).await {
569                tracing::warn!("set_text buf_set_lines: {e}");
570            }
571            // Send the cursor home in nvim too, not just in the snapshot above.
572            // `set_lines` replaces content and leaves the window cursor where
573            // the previous note left it (clamped to the new line count), and
574            // `apply_lua_state` assigns `snap.cursor` from nvim *unguarded* —
575            // unlike `snap.lines`, which `in_flight` protects. So the local
576            // (0, 0) is silently replaced by the old position within a refresh
577            // cycle. Nothing repaints until the next event, which is why this
578            // read as "the first movement key makes the cursor jump" rather
579            // than as the stale value it always was.
580            //
581            // After `set_lines`, so line 1 is guaranteed to exist. Before
582            // clearing `in_flight`, so the buffer is never observed mid-load.
583            match nvim.get_current_win().await {
584                Ok(win) => {
585                    // nvim rows are 1-based, columns 0-based.
586                    if let Err(e) = win.set_cursor((1, 0)).await {
587                        tracing::warn!("set_text win_set_cursor: {e}");
588                    }
589                }
590                Err(e) => tracing::warn!("set_text get_current_win: {e}"),
591            }
592            in_flight.store(false, Ordering::SeqCst);
593        });
594    }
595
596    /// Notify nvim of a terminal resize, but only when the dimensions actually change.
597    pub fn maybe_resize(&self, width: u16, height: u16) {
598        let mut guard = self.last_ui_size.lock().unwrap_or_else(|p| p.into_inner());
599        if *guard == (width, height) {
600            return;
601        }
602        *guard = (width, height);
603        drop(guard);
604
605        let nvim = self.nvim.clone();
606        let is_dead = self.is_dead.clone();
607        tokio::spawn(async move {
608            if let Err(e) = nvim.ui_try_resize(width as i64, height as i64).await {
609                if e.is_channel_closed() {
610                    is_dead.store(true, Ordering::SeqCst);
611                }
612                tracing::debug!("ui_try_resize error: {e}");
613            }
614        });
615    }
616
617    /// Insert `text` at nvim's current cursor position via `nvim_paste`.
618    /// Honours nvim's current mode (insert/normal/visual) — visual replaces the
619    /// selection, normal/insert insert at cursor — so it works as a drop-in
620    /// for the textarea backend's insert/replace flow.
621    pub fn paste(&self, text: &str, tx: AppTx) {
622        self.ensure_refresh_task(&tx);
623        let nvim = self.nvim.clone();
624        let is_dead = self.is_dead.clone();
625        let key_tx = self.key_tx.clone();
626        let payload = text.to_string();
627        tokio::spawn(async move {
628            // phase = -1 → single-chunk paste (not part of a streamed sequence).
629            match nvim.paste(&payload, false, -1).await {
630                Ok(_) => {
631                    key_tx.send_modify(|v| *v = v.wrapping_add(1));
632                }
633                Err(e) => {
634                    if e.is_channel_closed() {
635                        is_dead.store(true, Ordering::SeqCst);
636                        tx.send(AppEvent::Redraw).ok();
637                    }
638                    tracing::debug!("nvim_paste error: {e}");
639                }
640            }
641        });
642    }
643
644    /// Forward a keystroke to nvim.
645    pub fn handle_key(&self, key: &ratatui::crossterm::event::KeyEvent, tx: AppTx) {
646        self.ensure_refresh_task(&tx);
647
648        let Some(nvim_key) = key_event_to_nvim_string(key) else {
649            tracing::debug!("unmappable key: {key:?}");
650            return;
651        };
652
653        let nvim = self.nvim.clone();
654        let is_dead = self.is_dead.clone();
655        let key_tx = self.key_tx.clone();
656
657        tokio::spawn(async move {
658            match nvim.input(&nvim_key).await {
659                Ok(_) => {
660                    // Signal the refresh task: a key was just sent.
661                    key_tx.send_modify(|v| *v = v.wrapping_add(1));
662                }
663                Err(e) => {
664                    if e.is_channel_closed() {
665                        is_dead.store(true, Ordering::SeqCst);
666                        tx.send(AppEvent::Redraw).ok();
667                    }
668                    tracing::debug!("nvim_input error: {e}");
669                }
670            }
671        });
672    }
673}
674
675// ---------------------------------------------------------------------------
676// Parse the Lua state bundle and apply it to the snapshot.
677// ---------------------------------------------------------------------------
678
679/// Decode the Lua state bundle (pure, in [`super::nvim_decode`]) and merge it
680/// into the live snapshot. Decoding owns the wire-format facts; this function
681/// owns the stateful bookkeeping that decoding cannot: the `in_flight` gate and
682/// the `content_gen`/`dirty` revision counters.
683fn apply_lua_state(
684    snapshot: &Arc<Mutex<NvimSnapshot>>,
685    in_flight: &Arc<AtomicBool>,
686    value: nvim_rs::Value,
687) {
688    let Some(decoded) = decode(&value) else {
689        return;
690    };
691
692    let mut snap = snapshot.lock().unwrap_or_else(|p| p.into_inner());
693
694    match decoded {
695        DecodedState::Command { cmdline } => {
696            snap.mode = EditorMode::Command;
697            snap.cmdline = Some(cmdline);
698        }
699        DecodedState::Content {
700            mode,
701            lines,
702            cursor,
703            visual_selection,
704        } => {
705            if lines != snap.lines && !in_flight.load(Ordering::SeqCst) {
706                snap.dirty = true;
707                snap.lines = lines;
708                snap.content_gen = snap.content_gen.wrapping_add(1);
709            }
710            snap.cursor = cursor;
711            snap.mode = mode;
712            snap.cmdline = None;
713            snap.visual_selection = visual_selection;
714        }
715    }
716}
717
718// ---------------------------------------------------------------------------
719// Tests
720// ---------------------------------------------------------------------------
721
722#[cfg(test)]
723mod tests {
724    use super::*;
725
726    #[test]
727    fn direct_backend_has_no_mode_label() {
728        let b = BackendState::Textarea(TextareaBackend::direct(crate::ropetext::Text::new()));
729        assert_eq!(b.mode_label(), None);
730    }
731
732    #[test]
733    fn vim_backend_reports_normal_label() {
734        let b = BackendState::Textarea(TextareaBackend::vim(crate::ropetext::Text::new()));
735        assert_eq!(b.mode_label().as_deref(), Some("NORMAL"));
736    }
737
738    #[test]
739    fn space_leads_only_for_vim_backend() {
740        assert!(
741            !BackendState::Textarea(TextareaBackend::direct(crate::ropetext::Text::new()))
742                .space_leads()
743        );
744        assert!(
745            BackendState::Textarea(TextareaBackend::vim(crate::ropetext::Text::new()))
746                .space_leads()
747        );
748    }
749
750    #[test]
751    fn modal_is_insert_classifies_backends() {
752        // Direct textarea → None (non-modal, leave terminal cursor alone).
753        assert_eq!(
754            BackendState::Textarea(TextareaBackend::direct(crate::ropetext::Text::new()))
755                .modal_is_insert(),
756            None
757        );
758        // Vim backend starts in Normal mode → Some(false) (block cursor).
759        assert_eq!(
760            BackendState::Textarea(TextareaBackend::vim(crate::ropetext::Text::new()))
761                .modal_is_insert(),
762            Some(false)
763        );
764    }
765}