Lab 12: The Windowed Renderer (Milestone 7)
Background
A window, a shell inside it, and pixels. This lab joins terminal-pty, terminal-core, and a CPU
renderer into a working graphical terminal in roughly 400 lines.
CPU rendering, softbuffer, fontdue. No GPU. No text-layout engine. You will write the blend loop
yourself, because that is where the concepts are.
Why This Lab Matters
- It closes the loop: for the first time, every layer you have built is running simultaneously.
- The freeze bug, the ghost cursor, and the baseline mistake all appear here, and each one teaches something you cannot learn from reading.
Prerequisites
- Lab 8 complete.
- Choosing the Stack, Fonts and Rasterization, and Rendering and Damage read.
- A monospace TTF/OTF on disk whose path you know.
Predict First
- Your first render draws glyphs at the cell's top-left. What will it look like?
- You render inside the PTY-read branch. What happens when you run
yes? - You forget to damage the old cursor row. What artifact appears?
- What CPU should an idle terminal use?
Step 1: The Crates
cargo new --lib crates/terminal-render-model --name terminal-render-model
cargo new --bin crates/terminal-gui --name terminal-gui
# terminal-render-model
[dependencies]
terminal-core = { path = "../terminal-core" }
# NOTHING else. This crate speaks in CELLS, never pixels.
# terminal-gui
[dependencies]
winit = "0.30"
softbuffer = "0.4"
fontdue = "0.9"
terminal-core = { path = "../terminal-core" }
terminal-pty = { path = "../terminal-pty" }
terminal-input = { path = "../terminal-input" }
terminal-render-model = { path = "../terminal-render-model" }
Step 2: The Render Model
#![allow(unused)] fn main() { // crates/terminal-render-model/src/lib.rs /// A renderer-independent description of what to draw. Produced from a /// Terminal, consumed by any renderer. This boundary is what lets you swap CPU /// for GPU, render on another thread, and test rendering without a window. pub struct RenderSnapshot { pub rows: usize, pub cols: usize, pub lines: Vec<RenderLine>, pub cursor: Option<RenderCursor>, pub dirty: Vec<usize>, pub all_dirty: bool, } pub struct RenderLine { /// Runs of cells sharing a style, so the renderer can batch background fills /// and (later) shape whole runs at once. pub runs: Vec<StyledRun>, } pub struct StyledRun { pub start_col: usize, pub text: String, // graphemes, in order pub widths: Vec<u8>, // cells per grapheme; the renderer must not re-derive this pub fg: Rgb, // ALREADY RESOLVED: theme, bold-brightening, inverse, selection pub bg: Rgb, pub flags: RenderFlags, // UNDERLINE | STRIKE | ITALIC | BOLD | ... pub underline_color: Option<Rgb>, } impl RenderSnapshot { pub fn from_terminal(t: &Terminal, theme: &Theme, sel: Option<Selection>) -> Self { // Color resolution happens HERE, once, not per pixel: // Color::Indexed(n) → theme.ansi[n] // bold + indexed 0-7 → indexed 8-15 (if the theme says so) // dim → blend toward bg // inverse → swap fg/bg // selected → swap again (so it composes with inverse correctly) // hidden → fg = bg // ... } } }
Note: Resolving colors in the snapshot rather than in the renderer is what keeps
Color::Indexedmeaningful — the theme applies at one point, and the renderer never sees a palette index. A color-blind palette or a live theme switch then costs one line.
Step 3: The Renderer
#![allow(unused)] fn main() { pub struct CpuRenderer { buffer: Vec<u32>, // 0x00RRGGBB, softbuffer's format width: usize, // PHYSICAL pixels height: usize, cell: CellMetrics, fonts: FontSet, // regular, bold, italic, bold-italic, fallbacks atlas: GlyphAtlas, } impl CpuRenderer { fn draw(&mut self, snap: &RenderSnapshot) { let rows: Vec<usize> = if snap.all_dirty { (0..snap.rows).collect() } else { snap.dirty.clone() }; for r in rows { self.draw_line(r, &snap.lines[r]); } if let Some(c) = &snap.cursor { self.draw_cursor(c); } } fn draw_line(&mut self, row: usize, line: &RenderLine) { for run in &line.runs { let mut col = run.start_col; // 1. Background first, for the whole run. One rect fill rather than // one per cell. let cells: usize = run.widths.iter().map(|&w| w as usize).sum(); self.fill_rect(col, row, cells, 1, run.bg); // 2. Then the glyphs. for (g, &w) in run.text.graphemes(true).zip(&run.widths) { self.draw_grapheme(g, col, row, run.fg, run.bg, run.flags, w); col += w as usize; } // 3. Then decorations, which are drawn OVER the glyphs. if run.flags.contains(RenderFlags::UNDERLINE) { self.draw_underline(run.start_col, row, cells, run.underline_color.unwrap_or(run.fg), run.underline_style); } if run.flags.contains(RenderFlags::STRIKE) { self.draw_strike(run.start_col, row, cells, run.fg); } } } fn draw_grapheme(&mut self, g: &str, col: usize, row: usize, fg: Rgb, bg: Rgb, flags: RenderFlags, width: u8) { let Some(ch) = g.chars().next() else { return }; let key = GlyphKey { ch, bold: flags.contains(RenderFlags::BOLD), italic: flags.contains(RenderFlags::ITALIC), size_px: self.font_size_px, }; let entry = self.atlas.get_or_rasterize(key, &self.fonts); let cell_x = col * self.cell.width; let cell_y = row * self.cell.height; // THE BASELINE RULE. Drawing at cell_y instead clips every descender. let baseline = cell_y + self.cell.ascent; let gx = cell_x as i32 + entry.xmin; let gy = baseline as i32 - (entry.height as i32 + entry.ymin); for py in 0..entry.height { for px in 0..entry.width { let cov = self.atlas.pixel(&entry, px, py); if cov == 0 { continue; } let x = gx + px as i32; let y = gy + py as i32; // Glyphs legitimately exceed their cell (descenders, accents). // Clip; never panic. A terminal must not crash on a font. if x < 0 || y < 0 || x >= self.width as i32 || y >= self.height as i32 { continue; } let idx = y as usize * self.width + x as usize; self.buffer[idx] = blend(fg, bg, cov); } } // A wide grapheme's second cell already had its background filled by the // run fill; the glyph simply extends into it. Nothing more to do — but // note that a FALLBACK font's advance is ignored on purpose: the grid, // not the font, decides how many cells this occupies. let _ = width; } } }
Step 4: The Application
#![allow(unused)] fn main() { struct App { window: Option<Arc<Window>>, surface: Option<Surface<Arc<Window>, Arc<Window>>>, renderer: CpuRenderer, terminal: Terminal, pty: Pty, pty_rx: Receiver<Vec<u8>>, mods: Modifiers, last_frame: Instant, min_frame_interval: Duration, } impl ApplicationHandler<UserEvent> for App { fn resumed(&mut self, el: &ActiveEventLoop) { /* create the window + surface */ } fn user_event(&mut self, _: &ActiveEventLoop, ev: UserEvent) { match ev { UserEvent::PtyOutput => { // Drain EVERYTHING. Do not render here — that is the freeze bug. while let Ok(chunk) = self.pty_rx.try_recv() { self.terminal.advance(&chunk); } let replies = self.terminal.take_replies(); if !replies.is_empty() { let _ = self.pty.write_all(&replies); } // Only request a redraw if something changed. Unconditional // requests are how a terminal burns a core while idle. if self.terminal.damage().any() { if let Some(w) = &self.window { w.request_redraw(); } } } UserEvent::PtyExit => el.exit(), } } fn window_event(&mut self, el: &ActiveEventLoop, _: WindowId, event: WindowEvent) { match event { WindowEvent::ModifiersChanged(m) => self.mods = translate_mods(m), WindowEvent::KeyboardInput { event, .. } => self.on_key(&event), WindowEvent::Resized(size) => self.on_resize(size), WindowEvent::ScaleFactorChanged { scale_factor, .. } => self.on_scale(scale_factor), WindowEvent::RedrawRequested => self.on_redraw(), WindowEvent::CloseRequested => el.exit(), _ => {} } } } impl App { fn on_redraw(&mut self) { // Rate-limit. Under a flood, damage arrives far faster than 60 Hz. let now = Instant::now(); if now.duration_since(self.last_frame) < self.min_frame_interval { if let Some(w) = &self.window { w.request_redraw(); } return; } self.last_frame = now; let snap = RenderSnapshot::from_terminal(&self.terminal, &self.theme, self.selection); self.renderer.draw(&snap); self.terminal.clear_damage(); let mut buf = self.surface.as_mut().unwrap().buffer_mut().unwrap(); buf.copy_from_slice(self.renderer.pixels()); buf.present().unwrap(); } fn on_resize(&mut self, size: PhysicalSize<u32>) { // ORDER MATTERS: surface, then terminal, then PTY. Resizing the PTY // first makes the child redraw into a grid that is still the old size. self.surface.as_mut().unwrap().resize( NonZeroU32::new(size.width.max(1)).unwrap(), NonZeroU32::new(size.height.max(1)).unwrap()).unwrap(); self.renderer.resize(size.width as usize, size.height as usize); let cols = (size.width as usize / self.renderer.cell.width).max(1); let rows = (size.height as usize / self.renderer.cell.height).max(1); if (rows, cols) == (self.terminal.rows(), self.terminal.cols()) { return; } self.terminal.resize(rows, cols); let _ = self.pty.resize(PtySize { rows: rows as u16, cols: cols as u16, pixel_width: size.width as u16, pixel_height: size.height as u16, }); self.terminal.damage_all(); if let Some(w) = &self.window { w.request_redraw(); } } } }
And the idle rule:
#![allow(unused)] fn main() { // In main(), before running: event_loop.set_control_flow(ControlFlow::Wait); // NOT Poll. Poll spins the loop continuously and burns a core on an idle terminal. }
Step 5: Run It
cargo run -p terminal-gui --release
Tip: Use
--release. A debug-build CPU renderer is 10–30× slower and will make you think you need a GPU when you do not.
Expected Output
A window opens with a shell prompt. Then, working through the verification list:
1. type `ls` → colored output, correct wrapping
2. `vim` → alternate screen, :q restores the shell
3. `top` → header stays put, rows update
4. resize the window → both redraw at the new size
5. `yes` → scrolls fast, window stays responsive, ^C stops it
6. `printf '日本語 🙂 é\n'` → correct widths, no overlap
7. idle → 0% CPU in `top`
Debugging Steps
Text sits high and descenders are clipped
Drawing at the cell top instead of the baseline. baseline = cell_y + ascent.
Every other column is blurry
Cell width is fractional. Round to an integer.
Text is tiny or enormous on a Retina display
Ignoring the scale factor. Rasterize at font_size × scale_factor, and create the surface at
physical size.
A ghost cursor block follows the real one
Cursor movement is not damaging the old row.
yes freezes the window
Rendering inside the read branch. Decouple.
Idle CPU is 100%
ControlFlow::Poll, or request_redraw() unconditionally, or a permanently-damaged state.
Bold text renders as regular
The atlas key omits bold, so the first-cached regular glyph is reused.
CJK characters overlap the next cell
You are advancing by the font's advance rather than by the grid's cell width. The grid decides.
The window is black and nothing renders
Check that you present(), that the surface was resized, and that dirty is not empty on the first
frame (mark all dirty initially).
Experiment
CLAIM. Damage tracking is measurable, and its benefit scales with grid size.
METHOD. Add --debug-damage printing dirty rows and frame time per frame. Then:
cargo run -p terminal-gui --release -- --debug-damage
# type a character → dirty=[N] frame=0.02ms
# run `clear` → dirty=ALL frame=0.4ms
# run `top` → dirty=[2..20] frame=0.15ms
# run `yes` → dirty=ALL every frame, capped at 60/s
# Now force full redraws and compare:
cargo run -p terminal-gui --release -- --debug-damage --no-damage-tracking
Do it at three sizes: 80×24, 200×50, and as large as your monitor allows.
PREDICTION. At which grid size does full-redraw frame time exceed 16 ms? Write the number down before measuring.
Test
#![allow(unused)] fn main() { #[test] fn pixel_to_cell_math_is_exact() { let cell = CellMetrics { width: 8, height: 17, ascent: 13 }; assert_eq!(pixels_to_cells(800, 600, &cell), (35, 100)); // 600/17 = 35.3 → 35 assert_eq!(pixels_to_cells(1, 1, &cell), (1, 1)); // never zero assert_eq!(pixels_to_cells(0, 0, &cell), (1, 1)); } #[test] fn baseline_is_ascent_below_the_cell_top() { let cell = CellMetrics { width: 8, height: 17, ascent: 13 }; assert_eq!(baseline_y(0, &cell), 13); assert_eq!(baseline_y(5, &cell), 5 * 17 + 13); } #[test] fn render_snapshot_resolves_colors_before_the_renderer_sees_them() { // The renderer must never see a palette index — theming happens once. let mut t = Terminal::new(3, 10); t.advance(b"\x1b[31mX"); let snap = RenderSnapshot::from_terminal(&t, &Theme::default(), None); assert!(matches!(snap.lines[0].runs[0].fg, Rgb { .. })); } #[test] fn inverse_and_selection_compose() { // Selection inverts; inverse text inverts; selected inverse text must end up // back at the original colors, not double-inverted into nonsense. let mut t = Terminal::new(3, 10); t.advance(b"\x1b[31;42;7mX"); let plain = RenderSnapshot::from_terminal(&t, &Theme::default(), None); let selected = RenderSnapshot::from_terminal(&t, &Theme::default(), Some(all_selected())); assert_eq!(plain.lines[0].runs[0].fg, selected.lines[0].runs[0].bg); } #[test] fn glyphs_outside_the_buffer_are_clipped() { let mut r = CpuRenderer::new(80, 24, test_fonts(), 16.0); // Bottom-right descender and a tall accent at the top-left. r.draw_grapheme("g", 79, 23, WHITE, BLACK, RenderFlags::empty(), 1); r.draw_grapheme("Ǻ", 0, 0, WHITE, BLACK, RenderFlags::empty(), 1); } #[test] fn resize_order_is_terminal_then_pty() { let mut app = TestApp::new(); app.record_calls(); app.on_resize(PhysicalSize::new(1600, 900)); let calls = app.calls(); assert!(calls.iter().position(|c| c == "terminal.resize").unwrap() < calls.iter().position(|c| c == "pty.resize").unwrap()); } }
Challenge Extensions
- Scrollback scrolling with the mouse wheel and Shift+PgUp/PgDn, including the alt-screen rule (translate wheel to arrow keys there).
- Configurable fonts and theme from a TOML file, with live reload.
- A
wgpubackend behind a feature flag, taking the sameRenderSnapshot. Benchmark both. - Box-drawing characters drawn programmatically so lines are seamless regardless of the font.
- A debug overlay (F12): FPS, dirty rows, atlas hit rate, bytes/sec, PTY buffer depth.
- Cursor blinking on a timer that does not wake the loop at 60 Hz, and that suspends while output is arriving.
- Ligature support via
swashfor a run of same-styled cells, keeping the cell model authoritative for cursor and selection.
Deliverables
-
A window with a working shell;
vim,top, andyesall behave. - CPU rendering with a glyph atlas; hit rate printable and >99% after warm-up.
- Correct metrics: you can state your font's ascent/descent/gap/advance and the derived cell size.
- The baseline rule implemented; no clipped descenders.
- Damage tracking with a printable dirty-row count.
-
Idle CPU at ~0%, verified with
top. - Resize in the correct order, with HiDPI handled.
-
A cursor with at least two shapes, no ghosting, honoring
?25. - The damage benchmark from the experiment, at three grid sizes.
Validation / Self-check
- Give the formula for the baseline and say what breaks if you use the cell top.
- Why must cell dimensions be integers?
- What is in the atlas cache key, and what does omitting style produce?
- Why does
RenderSnapshotresolve colors rather than passing palette indices? - State the correct resize order and the bug from reversing it.
- Why
ControlFlow::Waitrather thanPoll? - What is the freeze bug and how does decoupling fix it?
- Why must the renderer ignore a fallback font's advance?
- At what grid size did full redraw exceed 16 ms on your machine?
- Why does
terminal-render-modelexist rather than the renderer readingTerminaldirectly? Name three capabilities it buys.
Next: Lab 13 — The Input Encoder.