use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::process::ChildStdin;
use tokio_util::compat::Compat;
use nvim_rs::{
create::tokio::new_child_cmd,
error::LoopError,
Handler, Neovim, UiAttachOptions,
};
use ratatui_textarea::TextArea;
use super::nvim_rpc::key_event_to_nvim_string;
use super::snapshot::{NvimMode, NvimSnapshot};
use crate::components::events::{AppEvent, AppTx};
use crate::settings::EditorBackendSetting;
type NvimWriter = Compat<ChildStdin>;
type NvimClient = Neovim<NvimWriter>;
const STATE_QUERY_LUA: &str = r#"
local m = vim.api.nvim_get_mode().mode
if m == 'c' then
return {m, vim.fn.getcmdtype(), vim.fn.getcmdline()}
else
local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
local cursor = vim.api.nvim_win_get_cursor(0)
local vpos = vim.fn.getpos('v')
return {m, lines, cursor, vpos}
end
"#;
#[derive(Clone)]
struct NvimHandler {
flush_tx: tokio::sync::watch::Sender<u64>,
}
#[async_trait::async_trait]
impl Handler for NvimHandler {
type Writer = NvimWriter;
async fn handle_notify(
&self,
name: String,
args: Vec<nvim_rs::Value>,
_neovim: NvimClient,
) {
if name != "redraw" {
return;
}
for arg in &args {
if let Some(events) = arg.as_array() {
for event in events {
if let Some(ea) = event.as_array()
&& ea.first().and_then(|v| v.as_str()) == Some("flush") {
self.flush_tx.send_modify(|v| *v = v.wrapping_add(1));
return;
}
}
}
}
}
}
pub enum BackendState {
Textarea(TextArea<'static>),
Nvim(NvimBackend),
}
impl BackendState {
pub fn from_settings(
editor_backend: &EditorBackendSetting,
nvim_path: Option<&PathBuf>,
) -> Self {
if matches!(editor_backend, EditorBackendSetting::Nvim) {
match NvimBackend::new(nvim_path) {
Ok(backend) => return BackendState::Nvim(backend),
Err(e) => {
log::warn!("nvim backend unavailable, falling back to textarea: {e}")
}
}
}
BackendState::Textarea(TextArea::default())
}
}
pub struct NvimBackend {
pub nvim: NvimClient,
pub snapshot: Arc<Mutex<NvimSnapshot>>,
pub is_dead: Arc<AtomicBool>,
set_text_in_flight: Arc<AtomicBool>,
flush_rx: tokio::sync::watch::Receiver<u64>,
key_tx: tokio::sync::watch::Sender<u64>,
pending_key_rx: Mutex<Option<tokio::sync::watch::Receiver<u64>>>,
pub last_ui_size: Mutex<(u16, u16)>,
io_handle: tokio::task::JoinHandle<Result<(), Box<LoopError>>>,
child: Option<tokio::process::Child>,
}
impl Drop for NvimBackend {
fn drop(&mut self) {
self.io_handle.abort();
if let Some(ref mut child) = self.child {
let _ = child.start_kill();
}
}
}
impl NvimBackend {
pub fn new(nvim_path: Option<&PathBuf>) -> Result<Self, String> {
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current()
.block_on(Self::new_async(nvim_path))
})
}
async fn new_async(nvim_path: Option<&PathBuf>) -> Result<Self, String> {
let binary = nvim_path
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| "nvim".to_string());
let (flush_tx, flush_rx) = tokio::sync::watch::channel(0u64);
let (key_tx, key_rx) = tokio::sync::watch::channel(0u64);
let handler = NvimHandler { flush_tx };
let mut cmd = tokio::process::Command::new(&binary);
cmd.arg("--embed")
.stderr(std::process::Stdio::null());
let (nvim, io_handle, child) = new_child_cmd(&mut cmd, handler)
.await
.map_err(|e| format!("failed to spawn {binary}: {e}"))?;
let mut ui_opts = UiAttachOptions::new();
ui_opts.set_rgb(false);
nvim.ui_attach(80, 24, &ui_opts).await
.map_err(|e| format!("nvim_ui_attach failed: {e}"))?;
let _ = nvim.command("set noswapfile").await;
let _ = nvim.command("set buftype=nofile").await;
let _ = nvim.command("set nomodeline").await;
let _ = nvim.command("set expandtab").await;
let _ = nvim.command("set tabstop=4").await;
Ok(Self {
nvim,
snapshot: Arc::new(Mutex::new(NvimSnapshot::default())),
is_dead: Arc::new(AtomicBool::new(false)),
set_text_in_flight: Arc::new(AtomicBool::new(false)),
flush_rx,
key_tx,
pending_key_rx: Mutex::new(Some(key_rx)),
last_ui_size: Mutex::new((80, 24)),
io_handle,
child: Some(child),
})
}
fn ensure_refresh_task(&self, tx: &AppTx) {
let mut guard = self.pending_key_rx.lock().unwrap_or_else(|p| p.into_inner());
let Some(key_rx) = guard.take() else { return };
let nvim = self.nvim.clone();
let snapshot = self.snapshot.clone();
let is_dead = self.is_dead.clone();
let in_flight = self.set_text_in_flight.clone();
let flush_rx = self.flush_rx.clone();
let tx = tx.clone();
tokio::spawn(async move {
let mut key_rx = key_rx;
let mut flush_rx = flush_rx;
loop {
tokio::select! {
res = flush_rx.changed() => {
if res.is_err() {
is_dead.store(true, Ordering::SeqCst);
tx.send(AppEvent::Redraw).ok();
break;
}
}
res = key_rx.changed() => {
if res.is_err() { break; }
tokio::time::timeout(
Duration::from_millis(30),
flush_rx.changed(),
).await.ok();
}
}
match nvim.exec_lua(STATE_QUERY_LUA, vec![]).await {
Ok(value) => {
apply_lua_state(&snapshot, &in_flight, value);
tx.send(AppEvent::Redraw).ok();
}
Err(e) => {
if e.is_channel_closed() {
is_dead.store(true, Ordering::SeqCst);
tx.send(AppEvent::Redraw).ok();
break;
}
log::debug!("exec_lua error: {e}");
}
}
}
});
}
pub fn set_text(&self, text: &str) {
let lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
{
let mut snap = self.snapshot.lock().unwrap_or_else(|p| p.into_inner());
snap.lines = if lines.is_empty() { vec![String::new()] } else { lines.clone() };
snap.cursor = (0, 0);
snap.dirty = false;
snap.content_gen = snap.content_gen.wrapping_add(1);
}
let nvim = self.nvim.clone();
let is_dead = self.is_dead.clone();
let in_flight = self.set_text_in_flight.clone();
in_flight.store(true, Ordering::SeqCst);
tokio::spawn(async move {
let buf = match nvim.get_current_buf().await {
Ok(b) => b,
Err(e) => {
in_flight.store(false, Ordering::SeqCst);
if e.is_channel_closed() { is_dead.store(true, Ordering::SeqCst); }
log::warn!("set_text get_current_buf: {e}");
return;
}
};
if let Err(e) = buf.set_lines(0, -1, false, lines).await {
log::warn!("set_text buf_set_lines: {e}");
}
in_flight.store(false, Ordering::SeqCst);
});
}
pub fn maybe_resize(&self, width: u16, height: u16) {
let mut guard = self.last_ui_size.lock().unwrap_or_else(|p| p.into_inner());
if *guard == (width, height) {
return;
}
*guard = (width, height);
drop(guard);
let nvim = self.nvim.clone();
let is_dead = self.is_dead.clone();
tokio::spawn(async move {
if let Err(e) = nvim.ui_try_resize(width as i64, height as i64).await {
if e.is_channel_closed() { is_dead.store(true, Ordering::SeqCst); }
log::debug!("ui_try_resize error: {e}");
}
});
}
pub fn handle_key(&self, key: &ratatui::crossterm::event::KeyEvent, tx: AppTx) {
self.ensure_refresh_task(&tx);
let Some(nvim_key) = key_event_to_nvim_string(key) else {
log::debug!("unmappable key: {key:?}");
return;
};
let nvim = self.nvim.clone();
let is_dead = self.is_dead.clone();
let key_tx = self.key_tx.clone();
tokio::spawn(async move {
match nvim.input(&nvim_key).await {
Ok(_) => {
key_tx.send_modify(|v| *v = v.wrapping_add(1));
}
Err(e) => {
if e.is_channel_closed() {
is_dead.store(true, Ordering::SeqCst);
tx.send(AppEvent::Redraw).ok();
}
log::debug!("nvim_input error: {e}");
}
}
});
}
}
fn byte_offset_to_char_idx(line: &str, byte_offset: usize) -> usize {
let safe = (0..=byte_offset.min(line.len()))
.rev()
.find(|&i| line.is_char_boundary(i))
.unwrap_or(0);
line[..safe].chars().count()
}
fn apply_lua_state(snapshot: &Arc<Mutex<NvimSnapshot>>, in_flight: &Arc<AtomicBool>, value: nvim_rs::Value) {
let Some(arr) = value.as_array() else { return };
let mode_str = match arr.first().and_then(|v| v.as_str()) {
Some(s) => s,
None => return,
};
let mode = NvimMode::from_nvim_str(mode_str);
let mut snap = snapshot.lock().unwrap_or_else(|p| p.into_inner());
if mode == NvimMode::Command {
let cmdtype = arr.get(1).and_then(|v| v.as_str()).unwrap_or("").to_string();
let cmdline = arr.get(2).and_then(|v| v.as_str()).unwrap_or("").to_string();
snap.mode = mode;
snap.cmdline = Some(format!("{cmdtype}{cmdline}"));
return;
}
let new_lines: Vec<String> = arr.get(1)
.and_then(|v| v.as_array())
.map(|ls| ls.iter().filter_map(|l| l.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default();
let new_lines = if new_lines.is_empty() { vec![String::new()] } else { new_lines };
let cursor = arr.get(2)
.and_then(|v| v.as_array())
.and_then(|c| {
let row = c.first()?.as_u64()? as usize;
let byte_col = c.get(1)?.as_u64()? as usize;
let row0 = row.saturating_sub(1);
let char_col = new_lines.get(row0)
.map(|line| byte_offset_to_char_idx(line, byte_col))
.unwrap_or(byte_col);
Some((row0, char_col))
})
.unwrap_or((0, 0));
let visual_selection = if matches!(mode, NvimMode::Visual | NvimMode::VisualLine) {
arr.get(3)
.and_then(|v| v.as_array())
.and_then(|p| {
let lnum = p.get(1)?.as_u64()? as usize;
let vcol_byte = p.get(2)?.as_u64()? as usize;
if lnum == 0 { return None; }
let row0 = lnum.saturating_sub(1);
let char_col = new_lines.get(row0)
.map(|line| byte_offset_to_char_idx(line, vcol_byte.saturating_sub(1)))
.unwrap_or(vcol_byte.saturating_sub(1));
Some((row0, char_col))
})
.map(|anchor| {
let (mut start, mut end) =
if anchor <= cursor { (anchor, cursor) } else { (cursor, anchor) };
if mode == NvimMode::VisualLine {
start.1 = 0;
end.1 = usize::MAX;
}
(start, end)
})
} else {
None
};
if new_lines != snap.lines && !in_flight.load(Ordering::SeqCst) {
snap.dirty = true;
snap.lines = new_lines;
snap.content_gen = snap.content_gen.wrapping_add(1);
}
snap.cursor = cursor;
snap.mode = mode;
snap.cmdline = None;
snap.visual_selection = visual_selection;
}