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::{NvimMode, NvimSnapshot};
14use crate::components::events::{AppEvent, AppTx};
15use crate::settings::EditorBackendSetting;
16
17type NvimWriter = Compat<ChildStdin>;
18type NvimClient = Neovim<NvimWriter>;
19
20// ---------------------------------------------------------------------------
21// Lua snippet: fetch all editor state in one round-trip.
22//
23// Command mode  → [mode, cmdtype, cmdline]
24// Other modes   → [mode, lines, cursor, vpos]
25// ---------------------------------------------------------------------------
26const STATE_QUERY_LUA: &str = r#"
27local m = vim.api.nvim_get_mode().mode
28if m == 'c' then
29  return {m, vim.fn.getcmdtype(), vim.fn.getcmdline()}
30else
31  local lines  = vim.api.nvim_buf_get_lines(0, 0, -1, false)
32  local cursor = vim.api.nvim_win_get_cursor(0)
33  local vpos   = vim.fn.getpos('v')
34  return {m, lines, cursor, vpos}
35end
36"#;
37
38// ---------------------------------------------------------------------------
39// Handler — increments flush_tx counter on every "flush" redraw event.
40// ---------------------------------------------------------------------------
41
42#[derive(Clone)]
43struct NvimHandler {
44    flush_tx: tokio::sync::watch::Sender<u64>,
45}
46
47#[async_trait::async_trait]
48impl Handler for NvimHandler {
49    type Writer = NvimWriter;
50
51    async fn handle_notify(&self, name: String, args: Vec<nvim_rs::Value>, _neovim: NvimClient) {
52        if name != "redraw" {
53            return;
54        }
55        for arg in &args {
56            if let Some(events) = arg.as_array() {
57                for event in events {
58                    if let Some(ea) = event.as_array()
59                        && ea.first().and_then(|v| v.as_str()) == Some("flush")
60                    {
61                        self.flush_tx.send_modify(|v| *v = v.wrapping_add(1));
62                        return;
63                    }
64                }
65            }
66        }
67    }
68}
69
70// ---------------------------------------------------------------------------
71// BackendState
72// ---------------------------------------------------------------------------
73
74#[allow(clippy::large_enum_variant)]
75pub enum BackendState {
76    Textarea(TextArea<'static>),
77    Nvim(NvimBackend),
78}
79
80impl BackendState {
81    /// Whether the textarea backend is active — the named form of the
82    /// structural guard, for sites that only need the yes/no.
83    pub fn is_textarea(&self) -> bool {
84        matches!(self, BackendState::Textarea(_))
85    }
86
87    /// The textarea, when it is the active backend. Textarea-only features
88    /// (autocomplete, smart edits, mouse selection) guard on this.
89    pub fn as_textarea(&self) -> Option<&TextArea<'static>> {
90        match self {
91            BackendState::Textarea(ta) => Some(ta),
92            BackendState::Nvim(_) => None,
93        }
94    }
95
96    pub fn as_textarea_mut(&mut self) -> Option<&mut TextArea<'static>> {
97        match self {
98            BackendState::Textarea(ta) => Some(ta),
99            BackendState::Nvim(_) => None,
100        }
101    }
102
103    /// The nvim backend, when it is the active one.
104    pub fn as_nvim(&self) -> Option<&NvimBackend> {
105        match self {
106            BackendState::Textarea(_) => None,
107            BackendState::Nvim(nvim) => Some(nvim),
108        }
109    }
110
111    /// The whole buffer as one string, whichever backend holds it.
112    pub fn text(&self) -> String {
113        match self {
114            BackendState::Textarea(ta) => ta.lines().join("\n"),
115            BackendState::Nvim(nvim) => nvim.snapshot().lines.join("\n"),
116        }
117    }
118
119    /// The cursor's (row, col), cheap on both backends — no line cloning.
120    /// The nvim row is clamped to the mirrored line count (the mirror can
121    /// lag the real cursor for a frame), matching the snapshot path.
122    pub fn cursor(&self) -> (usize, usize) {
123        match self {
124            BackendState::Textarea(ta) => super::cursor_tuple(ta),
125            BackendState::Nvim(nvim) => {
126                let snap = nvim.snapshot();
127                let max_row = snap.lines.len().saturating_sub(1);
128                (snap.cursor.0.min(max_row), snap.cursor.1)
129            }
130        }
131    }
132
133    /// If the nvim backend's process has died, replace it with a textarea
134    /// holding the last mirrored buffer, and report that it happened so the
135    /// host can re-arm textarea-only features.
136    pub fn recover_from_dead_nvim(&mut self) -> bool {
137        let fallback_text = match self.as_nvim() {
138            Some(nvim) if nvim.is_dead() => nvim.snapshot().lines.join("\n"),
139            _ => return false,
140        };
141        tracing::warn!("nvim process died; falling back to textarea backend");
142        *self = BackendState::Textarea(TextArea::from(fallback_text.lines()));
143        true
144    }
145
146    pub fn from_settings(
147        editor_backend: &EditorBackendSetting,
148        nvim_path: Option<&PathBuf>,
149    ) -> Self {
150        if matches!(editor_backend, EditorBackendSetting::Nvim) {
151            match NvimBackend::new(nvim_path) {
152                Ok(backend) => return BackendState::Nvim(backend),
153                Err(e) => {
154                    tracing::warn!("nvim backend unavailable, falling back to textarea: {e}")
155                }
156            }
157        }
158        BackendState::Textarea(TextArea::default())
159    }
160}
161
162// ---------------------------------------------------------------------------
163// NvimBackend
164// ---------------------------------------------------------------------------
165
166pub struct NvimBackend {
167    nvim: NvimClient,
168    snapshot: Arc<Mutex<NvimSnapshot>>,
169    is_dead: Arc<AtomicBool>,
170    /// Set while a `buf_set_lines` call spawned by `set_text` is in flight.
171    /// The refresh task skips line/dirty updates while this is `true` to avoid
172    /// overwriting the pre-populated snapshot with stale nvim state.
173    set_text_in_flight: Arc<AtomicBool>,
174    /// Incremented by the handler on every flush event.
175    flush_rx: tokio::sync::watch::Receiver<u64>,
176    /// Incremented by handle_key after each successful nvim_input call.
177    /// Gives the refresh task a wakeup path even when nvim doesn't send flush.
178    key_tx: tokio::sync::watch::Sender<u64>,
179    /// Stored until the refresh task is started on the first handle_key call.
180    pending_key_rx: Mutex<Option<tokio::sync::watch::Receiver<u64>>>,
181    /// Tracks the last size passed to `ui_attach`/`ui_try_resize` so we only
182    /// send a resize RPC when the terminal rect actually changes.
183    last_ui_size: Mutex<(u16, u16)>,
184    io_handle: tokio::task::JoinHandle<Result<(), Box<LoopError>>>,
185    child: Option<tokio::process::Child>,
186}
187
188impl Drop for NvimBackend {
189    fn drop(&mut self) {
190        // Abort the IO loop first so it stops sending on flush_tx,
191        // which lets the refresh task's flush_rx.changed() return Err and exit.
192        self.io_handle.abort();
193        if let Some(ref mut child) = self.child {
194            let _ = child.start_kill();
195        }
196    }
197}
198
199impl NvimBackend {
200    /// Locked view of the mirrored nvim state (cursor, lines, mode, dirty…).
201    /// Poison-recovering: a panicked refresh task never wedges the UI.
202    pub fn snapshot(&self) -> std::sync::MutexGuard<'_, NvimSnapshot> {
203        self.snapshot.lock().unwrap_or_else(|p| p.into_inner())
204    }
205
206    /// Whether the nvim process / IO loop has died (the host falls back to
207    /// the textarea backend when it has).
208    pub fn is_dead(&self) -> bool {
209        self.is_dead.load(std::sync::atomic::Ordering::SeqCst)
210    }
211
212    /// Clear the mirrored dirty flag — the buffer was just persisted.
213    pub fn mark_clean(&self) {
214        self.snapshot().dirty = false;
215    }
216
217    pub fn new(nvim_path: Option<&PathBuf>) -> Result<Self, String> {
218        tokio::task::block_in_place(|| {
219            tokio::runtime::Handle::current().block_on(Self::new_async(nvim_path))
220        })
221    }
222
223    async fn new_async(nvim_path: Option<&PathBuf>) -> Result<Self, String> {
224        let binary = nvim_path
225            .map(|p| p.to_string_lossy().into_owned())
226            .unwrap_or_else(|| "nvim".to_string());
227
228        let (flush_tx, flush_rx) = tokio::sync::watch::channel(0u64);
229        let (key_tx, key_rx) = tokio::sync::watch::channel(0u64);
230        let handler = NvimHandler { flush_tx };
231
232        let mut cmd = tokio::process::Command::new(&binary);
233        cmd.arg("--embed").stderr(std::process::Stdio::null());
234
235        let (nvim, io_handle, child) = new_child_cmd(&mut cmd, handler)
236            .await
237            .map_err(|e| format!("failed to spawn {binary}: {e}"))?;
238
239        let mut ui_opts = UiAttachOptions::new();
240        ui_opts.set_rgb(false);
241        nvim.ui_attach(80, 24, &ui_opts)
242            .await
243            .map_err(|e| format!("nvim_ui_attach failed: {e}"))?;
244
245        let _ = nvim.command("set noswapfile").await;
246        let _ = nvim.command("set buftype=nofile").await;
247        let _ = nvim.command("set nomodeline").await;
248        let _ = nvim.command("set expandtab").await;
249        let _ = nvim.command("set tabstop=4").await;
250
251        Ok(Self {
252            nvim,
253            snapshot: Arc::new(Mutex::new(NvimSnapshot::default())),
254            is_dead: Arc::new(AtomicBool::new(false)),
255            set_text_in_flight: Arc::new(AtomicBool::new(false)),
256            flush_rx,
257            key_tx,
258            pending_key_rx: Mutex::new(Some(key_rx)),
259            last_ui_size: Mutex::new((80, 24)),
260            io_handle,
261            child: Some(child),
262        })
263    }
264
265    /// Start the long-running refresh task on the first call; no-op afterwards.
266    fn ensure_refresh_task(&self, tx: &AppTx) {
267        let mut guard = self
268            .pending_key_rx
269            .lock()
270            .unwrap_or_else(|p| p.into_inner());
271        let Some(key_rx) = guard.take() else { return };
272
273        let nvim = self.nvim.clone();
274        let snapshot = self.snapshot.clone();
275        let is_dead = self.is_dead.clone();
276        let in_flight = self.set_text_in_flight.clone();
277        let flush_rx = self.flush_rx.clone();
278        let tx = tx.clone();
279
280        tokio::spawn(async move {
281            let mut key_rx = key_rx;
282            let mut flush_rx = flush_rx;
283
284            loop {
285                // Wake on either:
286                //  • flush event (nvim finished processing input — best path)
287                //  • key signal  (nvim_input returned; give nvim 30 ms to flush first)
288                tokio::select! {
289                    res = flush_rx.changed() => {
290                        if res.is_err() {
291                            // Sender dropped — nvim IO loop ended.
292                            is_dead.store(true, Ordering::SeqCst);
293                            tx.send(AppEvent::Redraw).ok();
294                            break;
295                        }
296                        // Flush arrived — state is fresh, query immediately.
297                    }
298                    res = key_rx.changed() => {
299                        if res.is_err() { break; }
300                        // nvim_input returned. Wait up to 30 ms for flush before
301                        // querying; proceed regardless so nothing is ever stuck.
302                        tokio::time::timeout(
303                            Duration::from_millis(30),
304                            flush_rx.changed(),
305                        ).await.ok();
306                    }
307                }
308
309                match nvim.exec_lua(STATE_QUERY_LUA, vec![]).await {
310                    Ok(value) => {
311                        apply_lua_state(&snapshot, &in_flight, value);
312                        tx.send(AppEvent::Redraw).ok();
313                    }
314                    Err(e) => {
315                        if e.is_channel_closed() {
316                            is_dead.store(true, Ordering::SeqCst);
317                            tx.send(AppEvent::Redraw).ok();
318                            break;
319                        }
320                        // Non-fatal (e.g. transient Lua error): log and continue.
321                        tracing::debug!("exec_lua error: {e}");
322                    }
323                }
324            }
325        });
326    }
327
328    /// Load content into the nvim buffer and pre-populate the snapshot.
329    ///
330    /// Contract: the synchronous snapshot pre-populate (lines + cursor +
331    /// dirty=false + content_gen bump) happens BEFORE `in_flight` is set
332    /// and the buf_set_lines RPC is spawned. A keystroke arriving between
333    /// the synchronous return of `set_text` and the spawned task actually
334    /// reaching nvim ends up routed via `handle_key`, and the refresh task
335    /// will skip snapshot updates while `in_flight=true` (see
336    /// `apply_lua_state`). Once the spawned RPC completes and `in_flight`
337    /// flips back to false, the refresh task will observe whatever buffer
338    /// state nvim has — including both the loaded content AND any keys the
339    /// user pressed in the interim. `snap.lines != new_lines` will then
340    /// re-set `dirty=true`. The window where `dirty=false` after a
341    /// concurrent keystroke is bounded by one refresh cycle (~30 ms).
342    /// Do NOT move the `in_flight.store(true)` earlier or clear it
343    /// before the RPC actually completes — both invariants are load-bearing.
344    pub fn set_text(&self, text: &str) {
345        let lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
346
347        {
348            let mut snap = self.snapshot.lock().unwrap_or_else(|p| p.into_inner());
349            snap.lines = if lines.is_empty() {
350                vec![String::new()]
351            } else {
352                lines.clone()
353            };
354            snap.cursor = (0, 0);
355            snap.dirty = false;
356            snap.content_gen = snap.content_gen.wrapping_add(1);
357        }
358
359        let nvim = self.nvim.clone();
360        let is_dead = self.is_dead.clone();
361        let in_flight = self.set_text_in_flight.clone();
362        in_flight.store(true, Ordering::SeqCst);
363        tokio::spawn(async move {
364            let buf = match nvim.get_current_buf().await {
365                Ok(b) => b,
366                Err(e) => {
367                    in_flight.store(false, Ordering::SeqCst);
368                    if e.is_channel_closed() {
369                        is_dead.store(true, Ordering::SeqCst);
370                    }
371                    tracing::warn!("set_text get_current_buf: {e}");
372                    return;
373                }
374            };
375            if let Err(e) = buf.set_lines(0, -1, false, lines).await {
376                tracing::warn!("set_text buf_set_lines: {e}");
377            }
378            in_flight.store(false, Ordering::SeqCst);
379        });
380    }
381
382    /// Notify nvim of a terminal resize, but only when the dimensions actually change.
383    pub fn maybe_resize(&self, width: u16, height: u16) {
384        let mut guard = self.last_ui_size.lock().unwrap_or_else(|p| p.into_inner());
385        if *guard == (width, height) {
386            return;
387        }
388        *guard = (width, height);
389        drop(guard);
390
391        let nvim = self.nvim.clone();
392        let is_dead = self.is_dead.clone();
393        tokio::spawn(async move {
394            if let Err(e) = nvim.ui_try_resize(width as i64, height as i64).await {
395                if e.is_channel_closed() {
396                    is_dead.store(true, Ordering::SeqCst);
397                }
398                tracing::debug!("ui_try_resize error: {e}");
399            }
400        });
401    }
402
403    /// Insert `text` at nvim's current cursor position via `nvim_paste`.
404    /// Honours nvim's current mode (insert/normal/visual) — visual replaces the
405    /// selection, normal/insert insert at cursor — so it works as a drop-in
406    /// for the textarea backend's insert/replace flow.
407    pub fn paste(&self, text: &str, tx: AppTx) {
408        self.ensure_refresh_task(&tx);
409        let nvim = self.nvim.clone();
410        let is_dead = self.is_dead.clone();
411        let key_tx = self.key_tx.clone();
412        let payload = text.to_string();
413        tokio::spawn(async move {
414            // phase = -1 → single-chunk paste (not part of a streamed sequence).
415            match nvim.paste(&payload, false, -1).await {
416                Ok(_) => {
417                    key_tx.send_modify(|v| *v = v.wrapping_add(1));
418                }
419                Err(e) => {
420                    if e.is_channel_closed() {
421                        is_dead.store(true, Ordering::SeqCst);
422                        tx.send(AppEvent::Redraw).ok();
423                    }
424                    tracing::debug!("nvim_paste error: {e}");
425                }
426            }
427        });
428    }
429
430    /// Forward a keystroke to nvim.
431    pub fn handle_key(&self, key: &ratatui::crossterm::event::KeyEvent, tx: AppTx) {
432        self.ensure_refresh_task(&tx);
433
434        let Some(nvim_key) = key_event_to_nvim_string(key) else {
435            tracing::debug!("unmappable key: {key:?}");
436            return;
437        };
438
439        let nvim = self.nvim.clone();
440        let is_dead = self.is_dead.clone();
441        let key_tx = self.key_tx.clone();
442
443        tokio::spawn(async move {
444            match nvim.input(&nvim_key).await {
445                Ok(_) => {
446                    // Signal the refresh task: a key was just sent.
447                    key_tx.send_modify(|v| *v = v.wrapping_add(1));
448                }
449                Err(e) => {
450                    if e.is_channel_closed() {
451                        is_dead.store(true, Ordering::SeqCst);
452                        tx.send(AppEvent::Redraw).ok();
453                    }
454                    tracing::debug!("nvim_input error: {e}");
455                }
456            }
457        });
458    }
459}
460
461// ---------------------------------------------------------------------------
462// Parse the Lua state bundle and apply it to the snapshot.
463// ---------------------------------------------------------------------------
464
465/// Convert a UTF-8 byte offset to a Unicode scalar (char) index.
466///
467/// `nvim_win_get_cursor` and `getpos()` return byte offsets. This converts them
468/// to char indices so the rest of the rendering pipeline can use char-indexed
469/// operations consistently. If the offset falls in the middle of a multi-byte
470/// sequence it is snapped to the nearest valid char boundary.
471fn byte_offset_to_char_idx(line: &str, byte_offset: usize) -> usize {
472    // Walk backward from the offset to the nearest valid char boundary, then
473    // count chars up to that point. Handles mid-codepoint offsets safely.
474    let safe = (0..=byte_offset.min(line.len()))
475        .rev()
476        .find(|&i| line.is_char_boundary(i))
477        .unwrap_or(0);
478    line[..safe].chars().count()
479}
480
481fn apply_lua_state(
482    snapshot: &Arc<Mutex<NvimSnapshot>>,
483    in_flight: &Arc<AtomicBool>,
484    value: nvim_rs::Value,
485) {
486    let Some(arr) = value.as_array() else { return };
487    let mode_str = match arr.first().and_then(|v| v.as_str()) {
488        Some(s) => s,
489        None => return,
490    };
491    let mode = NvimMode::from_nvim_str(mode_str);
492
493    let mut snap = snapshot.lock().unwrap_or_else(|p| p.into_inner());
494
495    if mode == NvimMode::Command {
496        let cmdtype = arr
497            .get(1)
498            .and_then(|v| v.as_str())
499            .unwrap_or("")
500            .to_string();
501        let cmdline = arr
502            .get(2)
503            .and_then(|v| v.as_str())
504            .unwrap_or("")
505            .to_string();
506        snap.mode = mode;
507        snap.cmdline = Some(format!("{cmdtype}{cmdline}"));
508        return;
509    }
510
511    // Lines.
512    let new_lines: Vec<String> = arr
513        .get(1)
514        .and_then(|v| v.as_array())
515        .map(|ls| {
516            ls.iter()
517                .filter_map(|l| l.as_str().map(|s| s.to_string()))
518                .collect()
519        })
520        .unwrap_or_default();
521    let new_lines = if new_lines.is_empty() {
522        vec![String::new()]
523    } else {
524        new_lines
525    };
526
527    // Cursor: nvim_win_get_cursor → [row(1-indexed), col(0-indexed byte offset)].
528    // Convert the byte offset to a char index so all downstream code works in
529    // char-index space uniformly (independent of multi-byte character widths).
530    let cursor = arr
531        .get(2)
532        .and_then(|v| v.as_array())
533        .and_then(|c| {
534            let row = c.first()?.as_u64()? as usize;
535            let byte_col = c.get(1)?.as_u64()? as usize;
536            let row0 = row.saturating_sub(1);
537            let char_col = new_lines
538                .get(row0)
539                .map(|line| byte_offset_to_char_idx(line, byte_col))
540                .unwrap_or(byte_col);
541            Some((row0, char_col))
542        })
543        .unwrap_or((0, 0));
544
545    // Visual selection: getpos("v") → [bufnum, lnum(1-indexed), col(1-indexed byte offset), off].
546    // Convert the 1-indexed byte col to a 0-indexed char index.
547    let visual_selection = if matches!(mode, NvimMode::Visual | NvimMode::VisualLine) {
548        arr.get(3)
549            .and_then(|v| v.as_array())
550            .and_then(|p| {
551                let lnum = p.get(1)?.as_u64()? as usize;
552                let vcol_byte = p.get(2)?.as_u64()? as usize;
553                if lnum == 0 {
554                    return None;
555                }
556                let row0 = lnum.saturating_sub(1);
557                let char_col = new_lines
558                    .get(row0)
559                    .map(|line| byte_offset_to_char_idx(line, vcol_byte.saturating_sub(1)))
560                    .unwrap_or(vcol_byte.saturating_sub(1));
561                Some((row0, char_col))
562            })
563            .map(|anchor| {
564                let (mut start, mut end) = if anchor <= cursor {
565                    (anchor, cursor)
566                } else {
567                    (cursor, anchor)
568                };
569                if mode == NvimMode::VisualLine {
570                    start.1 = 0;
571                    end.1 = usize::MAX;
572                }
573                (start, end)
574            })
575    } else {
576        None
577    };
578
579    if new_lines != snap.lines && !in_flight.load(Ordering::SeqCst) {
580        snap.dirty = true;
581        snap.lines = new_lines;
582        snap.content_gen = snap.content_gen.wrapping_add(1);
583    }
584    snap.cursor = cursor;
585    snap.mode = mode;
586    snap.cmdline = None;
587    snap.visual_selection = visual_selection;
588}