bddbc526
Add waystty design spec
a73x 2026-04-07 19:07
Minimal Wayland terminal emulator using libghostty-vt, Vulkan rendering with glyph atlas + instanced quads, and freetype/harfbuzz font stack. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
diff --git a/docs/superpowers/specs/2026-04-07-waystty-design.md b/docs/superpowers/specs/2026-04-07-waystty-design.md new file mode 100644 index 0000000..2791c61 --- /dev/null +++ b/docs/superpowers/specs/2026-04-07-waystty-design.md @@ -0,0 +1,203 @@ # 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 - `wl_seat` → `wl_keyboard` + `wl_pointer` — input 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. Event loop: `wl_display_get_fd()` polled alongside PTY fd. Single-threaded. ### pty.zig - `forkpty()` to spawn child shell (`$SHELL` or `/bin/sh`) - 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`: - `Terminal` — init, feed bytes, resize, scroll, get render state - `KeyEncoder` / `MouseEncoder` — encode input events to VT sequences - `RenderState` — snapshot, iterate rows/cells, get colors/cursor - Effect callbacks: write-to-pty, device-attributes, title-changed, etc. ### font.zig - fontconfig: find system monospace font at startup - freetype: load font face, rasterize glyphs to bitmaps - harfbuzz: shape text runs (ligatures, combining characters) - Glyph atlas: single RGBA texture (e.g. 1024x1024), row-based packing, returns UV coords per glyph ### 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 terminal dirty or resize: snapshot render state rebuild instance buffer 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 System C library linkage: - libghostty-vt - 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