A terminal that stopped draining the pty parked the writer thread inside its tty write while it held the stderr lock; the next inline stderr write from the event loop (mouse toggle, notification, kitty clear) deadlocked the loop, and /quit hung on the unbounded writer join and the teardown writes. EscapeWriter queues out-of-band escapes on the frame writer: the mouse capture toggle, notifications (title, progress, protocol), the auth cursor, gboom keyboard flags, the wrap clipboard request, and the kitty clears (queued per agent view and merged into the frame's post-flush, nested subagent views included). The $EDITOR/$PAGER handoff aborts when the writer does not drain. WriterThread::join_within and the teardown run under a 2 s grace; after a stall fd 2 is silenced, the exit hints, relaunch and update prints are skipped, and the panic hook keeps only its log report. Ported from upstream 1.0.19 (grok-build EscapeWriter, WriterJoin, run_bounded_teardown), extended to the remaining inline writes. Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
697 lines
27 KiB
Rust
697 lines
27 KiB
Rust
//! 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<CrosstermBackend<TermWriter>>;
|
|
/// 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<std::sync::atomic::AtomicU64>,
|
|
written: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
|
}
|
|
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<u8>`. 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<u8>,
|
|
tx: mpsc::Sender<Vec<u8>>,
|
|
sync: WriterSync,
|
|
}
|
|
impl TermWriter {
|
|
pub fn new(tx: mpsc::Sender<Vec<u8>>, 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
|
|
}
|
|
/// A cloneable handle onto this writer's queue for out-of-band escapes.
|
|
pub fn escape_writer(&self) -> EscapeWriter {
|
|
EscapeWriter::new(self.tx.clone(), self.sync.clone())
|
|
}
|
|
}
|
|
impl Write for TermWriter {
|
|
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
|
|
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();
|
|
}
|
|
}
|
|
/// Out-of-band escapes ride the frame queue (an inline stderr write deadlocks on a stalled tty); drop every clone before `join_within`.
|
|
#[derive(Clone)]
|
|
pub struct EscapeWriter {
|
|
tx: mpsc::Sender<Vec<u8>>,
|
|
sync: WriterSync,
|
|
}
|
|
impl EscapeWriter {
|
|
pub fn new(tx: mpsc::Sender<Vec<u8>>, sync: WriterSync) -> Self {
|
|
Self { tx, sync }
|
|
}
|
|
/// No writer thread behind it: sends are dropped. For tests and views built before the terminal exists.
|
|
pub fn disconnected() -> Self {
|
|
let (tx, _rx) = mpsc::channel::<Vec<u8>>();
|
|
Self {
|
|
tx,
|
|
sync: WriterSync::new(),
|
|
}
|
|
}
|
|
/// Never blocks; covered by [`WriterSync::wait_drained`].
|
|
pub fn emit(&self, bytes: impl Into<Vec<u8>>) {
|
|
let data: Vec<u8> = bytes.into();
|
|
if data.is_empty() {
|
|
return;
|
|
}
|
|
self.sync.mark_queued();
|
|
let _ = self.tx.send(data);
|
|
}
|
|
/// Winapi-only commands run synchronously: a console API call, not a tty write.
|
|
pub fn emit_command(&self, command: impl crossterm::Command) {
|
|
#[cfg(windows)]
|
|
if !command.is_ansi_code_supported() {
|
|
let _ = command.execute_winapi();
|
|
return;
|
|
}
|
|
let mut ansi = String::new();
|
|
if command.write_ansi(&mut ansi).is_ok() {
|
|
self.emit(ansi);
|
|
}
|
|
}
|
|
}
|
|
/// Outcome of [`WriterThread::join_within`].
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum WriterJoin {
|
|
/// The thread drained its queue and exited.
|
|
Joined,
|
|
/// Still running at the deadline (tty blocked, or a sender alive); detached.
|
|
TimedOut,
|
|
}
|
|
/// Teardown waits this long for the writer thread before detaching it, so a stalled tty cannot hang `/quit`.
|
|
pub const WRITER_JOIN_GRACE: Duration = Duration::from_secs(2);
|
|
/// 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<std::thread::JoinHandle<()>>,
|
|
}
|
|
impl WriterThread {
|
|
/// Wait up to `grace` for the thread to drain and exit, else detach it; every sender (`EscapeWriter` clones too) must be dropped first.
|
|
pub fn join_within(mut self, grace: Duration) -> WriterJoin {
|
|
let Some(handle) = self.handle.take() else {
|
|
return WriterJoin::Joined;
|
|
};
|
|
if wait_finished(&handle, grace) {
|
|
let _ = handle.join();
|
|
WriterJoin::Joined
|
|
} else {
|
|
tracing::warn!(
|
|
grace_ms = grace.as_millis() as u64,
|
|
"term-writer thread still running at teardown; detaching"
|
|
);
|
|
WriterJoin::TimedOut
|
|
}
|
|
}
|
|
#[cfg(test)]
|
|
fn for_test(handle: std::thread::JoinHandle<()>) -> Self {
|
|
Self {
|
|
handle: Some(handle),
|
|
}
|
|
}
|
|
}
|
|
fn wait_finished(handle: &std::thread::JoinHandle<()>, grace: Duration) -> bool {
|
|
let deadline = Instant::now() + grace;
|
|
while !handle.is_finished() {
|
|
if Instant::now() >= deadline {
|
|
return false;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(1));
|
|
}
|
|
true
|
|
}
|
|
impl Drop for WriterThread {
|
|
fn drop(&mut self) {
|
|
if let Some(handle) = self.handle.take()
|
|
&& wait_finished(&handle, WRITER_JOIN_GRACE)
|
|
{
|
|
let _ = handle.join();
|
|
}
|
|
}
|
|
}
|
|
/// Spawn a background OS thread that writes frame data to stderr.
|
|
///
|
|
/// Returns `(Sender, WriterSync, WriterThread)`. Send `Vec<u8>` 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_within`] to wait for it.
|
|
pub fn spawn_writer_thread() -> (mpsc::Sender<Vec<u8>>, WriterSync, WriterThread) {
|
|
let (tx, rx) = mpsc::channel::<Vec<u8>>();
|
|
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::<u64>().ok())
|
|
.map(Duration::from_millis);
|
|
let handle = std::thread::Builder::new()
|
|
.name("term-writer".into())
|
|
.spawn(move || {
|
|
#[cfg(not(windows))]
|
|
let mut writer: Box<dyn std::io::Write> = {
|
|
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<dyn std::io::Write> = 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<W: Write>(&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<LinkSpan>` 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<PostFlush>` — 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<LinkSpan>,
|
|
) -> (
|
|
Option<(u16, u16)>,
|
|
Option<crate::terminal::overlay::PostFlush>,
|
|
),
|
|
) {
|
|
let _ = terminal.backend_mut().queue(BeginSynchronizedUpdate);
|
|
let _ = terminal.autoresize();
|
|
let mut link_spans: Vec<LinkSpan> = 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<LinkSpan>,
|
|
) -> (
|
|
Option<(u16, u16)>,
|
|
Option<crate::terminal::overlay::PostFlush>,
|
|
) {
|
|
frame.render_widget(Paragraph::new("hello world"), frame.area());
|
|
(None, None)
|
|
}
|
|
let (tx, rx) = mpsc::channel::<Vec<u8>>();
|
|
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<u8> = rx.try_iter().flatten().collect();
|
|
assert!(!first.is_empty(), "first frame should emit bytes");
|
|
draw_frame(&mut terminal, &mut cursor, render);
|
|
let second: Vec<u8> = 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::<Vec<u8>>();
|
|
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::<Vec<u8>>();
|
|
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"
|
|
);
|
|
}
|
|
/// Frames and escapes share one queue, so the writer sees them in call order.
|
|
#[test]
|
|
#[cfg(not(windows))]
|
|
fn escapes_share_the_frame_queue_in_call_order() {
|
|
let (tx, rx) = mpsc::channel::<Vec<u8>>();
|
|
let sync = WriterSync::new();
|
|
let mut frames = TermWriter::new(tx, sync.clone());
|
|
let escapes = frames.escape_writer();
|
|
frames.write_all(b"frame-1").unwrap();
|
|
frames.flush().unwrap();
|
|
escapes.emit("\x1b[?1000h");
|
|
frames.write_all(b"frame-2").unwrap();
|
|
frames.flush().unwrap();
|
|
escapes.emit_command(crossterm::event::DisableMouseCapture);
|
|
let payloads: Vec<Vec<u8>> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
|
|
assert_eq!(payloads.len(), 4);
|
|
assert_eq!(payloads[0], b"frame-1");
|
|
assert_eq!(payloads[1], b"\x1b[?1000h");
|
|
assert_eq!(payloads[2], b"frame-2");
|
|
assert!(payloads[3].starts_with(b"\x1b["), "{:?}", payloads[3]);
|
|
assert!(!sync.is_drained(), "four payloads queued, none written");
|
|
}
|
|
/// A writer thread parked in a blocked tty write must not hang teardown.
|
|
#[test]
|
|
fn join_within_times_out_on_a_stuck_writer_thread() {
|
|
let (release_tx, release_rx) = mpsc::channel::<()>();
|
|
let handle = std::thread::spawn(move || {
|
|
let _ = release_rx.recv();
|
|
});
|
|
let started = Instant::now();
|
|
let outcome = WriterThread::for_test(handle).join_within(Duration::from_millis(50));
|
|
assert_eq!(outcome, WriterJoin::TimedOut);
|
|
assert!(started.elapsed() < Duration::from_secs(2));
|
|
let _ = release_tx.send(());
|
|
}
|
|
#[test]
|
|
fn join_within_joins_a_finished_writer_thread() {
|
|
let handle = std::thread::spawn(|| {});
|
|
assert_eq!(
|
|
WriterThread::for_test(handle).join_within(Duration::from_secs(5)),
|
|
WriterJoin::Joined
|
|
);
|
|
}
|
|
}
|