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