docs/superpowers/specs/2026-04-07-waystty-design.md
Ref: Size: 10.9 KiB
# waystty Design Spec
A minimal, hackable Wayland terminal emulator written in Zig, using libghostty-vt for terminal emulation, Vulkan for rendering, and freetype/harfbuzz for font handling.
## Goals
- Light and minimal — a terminal you can hack on
- Comparable to foot in spirit: fast, simple, Wayland-native
- libghostty-vt handles VT parsing and terminal state; we own everything else
## Architecture
Six modules, each owning one concern:
```
main.zig — event loop, glue, signal handling
wayland.zig — Wayland protocol handling (zig-wayland)
vt.zig — idiomatic Zig wrapper around libghostty-vt C API
pty.zig — PTY spawn, read/write, child process management
font.zig — fontconfig lookup, freetype rasterization, harfbuzz shaping, glyph atlas
renderer.zig — Vulkan setup, pipeline, instanced quad rendering
```
### Data flow per frame
1. Wayland dispatches input events (keyboard/pointer) to main loop
2. Input encoded via vt.zig (key/mouse encoders), written to PTY
3. PTY output read, fed to terminal via `ghostty_terminal_vt_write`
4. Renderer snapshots render state from vt.zig, iterates cells
5. Missing glyphs rasterized on demand by font.zig into the atlas
6. Renderer builds instance buffer (cell position, atlas UV, fg/bg color), issues single instanced draw call
## Module Details
### wayland.zig
Protocols:
- `wl_compositor` + `wl_surface` — drawing surface
- `xdg_wm_base` + `xdg_surface` + `xdg_toplevel` — window management (SSD only, no CSD)
- `wl_seat` → `wl_keyboard` + `wl_pointer` — input
- `wp_cursor_shape_v1` — set I-beam cursor over terminal area
- `wl_output` — monitor scale factor for DPI awareness
- `wp_fractional_scale_v1` — fractional DPI scaling (if available)
Vulkan surface via `VK_KHR_wayland_surface` (`VkSurfaceKHR` from `wl_display` + `wl_surface`).
Keyboard input: keycodes from `wl_keyboard.key`, mapped via xkbcommon to keysyms/UTF-8, then handed to vt.zig's key encoder.
Key repeat: client-side implementation using `wl_keyboard.repeat_info` (rate + delay). Wayland does not repeat keys for you — we must track the last pressed key and fire repeats on a timer in the event loop.
Focus events: track `wl_keyboard.enter` / `wl_keyboard.leave`, encode via `ghostty_focus_encode()` when terminal has `GHOSTTY_MODE_FOCUS_EVENT` set.
Event loop: `wl_display_get_fd()` polled alongside PTY fd. Single-threaded.
#### Clipboard (phase 2)
Not implemented in v1. Requires `wl_data_device_manager`, `wl_data_device`, `wl_data_source`, `wl_data_offer` — significant protocol work. Will be the first feature added after the terminal is functional. Without clipboard, the terminal is usable but inconvenient.
### pty.zig
- `forkpty()` to spawn child shell (`$SHELL` or `/bin/sh`)
- Set `TERM=xterm-256color` in child environment
- Non-blocking master fd
- `read()` / `write()` helpers
- `SIGCHLD` handling for child exit
- `ioctl(TIOCSWINSZ)` for resize
### vt.zig
Zig wrapper around `<ghostty/vt.h>` via `@cImport`. The C API is opaque-handle-based — all types are pointers created/freed with `_new`/`_free` pairs. Configuration uses a setter pattern: `ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_*, value)`.
Wrapped handle types:
- `Terminal` — `ghostty_terminal_new` / `_free`. Init with cols, rows, max_scrollback. Feed bytes via `ghostty_terminal_vt_write`. Resize via `ghostty_terminal_resize`. Scroll viewport via `ghostty_terminal_scroll_viewport`.
- `RenderState` — `ghostty_render_state_new` / `_free`. Snapshot terminal state via `ghostty_render_state_update(render_state, terminal)`. Must be called after feeding PTY data, before iterating cells. Query colors via `ghostty_render_state_colors_get`. Check dirty flag via `GHOSTTY_RENDER_STATE_OPTION_DIRTY`.
- `RowIterator` — `ghostty_render_state_row_iterator_new` / `_free`. Iterate rows via `_next`. Each row yields a `RowCells`.
- `RowCells` — `ghostty_render_state_row_cells_new` / `_free`. Iterate cells via `_next`. Per-cell getters for: graphemes buffer, fg/bg colors, style flags (bold, italic, inverse, underline). Check row dirty via `GHOSTTY_RENDER_STATE_ROW_OPTION_DIRTY`.
- `KeyEncoder` — `ghostty_key_encoder_new` / `_free`. Before each encode, sync terminal modes via `ghostty_key_encoder_setopt_from_terminal`. Then create `KeyEvent` (`_new` / `_free`), populate, and encode.
- `MouseEncoder` — `ghostty_mouse_encoder_new` / `_free`. Before each encode, sync via `ghostty_mouse_encoder_setopt_from_terminal`. Create `MouseEvent` (`_new` / `_free`), populate, and encode.
Required effect callbacks (set via `ghostty_terminal_set`):
- `GHOSTTY_TERMINAL_OPT_WRITE_PTY` — terminal sends responses (device status, cursor position) back to shell. **Mandatory**.
- `GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES` — DA1/DA2/DA3 query responses. Reports VT220 conformance.
- `GHOSTTY_TERMINAL_OPT_SIZE` — reports terminal dimensions to querying applications.
- `GHOSTTY_TERMINAL_OPT_XTVERSION` — returns "waystty" for XTVERSION queries.
- `GHOSTTY_TERMINAL_OPT_TITLE_CHANGED` — updates the xdg_toplevel title.
- `GHOSTTY_TERMINAL_OPT_COLOR_SCHEME` — stub (returns false).
System init: call `ghostty_sys_set(GHOSTTY_SYS_OPT_DECODE_PNG, decode_png)` before creating terminal if Kitty graphics support is desired (phase 2).
### font.zig
- fontconfig: find system monospace font at startup
- freetype: load font face, rasterize glyphs to single-channel (R8) bitmaps
- harfbuzz: deferred to phase 2. For v1, render single codepoints per cell — libghostty already handles grapheme clustering and provides codepoints per cell via `GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_BUF`. Ligature/shaping support can be added later by shaping runs of same-styled cells.
- Glyph atlas: single R8 texture (e.g. 1024x1024), row-based packing, returns UV coords per glyph. Fragment shader applies fg color to the single-channel alpha. Colored emoji would need a separate RGBA atlas (phase 2).
- Cell metrics: `cell_width` derived from font advance width of 'M', `cell_height` from font ascent + descent + line gap. These are computed once at font load and used for grid calculations.
### renderer.zig
Vulkan setup:
- Instance → physical device → logical device → swapchain (FIFO present mode)
- Single render pass, single subpass, single graphics pipeline
- Vertex shader: per-instance data (cell x/y, atlas UV rect, fg color, bg color) → positioned quad
- Fragment shader: samples glyph atlas, applies fg color; bg drawn as untextured quads
- One instanced draw call per frame
- Swapchain recreation on resize
Shaders: GLSL compiled to SPIR-V via `glslc` at build time, embedded via `@embedFile`.
## Event Loop
```
init wayland → init vulkan → init font → init terminal → spawn pty
loop:
poll(wayland_fd, pty_fd)
if wayland_fd readable:
wl_display_dispatch()
→ keyboard/pointer events → encode via vt.zig → write to pty
if pty_fd readable:
read pty → feed to terminal via ghostty_terminal_vt_write()
if key repeat timer expired:
re-send last key event to pty
if terminal dirty (GHOSTTY_RENDER_STATE_OPTION_DIRTY) or resize:
ghostty_render_state_update(render_state, terminal)
rebuild instance buffer (skip clean rows via ROW_OPTION_DIRTY)
render frame
if child exited:
break
if xdg_toplevel close:
break
cleanup: free terminal, destroy vulkan, disconnect wayland
```
### Resize handling
1. `xdg_toplevel` configure event gives new pixel dimensions
2. Recreate Vulkan swapchain
3. Recalculate grid: `cols = pixel_width / cell_width`, `rows = pixel_height / cell_height`
4. `ghostty_terminal_resize()` with new dimensions
5. `ioctl(TIOCSWINSZ)` on PTY fd
## Hardcoded Defaults
- Font: system monospace via fontconfig, 14px
- Colors: 16-color palette + fg/bg
- Scrollback: 1000 lines
- Window title: "waystty"
- Shell: `$SHELL` or `/bin/sh`
## Build System
`build.zig` only. No Makefile.
Targets:
- `zig build` — build
- `zig build run` — build and run
Zig package dependencies (via `build.zig.zon`):
- zig-wayland — Wayland protocol bindings
- vulkan-zig — Vulkan bindings generator
**libghostty-vt acquisition:** Built from source. The ghostty repo is fetched via `build.zig.zon` (pinned commit hash). A custom build step invokes `zig build lib-vt` within the fetched source to produce the shared library, similar to how ghostling's CMakeLists.txt handles it.
System C library linkage:
- freetype2
- harfbuzz
- fontconfig
- xkbcommon
- wayland-client
Shader build step: `glslc` compiles `.vert`/`.frag` to SPIR-V, embedded via `@embedFile`.
### Project layout
```
waystty/
├── build.zig
├── build.zig.zon
├── src/
│ ├── main.zig
│ ├── wayland.zig
│ ├── vt.zig
│ ├── pty.zig
│ ├── font.zig
│ └── renderer.zig
└── shaders/
├── cell.vert
└── cell.frag
```
## Testing
### Unit tests
Zig `test` blocks per module:
- `pty.zig` — spawn shell, write/read, verify child exit
- `font.zig` — load font, rasterize glyph, verify atlas packing and UV coords
- `vt.zig` — feed escape sequences, snapshot render state, verify cells/colors/cursor
`wayland.zig` and `renderer.zig` require a compositor/GPU — not unit tested.
Run with `zig build test`.
### Integration testing
End-to-end: spawn waystty with `echo "hello"; exit`, verify clean exit. Can run under `cage` or similar headless compositor in CI. Not day-one priority.
## Performance
### Measurement tools
1. **vtebench** — terminal throughput benchmark, compare against foot
2. **Frame timing** — `std.time.Timer` in render loop, warn in debug builds if frame > 16ms
3. **tracy** — Zig's `std.trace` for profiling key sections (pty read, VT parse, atlas rasterize, Vulkan draw)
### Performance by design
- Single instanced draw call per frame
- Glyph atlas caching (rasterize once, reuse)
- Dirty tracking from libghostty render state
- Single-threaded with `poll()` (no lock contention)
- FIFO vsync (no wasted frames)
### Non-goals
- No premature optimization — get it working, then profile
- No threading unless profiling proves the single thread is the bottleneck
## Phased Features
Features deferred from v1 to keep initial scope minimal:
| Feature | Phase | Notes |
|---------|-------|-------|
| Clipboard (copy/paste) | 2 | `wl_data_device_manager` protocol work. First post-v1 priority. |
| Harfbuzz text shaping | 2 | Ligatures, complex scripts. v1 renders single codepoints per cell. |
| Kitty graphics protocol | 2 | Image display in terminal. Requires PNG decode callback + placement rendering. |
| Colored emoji | 2 | Separate RGBA atlas alongside the R8 glyph atlas. |
| Scrollbar rendering | 2 | Visual scrollbar. Viewport scrolling via `ghostty_terminal_scroll_viewport` is in v1. |
| Config file | future | `~/.config/waystty/config` for fonts, colors, keybindings. |
| URL detection/opening | future | Click-to-open URLs. |
| Multiple windows/tabs | future | Out of scope for a minimal terminal. |