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