diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a631be8..39a22f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,8 @@ jobs: run: ./tools/parity/tty_compare.sh - name: CLI contract corpus run: ./tools/tests/cli_corpus.sh + - name: Resize behavior on a pty + run: python3 tools/tests/resize_behavior.py test-macos: name: Tests (macOS) diff --git a/src/engine/effect.rs b/src/engine/effect.rs index 6accb18..234069f 100644 --- a/src/engine/effect.rs +++ b/src/engine/effect.rs @@ -13,17 +13,41 @@ pub trait Effect: EffectHooks { fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option; } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RunOutcome { + Complete, + Interrupted, + TerminalResized, +} + /// __main__ run loop with terminal_output(): prep canvas, stream frames, /// always restore the cursor (even on error — RAII would not run on a raw /// process exit, so this is explicit). -pub fn run_effect(effect: &mut dyn Effect, ctx: &mut EngineCtx) -> Result<(), EngineError> { +/// +/// With `stop_on_resize`, a settled terminal resize also ends the pass, wiped +/// and parked at the top of the area so the caller can rebuild in place. +pub fn run_effect( + effect: &mut dyn Effect, + ctx: &mut EngineCtx, + stop_on_resize: bool, +) -> Result { effect.build(ctx)?; let stdout = std::io::stdout(); let mut out = stdout.lock(); ctx.terminal.prep_canvas(&mut out).map_err(io_err)?; + let mut outcome = RunOutcome::Complete; let result = (|| { - while let Some(frame) = effect.next_frame(ctx) { - if crate::interrupted() { + loop { + if let Some(stop) = requested_stop(ctx, stop_on_resize) { + outcome = stop; + break; + } + let Some(frame) = effect.next_frame(ctx) else { + break; + }; + if let Some(stop) = requested_stop(ctx, stop_on_resize) { + outcome = stop; + ctx.terminal.recycle_output_string(frame); break; } ctx.terminal.print_frame(&mut out, &frame).map_err(io_err)?; @@ -31,9 +55,26 @@ pub fn run_effect(effect: &mut dyn Effect, ctx: &mut EngineCtx) -> Result<(), En } Ok(()) })(); - ctx.terminal.restore_cursor(&mut out, "\n").map_err(io_err)?; + if outcome == RunOutcome::TerminalResized { + // Leave the cursor hidden and parked at the top of the wiped area: the + // rebuild redraws in place, and showing the cursor here would strobe it + // dozens of times a second through a window drag. + ctx.terminal.reset_canvas_area(&mut out).map_err(io_err)?; + } else { + ctx.terminal.restore_cursor(&mut out, "\n").map_err(io_err)?; + } out.flush().ok(); - result + result.map(|_| outcome) +} + +fn requested_stop(ctx: &mut EngineCtx, stop_on_resize: bool) -> Option { + if crate::interrupted() { + Some(RunOutcome::Interrupted) + } else if stop_on_resize && ctx.terminal.resize_settled() { + Some(RunOutcome::TerminalResized) + } else { + None + } } /// Parity mode: write length-prefixed frames to stdout, no tty escapes. diff --git a/src/engine/terminal.rs b/src/engine/terminal.rs index 2223915..e21c2ea 100644 --- a/src/engine/terminal.rs +++ b/src/engine/terminal.rs @@ -116,6 +116,12 @@ pub struct Terminal { pub arena: Vec, next_character_id: u32, pub input_colors_frequency: ColorFrequency, + terminal_dimensions: (i64, i64), + resize_seen_at: Option, + layout: Layout, + /// Pre-wrap input line lengths — all `compute_layout` needs from the input, + /// so a resize can re-derive the geometry without re-preprocessing. + input_line_lengths: Vec, pub canvas_column_offset: i64, pub canvas_row_offset: i64, pub visible_top: i64, @@ -179,23 +185,19 @@ impl Terminal { } .preprocess(input_data)?; - let (mut terminal_width, mut terminal_height) = get_terminal_dimensions(); - let (canvas_height, canvas_width) = - get_canvas_dimensions(&config, &preprocessed_lines, terminal_width, terminal_height); - let mut canvas = Canvas::new(canvas_height, canvas_width); - - let (canvas_column_offset, canvas_row_offset) = if !config.ignore_terminal_dimensions { - calc_canvas_offsets(&config, &canvas, terminal_width, terminal_height) - } else { - terminal_width = canvas.right; - terminal_height = canvas.top; - (0, 0) - }; - - let visible_top = std::cmp::min(canvas.top + canvas_row_offset, terminal_height); - let visible_bottom = std::cmp::max(canvas.bottom + canvas_row_offset, 1); - let visible_right = std::cmp::min(canvas.right + canvas_column_offset, terminal_width); - let visible_left = std::cmp::max(canvas.left + canvas_column_offset, 1); + let input_line_lengths: Vec = preprocessed_lines.iter().map(|l| l.len() as i64).collect(); + let terminal_dimensions = get_terminal_dimensions(); + let layout = compute_layout(&config, &input_line_lengths, terminal_dimensions.0, terminal_dimensions.1); + let mut canvas = Canvas::new(layout.canvas_height, layout.canvas_width); + let Layout { + column_offset: canvas_column_offset, + row_offset: canvas_row_offset, + visible_top, + visible_bottom, + visible_right, + visible_left, + .. + } = layout; let input_characters = setup_input_characters(&config, &mut canvas, &mut arena, preprocessed_lines)? .into_iter() @@ -224,6 +226,10 @@ impl Terminal { arena, next_character_id, input_colors_frequency, + terminal_dimensions, + resize_seen_at: None, + layout, + input_line_lengths, canvas_column_offset, canvas_row_offset, visible_top, @@ -602,6 +608,49 @@ impl Terminal { } } + /// Whether a resize has landed, settled, and actually moved something. + /// + /// Settled: dragging a window edge emits a SIGWINCH per step, and rebuilding + /// for each one pins the animation at its opening frames for the whole drag. + /// Each signal restarts a quiet window; the old canvas keeps animating until + /// it expires, so the wait costs nothing on screen. + /// + /// Moved something: a new terminal size is not enough. With an input-sized + /// canvas and no anchor offsets most resizes leave every rendered cell + /// exactly where it was, and restarting for those is pure loss. Explicitly + /// ignored dimensions are fixed by definition. + pub fn resize_settled(&mut self) -> bool { + const QUIET: std::time::Duration = std::time::Duration::from_millis(50); + + if crate::take_terminal_resize() { + self.resize_seen_at = Some(Instant::now()); + } + match self.resize_seen_at { + Some(seen) if seen.elapsed() >= QUIET => self.resize_seen_at = None, + _ => return false, + } + if self.config.ignore_terminal_dimensions { + return false; + } + let (width, height) = get_terminal_dimensions(); + if (width, height) == self.terminal_dimensions { + return false; + } + compute_layout(&self.config, &self.input_line_lengths, width, height) != self.layout + } + + /// After a resize: go back to the top of the area this run allocated, wipe + /// it, and leave the cursor there so the rebuilt canvas takes the same rows + /// instead of scrolling a second one into the terminal. + pub fn reset_canvas_area(&self, out: &mut impl Write) -> std::io::Result<()> { + out.write_all(ansi::DEC_RESTORE_CURSOR.as_bytes())?; + if self.visible_top > 0 { + out.write_all(ansi::move_cursor_up(self.visible_top as usize).as_bytes())?; + } + out.write_all(ansi::CLEAR_TO_END_OF_SCREEN.as_bytes())?; + Ok(()) + } + // --- tty side (upstream's second Terminal instance) --- pub fn prep_canvas(&mut self, out: &mut impl Write) -> std::io::Result<()> { @@ -671,10 +720,54 @@ fn get_terminal_dimensions() -> (i64, i64) { } } +/// Everything about the drawing area that is derived from the terminal size. +/// A resize only matters if recomputing this yields something different, so it +/// is factored out of Terminal::new rather than inlined there. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Layout { + canvas_height: i64, + canvas_width: i64, + column_offset: i64, + row_offset: i64, + visible_top: i64, + visible_bottom: i64, + visible_right: i64, + visible_left: i64, +} + +fn compute_layout( + config: &TerminalConfig, + line_lengths: &[i64], + terminal_width: i64, + terminal_height: i64, +) -> Layout { + let (canvas_height, canvas_width) = + get_canvas_dimensions(config, line_lengths, terminal_width, terminal_height); + let canvas = Canvas::new(canvas_height, canvas_width); + let (mut width, mut height) = (terminal_width, terminal_height); + let (column_offset, row_offset) = if !config.ignore_terminal_dimensions { + calc_canvas_offsets(config, &canvas, width, height) + } else { + width = canvas.right; + height = canvas.top; + (0, 0) + }; + Layout { + canvas_height, + canvas_width, + column_offset, + row_offset, + visible_top: std::cmp::min(canvas.top + row_offset, height), + visible_bottom: std::cmp::max(canvas.bottom + row_offset, 1), + visible_right: std::cmp::min(canvas.right + column_offset, width), + visible_left: std::cmp::max(canvas.left + column_offset, 1), + } +} + /// Terminal._get_canvas_dimensions -> (height, width). fn get_canvas_dimensions( config: &TerminalConfig, - preprocessed_lines: &[Vec], + line_lengths: &[i64], terminal_width: i64, terminal_height: i64, ) -> (i64, i64) { @@ -683,7 +776,7 @@ fn get_canvas_dimensions( } else if config.canvas_width == 0 { terminal_width } else { - let input_width = preprocessed_lines.iter().map(|l| l.len() as i64).max().unwrap_or(0); + let input_width = line_lengths.iter().copied().max().unwrap_or(0); if config.ignore_terminal_dimensions { input_width } else { @@ -695,11 +788,11 @@ fn get_canvas_dimensions( } else if config.canvas_height == 0 { terminal_height } else { - let input_height = preprocessed_lines.len() as i64; + let input_height = line_lengths.len() as i64; if config.ignore_terminal_dimensions { input_height } else if config.wrap_text { - std::cmp::min(wrapped_line_count(preprocessed_lines, canvas_width), terminal_height) + std::cmp::min(wrapped_line_count(line_lengths, canvas_width), terminal_height) } else { std::cmp::min(terminal_height, input_height) } @@ -707,10 +800,10 @@ fn get_canvas_dimensions( (canvas_height, canvas_width) } -fn wrapped_line_count(lines: &[Vec], width: i64) -> i64 { +fn wrapped_line_count(line_lengths: &[i64], width: i64) -> i64 { let mut count: i64 = 0; - for line in lines { - let mut remaining = line.len() as i64; + for &length in line_lengths { + let mut remaining = length; while remaining > width { count += 1; remaining -= width; diff --git a/src/lib.rs b/src/lib.rs index 8dfc8b5..54be714 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ pub mod utils; use std::sync::atomic::{AtomicBool, Ordering}; static INTERRUPTED: AtomicBool = AtomicBool::new(false); +static TERMINAL_RESIZED: AtomicBool = AtomicBool::new(false); /// SIGINT is recorded and checked from the run loop so teardown (cursor /// restore) happens through normal control flow — Drop alone would not run on @@ -13,7 +14,7 @@ static INTERRUPTED: AtomicBool = AtomicBool::new(false); pub fn install_sigint_handler() { // SAFETY: signal(2) with a signal-safe handler that only stores a flag. unsafe { - libc_signal(2 /* SIGINT */, handle_sigint as *const () as usize); + libc_signal(SIGINT, handle_sigint as *const () as usize); } } @@ -25,14 +26,38 @@ pub fn interrupted() -> bool { INTERRUPTED.load(Ordering::SeqCst) } +/// Record terminal resizes so the CLI can rebuild effects whose canvas and +/// character positions were derived from the previous dimensions. +pub fn install_sigwinch_handler() { + // SAFETY: signal(2) with a signal-safe handler that only stores a flag. + unsafe { + libc_signal(SIGWINCH, handle_sigwinch as *const () as usize); + } +} + +extern "C" fn handle_sigwinch(_: i32) { + TERMINAL_RESIZED.store(true, Ordering::SeqCst); +} + +/// Consume a pending terminal resize notification. +pub fn take_terminal_resize() -> bool { + TERMINAL_RESIZED.swap(false, Ordering::SeqCst) +} + /// Restore default SIGPIPE so `ttfx ... | head` dies quietly like any Unix /// tool instead of panicking on a broken pipe (Rust ignores SIGPIPE by default). pub fn restore_sigpipe() { unsafe { - libc_signal(13 /* SIGPIPE */, 0 /* SIG_DFL */); + libc_signal(SIGPIPE, SIG_DFL); } } +const SIGINT: i32 = 2; +const SIGPIPE: i32 = 13; +/// 28 on Linux and on the BSDs, macOS included. +const SIGWINCH: i32 = 28; +const SIG_DFL: usize = 0; + unsafe fn libc_signal(signum: i32, handler: usize) { unsafe extern "C" { fn signal(signum: i32, handler: usize) -> usize; @@ -41,3 +66,16 @@ unsafe fn libc_signal(signum: i32, handler: usize) { signal(signum, handler); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn terminal_resize_notifications_are_consumed() { + take_terminal_resize(); + handle_sigwinch(SIGWINCH); + assert!(take_terminal_resize()); + assert!(!take_terminal_resize()); + } +} diff --git a/src/main.rs b/src/main.rs index 4df1261..3a4153e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,6 +25,18 @@ fn get_piped_input() -> String { } } +/// Skip the arena teardown on the way out. Output is already flushed and +/// nothing in the engine has a Drop impl that does work, so freeing tens of +/// thousands of characters — each with its own scenes, paths and frames — one +/// by one is pure exit latency; on binarypath it is ~4% of the run. +/// +/// Only the exit paths come through here. A resize rebuild drops its engine +/// normally, so a long session of resizes does not accumulate them. +fn forget_engine(effect: E, ctx: C) { + std::mem::forget(effect); + std::mem::forget(ctx); +} + fn main() -> ExitCode { ttfx::restore_sigpipe(); let cli = cli::Cli::parse(); @@ -109,39 +121,65 @@ fn main() -> ExitCode { } }; - let config = cli.terminal_config(); - let clock = if cli.parity_dump || cli.virtual_clock { - ttfx::engine::ctx::Clock::virtual_with_frame_rate(config.frame_rate) - } else { - ttfx::engine::ctx::Clock::real() - }; - let frame_rate = config.frame_rate; - let mut ctx = match ttfx::engine::ctx::EngineCtx::new(&input_data, config, rng, clock) { - Ok(ctx) => ctx, - Err(engine::error::EngineError::UnsupportedAnsiSequence(seq)) => { - eprintln!("Error: Unsupported ANSI sequence in input data: {seq:?}"); - return ExitCode::from(1); - } - Err(e) => { - eprintln!("Error: {e}"); - return ExitCode::from(1); - } - }; - let _ = frame_rate; - let mut effect = effect_command.build_effect(); - - let result = if cli.parity_dump { - ttfx::engine::effect::dump_effect(effect.as_mut(), &mut ctx, cli.max_frames).map(|_| ()) - } else { + let mut config = cli.terminal_config(); + // SIGWINCH is delivered to every process in the terminal's foreground group, + // whatever its stdout points at. Reacting to it when the animation is being + // redirected would leave a truncated first run followed by a complete second + // one in the file, so the resize path is tty-only. + let resize_aware = !cli.parity_dump && std::io::stdout().is_terminal(); + if !cli.parity_dump { ttfx::install_sigint_handler(); - ttfx::engine::effect::run_effect(effect.as_mut(), &mut ctx) + } + if resize_aware { + ttfx::install_sigwinch_handler(); + } + + let result = loop { + let clock = if cli.parity_dump || cli.virtual_clock { + ttfx::engine::ctx::Clock::virtual_with_frame_rate(config.frame_rate) + } else { + ttfx::engine::ctx::Clock::real() + }; + let mut ctx = match ttfx::engine::ctx::EngineCtx::new( + &input_data, + config.clone(), + rng, + clock, + ) { + Ok(ctx) => ctx, + Err(engine::error::EngineError::UnsupportedAnsiSequence(seq)) => { + eprintln!("Error: Unsupported ANSI sequence in input data: {seq:?}"); + return ExitCode::from(1); + } + Err(e) => { + eprintln!("Error: {e}"); + return ExitCode::from(1); + } + }; + let mut effect = effect_command.build_effect(); + + let outcome = if cli.parity_dump { + ttfx::engine::effect::dump_effect(effect.as_mut(), &mut ctx, cli.max_frames) + .map(|_| ttfx::engine::effect::RunOutcome::Complete) + } else { + ttfx::engine::effect::run_effect(effect.as_mut(), &mut ctx, resize_aware) + }; + match outcome { + Ok(ttfx::engine::effect::RunOutcome::TerminalResized) => { + // run_effect wiped the old area and left the cursor at its top, + // so the rebuild lays out from here. --reuse-canvas would send + // prep_canvas to a DEC anchor that no longer applies, so it only + // governs the first run. Dropping this engine normally is what + // keeps a long session of resizes from accumulating them. + config.reuse_canvas = false; + rng = ctx.rng; + } + done => { + forget_engine(effect, ctx); + break done.map(|_| ()); + } + } }; - // Output is already flushed, and nothing in the engine has a Drop impl that - // does work. Freeing an arena of tens of thousands of characters, each with - // its own scenes, paths and frames, is pure exit latency — on binarypath it - // is ~4% of the run. Hand it to the kernel instead. - std::mem::forget(effect); - std::mem::forget(ctx); std::mem::forget(input_data); match result { diff --git a/src/utils/ansi.rs b/src/utils/ansi.rs index bd0298e..328d91d 100644 --- a/src/utils/ansi.rs +++ b/src/utils/ansi.rs @@ -7,6 +7,7 @@ pub const DEC_RESTORE_CURSOR: &str = "\x1b8"; pub const HIDE_CURSOR: &str = "\x1b[?25l"; pub const SHOW_CURSOR: &str = "\x1b[?25h"; pub const RESET_ALL: &str = "\x1b[0m"; +pub const CLEAR_TO_END_OF_SCREEN: &str = "\x1b[0J"; pub const BOLD: &str = "\x1b[1m"; pub const DIM: &str = "\x1b[2m"; pub const ITALIC: &str = "\x1b[3m"; diff --git a/tools/tests/resize_behavior.py b/tools/tests/resize_behavior.py new file mode 100644 index 0000000..485da82 --- /dev/null +++ b/tools/tests/resize_behavior.py @@ -0,0 +1,150 @@ +"""Resize-restart behavior, driven on a real pty. + +The restart-on-resize path is signal-driven and tty-dependent, so none of the +other suites can see it: parity runs go through --parity-dump, and the CLI +corpus never allocates a terminal. This spawns the built binary on a pty, +drives TIOCSWINSZ, and asserts on the emitted byte stream. + +Each prep_canvas emits exactly one hide-cursor, so counting those counts runs. + +Usage: resize_behavior.py [path-to-ttfx] +""" + +from __future__ import annotations + +import fcntl +import os +import pty +import select +import struct +import sys +import termios +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +BIN = sys.argv[1] if len(sys.argv) > 1 else str(ROOT / "target/release/ttfx") +HIDE, SHOW = b"\x1b[?25l", b"\x1b[?25h" +MAX_BYTES = 8 << 20 +TEXT = b"hello world\nsecond line" + + +def set_size(fd: int, cols: int, rows: int) -> None: + fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0)) + + +def child_env() -> dict[str, str]: + # COLUMNS/LINES would win over the tty and make every resize invisible. + return {k: v for k, v in os.environ.items() if k not in ("COLUMNS", "LINES")} + + +def spawn(args, stdout_pipe: bool): + """Fork onto a pty; stdin is always a pipe, stdout optionally one too.""" + sin_r, sin_w = os.pipe() + out_r, out_w = (os.pipe() if stdout_pipe else (None, None)) + pid, fd = pty.fork() + if pid == 0: + os.close(sin_w) + os.dup2(sin_r, 0) + os.close(sin_r) + if stdout_pipe: + os.close(out_r) + os.dup2(out_w, 1) + os.close(out_w) + os.execve(BIN, [BIN] + args, child_env()) + os._exit(127) + os.close(sin_r) + if stdout_pipe: + os.close(out_w) + return pid, fd, sin_w, out_r + + +def drive(args, resizes=(), cols=80, rows=24, first_delay=0.30, gap=0.05, + budget=8.0, stdout_pipe=False, slow=False): + """Run to completion (or budget), applying `resizes`; return the stream.""" + pid, fd, sin_w, out_r = spawn(args, stdout_pipe) + set_size(fd, cols, rows) + os.write(sin_w, TEXT) + os.close(sin_w) + + source = out_r if stdout_pipe else fd + captured = bytearray() + start = time.time() + applied = False + while time.time() - start < budget and len(captured) < MAX_BYTES: + if not applied and time.time() - start >= first_delay: + for size in resizes: + set_size(fd, *size) + time.sleep(gap) + applied = True + ready, _, _ = select.select([source], [], [], 0.02) + if ready: + try: + chunk = os.read(source, 256 if slow else 65536) + except OSError: + break + if not chunk: + break + captured.extend(chunk) + elif not stdout_pipe and os.waitpid(pid, os.WNOHANG)[0] == pid: + break + if slow: + time.sleep(0.02) + for closer in (lambda: os.kill(pid, 9), lambda: os.waitpid(pid, 0)): + try: + closer() + except (ProcessLookupError, ChildProcessError): + pass + os.close(fd) + if stdout_pipe: + os.close(out_r) + return bytes(captured) + + +def runs_in(stream: bytes) -> int: + return stream.count(HIDE) + + +def main() -> int: + failures = 0 + + def check(label, got, want): + nonlocal failures + ok = got == want + failures += not ok + print(f" {'ok ' if ok else 'FAIL'} {label}: got {got}, want {want}") + + # A slow consumer keeps the process alive across the resize; a file sink + # drains instantly and the animation would finish before the signal lands. + print("stdout piped to a slow consumer, window resized") + piped = ["--seed", "1", "--canvas-width", "0", "pour"] + quiet = drive(piped, stdout_pipe=True, slow=True, first_delay=0.5, budget=6.0) + noisy = drive(piped, resizes=[(40, 24)], stdout_pipe=True, slow=True, first_delay=0.5, budget=6.0) + check("runs without a resize", runs_in(quiet), 1) + check("runs with a resize", runs_in(noisy), 1) + check("bytes match the undisturbed run", len(noisy), len(quiet)) + + print("tty, resize that cannot move a cell") + check("runs", runs_in(drive(["--seed", "1", "pour"], resizes=[(100, 30)])), 1) + + print("tty, resize that changes the canvas") + changed = drive(["--seed", "1", "pour"], resizes=[(8, 24)]) + check("runs", runs_in(changed), 2) + + print("tty, burst of resizes during a drag") + burst = drive(["--seed", "1", "pour"], + resizes=[(9, 24), (8, 24), (7, 24), (6, 24), (7, 24), (8, 24)], gap=0.02) + rebuilds = runs_in(burst) + ok = rebuilds <= 3 + failures += not ok + print(f" {'ok ' if ok else 'FAIL'} rebuilds for a 6-step drag: {rebuilds}, want <= 3") + + print("tty, cursor is not shown between runs") + check("show-cursor before the rebuild", changed[: changed.rfind(HIDE)].count(SHOW), 0) + + print(f"\nresize behavior: {'all checks passed' if not failures else f'{failures} failed'}") + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main())