//! Frame drawing with cursor blink preservation. //! //! # Problem //! //! Ratatui's [`Terminal::draw()`] (internally `try_draw()`) unconditionally //! sends cursor escape sequences on every frame: //! //! - If `frame.set_cursor_position()` was called: `Show` + `MoveTo` every frame //! - If not called: `Hide` every frame //! //! Both reset the terminal's cursor blink timer (`Show` restarts the blink //! cycle, `MoveTo` resets the blink phase). At 30fps, the 500ms blink interval //! never completes, so the cursor appears solid. //! //! # Solution //! //! We bypass `try_draw()` and use ratatui's lower-level API directly: //! //! ```text //! terminal.autoresize() — handle terminal size changes //! terminal.get_frame() — get a fresh buffer to render into //! terminal.flush() — diff old/new buffers, write only changed cells //! terminal.swap_buffers() — prepare for next frame //! ``` //! //! Cursor is managed entirely by [`CursorState`] with de-duplication: //! //! - **No cell changes + same position**: zero cursor commands → blink preserved //! - **Cells changed + same position**: `MoveTo` to fix cursor after cell writes //! - **Position changed**: `MoveTo` (blink resets — expected, user just typed) //! - **Visibility transition**: `Show`/`Hide` (only on actual transition) //! - **Idle (no draw calls)**: nothing sent → blink runs undisturbed //! //! The "no cell changes" optimization is possible because we use //! [`kigi_ratatui_inline::Terminal`] whose `flush()` returns `bool` indicating //! whether any cells were written. When animated entries are off-screen, the //! buffer diff is empty and we skip all cursor commands. //! //! # Synchronized output //! //! Each frame is wrapped in `BeginSynchronizedUpdate` / `EndSynchronizedUpdate` //! so the terminal processes all escape sequences atomically. This prevents //! flicker and is critical for multiplexers like zellij and tmux. use crossterm::terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate}; use crossterm::{QueueableCommand, cursor}; use kigi_ratatui_inline::LinkSpan; use ratatui::Frame; use ratatui::backend::CrosstermBackend; use std::io::Write; use std::sync::mpsc; use std::time::{Duration, Instant}; /// Terminal type for the pager. Defined here (beside [`TermWriter`]) so the /// `render` module does not depend on `app`. Re-exported from `app` as /// `crate::app::PagerTerminal` for existing call sites. pub type PagerTerminal = kigi_ratatui_inline::Terminal>; /// Shared queued/written frame counters linking [`TermWriter`] to the writer /// thread, so callers can wait for the output pipeline to drain. /// /// The channel between them is fire-and-forget by design (the event loop must /// never block on pty I/O), but a few operations need a *happens-before* on /// terminal bytes: suspending into a tty-taking child (`$EDITOR` / `$PAGER`) /// while a frame is still queued lets that frame race the child's own output — /// it can land on the child's alternate screen (so the main screen never /// receives it) or tear mid-escape-sequence around the alt-screen switch, /// leaving the restored screen out of sync with the renderer's diff buffer /// (stale rows, one-line offsets, literal `[` fragments). [`wait_drained`] /// closes that window. /// /// `queued` is incremented *before* the frame is sent and `written` after the /// writer thread has flushed it to the tty, so `written == queued` ⇒ every /// frame handed to the channel has reached the terminal fd. /// /// [`wait_drained`]: WriterSync::wait_drained #[derive(Clone, Debug, Default)] pub struct WriterSync { queued: std::sync::Arc, written: std::sync::Arc, } impl WriterSync { pub fn new() -> Self { Self::default() } /// Record a frame handed to the channel. Called by [`TermWriter::flush`] /// *before* the send so `written` can never observably exceed `queued`. fn mark_queued(&self) { self.queued .fetch_add(1, std::sync::atomic::Ordering::SeqCst); } /// Record a frame fully written + flushed to the tty (writer thread). fn mark_written(&self) { self.written .fetch_add(1, std::sync::atomic::Ordering::SeqCst); } /// Whether every queued frame has been written to the tty. pub fn is_drained(&self) -> bool { self.written.load(std::sync::atomic::Ordering::SeqCst) >= self.queued.load(std::sync::atomic::Ordering::SeqCst) } /// Block (bounded) until the writer thread has flushed every queued frame. /// /// Returns `true` when drained, `false` on timeout (wedged pty / dead /// writer thread — callers proceed anyway, matching the bounded /// reader-park in the suspend path). pub fn wait_drained(&self, timeout: Duration) -> bool { let deadline = Instant::now() + timeout; while !self.is_drained() { if Instant::now() >= deadline { return false; } std::thread::sleep(Duration::from_millis(1)); } true } } /// A writer that buffers frame output and sends it to a background thread /// for non-blocking terminal I/O. /// /// All escape sequences produced during a frame are collected in an internal /// `Vec`. When [`flush()`](Write::flush) is called, the accumulated bytes /// are sent through a channel to a dedicated writer thread that performs the /// actual (potentially blocking) `write()` to stderr / the pty fd. /// /// This decouples the tokio event loop from pty back-pressure: if the /// terminal emulator is slow to read (e.g. Ghostty busy with another pane), /// only the writer thread stalls — the event loop keeps processing timers, /// events, and ACP messages. pub struct TermWriter { buf: Vec, tx: mpsc::Sender>, sync: WriterSync, } impl TermWriter { pub fn new(tx: mpsc::Sender>, sync: WriterSync) -> Self { Self { buf: Vec::with_capacity(32 * 1024), tx, sync, } } /// Drop the current frame's buffered bytes without sending them. pub fn discard(&mut self) { self.buf.clear(); } /// The queued/written counters shared with the writer thread. Used by the /// suspend path to [`WriterSync::wait_drained`] before a child takes the tty. pub fn writer_sync(&self) -> &WriterSync { &self.sync } } impl Write for TermWriter { fn write(&mut self, data: &[u8]) -> std::io::Result { self.buf.extend_from_slice(data); Ok(data.len()) } fn flush(&mut self) -> std::io::Result<()> { if !self.buf.is_empty() { let data = std::mem::take(&mut self.buf); self.sync.mark_queued(); let _ = self.tx.send(data); } Ok(()) } } impl Drop for TermWriter { fn drop(&mut self) { let _ = self.flush(); } } /// Handle for the background writer thread. /// /// Joining ensures all queued frames have been written to the terminal /// before proceeding with teardown (e.g. `LeaveAlternateScreen`). pub struct WriterThread { handle: Option>, } impl WriterThread { /// Block until the writer thread has processed all pending frames and /// exited. The [`mpsc::Sender`] must be dropped *before* calling this, /// otherwise the thread will never see the channel close. pub fn join(mut self) { if let Some(h) = self.handle.take() { let _ = h.join(); } } } impl Drop for WriterThread { fn drop(&mut self) { if let Some(h) = self.handle.take() { let _ = h.join(); } } } /// Spawn a background OS thread that writes frame data to stderr. /// /// Returns `(Sender, WriterSync, WriterThread)`. Send `Vec` frame data /// through the sender; the thread writes each frame to stderr via a 64 KiB /// `BufWriter`. The [`WriterSync`] must be shared with every [`TermWriter`] /// built on the sender so [`WriterSync::wait_drained`] tracks the queue. /// Drop the sender to signal the thread to exit, then call /// [`WriterThread::join`] to wait for it. pub fn spawn_writer_thread() -> (mpsc::Sender>, WriterSync, WriterThread) { let (tx, rx) = mpsc::channel::>(); let sync = WriterSync::new(); let thread_sync = sync.clone(); let test_delay = std::env::var("KIGI_TEST_FRAME_WRITE_DELAY_MS") .ok() .and_then(|v| v.parse::().ok()) .map(Duration::from_millis); let handle = std::thread::Builder::new() .name("term-writer".into()) .spawn(move || { #[cfg(not(windows))] let mut writer: Box = { let tui_out = kigi_tty_utils::dup_tui_stderr().unwrap_or_else(|_| { use std::os::unix::io::{AsRawFd, FromRawFd}; let fd = unsafe { libc::dup(std::io::stderr().as_raw_fd()) }; unsafe { std::fs::File::from_raw_fd(fd) } }); Box::new(std::io::BufWriter::with_capacity(64 * 1024, tui_out)) }; #[cfg(windows)] let mut writer: Box = Box::new(std::io::BufWriter::with_capacity( 64 * 1024, std::io::stderr(), )); while let Ok(data) = rx.recv() { if let Some(delay) = test_delay { std::thread::sleep(delay); } { let _guard = kigi_shared::stderr::stderr_lock(); let _ = writer.write_all(&data); let _ = writer.flush(); } thread_sync.mark_written(); } }) .expect("failed to spawn term-writer thread"); ( tx, sync, WriterThread { handle: Some(handle), }, ) } /// Cursor state tracker for blink-preserving cursor management. /// /// Tracks the last cursor position written to the terminal. By comparing /// with the desired position each frame, we emit the minimum cursor escape /// sequences necessary — avoiding redundant `Show`/`Hide`/`MoveTo` that /// would reset the terminal's blink timer. #[derive(Debug, Default)] pub struct CursorState { /// Last cursor position written to the terminal. /// `None` = cursor is hidden; `Some((x, y))` = cursor visible at (x, y). last_pos: Option<(u16, u16)>, } /// What cursor commands to emit after a frame render. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CursorAction { /// No cursor commands needed — blink timer preserved. None, /// Cursor is visible and cells changed — reposition after cell writes /// disturbed the terminal cursor. Resets blink (unavoidable when cells /// change on screen). Reposition(u16, u16), /// Cursor becoming visible at (x, y) — needs `MoveTo` + `Show`. Show(u16, u16), /// Cursor becoming hidden — needs `Hide`. Hide, } impl CursorState { pub fn new() -> Self { Self { last_pos: None } } /// Determine what cursor action to take for this frame. /// /// Pure function — computes the action from current state without /// side effects. Call [`apply`] to execute it. pub fn action(&self, cursor_pos: Option<(u16, u16)>, has_changes: bool) -> CursorAction { if cursor_pos == self.last_pos { if has_changes && let Some((x, y)) = cursor_pos { return CursorAction::Reposition(x, y); } CursorAction::None } else { match (cursor_pos, self.last_pos) { (Some((x, y)), Some(_)) => CursorAction::Reposition(x, y), (Some((x, y)), None) => CursorAction::Show(x, y), (None, Some(_)) => CursorAction::Hide, (None, None) => CursorAction::None, } } } /// Execute a cursor action by queuing escape sequences into `w`. /// /// Uses `queue!` (buffered) instead of `execute!` (immediate flush) so /// that cursor commands are batched with the rest of the frame data and /// written to the terminal atomically by the writer thread. pub fn apply(&mut self, action: CursorAction, w: &mut W) { match action { CursorAction::None => {} CursorAction::Reposition(x, y) => { let _ = w.queue(cursor::MoveTo(x, y)); self.last_pos = Some((x, y)); } CursorAction::Show(x, y) => { let _ = w.queue(cursor::MoveTo(x, y)); let _ = w.queue(cursor::Show); self.last_pos = Some((x, y)); } CursorAction::Hide => { let _ = w.queue(cursor::Hide); self.last_pos = None; } } } } /// Render a frame to the terminal with cursor blink preservation. /// /// Bypasses ratatui's `try_draw()` to avoid its unconditional cursor /// management. See [module docs](self) for the full rationale. /// /// The `render_fn` receives a [`Frame`] and a `&mut Vec` to populate /// with the frame's OSC 8 hyperlink regions (absolute viewport coordinates). /// Those spans are handed to the terminal before the diff so hyperlinks /// participate in the cell diff (emitted/cleared in lockstep with content) — /// no out-of-band post-flush repaint. It returns a tuple of: /// - `Option<(u16, u16)>` — cursor position (or `None` to hide cursor) /// - `Option` — escape sequences to write after cell flush (e.g. /// Kitty graphics protocol image data). Written inside the synchronized /// update block so the image appears atomically with the cell diff. pub fn draw_frame( terminal: &mut PagerTerminal, cursor: &mut CursorState, render_fn: impl FnOnce( &mut Frame, &mut Vec, ) -> ( Option<(u16, u16)>, Option, ), ) { let _ = terminal.backend_mut().queue(BeginSynchronizedUpdate); let _ = terminal.autoresize(); let mut link_spans: Vec = Vec::new(); let (cursor_pos, post_flush_escapes) = { let mut frame = terminal.get_frame(); render_fn(&mut frame, &mut link_spans) }; terminal.set_frame_links(&link_spans); let has_changes = terminal.flush_with_links().unwrap_or(false); terminal.swap_buffers(); let post_flush_wrote_cursor = post_flush_escapes.is_some(); let action = cursor.action(cursor_pos, has_changes || post_flush_wrote_cursor); if !has_changes && !post_flush_wrote_cursor && action == CursorAction::None { terminal.backend_mut().writer_mut().discard(); return; } if let Some(post_flush) = post_flush_escapes { let _ = post_flush.write_to(terminal.backend_mut()); } cursor.apply(action, terminal.backend_mut()); let _ = terminal.backend_mut().queue(EndSynchronizedUpdate); let _ = terminal.backend_mut().flush(); } #[cfg(test)] mod tests { use super::*; /// An `unchanged` frame must emit zero bytes to the PTY. #[test] fn idle_frame_emits_zero_bytes() { use ratatui::backend::CrosstermBackend; use ratatui::layout::Rect; use ratatui::widgets::Paragraph; use ratatui::{TerminalOptions, Viewport}; use std::sync::mpsc; fn render( frame: &mut ratatui::Frame, _links: &mut Vec, ) -> ( Option<(u16, u16)>, Option, ) { frame.render_widget(Paragraph::new("hello world"), frame.area()); (None, None) } let (tx, rx) = mpsc::channel::>(); let backend = CrosstermBackend::new(TermWriter::new(tx, WriterSync::new())); let mut terminal = kigi_ratatui_inline::Terminal::with_options( backend, TerminalOptions { viewport: Viewport::Fixed(Rect::new(0, 0, 80, 24)), }, ) .expect("build terminal"); let mut cursor = CursorState::new(); draw_frame(&mut terminal, &mut cursor, render); let first: Vec = rx.try_iter().flatten().collect(); assert!(!first.is_empty(), "first frame should emit bytes"); draw_frame(&mut terminal, &mut cursor, render); let second: Vec = rx.try_iter().flatten().collect(); assert!( second.is_empty(), "idle (unchanged) frame must emit 0 bytes, got {}: {:?}", second.len(), String::from_utf8_lossy(&second), ); } /// `wait_drained` semantics: drained when `written` has caught up with /// `queued` — immediately when nothing is pending, after the consumer /// marks the frame written, and a bounded `false` when it never does. /// This is the happens-before the suspend path relies on so no queued /// frame can race a tty-taking `$EDITOR` / `$PAGER` child. #[test] fn writer_sync_drains_when_written_catches_queued() { let sync = WriterSync::new(); assert!(sync.wait_drained(Duration::from_millis(1))); sync.mark_queued(); assert!(!sync.is_drained()); assert!(!sync.wait_drained(Duration::from_millis(5))); let consumer_sync = sync.clone(); let consumer = std::thread::spawn(move || { std::thread::sleep(Duration::from_millis(10)); consumer_sync.mark_written(); }); assert!(sync.wait_drained(Duration::from_secs(5))); consumer.join().expect("consumer thread"); } /// A `TermWriter::flush` with buffered bytes marks the frame queued; the /// writer-thread side marking it written restores the drained state. #[test] fn term_writer_flush_marks_queued() { let (tx, rx) = mpsc::channel::>(); let sync = WriterSync::new(); let mut writer = TermWriter::new(tx, sync.clone()); writer.flush().expect("flush"); assert!(sync.is_drained()); writer.write_all(b"frame bytes").expect("write"); writer.flush().expect("flush"); assert!(!sync.is_drained(), "queued frame not yet written"); assert_eq!(rx.try_recv().expect("frame on channel"), b"frame bytes"); sync.mark_written(); assert!(sync.is_drained()); } fn state_hidden() -> CursorState { CursorState { last_pos: None } } fn state_at(x: u16, y: u16) -> CursorState { CursorState { last_pos: Some((x, y)), } } #[test] fn hidden_no_changes_stays_hidden() { let s = state_hidden(); assert_eq!(s.action(None, false), CursorAction::None); } #[test] fn visible_same_pos_no_changes_preserves_blink() { let s = state_at(5, 10); assert_eq!(s.action(Some((5, 10)), false), CursorAction::None); } #[test] fn visible_new_pos_no_changes_repositions() { let s = state_at(5, 10); assert_eq!( s.action(Some((6, 10)), false), CursorAction::Reposition(6, 10) ); } #[test] fn hidden_with_changes_stays_hidden() { let s = state_hidden(); assert_eq!(s.action(None, true), CursorAction::None); } #[test] fn visible_same_pos_with_changes_repositions() { let s = state_at(5, 10); assert_eq!( s.action(Some((5, 10)), true), CursorAction::Reposition(5, 10) ); } #[test] fn visible_new_pos_with_changes_repositions() { let s = state_at(5, 10); assert_eq!( s.action(Some((8, 10)), true), CursorAction::Reposition(8, 10) ); } #[test] fn hidden_to_visible_shows() { let s = state_hidden(); assert_eq!(s.action(Some((5, 10)), false), CursorAction::Show(5, 10)); } #[test] fn hidden_to_visible_with_changes_shows() { let s = state_hidden(); assert_eq!(s.action(Some((5, 10)), true), CursorAction::Show(5, 10)); } #[test] fn visible_to_hidden_hides() { let s = state_at(5, 10); assert_eq!(s.action(None, false), CursorAction::Hide); } #[test] fn visible_to_hidden_with_changes_hides() { let s = state_at(5, 10); assert_eq!(s.action(None, true), CursorAction::Hide); } #[test] fn apply_show_updates_last_pos() { let mut s = state_hidden(); let mut sink = Vec::new(); s.apply(CursorAction::Show(3, 7), &mut sink); assert_eq!(s.last_pos, Some((3, 7))); } #[test] fn apply_hide_clears_last_pos() { let mut s = state_at(3, 7); let mut sink = Vec::new(); s.apply(CursorAction::Hide, &mut sink); assert_eq!(s.last_pos, None); } #[test] fn apply_reposition_updates_last_pos() { let mut s = state_at(3, 7); let mut sink = Vec::new(); s.apply(CursorAction::Reposition(5, 9), &mut sink); assert_eq!(s.last_pos, Some((5, 9))); } #[test] fn apply_none_preserves_state() { let mut s = state_at(3, 7); let mut sink = Vec::new(); s.apply(CursorAction::None, &mut sink); assert_eq!(s.last_pos, Some((3, 7))); } /// Verify the writer thread correctly round-trips multi-byte UTF-8 /// through the channel. This catches encoding issues where the writer /// silently corrupts Braille/emoji/CJK characters. #[test] fn writer_thread_preserves_multibyte_utf8() { let test_payload = "⣀⣾⠿⠛\u{e0a0}\u{1F600}"; let expected_bytes = test_payload.as_bytes().to_vec(); let (tx, rx) = std::sync::mpsc::channel::>(); let capture = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); let capture2 = capture.clone(); let handle = std::thread::spawn(move || { let mut buf = Vec::new(); while let Ok(data) = rx.recv() { buf.extend_from_slice(&data); } *capture2.lock().unwrap() = buf; }); tx.send(expected_bytes.clone()).unwrap(); drop(tx); handle.join().unwrap(); let captured = capture.lock().unwrap(); assert_eq!( *captured, expected_bytes, "Writer thread corrupted multi-byte UTF-8 payload" ); assert_eq!( std::str::from_utf8(&captured).unwrap(), test_payload, "Round-tripped bytes do not decode to original UTF-8 string" ); } }