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