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 vim_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 vim_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 vim_is_charwise_visual(&self) -> bool {
226 matches!(self,
227 BackendState::Textarea(TextareaBackend { input: InputInterpreter::Vim(e), .. })
228 if *e.mode() == EditorMode::Visual)
229 }
230
231 pub fn vim_reset_to_normal(&mut self) {
234 if let BackendState::Textarea(TextareaBackend {
235 input: InputInterpreter::Vim(engine),
236 ..
237 }) = self
238 {
239 engine.reset_to_normal();
240 }
241 }
242
243 pub fn vim_handle_key(
246 &mut self,
247 key: &ratatui::crossterm::event::KeyEvent,
248 ) -> Option<super::vim::VimKeyOutcome> {
249 match self {
250 BackendState::Textarea(TextareaBackend {
251 ta,
252 input: InputInterpreter::Vim(engine),
253 }) => Some(engine.handle_key(key, ta)),
254 _ => None,
255 }
256 }
257
258 pub fn vim_pending_hint(&self) -> Option<String> {
262 match self {
263 BackendState::Textarea(TextareaBackend {
264 input: InputInterpreter::Vim(e),
265 ..
266 }) => e.pending_hint(),
267 _ => None,
268 }
269 }
270
271 pub fn mode_label(&self) -> Option<String> {
274 match self {
275 BackendState::Textarea(TextareaBackend {
276 input: InputInterpreter::Vim(engine),
277 ..
278 }) => Some(engine.mode_label()),
279 BackendState::Textarea(_) => None,
280 BackendState::Nvim(nvim) => Some(nvim.snapshot().footer_label()),
281 }
282 }
283
284 pub fn modal_is_insert(&self) -> Option<bool> {
289 match self {
290 BackendState::Textarea(TextareaBackend {
291 input: InputInterpreter::Vim(e),
292 ..
293 }) => Some(*e.mode() == EditorMode::Insert),
294 BackendState::Textarea(_) => None,
295 BackendState::Nvim(nvim) => Some(nvim.snapshot().mode == EditorMode::Insert),
296 }
297 }
298
299 pub fn from_settings(
300 editor_backend: &EditorBackendSetting,
301 nvim_path: Option<&PathBuf>,
302 ) -> Self {
303 if matches!(editor_backend, EditorBackendSetting::Nvim) {
304 match NvimBackend::new(nvim_path) {
305 Ok(backend) => return BackendState::Nvim(backend),
306 Err(e) => {
307 tracing::warn!("nvim backend unavailable, falling back to textarea: {e}")
308 }
309 }
310 }
311 let tb = match editor_backend {
312 EditorBackendSetting::Vim => TextareaBackend::vim(TextArea::default()),
313 EditorBackendSetting::Textarea | EditorBackendSetting::Nvim => {
316 TextareaBackend::direct(TextArea::default())
317 }
318 };
319 BackendState::Textarea(tb)
320 }
321}
322
323pub struct NvimBackend {
328 nvim: NvimClient,
329 snapshot: Arc<Mutex<NvimSnapshot>>,
330 is_dead: Arc<AtomicBool>,
331 set_text_in_flight: Arc<AtomicBool>,
335 flush_rx: tokio::sync::watch::Receiver<u64>,
337 key_tx: tokio::sync::watch::Sender<u64>,
340 pending_key_rx: Mutex<Option<tokio::sync::watch::Receiver<u64>>>,
342 last_ui_size: Mutex<(u16, u16)>,
345 io_handle: tokio::task::JoinHandle<Result<(), Box<LoopError>>>,
346 child: Option<tokio::process::Child>,
347}
348
349impl Drop for NvimBackend {
350 fn drop(&mut self) {
351 self.io_handle.abort();
354 if let Some(ref mut child) = self.child {
355 let _ = child.start_kill();
356 }
357 }
358}
359
360impl NvimBackend {
361 pub fn snapshot(&self) -> std::sync::MutexGuard<'_, NvimSnapshot> {
364 self.snapshot.lock().unwrap_or_else(|p| p.into_inner())
365 }
366
367 pub fn is_dead(&self) -> bool {
370 self.is_dead.load(std::sync::atomic::Ordering::SeqCst)
371 }
372
373 pub fn mark_clean(&self) {
375 self.snapshot().dirty = false;
376 }
377
378 pub fn new(nvim_path: Option<&PathBuf>) -> Result<Self, String> {
379 tokio::task::block_in_place(|| {
380 tokio::runtime::Handle::current().block_on(Self::new_async(nvim_path))
381 })
382 }
383
384 async fn new_async(nvim_path: Option<&PathBuf>) -> Result<Self, String> {
385 let binary = nvim_path
386 .map(|p| p.to_string_lossy().into_owned())
387 .unwrap_or_else(|| "nvim".to_string());
388
389 let (flush_tx, flush_rx) = tokio::sync::watch::channel(0u64);
390 let (key_tx, key_rx) = tokio::sync::watch::channel(0u64);
391 let handler = NvimHandler { flush_tx };
392
393 let mut cmd = tokio::process::Command::new(&binary);
394 cmd.arg("--embed").stderr(std::process::Stdio::null());
395
396 let (nvim, io_handle, child) = new_child_cmd(&mut cmd, handler)
397 .await
398 .map_err(|e| format!("failed to spawn {binary}: {e}"))?;
399
400 let mut ui_opts = UiAttachOptions::new();
401 ui_opts.set_rgb(false);
402 nvim.ui_attach(80, 24, &ui_opts)
403 .await
404 .map_err(|e| format!("nvim_ui_attach failed: {e}"))?;
405
406 let _ = nvim.command("set noswapfile").await;
407 let _ = nvim.command("set buftype=nofile").await;
408 let _ = nvim.command("set nomodeline").await;
409 let _ = nvim.command("set expandtab").await;
410 let _ = nvim
413 .command(&format!("set tabstop={}", super::markdown::TAB_STOP))
414 .await;
415
416 Ok(Self {
417 nvim,
418 snapshot: Arc::new(Mutex::new(NvimSnapshot::default())),
419 is_dead: Arc::new(AtomicBool::new(false)),
420 set_text_in_flight: Arc::new(AtomicBool::new(false)),
421 flush_rx,
422 key_tx,
423 pending_key_rx: Mutex::new(Some(key_rx)),
424 last_ui_size: Mutex::new((80, 24)),
425 io_handle,
426 child: Some(child),
427 })
428 }
429
430 fn ensure_refresh_task(&self, tx: &AppTx) {
432 let mut guard = self
433 .pending_key_rx
434 .lock()
435 .unwrap_or_else(|p| p.into_inner());
436 let Some(key_rx) = guard.take() else { return };
437
438 let nvim = self.nvim.clone();
439 let snapshot = self.snapshot.clone();
440 let is_dead = self.is_dead.clone();
441 let in_flight = self.set_text_in_flight.clone();
442 let flush_rx = self.flush_rx.clone();
443 let tx = tx.clone();
444
445 tokio::spawn(async move {
446 let mut key_rx = key_rx;
447 let mut flush_rx = flush_rx;
448
449 loop {
450 tokio::select! {
454 res = flush_rx.changed() => {
455 if res.is_err() {
456 is_dead.store(true, Ordering::SeqCst);
458 tx.send(AppEvent::Redraw).ok();
459 break;
460 }
461 }
463 res = key_rx.changed() => {
464 if res.is_err() { break; }
465 tokio::time::timeout(
468 Duration::from_millis(30),
469 flush_rx.changed(),
470 ).await.ok();
471 }
472 }
473
474 match nvim.exec_lua(STATE_QUERY_LUA, vec![]).await {
475 Ok(value) => {
476 apply_lua_state(&snapshot, &in_flight, value);
477 tx.send(AppEvent::Redraw).ok();
478 }
479 Err(e) => {
480 if e.is_channel_closed() {
481 is_dead.store(true, Ordering::SeqCst);
482 tx.send(AppEvent::Redraw).ok();
483 break;
484 }
485 tracing::debug!("exec_lua error: {e}");
487 }
488 }
489 }
490 });
491 }
492
493 pub fn set_text(&self, text: &str) {
510 let lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
511
512 {
513 let mut snap = self.snapshot.lock().unwrap_or_else(|p| p.into_inner());
514 snap.lines = if lines.is_empty() {
515 vec![String::new()]
516 } else {
517 lines.clone()
518 };
519 snap.cursor = (0, 0);
520 snap.dirty = false;
521 snap.content_gen = snap.content_gen.wrapping_add(1);
522 }
523
524 let nvim = self.nvim.clone();
525 let is_dead = self.is_dead.clone();
526 let in_flight = self.set_text_in_flight.clone();
527 in_flight.store(true, Ordering::SeqCst);
528 tokio::spawn(async move {
529 let buf = match nvim.get_current_buf().await {
530 Ok(b) => b,
531 Err(e) => {
532 in_flight.store(false, Ordering::SeqCst);
533 if e.is_channel_closed() {
534 is_dead.store(true, Ordering::SeqCst);
535 }
536 tracing::warn!("set_text get_current_buf: {e}");
537 return;
538 }
539 };
540 if let Err(e) = buf.set_lines(0, -1, false, lines).await {
541 tracing::warn!("set_text buf_set_lines: {e}");
542 }
543 in_flight.store(false, Ordering::SeqCst);
544 });
545 }
546
547 pub fn maybe_resize(&self, width: u16, height: u16) {
549 let mut guard = self.last_ui_size.lock().unwrap_or_else(|p| p.into_inner());
550 if *guard == (width, height) {
551 return;
552 }
553 *guard = (width, height);
554 drop(guard);
555
556 let nvim = self.nvim.clone();
557 let is_dead = self.is_dead.clone();
558 tokio::spawn(async move {
559 if let Err(e) = nvim.ui_try_resize(width as i64, height as i64).await {
560 if e.is_channel_closed() {
561 is_dead.store(true, Ordering::SeqCst);
562 }
563 tracing::debug!("ui_try_resize error: {e}");
564 }
565 });
566 }
567
568 pub fn paste(&self, text: &str, tx: AppTx) {
573 self.ensure_refresh_task(&tx);
574 let nvim = self.nvim.clone();
575 let is_dead = self.is_dead.clone();
576 let key_tx = self.key_tx.clone();
577 let payload = text.to_string();
578 tokio::spawn(async move {
579 match nvim.paste(&payload, false, -1).await {
581 Ok(_) => {
582 key_tx.send_modify(|v| *v = v.wrapping_add(1));
583 }
584 Err(e) => {
585 if e.is_channel_closed() {
586 is_dead.store(true, Ordering::SeqCst);
587 tx.send(AppEvent::Redraw).ok();
588 }
589 tracing::debug!("nvim_paste error: {e}");
590 }
591 }
592 });
593 }
594
595 pub fn handle_key(&self, key: &ratatui::crossterm::event::KeyEvent, tx: AppTx) {
597 self.ensure_refresh_task(&tx);
598
599 let Some(nvim_key) = key_event_to_nvim_string(key) else {
600 tracing::debug!("unmappable key: {key:?}");
601 return;
602 };
603
604 let nvim = self.nvim.clone();
605 let is_dead = self.is_dead.clone();
606 let key_tx = self.key_tx.clone();
607
608 tokio::spawn(async move {
609 match nvim.input(&nvim_key).await {
610 Ok(_) => {
611 key_tx.send_modify(|v| *v = v.wrapping_add(1));
613 }
614 Err(e) => {
615 if e.is_channel_closed() {
616 is_dead.store(true, Ordering::SeqCst);
617 tx.send(AppEvent::Redraw).ok();
618 }
619 tracing::debug!("nvim_input error: {e}");
620 }
621 }
622 });
623 }
624}
625
626fn apply_lua_state(
635 snapshot: &Arc<Mutex<NvimSnapshot>>,
636 in_flight: &Arc<AtomicBool>,
637 value: nvim_rs::Value,
638) {
639 let Some(decoded) = decode(&value) else {
640 return;
641 };
642
643 let mut snap = snapshot.lock().unwrap_or_else(|p| p.into_inner());
644
645 match decoded {
646 DecodedState::Command { cmdline } => {
647 snap.mode = EditorMode::Command;
648 snap.cmdline = Some(cmdline);
649 }
650 DecodedState::Content {
651 mode,
652 lines,
653 cursor,
654 visual_selection,
655 } => {
656 if lines != snap.lines && !in_flight.load(Ordering::SeqCst) {
657 snap.dirty = true;
658 snap.lines = lines;
659 snap.content_gen = snap.content_gen.wrapping_add(1);
660 }
661 snap.cursor = cursor;
662 snap.mode = mode;
663 snap.cmdline = None;
664 snap.visual_selection = visual_selection;
665 }
666 }
667}
668
669#[cfg(test)]
674mod tests {
675 use super::*;
676 use ratatui_textarea::TextArea;
677
678 #[test]
679 fn direct_backend_has_no_mode_label() {
680 let b = BackendState::Textarea(TextareaBackend::direct(TextArea::default()));
681 assert_eq!(b.mode_label(), None);
682 }
683
684 #[test]
685 fn vim_backend_reports_normal_label() {
686 let b = BackendState::Textarea(TextareaBackend::vim(TextArea::default()));
687 assert_eq!(b.mode_label().as_deref(), Some("NORMAL"));
688 }
689
690 #[test]
691 fn vim_space_leads_only_for_vim_backend() {
692 assert!(
693 !BackendState::Textarea(TextareaBackend::direct(TextArea::default())).vim_space_leads()
694 );
695 assert!(
696 BackendState::Textarea(TextareaBackend::vim(TextArea::default())).vim_space_leads()
697 );
698 }
699
700 #[test]
701 fn modal_is_insert_classifies_backends() {
702 assert_eq!(
704 BackendState::Textarea(TextareaBackend::direct(TextArea::default())).modal_is_insert(),
705 None
706 );
707 assert_eq!(
709 BackendState::Textarea(TextareaBackend::vim(TextArea::default())).modal_is_insert(),
710 Some(false)
711 );
712 }
713}