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::{EditorMode, NvimSnapshot};
14use super::vim::VimEngine;
15use crate::components::events::{AppEvent, AppTx};
16use crate::settings::EditorBackendSetting;
17
18type NvimWriter = Compat<ChildStdin>;
19type NvimClient = Neovim<NvimWriter>;
20
21const STATE_QUERY_LUA: &str = r#"
28local m = vim.api.nvim_get_mode().mode
29if m == 'c' then
30 return {m, vim.fn.getcmdtype(), vim.fn.getcmdline()}
31else
32 local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
33 local cursor = vim.api.nvim_win_get_cursor(0)
34 local vpos = vim.fn.getpos('v')
35 return {m, lines, cursor, vpos}
36end
37"#;
38
39#[derive(Clone)]
44struct NvimHandler {
45 flush_tx: tokio::sync::watch::Sender<u64>,
46}
47
48#[async_trait::async_trait]
49impl Handler for NvimHandler {
50 type Writer = NvimWriter;
51
52 async fn handle_notify(&self, name: String, args: Vec<nvim_rs::Value>, _neovim: NvimClient) {
53 if name != "redraw" {
54 return;
55 }
56 for arg in &args {
57 if let Some(events) = arg.as_array() {
58 for event in events {
59 if let Some(ea) = event.as_array()
60 && ea.first().and_then(|v| v.as_str()) == Some("flush")
61 {
62 self.flush_tx.send_modify(|v| *v = v.wrapping_add(1));
63 return;
64 }
65 }
66 }
67 }
68 }
69}
70
71#[derive(Debug, Default)]
79pub enum InputInterpreter {
80 #[default]
82 Direct,
83 Vim(Box<VimEngine>),
85}
86
87#[derive(Debug)]
89pub struct TextareaBackend {
90 pub ta: TextArea<'static>,
91 pub input: InputInterpreter,
92}
93
94impl TextareaBackend {
95 pub fn direct(ta: TextArea<'static>) -> Self {
96 Self {
97 ta,
98 input: InputInterpreter::Direct,
99 }
100 }
101 pub fn vim(ta: TextArea<'static>) -> Self {
102 Self {
103 ta,
104 input: InputInterpreter::Vim(Box::default()),
105 }
106 }
107}
108
109#[allow(clippy::large_enum_variant)]
114pub enum BackendState {
115 Textarea(TextareaBackend),
116 Nvim(NvimBackend),
117}
118
119impl BackendState {
120 pub fn is_textarea(&self) -> bool {
123 matches!(self, BackendState::Textarea(_))
124 }
125
126 pub fn is_vim(&self) -> bool {
128 matches!(
129 self,
130 BackendState::Textarea(TextareaBackend {
131 input: InputInterpreter::Vim(_),
132 ..
133 })
134 )
135 }
136
137 pub fn as_textarea(&self) -> Option<&TextArea<'static>> {
140 match self {
141 BackendState::Textarea(tb) => Some(&tb.ta),
142 BackendState::Nvim(_) => None,
143 }
144 }
145
146 pub fn as_textarea_mut(&mut self) -> Option<&mut TextArea<'static>> {
147 match self {
148 BackendState::Textarea(tb) => Some(&mut tb.ta),
149 BackendState::Nvim(_) => None,
150 }
151 }
152
153 pub fn as_nvim(&self) -> Option<&NvimBackend> {
155 match self {
156 BackendState::Textarea(_) => None,
157 BackendState::Nvim(nvim) => Some(nvim),
158 }
159 }
160
161 pub fn text(&self) -> String {
163 match self {
164 BackendState::Textarea(tb) => tb.ta.lines().join("\n"),
165 BackendState::Nvim(nvim) => nvim.snapshot().lines.join("\n"),
166 }
167 }
168
169 pub fn cursor(&self) -> (usize, usize) {
173 match self {
174 BackendState::Textarea(tb) => super::cursor_tuple(&tb.ta),
175 BackendState::Nvim(nvim) => {
176 let snap = nvim.snapshot();
177 let max_row = snap.lines.len().saturating_sub(1);
178 (snap.cursor.0.min(max_row), snap.cursor.1)
179 }
180 }
181 }
182
183 pub fn recover_from_dead_nvim(&mut self) -> bool {
187 let fallback_text = match self.as_nvim() {
188 Some(nvim) if nvim.is_dead() => nvim.snapshot().lines.join("\n"),
189 _ => return false,
190 };
191 tracing::warn!("nvim process died; falling back to textarea backend");
192 *self = BackendState::Textarea(TextareaBackend::direct(TextArea::from(
193 fallback_text.lines(),
194 )));
195 true
196 }
197
198 pub fn vim_sync_mouse_selection(&mut self, has_selection: bool) {
203 if let BackendState::Textarea(TextareaBackend {
204 input: InputInterpreter::Vim(e),
205 ..
206 }) = self
207 {
208 e.sync_mouse_selection(has_selection);
209 }
210 }
211
212 pub fn vim_space_leads(&self) -> bool {
216 matches!(self,
217 BackendState::Textarea(TextareaBackend { input: InputInterpreter::Vim(e), .. })
218 if e.space_leads())
219 }
220
221 pub fn vim_is_charwise_visual(&self) -> bool {
225 matches!(self,
226 BackendState::Textarea(TextareaBackend { input: InputInterpreter::Vim(e), .. })
227 if *e.mode() == EditorMode::Visual)
228 }
229
230 pub fn vim_reset_to_normal(&mut self) {
233 if let BackendState::Textarea(TextareaBackend {
234 input: InputInterpreter::Vim(engine),
235 ..
236 }) = self
237 {
238 engine.reset_to_normal();
239 }
240 }
241
242 pub fn vim_handle_key(
245 &mut self,
246 key: &ratatui::crossterm::event::KeyEvent,
247 ) -> Option<super::vim::VimKeyOutcome> {
248 match self {
249 BackendState::Textarea(TextareaBackend {
250 ta,
251 input: InputInterpreter::Vim(engine),
252 }) => Some(engine.handle_key(key, ta)),
253 _ => None,
254 }
255 }
256
257 pub fn vim_pending_hint(&self) -> Option<String> {
261 match self {
262 BackendState::Textarea(TextareaBackend {
263 input: InputInterpreter::Vim(e),
264 ..
265 }) => e.pending_hint(),
266 _ => None,
267 }
268 }
269
270 pub fn mode_label(&self) -> Option<String> {
273 match self {
274 BackendState::Textarea(TextareaBackend {
275 input: InputInterpreter::Vim(engine),
276 ..
277 }) => Some(engine.mode_label()),
278 BackendState::Textarea(_) => None,
279 BackendState::Nvim(nvim) => Some(nvim.snapshot().footer_label()),
280 }
281 }
282
283 pub fn modal_is_insert(&self) -> Option<bool> {
288 match self {
289 BackendState::Textarea(TextareaBackend {
290 input: InputInterpreter::Vim(e),
291 ..
292 }) => Some(*e.mode() == EditorMode::Insert),
293 BackendState::Textarea(_) => None,
294 BackendState::Nvim(nvim) => Some(nvim.snapshot().mode == EditorMode::Insert),
295 }
296 }
297
298 pub fn from_settings(
299 editor_backend: &EditorBackendSetting,
300 nvim_path: Option<&PathBuf>,
301 ) -> Self {
302 if matches!(editor_backend, EditorBackendSetting::Nvim) {
303 match NvimBackend::new(nvim_path) {
304 Ok(backend) => return BackendState::Nvim(backend),
305 Err(e) => {
306 tracing::warn!("nvim backend unavailable, falling back to textarea: {e}")
307 }
308 }
309 }
310 let tb = match editor_backend {
311 EditorBackendSetting::Vim => TextareaBackend::vim(TextArea::default()),
312 EditorBackendSetting::Textarea | EditorBackendSetting::Nvim => {
315 TextareaBackend::direct(TextArea::default())
316 }
317 };
318 BackendState::Textarea(tb)
319 }
320}
321
322pub struct NvimBackend {
327 nvim: NvimClient,
328 snapshot: Arc<Mutex<NvimSnapshot>>,
329 is_dead: Arc<AtomicBool>,
330 set_text_in_flight: Arc<AtomicBool>,
334 flush_rx: tokio::sync::watch::Receiver<u64>,
336 key_tx: tokio::sync::watch::Sender<u64>,
339 pending_key_rx: Mutex<Option<tokio::sync::watch::Receiver<u64>>>,
341 last_ui_size: Mutex<(u16, u16)>,
344 io_handle: tokio::task::JoinHandle<Result<(), Box<LoopError>>>,
345 child: Option<tokio::process::Child>,
346}
347
348impl Drop for NvimBackend {
349 fn drop(&mut self) {
350 self.io_handle.abort();
353 if let Some(ref mut child) = self.child {
354 let _ = child.start_kill();
355 }
356 }
357}
358
359impl NvimBackend {
360 pub fn snapshot(&self) -> std::sync::MutexGuard<'_, NvimSnapshot> {
363 self.snapshot.lock().unwrap_or_else(|p| p.into_inner())
364 }
365
366 pub fn is_dead(&self) -> bool {
369 self.is_dead.load(std::sync::atomic::Ordering::SeqCst)
370 }
371
372 pub fn mark_clean(&self) {
374 self.snapshot().dirty = false;
375 }
376
377 pub fn new(nvim_path: Option<&PathBuf>) -> Result<Self, String> {
378 tokio::task::block_in_place(|| {
379 tokio::runtime::Handle::current().block_on(Self::new_async(nvim_path))
380 })
381 }
382
383 async fn new_async(nvim_path: Option<&PathBuf>) -> Result<Self, String> {
384 let binary = nvim_path
385 .map(|p| p.to_string_lossy().into_owned())
386 .unwrap_or_else(|| "nvim".to_string());
387
388 let (flush_tx, flush_rx) = tokio::sync::watch::channel(0u64);
389 let (key_tx, key_rx) = tokio::sync::watch::channel(0u64);
390 let handler = NvimHandler { flush_tx };
391
392 let mut cmd = tokio::process::Command::new(&binary);
393 cmd.arg("--embed").stderr(std::process::Stdio::null());
394
395 let (nvim, io_handle, child) = new_child_cmd(&mut cmd, handler)
396 .await
397 .map_err(|e| format!("failed to spawn {binary}: {e}"))?;
398
399 let mut ui_opts = UiAttachOptions::new();
400 ui_opts.set_rgb(false);
401 nvim.ui_attach(80, 24, &ui_opts)
402 .await
403 .map_err(|e| format!("nvim_ui_attach failed: {e}"))?;
404
405 let _ = nvim.command("set noswapfile").await;
406 let _ = nvim.command("set buftype=nofile").await;
407 let _ = nvim.command("set nomodeline").await;
408 let _ = nvim.command("set expandtab").await;
409 let _ = nvim.command("set tabstop=4").await;
410
411 Ok(Self {
412 nvim,
413 snapshot: Arc::new(Mutex::new(NvimSnapshot::default())),
414 is_dead: Arc::new(AtomicBool::new(false)),
415 set_text_in_flight: Arc::new(AtomicBool::new(false)),
416 flush_rx,
417 key_tx,
418 pending_key_rx: Mutex::new(Some(key_rx)),
419 last_ui_size: Mutex::new((80, 24)),
420 io_handle,
421 child: Some(child),
422 })
423 }
424
425 fn ensure_refresh_task(&self, tx: &AppTx) {
427 let mut guard = self
428 .pending_key_rx
429 .lock()
430 .unwrap_or_else(|p| p.into_inner());
431 let Some(key_rx) = guard.take() else { return };
432
433 let nvim = self.nvim.clone();
434 let snapshot = self.snapshot.clone();
435 let is_dead = self.is_dead.clone();
436 let in_flight = self.set_text_in_flight.clone();
437 let flush_rx = self.flush_rx.clone();
438 let tx = tx.clone();
439
440 tokio::spawn(async move {
441 let mut key_rx = key_rx;
442 let mut flush_rx = flush_rx;
443
444 loop {
445 tokio::select! {
449 res = flush_rx.changed() => {
450 if res.is_err() {
451 is_dead.store(true, Ordering::SeqCst);
453 tx.send(AppEvent::Redraw).ok();
454 break;
455 }
456 }
458 res = key_rx.changed() => {
459 if res.is_err() { break; }
460 tokio::time::timeout(
463 Duration::from_millis(30),
464 flush_rx.changed(),
465 ).await.ok();
466 }
467 }
468
469 match nvim.exec_lua(STATE_QUERY_LUA, vec![]).await {
470 Ok(value) => {
471 apply_lua_state(&snapshot, &in_flight, value);
472 tx.send(AppEvent::Redraw).ok();
473 }
474 Err(e) => {
475 if e.is_channel_closed() {
476 is_dead.store(true, Ordering::SeqCst);
477 tx.send(AppEvent::Redraw).ok();
478 break;
479 }
480 tracing::debug!("exec_lua error: {e}");
482 }
483 }
484 }
485 });
486 }
487
488 pub fn set_text(&self, text: &str) {
505 let lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
506
507 {
508 let mut snap = self.snapshot.lock().unwrap_or_else(|p| p.into_inner());
509 snap.lines = if lines.is_empty() {
510 vec![String::new()]
511 } else {
512 lines.clone()
513 };
514 snap.cursor = (0, 0);
515 snap.dirty = false;
516 snap.content_gen = snap.content_gen.wrapping_add(1);
517 }
518
519 let nvim = self.nvim.clone();
520 let is_dead = self.is_dead.clone();
521 let in_flight = self.set_text_in_flight.clone();
522 in_flight.store(true, Ordering::SeqCst);
523 tokio::spawn(async move {
524 let buf = match nvim.get_current_buf().await {
525 Ok(b) => b,
526 Err(e) => {
527 in_flight.store(false, Ordering::SeqCst);
528 if e.is_channel_closed() {
529 is_dead.store(true, Ordering::SeqCst);
530 }
531 tracing::warn!("set_text get_current_buf: {e}");
532 return;
533 }
534 };
535 if let Err(e) = buf.set_lines(0, -1, false, lines).await {
536 tracing::warn!("set_text buf_set_lines: {e}");
537 }
538 in_flight.store(false, Ordering::SeqCst);
539 });
540 }
541
542 pub fn maybe_resize(&self, width: u16, height: u16) {
544 let mut guard = self.last_ui_size.lock().unwrap_or_else(|p| p.into_inner());
545 if *guard == (width, height) {
546 return;
547 }
548 *guard = (width, height);
549 drop(guard);
550
551 let nvim = self.nvim.clone();
552 let is_dead = self.is_dead.clone();
553 tokio::spawn(async move {
554 if let Err(e) = nvim.ui_try_resize(width as i64, height as i64).await {
555 if e.is_channel_closed() {
556 is_dead.store(true, Ordering::SeqCst);
557 }
558 tracing::debug!("ui_try_resize error: {e}");
559 }
560 });
561 }
562
563 pub fn paste(&self, text: &str, tx: AppTx) {
568 self.ensure_refresh_task(&tx);
569 let nvim = self.nvim.clone();
570 let is_dead = self.is_dead.clone();
571 let key_tx = self.key_tx.clone();
572 let payload = text.to_string();
573 tokio::spawn(async move {
574 match nvim.paste(&payload, false, -1).await {
576 Ok(_) => {
577 key_tx.send_modify(|v| *v = v.wrapping_add(1));
578 }
579 Err(e) => {
580 if e.is_channel_closed() {
581 is_dead.store(true, Ordering::SeqCst);
582 tx.send(AppEvent::Redraw).ok();
583 }
584 tracing::debug!("nvim_paste error: {e}");
585 }
586 }
587 });
588 }
589
590 pub fn handle_key(&self, key: &ratatui::crossterm::event::KeyEvent, tx: AppTx) {
592 self.ensure_refresh_task(&tx);
593
594 let Some(nvim_key) = key_event_to_nvim_string(key) else {
595 tracing::debug!("unmappable key: {key:?}");
596 return;
597 };
598
599 let nvim = self.nvim.clone();
600 let is_dead = self.is_dead.clone();
601 let key_tx = self.key_tx.clone();
602
603 tokio::spawn(async move {
604 match nvim.input(&nvim_key).await {
605 Ok(_) => {
606 key_tx.send_modify(|v| *v = v.wrapping_add(1));
608 }
609 Err(e) => {
610 if e.is_channel_closed() {
611 is_dead.store(true, Ordering::SeqCst);
612 tx.send(AppEvent::Redraw).ok();
613 }
614 tracing::debug!("nvim_input error: {e}");
615 }
616 }
617 });
618 }
619}
620
621fn byte_offset_to_char_idx(line: &str, byte_offset: usize) -> usize {
632 let safe = (0..=byte_offset.min(line.len()))
635 .rev()
636 .find(|&i| line.is_char_boundary(i))
637 .unwrap_or(0);
638 line[..safe].chars().count()
639}
640
641fn apply_lua_state(
642 snapshot: &Arc<Mutex<NvimSnapshot>>,
643 in_flight: &Arc<AtomicBool>,
644 value: nvim_rs::Value,
645) {
646 let Some(arr) = value.as_array() else { return };
647 let mode_str = match arr.first().and_then(|v| v.as_str()) {
648 Some(s) => s,
649 None => return,
650 };
651 let mode = EditorMode::from_nvim_str(mode_str);
652
653 let mut snap = snapshot.lock().unwrap_or_else(|p| p.into_inner());
654
655 if mode == EditorMode::Command {
656 let cmdtype = arr
657 .get(1)
658 .and_then(|v| v.as_str())
659 .unwrap_or("")
660 .to_string();
661 let cmdline = arr
662 .get(2)
663 .and_then(|v| v.as_str())
664 .unwrap_or("")
665 .to_string();
666 snap.mode = mode;
667 snap.cmdline = Some(format!("{cmdtype}{cmdline}"));
668 return;
669 }
670
671 let new_lines: Vec<String> = arr
673 .get(1)
674 .and_then(|v| v.as_array())
675 .map(|ls| {
676 ls.iter()
677 .filter_map(|l| l.as_str().map(|s| s.to_string()))
678 .collect()
679 })
680 .unwrap_or_default();
681 let new_lines = if new_lines.is_empty() {
682 vec![String::new()]
683 } else {
684 new_lines
685 };
686
687 let cursor = arr
691 .get(2)
692 .and_then(|v| v.as_array())
693 .and_then(|c| {
694 let row = c.first()?.as_u64()? as usize;
695 let byte_col = c.get(1)?.as_u64()? as usize;
696 let row0 = row.saturating_sub(1);
697 let char_col = new_lines
698 .get(row0)
699 .map(|line| byte_offset_to_char_idx(line, byte_col))
700 .unwrap_or(byte_col);
701 Some((row0, char_col))
702 })
703 .unwrap_or((0, 0));
704
705 let visual_selection = if matches!(mode, EditorMode::Visual | EditorMode::VisualLine) {
708 arr.get(3)
709 .and_then(|v| v.as_array())
710 .and_then(|p| {
711 let lnum = p.get(1)?.as_u64()? as usize;
712 let vcol_byte = p.get(2)?.as_u64()? as usize;
713 if lnum == 0 {
714 return None;
715 }
716 let row0 = lnum.saturating_sub(1);
717 let char_col = new_lines
718 .get(row0)
719 .map(|line| byte_offset_to_char_idx(line, vcol_byte.saturating_sub(1)))
720 .unwrap_or(vcol_byte.saturating_sub(1));
721 Some((row0, char_col))
722 })
723 .map(|anchor| {
724 let (mut start, mut end) = if anchor <= cursor {
725 (anchor, cursor)
726 } else {
727 (cursor, anchor)
728 };
729 if mode == EditorMode::VisualLine {
730 start.1 = 0;
731 end.1 = usize::MAX;
732 }
733 (start, end)
734 })
735 } else {
736 None
737 };
738
739 if new_lines != snap.lines && !in_flight.load(Ordering::SeqCst) {
740 snap.dirty = true;
741 snap.lines = new_lines;
742 snap.content_gen = snap.content_gen.wrapping_add(1);
743 }
744 snap.cursor = cursor;
745 snap.mode = mode;
746 snap.cmdline = None;
747 snap.visual_selection = visual_selection;
748}
749
750#[cfg(test)]
755mod tests {
756 use super::*;
757 use ratatui_textarea::TextArea;
758
759 #[test]
760 fn direct_backend_has_no_mode_label() {
761 let b = BackendState::Textarea(TextareaBackend::direct(TextArea::default()));
762 assert_eq!(b.mode_label(), None);
763 }
764
765 #[test]
766 fn vim_backend_reports_normal_label() {
767 let b = BackendState::Textarea(TextareaBackend::vim(TextArea::default()));
768 assert_eq!(b.mode_label().as_deref(), Some("NORMAL"));
769 }
770
771 #[test]
772 fn vim_space_leads_only_for_vim_backend() {
773 assert!(
774 !BackendState::Textarea(TextareaBackend::direct(TextArea::default())).vim_space_leads()
775 );
776 assert!(
777 BackendState::Textarea(TextareaBackend::vim(TextArea::default())).vim_space_leads()
778 );
779 }
780
781 #[test]
782 fn modal_is_insert_classifies_backends() {
783 assert_eq!(
785 BackendState::Textarea(TextareaBackend::direct(TextArea::default())).modal_is_insert(),
786 None
787 );
788 assert_eq!(
790 BackendState::Textarea(TextareaBackend::vim(TextArea::default())).modal_is_insert(),
791 Some(false)
792 );
793 }
794}