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

ChapterCovers
Choosing the Stackwinit, softbuffer, pixels, wgpu, fontdue, cosmic-text, swash — what each does and what it hides
Input EncodingPhysical keys vs. text input, modifiers, every special key, DECCKM, kitty protocol, paste, mouse
Fonts and RasterizationMetrics, baselines, cell size, glyph rasterization, atlases, shaping, the monospace assumption
Rendering and DamageThe frame loop, dirty regions, frame timing, decoupling read from render, CPU vs. GPU
Trace a KeystrokeThe complete path for a, and for every special key

The Labs

LabMilestoneBuild
Lab 12M7A window with a shell in it, CPU-rendered
Lab 13M8terminal-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:

  1. terminal-gui translates winit types into terminal-input types. terminal-input must never mention winit. Otherwise swapping the windowing layer means rewriting the input encoder, and the encoder cannot be unit-tested without a window.
  2. The renderer consumes a RenderSnapshot, not the Terminal. Otherwise you cannot render on another thread, cannot test rendering, and cannot swap CPU for GPU.

The Concepts Taught Here

ConceptChapter
Event loopsRendering and Damage
Keyboard eventsInput Encoding
Text input vs. physical key eventsInput Encoding
Key modifiersInput Encoding
Encoding terminal inputInput Encoding
Rendering a cell gridRendering and Damage
Font metricsFonts and Rasterization
BaselinesFonts and Rasterization
Glyph rasterizationFonts and Rasterization
Glyph atlasesFonts and Rasterization
Text shapingFonts and Rasterization
Monospace assumptionsFonts and Rasterization
Unicode widthFonts and Rasterization + Section 2
Dirty-region renderingRendering and Damage
Frame timingRendering and Damage
GPU vs. CPU renderingRendering 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: yes does 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

MistakeSymptomFix
Rendering inside the PTY-read branchyes freezes the UI; the shell blocks in writeDecouple: drain into a buffer, render on a timer
Redrawing the whole screen every frame100% CPU on an idle terminalDamage tracking, and only request a redraw when damaged
Drawing glyphs at the top of the cellDescenders clipped; text sits too highDraw at the baseline = cell_top + ascent
Computing cell width from a glyph's bounding boxInconsistent cell widthsUse the advance width of a reference glyph
Using text input events for control keysCtrl+C never arrivesControl keys come from physical key events, not text input
Using physical key events for textBroken on non-US layouts and IMEText comes from the text-input event
Not resetting mouse/alt-screen modes on exitThe user's shell emits garbage on clickReset on every exit path
winit types leaking into terminal-inputThe encoder cannot be tested or reusedTranslate at the boundary
Renderer reading Terminal directlyCannot render off-thread, cannot testRenderSnapshot
Ignoring HiDPI scale factorBlurry text, wrong cell countsMultiply by scale_factor, round cell size to integers
Cursor not damaging its old rowGhost cursor blocks left behindDamage 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.