Choosing the Stack
You need three things from a graphical frontend: a window with an event loop, a surface you can put pixels on, and a way to turn characters into pixels. Rust has good options for each, at several levels of abstraction. This chapter evaluates them against one criterion:
Which stack exposes the relevant concepts most clearly?
Not which is fastest to a screenshot. Not which is most popular. A stack that hides glyph
rasterization behind draw_text(x, y, "hello") has removed the lesson.
The Three Layers
┌─────────────────────────────────────────────────────────────────────┐
│ 1. WINDOWING + EVENTS │
│ open a window, get keyboard/mouse/resize events, present frames │
│ → winit │
├─────────────────────────────────────────────────────────────────────┤
│ 2. SURFACE / PIXELS │
│ a buffer you can write pixels into, and get onto the screen │
│ → softbuffer (CPU) | pixels (CPU→GPU blit) | wgpu (GPU) │
├─────────────────────────────────────────────────────────────────────┤
│ 3. TEXT │
│ font parsing, metrics, glyph rasterization, shaping, fallback │
│ → fontdue | swash | cosmic-text | ab_glyph │
└─────────────────────────────────────────────────────────────────────┘
Layer 1: Windowing
| Crate | What it gives you | What it hides | Verdict |
|---|---|---|---|
winit | Cross-platform window creation, an event loop, keyboard (physical + logical + text), mouse, resize, DPI scale, IME | Platform APIs (X11/Wayland/AppKit/Win32) — genuinely fine to hide; you are not learning window managers | Use this. It is the de-facto standard and every Rust terminal uses it. |
sdl2 | Same, plus audio, plus its own rendering | More surface than you need; a C dependency | No |
gtk-rs / tao | Same, plus widgets | Far too much abstraction for a grid of cells | No |
| Raw X11/Wayland/AppKit | Everything | Nothing | Educational, but it is a different curriculum |
Note:
winit's API changes between major versions, notably around the event loop (runvs.run_appwith anApplicationHandler) and keyboard events (the 0.29 keyboard rework introducedPhysicalKey/Key/text). Pin a version inCargo.toml, read that version's docs, and expect the code in this book to need mechanical adjustment. Runcargo doc --open -p winitrather than trusting any tutorial, including this one.
Layer 2: The Surface
This is the important choice, and the answer is start on the CPU.
| Crate | Model | Exposes | Hides | When |
|---|---|---|---|---|
softbuffer | A raw &mut [u32] of 0x00RRGGBB pixels, presented to the window | Everything. You write pixels. There is no drawing API at all. | Nothing | Start here. Milestone 7. |
pixels | A CPU framebuffer uploaded to a GPU texture each frame | The framebuffer; a little GPU setup | The upload path | Fine alternative; slightly more machinery |
wgpu | Full GPU: shaders, buffers, pipelines, textures | GPU rendering properly | CPU rasterization | Later, as an optimization |
skia/tiny-skia/vello | A 2-D drawing API | Paths, fills, AA | Glyph placement, rasterization | No — hides the lesson |
Why softbuffer is the right start:
#![allow(unused)] fn main() { // This is the ENTIRE drawing API. There is no draw_text, no draw_rect. let mut buffer = surface.buffer_mut()?; buffer[y * width + x] = 0x00_FF_00_00; // one red pixel at (x, y) buffer.present()?; }
You cannot avoid learning what a baseline is, because there is no function that would have known it for you. You will write:
for each dirty row:
for each cell in the row:
resolve colors (theme, inverse, selection)
fill the cell rectangle with the background
rasterize the glyph → an 8-bit coverage bitmap
for each pixel of the bitmap:
blend(fg, bg, coverage) → buffer[...]
Every line of that is a concept from Fonts and Rasterization.
When to move to wgpu: when your damage-tracked CPU renderer is measurably too slow at a size
you care about — and you have the measurement. On a modern machine, an 80×24 CPU renderer with
damage tracking is comfortably under 1 ms per frame. At 400×100 with a full-screen redraw it is
not. Measure, then move. Moving early means debugging shader pipelines while you still do not
know where the baseline is.
Layer 3: Text
| Crate | Scope | Gives you | Verdict |
|---|---|---|---|
fontdue | Rasterizer + metrics | Parse a TTF/OTF, get metrics, rasterize a glyph to an 8-bit coverage bitmap. Small, pure Rust, no shaping. | Start here. It is exactly the right size for a monospace grid. |
ab_glyph | Rasterizer + metrics | Similar scope, similar suitability | Fine alternative |
swash | Rasterizer + shaper | Adds real shaping (ligatures, complex scripts), font fallback hooks | Move here when you want ligatures or Arabic/Indic |
cosmic-text | Full text stack | Shaping, layout, fallback, font discovery, editing | Too much for a grid — it wants to own layout, and the grid already decided layout |
rusttype | Predecessor to ab_glyph | — | Deprecated |
harfbuzz (via bindings) | The shaper | The real thing, C dependency | Only via swash/cosmic-text unless you have a reason |
Why not cosmic-text from the start? It is genuinely good, and it solves problems a terminal
partly does not have. A terminal grid has already decided that each cell is one column wide at a
fixed advance. A general text layout engine wants to decide positions itself. Using one means
constantly overriding its layout decisions — and hiding, behind that fight, the very concepts
(advance, baseline, cell metrics) you are here to learn.
The monospace assumption is what makes fontdue sufficient:
ASSUMPTION: every glyph advances by exactly cell_width.
TRUE FOR: ASCII and most Latin in a monospace font
FALSE FOR: CJK (2 cells), emoji (2 cells), combining marks (0 cells),
ligatures (n glyphs in m cells), and any proportional font
The terminal handles the first three by ASSIGNING widths itself
(see the Unicode chapter) and rendering each cell independently.
Ligatures require real shaping, which is why they are a `swash` feature.
The Recommended Stack
[dependencies]
winit = "0.30" # windowing + events. Pin it; the API moves.
softbuffer = "0.4" # a raw pixel buffer. No drawing API by design.
fontdue = "0.9" # font parsing, metrics, glyph rasterization.
# Explicitly NOT (yet):
# wgpu — GPU rendering. Milestone 7 stretch goal, after measurement.
# cosmic-text — a full text stack. Wants to own layout; the grid already does.
# swash — shaping. Add when you want ligatures or complex scripts.
Roughly 400 lines gets you a working terminal window with this stack. That is small enough to hold in your head, which is the entire point.
What Each Crate Wraps
Per the curriculum's rule — name the underlying capability before adopting the abstraction:
| Crate | Underlying capability |
|---|---|
winit | Platform window APIs: X11/Wayland (Linux), AppKit NSWindow/NSEvent (macOS), Win32 (Windows). Its event loop is the platform's run loop. |
softbuffer | Platform surface presentation: X11 XPutImage/shm, Wayland wl_shm buffers, macOS CGContext/IOSurface. It gives you the memory the compositor will read. |
pixels | The above, plus a wgpu texture upload and a fullscreen-quad shader. |
wgpu | Vulkan / Metal / D3D12 / OpenGL, behind the WebGPU API. |
fontdue | TrueType/OpenType table parsing (head, hhea, cmap, glyf/CFF, hmtx) plus a scanline rasterizer with anti-aliasing. |
swash | The above, plus OpenType shaping (GSUB/GPOS) — the same job HarfBuzz does. |
If you want the concept without the crate, the exercises are: read a TTF header and find the cmap
table by hand; open an X11 window with xcb and blit a buffer. Both are worthwhile afternoons and
neither is required here.
The HiDPI Trap
Every one of these layers has a scale factor, and getting it wrong produces blurry text that people notice immediately.
Window logical size: 800 × 600 (what the user sees, in "points")
Scale factor: 2.0 (a Retina/HiDPI display)
Physical pixel size: 1600 × 1200 (what you actually draw)
Rules:
• Create the SURFACE at PHYSICAL size.
• Rasterize glyphs at font_size × scale_factor.
• Compute cell size in PHYSICAL pixels, then ROUND TO INTEGERS.
• cols = physical_width / cell_width_px (integer division)
• Handle ScaleFactorChanged: the window can move between monitors.
Warning: Rounding cell size to integers is not optional. A cell width of 8.4 px means column 100 starts at x=840.0 and column 101 at 848.4 — glyphs land on fractional pixel boundaries, and every other column looks blurry. Round the cell size, accept a few unused pixels at the right and bottom edges, and fill them with the background color.
The Decision Table
If you want to deviate, here is the honest trade-off:
| You want | Choose | You give up |
|---|---|---|
| To learn the mechanics | winit + softbuffer + fontdue | Speed at very large sizes |
| Ligatures (Fira Code, JetBrains Mono) | swash instead of fontdue | Simplicity; shaping is genuinely complex |
| Complex scripts (Arabic, Devanagari) | cosmic-text | The clean grid model; these scripts fight the cell abstraction |
| Maximum performance | wgpu with an instanced glyph-quad pipeline | Debuggability; shader bugs are opaque |
| Fewest dependencies | Raw X11/Wayland + a hand-written TTF parser | Months |
| Cross-platform including Windows | winit + wgpu | softbuffer works on Windows too, actually — this is not a real constraint |
Experiment
CLAIM. softbuffer genuinely gives you nothing but pixels, and that is the point.
METHOD. Before writing any terminal code, write a 40-line program that opens a window and draws a
red rectangle with winit + softbuffer. Then try to draw the letter "A" without fontdue.
PREDICTION. How long do you think it takes to draw a recognizable "A" from scratch? What would you need to know?
RESULT. The answer is that you would need to parse a font file, extract a glyph outline (quadratic
Bézier curves for TrueType), scan-convert it with anti-aliasing, and position it on a baseline.
fontdue is 3,000 lines that do exactly that. Now you know precisely what it is doing for you, which
is the correct relationship to have with a dependency.
Validation / Self-check
- Name the three layers of a graphical frontend and one crate for each.
- Why start with CPU rendering rather than GPU?
- What is
softbuffer's entire drawing API, and why is that a feature? - Why not
cosmic-textfor a terminal grid? - What is the monospace assumption, and name three cases where it is false.
- What does
fontduedo that you would otherwise write yourself? - What does
swashadd overfontdue, and when do you need it? - State the four HiDPI rules. What goes wrong if cell size is not rounded to integers?
- What underlying platform capability does
winitwrap on each of Linux, macOS, and Windows? - What measurement would justify moving to
wgpu?
Next: Input Encoding.