From 86abf163462dde121d39f08617a4b07cb4e1dadf Mon Sep 17 00:00:00 2001 From: adityagarud Date: Mon, 10 Aug 2026 12:57:04 +0000 Subject: [PATCH 1/4] Rebuild effects when the terminal is resized Newly created terminals can briefly expose the pty default of 80x24 before the compositor assigns the real window size. Since effect state is built from the measured canvas, that leaves fullscreen animations painting into one corner. Record SIGWINCH, verify that the dimensions actually changed, and end the current render pass without advancing the cursor. The CLI then rebuilds the same selected effect against the new dimensions, carrying the RNG state forward and reusing the existing output area. Normal runs and the existing library runner remain unchanged. --- src/engine/effect.rs | 47 +++++++++++++++++++++++++++-- src/engine/terminal.rs | 8 +++++ src/lib.rs | 33 ++++++++++++++++++++ src/main.rs | 68 +++++++++++++++++++++++++++--------------- 4 files changed, 130 insertions(+), 26 deletions(-) diff --git a/src/engine/effect.rs b/src/engine/effect.rs index 6accb18..db4dd66 100644 --- a/src/engine/effect.rs +++ b/src/engine/effect.rs @@ -13,17 +13,53 @@ 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> { + run_effect_inner(effect, ctx, false).map(|_| ()) +} + +/// Run an effect until it completes, is interrupted, or the terminal changes +/// size. The CLI uses the resize outcome to rebuild dimension-dependent effect +/// state; `run_effect` remains unchanged for library callers. +pub fn run_effect_resize_aware( + effect: &mut dyn Effect, + ctx: &mut EngineCtx, +) -> Result { + run_effect_inner(effect, ctx, true) +} + +fn run_effect_inner( + 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() { + outcome = RunOutcome::Interrupted; + ctx.terminal.recycle_output_string(frame); + break; + } + if stop_on_resize + && crate::take_terminal_resize() + && ctx.terminal.dimensions_changed() + { + outcome = RunOutcome::TerminalResized; + ctx.terminal.recycle_output_string(frame); break; } ctx.terminal.print_frame(&mut out, &frame).map_err(io_err)?; @@ -31,9 +67,16 @@ 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)?; + let end_symbol = if outcome == RunOutcome::TerminalResized { + "" + } else { + "\n" + }; + ctx.terminal + .restore_cursor(&mut out, end_symbol) + .map_err(io_err)?; out.flush().ok(); - result + result.map(|_| outcome) } /// 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..a53e928 100644 --- a/src/engine/terminal.rs +++ b/src/engine/terminal.rs @@ -602,6 +602,14 @@ impl Terminal { } } + /// Whether a SIGWINCH corresponds to dimensions that would change this + /// terminal's layout. Explicitly ignored dimensions remain fixed by + /// definition, even if the surrounding tty changes size. + pub fn dimensions_changed(&self) -> bool { + !self.config.ignore_terminal_dimensions + && get_terminal_dimensions() != (self.terminal_width, self.terminal_height) + } + // --- tty side (upstream's second Terminal instance) --- pub fn prep_canvas(&mut self, out: &mut impl Write) -> std::io::Result<()> { diff --git a/src/lib.rs b/src/lib.rs index 8dfc8b5..5e3b397 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 @@ -25,6 +26,25 @@ 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() { + // SIGWINCH is 28 on both supported targets (Linux and macOS). The handler + // is signal-safe: like SIGINT above, it only stores an atomic flag. + unsafe { + libc_signal(28 /* 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() { @@ -41,3 +61,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(28); + assert!(take_terminal_resize()); + assert!(!take_terminal_resize()); + } +} diff --git a/src/main.rs b/src/main.rs index 4df1261..29882d5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -109,32 +109,52 @@ 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 mut config = cli.terminal_config(); + if !cli.parity_dump { + ttfx::install_sigint_handler(); + 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(); + + if cli.parity_dump { + break ttfx::engine::effect::dump_effect(effect.as_mut(), &mut ctx, cli.max_frames) + .map(|_| ()); } - }; - 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 { - ttfx::install_sigint_handler(); - ttfx::engine::effect::run_effect(effect.as_mut(), &mut ctx) + match ttfx::engine::effect::run_effect_resize_aware(effect.as_mut(), &mut ctx) { + Ok(ttfx::engine::effect::RunOutcome::TerminalResized) => { + // The current output area is already allocated. Reuse it when + // rebuilding at the new dimensions instead of scrolling a + // second canvas into the terminal. + config.reuse_canvas = true; + rng = ctx.rng; + } + Ok(_) => break Ok(()), + Err(e) => break Err(e), + } }; // 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 From 5384be256ebc5fd008c93dfcfa70ab0c34485e40 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 10 Aug 2026 07:32:22 -0700 Subject: [PATCH 2/4] Fix resize portability and responsiveness --- Cargo.lock | 1 + Cargo.toml | 1 + src/engine/effect.rs | 30 +++++++++++++++++++++--------- src/engine/terminal.rs | 32 ++++++++++++++++++++++++++++++-- src/lib.rs | 32 +++++++++++++++++--------------- 5 files changed, 70 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eedf75e..d37d23b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -217,6 +217,7 @@ version = "0.2.1" dependencies = [ "clap", "clap_complete", + "libc", "terminal_size", ] diff --git a/Cargo.toml b/Cargo.toml index ecf9637..78833cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ license = "MIT" [dependencies] clap = { version = "4", features = ["derive"] } clap_complete = "4.6.9" +libc = "0.2" terminal_size = "0.4" [profile.release] diff --git a/src/engine/effect.rs b/src/engine/effect.rs index db4dd66..6215e38 100644 --- a/src/engine/effect.rs +++ b/src/engine/effect.rs @@ -48,17 +48,16 @@ fn run_effect_inner( 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() { - outcome = RunOutcome::Interrupted; - ctx.terminal.recycle_output_string(frame); + loop { + if let Some(stop) = requested_stop(ctx, stop_on_resize) { + outcome = stop; break; } - if stop_on_resize - && crate::take_terminal_resize() - && ctx.terminal.dimensions_changed() - { - outcome = RunOutcome::TerminalResized; + 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; } @@ -79,6 +78,19 @@ fn run_effect_inner( result.map(|_| outcome) } +fn requested_stop(ctx: &EngineCtx, stop_on_resize: bool) -> Option { + if crate::interrupted() { + Some(RunOutcome::Interrupted) + } else if stop_on_resize + && crate::take_terminal_resize() + && ctx.terminal.dimensions_changed() + { + Some(RunOutcome::TerminalResized) + } else { + None + } +} + /// Parity mode: write length-prefixed frames to stdout, no tty escapes. pub fn dump_effect( effect: &mut dyn Effect, diff --git a/src/engine/terminal.rs b/src/engine/terminal.rs index a53e928..c6b7ef9 100644 --- a/src/engine/terminal.rs +++ b/src/engine/terminal.rs @@ -116,6 +116,7 @@ pub struct Terminal { pub arena: Vec, next_character_id: u32, pub input_colors_frequency: ColorFrequency, + terminal_dimensions: (i64, i64), pub canvas_column_offset: i64, pub canvas_row_offset: i64, pub visible_top: i64, @@ -180,6 +181,7 @@ impl Terminal { .preprocess(input_data)?; let (mut terminal_width, mut terminal_height) = get_terminal_dimensions(); + let terminal_dimensions = (terminal_width, terminal_height); let (canvas_height, canvas_width) = get_canvas_dimensions(&config, &preprocessed_lines, terminal_width, terminal_height); let mut canvas = Canvas::new(canvas_height, canvas_width); @@ -224,6 +226,7 @@ impl Terminal { arena, next_character_id, input_colors_frequency, + terminal_dimensions, canvas_column_offset, canvas_row_offset, visible_top, @@ -607,7 +610,7 @@ impl Terminal { /// definition, even if the surrounding tty changes size. pub fn dimensions_changed(&self) -> bool { !self.config.ignore_terminal_dimensions - && get_terminal_dimensions() != (self.terminal_width, self.terminal_height) + && get_terminal_dimensions() != self.terminal_dimensions } // --- tty side (upstream's second Terminal instance) --- @@ -654,12 +657,37 @@ impl Terminal { let frame_delay = 1.0 / self.frame_rate as f64; let elapsed = self.last_time_printed.elapsed().as_secs_f64(); if elapsed < frame_delay { - std::thread::sleep(std::time::Duration::from_secs_f64(frame_delay - elapsed)); + sleep_until_signal(std::time::Duration::from_secs_f64(frame_delay - elapsed)); } self.last_time_printed = Instant::now(); } } +/// Sleep for frame pacing, but return promptly when one of the installed +/// process handlers records an interrupt or terminal resize. +fn sleep_until_signal(duration: std::time::Duration) { + let mut requested = libc::timespec { + tv_sec: duration.as_secs() as libc::time_t, + tv_nsec: duration.subsec_nanos() as libc::c_long, + }; + loop { + let mut remaining = std::mem::MaybeUninit::::uninit(); + // SAFETY: both pointers refer to valid timespec storage for the call. + let result = unsafe { libc::nanosleep(&requested, remaining.as_mut_ptr()) }; + if result == 0 { + break; + } + if std::io::Error::last_os_error().raw_os_error() != Some(libc::EINTR) { + break; + } + if crate::interrupted() || crate::terminal_resize_pending() { + break; + } + // SAFETY: POSIX requires nanosleep to initialize `remaining` on EINTR. + requested = unsafe { remaining.assume_init() }; + } +} + /// shutil.get_terminal_size semantics: COLUMNS/LINES env vars win; else query /// the tty; on failure (80, 24). fn get_terminal_dimensions() -> (i64, i64) { diff --git a/src/lib.rs b/src/lib.rs index 5e3b397..14300e3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,7 +14,10 @@ static TERMINAL_RESIZED: 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( + libc::SIGINT, + handle_sigint as *const () as libc::sighandler_t, + ); } } @@ -29,10 +32,12 @@ pub fn interrupted() -> bool { /// 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() { - // SIGWINCH is 28 on both supported targets (Linux and macOS). The handler - // is signal-safe: like SIGINT above, it only stores an atomic flag. + // SAFETY: signal(2) with a signal-safe handler that only stores a flag. unsafe { - libc_signal(28 /* SIGWINCH */, handle_sigwinch as *const () as usize); + libc::signal( + libc::SIGWINCH, + handle_sigwinch as *const () as libc::sighandler_t, + ); } } @@ -45,20 +50,15 @@ pub fn take_terminal_resize() -> bool { TERMINAL_RESIZED.swap(false, Ordering::SeqCst) } +pub(crate) fn terminal_resize_pending() -> bool { + TERMINAL_RESIZED.load(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 */); - } -} - -unsafe fn libc_signal(signum: i32, handler: usize) { - unsafe extern "C" { - fn signal(signum: i32, handler: usize) -> usize; - } - unsafe { - signal(signum, handler); + libc::signal(libc::SIGPIPE, libc::SIG_DFL); } } @@ -69,8 +69,10 @@ mod tests { #[test] fn terminal_resize_notifications_are_consumed() { take_terminal_resize(); - handle_sigwinch(28); + handle_sigwinch(libc::SIGWINCH); + assert!(terminal_resize_pending()); assert!(take_terminal_resize()); + assert!(!terminal_resize_pending()); assert!(!take_terminal_resize()); } } From 6db0138f65a79e31124482c36667e39f28d2a29c Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 10 Aug 2026 12:39:31 -0700 Subject: [PATCH 3/4] Address review of the resize-restart path Six fixes from the review of #4, plus a pty-driven regression test that fails on all of them without these changes. Only react to SIGWINCH when stdout is a terminal. The signal reaches every process in the terminal's foreground group whatever its stdout points at, and terminal_size() falls back to stderr, so `ttfx pour | less` in a window being resized restarted mid-stream: the consumer saw a truncated first run followed by a complete second one. A file sink drains too fast to show it, which is why the test drives a deliberately slow reader. Compare the canvas geometry, not the raw terminal size. With an input-sized canvas and no anchor offsets, most resizes cannot move a single rendered cell, and restarting for those is pure loss. compute_layout is now factored out of Terminal::new so a resize can re-derive the geometry from the stored line lengths and compare. Wait for the size to settle before rebuilding. Dragging a window edge emits a SIGWINCH per step; rebuilding for each one pinned the animation at its opening frames for the whole drag and started it over on release. A 6-step drag now costs at most 3 rebuilds instead of 7. Wipe the old area instead of reusing the canvas. Reusing it moved up by the new visible_top from an anchor that no longer had that much room above it, so the blank-line loop ran past the anchor and scrolled a line of scrollback away per rebuild. The resize path now returns to the top of the area it allocated, erases to the end of the screen, and lays the new canvas out from there. Leave the cursor hidden across a rebuild. Restoring it between runs strobed the cursor 20-40 times a second through a drag. Drop the libc dependency. Only signal(2) and a handful of constants were needed, and signal was already hand-declared; the pacing sleep now slices rather than reaching for nanosleep, which also makes it portable. Rebasing onto master also had to reconcile the run loop with the arena teardown skip that landed in #5: forgetting the engine now happens on the exit paths inside the loop, never on a resize rebuild, which still drops its engine so a long session of resizes cannot accumulate them. --- .github/workflows/ci.yml | 2 + Cargo.lock | 1 - Cargo.toml | 1 - src/engine/effect.rs | 16 ++-- src/engine/terminal.rs | 158 ++++++++++++++++++++++----------- src/lib.rs | 51 ++++++++--- src/main.rs | 56 +++++++++--- src/utils/ansi.rs | 1 + tools/tests/resize_behavior.py | 150 +++++++++++++++++++++++++++++++ 9 files changed, 352 insertions(+), 84 deletions(-) create mode 100644 tools/tests/resize_behavior.py 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/Cargo.lock b/Cargo.lock index d37d23b..eedf75e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -217,7 +217,6 @@ version = "0.2.1" dependencies = [ "clap", "clap_complete", - "libc", "terminal_size", ] diff --git a/Cargo.toml b/Cargo.toml index 78833cc..ecf9637 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,6 @@ license = "MIT" [dependencies] clap = { version = "4", features = ["derive"] } clap_complete = "4.6.9" -libc = "0.2" terminal_size = "0.4" [profile.release] diff --git a/src/engine/effect.rs b/src/engine/effect.rs index 6215e38..48cc602 100644 --- a/src/engine/effect.rs +++ b/src/engine/effect.rs @@ -66,14 +66,14 @@ fn run_effect_inner( } Ok(()) })(); - let end_symbol = if outcome == RunOutcome::TerminalResized { - "" + 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 { - "\n" - }; - ctx.terminal - .restore_cursor(&mut out, end_symbol) - .map_err(io_err)?; + ctx.terminal.restore_cursor(&mut out, "\n").map_err(io_err)?; + } out.flush().ok(); result.map(|_| outcome) } @@ -83,7 +83,7 @@ fn requested_stop(ctx: &EngineCtx, stop_on_resize: bool) -> Option { Some(RunOutcome::Interrupted) } else if stop_on_resize && crate::take_terminal_resize() - && ctx.terminal.dimensions_changed() + && ctx.terminal.layout_changed() { Some(RunOutcome::TerminalResized) } else { diff --git a/src/engine/terminal.rs b/src/engine/terminal.rs index c6b7ef9..e4ca0d4 100644 --- a/src/engine/terminal.rs +++ b/src/engine/terminal.rs @@ -117,6 +117,10 @@ pub struct Terminal { next_character_id: u32, pub input_colors_frequency: ColorFrequency, terminal_dimensions: (i64, i64), + 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, @@ -180,24 +184,19 @@ impl Terminal { } .preprocess(input_data)?; - let (mut terminal_width, mut terminal_height) = get_terminal_dimensions(); - let terminal_dimensions = (terminal_width, terminal_height); - 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() @@ -227,6 +226,8 @@ impl Terminal { next_character_id, input_colors_frequency, terminal_dimensions, + layout, + input_line_lengths, canvas_column_offset, canvas_row_offset, visible_top, @@ -605,12 +606,32 @@ impl Terminal { } } - /// Whether a SIGWINCH corresponds to dimensions that would change this - /// terminal's layout. Explicitly ignored dimensions remain fixed by - /// definition, even if the surrounding tty changes size. - pub fn dimensions_changed(&self) -> bool { - !self.config.ignore_terminal_dimensions - && get_terminal_dimensions() != self.terminal_dimensions + /// Whether a SIGWINCH actually moved anything. 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 the + /// animation for those is pure loss. Explicitly ignored dimensions are + /// fixed by definition. + pub fn layout_changed(&self) -> bool { + 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) --- @@ -663,34 +684,27 @@ impl Terminal { } } -/// Sleep for frame pacing, but return promptly when one of the installed -/// process handlers records an interrupt or terminal resize. +/// Sleep for frame pacing in slices, so an interrupt or a resize is noticed +/// within a few milliseconds instead of at the end of the frame delay. +/// `thread::sleep` retries through EINTR, so a signal alone will not cut it. fn sleep_until_signal(duration: std::time::Duration) { - let mut requested = libc::timespec { - tv_sec: duration.as_secs() as libc::time_t, - tv_nsec: duration.subsec_nanos() as libc::c_long, - }; + const SLICE: std::time::Duration = std::time::Duration::from_millis(4); + let deadline = Instant::now() + duration; loop { - let mut remaining = std::mem::MaybeUninit::::uninit(); - // SAFETY: both pointers refer to valid timespec storage for the call. - let result = unsafe { libc::nanosleep(&requested, remaining.as_mut_ptr()) }; - if result == 0 { - break; - } - if std::io::Error::last_os_error().raw_os_error() != Some(libc::EINTR) { - break; - } if crate::interrupted() || crate::terminal_resize_pending() { - break; + return; + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return; } - // SAFETY: POSIX requires nanosleep to initialize `remaining` on EINTR. - requested = unsafe { remaining.assume_init() }; + std::thread::sleep(remaining.min(SLICE)); } } /// shutil.get_terminal_size semantics: COLUMNS/LINES env vars win; else query /// the tty; on failure (80, 24). -fn get_terminal_dimensions() -> (i64, i64) { +pub(crate) fn get_terminal_dimensions() -> (i64, i64) { let env_dim = |name: &str| -> Option { std::env::var(name).ok()?.parse::().ok() }; @@ -707,10 +721,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) { @@ -719,7 +777,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 { @@ -731,11 +789,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) } @@ -743,10 +801,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 14300e3..9b24714 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,10 +14,7 @@ static TERMINAL_RESIZED: 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( - libc::SIGINT, - handle_sigint as *const () as libc::sighandler_t, - ); + libc_signal(SIGINT, handle_sigint as *const () as usize); } } @@ -34,10 +31,7 @@ pub fn interrupted() -> bool { pub fn install_sigwinch_handler() { // SAFETY: signal(2) with a signal-safe handler that only stores a flag. unsafe { - libc::signal( - libc::SIGWINCH, - handle_sigwinch as *const () as libc::sighandler_t, - ); + libc_signal(SIGWINCH, handle_sigwinch as *const () as usize); } } @@ -58,7 +52,44 @@ pub(crate) fn terminal_resize_pending() -> bool { /// tool instead of panicking on a broken pipe (Rust ignores SIGPIPE by default). pub fn restore_sigpipe() { unsafe { - libc::signal(libc::SIGPIPE, libc::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; + } + unsafe { + signal(signum, handler); + } +} + +/// Wait for the window to stop changing size before acting on a resize. +/// Dragging a window edge emits a SIGWINCH per step; rebuilding for each one +/// pins the animation at its opening frames for the whole drag and then starts +/// it over on release. +pub fn wait_for_resize_to_settle() { + use std::time::{Duration, Instant}; + const QUIET: Duration = Duration::from_millis(40); + const LIMIT: Duration = Duration::from_secs(2); + + let deadline = Instant::now() + LIMIT; + let mut last = crate::engine::terminal::get_terminal_dimensions(); + while Instant::now() < deadline && !interrupted() { + std::thread::sleep(QUIET); + take_terminal_resize(); + let current = crate::engine::terminal::get_terminal_dimensions(); + if current == last { + break; + } + last = current; } } @@ -69,7 +100,7 @@ mod tests { #[test] fn terminal_resize_notifications_are_consumed() { take_terminal_resize(); - handle_sigwinch(libc::SIGWINCH); + handle_sigwinch(SIGWINCH); assert!(terminal_resize_pending()); assert!(take_terminal_resize()); assert!(!terminal_resize_pending()); diff --git a/src/main.rs b/src/main.rs index 29882d5..a93cb39 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(); @@ -110,8 +122,15 @@ fn main() -> ExitCode { }; 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(); + } + if resize_aware { ttfx::install_sigwinch_handler(); } @@ -140,28 +159,37 @@ fn main() -> ExitCode { let mut effect = effect_command.build_effect(); if cli.parity_dump { - break ttfx::engine::effect::dump_effect(effect.as_mut(), &mut ctx, cli.max_frames) + let dumped = ttfx::engine::effect::dump_effect(effect.as_mut(), &mut ctx, cli.max_frames) .map(|_| ()); + forget_engine(effect, ctx); + break dumped; } - match ttfx::engine::effect::run_effect_resize_aware(effect.as_mut(), &mut ctx) { + let outcome = if resize_aware { + ttfx::engine::effect::run_effect_resize_aware(effect.as_mut(), &mut ctx) + } else { + ttfx::engine::effect::run_effect(effect.as_mut(), &mut ctx) + .map(|_| ttfx::engine::effect::RunOutcome::Complete) + }; + match outcome { Ok(ttfx::engine::effect::RunOutcome::TerminalResized) => { - // The current output area is already allocated. Reuse it when - // rebuilding at the new dimensions instead of scrolling a - // second canvas into the terminal. - config.reuse_canvas = true; + // run_effect_resize_aware wiped the old area and left the cursor + // at its top, so the rebuild lays out from here. Reusing the + // canvas would restore a DEC anchor that no longer applies. + config.reuse_canvas = false; rng = ctx.rng; + ttfx::wait_for_resize_to_settle(); + } + Ok(_) => { + forget_engine(effect, ctx); + break Ok(()); + } + Err(e) => { + forget_engine(effect, ctx); + break Err(e); } - Ok(_) => break Ok(()), - Err(e) => break Err(e), } }; - // 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()) From f6e20384de555b309d46d95d58785e58fb6f04ad Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 10 Aug 2026 13:34:53 -0700 Subject: [PATCH 4/4] Cut the resize path down to what it needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The restart machinery had grown three layers that each cost more than they returned. Fold the debounce into the run loop. wait_for_resize_to_settle polled from main with a 40ms sleep, and it ran after the screen had already been wiped, so every rebuild showed a blank hole for at least that long. Terminal now restarts a 50ms quiet window on each SIGWINCH and checks it where the frame loop already checks for interrupts: the old canvas keeps animating until the window expires, so the wait costs nothing on screen. A 6-step drag now rebuilds twice instead of three times. Drop sleep_until_signal. Slicing the frame delay into 4ms pieces bought at most one frame of latency — the default frame rate is 60, so the delay it was slicing is 16.7ms — on a path that then deliberately waits 50ms for the size to settle. thread::sleep is back. Collapse run_effect, run_effect_resize_aware and run_effect_inner into one run_effect(effect, ctx, stop_on_resize). The split existed to keep the old signature "unchanged for library callers" that do not exist, and main was branching on resize_aware to choose between two wrappers that branch on the same bool again. dump_effect now shares the single match, which also puts forget_engine on one exit path instead of three. Co-Authored-By: Claude --- src/engine/effect.rs | 26 +++++---------------- src/engine/terminal.rs | 51 +++++++++++++++++++++--------------------- src/lib.rs | 28 ----------------------- src/main.rs | 32 +++++++++----------------- 4 files changed, 42 insertions(+), 95 deletions(-) diff --git a/src/engine/effect.rs b/src/engine/effect.rs index 48cc602..234069f 100644 --- a/src/engine/effect.rs +++ b/src/engine/effect.rs @@ -23,21 +23,10 @@ pub enum RunOutcome { /// __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> { - run_effect_inner(effect, ctx, false).map(|_| ()) -} - -/// Run an effect until it completes, is interrupted, or the terminal changes -/// size. The CLI uses the resize outcome to rebuild dimension-dependent effect -/// state; `run_effect` remains unchanged for library callers. -pub fn run_effect_resize_aware( - effect: &mut dyn Effect, - ctx: &mut EngineCtx, -) -> Result { - run_effect_inner(effect, ctx, true) -} - -fn run_effect_inner( +/// +/// 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, @@ -78,13 +67,10 @@ fn run_effect_inner( result.map(|_| outcome) } -fn requested_stop(ctx: &EngineCtx, stop_on_resize: bool) -> Option { +fn requested_stop(ctx: &mut EngineCtx, stop_on_resize: bool) -> Option { if crate::interrupted() { Some(RunOutcome::Interrupted) - } else if stop_on_resize - && crate::take_terminal_resize() - && ctx.terminal.layout_changed() - { + } else if stop_on_resize && ctx.terminal.resize_settled() { Some(RunOutcome::TerminalResized) } else { None diff --git a/src/engine/terminal.rs b/src/engine/terminal.rs index e4ca0d4..e21c2ea 100644 --- a/src/engine/terminal.rs +++ b/src/engine/terminal.rs @@ -117,6 +117,7 @@ pub struct Terminal { 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. @@ -226,6 +227,7 @@ impl Terminal { next_character_id, input_colors_frequency, terminal_dimensions, + resize_seen_at: None, layout, input_line_lengths, canvas_column_offset, @@ -606,12 +608,27 @@ impl Terminal { } } - /// Whether a SIGWINCH actually moved anything. 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 the - /// animation for those is pure loss. Explicitly ignored dimensions are - /// fixed by definition. - pub fn layout_changed(&self) -> bool { + /// 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; } @@ -678,33 +695,15 @@ impl Terminal { let frame_delay = 1.0 / self.frame_rate as f64; let elapsed = self.last_time_printed.elapsed().as_secs_f64(); if elapsed < frame_delay { - sleep_until_signal(std::time::Duration::from_secs_f64(frame_delay - elapsed)); + std::thread::sleep(std::time::Duration::from_secs_f64(frame_delay - elapsed)); } self.last_time_printed = Instant::now(); } } -/// Sleep for frame pacing in slices, so an interrupt or a resize is noticed -/// within a few milliseconds instead of at the end of the frame delay. -/// `thread::sleep` retries through EINTR, so a signal alone will not cut it. -fn sleep_until_signal(duration: std::time::Duration) { - const SLICE: std::time::Duration = std::time::Duration::from_millis(4); - let deadline = Instant::now() + duration; - loop { - if crate::interrupted() || crate::terminal_resize_pending() { - return; - } - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - return; - } - std::thread::sleep(remaining.min(SLICE)); - } -} - /// shutil.get_terminal_size semantics: COLUMNS/LINES env vars win; else query /// the tty; on failure (80, 24). -pub(crate) fn get_terminal_dimensions() -> (i64, i64) { +fn get_terminal_dimensions() -> (i64, i64) { let env_dim = |name: &str| -> Option { std::env::var(name).ok()?.parse::().ok() }; diff --git a/src/lib.rs b/src/lib.rs index 9b24714..54be714 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,10 +44,6 @@ pub fn take_terminal_resize() -> bool { TERMINAL_RESIZED.swap(false, Ordering::SeqCst) } -pub(crate) fn terminal_resize_pending() -> bool { - TERMINAL_RESIZED.load(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() { @@ -71,28 +67,6 @@ unsafe fn libc_signal(signum: i32, handler: usize) { } } -/// Wait for the window to stop changing size before acting on a resize. -/// Dragging a window edge emits a SIGWINCH per step; rebuilding for each one -/// pins the animation at its opening frames for the whole drag and then starts -/// it over on release. -pub fn wait_for_resize_to_settle() { - use std::time::{Duration, Instant}; - const QUIET: Duration = Duration::from_millis(40); - const LIMIT: Duration = Duration::from_secs(2); - - let deadline = Instant::now() + LIMIT; - let mut last = crate::engine::terminal::get_terminal_dimensions(); - while Instant::now() < deadline && !interrupted() { - std::thread::sleep(QUIET); - take_terminal_resize(); - let current = crate::engine::terminal::get_terminal_dimensions(); - if current == last { - break; - } - last = current; - } -} - #[cfg(test)] mod tests { use super::*; @@ -101,9 +75,7 @@ mod tests { fn terminal_resize_notifications_are_consumed() { take_terminal_resize(); handle_sigwinch(SIGWINCH); - assert!(terminal_resize_pending()); assert!(take_terminal_resize()); - assert!(!terminal_resize_pending()); assert!(!take_terminal_resize()); } } diff --git a/src/main.rs b/src/main.rs index a93cb39..3a4153e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -158,35 +158,25 @@ fn main() -> ExitCode { }; let mut effect = effect_command.build_effect(); - if cli.parity_dump { - let dumped = ttfx::engine::effect::dump_effect(effect.as_mut(), &mut ctx, cli.max_frames) - .map(|_| ()); - forget_engine(effect, ctx); - break dumped; - } - - let outcome = if resize_aware { - ttfx::engine::effect::run_effect_resize_aware(effect.as_mut(), &mut ctx) - } else { - ttfx::engine::effect::run_effect(effect.as_mut(), &mut ctx) + 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_resize_aware wiped the old area and left the cursor - // at its top, so the rebuild lays out from here. Reusing the - // canvas would restore a DEC anchor that no longer applies. + // 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; - ttfx::wait_for_resize_to_settle(); } - Ok(_) => { - forget_engine(effect, ctx); - break Ok(()); - } - Err(e) => { + done => { forget_engine(effect, ctx); - break Err(e); + break done.map(|_| ()); } } };