Rendering, Damage, and Frame Timing

A terminal has an unusual performance profile: it is idle 99% of the time, and then a program dumps 50 MB to stdout. Both cases must work — the idle case at ~0% CPU, the flood case without freezing the UI or the shell.

This chapter covers the frame loop, damage tracking, the read/render decoupling that prevents the classic freeze, and when GPU rendering is actually justified.


The Freeze Bug (Read This First)

   ✗ THE BUG:
     loop {
       let n = pty.read(&mut buf);
       terminal.advance(&buf[..n]);
       render();                       ← 16ms of work
       present();
     }

   `yes` produces ~100 MB/s. Each read gives you 64 KB, then you spend 16 ms
   rendering. The PTY's output buffer fills. The child blocks in write().
   The UI stops responding because you are rendering as fast as you can.
   The user reports: "my terminal froze."
   ✓ THE FIX: decouple.

     PTY reading:  drain EVERYTHING available, feed the parser, mark damage.
                   Never render here.
     Rendering:    on a timer (vsync / 60 Hz), IF anything is damaged.

     read  ──▶ [terminal state] ──▶ render
      fast        the buffer         bounded rate

Two consequences worth stating:

  1. You may parse many screens' worth of output and render once. That is correct — the intermediate states were never visible. A terminal is not obliged to display every frame a program emits.
  2. The PTY drain must be unbounded per iteration (read until EAGAIN), or a fast producer outpaces you forever.

The Frame Loop

#![allow(unused)]
fn main() {
enum UserEvent { PtyOutput, PtyExit }

impl ApplicationHandler<UserEvent> for App {
    fn user_event(&mut self, _: &ActiveEventLoop, ev: UserEvent) {
        match ev {
            UserEvent::PtyOutput => {
                // Drain EVERYTHING. Do not render here.
                while let Some(chunk) = self.pty_rx.try_recv().ok() {
                    self.terminal.advance(&chunk);
                }
                let replies = self.terminal.take_replies();
                if !replies.is_empty() { let _ = self.pty.write_all(&replies); }

                // Request a redraw only if something actually changed. Requesting
                // unconditionally is how a terminal burns a core while idle.
                if self.terminal.damage().any() {
                    self.window.request_redraw();
                }
            }
            UserEvent::PtyExit => self.shutdown(),
        }
    }

    fn window_event(&mut self, el: &ActiveEventLoop, _: WindowId, event: WindowEvent) {
        match event {
            WindowEvent::RedrawRequested => {
                // Coalesce: if we rendered less than a frame ago, skip. The next
                // damage will request another redraw.
                let now = Instant::now();
                if now.duration_since(self.last_frame) < self.min_frame_interval {
                    self.window.request_redraw();     // try again next tick
                    return;
                }
                self.last_frame = now;

                let snapshot = RenderSnapshot::from(&self.terminal);
                self.renderer.draw(&snapshot);
                self.terminal.clear_damage();
            }
            WindowEvent::Resized(size) => self.on_resize(size),
            WindowEvent::KeyboardInput { event, .. } => self.on_key(&event),
            WindowEvent::CloseRequested => self.shutdown(),
            _ => {}
        }
    }
}
}

Note: winit 0.30 moved to the ApplicationHandler trait; older versions use a closure passed to run. The structure — an event source, a damage flag, a rate-limited redraw — is identical either way. Pin your version and read its docs.


Getting PTY Output Into the Event Loop

Three options:

ApproachHowTrade-off
Reader thread + EventLoopProxyA thread blocks on read(master), sends chunks over a channel, and wakes the event loop with proxy.send_event(PtyOutput)Simplest and recommended. One thread, no polling integration, works on every platform.
Poll integrationPut the PTY fd into the platform event loopPlatform-specific and winit does not expose it portably
Timer pollingNon-blocking read on every frameAdds up to one frame of latency; wastes wake-ups when idle
#![allow(unused)]
fn main() {
// The reader thread. Note it does NO parsing — it moves bytes and wakes the loop.
// Parsing on this thread would need a lock on the Terminal, and the render would
// then contend with it.
std::thread::spawn(move || {
    let mut buf = [0u8; 65536];
    loop {
        match read_master(master_fd, &mut buf) {
            Ok(0) => { let _ = proxy.send_event(UserEvent::PtyExit); break; }
            Ok(n) => {
                if tx.send(buf[..n].to_vec()).is_err() { break; }
                // Coalescing: only wake the loop if it is not already awake.
                // Without this, a flood sends 1,500 wake-ups per second.
                if !pending.swap(true, Ordering::AcqRel) {
                    let _ = proxy.send_event(UserEvent::PtyOutput);
                }
            }
            Err(e) if e.kind() == ErrorKind::Interrupted => continue,
            Err(e) if e.raw_os_error() == Some(libc::EIO) => {     // Linux: child gone
                let _ = proxy.send_event(UserEvent::PtyExit); break;
            }
            Err(_) => { let _ = proxy.send_event(UserEvent::PtyExit); break; }
        }
    }
});
}

Damage Tracking

Row granularity is the right default for a terminal.

#![allow(unused)]
fn main() {
pub struct RenderSnapshot {
    pub rows: Vec<RenderRow>,
    pub cursor: Option<RenderCursor>,
    /// Which rows changed since the last snapshot. Empty means nothing to do.
    pub dirty: Vec<usize>,
    pub all_dirty: bool,
}

fn draw(&mut self, snap: &RenderSnapshot) {
    let rows: Box<dyn Iterator<Item = usize>> = if snap.all_dirty {
        Box::new(0..snap.rows.len())
    } else {
        Box::new(snap.dirty.iter().copied())
    };
    for r in rows {
        self.draw_row(r, &snap.rows[r]);
    }
    if let Some(c) = &snap.cursor { self.draw_cursor(c); }
    self.present();
}
}

What must mark damage:

EventRows
Cell writeThat row
Cursor moveOld and new — the cursor is drawn, so both need repainting
ScrollThe whole region
EraseThe affected rows
Alt-screen switch, ?25 toggle, palette change, resizeAll
Selection changeThe rows in the old and new selection
Blink tickThe cursor row only

Warning: Missing the "old row" on a cursor move leaves a ghost cursor block behind. It is the first rendering artifact you will see, and it is always this.

Does damage tracking actually pay?

Measure. Rough numbers on a modern machine with a CPU renderer:

   80×24, full redraw:      ~1,900 cells  ≈ 0.3–0.8 ms   ← damage barely matters
   200×50, full redraw:    ~10,000 cells  ≈ 2–4 ms       ← damage helps
   400×100, full redraw:   ~40,000 cells  ≈ 8–16 ms      ← damage is essential
   Typical typing:              1–2 rows  ≈ 0.02 ms      ← 100× cheaper

Print the dirty-row count per frame in your debug overlay. If it is usually the whole screen, your damage tracking has a bug — probably something marking all_dirty on every parse.


Frame Timing

#![allow(unused)]
fn main() {
pub struct FrameTiming {
    last: Instant,
    /// ~16.6 ms for 60 Hz. Query the monitor's refresh rate where you can.
    min_interval: Duration,
}
}

Three regimes, and each needs different behavior:

RegimeBehavior
Idle (no output, no input)Zero frames. No timer, no wake-ups. CPU at 0%. Use ControlFlow::Wait.
TypingOne frame per keystroke, ~1–2 dirty rows
Flood (yes, cat bigfile)Cap at the refresh rate. Parse everything; render 60× per second.
Blinking cursorWake on the blink interval only (~500 ms), not at 60 Hz. Suspend blinking while output is arriving.
#![allow(unused)]
fn main() {
// The idle rule, in one line:
event_loop.set_control_flow(ControlFlow::Wait);
// NOT ControlFlow::Poll, which spins the loop continuously and burns a core.
}

An idle terminal at anything above 0% CPU is a bug. Verify it with top — many shipped terminals have failed this.


Resize: The Order Matters

#![allow(unused)]
fn main() {
fn on_resize(&mut self, physical: PhysicalSize<u32>) {
    // 1. Reconfigure the surface at PHYSICAL pixel size.
    self.surface.resize(physical.width, physical.height);

    // 2. Pixels → cells. Integer division; the remainder is padding.
    let cols = (physical.width as usize / self.cell.width).max(1);
    let rows = (physical.height as usize / self.cell.height).max(1);
    if (rows, cols) == (self.terminal.rows(), self.terminal.cols()) { return; }

    // 3. Terminal state FIRST, so the grid is the right shape...
    self.terminal.resize(rows, cols);

    // 4. ...THEN the PTY, which triggers SIGWINCH to the child. If you do this
    //    first, the child redraws at the new size into a grid that is still the
    //    old size, and the first frame after a resize is corrupt.
    let _ = self.pty.resize(PtySize {
        rows: rows as u16,
        cols: cols as u16,
        // Pixel dimensions are used by graphics protocols (sixel, kitty images).
        // Most terminals leave them 0; setting them costs nothing.
        pixel_width: physical.width as u16,
        pixel_height: physical.height as u16,
    });

    self.terminal.damage_all();
    self.window.request_redraw();
}
}

Also handle ScaleFactorChanged — a window dragged between a Retina and a non-Retina monitor changes scale mid-session. Recompute cell metrics, re-rasterize the atlas, and resize.

Tip: Debounce resize. A window drag produces dozens of events per second, each triggering a SIGWINCH and a full redraw in the child. Programs like vim redraw the entire screen on every one. Coalescing resizes over ~50 ms makes dragging smooth and is what real terminals do.


CPU vs. GPU: When to Move

Start on the CPU. Move when you have a measurement, not before.

CPU (softbuffer)GPU (wgpu)
Setup complexity~50 lines~500 lines: instance, adapter, device, pipeline, shaders, bind groups
Debuggabilityprintln! and a hex dumpShader debugging, which is genuinely painful
80×24 full redraw0.3–0.8 ms~0.1 ms — the difference is invisible
400×100 full redraw8–16 ms~0.3 ms — this is the case that matters
AtlasA Vec<u8>A texture
Per-frame workBlend every pixel on the CPUUpload instance data; the GPU blends
PowerHigher on a floodLower

The GPU design, when you get there:

   Atlas → one texture (R8, glyph coverage)
   Per cell → one INSTANCE: {cell_x, cell_y, atlas_uv, fg_rgba, bg_rgba}
   Vertex shader   → expands each instance to a quad
   Fragment shader → samples the atlas, mixes fg/bg by coverage
   One draw call for the whole screen.

That is the architecture every GPU terminal uses. It is not conceptually hard; it is just 500 lines of API ceremony, and doing it before you understand baselines means debugging two things at once.


Experiment

CLAIM. Coupling reading to rendering makes a terminal freeze under load, and the freeze is measurable.

METHOD.

#![allow(unused)]
fn main() {
// Add a --coupled flag that renders inside the PTY-read branch.
}
cargo run -p terminal-gui -- --coupled
#   inside: yes
#   Try to type. Try to press Ctrl+C. Measure with `top`.

cargo run -p terminal-gui
#   inside: yes
#   Type. Press Ctrl+C. It responds.

# Measure the throughput difference:
time (yes | head -c 50000000)      # inside each mode

PREDICTION. Before running: in coupled mode, does the child keep running? What syscall is it in? How long does Ctrl+C take to take effect?

RESULT. Record both throughputs and the Ctrl+C latency. Then explain the mechanism using the PTY buffer chapter.


Test

#![allow(unused)]
fn main() {
#[test]
fn idle_terminal_requests_no_frames() {
    let mut app = TestApp::new();
    app.run_for(Duration::from_secs(1));
    assert_eq!(app.frames_rendered(), 0, "an idle terminal must not render");
}

#[test]
fn a_flood_is_capped_at_the_frame_rate() {
    let mut app = TestApp::new();
    app.feed_pty(&vec![b'x'; 10_000_000]);
    app.run_for(Duration::from_secs(1));
    assert!(app.frames_rendered() <= 70, "rendered {} frames in 1s", app.frames_rendered());
    assert_eq!(app.bytes_parsed(), 10_000_000, "but ALL bytes must be parsed");
}

#[test]
fn typing_damages_only_the_affected_rows() {
    let mut app = TestApp::new();
    app.clear_damage();
    app.feed_pty(b"hello");
    assert_eq!(app.dirty_rows(), vec![0]);
}

#[test]
fn cursor_movement_damages_both_rows() {
    let mut app = TestApp::new();
    app.feed_pty(b"\x1b[1;1H");
    app.clear_damage();
    app.feed_pty(b"\x1b[10;5H");
    let d = app.dirty_rows();
    assert!(d.contains(&0) && d.contains(&9), "ghost cursor bug: {d:?}");
}

#[test]
fn resize_updates_terminal_before_pty() {
    // Order matters: PTY first means the child redraws into a stale grid.
    let mut app = TestApp::new();
    app.record_calls();
    app.resize(PhysicalSize::new(1600, 900));
    let calls = app.recorded_calls();
    let t = calls.iter().position(|c| c == "terminal.resize").unwrap();
    let p = calls.iter().position(|c| c == "pty.resize").unwrap();
    assert!(t < p, "terminal must be resized before the PTY");
}

#[test]
fn pixel_to_cell_conversion_never_yields_zero() {
    // A window dragged to 1 pixel must not produce a 0-column terminal.
    let cell = CellMetrics { width: 8, height: 17, ascent: 13 };
    assert_eq!(pixels_to_cells(1, 1, &cell), (1, 1));
    assert_eq!(pixels_to_cells(0, 0, &cell), (1, 1));
}
}

Challenge Extensions

  1. A debug overlay: FPS, dirty rows per frame, bytes/sec parsed, atlas hit rate, PTY buffer high-water mark. Toggle with a keybinding. You will use it constantly.
  2. Resize debouncing over 50 ms; measure SIGWINCH count during a 3-second drag, before and after.
  3. A wgpu backend behind a feature flag, with the same RenderSnapshot input. Benchmark both at 80×24 and 400×100 and report the numbers.
  4. Cell-level damage instead of row-level. Measure whether it helps. (It usually does not at terminal sizes, and finding that out empirically is the lesson.)
  5. Adaptive frame rate: render at the monitor's actual refresh rate, queried from winit.
  6. Measure the flood path end to end: cat a 100 MB file and report bytes/sec through the parser and frames rendered. Compare against alacritty, kitty, and xterm on the same file.

Validation / Self-check

  1. Describe the freeze bug, its mechanism, and its fix.
  2. Why is it correct to parse many screens of output and render once?
  3. Why must the PTY drain be unbounded per iteration?
  4. Which events mark damage, and which one is most commonly forgotten?
  5. What CPU usage should an idle terminal show, and which ControlFlow gives you that?
  6. Why does the cursor blink not require a 60 Hz timer?
  7. State the correct resize order and explain what breaks if reversed.
  8. Why debounce resize? What does the child do on each SIGWINCH?
  9. At what grid size does CPU rendering stop being adequate? What measurement would tell you?
  10. Describe the GPU architecture in four lines. Why is it not the starting point?
  11. Why does the reader thread not parse?

Next: Trace a Keystroke.