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

CrateWhat it gives youWhat it hidesVerdict
winitCross-platform window creation, an event loop, keyboard (physical + logical + text), mouse, resize, DPI scale, IMEPlatform APIs (X11/Wayland/AppKit/Win32) — genuinely fine to hide; you are not learning window managersUse this. It is the de-facto standard and every Rust terminal uses it.
sdl2Same, plus audio, plus its own renderingMore surface than you need; a C dependencyNo
gtk-rs / taoSame, plus widgetsFar too much abstraction for a grid of cellsNo
Raw X11/Wayland/AppKitEverythingNothingEducational, but it is a different curriculum

Note: winit's API changes between major versions, notably around the event loop (run vs. run_app with an ApplicationHandler) and keyboard events (the 0.29 keyboard rework introduced PhysicalKey/Key/text). Pin a version in Cargo.toml, read that version's docs, and expect the code in this book to need mechanical adjustment. Run cargo doc --open -p winit rather than trusting any tutorial, including this one.


Layer 2: The Surface

This is the important choice, and the answer is start on the CPU.

CrateModelExposesHidesWhen
softbufferA raw &mut [u32] of 0x00RRGGBB pixels, presented to the windowEverything. You write pixels. There is no drawing API at all.NothingStart here. Milestone 7.
pixelsA CPU framebuffer uploaded to a GPU texture each frameThe framebuffer; a little GPU setupThe upload pathFine alternative; slightly more machinery
wgpuFull GPU: shaders, buffers, pipelines, texturesGPU rendering properlyCPU rasterizationLater, as an optimization
skia/tiny-skia/velloA 2-D drawing APIPaths, fills, AAGlyph placement, rasterizationNo — 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

CrateScopeGives youVerdict
fontdueRasterizer + metricsParse 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_glyphRasterizer + metricsSimilar scope, similar suitabilityFine alternative
swashRasterizer + shaperAdds real shaping (ligatures, complex scripts), font fallback hooksMove here when you want ligatures or Arabic/Indic
cosmic-textFull text stackShaping, layout, fallback, font discovery, editingToo much for a grid — it wants to own layout, and the grid already decided layout
rusttypePredecessor to ab_glyph—Deprecated
harfbuzz (via bindings)The shaperThe real thing, C dependencyOnly 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.

[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:

CrateUnderlying capability
winitPlatform window APIs: X11/Wayland (Linux), AppKit NSWindow/NSEvent (macOS), Win32 (Windows). Its event loop is the platform's run loop.
softbufferPlatform surface presentation: X11 XPutImage/shm, Wayland wl_shm buffers, macOS CGContext/IOSurface. It gives you the memory the compositor will read.
pixelsThe above, plus a wgpu texture upload and a fullscreen-quad shader.
wgpuVulkan / Metal / D3D12 / OpenGL, behind the WebGPU API.
fontdueTrueType/OpenType table parsing (head, hhea, cmap, glyf/CFF, hmtx) plus a scanline rasterizer with anti-aliasing.
swashThe 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 wantChooseYou give up
To learn the mechanicswinit + softbuffer + fontdueSpeed at very large sizes
Ligatures (Fira Code, JetBrains Mono)swash instead of fontdueSimplicity; shaping is genuinely complex
Complex scripts (Arabic, Devanagari)cosmic-textThe clean grid model; these scripts fight the cell abstraction
Maximum performancewgpu with an instanced glyph-quad pipelineDebuggability; shader bugs are opaque
Fewest dependenciesRaw X11/Wayland + a hand-written TTF parserMonths
Cross-platform including Windowswinit + wgpusoftbuffer 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

  1. Name the three layers of a graphical frontend and one crate for each.
  2. Why start with CPU rendering rather than GPU?
  3. What is softbuffer's entire drawing API, and why is that a feature?
  4. Why not cosmic-text for a terminal grid?
  5. What is the monospace assumption, and name three cases where it is false.
  6. What does fontdue do that you would otherwise write yourself?
  7. What does swash add over fontdue, and when do you need it?
  8. State the four HiDPI rules. What goes wrong if cell size is not rounded to integers?
  9. What underlying platform capability does winit wrap on each of Linux, macOS, and Windows?
  10. What measurement would justify moving to wgpu?

Next: Input Encoding.