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 super::rope_buffer::RopeBuffer;
10use nvim_rs::{Handler, Neovim, UiAttachOptions, create::tokio::new_child_cmd, error::LoopError};
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 typing: super::typing_run::TypingRun,
94 pub ta: RopeBuffer,
97 pub input: InputInterpreter,
98}
99
100impl TextareaBackend {
101 pub fn direct(text: crate::ropetext::Text) -> Self {
102 Self {
103 ta: RopeBuffer::new(text),
104 typing: super::typing_run::TypingRun::default(),
105 input: InputInterpreter::Direct,
106 }
107 }
108 pub fn vim(text: crate::ropetext::Text) -> Self {
109 Self {
110 ta: RopeBuffer::new(text),
111 typing: super::typing_run::TypingRun::default(),
112 input: InputInterpreter::Vim(Box::default()),
113 }
114 }
115}
116
117#[allow(clippy::large_enum_variant)]
122pub enum BackendState {
123 Textarea(TextareaBackend),
124 Nvim(NvimBackend),
125}
126
127impl BackendState {
128 pub fn is_textarea(&self) -> bool {
131 matches!(self, BackendState::Textarea(_))
132 }
133
134 pub fn is_vim(&self) -> bool {
136 matches!(
137 self,
138 BackendState::Textarea(TextareaBackend {
139 input: InputInterpreter::Vim(_),
140 ..
141 })
142 )
143 }
144
145 pub fn as_textarea(&self) -> Option<&RopeBuffer> {
148 match self {
149 BackendState::Textarea(tb) => Some(&tb.ta),
150 BackendState::Nvim(_) => None,
151 }
152 }
153
154 pub fn as_textarea_parts_mut(
156 &mut self,
157 ) -> Option<(&mut RopeBuffer, &mut super::typing_run::TypingRun)> {
158 match self {
159 BackendState::Textarea(tb) => Some((&mut tb.ta, &mut tb.typing)),
160 BackendState::Nvim(_) => None,
161 }
162 }
163
164 pub fn as_textarea_mut(&mut self) -> Option<&mut RopeBuffer> {
165 match self {
166 BackendState::Textarea(tb) => Some(&mut tb.ta),
167 BackendState::Nvim(_) => None,
168 }
169 }
170
171 pub fn as_nvim(&self) -> Option<&NvimBackend> {
173 match self {
174 BackendState::Textarea(_) => None,
175 BackendState::Nvim(nvim) => Some(nvim),
176 }
177 }
178
179 pub fn text(&self) -> String {
181 match self {
182 BackendState::Textarea(tb) => tb.ta.text().to_string(),
183 BackendState::Nvim(nvim) => nvim.snapshot().lines.join("\n"),
184 }
185 }
186
187 pub fn cursor(&self) -> (usize, usize) {
191 match self {
192 BackendState::Textarea(tb) => tb.ta.cursor(),
193 BackendState::Nvim(nvim) => {
194 let snap = nvim.snapshot();
195 let max_row = snap.lines.len().saturating_sub(1);
196 (snap.cursor.0.min(max_row), snap.cursor.1)
197 }
198 }
199 }
200
201 pub fn recover_from_dead_nvim(&mut self) -> bool {
205 let fallback_text = match self.as_nvim() {
206 Some(nvim) if nvim.is_dead() => nvim.snapshot().lines.join("\n"),
207 _ => return false,
208 };
209 tracing::warn!("nvim process died; falling back to textarea backend");
210 *self = BackendState::Textarea(TextareaBackend::direct(crate::ropetext::Text::from(
211 fallback_text.as_str(),
212 )));
213 true
214 }
215
216 pub fn sync_mouse_selection(&mut self, has_selection: bool) {
221 if let BackendState::Textarea(TextareaBackend {
222 input: InputInterpreter::Vim(e),
223 ta,
224 ..
225 }) = self
226 {
227 e.sync_mouse_selection(has_selection, ta);
228 }
229 }
230
231 pub fn visual_range(&self) -> Option<((usize, usize), (usize, usize))> {
235 match self {
236 BackendState::Textarea(TextareaBackend {
237 input: InputInterpreter::Vim(e),
238 ta,
239 ..
240 }) => e.visual_range(ta),
241 _ => None,
242 }
243 }
244
245 pub fn selection_as_shown(&self) -> Option<((usize, usize), (usize, usize))> {
248 self.visual_range()
249 .or_else(|| self.as_textarea().and_then(|ta| ta.selection_range()))
250 }
251
252 pub fn visual_is_empty(&self) -> bool {
255 match self {
256 BackendState::Textarea(TextareaBackend {
257 input: InputInterpreter::Vim(e),
258 ta,
259 ..
260 }) => e.visual_is_empty(ta),
261 _ => false,
262 }
263 }
264
265 pub fn copy_visual(&mut self) -> Option<String> {
269 match self {
270 BackendState::Textarea(TextareaBackend {
271 input: InputInterpreter::Vim(e),
272 ta,
273 ..
274 }) => e.copy_visual(ta),
275 _ => None,
276 }
277 }
278
279 pub fn select_dragged(&mut self, origin: (usize, usize), pos: (usize, usize)) -> bool {
283 match self {
284 BackendState::Textarea(TextareaBackend {
285 input: InputInterpreter::Vim(e),
286 ta,
287 ..
288 }) => e.select_dragged(ta, origin, pos),
289 _ => false,
290 }
291 }
292
293 pub fn adopt_host_selection(&mut self) {
296 if let BackendState::Textarea(TextareaBackend {
297 input: InputInterpreter::Vim(e),
298 ta,
299 ..
300 }) = self
301 {
302 e.adopt_host_selection(ta);
303 }
304 }
305
306 pub fn conclude_visual(&mut self) {
309 if let BackendState::Textarea(TextareaBackend {
310 input: InputInterpreter::Vim(e),
311 ta,
312 ..
313 }) = self
314 {
315 e.conclude_visual(ta);
316 }
317 }
318
319 pub fn space_leads(&self) -> bool {
323 matches!(self,
324 BackendState::Textarea(TextareaBackend { input: InputInterpreter::Vim(e), .. })
325 if e.space_leads())
326 }
327
328 pub fn is_visual_line(&self) -> bool {
332 matches!(self,
333 BackendState::Textarea(TextareaBackend { input: InputInterpreter::Vim(e), .. })
334 if *e.mode() == EditorMode::VisualLine)
335 }
336
337 pub fn reset_input_state(&mut self) {
341 if let BackendState::Textarea(TextareaBackend {
342 input: InputInterpreter::Vim(engine),
343 ..
344 }) = self
345 {
346 engine.reset_to_normal();
348 }
349 }
350
351 pub fn vim_handle_key(
354 &mut self,
355 key: &ratatui::crossterm::event::KeyEvent,
356 ) -> Option<super::vim::VimKeyOutcome> {
357 match self {
358 BackendState::Textarea(TextareaBackend {
359 ta,
360 input: InputInterpreter::Vim(engine),
361 ..
362 }) => Some(engine.handle_key(key, ta)),
363 _ => None,
364 }
365 }
366
367 pub fn pending_input_hint(&self) -> Option<String> {
371 match self {
372 BackendState::Textarea(TextareaBackend {
373 input: InputInterpreter::Vim(e),
374 ..
375 }) => e.pending_hint(),
376 _ => None,
377 }
378 }
379
380 pub fn mode_label(&self) -> Option<String> {
383 match self {
384 BackendState::Textarea(TextareaBackend {
385 input: InputInterpreter::Vim(engine),
386 ..
387 }) => Some(engine.mode_label()),
388 BackendState::Textarea(_) => None,
389 BackendState::Nvim(nvim) => Some(nvim.snapshot().footer_label()),
390 }
391 }
392
393 pub fn modal_is_insert(&self) -> Option<bool> {
398 match self {
399 BackendState::Textarea(TextareaBackend {
400 input: InputInterpreter::Vim(e),
401 ..
402 }) => Some(*e.mode() == EditorMode::Insert),
403 BackendState::Textarea(_) => None,
404 BackendState::Nvim(nvim) => Some(nvim.snapshot().mode == EditorMode::Insert),
405 }
406 }
407
408 pub fn from_settings(
409 editor_backend: &EditorBackendSetting,
410 nvim_path: Option<&PathBuf>,
411 ) -> Self {
412 if matches!(editor_backend, EditorBackendSetting::Nvim) {
413 match NvimBackend::new(nvim_path) {
414 Ok(backend) => return BackendState::Nvim(backend),
415 Err(e) => {
416 tracing::warn!("nvim backend unavailable, falling back to textarea: {e}")
417 }
418 }
419 }
420 let tb = match editor_backend {
421 EditorBackendSetting::Vim => TextareaBackend::vim(crate::ropetext::Text::new()),
422 EditorBackendSetting::Plain | EditorBackendSetting::Nvim => {
425 TextareaBackend::direct(crate::ropetext::Text::new())
426 }
427 };
428 BackendState::Textarea(tb)
429 }
430}
431
432pub struct NvimBackend {
437 nvim: NvimClient,
438 snapshot: Arc<Mutex<NvimSnapshot>>,
439 is_dead: Arc<AtomicBool>,
440 set_text_in_flight: Arc<AtomicBool>,
444 flush_rx: tokio::sync::watch::Receiver<u64>,
446 key_tx: tokio::sync::watch::Sender<u64>,
449 pending_key_rx: Mutex<Option<tokio::sync::watch::Receiver<u64>>>,
451 last_ui_size: Mutex<(u16, u16)>,
454 io_handle: tokio::task::JoinHandle<Result<(), Box<LoopError>>>,
455 child: Option<tokio::process::Child>,
456}
457
458impl Drop for NvimBackend {
459 fn drop(&mut self) {
460 self.io_handle.abort();
463 if let Some(ref mut child) = self.child {
464 let _ = child.start_kill();
465 }
466 }
467}
468
469impl NvimBackend {
470 pub fn snapshot(&self) -> std::sync::MutexGuard<'_, NvimSnapshot> {
473 self.snapshot.lock().unwrap_or_else(|p| p.into_inner())
474 }
475
476 pub fn is_dead(&self) -> bool {
479 self.is_dead.load(std::sync::atomic::Ordering::SeqCst)
480 }
481
482 pub fn mark_clean(&self) {
484 self.snapshot().dirty = false;
485 }
486
487 pub fn new(nvim_path: Option<&PathBuf>) -> Result<Self, String> {
488 tokio::task::block_in_place(|| {
489 tokio::runtime::Handle::current().block_on(Self::new_async(nvim_path))
490 })
491 }
492
493 async fn new_async(nvim_path: Option<&PathBuf>) -> Result<Self, String> {
494 let binary = nvim_path
495 .map(|p| p.to_string_lossy().into_owned())
496 .unwrap_or_else(|| "nvim".to_string());
497
498 let (flush_tx, flush_rx) = tokio::sync::watch::channel(0u64);
499 let (key_tx, key_rx) = tokio::sync::watch::channel(0u64);
500 let handler = NvimHandler { flush_tx };
501
502 let mut cmd = tokio::process::Command::new(&binary);
503 cmd.arg("--embed").stderr(std::process::Stdio::null());
504
505 let (nvim, io_handle, child) = new_child_cmd(&mut cmd, handler)
506 .await
507 .map_err(|e| format!("failed to spawn {binary}: {e}"))?;
508
509 let mut ui_opts = UiAttachOptions::new();
510 ui_opts.set_rgb(false);
511 nvim.ui_attach(80, 24, &ui_opts)
512 .await
513 .map_err(|e| format!("nvim_ui_attach failed: {e}"))?;
514
515 let _ = nvim.command("set noswapfile").await;
516 let _ = nvim.command("set buftype=nofile").await;
517 let _ = nvim.command("set nomodeline").await;
518 let _ = nvim.command("set expandtab").await;
519 let _ = nvim
522 .command(&format!("set tabstop={}", super::markdown::TAB_STOP))
523 .await;
524
525 Ok(Self {
526 nvim,
527 snapshot: Arc::new(Mutex::new(NvimSnapshot::default())),
528 is_dead: Arc::new(AtomicBool::new(false)),
529 set_text_in_flight: Arc::new(AtomicBool::new(false)),
530 flush_rx,
531 key_tx,
532 pending_key_rx: Mutex::new(Some(key_rx)),
533 last_ui_size: Mutex::new((80, 24)),
534 io_handle,
535 child: Some(child),
536 })
537 }
538
539 fn ensure_refresh_task(&self, tx: &AppTx) {
541 let mut guard = self
542 .pending_key_rx
543 .lock()
544 .unwrap_or_else(|p| p.into_inner());
545 let Some(key_rx) = guard.take() else { return };
546
547 let nvim = self.nvim.clone();
548 let snapshot = self.snapshot.clone();
549 let is_dead = self.is_dead.clone();
550 let in_flight = self.set_text_in_flight.clone();
551 let flush_rx = self.flush_rx.clone();
552 let tx = tx.clone();
553
554 tokio::spawn(async move {
555 let mut key_rx = key_rx;
556 let mut flush_rx = flush_rx;
557
558 loop {
559 tokio::select! {
563 res = flush_rx.changed() => {
564 if res.is_err() {
565 is_dead.store(true, Ordering::SeqCst);
567 tx.send(AppEvent::Redraw).ok();
568 break;
569 }
570 }
572 res = key_rx.changed() => {
573 if res.is_err() { break; }
574 tokio::time::timeout(
577 Duration::from_millis(30),
578 flush_rx.changed(),
579 ).await.ok();
580 }
581 }
582
583 match nvim.exec_lua(STATE_QUERY_LUA, vec![]).await {
584 Ok(value) => {
585 apply_lua_state(&snapshot, &in_flight, value);
586 tx.send(AppEvent::Redraw).ok();
587 }
588 Err(e) => {
589 if e.is_channel_closed() {
590 is_dead.store(true, Ordering::SeqCst);
591 tx.send(AppEvent::Redraw).ok();
592 break;
593 }
594 tracing::debug!("exec_lua error: {e}");
596 }
597 }
598 }
599 });
600 }
601
602 pub fn set_text(&self, text: &str) {
619 let lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
620
621 {
622 let mut snap = self.snapshot.lock().unwrap_or_else(|p| p.into_inner());
623 snap.lines = if lines.is_empty() {
624 vec![String::new()]
625 } else {
626 lines.clone()
627 };
628 snap.cursor = (0, 0);
629 snap.dirty = false;
630 snap.content_gen = snap.content_gen.wrapping_add(1);
631 }
632
633 let nvim = self.nvim.clone();
634 let is_dead = self.is_dead.clone();
635 let in_flight = self.set_text_in_flight.clone();
636 in_flight.store(true, Ordering::SeqCst);
637 tokio::spawn(async move {
638 let buf = match nvim.get_current_buf().await {
639 Ok(b) => b,
640 Err(e) => {
641 in_flight.store(false, Ordering::SeqCst);
642 if e.is_channel_closed() {
643 is_dead.store(true, Ordering::SeqCst);
644 }
645 tracing::warn!("set_text get_current_buf: {e}");
646 return;
647 }
648 };
649 if let Err(e) = buf.set_lines(0, -1, false, lines).await {
650 tracing::warn!("set_text buf_set_lines: {e}");
651 }
652 match nvim.get_current_win().await {
665 Ok(win) => {
666 if let Err(e) = win.set_cursor((1, 0)).await {
668 tracing::warn!("set_text win_set_cursor: {e}");
669 }
670 }
671 Err(e) => tracing::warn!("set_text get_current_win: {e}"),
672 }
673 in_flight.store(false, Ordering::SeqCst);
674 });
675 }
676
677 pub fn maybe_resize(&self, width: u16, height: u16) {
679 let mut guard = self.last_ui_size.lock().unwrap_or_else(|p| p.into_inner());
680 if *guard == (width, height) {
681 return;
682 }
683 *guard = (width, height);
684 drop(guard);
685
686 let nvim = self.nvim.clone();
687 let is_dead = self.is_dead.clone();
688 tokio::spawn(async move {
689 if let Err(e) = nvim.ui_try_resize(width as i64, height as i64).await {
690 if e.is_channel_closed() {
691 is_dead.store(true, Ordering::SeqCst);
692 }
693 tracing::debug!("ui_try_resize error: {e}");
694 }
695 });
696 }
697
698 pub fn paste(&self, text: &str, tx: AppTx) {
703 self.ensure_refresh_task(&tx);
704 let nvim = self.nvim.clone();
705 let is_dead = self.is_dead.clone();
706 let key_tx = self.key_tx.clone();
707 let payload = text.to_string();
708 tokio::spawn(async move {
709 match nvim.paste(&payload, false, -1).await {
711 Ok(_) => {
712 key_tx.send_modify(|v| *v = v.wrapping_add(1));
713 }
714 Err(e) => {
715 if e.is_channel_closed() {
716 is_dead.store(true, Ordering::SeqCst);
717 tx.send(AppEvent::Redraw).ok();
718 }
719 tracing::debug!("nvim_paste error: {e}");
720 }
721 }
722 });
723 }
724
725 pub fn handle_key(&self, key: &ratatui::crossterm::event::KeyEvent, tx: AppTx) {
727 self.ensure_refresh_task(&tx);
728
729 let Some(nvim_key) = key_event_to_nvim_string(key) else {
730 tracing::debug!("unmappable key: {key:?}");
731 return;
732 };
733
734 let nvim = self.nvim.clone();
735 let is_dead = self.is_dead.clone();
736 let key_tx = self.key_tx.clone();
737
738 tokio::spawn(async move {
739 match nvim.input(&nvim_key).await {
740 Ok(_) => {
741 key_tx.send_modify(|v| *v = v.wrapping_add(1));
743 }
744 Err(e) => {
745 if e.is_channel_closed() {
746 is_dead.store(true, Ordering::SeqCst);
747 tx.send(AppEvent::Redraw).ok();
748 }
749 tracing::debug!("nvim_input error: {e}");
750 }
751 }
752 });
753 }
754}
755
756fn apply_lua_state(
765 snapshot: &Arc<Mutex<NvimSnapshot>>,
766 in_flight: &Arc<AtomicBool>,
767 value: nvim_rs::Value,
768) {
769 let Some(decoded) = decode(&value) else {
770 return;
771 };
772
773 let mut snap = snapshot.lock().unwrap_or_else(|p| p.into_inner());
774
775 match decoded {
776 DecodedState::Command { cmdline } => {
777 snap.mode = EditorMode::Command;
778 snap.cmdline = Some(cmdline);
779 }
780 DecodedState::Content {
781 mode,
782 lines,
783 cursor,
784 visual_selection,
785 } => {
786 if lines != snap.lines && !in_flight.load(Ordering::SeqCst) {
787 snap.dirty = true;
788 snap.lines = lines;
789 snap.content_gen = snap.content_gen.wrapping_add(1);
790 }
791 snap.cursor = cursor;
792 snap.mode = mode;
793 snap.cmdline = None;
794 snap.visual_selection = visual_selection;
795 }
796 }
797}
798
799#[cfg(test)]
804mod tests {
805 use super::*;
806
807 #[test]
808 fn direct_backend_has_no_mode_label() {
809 let b = BackendState::Textarea(TextareaBackend::direct(crate::ropetext::Text::new()));
810 assert_eq!(b.mode_label(), None);
811 }
812
813 #[test]
814 fn vim_backend_reports_normal_label() {
815 let b = BackendState::Textarea(TextareaBackend::vim(crate::ropetext::Text::new()));
816 assert_eq!(b.mode_label().as_deref(), Some("NORMAL"));
817 }
818
819 #[test]
820 fn space_leads_only_for_vim_backend() {
821 assert!(
822 !BackendState::Textarea(TextareaBackend::direct(crate::ropetext::Text::new()))
823 .space_leads()
824 );
825 assert!(
826 BackendState::Textarea(TextareaBackend::vim(crate::ropetext::Text::new()))
827 .space_leads()
828 );
829 }
830
831 #[test]
832 fn modal_is_insert_classifies_backends() {
833 assert_eq!(
835 BackendState::Textarea(TextareaBackend::direct(crate::ropetext::Text::new()))
836 .modal_is_insert(),
837 None
838 );
839 assert_eq!(
841 BackendState::Textarea(TextareaBackend::vim(crate::ropetext::Text::new()))
842 .modal_is_insert(),
843 Some(false)
844 );
845 }
846}