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:
- 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.
- 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:
winit0.30 moved to theApplicationHandlertrait; older versions use a closure passed torun. 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:
| Approach | How | Trade-off |
|---|---|---|
Reader thread + EventLoopProxy | A 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 integration | Put the PTY fd into the platform event loop | Platform-specific and winit does not expose it portably |
| Timer polling | Non-blocking read on every frame | Adds 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:
| Event | Rows |
|---|---|
| Cell write | That row |
| Cursor move | Old and new — the cursor is drawn, so both need repainting |
| Scroll | The whole region |
| Erase | The affected rows |
Alt-screen switch, ?25 toggle, palette change, resize | All |
| Selection change | The rows in the old and new selection |
| Blink tick | The 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:
| Regime | Behavior |
|---|---|
| Idle (no output, no input) | Zero frames. No timer, no wake-ups. CPU at 0%. Use ControlFlow::Wait. |
| Typing | One frame per keystroke, ~1–2 dirty rows |
Flood (yes, cat bigfile) | Cap at the refresh rate. Parse everything; render 60× per second. |
| Blinking cursor | Wake 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
SIGWINCHand a full redraw in the child. Programs likevimredraw 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 |
| Debuggability | println! and a hex dump | Shader debugging, which is genuinely painful |
| 80×24 full redraw | 0.3–0.8 ms | ~0.1 ms — the difference is invisible |
| 400×100 full redraw | 8–16 ms | ~0.3 ms — this is the case that matters |
| Atlas | A Vec<u8> | A texture |
| Per-frame work | Blend every pixel on the CPU | Upload instance data; the GPU blends |
| Power | Higher on a flood | Lower |
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
- 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.
- Resize debouncing over 50 ms; measure
SIGWINCHcount during a 3-second drag, before and after. - A
wgpubackend behind a feature flag, with the sameRenderSnapshotinput. Benchmark both at 80×24 and 400×100 and report the numbers. - 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.)
- Adaptive frame rate: render at the monitor's actual refresh rate, queried from
winit. - Measure the flood path end to end:
cata 100 MB file and report bytes/sec through the parser and frames rendered. Compare againstalacritty,kitty, andxtermon the same file.
Validation / Self-check
- Describe the freeze bug, its mechanism, and its fix.
- Why is it correct to parse many screens of output and render once?
- Why must the PTY drain be unbounded per iteration?
- Which events mark damage, and which one is most commonly forgotten?
- What CPU usage should an idle terminal show, and which
ControlFlowgives you that? - Why does the cursor blink not require a 60 Hz timer?
- State the correct resize order and explain what breaks if reversed.
- Why debounce resize? What does the child do on each
SIGWINCH? - At what grid size does CPU rendering stop being adequate? What measurement would tell you?
- Describe the GPU architecture in four lines. Why is it not the starting point?
- Why does the reader thread not parse?
Next: Trace a Keystroke.