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) => super::cursor_tuple(&tb.ta),
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 reset_input_state(&mut self) {
252 if let BackendState::Textarea(TextareaBackend {
253 input: InputInterpreter::Vim(engine),
254 ..
255 }) = self
256 {
257 engine.reset_to_normal();
258 }
259 }
260
261 pub fn vim_handle_key(
264 &mut self,
265 key: &ratatui::crossterm::event::KeyEvent,
266 ) -> Option<super::vim::VimKeyOutcome> {
267 match self {
268 BackendState::Textarea(TextareaBackend {
269 ta,
270 input: InputInterpreter::Vim(engine),
271 ..
272 }) => Some(engine.handle_key(key, ta)),
273 _ => None,
274 }
275 }
276
277 pub fn pending_input_hint(&self) -> Option<String> {
281 match self {
282 BackendState::Textarea(TextareaBackend {
283 input: InputInterpreter::Vim(e),
284 ..
285 }) => e.pending_hint(),
286 _ => None,
287 }
288 }
289
290 pub fn mode_label(&self) -> Option<String> {
293 match self {
294 BackendState::Textarea(TextareaBackend {
295 input: InputInterpreter::Vim(engine),
296 ..
297 }) => Some(engine.mode_label()),
298 BackendState::Textarea(_) => None,
299 BackendState::Nvim(nvim) => Some(nvim.snapshot().footer_label()),
300 }
301 }
302
303 pub fn modal_is_insert(&self) -> Option<bool> {
308 match self {
309 BackendState::Textarea(TextareaBackend {
310 input: InputInterpreter::Vim(e),
311 ..
312 }) => Some(*e.mode() == EditorMode::Insert),
313 BackendState::Textarea(_) => None,
314 BackendState::Nvim(nvim) => Some(nvim.snapshot().mode == EditorMode::Insert),
315 }
316 }
317
318 pub fn from_settings(
319 editor_backend: &EditorBackendSetting,
320 nvim_path: Option<&PathBuf>,
321 ) -> Self {
322 if matches!(editor_backend, EditorBackendSetting::Nvim) {
323 match NvimBackend::new(nvim_path) {
324 Ok(backend) => return BackendState::Nvim(backend),
325 Err(e) => {
326 tracing::warn!("nvim backend unavailable, falling back to textarea: {e}")
327 }
328 }
329 }
330 let tb = match editor_backend {
331 EditorBackendSetting::Vim => TextareaBackend::vim(crate::ropetext::Text::new()),
332 EditorBackendSetting::Plain | EditorBackendSetting::Nvim => {
335 TextareaBackend::direct(crate::ropetext::Text::new())
336 }
337 };
338 BackendState::Textarea(tb)
339 }
340}
341
342pub struct NvimBackend {
347 nvim: NvimClient,
348 snapshot: Arc<Mutex<NvimSnapshot>>,
349 is_dead: Arc<AtomicBool>,
350 set_text_in_flight: Arc<AtomicBool>,
354 flush_rx: tokio::sync::watch::Receiver<u64>,
356 key_tx: tokio::sync::watch::Sender<u64>,
359 pending_key_rx: Mutex<Option<tokio::sync::watch::Receiver<u64>>>,
361 last_ui_size: Mutex<(u16, u16)>,
364 io_handle: tokio::task::JoinHandle<Result<(), Box<LoopError>>>,
365 child: Option<tokio::process::Child>,
366}
367
368impl Drop for NvimBackend {
369 fn drop(&mut self) {
370 self.io_handle.abort();
373 if let Some(ref mut child) = self.child {
374 let _ = child.start_kill();
375 }
376 }
377}
378
379impl NvimBackend {
380 pub fn snapshot(&self) -> std::sync::MutexGuard<'_, NvimSnapshot> {
383 self.snapshot.lock().unwrap_or_else(|p| p.into_inner())
384 }
385
386 pub fn is_dead(&self) -> bool {
389 self.is_dead.load(std::sync::atomic::Ordering::SeqCst)
390 }
391
392 pub fn mark_clean(&self) {
394 self.snapshot().dirty = false;
395 }
396
397 pub fn new(nvim_path: Option<&PathBuf>) -> Result<Self, String> {
398 tokio::task::block_in_place(|| {
399 tokio::runtime::Handle::current().block_on(Self::new_async(nvim_path))
400 })
401 }
402
403 async fn new_async(nvim_path: Option<&PathBuf>) -> Result<Self, String> {
404 let binary = nvim_path
405 .map(|p| p.to_string_lossy().into_owned())
406 .unwrap_or_else(|| "nvim".to_string());
407
408 let (flush_tx, flush_rx) = tokio::sync::watch::channel(0u64);
409 let (key_tx, key_rx) = tokio::sync::watch::channel(0u64);
410 let handler = NvimHandler { flush_tx };
411
412 let mut cmd = tokio::process::Command::new(&binary);
413 cmd.arg("--embed").stderr(std::process::Stdio::null());
414
415 let (nvim, io_handle, child) = new_child_cmd(&mut cmd, handler)
416 .await
417 .map_err(|e| format!("failed to spawn {binary}: {e}"))?;
418
419 let mut ui_opts = UiAttachOptions::new();
420 ui_opts.set_rgb(false);
421 nvim.ui_attach(80, 24, &ui_opts)
422 .await
423 .map_err(|e| format!("nvim_ui_attach failed: {e}"))?;
424
425 let _ = nvim.command("set noswapfile").await;
426 let _ = nvim.command("set buftype=nofile").await;
427 let _ = nvim.command("set nomodeline").await;
428 let _ = nvim.command("set expandtab").await;
429 let _ = nvim
432 .command(&format!("set tabstop={}", super::markdown::TAB_STOP))
433 .await;
434
435 Ok(Self {
436 nvim,
437 snapshot: Arc::new(Mutex::new(NvimSnapshot::default())),
438 is_dead: Arc::new(AtomicBool::new(false)),
439 set_text_in_flight: Arc::new(AtomicBool::new(false)),
440 flush_rx,
441 key_tx,
442 pending_key_rx: Mutex::new(Some(key_rx)),
443 last_ui_size: Mutex::new((80, 24)),
444 io_handle,
445 child: Some(child),
446 })
447 }
448
449 fn ensure_refresh_task(&self, tx: &AppTx) {
451 let mut guard = self
452 .pending_key_rx
453 .lock()
454 .unwrap_or_else(|p| p.into_inner());
455 let Some(key_rx) = guard.take() else { return };
456
457 let nvim = self.nvim.clone();
458 let snapshot = self.snapshot.clone();
459 let is_dead = self.is_dead.clone();
460 let in_flight = self.set_text_in_flight.clone();
461 let flush_rx = self.flush_rx.clone();
462 let tx = tx.clone();
463
464 tokio::spawn(async move {
465 let mut key_rx = key_rx;
466 let mut flush_rx = flush_rx;
467
468 loop {
469 tokio::select! {
473 res = flush_rx.changed() => {
474 if res.is_err() {
475 is_dead.store(true, Ordering::SeqCst);
477 tx.send(AppEvent::Redraw).ok();
478 break;
479 }
480 }
482 res = key_rx.changed() => {
483 if res.is_err() { break; }
484 tokio::time::timeout(
487 Duration::from_millis(30),
488 flush_rx.changed(),
489 ).await.ok();
490 }
491 }
492
493 match nvim.exec_lua(STATE_QUERY_LUA, vec![]).await {
494 Ok(value) => {
495 apply_lua_state(&snapshot, &in_flight, value);
496 tx.send(AppEvent::Redraw).ok();
497 }
498 Err(e) => {
499 if e.is_channel_closed() {
500 is_dead.store(true, Ordering::SeqCst);
501 tx.send(AppEvent::Redraw).ok();
502 break;
503 }
504 tracing::debug!("exec_lua error: {e}");
506 }
507 }
508 }
509 });
510 }
511
512 pub fn set_text(&self, text: &str) {
529 let lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
530
531 {
532 let mut snap = self.snapshot.lock().unwrap_or_else(|p| p.into_inner());
533 snap.lines = if lines.is_empty() {
534 vec![String::new()]
535 } else {
536 lines.clone()
537 };
538 snap.cursor = (0, 0);
539 snap.dirty = false;
540 snap.content_gen = snap.content_gen.wrapping_add(1);
541 }
542
543 let nvim = self.nvim.clone();
544 let is_dead = self.is_dead.clone();
545 let in_flight = self.set_text_in_flight.clone();
546 in_flight.store(true, Ordering::SeqCst);
547 tokio::spawn(async move {
548 let buf = match nvim.get_current_buf().await {
549 Ok(b) => b,
550 Err(e) => {
551 in_flight.store(false, Ordering::SeqCst);
552 if e.is_channel_closed() {
553 is_dead.store(true, Ordering::SeqCst);
554 }
555 tracing::warn!("set_text get_current_buf: {e}");
556 return;
557 }
558 };
559 if let Err(e) = buf.set_lines(0, -1, false, lines).await {
560 tracing::warn!("set_text buf_set_lines: {e}");
561 }
562 match nvim.get_current_win().await {
575 Ok(win) => {
576 if let Err(e) = win.set_cursor((1, 0)).await {
578 tracing::warn!("set_text win_set_cursor: {e}");
579 }
580 }
581 Err(e) => tracing::warn!("set_text get_current_win: {e}"),
582 }
583 in_flight.store(false, Ordering::SeqCst);
584 });
585 }
586
587 pub fn maybe_resize(&self, width: u16, height: u16) {
589 let mut guard = self.last_ui_size.lock().unwrap_or_else(|p| p.into_inner());
590 if *guard == (width, height) {
591 return;
592 }
593 *guard = (width, height);
594 drop(guard);
595
596 let nvim = self.nvim.clone();
597 let is_dead = self.is_dead.clone();
598 tokio::spawn(async move {
599 if let Err(e) = nvim.ui_try_resize(width as i64, height as i64).await {
600 if e.is_channel_closed() {
601 is_dead.store(true, Ordering::SeqCst);
602 }
603 tracing::debug!("ui_try_resize error: {e}");
604 }
605 });
606 }
607
608 pub fn paste(&self, text: &str, tx: AppTx) {
613 self.ensure_refresh_task(&tx);
614 let nvim = self.nvim.clone();
615 let is_dead = self.is_dead.clone();
616 let key_tx = self.key_tx.clone();
617 let payload = text.to_string();
618 tokio::spawn(async move {
619 match nvim.paste(&payload, false, -1).await {
621 Ok(_) => {
622 key_tx.send_modify(|v| *v = v.wrapping_add(1));
623 }
624 Err(e) => {
625 if e.is_channel_closed() {
626 is_dead.store(true, Ordering::SeqCst);
627 tx.send(AppEvent::Redraw).ok();
628 }
629 tracing::debug!("nvim_paste error: {e}");
630 }
631 }
632 });
633 }
634
635 pub fn handle_key(&self, key: &ratatui::crossterm::event::KeyEvent, tx: AppTx) {
637 self.ensure_refresh_task(&tx);
638
639 let Some(nvim_key) = key_event_to_nvim_string(key) else {
640 tracing::debug!("unmappable key: {key:?}");
641 return;
642 };
643
644 let nvim = self.nvim.clone();
645 let is_dead = self.is_dead.clone();
646 let key_tx = self.key_tx.clone();
647
648 tokio::spawn(async move {
649 match nvim.input(&nvim_key).await {
650 Ok(_) => {
651 key_tx.send_modify(|v| *v = v.wrapping_add(1));
653 }
654 Err(e) => {
655 if e.is_channel_closed() {
656 is_dead.store(true, Ordering::SeqCst);
657 tx.send(AppEvent::Redraw).ok();
658 }
659 tracing::debug!("nvim_input error: {e}");
660 }
661 }
662 });
663 }
664}
665
666fn apply_lua_state(
675 snapshot: &Arc<Mutex<NvimSnapshot>>,
676 in_flight: &Arc<AtomicBool>,
677 value: nvim_rs::Value,
678) {
679 let Some(decoded) = decode(&value) else {
680 return;
681 };
682
683 let mut snap = snapshot.lock().unwrap_or_else(|p| p.into_inner());
684
685 match decoded {
686 DecodedState::Command { cmdline } => {
687 snap.mode = EditorMode::Command;
688 snap.cmdline = Some(cmdline);
689 }
690 DecodedState::Content {
691 mode,
692 lines,
693 cursor,
694 visual_selection,
695 } => {
696 if lines != snap.lines && !in_flight.load(Ordering::SeqCst) {
697 snap.dirty = true;
698 snap.lines = lines;
699 snap.content_gen = snap.content_gen.wrapping_add(1);
700 }
701 snap.cursor = cursor;
702 snap.mode = mode;
703 snap.cmdline = None;
704 snap.visual_selection = visual_selection;
705 }
706 }
707}
708
709#[cfg(test)]
714mod tests {
715 use super::*;
716
717 #[test]
718 fn direct_backend_has_no_mode_label() {
719 let b = BackendState::Textarea(TextareaBackend::direct(crate::ropetext::Text::new()));
720 assert_eq!(b.mode_label(), None);
721 }
722
723 #[test]
724 fn vim_backend_reports_normal_label() {
725 let b = BackendState::Textarea(TextareaBackend::vim(crate::ropetext::Text::new()));
726 assert_eq!(b.mode_label().as_deref(), Some("NORMAL"));
727 }
728
729 #[test]
730 fn space_leads_only_for_vim_backend() {
731 assert!(
732 !BackendState::Textarea(TextareaBackend::direct(crate::ropetext::Text::new()))
733 .space_leads()
734 );
735 assert!(
736 BackendState::Textarea(TextareaBackend::vim(crate::ropetext::Text::new()))
737 .space_leads()
738 );
739 }
740
741 #[test]
742 fn modal_is_insert_classifies_backends() {
743 assert_eq!(
745 BackendState::Textarea(TextareaBackend::direct(crate::ropetext::Text::new()))
746 .modal_is_insert(),
747 None
748 );
749 assert_eq!(
751 BackendState::Textarea(TextareaBackend::vim(crate::ropetext::Text::new()))
752 .modal_is_insert(),
753 Some(false)
754 );
755 }
756}