Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
51 changes: 46 additions & 5 deletions src/engine/effect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,27 +13,68 @@ pub trait Effect: EffectHooks {
fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option<String>;
}

#[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<RunOutcome, EngineError> {
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)?;
ctx.terminal.recycle_output_string(frame);
}
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<RunOutcome> {
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.
Expand Down
141 changes: 117 additions & 24 deletions src/engine/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ pub struct Terminal {
pub arena: Vec<EffectCharacter>,
next_character_id: u32,
pub input_colors_frequency: ColorFrequency,
terminal_dimensions: (i64, i64),
resize_seen_at: Option<Instant>,
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<i64>,
pub canvas_column_offset: i64,
pub canvas_row_offset: i64,
pub visible_top: i64,
Expand Down Expand Up @@ -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<i64> = 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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<()> {
Expand Down Expand Up @@ -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<CharId>],
line_lengths: &[i64],
terminal_width: i64,
terminal_height: i64,
) -> (i64, i64) {
Expand All @@ -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 {
Expand All @@ -695,22 +788,22 @@ 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)
}
};
(canvas_height, canvas_width)
}

fn wrapped_line_count(lines: &[Vec<CharId>], 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;
Expand Down
42 changes: 40 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ 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
/// a raw signal exit (plan.md §8).
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);
}
}

Expand All @@ -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;
Expand All @@ -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());
}
}
Loading
Loading