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 ..
224 }) = self
225 {
226 e.sync_mouse_selection(has_selection);
227 }
228 }
229
230 pub fn space_leads(&self) -> bool {
234 matches!(self,
235 BackendState::Textarea(TextareaBackend { input: InputInterpreter::Vim(e), .. })
236 if e.space_leads())
237 }
238
239 pub fn selection_includes_cursor(&self) -> bool {
243 matches!(self,
244 BackendState::Textarea(TextareaBackend { input: InputInterpreter::Vim(e), .. })
245 if *e.mode() == EditorMode::Visual)
246 }
247
248 pub fn is_visual_line(&self) -> bool {
252 matches!(self,
253 BackendState::Textarea(TextareaBackend { input: InputInterpreter::Vim(e), .. })
254 if *e.mode() == EditorMode::VisualLine)
255 }
256
257 pub fn reset_input_state(&mut self) {
261 if let BackendState::Textarea(TextareaBackend {
262 input: InputInterpreter::Vim(engine),
263 ..
264 }) = self
265 {
266 engine.reset_to_normal();
267 }
268 }
269
270 pub fn vim_handle_key(
273 &mut self,
274 key: &ratatui::crossterm::event::KeyEvent,
275 ) -> Option<super::vim::VimKeyOutcome> {
276 match self {
277 BackendState::Textarea(TextareaBackend {
278 ta,
279 input: InputInterpreter::Vim(engine),
280 ..
281 }) => Some(engine.handle_key(key, ta)),
282 _ => None,
283 }
284 }
285
286 pub fn pending_input_hint(&self) -> Option<String> {
290 match self {
291 BackendState::Textarea(TextareaBackend {
292 input: InputInterpreter::Vim(e),
293 ..
294 }) => e.pending_hint(),
295 _ => None,
296 }
297 }
298
299 pub fn mode_label(&self) -> Option<String> {
302 match self {
303 BackendState::Textarea(TextareaBackend {
304 input: InputInterpreter::Vim(engine),
305 ..
306 }) => Some(engine.mode_label()),
307 BackendState::Textarea(_) => None,
308 BackendState::Nvim(nvim) => Some(nvim.snapshot().footer_label()),
309 }
310 }
311
312 pub fn modal_is_insert(&self) -> Option<bool> {
317 match self {
318 BackendState::Textarea(TextareaBackend {
319 input: InputInterpreter::Vim(e),
320 ..
321 }) => Some(*e.mode() == EditorMode::Insert),
322 BackendState::Textarea(_) => None,
323 BackendState::Nvim(nvim) => Some(nvim.snapshot().mode == EditorMode::Insert),
324 }
325 }
326
327 pub fn from_settings(
328 editor_backend: &EditorBackendSetting,
329 nvim_path: Option<&PathBuf>,
330 ) -> Self {
331 if matches!(editor_backend, EditorBackendSetting::Nvim) {
332 match NvimBackend::new(nvim_path) {
333 Ok(backend) => return BackendState::Nvim(backend),
334 Err(e) => {
335 tracing::warn!("nvim backend unavailable, falling back to textarea: {e}")
336 }
337 }
338 }
339 let tb = match editor_backend {
340 EditorBackendSetting::Vim => TextareaBackend::vim(crate::ropetext::Text::new()),
341 EditorBackendSetting::Plain | EditorBackendSetting::Nvim => {
344 TextareaBackend::direct(crate::ropetext::Text::new())
345 }
346 };
347 BackendState::Textarea(tb)
348 }
349}
350
351pub struct NvimBackend {
356 nvim: NvimClient,
357 snapshot: Arc<Mutex<NvimSnapshot>>,
358 is_dead: Arc<AtomicBool>,
359 set_text_in_flight: Arc<AtomicBool>,
363 flush_rx: tokio::sync::watch::Receiver<u64>,
365 key_tx: tokio::sync::watch::Sender<u64>,
368 pending_key_rx: Mutex<Option<tokio::sync::watch::Receiver<u64>>>,
370 last_ui_size: Mutex<(u16, u16)>,
373 io_handle: tokio::task::JoinHandle<Result<(), Box<LoopError>>>,
374 child: Option<tokio::process::Child>,
375}
376
377impl Drop for NvimBackend {
378 fn drop(&mut self) {
379 self.io_handle.abort();
382 if let Some(ref mut child) = self.child {
383 let _ = child.start_kill();
384 }
385 }
386}
387
388impl NvimBackend {
389 pub fn snapshot(&self) -> std::sync::MutexGuard<'_, NvimSnapshot> {
392 self.snapshot.lock().unwrap_or_else(|p| p.into_inner())
393 }
394
395 pub fn is_dead(&self) -> bool {
398 self.is_dead.load(std::sync::atomic::Ordering::SeqCst)
399 }
400
401 pub fn mark_clean(&self) {
403 self.snapshot().dirty = false;
404 }
405
406 pub fn new(nvim_path: Option<&PathBuf>) -> Result<Self, String> {
407 tokio::task::block_in_place(|| {
408 tokio::runtime::Handle::current().block_on(Self::new_async(nvim_path))
409 })
410 }
411
412 async fn new_async(nvim_path: Option<&PathBuf>) -> Result<Self, String> {
413 let binary = nvim_path
414 .map(|p| p.to_string_lossy().into_owned())
415 .unwrap_or_else(|| "nvim".to_string());
416
417 let (flush_tx, flush_rx) = tokio::sync::watch::channel(0u64);
418 let (key_tx, key_rx) = tokio::sync::watch::channel(0u64);
419 let handler = NvimHandler { flush_tx };
420
421 let mut cmd = tokio::process::Command::new(&binary);
422 cmd.arg("--embed").stderr(std::process::Stdio::null());
423
424 let (nvim, io_handle, child) = new_child_cmd(&mut cmd, handler)
425 .await
426 .map_err(|e| format!("failed to spawn {binary}: {e}"))?;
427
428 let mut ui_opts = UiAttachOptions::new();
429 ui_opts.set_rgb(false);
430 nvim.ui_attach(80, 24, &ui_opts)
431 .await
432 .map_err(|e| format!("nvim_ui_attach failed: {e}"))?;
433
434 let _ = nvim.command("set noswapfile").await;
435 let _ = nvim.command("set buftype=nofile").await;
436 let _ = nvim.command("set nomodeline").await;
437 let _ = nvim.command("set expandtab").await;
438 let _ = nvim
441 .command(&format!("set tabstop={}", super::markdown::TAB_STOP))
442 .await;
443
444 Ok(Self {
445 nvim,
446 snapshot: Arc::new(Mutex::new(NvimSnapshot::default())),
447 is_dead: Arc::new(AtomicBool::new(false)),
448 set_text_in_flight: Arc::new(AtomicBool::new(false)),
449 flush_rx,
450 key_tx,
451 pending_key_rx: Mutex::new(Some(key_rx)),
452 last_ui_size: Mutex::new((80, 24)),
453 io_handle,
454 child: Some(child),
455 })
456 }
457
458 fn ensure_refresh_task(&self, tx: &AppTx) {
460 let mut guard = self
461 .pending_key_rx
462 .lock()
463 .unwrap_or_else(|p| p.into_inner());
464 let Some(key_rx) = guard.take() else { return };
465
466 let nvim = self.nvim.clone();
467 let snapshot = self.snapshot.clone();
468 let is_dead = self.is_dead.clone();
469 let in_flight = self.set_text_in_flight.clone();
470 let flush_rx = self.flush_rx.clone();
471 let tx = tx.clone();
472
473 tokio::spawn(async move {
474 let mut key_rx = key_rx;
475 let mut flush_rx = flush_rx;
476
477 loop {
478 tokio::select! {
482 res = flush_rx.changed() => {
483 if res.is_err() {
484 is_dead.store(true, Ordering::SeqCst);
486 tx.send(AppEvent::Redraw).ok();
487 break;
488 }
489 }
491 res = key_rx.changed() => {
492 if res.is_err() { break; }
493 tokio::time::timeout(
496 Duration::from_millis(30),
497 flush_rx.changed(),
498 ).await.ok();
499 }
500 }
501
502 match nvim.exec_lua(STATE_QUERY_LUA, vec![]).await {
503 Ok(value) => {
504 apply_lua_state(&snapshot, &in_flight, value);
505 tx.send(AppEvent::Redraw).ok();
506 }
507 Err(e) => {
508 if e.is_channel_closed() {
509 is_dead.store(true, Ordering::SeqCst);
510 tx.send(AppEvent::Redraw).ok();
511 break;
512 }
513 tracing::debug!("exec_lua error: {e}");
515 }
516 }
517 }
518 });
519 }
520
521 pub fn set_text(&self, text: &str) {
538 let lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
539
540 {
541 let mut snap = self.snapshot.lock().unwrap_or_else(|p| p.into_inner());
542 snap.lines = if lines.is_empty() {
543 vec![String::new()]
544 } else {
545 lines.clone()
546 };
547 snap.cursor = (0, 0);
548 snap.dirty = false;
549 snap.content_gen = snap.content_gen.wrapping_add(1);
550 }
551
552 let nvim = self.nvim.clone();
553 let is_dead = self.is_dead.clone();
554 let in_flight = self.set_text_in_flight.clone();
555 in_flight.store(true, Ordering::SeqCst);
556 tokio::spawn(async move {
557 let buf = match nvim.get_current_buf().await {
558 Ok(b) => b,
559 Err(e) => {
560 in_flight.store(false, Ordering::SeqCst);
561 if e.is_channel_closed() {
562 is_dead.store(true, Ordering::SeqCst);
563 }
564 tracing::warn!("set_text get_current_buf: {e}");
565 return;
566 }
567 };
568 if let Err(e) = buf.set_lines(0, -1, false, lines).await {
569 tracing::warn!("set_text buf_set_lines: {e}");
570 }
571 match nvim.get_current_win().await {
584 Ok(win) => {
585 if let Err(e) = win.set_cursor((1, 0)).await {
587 tracing::warn!("set_text win_set_cursor: {e}");
588 }
589 }
590 Err(e) => tracing::warn!("set_text get_current_win: {e}"),
591 }
592 in_flight.store(false, Ordering::SeqCst);
593 });
594 }
595
596 pub fn maybe_resize(&self, width: u16, height: u16) {
598 let mut guard = self.last_ui_size.lock().unwrap_or_else(|p| p.into_inner());
599 if *guard == (width, height) {
600 return;
601 }
602 *guard = (width, height);
603 drop(guard);
604
605 let nvim = self.nvim.clone();
606 let is_dead = self.is_dead.clone();
607 tokio::spawn(async move {
608 if let Err(e) = nvim.ui_try_resize(width as i64, height as i64).await {
609 if e.is_channel_closed() {
610 is_dead.store(true, Ordering::SeqCst);
611 }
612 tracing::debug!("ui_try_resize error: {e}");
613 }
614 });
615 }
616
617 pub fn paste(&self, text: &str, tx: AppTx) {
622 self.ensure_refresh_task(&tx);
623 let nvim = self.nvim.clone();
624 let is_dead = self.is_dead.clone();
625 let key_tx = self.key_tx.clone();
626 let payload = text.to_string();
627 tokio::spawn(async move {
628 match nvim.paste(&payload, false, -1).await {
630 Ok(_) => {
631 key_tx.send_modify(|v| *v = v.wrapping_add(1));
632 }
633 Err(e) => {
634 if e.is_channel_closed() {
635 is_dead.store(true, Ordering::SeqCst);
636 tx.send(AppEvent::Redraw).ok();
637 }
638 tracing::debug!("nvim_paste error: {e}");
639 }
640 }
641 });
642 }
643
644 pub fn handle_key(&self, key: &ratatui::crossterm::event::KeyEvent, tx: AppTx) {
646 self.ensure_refresh_task(&tx);
647
648 let Some(nvim_key) = key_event_to_nvim_string(key) else {
649 tracing::debug!("unmappable key: {key:?}");
650 return;
651 };
652
653 let nvim = self.nvim.clone();
654 let is_dead = self.is_dead.clone();
655 let key_tx = self.key_tx.clone();
656
657 tokio::spawn(async move {
658 match nvim.input(&nvim_key).await {
659 Ok(_) => {
660 key_tx.send_modify(|v| *v = v.wrapping_add(1));
662 }
663 Err(e) => {
664 if e.is_channel_closed() {
665 is_dead.store(true, Ordering::SeqCst);
666 tx.send(AppEvent::Redraw).ok();
667 }
668 tracing::debug!("nvim_input error: {e}");
669 }
670 }
671 });
672 }
673}
674
675fn apply_lua_state(
684 snapshot: &Arc<Mutex<NvimSnapshot>>,
685 in_flight: &Arc<AtomicBool>,
686 value: nvim_rs::Value,
687) {
688 let Some(decoded) = decode(&value) else {
689 return;
690 };
691
692 let mut snap = snapshot.lock().unwrap_or_else(|p| p.into_inner());
693
694 match decoded {
695 DecodedState::Command { cmdline } => {
696 snap.mode = EditorMode::Command;
697 snap.cmdline = Some(cmdline);
698 }
699 DecodedState::Content {
700 mode,
701 lines,
702 cursor,
703 visual_selection,
704 } => {
705 if lines != snap.lines && !in_flight.load(Ordering::SeqCst) {
706 snap.dirty = true;
707 snap.lines = lines;
708 snap.content_gen = snap.content_gen.wrapping_add(1);
709 }
710 snap.cursor = cursor;
711 snap.mode = mode;
712 snap.cmdline = None;
713 snap.visual_selection = visual_selection;
714 }
715 }
716}
717
718#[cfg(test)]
723mod tests {
724 use super::*;
725
726 #[test]
727 fn direct_backend_has_no_mode_label() {
728 let b = BackendState::Textarea(TextareaBackend::direct(crate::ropetext::Text::new()));
729 assert_eq!(b.mode_label(), None);
730 }
731
732 #[test]
733 fn vim_backend_reports_normal_label() {
734 let b = BackendState::Textarea(TextareaBackend::vim(crate::ropetext::Text::new()));
735 assert_eq!(b.mode_label().as_deref(), Some("NORMAL"));
736 }
737
738 #[test]
739 fn space_leads_only_for_vim_backend() {
740 assert!(
741 !BackendState::Textarea(TextareaBackend::direct(crate::ropetext::Text::new()))
742 .space_leads()
743 );
744 assert!(
745 BackendState::Textarea(TextareaBackend::vim(crate::ropetext::Text::new()))
746 .space_leads()
747 );
748 }
749
750 #[test]
751 fn modal_is_insert_classifies_backends() {
752 assert_eq!(
754 BackendState::Textarea(TextareaBackend::direct(crate::ropetext::Text::new()))
755 .modal_is_insert(),
756 None
757 );
758 assert_eq!(
760 BackendState::Textarea(TextareaBackend::vim(crate::ropetext::Text::new()))
761 .modal_is_insert(),
762 Some(false)
763 );
764 }
765}