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    pub fn from_settings(
82        editor_backend: &EditorBackendSetting,
83        nvim_path: Option<&PathBuf>,
84    ) -> Self {
85        if matches!(editor_backend, EditorBackendSetting::Nvim) {
86            match NvimBackend::new(nvim_path) {
87                Ok(backend) => return BackendState::Nvim(backend),
88                Err(e) => {
89                    tracing::warn!("nvim backend unavailable, falling back to textarea: {e}")
90                }
91            }
92        }
93        BackendState::Textarea(TextArea::default())
94    }
95}
96
97// ---------------------------------------------------------------------------
98// NvimBackend
99// ---------------------------------------------------------------------------
100
101pub struct NvimBackend {
102    pub nvim: NvimClient,
103    pub snapshot: Arc<Mutex<NvimSnapshot>>,
104    pub is_dead: Arc<AtomicBool>,
105    /// Set while a `buf_set_lines` call spawned by `set_text` is in flight.
106    /// The refresh task skips line/dirty updates while this is `true` to avoid
107    /// overwriting the pre-populated snapshot with stale nvim state.
108    set_text_in_flight: Arc<AtomicBool>,
109    /// Incremented by the handler on every flush event.
110    flush_rx: tokio::sync::watch::Receiver<u64>,
111    /// Incremented by handle_key after each successful nvim_input call.
112    /// Gives the refresh task a wakeup path even when nvim doesn't send flush.
113    key_tx: tokio::sync::watch::Sender<u64>,
114    /// Stored until the refresh task is started on the first handle_key call.
115    pending_key_rx: Mutex<Option<tokio::sync::watch::Receiver<u64>>>,
116    /// Tracks the last size passed to `ui_attach`/`ui_try_resize` so we only
117    /// send a resize RPC when the terminal rect actually changes.
118    pub last_ui_size: Mutex<(u16, u16)>,
119    io_handle: tokio::task::JoinHandle<Result<(), Box<LoopError>>>,
120    child: Option<tokio::process::Child>,
121}
122
123impl Drop for NvimBackend {
124    fn drop(&mut self) {
125        // Abort the IO loop first so it stops sending on flush_tx,
126        // which lets the refresh task's flush_rx.changed() return Err and exit.
127        self.io_handle.abort();
128        if let Some(ref mut child) = self.child {
129            let _ = child.start_kill();
130        }
131    }
132}
133
134impl NvimBackend {
135    pub fn new(nvim_path: Option<&PathBuf>) -> Result<Self, String> {
136        tokio::task::block_in_place(|| {
137            tokio::runtime::Handle::current().block_on(Self::new_async(nvim_path))
138        })
139    }
140
141    async fn new_async(nvim_path: Option<&PathBuf>) -> Result<Self, String> {
142        let binary = nvim_path
143            .map(|p| p.to_string_lossy().into_owned())
144            .unwrap_or_else(|| "nvim".to_string());
145
146        let (flush_tx, flush_rx) = tokio::sync::watch::channel(0u64);
147        let (key_tx, key_rx) = tokio::sync::watch::channel(0u64);
148        let handler = NvimHandler { flush_tx };
149
150        let mut cmd = tokio::process::Command::new(&binary);
151        cmd.arg("--embed").stderr(std::process::Stdio::null());
152
153        let (nvim, io_handle, child) = new_child_cmd(&mut cmd, handler)
154            .await
155            .map_err(|e| format!("failed to spawn {binary}: {e}"))?;
156
157        let mut ui_opts = UiAttachOptions::new();
158        ui_opts.set_rgb(false);
159        nvim.ui_attach(80, 24, &ui_opts)
160            .await
161            .map_err(|e| format!("nvim_ui_attach failed: {e}"))?;
162
163        let _ = nvim.command("set noswapfile").await;
164        let _ = nvim.command("set buftype=nofile").await;
165        let _ = nvim.command("set nomodeline").await;
166        let _ = nvim.command("set expandtab").await;
167        let _ = nvim.command("set tabstop=4").await;
168
169        Ok(Self {
170            nvim,
171            snapshot: Arc::new(Mutex::new(NvimSnapshot::default())),
172            is_dead: Arc::new(AtomicBool::new(false)),
173            set_text_in_flight: Arc::new(AtomicBool::new(false)),
174            flush_rx,
175            key_tx,
176            pending_key_rx: Mutex::new(Some(key_rx)),
177            last_ui_size: Mutex::new((80, 24)),
178            io_handle,
179            child: Some(child),
180        })
181    }
182
183    /// Start the long-running refresh task on the first call; no-op afterwards.
184    fn ensure_refresh_task(&self, tx: &AppTx) {
185        let mut guard = self
186            .pending_key_rx
187            .lock()
188            .unwrap_or_else(|p| p.into_inner());
189        let Some(key_rx) = guard.take() else { return };
190
191        let nvim = self.nvim.clone();
192        let snapshot = self.snapshot.clone();
193        let is_dead = self.is_dead.clone();
194        let in_flight = self.set_text_in_flight.clone();
195        let flush_rx = self.flush_rx.clone();
196        let tx = tx.clone();
197
198        tokio::spawn(async move {
199            let mut key_rx = key_rx;
200            let mut flush_rx = flush_rx;
201
202            loop {
203                // Wake on either:
204                //  • flush event (nvim finished processing input — best path)
205                //  • key signal  (nvim_input returned; give nvim 30 ms to flush first)
206                tokio::select! {
207                    res = flush_rx.changed() => {
208                        if res.is_err() {
209                            // Sender dropped — nvim IO loop ended.
210                            is_dead.store(true, Ordering::SeqCst);
211                            tx.send(AppEvent::Redraw).ok();
212                            break;
213                        }
214                        // Flush arrived — state is fresh, query immediately.
215                    }
216                    res = key_rx.changed() => {
217                        if res.is_err() { break; }
218                        // nvim_input returned. Wait up to 30 ms for flush before
219                        // querying; proceed regardless so nothing is ever stuck.
220                        tokio::time::timeout(
221                            Duration::from_millis(30),
222                            flush_rx.changed(),
223                        ).await.ok();
224                    }
225                }
226
227                match nvim.exec_lua(STATE_QUERY_LUA, vec![]).await {
228                    Ok(value) => {
229                        apply_lua_state(&snapshot, &in_flight, value);
230                        tx.send(AppEvent::Redraw).ok();
231                    }
232                    Err(e) => {
233                        if e.is_channel_closed() {
234                            is_dead.store(true, Ordering::SeqCst);
235                            tx.send(AppEvent::Redraw).ok();
236                            break;
237                        }
238                        // Non-fatal (e.g. transient Lua error): log and continue.
239                        tracing::debug!("exec_lua error: {e}");
240                    }
241                }
242            }
243        });
244    }
245
246    /// Load content into the nvim buffer and pre-populate the snapshot.
247    ///
248    /// Contract: the synchronous snapshot pre-populate (lines + cursor +
249    /// dirty=false + content_gen bump) happens BEFORE `in_flight` is set
250    /// and the buf_set_lines RPC is spawned. A keystroke arriving between
251    /// the synchronous return of `set_text` and the spawned task actually
252    /// reaching nvim ends up routed via `handle_key`, and the refresh task
253    /// will skip snapshot updates while `in_flight=true` (see
254    /// `apply_lua_state`). Once the spawned RPC completes and `in_flight`
255    /// flips back to false, the refresh task will observe whatever buffer
256    /// state nvim has — including both the loaded content AND any keys the
257    /// user pressed in the interim. `snap.lines != new_lines` will then
258    /// re-set `dirty=true`. The window where `dirty=false` after a
259    /// concurrent keystroke is bounded by one refresh cycle (~30 ms).
260    /// Do NOT move the `in_flight.store(true)` earlier or clear it
261    /// before the RPC actually completes — both invariants are load-bearing.
262    pub fn set_text(&self, text: &str) {
263        let lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
264
265        {
266            let mut snap = self.snapshot.lock().unwrap_or_else(|p| p.into_inner());
267            snap.lines = if lines.is_empty() {
268                vec![String::new()]
269            } else {
270                lines.clone()
271            };
272            snap.cursor = (0, 0);
273            snap.dirty = false;
274            snap.content_gen = snap.content_gen.wrapping_add(1);
275        }
276
277        let nvim = self.nvim.clone();
278        let is_dead = self.is_dead.clone();
279        let in_flight = self.set_text_in_flight.clone();
280        in_flight.store(true, Ordering::SeqCst);
281        tokio::spawn(async move {
282            let buf = match nvim.get_current_buf().await {
283                Ok(b) => b,
284                Err(e) => {
285                    in_flight.store(false, Ordering::SeqCst);
286                    if e.is_channel_closed() {
287                        is_dead.store(true, Ordering::SeqCst);
288                    }
289                    tracing::warn!("set_text get_current_buf: {e}");
290                    return;
291                }
292            };
293            if let Err(e) = buf.set_lines(0, -1, false, lines).await {
294                tracing::warn!("set_text buf_set_lines: {e}");
295            }
296            in_flight.store(false, Ordering::SeqCst);
297        });
298    }
299
300    /// Notify nvim of a terminal resize, but only when the dimensions actually change.
301    pub fn maybe_resize(&self, width: u16, height: u16) {
302        let mut guard = self.last_ui_size.lock().unwrap_or_else(|p| p.into_inner());
303        if *guard == (width, height) {
304            return;
305        }
306        *guard = (width, height);
307        drop(guard);
308
309        let nvim = self.nvim.clone();
310        let is_dead = self.is_dead.clone();
311        tokio::spawn(async move {
312            if let Err(e) = nvim.ui_try_resize(width as i64, height as i64).await {
313                if e.is_channel_closed() {
314                    is_dead.store(true, Ordering::SeqCst);
315                }
316                tracing::debug!("ui_try_resize error: {e}");
317            }
318        });
319    }
320
321    /// Insert `text` at nvim's current cursor position via `nvim_paste`.
322    /// Honours nvim's current mode (insert/normal/visual) — visual replaces the
323    /// selection, normal/insert insert at cursor — so it works as a drop-in
324    /// for the textarea backend's insert/replace flow.
325    pub fn paste(&self, text: &str, tx: AppTx) {
326        self.ensure_refresh_task(&tx);
327        let nvim = self.nvim.clone();
328        let is_dead = self.is_dead.clone();
329        let key_tx = self.key_tx.clone();
330        let payload = text.to_string();
331        tokio::spawn(async move {
332            // phase = -1 → single-chunk paste (not part of a streamed sequence).
333            match nvim.paste(&payload, false, -1).await {
334                Ok(_) => {
335                    key_tx.send_modify(|v| *v = v.wrapping_add(1));
336                }
337                Err(e) => {
338                    if e.is_channel_closed() {
339                        is_dead.store(true, Ordering::SeqCst);
340                        tx.send(AppEvent::Redraw).ok();
341                    }
342                    tracing::debug!("nvim_paste error: {e}");
343                }
344            }
345        });
346    }
347
348    /// Forward a keystroke to nvim.
349    pub fn handle_key(&self, key: &ratatui::crossterm::event::KeyEvent, tx: AppTx) {
350        self.ensure_refresh_task(&tx);
351
352        let Some(nvim_key) = key_event_to_nvim_string(key) else {
353            tracing::debug!("unmappable key: {key:?}");
354            return;
355        };
356
357        let nvim = self.nvim.clone();
358        let is_dead = self.is_dead.clone();
359        let key_tx = self.key_tx.clone();
360
361        tokio::spawn(async move {
362            match nvim.input(&nvim_key).await {
363                Ok(_) => {
364                    // Signal the refresh task: a key was just sent.
365                    key_tx.send_modify(|v| *v = v.wrapping_add(1));
366                }
367                Err(e) => {
368                    if e.is_channel_closed() {
369                        is_dead.store(true, Ordering::SeqCst);
370                        tx.send(AppEvent::Redraw).ok();
371                    }
372                    tracing::debug!("nvim_input error: {e}");
373                }
374            }
375        });
376    }
377}
378
379// ---------------------------------------------------------------------------
380// Parse the Lua state bundle and apply it to the snapshot.
381// ---------------------------------------------------------------------------
382
383/// Convert a UTF-8 byte offset to a Unicode scalar (char) index.
384///
385/// `nvim_win_get_cursor` and `getpos()` return byte offsets. This converts them
386/// to char indices so the rest of the rendering pipeline can use char-indexed
387/// operations consistently. If the offset falls in the middle of a multi-byte
388/// sequence it is snapped to the nearest valid char boundary.
389fn byte_offset_to_char_idx(line: &str, byte_offset: usize) -> usize {
390    // Walk backward from the offset to the nearest valid char boundary, then
391    // count chars up to that point. Handles mid-codepoint offsets safely.
392    let safe = (0..=byte_offset.min(line.len()))
393        .rev()
394        .find(|&i| line.is_char_boundary(i))
395        .unwrap_or(0);
396    line[..safe].chars().count()
397}
398
399fn apply_lua_state(
400    snapshot: &Arc<Mutex<NvimSnapshot>>,
401    in_flight: &Arc<AtomicBool>,
402    value: nvim_rs::Value,
403) {
404    let Some(arr) = value.as_array() else { return };
405    let mode_str = match arr.first().and_then(|v| v.as_str()) {
406        Some(s) => s,
407        None => return,
408    };
409    let mode = NvimMode::from_nvim_str(mode_str);
410
411    let mut snap = snapshot.lock().unwrap_or_else(|p| p.into_inner());
412
413    if mode == NvimMode::Command {
414        let cmdtype = arr
415            .get(1)
416            .and_then(|v| v.as_str())
417            .unwrap_or("")
418            .to_string();
419        let cmdline = arr
420            .get(2)
421            .and_then(|v| v.as_str())
422            .unwrap_or("")
423            .to_string();
424        snap.mode = mode;
425        snap.cmdline = Some(format!("{cmdtype}{cmdline}"));
426        return;
427    }
428
429    // Lines.
430    let new_lines: Vec<String> = arr
431        .get(1)
432        .and_then(|v| v.as_array())
433        .map(|ls| {
434            ls.iter()
435                .filter_map(|l| l.as_str().map(|s| s.to_string()))
436                .collect()
437        })
438        .unwrap_or_default();
439    let new_lines = if new_lines.is_empty() {
440        vec![String::new()]
441    } else {
442        new_lines
443    };
444
445    // Cursor: nvim_win_get_cursor → [row(1-indexed), col(0-indexed byte offset)].
446    // Convert the byte offset to a char index so all downstream code works in
447    // char-index space uniformly (independent of multi-byte character widths).
448    let cursor = arr
449        .get(2)
450        .and_then(|v| v.as_array())
451        .and_then(|c| {
452            let row = c.first()?.as_u64()? as usize;
453            let byte_col = c.get(1)?.as_u64()? as usize;
454            let row0 = row.saturating_sub(1);
455            let char_col = new_lines
456                .get(row0)
457                .map(|line| byte_offset_to_char_idx(line, byte_col))
458                .unwrap_or(byte_col);
459            Some((row0, char_col))
460        })
461        .unwrap_or((0, 0));
462
463    // Visual selection: getpos("v") → [bufnum, lnum(1-indexed), col(1-indexed byte offset), off].
464    // Convert the 1-indexed byte col to a 0-indexed char index.
465    let visual_selection = if matches!(mode, NvimMode::Visual | NvimMode::VisualLine) {
466        arr.get(3)
467            .and_then(|v| v.as_array())
468            .and_then(|p| {
469                let lnum = p.get(1)?.as_u64()? as usize;
470                let vcol_byte = p.get(2)?.as_u64()? as usize;
471                if lnum == 0 {
472                    return None;
473                }
474                let row0 = lnum.saturating_sub(1);
475                let char_col = new_lines
476                    .get(row0)
477                    .map(|line| byte_offset_to_char_idx(line, vcol_byte.saturating_sub(1)))
478                    .unwrap_or(vcol_byte.saturating_sub(1));
479                Some((row0, char_col))
480            })
481            .map(|anchor| {
482                let (mut start, mut end) = if anchor <= cursor {
483                    (anchor, cursor)
484                } else {
485                    (cursor, anchor)
486                };
487                if mode == NvimMode::VisualLine {
488                    start.1 = 0;
489                    end.1 = usize::MAX;
490                }
491                (start, end)
492            })
493    } else {
494        None
495    };
496
497    if new_lines != snap.lines && !in_flight.load(Ordering::SeqCst) {
498        snap.dirty = true;
499        snap.lines = new_lines;
500        snap.content_gen = snap.content_gen.wrapping_add(1);
501    }
502    snap.cursor = cursor;
503    snap.mode = mode;
504    snap.cmdline = None;
505    snap.visual_selection = visual_selection;
506}