Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 17 additions & 18 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,9 @@ on:

jobs:
# The core library has no C dependencies, so the full test suite runs on every
# OS with just the Zig toolchain. Each OS also renders a UI screenshot (pure
# renderer, no SDL) and uploads it as an artifact, so you can see — and diff —
# how zigui looks on each platform.
# OS with just the Zig toolchain — no GPU, window server, or SDL required.
test:
name: test + screenshot (${{ matrix.os }})
name: test (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
Expand All @@ -25,23 +23,14 @@ jobs:
version: 0.16.0
- name: Run tests
run: zig build test --summary all
- name: Render UI screenshot (pure renderer, no SDL)
shell: bash
continue-on-error: true
run: zig build run-screenshot -Doptimize=ReleaseFast -- "zigui-${{ matrix.os }}.bmp"
- name: Upload screenshot
if: always()
uses: actions/upload-artifact@v4
with:
name: screenshot-${{ matrix.os }}
path: "zigui-${{ matrix.os }}.bmp"
if-no-files-found: warn

# The SDL3-backed examples are built (not run) to verify the backend links.
# The SDL3-backed examples are built (not run) to verify the backend links, and
# the showcase renders one frame to a BMP via its headless `--screenshot` flag
# (pure software rasterizer — no window) which is uploaded as an artifact.
# macOS only: SDL3 installs cleanly via Homebrew, whereas it isn't yet packaged
# in the Ubuntu runner's apt. (Core Linux coverage is the test + docker jobs.)
examples:
name: build examples (macOS)
name: build examples + screenshot (macOS)
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -51,7 +40,17 @@ jobs:
- name: Install SDL3
run: brew install sdl3
- name: Build examples
run: zig build hello settings showcase llm-chat
run: zig build showcase edit -Doptimize=ReleaseFast
- name: Render UI screenshot (headless, no window)
continue-on-error: true
run: ./zig-out/bin/showcase --screenshot "zigui-showcase.bmp"
- name: Upload screenshot
if: always()
uses: actions/upload-artifact@v4
with:
name: showcase-screenshot
path: "zigui-showcase.bmp"
if-no-files-found: warn

# Reproduce the Linux test run inside Docker (mirrors local `docker build`).
docker:
Expand Down
127 changes: 110 additions & 17 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,27 +23,33 @@ seams each one uses.
## Build / test / run — and the gotchas

```sh
zig build test --summary all # 152 tests, headless. THIS is the inner loop.
zig build hello settings showcase llm-chat edit # build the examples (does NOT run them)
zig build test --summary all # 169 tests, headless. THIS is the inner loop.
zig build showcase edit # build the two examples (does NOT run them)
zig build run-showcase # opens a window — blocks on the event loop
docker build -t zigui-test . # run the full suite on Linux

# Headless proof the llm-chat networking works against a real LLM (no window):
./zig-out/bin/llm-chat --smoke "say hi" --model <name> # one-shot
./zig-out/bin/llm-chat --smoke "count to 5" --model <name> --stream # SSE path

# Headless UI iteration for the text editor (renders one frame to a BMP):
# Headless UI iteration (renders one frame to a BMP, no window):
./zig-out/bin/showcase --screenshot /tmp/sc.bmp [section] [--dark] [--accent N]
./zig-out/bin/edit --screenshot /tmp/edit.bmp [file] [--demo-find|--demo-dialog]
# sips -s format png /tmp/sc.bmp --out /tmp/sc.png # to view on macOS
# --bench N re-rasterizes the frame N times and prints ms/frame (build with
# -Doptimize=ReleaseFast) — use it when touching src/render/raster.zig.
```

There are exactly **two examples**: `examples/showcase` (the kitchen-sink gallery
— every public component across sidebar sections, plus live light/dark and accent
switchers, built on the macOS 26 theme) and `examples/edit` (the multi-line text
editor). The old `hello`/`settings`/`llm-chat`/`screenshot` examples were folded
into `showcase` and removed.

- **Use `zig build test`, NOT `zig test src/zigui.zig`.** The bundled Inter font
is an anonymous import `inter_font` wired only in `build.zig`
(`mod.addAnonymousImport("inter_font", ...)`); `text/ttf.zig` does
`@embedFile("inter_font")`. The raw `zig test` invocation has no such import
and fails.
- **Never run `zig build run-*` in a headless/agent context** — it opens an SDL
window and blocks in `SDL_WaitEvent`. Use `zig build hello settings` to verify
the backend compiles/links.
window and blocks in `SDL_WaitEvent`. Use `zig build showcase edit` to verify
the backend compiles/links, or the `--screenshot` flag to render one frame.
- Tests are **inline** (`test "..." {}` blocks). A module's tests only run if the
root (`src/zigui.zig`) imports it — add a `pub const x = @import(...)` there.
- **Zig 0.16 gotchas hit during the build** (so you don't rediscover them):
Expand Down Expand Up @@ -82,15 +88,18 @@ app.zig (SDL3): build → render → upload framebuffer to texture → present

| File | Responsibility |
|---|---|
| `src/view/view.zig` | **The hub.** `View`, `Kind` union, constructors, `Modifiers`, `buildNode`/`measure`/`render`/`paint`/`paintContent`, `HitAction`/`dispatchTap`, focus. Most features touch this. |
| `src/view/view.zig` | **The engine + facade.** `View`, `Kind` union, `Modifiers`, `Context`, `buildNode`/`buildContentNode`/`measure`/`render`/`paint`/`paintContent`, `HitAction`/`dispatchTap`, focus, overlays, the text context-menu, and the primitive components (text/shape/button/toggle/slider/stepper/progress/picker/textfield/editor/icon/scroll). Re-exports the `components/` modules so historical `view.X` names stay stable. |
| `src/components/*.zig` | Cohesive, separable components split out of the hub: `navigation`, `tabs`, `menu`, `grid`, `list`, `collections` (Sidebar/Table/RadioGroup), and `text_buffer` (`TextFieldState` + pure UTF-8 line/column geometry). Each imports `view.zig` for the shared primitives/helpers; `view.zig` re-exports their public names. |
| `src/layout/engine.zig` | `Node` union, `Proposal`, `SizingHints`, `measure`, `arrange`→`LayoutResult`. Pure, tested. |
| `src/layout/stack.zig` | `distribute()` — the stack space-allocation math. Pure. |
| `src/render/canvas.zig` | `DrawCommand` union + `Canvas` builder. The renderer-agnostic seam. |
| `src/render/raster.zig` | `Framebuffer` + software rasterizer (SDF AA). Where new draw primitives get pixels. |
| `src/text/*` | `ttf` (parser+rasterizer), `atlas` (`GlyphCache`), `shape` (measure/wrap), `font` (`drawText`). |
| `src/theme/*` | `Theme` tokens; `macos.light`/`macos.dark`. |
| `src/theme/theme.zig` | `Theme`, `Palette`, `Metrics`, `Typography`, and the **`Painter`** vtable + `Surface`/`ControlState`/`Role`. The color-scheme/painter vocabulary. |
| `src/theme/{macos,win2000,windows10,kde}.zig` | The four built-in theme families: each exports `light`/`dark` `Theme` values and a `Painter` impl drawing that family's chrome (glass / bevels / flat / Breeze). |
| `src/theme/registry.zig` | `Family` enum + `forScheme(family, scheme)` — picks a `Theme` for the OS appearance (light-only families ignore dark). |
| `src/state/*` | `State(T)`, `Binding(T)`, `Observer`. |
| `src/app.zig` | SDL3 window/event loop. **Only file that links C.** Where overlays, animation ticking, HiDPI scale, and key routing get wired. |
| `src/app.zig` | SDL3 window/event loop. **Only file that links C.** Where overlays, animation ticking, HiDPI scale, key routing, and `systemTheme()`/`colorScheme()` (OS dark/light) get wired. |
| `src/components.zig`, `src/zigui.zig` | Public re-exports. |

### Key invariants
Expand Down Expand Up @@ -144,19 +153,103 @@ exercises nav + tabs + sheet + material + a11y together; `examples/showcase`
demos them in a real window. Notes below record *how* each is wired and the
deliberate deviations from the original plan, so you can extend safely.

### Icons — `Icon` / `IconButton` (bundled icon font, reuses the glyph path)
A second embedded font (`assets/fonts/icons.ttf`, a ~50-glyph subset of **Lucide**,
ISC) is wired as the `icon_font` anonymous import alongside Inter, exposed as
`Font.icons()` and `ttf.icon_ttf`. The catalog is `src/icons.zig`: `Icon` (re-
exported as `zigui.IconName`) is an `enum(u21)` whose **value is each glyph's PUA
codepoint**, so rendering is just `glyphIndex(codepoint)` through the ordinary text
path — icons are tintable and HiDPI-crisp for free, with **no new `DrawCommand`**.
`font.drawIcon` rasterizes the glyph centered in a square box (same device-res
coverage trick as `drawTextScaled`). `Context` gained a defaulted-null
`icon_cache: ?*GlyphCache` (the app wires it next to `cache`; `TestEnv` sets it;
when null, icons paint nothing — the safe fast path). View constructors:
`Icon(.heart, 18, color_or_null)` (a `size`×`size` leaf; `null` color inherits
`.foreground`) and `IconButton(.trash, 18, callback)` (a padded square tap target
reusing `.callback`). Call sites rely on enum-literal inference, so the `IconName`
type name is rarely spelled out. Subset/regenerate via `pyftsubset` + the codepoints
in `icons.zig`; attribution in `assets/fonts/NOTICE.md`. (Tests: `view: Icon …`,
`view: IconButton …`; `drawIcon: …` in `font.zig`.)

### Grids — `LazyVGrid` / `LazyHGrid` (composition, no new primitive)
`LazyVGrid(columns, spacing, items, mapFn)` maps `items`→cells, chunks them into
rows, and returns a `VStack` of `HStack`s; `LazyHGrid` is the transpose. Cells get
`.frameMaxWidth()`/`.frameMaxHeight()` for even tracks, and a short final row is
padded with invisible `Empty()` cells so **columns stay aligned**. No `Kind`-level
grid was needed. (`view.zig`; tests `LazyVGrid …`/`LazyHGrid …`.)

### Themes & painters — `Palette` + `Painter` seam (macOS / Win2000 / Win10 / KDE)
A `Theme` is **palette + metrics + typography + `Painter`**. The palette
(`theme.Palette`, the renamed `Colors`) holds the semantic color roles resolved
for one `ColorScheme`; it gained translucent "liquid glass" roles
(`hover`/`control_border`/`glass`/`control_track`) and **defaulted** chiseled-bevel
roles (`control_face`/`control_highlight`/`control_light`/`control_shadow`/
`control_dark_shadow`/`on_control`) that only Win2000 sets.

The **look** is owned by a per-theme **`Painter`** — a small vtable
(`button`/`field`/`segmentedTrack`/`segmentedSelection`/`switchTrack`) that draws
only *chrome* into a `theme.Surface` (canvas + palette + metrics + scheme +
opacity, with `fill`/`stroke`/`vGradient`/`lineSeg` helpers). **Painters depend
only on the renderer + tokens, never on the view layer** — so the seam has no
dependency cycle: the view layer keeps text/layout/hit-regions and calls
`ctx.theme.painter.*` (via `ctx.surface(canvas)`) for decoration; `painter.button`
even *returns* the label color so a theme controls both at once. To add a glassy
vs. bevelled vs. flat control, implement the painter method per family — don't
hard-code a look in `view.zig`.

The four families live in `src/theme/{macos,win2000,windows10,kde}.zig`:
macОС = vertical gradient sheen + white rim (the original look, reproduced
exactly so the pixel tests still pass); Win2000 = 2-ring raised/sunken bevels +
silver `control_face`, **light-only**; Windows 10 = flat fills + 1px borders;
KDE/Breeze = subtle gradients + thin borders + 3px corners.
`theme/registry.zig` exposes `Family` + `forScheme(family, scheme)` (light-only
families ignore a dark request). `app.systemTheme()`/`app.colorScheme()` read the
OS preference (SDL3) so an app/theme-provider follows dark/light live; the
`showcase` footer has a theme-family **`RadioGroup`** and seeds dark mode from the
OS on the first frame. Headless: `showcase --screenshot <out.bmp> --theme N
[--dark]`.

### Build-time theme tokens — `setThemeTokens` / `BuildTokens` (for composed controls)
Composed constructors have **no `Context`** (the long-standing reason
`NavigationSplitView` takes a `sidebar_fill: Color`), but selection-driven ones
need the accent/hover tints at *build* time. So `view.zig` keeps a thread-local
`BuildTokens` (accent, on_accent, hover, row_stripe) that the app publishes once
per frame via `setThemeTokens(theme)` — wired in `app.zig` right before
`beginBuild`. Defaults match the macOS **light** theme, so headless tests and
un-wired callers render correctly with no setup. `selectAction(binding, value)`
is the matching reusable `Callback` (a build-arena `{binding,value}` + thunk, like
`NavPushCtx`) that lets selection be pure composition over `onTap` — no new
`HitAction`. Sidebar/Table/RadioGroup all use it.

### Sidebar / Table / RadioGroup — new macOS components (all pure composition)
- **`Sidebar(items: []const SidebarItem, selection: Binding(i64))`** — a source-
list of rows (`SidebarItem{label, icon: ?IconName}`) with a rounded accent
selection highlight, leading icon, and `hoverFill`. Each row is an `HStack`
`.onTap(selectAction(...))`. **Gotcha:** the row's children must be allocated in
`buildAlloc()` — `makeStackFromSlice` keeps the slice, so a stack-local array
dangles after the constructor returns.
- **`Table(columns: []const TableColumn, rows: []const []const []const u8,
selection: ?Binding(i64))`** — a header `HStack` over a `ScrollView` of row
`HStack`s, with zebra striping (`build_tokens.row_stripe`) and an accent
selected row. `TableColumn{title, width: ?f32}` (null width = flexible). Cells
are left-aligned via `tableCell` (`HStack(.{content, Spacer()})`). Wrap in a
fixed frame for the scrollable look.
- **`RadioGroup(selection: Binding(i64), options: []const []const u8)`** — a
vertical list of rows; the dot is `radioIndicator` (layered `Circle`s: accent
disc + white center when selected, hollow ring otherwise).

`examples/showcase` demos every component (sidebar shell + per-category pages)
and has a headless `--screenshot <out.bmp> [section]` path (libc BMP writer, wires
the icon cache + `setThemeTokens`) for visual iteration without a window.

### TabView — `Tab` + `TabView` (composition, reuses `.select`)
`TabView(selection: Binding(i64), tabs: []const Tab)` →
`VStack(.{ tabs[sel].content.frameMaxWidth(), Divider(), bar })`. The tab **bar is
a `Picker`** over the tab labels — that reuses `Picker`'s `.select` `HitAction` and
its selected-segment styling for free, so no new interaction was added. The body
switches on the binding; rebuilt each frame.
`TabView(selection: Binding(i64), tabs: []const Tab)` → a **centered glass
segmented bar on top** (macOS style), then `Divider()`, then the selected tab's
content filling the rest: `VStack(.{ bar, Divider(), content.frameMaxWidth()
.frameMaxHeight() }).spacing(0)`, where `bar = HStack(.{ Spacer(), Picker(...),
Spacer() })`. The tab **bar is a `Picker`** over the tab labels — that reuses
`Picker`'s `.select` `HitAction` and its selected-segment styling for free, so no
new interaction was added. The body switches on the binding; rebuilt each frame.

### Navigation — `NavigationSplitView` + `NavState`/`NavigationLink`/`NavBackButton`
- **Split view:** `NavigationSplitView(sidebar, detail, sidebar_fill: Color)` =
Expand Down
60 changes: 48 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,22 @@ native macOS / SwiftUI look and feel to macOS, Linux, and Windows.

## Screenshots

| Settings demo | Showcase (nav · tabs · sheet · material) | Streaming LLM chat |
The `showcase` example, rendered in each built-in theme family:

| macOS (Liquid Glass, light) | macOS (Liquid Glass, dark) | Windows 10 (dark) |
|---|---|---|
| ![macOS light](docs/macos-light.png) | ![macOS dark](docs/macos-dark.png) | ![Windows 10](docs/windows10.png) |

| Windows 2000 | KDE Plasma (Breeze) | Material |
|---|---|---|
| ![Settings](docs/settings.png) | ![Showcase](docs/showcase.png) | ![LLM chat](docs/llm-chat.png) |
| ![Windows 2000](docs/win2000.png) | ![KDE Plasma](docs/kde.png) | ![Material](docs/material.png) |

And the `edit` example — a multi-line text editor:

All three are the **same pure-Zig renderer** — no native widgets.
![Text editor](docs/edit.png)

Every frame is the **same pure-Zig renderer** — no native widgets, themes are
just palettes + painters.

## Why

Expand Down Expand Up @@ -53,6 +64,32 @@ Modifiers chain fluently: `.padding()` `.frame()` `.background()`
`.foreground()` `.font()` `.cornerRadius()` `.border()` `.opacity()` `.onTap()`
`.disabled()` `.frameMaxWidth()` …

## Themes

A `Theme` bundles a color `Palette`, a type scale, layout `Metrics`, and a
`Painter` (the vtable that draws every control's chrome). Five families ship —
**macOS** (Liquid Glass), **Windows 10** (flat/Fluent-lite), **Windows 2000**
(chiseled bevels), **KDE Plasma** (Breeze), and **Material** (Google Material
Design) — each as a light and dark theme. Pick one with
`themeForScheme(family, scheme)`, and follow the OS appearance by recomputing it
from `app.colorScheme()` inside a theme provider:

```zig
// Pick a family and resolve it for a color scheme.
const theme = zigui.themeForScheme(.macos, .dark); // ThemeFamily: .macos/.windows10/.win2000/.kde/.mui

// Follow the OS dark/light appearance live (SDL3 backend):
fn themeProvider() zigui.Theme {
return zigui.themeForScheme(.macos, app.colorScheme()); // .light or .dark
}
// ...
app.setThemeProvider(themeProvider); // re-resolved each frame before the view builds
```

To author your own look, implement the `Painter` methods (`button`, `field`,
`slider`, `switchTrack`/`switchKnob`, `stepperBox`, `progress`, `segmented*`,
`panel`) — they draw only chrome into a `Surface`, never touching the view layer.

## Architecture at a glance

| Layer | Choice |
Expand All @@ -61,7 +98,7 @@ Modifiers chain fluently: `.padding()` `.frame()` `.background()`
| State | observable `State(T)` + `Binding(T)` + dirty flag |
| Layout | two-pass measure/arrange engine (SwiftUI-style proposals) |
| Text | bundled Inter (OFL) + pure-Zig TrueType rasterizer + glyph cache |
| Theme | macOS light/dark, fully tokenized |
| Theme | five families (macOS, Windows 10, Windows 2000, KDE Plasma, Material), light/dark, fully tokenized |
| 2D drawing | retained `Canvas` command list |
| Renderer (tests / headless) | **pure-Zig software rasterizer** (SDF anti-aliasing) |
| Renderer (on-screen) | software rasterizer presented via **SDL3** |
Expand Down Expand Up @@ -110,18 +147,17 @@ Run the demo apps (require SDL3 — `brew install sdl3` on macOS,
`apt install libsdl3-dev` on Linux):

```sh
zig build run-hello # minimal counter
zig build run-settings # macOS-like Settings demo
zig build run-showcase # nav · tabs · sheet · material · a11y
zig build run-llm-chat # streaming chat over an OpenAI-compatible API
zig build run-showcase # the kitchen-sink gallery: every component,
# light/dark + accent switchers (macOS 26 look)
zig build run-edit # a multi-line text editor (TextEdit/gedit-like)
zig build hello settings showcase llm-chat edit # build the examples without running them
zig build showcase edit # build the examples without running them

# Render one frame to a BMP without a window (great for screenshots / CI):
./zig-out/bin/showcase --screenshot out.bmp [section] [--dark] [--accent N]
```

> Run with `-Doptimize=ReleaseFast` for smooth UI — the CPU software rasterizer
> is much slower in the default Debug build. The `llm-chat` demo talks to a local
> OpenAI-compatible server (e.g. `mlx-serve --serve --model <model> --port 11234`);
> see [`examples/llm-chat`](examples/llm-chat).
> is much slower in the default Debug build.

### Validate on Linux via Docker

Expand Down
Loading
Loading