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_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
22const 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#[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#[derive(Debug, Default)]
80pub enum InputInterpreter {
81 #[default]
83 Direct,
84 Vim(Box<VimEngine>),
86}
87
88#[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#[allow(clippy::large_enum_variant)]
115pub enum BackendState {
116 Textarea(TextareaBackend),
117 Nvim(NvimBackend),
118}
119
120impl BackendState {
121 pub fn is_textarea(&self) -> bool {
124 matches!(self, BackendState::Textarea(_))
125 }
126
127 pub fn is_vim(&self) -> bool {
129 matches!(
130 self,
131 BackendState::Textarea(TextareaBackend {
132 input: InputInterpreter::Vim(_),
133 ..
134 })
135 )
136 }
137
138 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 pub fn as_nvim(&self) -> Option<&NvimBackend> {
156 match self {
157 BackendState::Textarea(_) => None,
158 BackendState::Nvim(nvim) => Some(nvim),
159 }
160 }
161
162 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 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 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 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 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 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 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 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 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 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 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 EditorBackendSetting::Textarea | EditorBackendSetting::Nvim => {
317 TextareaBackend::direct(TextArea::default())
318 }
319 };
320 BackendState::Textarea(tb)
321 }
322}
323
324pub struct NvimBackend {
329 nvim: NvimClient,
330 snapshot: Arc<Mutex<NvimSnapshot>>,
331 is_dead: Arc<AtomicBool>,
332 set_text_in_flight: Arc<AtomicBool>,
336 flush_rx: tokio::sync::watch::Receiver<u64>,
338 key_tx: tokio::sync::watch::Sender<u64>,
341 pending_key_rx: Mutex<Option<tokio::sync::watch::Receiver<u64>>>,
343 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 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 pub fn snapshot(&self) -> std::sync::MutexGuard<'_, NvimSnapshot> {
365 self.snapshot.lock().unwrap_or_else(|p| p.into_inner())
366 }
367
368 pub fn is_dead(&self) -> bool {
371 self.is_dead.load(std::sync::atomic::Ordering::SeqCst)
372 }
373
374 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 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 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 tokio::select! {
455 res = flush_rx.changed() => {
456 if res.is_err() {
457 is_dead.store(true, Ordering::SeqCst);
459 tx.send(AppEvent::Redraw).ok();
460 break;
461 }
462 }
464 res = key_rx.changed() => {
465 if res.is_err() { break; }
466 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 tracing::debug!("exec_lua error: {e}");
488 }
489 }
490 }
491 });
492 }
493
494 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 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 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 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 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 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
627fn 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#[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 assert_eq!(
703 BackendState::Textarea(TextareaBackend::direct(TextArea::default())).modal_is_insert(),
704 None
705 );
706 assert_eq!(
708 BackendState::Textarea(TextareaBackend::vim(TextArea::default())).modal_is_insert(),
709 Some(false)
710 );
711 }
712}