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


Predict First

  1. Your first render draws glyphs at the cell's top-left. What will it look like?
  2. You render inside the PTY-read branch. What happens when you run yes?
  3. You forget to damage the old cursor row. What artifact appears?
  4. 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::Indexed meaningful — 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

  1. Scrollback scrolling with the mouse wheel and Shift+PgUp/PgDn, including the alt-screen rule (translate wheel to arrow keys there).
  2. Configurable fonts and theme from a TOML file, with live reload.
  3. A wgpu backend behind a feature flag, taking the same RenderSnapshot. Benchmark both.
  4. Box-drawing characters drawn programmatically so lines are seamless regardless of the font.
  5. A debug overlay (F12): FPS, dirty rows, atlas hit rate, bytes/sec, PTY buffer depth.
  6. Cursor blinking on a timer that does not wake the loop at 60 Hz, and that suspends while output is arriving.
  7. Ligature support via swash for a run of same-styled cells, keeping the cell model authoritative for cursor and selection.

Deliverables

  • A window with a working shell; vim, top, and yes all 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

  1. Give the formula for the baseline and say what breaks if you use the cell top.
  2. Why must cell dimensions be integers?
  3. What is in the atlas cache key, and what does omitting style produce?
  4. Why does RenderSnapshot resolve colors rather than passing palette indices?
  5. State the correct resize order and the bug from reversing it.
  6. Why ControlFlow::Wait rather than Poll?
  7. What is the freeze bug and how does decoupling fix it?
  8. Why must the renderer ignore a fallback font's advance?
  9. At what grid size did full redraw exceed 16 ms on your machine?
  10. Why does terminal-render-model exist rather than the renderer reading Terminal directly? Name three capabilities it buys.

Next: Lab 13 — The Input Encoder.