Section 3: The Graphical Frontend
Your terminal core is complete and invisible. This section gives it a window.
You will build a minimal graphical frontend: open a window, start a shell through the PTY layer, send keyboard input, feed PTY output into the parser, render the screen grid, handle resize, and draw a cursor. CPU rendering first. GPU rendering is an optional optimization at the end, not the starting point.
The rule for stack selection: choose the smallest stack that exposes the relevant concepts clearly. Do not choose a framework because it produces the fastest visual result — you would be trading the lesson for a screenshot.
The Chapters
| Chapter | Covers |
|---|---|
| Choosing the Stack | winit, softbuffer, pixels, wgpu, fontdue, cosmic-text, swash — what each does and what it hides |
| Input Encoding | Physical keys vs. text input, modifiers, every special key, DECCKM, kitty protocol, paste, mouse |
| Fonts and Rasterization | Metrics, baselines, cell size, glyph rasterization, atlases, shaping, the monospace assumption |
| Rendering and Damage | The frame loop, dirty regions, frame timing, decoupling read from render, CPU vs. GPU |
| Trace a Keystroke | The complete path for a, and for every special key |
The Labs
| Lab | Milestone | Build |
|---|---|---|
| Lab 12 | M7 | A window with a shell in it, CPU-rendered |
| Lab 13 | M8 | terminal-input: every key, modifier, mode, and paste |
| Lab 14 | — | Selection, copy, paste, scrollback scrolling |
The Architecture
┌──────────────────────────────────────────────────────────────────────────┐
│ terminal-gui — the ONLY crate that knows about pixels │
│ │
│ winit event loop │
│ ├─ WindowEvent::KeyboardInput ──▶ translate to terminal-input types │
│ │ │ │
│ │ terminal-input::encode_key(key, mods, │
│ │ modes) ──▶ bytes │
│ │ │ │
│ │ pty.write(bytes) │
│ │ │
│ ├─ PTY readable (from a reader thread or poll integration) │
│ │ bytes ──▶ terminal.advance(bytes) │
│ │ ──▶ pty.write(terminal.take_replies()) │
│ │ │
│ ├─ WindowEvent::Resized │
│ │ pixels ──▶ cells ──▶ terminal.resize() ──▶ pty.resize() │
│ │ │
│ └─ RedrawRequested │
│ RenderSnapshot::from(&terminal) │
│ ──▶ for each dirty row: shape → rasterize → blit │
│ ──▶ present │
└──────────────────────────────────────────────────────────────────────────┘
Two boundaries that must hold:
terminal-guitranslateswinittypes intoterminal-inputtypes.terminal-inputmust never mentionwinit. Otherwise swapping the windowing layer means rewriting the input encoder, and the encoder cannot be unit-tested without a window.- The renderer consumes a
RenderSnapshot, not theTerminal. Otherwise you cannot render on another thread, cannot test rendering, and cannot swap CPU for GPU.
The Concepts Taught Here
| Concept | Chapter |
|---|---|
| Event loops | Rendering and Damage |
| Keyboard events | Input Encoding |
| Text input vs. physical key events | Input Encoding |
| Key modifiers | Input Encoding |
| Encoding terminal input | Input Encoding |
| Rendering a cell grid | Rendering and Damage |
| Font metrics | Fonts and Rasterization |
| Baselines | Fonts and Rasterization |
| Glyph rasterization | Fonts and Rasterization |
| Glyph atlases | Fonts and Rasterization |
| Text shaping | Fonts and Rasterization |
| Monospace assumptions | Fonts and Rasterization |
| Unicode width | Fonts and Rasterization + Section 2 |
| Dirty-region rendering | Rendering and Damage |
| Frame timing | Rendering and Damage |
| GPU vs. CPU rendering | Rendering and Damage |
Deliverables
- A window opens; a shell runs inside it; you can type and see output.
- CPU rendering, with the GPU path explicitly deferred.
- Correct font metrics: you can state your font's ascent, descent, line gap, and advance, and show how cell width and height derive from them.
- A glyph atlas with a cache and a measured hit rate.
- A cursor, in at least block and bar shapes, that does not leave ghosts.
-
Resize: pixels → cells →
Terminal::resize→Pty::resize, and the inner program redraws. - Damage tracking, with the dirty-row count printable per frame.
-
Bounded frame timing:
yesdoes not make the UI unresponsive. - Every key in the key encoding table unit-tested.
- Selection, copy, and paste, with bracketed paste honored.
- All terminal modes reset on every exit path, including panic.
Common Mistakes in This Section
| Mistake | Symptom | Fix |
|---|---|---|
| Rendering inside the PTY-read branch | yes freezes the UI; the shell blocks in write | Decouple: drain into a buffer, render on a timer |
| Redrawing the whole screen every frame | 100% CPU on an idle terminal | Damage tracking, and only request a redraw when damaged |
| Drawing glyphs at the top of the cell | Descenders clipped; text sits too high | Draw at the baseline = cell_top + ascent |
| Computing cell width from a glyph's bounding box | Inconsistent cell widths | Use the advance width of a reference glyph |
| Using text input events for control keys | Ctrl+C never arrives | Control keys come from physical key events, not text input |
| Using physical key events for text | Broken on non-US layouts and IME | Text comes from the text-input event |
| Not resetting mouse/alt-screen modes on exit | The user's shell emits garbage on click | Reset on every exit path |
winit types leaking into terminal-input | The encoder cannot be tested or reused | Translate at the boundary |
Renderer reading Terminal directly | Cannot render off-thread, cannot test | RenderSnapshot |
| Ignoring HiDPI scale factor | Blurry text, wrong cell counts | Multiply by scale_factor, round cell size to integers |
| Cursor not damaging its old row | Ghost cursor blocks left behind | Damage both rows on every cursor move |
How to Verify Success
cargo run -p terminal-gui
# Inside the window:
# 1. vim → opens, alternate screen, :q restores the shell
# 2. top → renders, updates, header stays put
# 3. resize the window → both redraw at the new size
# 4. yes → scrolls fast; the window stays responsive; ^C stops it
# 5. Ctrl+C during `sleep 100` → interrupts the sleep, not your terminal
# 6. Alt+B / Alt+F in bash → word movement works
# 7. paste multi-line text into `vim` in insert mode → no staircase
# 8. click and drag in htop → mouse reporting works
# 9. select text with the mouse, Ctrl+Shift+C, paste elsewhere
# 10. printf '日本語 🙂 é\n' → correct widths, no overlap
Next: Choosing the Stack.