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 **edit 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) => super::cursor_tuple(&tb.ta),
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    /// Reset any transient input-interpreter state for a freshly loaded note
249    /// (the vim interpreter returns to Normal; the other backends carry no
250    /// such state).
251    pub fn reset_input_state(&mut self) {
252        if let BackendState::Textarea(TextareaBackend {
253            input: InputInterpreter::Vim(engine),
254            ..
255        }) = self
256        {
257            engine.reset_to_normal();
258        }
259    }
260
261    /// If the active backend is the vim interpreter, run it for this key and
262    /// return the outcome. Returns `None` for Direct / Nvim backends.
263    pub fn vim_handle_key(
264        &mut self,
265        key: &ratatui::crossterm::event::KeyEvent,
266    ) -> Option<super::vim::VimKeyOutcome> {
267        match self {
268            BackendState::Textarea(TextareaBackend {
269                ta,
270                input: InputInterpreter::Vim(engine),
271                ..
272            }) => Some(engine.handle_key(key, ta)),
273            _ => None,
274        }
275    }
276
277    /// The in-progress input-command hint for the footer (the vim
278    /// interpreter's pending count/operator/find/g sequence). `None` when the
279    /// active backend has no pending sequence.
280    pub fn pending_input_hint(&self) -> Option<String> {
281        match self {
282            BackendState::Textarea(TextareaBackend {
283                input: InputInterpreter::Vim(e),
284                ..
285            }) => e.pending_hint(),
286            _ => None,
287        }
288    }
289
290    /// The footer modal-mode label, when the backend has one (nvim, or the
291    /// vim interpreter). `None` for the plain Direct textarea.
292    pub fn mode_label(&self) -> Option<String> {
293        match self {
294            BackendState::Textarea(TextareaBackend {
295                input: InputInterpreter::Vim(engine),
296                ..
297            }) => Some(engine.mode_label()),
298            BackendState::Textarea(_) => None,
299            BackendState::Nvim(nvim) => Some(nvim.snapshot().footer_label()),
300        }
301    }
302
303    /// Alloc-free cursor-shape classifier for the render path.
304    /// `None` = non-modal backend (Direct textarea — leave terminal cursor as-is).
305    /// `Some(true)` = Insert mode (bar cursor).
306    /// `Some(false)` = other modal mode (block cursor).
307    pub fn modal_is_insert(&self) -> Option<bool> {
308        match self {
309            BackendState::Textarea(TextareaBackend {
310                input: InputInterpreter::Vim(e),
311                ..
312            }) => Some(*e.mode() == EditorMode::Insert),
313            BackendState::Textarea(_) => None,
314            BackendState::Nvim(nvim) => Some(nvim.snapshot().mode == EditorMode::Insert),
315        }
316    }
317
318    pub fn from_settings(
319        editor_backend: &EditorBackendSetting,
320        nvim_path: Option<&PathBuf>,
321    ) -> Self {
322        if matches!(editor_backend, EditorBackendSetting::Nvim) {
323            match NvimBackend::new(nvim_path) {
324                Ok(backend) => return BackendState::Nvim(backend),
325                Err(e) => {
326                    tracing::warn!("nvim backend unavailable, falling back to textarea: {e}")
327                }
328            }
329        }
330        let tb = match editor_backend {
331            EditorBackendSetting::Vim => TextareaBackend::vim(crate::ropetext::Text::new()),
332            // Nvim is handled by the early return above; Textarea and any
333            // future non-modal setting use the direct interpreter.
334            EditorBackendSetting::Plain | EditorBackendSetting::Nvim => {
335                TextareaBackend::direct(crate::ropetext::Text::new())
336            }
337        };
338        BackendState::Textarea(tb)
339    }
340}
341
342// ---------------------------------------------------------------------------
343// NvimBackend
344// ---------------------------------------------------------------------------
345
346pub struct NvimBackend {
347    nvim: NvimClient,
348    snapshot: Arc<Mutex<NvimSnapshot>>,
349    is_dead: Arc<AtomicBool>,
350    /// Set while a `buf_set_lines` call spawned by `set_text` is in flight.
351    /// The refresh task skips line/dirty updates while this is `true` to avoid
352    /// overwriting the pre-populated snapshot with stale nvim state.
353    set_text_in_flight: Arc<AtomicBool>,
354    /// Incremented by the handler on every flush event.
355    flush_rx: tokio::sync::watch::Receiver<u64>,
356    /// Incremented by handle_key after each successful nvim_input call.
357    /// Gives the refresh task a wakeup path even when nvim doesn't send flush.
358    key_tx: tokio::sync::watch::Sender<u64>,
359    /// Stored until the refresh task is started on the first handle_key call.
360    pending_key_rx: Mutex<Option<tokio::sync::watch::Receiver<u64>>>,
361    /// Tracks the last size passed to `ui_attach`/`ui_try_resize` so we only
362    /// send a resize RPC when the terminal rect actually changes.
363    last_ui_size: Mutex<(u16, u16)>,
364    io_handle: tokio::task::JoinHandle<Result<(), Box<LoopError>>>,
365    child: Option<tokio::process::Child>,
366}
367
368impl Drop for NvimBackend {
369    fn drop(&mut self) {
370        // Abort the IO loop first so it stops sending on flush_tx,
371        // which lets the refresh task's flush_rx.changed() return Err and exit.
372        self.io_handle.abort();
373        if let Some(ref mut child) = self.child {
374            let _ = child.start_kill();
375        }
376    }
377}
378
379impl NvimBackend {
380    /// Locked view of the mirrored nvim state (cursor, lines, mode, dirty…).
381    /// Poison-recovering: a panicked refresh task never wedges the UI.
382    pub fn snapshot(&self) -> std::sync::MutexGuard<'_, NvimSnapshot> {
383        self.snapshot.lock().unwrap_or_else(|p| p.into_inner())
384    }
385
386    /// Whether the nvim process / IO loop has died (the host falls back to
387    /// the textarea backend when it has).
388    pub fn is_dead(&self) -> bool {
389        self.is_dead.load(std::sync::atomic::Ordering::SeqCst)
390    }
391
392    /// Clear the mirrored dirty flag — the buffer was just persisted.
393    pub fn mark_clean(&self) {
394        self.snapshot().dirty = false;
395    }
396
397    pub fn new(nvim_path: Option<&PathBuf>) -> Result<Self, String> {
398        tokio::task::block_in_place(|| {
399            tokio::runtime::Handle::current().block_on(Self::new_async(nvim_path))
400        })
401    }
402
403    async fn new_async(nvim_path: Option<&PathBuf>) -> Result<Self, String> {
404        let binary = nvim_path
405            .map(|p| p.to_string_lossy().into_owned())
406            .unwrap_or_else(|| "nvim".to_string());
407
408        let (flush_tx, flush_rx) = tokio::sync::watch::channel(0u64);
409        let (key_tx, key_rx) = tokio::sync::watch::channel(0u64);
410        let handler = NvimHandler { flush_tx };
411
412        let mut cmd = tokio::process::Command::new(&binary);
413        cmd.arg("--embed").stderr(std::process::Stdio::null());
414
415        let (nvim, io_handle, child) = new_child_cmd(&mut cmd, handler)
416            .await
417            .map_err(|e| format!("failed to spawn {binary}: {e}"))?;
418
419        let mut ui_opts = UiAttachOptions::new();
420        ui_opts.set_rgb(false);
421        nvim.ui_attach(80, 24, &ui_opts)
422            .await
423            .map_err(|e| format!("nvim_ui_attach failed: {e}"))?;
424
425        let _ = nvim.command("set noswapfile").await;
426        let _ = nvim.command("set buftype=nofile").await;
427        let _ = nvim.command("set nomodeline").await;
428        let _ = nvim.command("set expandtab").await;
429        // Pin nvim's tabstop to the renderer's TAB_STOP so tab-column math and
430        // cursor placement can never desync.
431        let _ = nvim
432            .command(&format!("set tabstop={}", super::markdown::TAB_STOP))
433            .await;
434
435        Ok(Self {
436            nvim,
437            snapshot: Arc::new(Mutex::new(NvimSnapshot::default())),
438            is_dead: Arc::new(AtomicBool::new(false)),
439            set_text_in_flight: Arc::new(AtomicBool::new(false)),
440            flush_rx,
441            key_tx,
442            pending_key_rx: Mutex::new(Some(key_rx)),
443            last_ui_size: Mutex::new((80, 24)),
444            io_handle,
445            child: Some(child),
446        })
447    }
448
449    /// Start the long-running refresh task on the first call; no-op afterwards.
450    fn ensure_refresh_task(&self, tx: &AppTx) {
451        let mut guard = self
452            .pending_key_rx
453            .lock()
454            .unwrap_or_else(|p| p.into_inner());
455        let Some(key_rx) = guard.take() else { return };
456
457        let nvim = self.nvim.clone();
458        let snapshot = self.snapshot.clone();
459        let is_dead = self.is_dead.clone();
460        let in_flight = self.set_text_in_flight.clone();
461        let flush_rx = self.flush_rx.clone();
462        let tx = tx.clone();
463
464        tokio::spawn(async move {
465            let mut key_rx = key_rx;
466            let mut flush_rx = flush_rx;
467
468            loop {
469                // Wake on either:
470                //  • flush event (nvim finished processing input — best path)
471                //  • key signal  (nvim_input returned; give nvim 30 ms to flush first)
472                tokio::select! {
473                    res = flush_rx.changed() => {
474                        if res.is_err() {
475                            // Sender dropped — nvim IO loop ended.
476                            is_dead.store(true, Ordering::SeqCst);
477                            tx.send(AppEvent::Redraw).ok();
478                            break;
479                        }
480                        // Flush arrived — state is fresh, query immediately.
481                    }
482                    res = key_rx.changed() => {
483                        if res.is_err() { break; }
484                        // nvim_input returned. Wait up to 30 ms for flush before
485                        // querying; proceed regardless so nothing is ever stuck.
486                        tokio::time::timeout(
487                            Duration::from_millis(30),
488                            flush_rx.changed(),
489                        ).await.ok();
490                    }
491                }
492
493                match nvim.exec_lua(STATE_QUERY_LUA, vec![]).await {
494                    Ok(value) => {
495                        apply_lua_state(&snapshot, &in_flight, value);
496                        tx.send(AppEvent::Redraw).ok();
497                    }
498                    Err(e) => {
499                        if e.is_channel_closed() {
500                            is_dead.store(true, Ordering::SeqCst);
501                            tx.send(AppEvent::Redraw).ok();
502                            break;
503                        }
504                        // Non-fatal (e.g. transient Lua error): log and continue.
505                        tracing::debug!("exec_lua error: {e}");
506                    }
507                }
508            }
509        });
510    }
511
512    /// Load content into the nvim buffer and pre-populate the snapshot.
513    ///
514    /// Contract: the synchronous snapshot pre-populate (lines + cursor +
515    /// dirty=false + content_gen bump) happens BEFORE `in_flight` is set
516    /// and the buf_set_lines RPC is spawned. A keystroke arriving between
517    /// the synchronous return of `set_text` and the spawned task actually
518    /// reaching nvim ends up routed via `handle_key`, and the refresh task
519    /// will skip snapshot updates while `in_flight=true` (see
520    /// `apply_lua_state`). Once the spawned RPC completes and `in_flight`
521    /// flips back to false, the refresh task will observe whatever buffer
522    /// state nvim has — including both the loaded content AND any keys the
523    /// user pressed in the interim. `snap.lines != new_lines` will then
524    /// re-set `dirty=true`. The window where `dirty=false` after a
525    /// concurrent keystroke is bounded by one refresh cycle (~30 ms).
526    /// Do NOT move the `in_flight.store(true)` earlier or clear it
527    /// before the RPC actually completes — both invariants are load-bearing.
528    pub fn set_text(&self, text: &str) {
529        let lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
530
531        {
532            let mut snap = self.snapshot.lock().unwrap_or_else(|p| p.into_inner());
533            snap.lines = if lines.is_empty() {
534                vec![String::new()]
535            } else {
536                lines.clone()
537            };
538            snap.cursor = (0, 0);
539            snap.dirty = false;
540            snap.content_gen = snap.content_gen.wrapping_add(1);
541        }
542
543        let nvim = self.nvim.clone();
544        let is_dead = self.is_dead.clone();
545        let in_flight = self.set_text_in_flight.clone();
546        in_flight.store(true, Ordering::SeqCst);
547        tokio::spawn(async move {
548            let buf = match nvim.get_current_buf().await {
549                Ok(b) => b,
550                Err(e) => {
551                    in_flight.store(false, Ordering::SeqCst);
552                    if e.is_channel_closed() {
553                        is_dead.store(true, Ordering::SeqCst);
554                    }
555                    tracing::warn!("set_text get_current_buf: {e}");
556                    return;
557                }
558            };
559            if let Err(e) = buf.set_lines(0, -1, false, lines).await {
560                tracing::warn!("set_text buf_set_lines: {e}");
561            }
562            in_flight.store(false, Ordering::SeqCst);
563        });
564    }
565
566    /// Notify nvim of a terminal resize, but only when the dimensions actually change.
567    pub fn maybe_resize(&self, width: u16, height: u16) {
568        let mut guard = self.last_ui_size.lock().unwrap_or_else(|p| p.into_inner());
569        if *guard == (width, height) {
570            return;
571        }
572        *guard = (width, height);
573        drop(guard);
574
575        let nvim = self.nvim.clone();
576        let is_dead = self.is_dead.clone();
577        tokio::spawn(async move {
578            if let Err(e) = nvim.ui_try_resize(width as i64, height as i64).await {
579                if e.is_channel_closed() {
580                    is_dead.store(true, Ordering::SeqCst);
581                }
582                tracing::debug!("ui_try_resize error: {e}");
583            }
584        });
585    }
586
587    /// Insert `text` at nvim's current cursor position via `nvim_paste`.
588    /// Honours nvim's current mode (insert/normal/visual) — visual replaces the
589    /// selection, normal/insert insert at cursor — so it works as a drop-in
590    /// for the textarea backend's insert/replace flow.
591    pub fn paste(&self, text: &str, tx: AppTx) {
592        self.ensure_refresh_task(&tx);
593        let nvim = self.nvim.clone();
594        let is_dead = self.is_dead.clone();
595        let key_tx = self.key_tx.clone();
596        let payload = text.to_string();
597        tokio::spawn(async move {
598            // phase = -1 → single-chunk paste (not part of a streamed sequence).
599            match nvim.paste(&payload, false, -1).await {
600                Ok(_) => {
601                    key_tx.send_modify(|v| *v = v.wrapping_add(1));
602                }
603                Err(e) => {
604                    if e.is_channel_closed() {
605                        is_dead.store(true, Ordering::SeqCst);
606                        tx.send(AppEvent::Redraw).ok();
607                    }
608                    tracing::debug!("nvim_paste error: {e}");
609                }
610            }
611        });
612    }
613
614    /// Forward a keystroke to nvim.
615    pub fn handle_key(&self, key: &ratatui::crossterm::event::KeyEvent, tx: AppTx) {
616        self.ensure_refresh_task(&tx);
617
618        let Some(nvim_key) = key_event_to_nvim_string(key) else {
619            tracing::debug!("unmappable key: {key:?}");
620            return;
621        };
622
623        let nvim = self.nvim.clone();
624        let is_dead = self.is_dead.clone();
625        let key_tx = self.key_tx.clone();
626
627        tokio::spawn(async move {
628            match nvim.input(&nvim_key).await {
629                Ok(_) => {
630                    // Signal the refresh task: a key was just sent.
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_input error: {e}");
639                }
640            }
641        });
642    }
643}
644
645// ---------------------------------------------------------------------------
646// Parse the Lua state bundle and apply it to the snapshot.
647// ---------------------------------------------------------------------------
648
649/// Decode the Lua state bundle (pure, in [`super::nvim_decode`]) and merge it
650/// into the live snapshot. Decoding owns the wire-format facts; this function
651/// owns the stateful bookkeeping that decoding cannot: the `in_flight` gate and
652/// the `content_gen`/`dirty` revision counters.
653fn apply_lua_state(
654    snapshot: &Arc<Mutex<NvimSnapshot>>,
655    in_flight: &Arc<AtomicBool>,
656    value: nvim_rs::Value,
657) {
658    let Some(decoded) = decode(&value) else {
659        return;
660    };
661
662    let mut snap = snapshot.lock().unwrap_or_else(|p| p.into_inner());
663
664    match decoded {
665        DecodedState::Command { cmdline } => {
666            snap.mode = EditorMode::Command;
667            snap.cmdline = Some(cmdline);
668        }
669        DecodedState::Content {
670            mode,
671            lines,
672            cursor,
673            visual_selection,
674        } => {
675            if lines != snap.lines && !in_flight.load(Ordering::SeqCst) {
676                snap.dirty = true;
677                snap.lines = lines;
678                snap.content_gen = snap.content_gen.wrapping_add(1);
679            }
680            snap.cursor = cursor;
681            snap.mode = mode;
682            snap.cmdline = None;
683            snap.visual_selection = visual_selection;
684        }
685    }
686}
687
688// ---------------------------------------------------------------------------
689// Tests
690// ---------------------------------------------------------------------------
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695
696    #[test]
697    fn direct_backend_has_no_mode_label() {
698        let b = BackendState::Textarea(TextareaBackend::direct(crate::ropetext::Text::new()));
699        assert_eq!(b.mode_label(), None);
700    }
701
702    #[test]
703    fn vim_backend_reports_normal_label() {
704        let b = BackendState::Textarea(TextareaBackend::vim(crate::ropetext::Text::new()));
705        assert_eq!(b.mode_label().as_deref(), Some("NORMAL"));
706    }
707
708    #[test]
709    fn space_leads_only_for_vim_backend() {
710        assert!(
711            !BackendState::Textarea(TextareaBackend::direct(crate::ropetext::Text::new()))
712                .space_leads()
713        );
714        assert!(
715            BackendState::Textarea(TextareaBackend::vim(crate::ropetext::Text::new()))
716                .space_leads()
717        );
718    }
719
720    #[test]
721    fn modal_is_insert_classifies_backends() {
722        // Direct textarea → None (non-modal, leave terminal cursor alone).
723        assert_eq!(
724            BackendState::Textarea(TextareaBackend::direct(crate::ropetext::Text::new()))
725                .modal_is_insert(),
726            None
727        );
728        // Vim backend starts in Normal mode → Some(false) (block cursor).
729        assert_eq!(
730            BackendState::Textarea(TextareaBackend::vim(crate::ropetext::Text::new()))
731                .modal_is_insert(),
732            Some(false)
733        );
734    }
735}