kimun_notes/components/text_editor/
backend.rs1use 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
20const 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#[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#[allow(clippy::large_enum_variant)]
75pub enum BackendState {
76 Textarea(TextArea<'static>),
77 Nvim(NvimBackend),
78}
79
80impl BackendState {
81 pub fn is_textarea(&self) -> bool {
84 matches!(self, BackendState::Textarea(_))
85 }
86
87 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 pub fn as_nvim(&self) -> Option<&NvimBackend> {
105 match self {
106 BackendState::Textarea(_) => None,
107 BackendState::Nvim(nvim) => Some(nvim),
108 }
109 }
110
111 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 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 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
162pub struct NvimBackend {
167 nvim: NvimClient,
168 snapshot: Arc<Mutex<NvimSnapshot>>,
169 is_dead: Arc<AtomicBool>,
170 set_text_in_flight: Arc<AtomicBool>,
174 flush_rx: tokio::sync::watch::Receiver<u64>,
176 key_tx: tokio::sync::watch::Sender<u64>,
179 pending_key_rx: Mutex<Option<tokio::sync::watch::Receiver<u64>>>,
181 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 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 pub fn snapshot(&self) -> std::sync::MutexGuard<'_, NvimSnapshot> {
203 self.snapshot.lock().unwrap_or_else(|p| p.into_inner())
204 }
205
206 pub fn is_dead(&self) -> bool {
209 self.is_dead.load(std::sync::atomic::Ordering::SeqCst)
210 }
211
212 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 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 tokio::select! {
289 res = flush_rx.changed() => {
290 if res.is_err() {
291 is_dead.store(true, Ordering::SeqCst);
293 tx.send(AppEvent::Redraw).ok();
294 break;
295 }
296 }
298 res = key_rx.changed() => {
299 if res.is_err() { break; }
300 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 tracing::debug!("exec_lua error: {e}");
322 }
323 }
324 }
325 });
326 }
327
328 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 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 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 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 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 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
461fn byte_offset_to_char_idx(line: &str, byte_offset: usize) -> usize {
472 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 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 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 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}