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_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 a `TextArea` (adr/0012).
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    pub ta: TextArea<'static>,
92    pub input: InputInterpreter,
93}
94
95impl TextareaBackend {
96    pub fn direct(ta: TextArea<'static>) -> Self {
97        Self {
98            ta,
99            input: InputInterpreter::Direct,
100        }
101    }
102    pub fn vim(ta: TextArea<'static>) -> Self {
103        Self {
104            ta,
105            input: InputInterpreter::Vim(Box::default()),
106        }
107    }
108}
109
110// ---------------------------------------------------------------------------
111// BackendState
112// ---------------------------------------------------------------------------
113
114#[allow(clippy::large_enum_variant)]
115pub enum BackendState {
116    Textarea(TextareaBackend),
117    Nvim(NvimBackend),
118}
119
120impl BackendState {
121    /// Whether the textarea backend is active — the named form of the
122    /// structural guard, for sites that only need the yes/no.
123    pub fn is_textarea(&self) -> bool {
124        matches!(self, BackendState::Textarea(_))
125    }
126
127    /// True when the active backend is the built-in vim interpreter (any mode).
128    pub fn is_vim(&self) -> bool {
129        matches!(
130            self,
131            BackendState::Textarea(TextareaBackend {
132                input: InputInterpreter::Vim(_),
133                ..
134            })
135        )
136    }
137
138    /// The textarea, when it is the active backend. Textarea-only features
139    /// (autocomplete, smart edits, mouse selection) guard on this.
140    pub fn as_textarea(&self) -> Option<&TextArea<'static>> {
141        match self {
142            BackendState::Textarea(tb) => Some(&tb.ta),
143            BackendState::Nvim(_) => None,
144        }
145    }
146
147    pub fn as_textarea_mut(&mut self) -> Option<&mut TextArea<'static>> {
148        match self {
149            BackendState::Textarea(tb) => Some(&mut tb.ta),
150            BackendState::Nvim(_) => None,
151        }
152    }
153
154    /// The nvim backend, when it is the active one.
155    pub fn as_nvim(&self) -> Option<&NvimBackend> {
156        match self {
157            BackendState::Textarea(_) => None,
158            BackendState::Nvim(nvim) => Some(nvim),
159        }
160    }
161
162    /// The whole buffer as one string, whichever backend holds it.
163    pub fn text(&self) -> String {
164        match self {
165            BackendState::Textarea(tb) => tb.ta.lines().join("\n"),
166            BackendState::Nvim(nvim) => nvim.snapshot().lines.join("\n"),
167        }
168    }
169
170    /// The cursor's (row, col), cheap on both backends — no line cloning.
171    /// The nvim row is clamped to the mirrored line count (the mirror can
172    /// lag the real cursor for a frame), matching the snapshot path.
173    pub fn cursor(&self) -> (usize, usize) {
174        match self {
175            BackendState::Textarea(tb) => super::cursor_tuple(&tb.ta),
176            BackendState::Nvim(nvim) => {
177                let snap = nvim.snapshot();
178                let max_row = snap.lines.len().saturating_sub(1);
179                (snap.cursor.0.min(max_row), snap.cursor.1)
180            }
181        }
182    }
183
184    /// If the nvim backend's process has died, replace it with a textarea
185    /// holding the last mirrored buffer, and report that it happened so the
186    /// host can re-arm textarea-only features.
187    pub fn recover_from_dead_nvim(&mut self) -> bool {
188        let fallback_text = match self.as_nvim() {
189            Some(nvim) if nvim.is_dead() => nvim.snapshot().lines.join("\n"),
190            _ => return false,
191        };
192        tracing::warn!("nvim process died; falling back to textarea backend");
193        *self = BackendState::Textarea(TextareaBackend::direct(TextArea::from(
194            fallback_text.lines(),
195        )));
196        true
197    }
198
199    /// Reconcile the vim engine mode after a host-driven mouse selection change.
200    /// If a selection now exists and the engine is in Normal, enters Visual.
201    /// If the selection is gone and the engine is in Visual/VisualLine, returns
202    /// to Normal. No-op for Direct / Nvim backends.
203    pub fn vim_sync_mouse_selection(&mut self, has_selection: bool) {
204        if let BackendState::Textarea(TextareaBackend {
205            input: InputInterpreter::Vim(e),
206            ..
207        }) = self
208        {
209            e.sync_mouse_selection(has_selection);
210        }
211    }
212
213    /// True when a bare Space should start the leader: vim backend in Normal
214    /// mode with empty pending state. False for Direct / Nvim backends and for
215    /// vim Insert/Visual modes or any pending state.
216    pub fn vim_space_leads(&self) -> bool {
217        matches!(self,
218            BackendState::Textarea(TextareaBackend { input: InputInterpreter::Vim(e), .. })
219            if e.space_leads())
220    }
221
222    /// True when the active backend is the vim interpreter in charwise Visual
223    /// mode (not VisualLine). Used by the highlight path to extend the end col
224    /// by one so the char under the cursor is visually included.
225    pub fn vim_is_charwise_visual(&self) -> bool {
226        matches!(self,
227            BackendState::Textarea(TextareaBackend { input: InputInterpreter::Vim(e), .. })
228            if *e.mode() == EditorMode::Visual)
229    }
230
231    /// Reset the vim interpreter to Normal mode (called when a fresh note is
232    /// loaded). No-op for Direct / Nvim backends.
233    pub fn vim_reset_to_normal(&mut self) {
234        if let BackendState::Textarea(TextareaBackend {
235            input: InputInterpreter::Vim(engine),
236            ..
237        }) = self
238        {
239            engine.reset_to_normal();
240        }
241    }
242
243    /// If the active backend is the vim interpreter, run it for this key and
244    /// return the outcome. Returns `None` for Direct / Nvim backends.
245    pub fn vim_handle_key(
246        &mut self,
247        key: &ratatui::crossterm::event::KeyEvent,
248    ) -> Option<super::vim::VimKeyOutcome> {
249        match self {
250            BackendState::Textarea(TextareaBackend {
251                ta,
252                input: InputInterpreter::Vim(engine),
253            }) => Some(engine.handle_key(key, ta)),
254            _ => None,
255        }
256    }
257
258    /// The in-progress vim command sequence (count/operator/find/g), for the
259    /// footer hint. Returns `None` for Direct / Nvim backends, or when the
260    /// vim interpreter has no pending state.
261    pub fn vim_pending_hint(&self) -> Option<String> {
262        match self {
263            BackendState::Textarea(TextareaBackend {
264                input: InputInterpreter::Vim(e),
265                ..
266            }) => e.pending_hint(),
267            _ => None,
268        }
269    }
270
271    /// The footer modal-mode label, when the backend has one (nvim, or the
272    /// vim interpreter). `None` for the plain Direct textarea.
273    pub fn mode_label(&self) -> Option<String> {
274        match self {
275            BackendState::Textarea(TextareaBackend {
276                input: InputInterpreter::Vim(engine),
277                ..
278            }) => Some(engine.mode_label()),
279            BackendState::Textarea(_) => None,
280            BackendState::Nvim(nvim) => Some(nvim.snapshot().footer_label()),
281        }
282    }
283
284    /// Alloc-free cursor-shape classifier for the render path.
285    /// `None` = non-modal backend (Direct textarea — leave terminal cursor as-is).
286    /// `Some(true)` = Insert mode (bar cursor).
287    /// `Some(false)` = other modal mode (block cursor).
288    pub fn modal_is_insert(&self) -> Option<bool> {
289        match self {
290            BackendState::Textarea(TextareaBackend {
291                input: InputInterpreter::Vim(e),
292                ..
293            }) => Some(*e.mode() == EditorMode::Insert),
294            BackendState::Textarea(_) => None,
295            BackendState::Nvim(nvim) => Some(nvim.snapshot().mode == EditorMode::Insert),
296        }
297    }
298
299    pub fn from_settings(
300        editor_backend: &EditorBackendSetting,
301        nvim_path: Option<&PathBuf>,
302    ) -> Self {
303        if matches!(editor_backend, EditorBackendSetting::Nvim) {
304            match NvimBackend::new(nvim_path) {
305                Ok(backend) => return BackendState::Nvim(backend),
306                Err(e) => {
307                    tracing::warn!("nvim backend unavailable, falling back to textarea: {e}")
308                }
309            }
310        }
311        let tb = match editor_backend {
312            EditorBackendSetting::Vim => TextareaBackend::vim(TextArea::default()),
313            // Nvim is handled by the early return above; Textarea and any
314            // future non-modal setting use the direct interpreter.
315            EditorBackendSetting::Textarea | EditorBackendSetting::Nvim => {
316                TextareaBackend::direct(TextArea::default())
317            }
318        };
319        BackendState::Textarea(tb)
320    }
321}
322
323// ---------------------------------------------------------------------------
324// NvimBackend
325// ---------------------------------------------------------------------------
326
327pub struct NvimBackend {
328    nvim: NvimClient,
329    snapshot: Arc<Mutex<NvimSnapshot>>,
330    is_dead: Arc<AtomicBool>,
331    /// Set while a `buf_set_lines` call spawned by `set_text` is in flight.
332    /// The refresh task skips line/dirty updates while this is `true` to avoid
333    /// overwriting the pre-populated snapshot with stale nvim state.
334    set_text_in_flight: Arc<AtomicBool>,
335    /// Incremented by the handler on every flush event.
336    flush_rx: tokio::sync::watch::Receiver<u64>,
337    /// Incremented by handle_key after each successful nvim_input call.
338    /// Gives the refresh task a wakeup path even when nvim doesn't send flush.
339    key_tx: tokio::sync::watch::Sender<u64>,
340    /// Stored until the refresh task is started on the first handle_key call.
341    pending_key_rx: Mutex<Option<tokio::sync::watch::Receiver<u64>>>,
342    /// Tracks the last size passed to `ui_attach`/`ui_try_resize` so we only
343    /// send a resize RPC when the terminal rect actually changes.
344    last_ui_size: Mutex<(u16, u16)>,
345    io_handle: tokio::task::JoinHandle<Result<(), Box<LoopError>>>,
346    child: Option<tokio::process::Child>,
347}
348
349impl Drop for NvimBackend {
350    fn drop(&mut self) {
351        // Abort the IO loop first so it stops sending on flush_tx,
352        // which lets the refresh task's flush_rx.changed() return Err and exit.
353        self.io_handle.abort();
354        if let Some(ref mut child) = self.child {
355            let _ = child.start_kill();
356        }
357    }
358}
359
360impl NvimBackend {
361    /// Locked view of the mirrored nvim state (cursor, lines, mode, dirty…).
362    /// Poison-recovering: a panicked refresh task never wedges the UI.
363    pub fn snapshot(&self) -> std::sync::MutexGuard<'_, NvimSnapshot> {
364        self.snapshot.lock().unwrap_or_else(|p| p.into_inner())
365    }
366
367    /// Whether the nvim process / IO loop has died (the host falls back to
368    /// the textarea backend when it has).
369    pub fn is_dead(&self) -> bool {
370        self.is_dead.load(std::sync::atomic::Ordering::SeqCst)
371    }
372
373    /// Clear the mirrored dirty flag — the buffer was just persisted.
374    pub fn mark_clean(&self) {
375        self.snapshot().dirty = false;
376    }
377
378    pub fn new(nvim_path: Option<&PathBuf>) -> Result<Self, String> {
379        tokio::task::block_in_place(|| {
380            tokio::runtime::Handle::current().block_on(Self::new_async(nvim_path))
381        })
382    }
383
384    async fn new_async(nvim_path: Option<&PathBuf>) -> Result<Self, String> {
385        let binary = nvim_path
386            .map(|p| p.to_string_lossy().into_owned())
387            .unwrap_or_else(|| "nvim".to_string());
388
389        let (flush_tx, flush_rx) = tokio::sync::watch::channel(0u64);
390        let (key_tx, key_rx) = tokio::sync::watch::channel(0u64);
391        let handler = NvimHandler { flush_tx };
392
393        let mut cmd = tokio::process::Command::new(&binary);
394        cmd.arg("--embed").stderr(std::process::Stdio::null());
395
396        let (nvim, io_handle, child) = new_child_cmd(&mut cmd, handler)
397            .await
398            .map_err(|e| format!("failed to spawn {binary}: {e}"))?;
399
400        let mut ui_opts = UiAttachOptions::new();
401        ui_opts.set_rgb(false);
402        nvim.ui_attach(80, 24, &ui_opts)
403            .await
404            .map_err(|e| format!("nvim_ui_attach failed: {e}"))?;
405
406        let _ = nvim.command("set noswapfile").await;
407        let _ = nvim.command("set buftype=nofile").await;
408        let _ = nvim.command("set nomodeline").await;
409        let _ = nvim.command("set expandtab").await;
410        // Pin nvim's tabstop to the renderer's TAB_STOP so tab-column math and
411        // cursor placement can never desync.
412        let _ = nvim
413            .command(&format!("set tabstop={}", super::markdown::TAB_STOP))
414            .await;
415
416        Ok(Self {
417            nvim,
418            snapshot: Arc::new(Mutex::new(NvimSnapshot::default())),
419            is_dead: Arc::new(AtomicBool::new(false)),
420            set_text_in_flight: Arc::new(AtomicBool::new(false)),
421            flush_rx,
422            key_tx,
423            pending_key_rx: Mutex::new(Some(key_rx)),
424            last_ui_size: Mutex::new((80, 24)),
425            io_handle,
426            child: Some(child),
427        })
428    }
429
430    /// Start the long-running refresh task on the first call; no-op afterwards.
431    fn ensure_refresh_task(&self, tx: &AppTx) {
432        let mut guard = self
433            .pending_key_rx
434            .lock()
435            .unwrap_or_else(|p| p.into_inner());
436        let Some(key_rx) = guard.take() else { return };
437
438        let nvim = self.nvim.clone();
439        let snapshot = self.snapshot.clone();
440        let is_dead = self.is_dead.clone();
441        let in_flight = self.set_text_in_flight.clone();
442        let flush_rx = self.flush_rx.clone();
443        let tx = tx.clone();
444
445        tokio::spawn(async move {
446            let mut key_rx = key_rx;
447            let mut flush_rx = flush_rx;
448
449            loop {
450                // Wake on either:
451                //  • flush event (nvim finished processing input — best path)
452                //  • key signal  (nvim_input returned; give nvim 30 ms to flush first)
453                tokio::select! {
454                    res = flush_rx.changed() => {
455                        if res.is_err() {
456                            // Sender dropped — nvim IO loop ended.
457                            is_dead.store(true, Ordering::SeqCst);
458                            tx.send(AppEvent::Redraw).ok();
459                            break;
460                        }
461                        // Flush arrived — state is fresh, query immediately.
462                    }
463                    res = key_rx.changed() => {
464                        if res.is_err() { break; }
465                        // nvim_input returned. Wait up to 30 ms for flush before
466                        // querying; proceed regardless so nothing is ever stuck.
467                        tokio::time::timeout(
468                            Duration::from_millis(30),
469                            flush_rx.changed(),
470                        ).await.ok();
471                    }
472                }
473
474                match nvim.exec_lua(STATE_QUERY_LUA, vec![]).await {
475                    Ok(value) => {
476                        apply_lua_state(&snapshot, &in_flight, value);
477                        tx.send(AppEvent::Redraw).ok();
478                    }
479                    Err(e) => {
480                        if e.is_channel_closed() {
481                            is_dead.store(true, Ordering::SeqCst);
482                            tx.send(AppEvent::Redraw).ok();
483                            break;
484                        }
485                        // Non-fatal (e.g. transient Lua error): log and continue.
486                        tracing::debug!("exec_lua error: {e}");
487                    }
488                }
489            }
490        });
491    }
492
493    /// Load content into the nvim buffer and pre-populate the snapshot.
494    ///
495    /// Contract: the synchronous snapshot pre-populate (lines + cursor +
496    /// dirty=false + content_gen bump) happens BEFORE `in_flight` is set
497    /// and the buf_set_lines RPC is spawned. A keystroke arriving between
498    /// the synchronous return of `set_text` and the spawned task actually
499    /// reaching nvim ends up routed via `handle_key`, and the refresh task
500    /// will skip snapshot updates while `in_flight=true` (see
501    /// `apply_lua_state`). Once the spawned RPC completes and `in_flight`
502    /// flips back to false, the refresh task will observe whatever buffer
503    /// state nvim has — including both the loaded content AND any keys the
504    /// user pressed in the interim. `snap.lines != new_lines` will then
505    /// re-set `dirty=true`. The window where `dirty=false` after a
506    /// concurrent keystroke is bounded by one refresh cycle (~30 ms).
507    /// Do NOT move the `in_flight.store(true)` earlier or clear it
508    /// before the RPC actually completes — both invariants are load-bearing.
509    pub fn set_text(&self, text: &str) {
510        let lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
511
512        {
513            let mut snap = self.snapshot.lock().unwrap_or_else(|p| p.into_inner());
514            snap.lines = if lines.is_empty() {
515                vec![String::new()]
516            } else {
517                lines.clone()
518            };
519            snap.cursor = (0, 0);
520            snap.dirty = false;
521            snap.content_gen = snap.content_gen.wrapping_add(1);
522        }
523
524        let nvim = self.nvim.clone();
525        let is_dead = self.is_dead.clone();
526        let in_flight = self.set_text_in_flight.clone();
527        in_flight.store(true, Ordering::SeqCst);
528        tokio::spawn(async move {
529            let buf = match nvim.get_current_buf().await {
530                Ok(b) => b,
531                Err(e) => {
532                    in_flight.store(false, Ordering::SeqCst);
533                    if e.is_channel_closed() {
534                        is_dead.store(true, Ordering::SeqCst);
535                    }
536                    tracing::warn!("set_text get_current_buf: {e}");
537                    return;
538                }
539            };
540            if let Err(e) = buf.set_lines(0, -1, false, lines).await {
541                tracing::warn!("set_text buf_set_lines: {e}");
542            }
543            in_flight.store(false, Ordering::SeqCst);
544        });
545    }
546
547    /// Notify nvim of a terminal resize, but only when the dimensions actually change.
548    pub fn maybe_resize(&self, width: u16, height: u16) {
549        let mut guard = self.last_ui_size.lock().unwrap_or_else(|p| p.into_inner());
550        if *guard == (width, height) {
551            return;
552        }
553        *guard = (width, height);
554        drop(guard);
555
556        let nvim = self.nvim.clone();
557        let is_dead = self.is_dead.clone();
558        tokio::spawn(async move {
559            if let Err(e) = nvim.ui_try_resize(width as i64, height as i64).await {
560                if e.is_channel_closed() {
561                    is_dead.store(true, Ordering::SeqCst);
562                }
563                tracing::debug!("ui_try_resize error: {e}");
564            }
565        });
566    }
567
568    /// Insert `text` at nvim's current cursor position via `nvim_paste`.
569    /// Honours nvim's current mode (insert/normal/visual) — visual replaces the
570    /// selection, normal/insert insert at cursor — so it works as a drop-in
571    /// for the textarea backend's insert/replace flow.
572    pub fn paste(&self, text: &str, tx: AppTx) {
573        self.ensure_refresh_task(&tx);
574        let nvim = self.nvim.clone();
575        let is_dead = self.is_dead.clone();
576        let key_tx = self.key_tx.clone();
577        let payload = text.to_string();
578        tokio::spawn(async move {
579            // phase = -1 → single-chunk paste (not part of a streamed sequence).
580            match nvim.paste(&payload, false, -1).await {
581                Ok(_) => {
582                    key_tx.send_modify(|v| *v = v.wrapping_add(1));
583                }
584                Err(e) => {
585                    if e.is_channel_closed() {
586                        is_dead.store(true, Ordering::SeqCst);
587                        tx.send(AppEvent::Redraw).ok();
588                    }
589                    tracing::debug!("nvim_paste error: {e}");
590                }
591            }
592        });
593    }
594
595    /// Forward a keystroke to nvim.
596    pub fn handle_key(&self, key: &ratatui::crossterm::event::KeyEvent, tx: AppTx) {
597        self.ensure_refresh_task(&tx);
598
599        let Some(nvim_key) = key_event_to_nvim_string(key) else {
600            tracing::debug!("unmappable key: {key:?}");
601            return;
602        };
603
604        let nvim = self.nvim.clone();
605        let is_dead = self.is_dead.clone();
606        let key_tx = self.key_tx.clone();
607
608        tokio::spawn(async move {
609            match nvim.input(&nvim_key).await {
610                Ok(_) => {
611                    // Signal the refresh task: a key was just sent.
612                    key_tx.send_modify(|v| *v = v.wrapping_add(1));
613                }
614                Err(e) => {
615                    if e.is_channel_closed() {
616                        is_dead.store(true, Ordering::SeqCst);
617                        tx.send(AppEvent::Redraw).ok();
618                    }
619                    tracing::debug!("nvim_input error: {e}");
620                }
621            }
622        });
623    }
624}
625
626// ---------------------------------------------------------------------------
627// Parse the Lua state bundle and apply it to the snapshot.
628// ---------------------------------------------------------------------------
629
630/// Decode the Lua state bundle (pure, in [`super::nvim_decode`]) and merge it
631/// into the live snapshot. Decoding owns the wire-format facts; this function
632/// owns the stateful bookkeeping that decoding cannot: the `in_flight` gate and
633/// the `content_gen`/`dirty` revision counters.
634fn apply_lua_state(
635    snapshot: &Arc<Mutex<NvimSnapshot>>,
636    in_flight: &Arc<AtomicBool>,
637    value: nvim_rs::Value,
638) {
639    let Some(decoded) = decode(&value) else {
640        return;
641    };
642
643    let mut snap = snapshot.lock().unwrap_or_else(|p| p.into_inner());
644
645    match decoded {
646        DecodedState::Command { cmdline } => {
647            snap.mode = EditorMode::Command;
648            snap.cmdline = Some(cmdline);
649        }
650        DecodedState::Content {
651            mode,
652            lines,
653            cursor,
654            visual_selection,
655        } => {
656            if lines != snap.lines && !in_flight.load(Ordering::SeqCst) {
657                snap.dirty = true;
658                snap.lines = lines;
659                snap.content_gen = snap.content_gen.wrapping_add(1);
660            }
661            snap.cursor = cursor;
662            snap.mode = mode;
663            snap.cmdline = None;
664            snap.visual_selection = visual_selection;
665        }
666    }
667}
668
669// ---------------------------------------------------------------------------
670// Tests
671// ---------------------------------------------------------------------------
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676    use ratatui_textarea::TextArea;
677
678    #[test]
679    fn direct_backend_has_no_mode_label() {
680        let b = BackendState::Textarea(TextareaBackend::direct(TextArea::default()));
681        assert_eq!(b.mode_label(), None);
682    }
683
684    #[test]
685    fn vim_backend_reports_normal_label() {
686        let b = BackendState::Textarea(TextareaBackend::vim(TextArea::default()));
687        assert_eq!(b.mode_label().as_deref(), Some("NORMAL"));
688    }
689
690    #[test]
691    fn vim_space_leads_only_for_vim_backend() {
692        assert!(
693            !BackendState::Textarea(TextareaBackend::direct(TextArea::default())).vim_space_leads()
694        );
695        assert!(
696            BackendState::Textarea(TextareaBackend::vim(TextArea::default())).vim_space_leads()
697        );
698    }
699
700    #[test]
701    fn modal_is_insert_classifies_backends() {
702        // Direct textarea → None (non-modal, leave terminal cursor alone).
703        assert_eq!(
704            BackendState::Textarea(TextareaBackend::direct(TextArea::default())).modal_is_insert(),
705            None
706        );
707        // Vim backend starts in Normal mode → Some(false) (block cursor).
708        assert_eq!(
709            BackendState::Textarea(TextareaBackend::vim(TextArea::default())).modal_is_insert(),
710            Some(false)
711        );
712    }
713}