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::{NvimMode, NvimSnapshot};
14use crate::components::events::{AppEvent, AppTx};
15use crate::settings::EditorBackendSetting;
16
17type NvimWriter = Compat<ChildStdin>;
18type NvimClient = Neovim<NvimWriter>;
19
20const STATE_QUERY_LUA: &str = r#"
27local m = vim.api.nvim_get_mode().mode
28if m == 'c' then
29 return {m, vim.fn.getcmdtype(), vim.fn.getcmdline()}
30else
31 local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
32 local cursor = vim.api.nvim_win_get_cursor(0)
33 local vpos = vim.fn.getpos('v')
34 return {m, lines, cursor, vpos}
35end
36"#;
37
38#[derive(Clone)]
43struct NvimHandler {
44 flush_tx: tokio::sync::watch::Sender<u64>,
45}
46
47#[async_trait::async_trait]
48impl Handler for NvimHandler {
49 type Writer = NvimWriter;
50
51 async fn handle_notify(&self, name: String, args: Vec<nvim_rs::Value>, _neovim: NvimClient) {
52 if name != "redraw" {
53 return;
54 }
55 for arg in &args {
56 if let Some(events) = arg.as_array() {
57 for event in events {
58 if let Some(ea) = event.as_array()
59 && ea.first().and_then(|v| v.as_str()) == Some("flush")
60 {
61 self.flush_tx.send_modify(|v| *v = v.wrapping_add(1));
62 return;
63 }
64 }
65 }
66 }
67 }
68}
69
70#[allow(clippy::large_enum_variant)]
75pub enum BackendState {
76 Textarea(TextArea<'static>),
77 Nvim(NvimBackend),
78}
79
80impl BackendState {
81 pub fn from_settings(
82 editor_backend: &EditorBackendSetting,
83 nvim_path: Option<&PathBuf>,
84 ) -> Self {
85 if matches!(editor_backend, EditorBackendSetting::Nvim) {
86 match NvimBackend::new(nvim_path) {
87 Ok(backend) => return BackendState::Nvim(backend),
88 Err(e) => {
89 tracing::warn!("nvim backend unavailable, falling back to textarea: {e}")
90 }
91 }
92 }
93 BackendState::Textarea(TextArea::default())
94 }
95}
96
97pub struct NvimBackend {
102 pub nvim: NvimClient,
103 pub snapshot: Arc<Mutex<NvimSnapshot>>,
104 pub is_dead: Arc<AtomicBool>,
105 set_text_in_flight: Arc<AtomicBool>,
109 flush_rx: tokio::sync::watch::Receiver<u64>,
111 key_tx: tokio::sync::watch::Sender<u64>,
114 pending_key_rx: Mutex<Option<tokio::sync::watch::Receiver<u64>>>,
116 pub last_ui_size: Mutex<(u16, u16)>,
119 io_handle: tokio::task::JoinHandle<Result<(), Box<LoopError>>>,
120 child: Option<tokio::process::Child>,
121}
122
123impl Drop for NvimBackend {
124 fn drop(&mut self) {
125 self.io_handle.abort();
128 if let Some(ref mut child) = self.child {
129 let _ = child.start_kill();
130 }
131 }
132}
133
134impl NvimBackend {
135 pub fn new(nvim_path: Option<&PathBuf>) -> Result<Self, String> {
136 tokio::task::block_in_place(|| {
137 tokio::runtime::Handle::current().block_on(Self::new_async(nvim_path))
138 })
139 }
140
141 async fn new_async(nvim_path: Option<&PathBuf>) -> Result<Self, String> {
142 let binary = nvim_path
143 .map(|p| p.to_string_lossy().into_owned())
144 .unwrap_or_else(|| "nvim".to_string());
145
146 let (flush_tx, flush_rx) = tokio::sync::watch::channel(0u64);
147 let (key_tx, key_rx) = tokio::sync::watch::channel(0u64);
148 let handler = NvimHandler { flush_tx };
149
150 let mut cmd = tokio::process::Command::new(&binary);
151 cmd.arg("--embed").stderr(std::process::Stdio::null());
152
153 let (nvim, io_handle, child) = new_child_cmd(&mut cmd, handler)
154 .await
155 .map_err(|e| format!("failed to spawn {binary}: {e}"))?;
156
157 let mut ui_opts = UiAttachOptions::new();
158 ui_opts.set_rgb(false);
159 nvim.ui_attach(80, 24, &ui_opts)
160 .await
161 .map_err(|e| format!("nvim_ui_attach failed: {e}"))?;
162
163 let _ = nvim.command("set noswapfile").await;
164 let _ = nvim.command("set buftype=nofile").await;
165 let _ = nvim.command("set nomodeline").await;
166 let _ = nvim.command("set expandtab").await;
167 let _ = nvim.command("set tabstop=4").await;
168
169 Ok(Self {
170 nvim,
171 snapshot: Arc::new(Mutex::new(NvimSnapshot::default())),
172 is_dead: Arc::new(AtomicBool::new(false)),
173 set_text_in_flight: Arc::new(AtomicBool::new(false)),
174 flush_rx,
175 key_tx,
176 pending_key_rx: Mutex::new(Some(key_rx)),
177 last_ui_size: Mutex::new((80, 24)),
178 io_handle,
179 child: Some(child),
180 })
181 }
182
183 fn ensure_refresh_task(&self, tx: &AppTx) {
185 let mut guard = self
186 .pending_key_rx
187 .lock()
188 .unwrap_or_else(|p| p.into_inner());
189 let Some(key_rx) = guard.take() else { return };
190
191 let nvim = self.nvim.clone();
192 let snapshot = self.snapshot.clone();
193 let is_dead = self.is_dead.clone();
194 let in_flight = self.set_text_in_flight.clone();
195 let flush_rx = self.flush_rx.clone();
196 let tx = tx.clone();
197
198 tokio::spawn(async move {
199 let mut key_rx = key_rx;
200 let mut flush_rx = flush_rx;
201
202 loop {
203 tokio::select! {
207 res = flush_rx.changed() => {
208 if res.is_err() {
209 is_dead.store(true, Ordering::SeqCst);
211 tx.send(AppEvent::Redraw).ok();
212 break;
213 }
214 }
216 res = key_rx.changed() => {
217 if res.is_err() { break; }
218 tokio::time::timeout(
221 Duration::from_millis(30),
222 flush_rx.changed(),
223 ).await.ok();
224 }
225 }
226
227 match nvim.exec_lua(STATE_QUERY_LUA, vec![]).await {
228 Ok(value) => {
229 apply_lua_state(&snapshot, &in_flight, value);
230 tx.send(AppEvent::Redraw).ok();
231 }
232 Err(e) => {
233 if e.is_channel_closed() {
234 is_dead.store(true, Ordering::SeqCst);
235 tx.send(AppEvent::Redraw).ok();
236 break;
237 }
238 tracing::debug!("exec_lua error: {e}");
240 }
241 }
242 }
243 });
244 }
245
246 pub fn set_text(&self, text: &str) {
263 let lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
264
265 {
266 let mut snap = self.snapshot.lock().unwrap_or_else(|p| p.into_inner());
267 snap.lines = if lines.is_empty() {
268 vec![String::new()]
269 } else {
270 lines.clone()
271 };
272 snap.cursor = (0, 0);
273 snap.dirty = false;
274 snap.content_gen = snap.content_gen.wrapping_add(1);
275 }
276
277 let nvim = self.nvim.clone();
278 let is_dead = self.is_dead.clone();
279 let in_flight = self.set_text_in_flight.clone();
280 in_flight.store(true, Ordering::SeqCst);
281 tokio::spawn(async move {
282 let buf = match nvim.get_current_buf().await {
283 Ok(b) => b,
284 Err(e) => {
285 in_flight.store(false, Ordering::SeqCst);
286 if e.is_channel_closed() {
287 is_dead.store(true, Ordering::SeqCst);
288 }
289 tracing::warn!("set_text get_current_buf: {e}");
290 return;
291 }
292 };
293 if let Err(e) = buf.set_lines(0, -1, false, lines).await {
294 tracing::warn!("set_text buf_set_lines: {e}");
295 }
296 in_flight.store(false, Ordering::SeqCst);
297 });
298 }
299
300 pub fn maybe_resize(&self, width: u16, height: u16) {
302 let mut guard = self.last_ui_size.lock().unwrap_or_else(|p| p.into_inner());
303 if *guard == (width, height) {
304 return;
305 }
306 *guard = (width, height);
307 drop(guard);
308
309 let nvim = self.nvim.clone();
310 let is_dead = self.is_dead.clone();
311 tokio::spawn(async move {
312 if let Err(e) = nvim.ui_try_resize(width as i64, height as i64).await {
313 if e.is_channel_closed() {
314 is_dead.store(true, Ordering::SeqCst);
315 }
316 tracing::debug!("ui_try_resize error: {e}");
317 }
318 });
319 }
320
321 pub fn paste(&self, text: &str, tx: AppTx) {
326 self.ensure_refresh_task(&tx);
327 let nvim = self.nvim.clone();
328 let is_dead = self.is_dead.clone();
329 let key_tx = self.key_tx.clone();
330 let payload = text.to_string();
331 tokio::spawn(async move {
332 match nvim.paste(&payload, false, -1).await {
334 Ok(_) => {
335 key_tx.send_modify(|v| *v = v.wrapping_add(1));
336 }
337 Err(e) => {
338 if e.is_channel_closed() {
339 is_dead.store(true, Ordering::SeqCst);
340 tx.send(AppEvent::Redraw).ok();
341 }
342 tracing::debug!("nvim_paste error: {e}");
343 }
344 }
345 });
346 }
347
348 pub fn handle_key(&self, key: &ratatui::crossterm::event::KeyEvent, tx: AppTx) {
350 self.ensure_refresh_task(&tx);
351
352 let Some(nvim_key) = key_event_to_nvim_string(key) else {
353 tracing::debug!("unmappable key: {key:?}");
354 return;
355 };
356
357 let nvim = self.nvim.clone();
358 let is_dead = self.is_dead.clone();
359 let key_tx = self.key_tx.clone();
360
361 tokio::spawn(async move {
362 match nvim.input(&nvim_key).await {
363 Ok(_) => {
364 key_tx.send_modify(|v| *v = v.wrapping_add(1));
366 }
367 Err(e) => {
368 if e.is_channel_closed() {
369 is_dead.store(true, Ordering::SeqCst);
370 tx.send(AppEvent::Redraw).ok();
371 }
372 tracing::debug!("nvim_input error: {e}");
373 }
374 }
375 });
376 }
377}
378
379fn byte_offset_to_char_idx(line: &str, byte_offset: usize) -> usize {
390 let safe = (0..=byte_offset.min(line.len()))
393 .rev()
394 .find(|&i| line.is_char_boundary(i))
395 .unwrap_or(0);
396 line[..safe].chars().count()
397}
398
399fn apply_lua_state(
400 snapshot: &Arc<Mutex<NvimSnapshot>>,
401 in_flight: &Arc<AtomicBool>,
402 value: nvim_rs::Value,
403) {
404 let Some(arr) = value.as_array() else { return };
405 let mode_str = match arr.first().and_then(|v| v.as_str()) {
406 Some(s) => s,
407 None => return,
408 };
409 let mode = NvimMode::from_nvim_str(mode_str);
410
411 let mut snap = snapshot.lock().unwrap_or_else(|p| p.into_inner());
412
413 if mode == NvimMode::Command {
414 let cmdtype = arr
415 .get(1)
416 .and_then(|v| v.as_str())
417 .unwrap_or("")
418 .to_string();
419 let cmdline = arr
420 .get(2)
421 .and_then(|v| v.as_str())
422 .unwrap_or("")
423 .to_string();
424 snap.mode = mode;
425 snap.cmdline = Some(format!("{cmdtype}{cmdline}"));
426 return;
427 }
428
429 let new_lines: Vec<String> = arr
431 .get(1)
432 .and_then(|v| v.as_array())
433 .map(|ls| {
434 ls.iter()
435 .filter_map(|l| l.as_str().map(|s| s.to_string()))
436 .collect()
437 })
438 .unwrap_or_default();
439 let new_lines = if new_lines.is_empty() {
440 vec![String::new()]
441 } else {
442 new_lines
443 };
444
445 let cursor = arr
449 .get(2)
450 .and_then(|v| v.as_array())
451 .and_then(|c| {
452 let row = c.first()?.as_u64()? as usize;
453 let byte_col = c.get(1)?.as_u64()? as usize;
454 let row0 = row.saturating_sub(1);
455 let char_col = new_lines
456 .get(row0)
457 .map(|line| byte_offset_to_char_idx(line, byte_col))
458 .unwrap_or(byte_col);
459 Some((row0, char_col))
460 })
461 .unwrap_or((0, 0));
462
463 let visual_selection = if matches!(mode, NvimMode::Visual | NvimMode::VisualLine) {
466 arr.get(3)
467 .and_then(|v| v.as_array())
468 .and_then(|p| {
469 let lnum = p.get(1)?.as_u64()? as usize;
470 let vcol_byte = p.get(2)?.as_u64()? as usize;
471 if lnum == 0 {
472 return None;
473 }
474 let row0 = lnum.saturating_sub(1);
475 let char_col = new_lines
476 .get(row0)
477 .map(|line| byte_offset_to_char_idx(line, vcol_byte.saturating_sub(1)))
478 .unwrap_or(vcol_byte.saturating_sub(1));
479 Some((row0, char_col))
480 })
481 .map(|anchor| {
482 let (mut start, mut end) = if anchor <= cursor {
483 (anchor, cursor)
484 } else {
485 (cursor, anchor)
486 };
487 if mode == NvimMode::VisualLine {
488 start.1 = 0;
489 end.1 = usize::MAX;
490 }
491 (start, end)
492 })
493 } else {
494 None
495 };
496
497 if new_lines != snap.lines && !in_flight.load(Ordering::SeqCst) {
498 snap.dirty = true;
499 snap.lines = new_lines;
500 snap.content_gen = snap.content_gen.wrapping_add(1);
501 }
502 snap.cursor = cursor;
503 snap.mode = mode;
504 snap.cmdline = None;
505 snap.visual_selection = visual_selection;
506}