diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0b041e..46ead6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: @@ -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 @@ -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: diff --git a/CLAUDE.md b/CLAUDE.md index 880b3b0..2be2eda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 # one-shot -./zig-out/bin/llm-chat --smoke "count to 5" --model --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): @@ -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 @@ -144,6 +153,24 @@ 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 @@ -151,12 +178,78 @@ rows, and returns a `VStack` of `HStack`s; `LazyHGrid` is the transpose. Cells g 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 --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 [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)` = diff --git a/README.md b/README.md index 5fd1f0c..daa27ff 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 | @@ -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** | @@ -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 --port 11234`); -> see [`examples/llm-chat`](examples/llm-chat). +> is much slower in the default Debug build. ### Validate on Linux via Docker diff --git a/assets/fonts/LICENSE-lucide b/assets/fonts/LICENSE-lucide new file mode 100644 index 0000000..718bb3f --- /dev/null +++ b/assets/fonts/LICENSE-lucide @@ -0,0 +1,43 @@ +ISC License + +Copyright (c) 2026 Lucide Icons and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--- + +The following Lucide icons are derived from the Feather project: + +airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out + +The MIT License (MIT) (for the icons listed above) + +Copyright (c) 2013-present Cole Bemis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/assets/fonts/NOTICE.md b/assets/fonts/NOTICE.md index 335ae0d..5accf3a 100644 --- a/assets/fonts/NOTICE.md +++ b/assets/fonts/NOTICE.md @@ -12,5 +12,34 @@ under the **SIL Open Font License, Version 1.1**. The file shipped here is the variable-font build (`InterVariable.ttf`), renamed to `Inter.ttf`. zigui reads the default-master outlines from its `glyf` table. +## Noto Emoji (`NotoEmoji.ttf`) + +`NotoEmoji.ttf` is the **monochrome** build of Google's **Noto Emoji** font, +redistributed under the **SIL Open Font License, Version 1.1** (same OFL text as +Inter, [`OFL.txt`](OFL.txt)). + +- Project: https://github.com/googlefonts/noto-emoji +- License: SIL Open Font License 1.1 (OFL) + +It is the variable-weight build (`NotoEmoji[wght].ttf`), renamed to +`NotoEmoji.ttf`; zigui reads the default-master `glyf` outlines. It is wired as a +**fallback face**: codepoints absent from Inter (emoji, and any other glyph it +lacks) resolve here and render through the same outline → coverage-mask path, so +emoji are monochrome and tint with the surrounding text color. + +## Lucide icons (`icons.ttf`) + +`icons.ttf` is a **subset** of the **Lucide** icon font (the `lucide-static` +build), redistributed under the **ISC License**. + +- Project: https://lucide.dev +- License: ISC — full text bundled alongside this file as + [`LICENSE-lucide`](LICENSE-lucide) + +It contains only the ~64 glyphs that `src/icons.zig` exposes, mapped to their +original Lucide Private-Use-Area codepoints. zigui renders them through the same +`glyf` outline → coverage-mask path it uses for text, so icons are tintable and +HiDPI-crisp. + This is a redistributable open font; no Apple assets (SF Pro, SF Symbols, etc.) are bundled. See the project root `LICENSE` for zigui's own MIT license. diff --git a/assets/fonts/NotoEmoji.ttf b/assets/fonts/NotoEmoji.ttf new file mode 100644 index 0000000..c2c26ab Binary files /dev/null and b/assets/fonts/NotoEmoji.ttf differ diff --git a/assets/fonts/icons-rows.json b/assets/fonts/icons-rows.json new file mode 100644 index 0000000..1ee2aae --- /dev/null +++ b/assets/fonts/icons-rows.json @@ -0,0 +1 @@ +[["close", "x", 57778], ["check", "check", 57452], ["chevron_left", "chevron-left", 57454], ["chevron_right", "chevron-right", 57455], ["chevron_up", "chevron-up", 57456], ["chevron_down", "chevron-down", 57453], ["arrow_left", "arrow-left", 57416], ["arrow_right", "arrow-right", 57417], ["arrow_up", "arrow-up", 57418], ["arrow_down", "arrow-down", 57410], ["plus", "plus", 57661], ["minus", "minus", 57628], ["trash", "trash-2", 57742], ["search", "search", 57681], ["settings", "settings", 57684], ["menu", "menu", 57621], ["more_horizontal", "ellipsis", 57526], ["more_vertical", "ellipsis-vertical", 57527], ["edit", "pencil", 57849], ["copy", "copy", 57502], ["share", "share-2", 57686], ["download", "download", 57522], ["upload", "upload", 57758], ["send", "send", 57682], ["heart", "heart", 57586], ["star", "star", 57718], ["bookmark", "bookmark", 57440], ["user", "user", 57759], ["users", "users", 57764], ["file", "file", 57536], ["folder", "folder", 57559], ["image", "image", 57590], ["home", "house", 57589], ["bell", "bell", 57433], ["mail", "mail", 57615], ["calendar", "calendar", 57443], ["clock", "clock", 57479], ["info", "info", 57593], ["alert", "circle-alert", 57463], ["help", "circle-help", 57474], ["eye", "eye", 57530], ["eye_off", "eye-off", 57531], ["lock", "lock", 57611], ["unlock", "lock-open", 57612], ["refresh", "refresh-cw", 57669], ["play", "play", 57660], ["pause", "pause", 57646], ["sun", "sun", 57720], ["moon", "moon", 57630], ["message_circle", "message-circle", 57622], ["audio_lines", "audio-lines", 58714], ["film", "film", 57552], ["boxes", "boxes", 58064], ["scroll_text", "scroll-text", 58463], ["list", "list", 57606], ["sparkles", "sparkles", 58386], ["wand", "wand-2", 58199], ["loader", "loader", 57609], ["badge_check", "badge-check", 57921], ["shield_check", "shield-check", 57855], ["cpu", "cpu", 57513], ["hard_drive", "hard-drive", 57581], ["zap", "zap", 57780], ["volume", "volume-2", 57771]] \ No newline at end of file diff --git a/assets/fonts/icons.ttf b/assets/fonts/icons.ttf new file mode 100644 index 0000000..aec336a Binary files /dev/null and b/assets/fonts/icons.ttf differ diff --git a/build.zig b/build.zig index 5972b32..b45e795 100644 --- a/build.zig +++ b/build.zig @@ -1,4 +1,8 @@ const std = @import("std"); +// The package manifest is the single source of truth for the version. Read it +// here and inject it into the module (below) as `build_options.version`, so +// `zigui.version` always matches `build.zig.zon` and the `zig fetch` tag. +const manifest = @import("build.zig.zon"); pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); @@ -12,11 +16,26 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = optimize, }); + // Inject the manifest version so `zigui.version` derives from build.zig.zon. + const options = b.addOptions(); + options.addOption([]const u8, "version", manifest.version); + mod.addImport("build_options", options.createModule()); // Bundle the default font (Inter, OFL) as an embeddable blob, importable as // `@embedFile("inter_font")` from anywhere in the module. mod.addAnonymousImport("inter_font", .{ .root_source_file = b.path("assets/fonts/Inter.ttf"), }); + // Bundle the icon font (a subset of Lucide, ISC) the same way, importable as + // `@embedFile("icon_font")`. See `src/icons.zig` and `assets/fonts/NOTICE.md`. + mod.addAnonymousImport("icon_font", .{ + .root_source_file = b.path("assets/fonts/icons.ttf"), + }); + // Bundle the monochrome emoji fallback font (Noto Emoji, OFL), importable as + // `@embedFile("emoji_font")`. Wired as a fallback face for glyphs Inter + // lacks; see `assets/fonts/NOTICE.md`. + mod.addAnonymousImport("emoji_font", .{ + .root_source_file = b.path("assets/fonts/NotoEmoji.ttf"), + }); // ---- tests ------------------------------------------------------------ const lib_tests = b.addTest(.{ .root_module = mod }); @@ -36,33 +55,11 @@ pub fn build(b: *std.Build) void { docs_step.dependOn(&install_docs.step); // ---- examples (link SDL3; not part of `zig build test`) --------------- - addExample(b, mod, target, optimize, "hello", "examples/hello/main.zig"); - addExample(b, mod, target, optimize, "settings", "examples/settings/main.zig"); + // `showcase` is the kitchen-sink gallery (every component + light/dark and + // accent switchers); `edit` is the multi-line text editor. Both support a + // headless `--screenshot ` flag for windowless rendering. addExample(b, mod, target, optimize, "showcase", "examples/showcase/main.zig"); - addExample(b, mod, target, optimize, "llm-chat", "examples/llm-chat/main.zig"); addExample(b, mod, target, optimize, "edit", "examples/edit/main.zig"); - - // A headless screenshot tool: renders a UI to a BMP using *only* the pure - // `zigui` module + libc (no SDL), so CI can produce a per-OS screenshot on - // every platform. Not an `addExample` (those link SDL3). - const screenshot = b.addExecutable(.{ - .name = "screenshot", - .root_module = b.createModule(.{ - .root_source_file = b.path("examples/screenshot/main.zig"), - .target = target, - .optimize = optimize, - .link_libc = true, // for the libc BMP writer (std.fs needs std.Io in 0.16) - .imports = &.{.{ .name = "zigui", .module = mod }}, - }), - }); - const screenshot_install = b.addInstallArtifact(screenshot, .{}); - b.step("screenshot", "Build the headless screenshot tool") - .dependOn(&screenshot_install.step); - const screenshot_run = b.addRunArtifact(screenshot); - screenshot_run.step.dependOn(&screenshot_install.step); - if (b.args) |args| screenshot_run.addArgs(args); - b.step("run-screenshot", "Render a UI screenshot to a BMP (pass the path: -- out.bmp)") - .dependOn(&screenshot_run.step); } /// Build a runnable example that links the SDL3-backed runtime (`src/app.zig`). diff --git a/docs/edit.png b/docs/edit.png new file mode 100644 index 0000000..99ed19f Binary files /dev/null and b/docs/edit.png differ diff --git a/docs/kde.png b/docs/kde.png new file mode 100644 index 0000000..fddfa5f Binary files /dev/null and b/docs/kde.png differ diff --git a/docs/llm-chat.png b/docs/llm-chat.png deleted file mode 100644 index 03d49fb..0000000 Binary files a/docs/llm-chat.png and /dev/null differ diff --git a/docs/macos-dark.png b/docs/macos-dark.png new file mode 100644 index 0000000..d63db42 Binary files /dev/null and b/docs/macos-dark.png differ diff --git a/docs/macos-light.png b/docs/macos-light.png new file mode 100644 index 0000000..2fe7440 Binary files /dev/null and b/docs/macos-light.png differ diff --git a/docs/material.png b/docs/material.png new file mode 100644 index 0000000..79c0880 Binary files /dev/null and b/docs/material.png differ diff --git a/docs/settings.png b/docs/settings.png deleted file mode 100644 index b68dd7f..0000000 Binary files a/docs/settings.png and /dev/null differ diff --git a/docs/showcase.png b/docs/showcase.png deleted file mode 100644 index 36b288f..0000000 Binary files a/docs/showcase.png and /dev/null differ diff --git a/docs/win2000.png b/docs/win2000.png new file mode 100644 index 0000000..42e91cc Binary files /dev/null and b/docs/win2000.png differ diff --git a/docs/windows10.png b/docs/windows10.png new file mode 100644 index 0000000..f7cb150 Binary files /dev/null and b/docs/windows10.png differ diff --git a/examples/edit/main.zig b/examples/edit/main.zig index 6c39c95..c09d5d1 100644 --- a/examples/edit/main.zig +++ b/examples/edit/main.zig @@ -28,7 +28,27 @@ const cstdio = @cImport({ @cInclude("stdio.h"); }); -const t = zigui.default_theme; +/// The live theme, recomputed by `themeProvider` from the app state each frame +/// (so every view built this frame sees the same scheme). `g_app` lets the +/// provider read the appearance selection. +var g_theme = zigui.default_theme; + +fn themeProvider() zigui.Theme { + const st = g_app orelse return g_theme; + // On the first frame (now that SDL is up), seed the appearance from the OS. + if (!st.os_synced) { + st.dark.set(app.systemTheme() == .dark); + st.os_synced = true; + } + const scheme: zigui.ColorScheme = if (st.dark.get()) .dark else .light; + g_theme = zigui.themeForScheme(.macos, scheme); + return g_theme; +} + +/// Shorthand for the live theme inside view builders / the screenshot path. +inline fn t() zigui.Theme { + return g_theme; +} // --- application state ------------------------------------------------------- @@ -46,6 +66,12 @@ const AppState = struct { line_numbers: zigui.State(bool), zoom: zigui.State(i64), // index into the font-size ladder; 3 == body + /// Dark appearance, seeded from the OS preference on the first frame. + dark: zigui.State(bool), + /// Set once the appearance has been seeded from the OS (kept off in headless + /// screenshots so they stay deterministic). + os_synced: bool = false, + // A single path-entry dialog, reused for Open and Save As (only one overlay // can be presented at a time, so `dialog_save` picks which it is). show_dialog: zigui.State(bool), @@ -326,7 +352,7 @@ fn toolbar(st: *AppState) zigui.View { fn findBar(st: *AppState) zigui.View { return zigui.HStack(.{ - zigui.Text("Find").font(.subheadline).foreground(t.colors.secondary_label), + zigui.Text("Find").font(.subheadline).foreground(t().colors.secondary_label), zigui.TextField("Search…", &st.find_field) .frameMaxWidth() .onSubmit(zigui.actionCtx(AppState, st, findNext)), @@ -335,7 +361,7 @@ fn findBar(st: *AppState) zigui.View { zigui.components.ButtonRoled("Done", .plain, zigui.actionCtx(AppState, st, toggleFind)), }).spacing(10) .paddingInsets(.{ .top = 7, .leading = 14, .bottom = 7, .trailing = 14 }) - .background(t.colors.secondary_background) + .background(t().colors.secondary_background) .frameMaxWidth(); } @@ -346,9 +372,9 @@ fn statusBar(st: *AppState) zigui.View { const lines = std.mem.count(u8, text, "\n") + 1; const meta = zigui.components.fmt("Ln {d}, Col {d} {d} chars {d} lines", .{ lc.line, lc.col, chars, lines }); return zigui.HStack(.{ - zigui.Text(meta).font(.footnote).foreground(t.colors.secondary_label), + zigui.Text(meta).font(.footnote).foreground(t().colors.secondary_label), zigui.Spacer(), - zigui.Text(st.status()).font(.footnote).foreground(t.colors.tertiary_label), + zigui.Text(st.status()).font(.footnote).foreground(t().colors.tertiary_label), }).spacing(14) .paddingInsets(.{ .top = 6, .leading = 14, .bottom = 6, .trailing = 14 }) .frameMaxWidth(); @@ -358,7 +384,7 @@ fn dialogView(st: *AppState) zigui.View { const onConfirm = zigui.actionCtx(AppState, st, confirmDialog); return zigui.VStack(.{ leading(zigui.Text(if (st.dialog_save) "Save As" else "Open").font(.title3)), - leading(zigui.Text("File path:").font(.subheadline).foreground(t.colors.secondary_label)), + leading(zigui.Text("File path:").font(.subheadline).foreground(t().colors.secondary_label)), zigui.TextField("/path/to/file.txt", &st.path_field) .frameMaxWidth() .onSubmit(onConfirm), @@ -444,9 +470,9 @@ fn renderScreenshot(gpa: std.mem.Allocator, st: *AppState, path: [:0]const u8, d zigui.beginBuild(a); const root = body(s); zigui.endBuild(); - var ctx = zigui.Context.initFull(t, ce, a, hh, oo, null); + var ctx = zigui.Context.initFull(t(), ce, a, hh, oo, null); ctx.scroll_regions = ss; - try ca.fillRect(rect, t.colors.window_background); + try ca.fillRect(rect, t().colors.window_background); try zigui.render(&ctx, root, rect, ca); } }.run; @@ -456,7 +482,7 @@ fn renderScreenshot(gpa: std.mem.Allocator, st: *AppState, path: [:0]const u8, d // Simulate a real mouse drag through the collected hit regions, then re-render // so the screenshot shows what the dispatched selection actually produces. if (drag) { - const lh = zigui.shape.lineHeight(&font.face, t.typography.body.size); + const lh = zigui.shape.lineHeight(&font.face, t().typography.body.size); _ = zigui.dispatchTap(hits.items, .{ .x = 70, .y = 8 + 4 * lh + 2 }); // press on line 5 zigui.dispatchDrag(hits.items, .{ .x = 250, .y = 8 + 7 * lh + 2 }); // drag to line 8 zigui.endDrag(); @@ -465,7 +491,7 @@ fn renderScreenshot(gpa: std.mem.Allocator, st: *AppState, path: [:0]const u8, d var fb = try zigui.Framebuffer.init(gpa, w, h); defer fb.deinit(); - fb.clear(t.colors.window_background); + fb.clear(t().colors.window_background); try zigui.raster.render(gpa, &fb, canvas.commands.items); writeBmp(path, &fb); } @@ -518,6 +544,7 @@ pub fn main(init: std.process.Init.Minimal) !void { .doc = zigui.TextFieldState.init(gpa), .line_numbers = zigui.State(bool).init(gpa, true), .zoom = zigui.State(i64).init(gpa, 3), + .dark = zigui.State(bool).init(gpa, false), .show_dialog = zigui.State(bool).init(gpa, false), .path_field = zigui.TextFieldState.init(gpa), .show_find = zigui.State(bool).init(gpa, false), @@ -528,6 +555,7 @@ pub fn main(init: std.process.Init.Minimal) !void { st.path.deinit(gpa); st.line_numbers.deinit(); st.zoom.deinit(); + st.dark.deinit(); st.show_dialog.deinit(); st.path_field.deinit(); st.show_find.deinit(); @@ -535,6 +563,7 @@ pub fn main(init: std.process.Init.Minimal) !void { } st.doc.multiline = true; st.setStatus("Ready", .{}); + g_app = &st; // Args: an optional file to open, and a headless --screenshot . var open_path: ?[]const u8 = null; @@ -543,6 +572,7 @@ pub fn main(init: std.process.Init.Minimal) !void { var demo_dialog = false; var demo_select = false; var demo_drag = false; + var force_dark = false; var arg_it = try std.process.Args.iterateAllocator(init.args, gpa); defer arg_it.deinit(); _ = arg_it.next(); // argv[0] @@ -557,6 +587,8 @@ pub fn main(init: std.process.Init.Minimal) !void { demo_select = true; } else if (std.mem.eql(u8, a, "--demo-drag")) { demo_drag = true; + } else if (std.mem.eql(u8, a, "--dark")) { + force_dark = true; } else if (!std.mem.startsWith(u8, a, "-")) { open_path = a; } @@ -565,6 +597,10 @@ pub fn main(init: std.process.Init.Minimal) !void { if (open_path) |p| loadPath(&st, p); if (screenshot_path) |path| { + // Deterministic theme: honor --dark, never probe the OS when headless. + st.dark.set(force_dark); + st.os_synced = true; + _ = themeProvider(); if (st.doc.text().len == 0) { st.doc.setText(sample_doc) catch {}; st.doc.caret = 0; @@ -590,8 +626,8 @@ pub fn main(init: std.process.Init.Minimal) !void { return; } - g_app = &st; app.setKeyHandler(&keyHandler); + app.setThemeProvider(themeProvider); // follow the OS dark/light appearance zigui.setFocus(&st.doc); // ready to type immediately try app.run(gpa, AppState, &st, .{ diff --git a/examples/hello/main.zig b/examples/hello/main.zig deleted file mode 100644 index c9acb51..0000000 --- a/examples/hello/main.zig +++ /dev/null @@ -1,30 +0,0 @@ -//! The smallest zigui app: a styled greeting and a working counter button. -//! Run with `zig build hello`. - -const std = @import("std"); -const zigui = @import("zigui"); -const app = @import("zigui_app"); - -const AppState = struct { - count: zigui.State(i32), - - fn inc(self: *AppState) void { - self.count.set(self.count.get() + 1); - } -}; - -fn body(st: *AppState) zigui.View { - return zigui.VStack(.{ - zigui.Text("Hello, zigui!").font(.large_title), - zigui.Text(zigui.components.fmt("You clicked {d} time(s)", .{st.count.get()})) - .foreground(zigui.default_theme.colors.secondary_label), - zigui.Button("Increment", zigui.actionCtx(AppState, st, AppState.inc)), - }).spacing(16).padding(40); -} - -pub fn main() !void { - const alloc = std.heap.page_allocator; - var st = AppState{ .count = zigui.State(i32).init(alloc, 0) }; - defer st.count.deinit(); - try app.run(alloc, AppState, &st, .{ .title = "zigui — hello", .width = 480, .height = 320 }, body); -} diff --git a/examples/llm-chat/chat_client.zig b/examples/llm-chat/chat_client.zig deleted file mode 100644 index 8cd59dd..0000000 --- a/examples/llm-chat/chat_client.zig +++ /dev/null @@ -1,549 +0,0 @@ -//! A single-threaded, non-blocking HTTP/SSE client for an OpenAI-compatible -//! `/v1/chat/completions` endpoint (e.g. mlx-serve at http://127.0.0.1:11234). -//! -//! There are no threads and no mutex: the socket is set non-blocking and the -//! GUI event loop calls `poll()` once per frame while a request is in flight, so -//! every `State`/buffer mutation stays on the UI thread. `poll()` reads whatever -//! bytes are available, de-chunks the HTTP body if needed, and parses complete -//! `\n`-terminated SSE `data:` lines, appending each delta to the current -//! assistant message buffer (which marks the UI dirty and triggers a redraw). -//! -//! Networking uses libc sockets directly (Zig 0.16 moved the socket primitives -//! out of `std.posix` into the new `std.Io` model); the example links libc, so a -//! `@cImport` of the POSIX headers — the same approach the SDL backend uses — is -//! the simplest portable path. - -const std = @import("std"); -const Allocator = std.mem.Allocator; - -const c = @cImport({ - @cInclude("sys/types.h"); - @cInclude("sys/socket.h"); - @cInclude("sys/time.h"); - @cInclude("netinet/in.h"); - @cInclude("netdb.h"); - @cInclude("unistd.h"); - @cInclude("fcntl.h"); - @cInclude("errno.h"); -}); - -/// Wall-clock milliseconds (std.time lost `milliTimestamp` in Zig 0.16; libc's -/// `gettimeofday` is the simplest replacement for the tok/s timing). -fn nowMs() i64 { - var tv: c.timeval = undefined; - _ = c.gettimeofday(&tv, null); - return @as(i64, tv.tv_sec) * 1000 + @divTrunc(@as(i64, tv.tv_usec), 1000); -} - -/// Sleep for `ms` milliseconds (std.time lost `sleep` in Zig 0.16). Used by the -/// headless `--stream` smoke loop to avoid a hot spin between polls. -pub fn sleepMs(ms: u32) void { - _ = c.usleep(@as(c.useconds_t, ms) * 1000); -} - -const stdc = std.c; - -/// Token accounting reported in the final `usage` object. -pub const Usage = struct { prompt_tokens: u64 = 0, completion_tokens: u64 = 0 }; - -/// One message in the OpenAI request `messages` array. -pub const WireMessage = struct { role: []const u8, content: []const u8 }; - -/// Generation parameters for a request. -pub const Params = struct { - model: []const u8, - temperature: f32 = 0.7, - max_tokens: u32 = 1024, -}; - -// --- JSON shapes (decode side; unknown fields ignored) ---------------------- - -const Delta = struct { - content: ?[]const u8 = null, - reasoning_content: ?[]const u8 = null, -}; -const StreamChoice = struct { - delta: Delta = .{}, - finish_reason: ?[]const u8 = null, -}; -const UsageWire = struct { - prompt_tokens: ?u64 = null, - completion_tokens: ?u64 = null, -}; -const StreamChunk = struct { - choices: []StreamChoice = &.{}, - usage: ?UsageWire = null, -}; - -const FullMessage = struct { content: ?[]const u8 = null }; -const FullChoice = struct { message: FullMessage = .{} }; -const FullResponse = struct { - choices: []FullChoice = &.{}, - usage: ?UsageWire = null, -}; - -// --- JSON shapes (encode side) ---------------------------------------------- - -const StreamOptions = struct { include_usage: bool = true }; -const ChatRequest = struct { - model: []const u8, - messages: []const WireMessage, - max_tokens: u32, - temperature: f32, - stream: bool, - stream_options: ?StreamOptions = null, -}; - -pub const ChatClient = struct { - gpa: Allocator, - - // Connection + streaming state. - fd: c_int = -1, - streaming: bool = false, - - // Where decoded assistant text is appended (an app-owned Message buffer). - target: ?*std.ArrayList(u8) = null, - - // Incremental parse state, all relative to `recv_buf`. - recv_buf: std.ArrayList(u8) = .empty, - decoded: std.ArrayList(u8) = .empty, - header_len: ?usize = null, - chunked: bool = false, - raw_cursor: usize = 0, // bytes of recv_buf body consumed by de-chunking - sse_cursor: usize = 0, // bytes of decoded scanned for SSE lines - chunk_remaining: ?usize = null, // null => expecting a chunk-size line - - // Results + timing. - usage: Usage = .{}, - finished: bool = false, - started_ms: i64 = 0, - elapsed_ms: i64 = 0, // wall time of the completed stream (for tok/s) - - pub fn init(gpa: Allocator) ChatClient { - return .{ .gpa = gpa }; - } - - pub fn deinit(self: *ChatClient) void { - self.closeSocket(); - self.recv_buf.deinit(self.gpa); - self.decoded.deinit(self.gpa); - } - - pub fn isStreaming(self: *const ChatClient) bool { - return self.streaming; - } - - fn closeSocket(self: *ChatClient) void { - if (self.fd >= 0) { - _ = c.close(self.fd); - self.fd = -1; - } - } - - /// Reset the per-request parse buffers (keeps capacity). - fn resetParse(self: *ChatClient) void { - self.recv_buf.clearRetainingCapacity(); - self.decoded.clearRetainingCapacity(); - self.header_len = null; - self.chunked = false; - self.raw_cursor = 0; - self.sse_cursor = 0; - self.chunk_remaining = null; - self.usage = .{}; - self.finished = false; - self.started_ms = nowMs(); - self.elapsed_ms = 0; - } - - /// Begin a streaming request, appending assistant text into `target`. - /// On failure, writes a human-readable error into `target` and is not left - /// streaming. - pub fn start( - self: *ChatClient, - url: []const u8, - target: *std.ArrayList(u8), - messages: []const WireMessage, - params: Params, - ) void { - self.cancel(); - self.resetParse(); - self.target = target; - - self.connectAndSend(url, messages, params, true) catch |err| { - self.failInto(target, err); - return; - }; - self.streaming = true; - } - - /// Cancel any in-flight stream and close the socket. - pub fn cancel(self: *ChatClient) void { - self.closeSocket(); - self.streaming = false; - self.target = null; - } - - fn failInto(self: *ChatClient, target: *std.ArrayList(u8), err: anyerror) void { - self.closeSocket(); - self.streaming = false; - const msg = std.fmt.allocPrint(self.gpa, "[error: {s} — is the server running at the configured URL?]", .{@errorName(err)}) catch { - return; - }; - defer self.gpa.free(msg); - target.appendSlice(self.gpa, msg) catch {}; - } - - /// Open a connection, send the request, and (for streaming) switch the socket - /// to non-blocking for the polled read phase. - fn connectAndSend( - self: *ChatClient, - url: []const u8, - messages: []const WireMessage, - params: Params, - stream: bool, - ) !void { - const ep = parseUrl(url); - var host_buf: [256]u8 = undefined; - const host_z = std.fmt.bufPrintZ(&host_buf, "{s}", .{ep.host}) catch return error.BadUrl; - - const fd = try tcpConnect(host_z, ep.port); - errdefer _ = c.close(fd); - - const body = try buildRequestBody(self.gpa, messages, params, stream); - defer self.gpa.free(body); - - const req = try std.fmt.allocPrint(self.gpa, "POST /v1/chat/completions HTTP/1.1\r\n" ++ - "Host: {s}:{d}\r\n" ++ - "Content-Type: application/json\r\n" ++ - "Content-Length: {d}\r\n" ++ - "Connection: close\r\n" ++ - "\r\n{s}", .{ ep.host, ep.port, body.len, body }); - defer self.gpa.free(req); - - // The request is small and the socket is still blocking, so this writes - // fully without partial-write handling. - try sendAll(fd, req); - - if (stream) setNonBlocking(fd); - self.fd = fd; - } - - /// Poll the socket for newly available bytes and process them. Call once per - /// frame while `isStreaming()`. Returns true if new content was appended. - pub fn poll(self: *ChatClient) bool { - if (!self.streaming or self.fd < 0) return false; - const target = self.target orelse return false; - const before = target.items.len; - - var buf: [16 * 1024]u8 = undefined; - var eof = false; - while (true) { - const n = c.recv(self.fd, @ptrCast(&buf), buf.len, 0); - if (n > 0) { - self.recv_buf.appendSlice(self.gpa, buf[0..@intCast(n)]) catch break; - continue; - } - if (n == 0) { - eof = true; - break; - } - // n < 0: EAGAIN/EWOULDBLOCK means "no more right now". - const e = stdc._errno().*; - if (e == c.EAGAIN or e == c.EWOULDBLOCK) break; - if (e == c.EINTR) continue; - eof = true; // a real error: treat like the stream ending - break; - } - - self.process(target); - - if (eof or self.finished) { - self.closeSocket(); - self.streaming = false; - self.elapsed_ms = nowMs() - self.started_ms; - } - return target.items.len != before; - } - - /// Parse HTTP headers once, de-chunk the body, then consume SSE lines. - fn process(self: *ChatClient, target: *std.ArrayList(u8)) void { - if (self.header_len == null) { - const sep = std.mem.indexOf(u8, self.recv_buf.items, "\r\n\r\n") orelse return; - const headers = self.recv_buf.items[0..sep]; - self.chunked = headerHasChunked(headers); - self.header_len = sep + 4; - self.raw_cursor = self.header_len.?; - } - - self.dechunk(); - self.consumeLines(target); - } - - /// Advance `raw_cursor` over recv_buf's body, appending decoded bytes to - /// `decoded`. Handles chunked transfer-encoding incrementally; otherwise - /// copies the body verbatim. - fn dechunk(self: *ChatClient) void { - const buf = self.recv_buf.items; - if (!self.chunked) { - if (self.raw_cursor < buf.len) { - self.decoded.appendSlice(self.gpa, buf[self.raw_cursor..]) catch {}; - self.raw_cursor = buf.len; - } - return; - } - while (true) { - if (self.chunk_remaining == null) { - // Need a complete "\r\n" size line. - const rel = std.mem.indexOf(u8, buf[self.raw_cursor..], "\r\n") orelse return; - const line = buf[self.raw_cursor .. self.raw_cursor + rel]; - const size = parseChunkSize(line); - self.raw_cursor += rel + 2; - if (size == 0) { - self.finished = true; // last chunk; ignore trailers - return; - } - self.chunk_remaining = size; - } else { - var rem = self.chunk_remaining.?; - const avail = buf.len - self.raw_cursor; - if (rem > 0) { - const take = @min(rem, avail); - if (take == 0) return; // need more bytes - self.decoded.appendSlice(self.gpa, buf[self.raw_cursor .. self.raw_cursor + take]) catch {}; - self.raw_cursor += take; - rem -= take; - self.chunk_remaining = rem; - if (rem > 0) return; // chunk not fully received yet - } - // Consume the trailing CRLF after the chunk data. - if (buf.len - self.raw_cursor < 2) return; - self.raw_cursor += 2; - self.chunk_remaining = null; - } - } - } - - /// Consume complete '\n'-terminated lines from `decoded`, handling each SSE - /// `data:` line. - fn consumeLines(self: *ChatClient, target: *std.ArrayList(u8)) void { - while (std.mem.indexOfScalarPos(u8, self.decoded.items, self.sse_cursor, '\n')) |nl| { - var line = self.decoded.items[self.sse_cursor..nl]; - self.sse_cursor = nl + 1; - if (line.len > 0 and line[line.len - 1] == '\r') line = line[0 .. line.len - 1]; - self.handleSseLine(line, target); - } - } - - fn handleSseLine(self: *ChatClient, line: []const u8, target: *std.ArrayList(u8)) void { - if (!std.mem.startsWith(u8, line, "data:")) return; - const payload = std.mem.trim(u8, line["data:".len..], " \t"); - if (std.mem.eql(u8, payload, "[DONE]")) { - self.finished = true; - return; - } - const parsed = std.json.parseFromSlice(StreamChunk, self.gpa, payload, .{ .ignore_unknown_fields = true }) catch return; - defer parsed.deinit(); - const chunk = parsed.value; - if (chunk.choices.len > 0) { - const d = chunk.choices[0].delta; - if (d.content) |t| target.appendSlice(self.gpa, t) catch {}; - if (d.reasoning_content) |t| target.appendSlice(self.gpa, t) catch {}; - } - if (chunk.usage) |u| { - if (u.prompt_tokens) |p| self.usage.prompt_tokens = p; - if (u.completion_tokens) |comp| self.usage.completion_tokens = comp; - } - } - - /// One-shot, non-streaming request: connect, send, read the whole response, - /// de-chunk, and return `choices[0].message.content`. Caller owns the result. - /// Used by the headless `--smoke` path. - pub fn request( - self: *ChatClient, - url: []const u8, - messages: []const WireMessage, - params: Params, - ) ![]u8 { - self.resetParse(); - try self.connectAndSend(url, messages, params, false); - defer self.closeSocket(); - - // Blocking read to EOF (the server sends `Connection: close`). - var buf: [16 * 1024]u8 = undefined; - while (true) { - const n = c.recv(self.fd, @ptrCast(&buf), buf.len, 0); - if (n > 0) { - try self.recv_buf.appendSlice(self.gpa, buf[0..@intCast(n)]); - continue; - } - if (n == 0) break; - const e = stdc._errno().*; - if (e == c.EINTR) continue; - return error.RecvFailed; - } - - // Split headers, de-chunk the body. - const sep = std.mem.indexOf(u8, self.recv_buf.items, "\r\n\r\n") orelse return error.BadResponse; - self.chunked = headerHasChunked(self.recv_buf.items[0..sep]); - self.header_len = sep + 4; - self.raw_cursor = self.header_len.?; - self.dechunk(); - - const parsed = try std.json.parseFromSlice(FullResponse, self.gpa, self.decoded.items, .{ .ignore_unknown_fields = true }); - defer parsed.deinit(); - if (parsed.value.choices.len == 0) return error.NoChoices; - const content = parsed.value.choices[0].message.content orelse return error.NoContent; - if (parsed.value.usage) |u| { - if (u.prompt_tokens) |p| self.usage.prompt_tokens = p; - if (u.completion_tokens) |comp| self.usage.completion_tokens = comp; - } - return self.gpa.dupe(u8, content); - } -}; - -/// A blocking `GET /health` probe driving the server-status dot. Returns true if -/// the server answers `200`/`{"status":...}`; false if it is down or errors. -/// Fast against localhost (a refused connection fails immediately). -pub fn checkHealth(url: []const u8) bool { - const ep = parseUrl(url); - var host_buf: [256]u8 = undefined; - const host_z = std.fmt.bufPrintZ(&host_buf, "{s}", .{ep.host}) catch return false; - const fd = tcpConnect(host_z, ep.port) catch return false; - defer _ = c.close(fd); - - var req_buf: [320]u8 = undefined; - const req = std.fmt.bufPrint(&req_buf, "GET /health HTTP/1.1\r\nHost: {s}:{d}\r\nConnection: close\r\n\r\n", .{ ep.host, ep.port }) catch return false; - sendAll(fd, req) catch return false; - - var buf: [1024]u8 = undefined; - var total: usize = 0; - while (total < buf.len) { - const n = c.recv(fd, @ptrCast(buf[total..].ptr), buf.len - total, 0); - if (n > 0) { - total += @intCast(n); - continue; - } - if (n == 0) break; - if (stdc._errno().* == c.EINTR) continue; - break; - } - const resp = buf[0..total]; - return std.mem.indexOf(u8, resp, " 200 ") != null or std.mem.indexOf(u8, resp, "\"status\"") != null; -} - -// --- helpers ---------------------------------------------------------------- - -const Endpoint = struct { host: []const u8, port: u16 }; - -/// Parse `http://host:port[/path]` (scheme and path optional) into host+port. -fn parseUrl(url: []const u8) Endpoint { - var rest = url; - if (std.mem.startsWith(u8, rest, "http://")) rest = rest["http://".len..]; - if (std.mem.startsWith(u8, rest, "https://")) rest = rest["https://".len..]; - if (std.mem.indexOfScalar(u8, rest, '/')) |i| rest = rest[0..i]; - var host = rest; - var port: u16 = 11234; - if (std.mem.lastIndexOfScalar(u8, rest, ':')) |i| { - host = rest[0..i]; - port = std.fmt.parseInt(u16, rest[i + 1 ..], 10) catch 11234; - } - if (host.len == 0) host = "127.0.0.1"; - return .{ .host = host, .port = port }; -} - -fn buildRequestBody(gpa: Allocator, messages: []const WireMessage, params: Params, stream: bool) ![]u8 { - const req = ChatRequest{ - .model = params.model, - .messages = messages, - .max_tokens = params.max_tokens, - .temperature = params.temperature, - .stream = stream, - .stream_options = if (stream) .{} else null, - }; - return std.json.Stringify.valueAlloc(gpa, req, .{ .emit_null_optional_fields = false }); -} - -fn headerHasChunked(headers: []const u8) bool { - // Case-insensitive scan for "transfer-encoding: chunked". - var buf: [512]u8 = undefined; - const n = @min(headers.len, buf.len); - const lower = std.ascii.lowerString(buf[0..n], headers[0..n]); - return std.mem.indexOf(u8, lower, "transfer-encoding: chunked") != null or - std.mem.indexOf(u8, lower, "transfer-encoding:chunked") != null; -} - -fn parseChunkSize(line: []const u8) usize { - // A chunk-size line may carry extensions after ';'; take the hex prefix. - var end: usize = 0; - while (end < line.len and line[end] != ';' and line[end] != '\r') : (end += 1) {} - return std.fmt.parseInt(usize, std.mem.trim(u8, line[0..end], " \t"), 16) catch 0; -} - -fn tcpConnect(host: [:0]const u8, port: u16) !c_int { - var hints: c.addrinfo = std.mem.zeroes(c.addrinfo); - hints.ai_family = c.AF_UNSPEC; - hints.ai_socktype = c.SOCK_STREAM; - var port_buf: [8]u8 = undefined; - const port_z = std.fmt.bufPrintZ(&port_buf, "{d}", .{port}) catch return error.BadUrl; - - var res: [*c]c.addrinfo = null; - if (c.getaddrinfo(host.ptr, port_z.ptr, &hints, &res) != 0) return error.DnsFailed; - defer c.freeaddrinfo(res); - - var ai: [*c]c.addrinfo = res; - while (ai != null) : (ai = ai.*.ai_next) { - const fd = c.socket(ai.*.ai_family, ai.*.ai_socktype, ai.*.ai_protocol); - if (fd < 0) continue; - if (c.connect(fd, ai.*.ai_addr, ai.*.ai_addrlen) == 0) return fd; - _ = c.close(fd); - } - return error.ConnectFailed; -} - -fn setNonBlocking(fd: c_int) void { - const flags = c.fcntl(fd, c.F_GETFL, @as(c_int, 0)); - if (flags < 0) return; - _ = c.fcntl(fd, c.F_SETFL, flags | c.O_NONBLOCK); -} - -fn sendAll(fd: c_int, data: []const u8) !void { - var off: usize = 0; - while (off < data.len) { - const n = c.send(fd, @ptrCast(data.ptr + off), data.len - off, 0); - if (n <= 0) { - const e = stdc._errno().*; - if (n < 0 and e == c.EINTR) continue; - return error.SendFailed; - } - off += @intCast(n); - } -} - -// --- tests ------------------------------------------------------------------ -// (Compiled only when this file is the test root; the library suite stays -// headless. These cover the pure parsing helpers without a socket.) - -const testing = std.testing; - -test "parseUrl: scheme/host/port/path variants" { - const a = parseUrl("http://127.0.0.1:11234"); - try testing.expectEqualStrings("127.0.0.1", a.host); - try testing.expectEqual(@as(u16, 11234), a.port); - const b = parseUrl("http://localhost:8080/v1/chat/completions"); - try testing.expectEqualStrings("localhost", b.host); - try testing.expectEqual(@as(u16, 8080), b.port); - const d = parseUrl("example.com"); - try testing.expectEqualStrings("example.com", d.host); - try testing.expectEqual(@as(u16, 11234), d.port); -} - -test "parseChunkSize: hex with optional extension" { - try testing.expectEqual(@as(usize, 0x1a), parseChunkSize("1a")); - try testing.expectEqual(@as(usize, 0x10), parseChunkSize("10;ext=1")); - try testing.expectEqual(@as(usize, 0), parseChunkSize("0")); -} - -test "headerHasChunked: case-insensitive detection" { - try testing.expect(headerHasChunked("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n")); - try testing.expect(!headerHasChunked("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n")); -} diff --git a/examples/llm-chat/main.zig b/examples/llm-chat/main.zig deleted file mode 100644 index 794314a..0000000 --- a/examples/llm-chat/main.zig +++ /dev/null @@ -1,742 +0,0 @@ -//! A streaming LLM chat client built with zigui, talking to an OpenAI-compatible -//! `/v1/chat/completions` endpoint (default: mlx-serve at http://127.0.0.1:11234). -//! -//! Run the server first, e.g.: -//! mlx-serve --serve --model --port 11234 -//! then `zig build run-llm-chat`. Set the server URL, model, temperature, and -//! system prompt in the Settings sheet (gear button). -//! -//! The networking lives in the sibling `chat_client.zig`. It is single-threaded -//! and non-blocking: `body` calls `client.poll()` once per frame while a request -//! is streaming (the busy-poll hook keeps the loop awake), so all state stays on -//! the UI thread — no threads, no locks. -//! -//! Headless smoke test (no window), to prove connectivity end-to-end: -//! zig build llm-chat && ./zig-out/bin/llm-chat --smoke "say hi in one word" -//! optionally with `--url ` and `--model `. - -const std = @import("std"); -const zigui = @import("zigui"); -const app = @import("zigui_app"); -const chat = @import("chat_client.zig"); - -// Used only by the headless `--screenshot` dev mode to write a BMP (std.fs needs -// the new std.Io model in Zig 0.16; libc is already linked). -const cstdio = @cImport({ - @cInclude("stdio.h"); -}); - -const t = zigui.default_theme; - -const default_url = "http://127.0.0.1:11234"; -const default_model = "local-model"; -const default_system = "You are a helpful assistant."; - -// --- model ------------------------------------------------------------------ - -const Role = enum { - system, - user, - assistant, - - fn wire(self: Role) []const u8 { - return switch (self) { - .system => "system", - .user => "user", - .assistant => "assistant", - }; - } -}; - -/// One chat message. Heap-allocated (the transcript holds `*Message`) so its -/// `content` buffer address is stable while the client streams into it, even as -/// the transcript array grows. -const Message = struct { - role: Role, - content: std.ArrayList(u8) = .empty, - streaming: bool = false, - prompt_tokens: u64 = 0, - completion_tokens: u64 = 0, - tps: f32 = 0, - - fn create(gpa: std.mem.Allocator, role: Role) !*Message { - const m = try gpa.create(Message); - m.* = .{ .role = role }; - return m; - } - fn destroy(m: *Message, gpa: std.mem.Allocator) void { - m.content.deinit(gpa); - gpa.destroy(m); - } -}; - -const Session = struct { - title: std.ArrayList(u8) = .empty, - messages: std.ArrayList(*Message) = .empty, - - fn create(gpa: std.mem.Allocator, title: []const u8) !*Session { - const s = try gpa.create(Session); - s.* = .{}; - try s.title.appendSlice(gpa, title); - return s; - } - fn destroy(s: *Session, gpa: std.mem.Allocator) void { - for (s.messages.items) |m| m.destroy(gpa); - s.messages.deinit(gpa); - s.title.deinit(gpa); - gpa.destroy(s); - } -}; - -const AppState = struct { - gpa: std.mem.Allocator, - sessions: std.ArrayList(*Session) = .empty, - active: usize = 0, - - input: zigui.TextFieldState, - scroll: zigui.ScrollState = .{}, - show_settings: zigui.State(bool), - - server_url: zigui.TextFieldState, - model: zigui.TextFieldState, - system_prompt: zigui.TextFieldState, - temperature: zigui.State(f32), - - client: chat.ChatClient, - status: bool = false, // last /health result (drives the dot) - was_streaming: bool = false, - pending: ?*Message = null, // assistant message currently being streamed - - // A per-frame arena for callback contexts bound to row indices (mirrors the - // library's build-arena lifetime: valid until the next `body`). - frame_arena: std.heap.ArenaAllocator, - - fn activeSession(st: *AppState) ?*Session { - if (st.active >= st.sessions.items.len) return null; - return st.sessions.items[st.active]; - } - - fn newChat(st: *AppState) void { - const s = Session.create(st.gpa, "New Chat") catch return; - st.sessions.append(st.gpa, s) catch return; - st.active = st.sessions.items.len - 1; - st.scroll.offset = 0; - } - - fn deleteSessionAt(st: *AppState, index: usize) void { - if (index >= st.sessions.items.len) return; - st.client.cancel(); // an in-flight stream may target this session - st.pending = null; - st.was_streaming = false; - const s = st.sessions.orderedRemove(index); - s.destroy(st.gpa); - if (st.sessions.items.len == 0) { - const s0 = Session.create(st.gpa, "New Chat") catch return; - st.sessions.append(st.gpa, s0) catch return; - } - if (st.active >= st.sessions.items.len) st.active = st.sessions.items.len - 1; - } - - /// Append the user's message + an empty assistant placeholder, assemble the - /// OpenAI `messages` array (system prompt + history), and start streaming. - fn send(st: *AppState) void { - if (st.client.isStreaming()) return; - const text = st.input.text(); - if (text.len == 0) return; - const sess = st.activeSession() orelse return; - - const um = Message.create(st.gpa, .user) catch return; - um.content.appendSlice(st.gpa, text) catch {}; - sess.messages.append(st.gpa, um) catch return; - - // Title a fresh chat from its first user message. - if (sess.messages.items.len == 1) { - sess.title.clearRetainingCapacity(); - sess.title.appendSlice(st.gpa, text[0..@min(text.len, 28)]) catch {}; - } - - const am = Message.create(st.gpa, .assistant) catch return; - am.streaming = true; - sess.messages.append(st.gpa, am) catch return; - st.pending = am; - - var wire: std.ArrayList(chat.WireMessage) = .empty; - defer wire.deinit(st.gpa); - if (st.system_prompt.text().len > 0) { - wire.append(st.gpa, .{ .role = "system", .content = st.system_prompt.text() }) catch {}; - } - for (sess.messages.items) |m| { - if (m == am) continue; // skip the empty placeholder - wire.append(st.gpa, .{ .role = m.role.wire(), .content = m.content.items }) catch {}; - } - - const params = chat.Params{ - .model = st.model.text(), - .temperature = st.temperature.get(), - .max_tokens = 1024, - }; - st.status = chat.checkHealth(st.server_url.text()); - st.client.start(st.server_url.text(), &am.content, wire.items, params); - st.was_streaming = st.client.isStreaming(); - st.input.setText("") catch {}; - st.scroll.offset = 1_000_000; // pin to bottom (clamped during paint) - } - - fn stop(st: *AppState) void { - st.client.cancel(); - if (st.pending) |am| am.streaming = false; - st.pending = null; - st.was_streaming = false; - } -}; - -// --- callbacks bound to a session row index --------------------------------- - -const RowCtx = struct { st: *AppState, index: usize }; - -fn rowCtx(st: *AppState, index: usize) *RowCtx { - const cx = st.frame_arena.allocator().create(RowCtx) catch unreachable; - cx.* = .{ .st = st, .index = index }; - return cx; -} -fn onSelectRow(p: ?*anyopaque) void { - const cx: *RowCtx = @ptrCast(@alignCast(p.?)); - cx.st.active = cx.index; - cx.st.scroll.offset = 1_000_000; -} -fn onDeleteRow(p: ?*anyopaque) void { - const cx: *RowCtx = @ptrCast(@alignCast(p.?)); - cx.st.deleteSessionAt(cx.index); -} - -// --- views ------------------------------------------------------------------ - -/// Cap a view's width to `w` points (keeps chat bubbles from spanning the pane). -fn maxWidth(v: zigui.View, w: f32) zigui.View { - var out = v; - var f = out.mods.frame orelse zigui.view.FrameSpec{}; - f.max_width = w; - out.mods.frame = f; - return out; -} - -const bubble_max: f32 = 520; - -/// macOS "system green" for the healthy status dot. -fn statusGreen() zigui.Color { - return zigui.Color.fromRgb8(52, 199, 89); -} - -/// Wrap `v` so it sits on the leading edge of a full-width row (the frame layout -/// centers by default, so a trailing Spacer is how you left-align). -fn leading(v: zigui.View) zigui.View { - return zigui.HStack(.{ v, zigui.Spacer() }).frameMaxWidth(); -} - -/// A single chat bubble: user messages on the accent on the right, assistant -/// messages in the control color on the left (corner radius 14, 12×8 padding), -/// with an optional tokens/sec caption under finished assistant replies. -fn bubbleView(msg: *Message) zigui.View { - const is_user = msg.role == .user; - const bg = if (is_user) t.colors.accent else t.colors.control_background; - const fg = if (is_user) t.colors.on_accent else t.colors.label; - // A trailing ellipsis hints "still generating" while streaming. - const shown = if (msg.streaming) - zigui.components.fmt("{s}…", .{msg.content.items}) - else - msg.content.items; - - const bubble = maxWidth( - zigui.WrappedText(shown) - .foreground(fg) - .paddingInsets(.{ .top = 8, .leading = 12, .bottom = 8, .trailing = 12 }) - .background(bg) - .cornerRadius(14), - bubble_max, - ); - - const row = if (is_user) - zigui.HStack(.{ zigui.Spacer(), bubble }).frameMaxWidth() - else - zigui.HStack(.{ bubble, zigui.Spacer() }).frameMaxWidth(); - - if (!is_user and !msg.streaming and msg.completion_tokens > 0) { - const cap = zigui.Text(zigui.components.fmt("{d} tokens · {d:.0} tok/s", .{ msg.completion_tokens, msg.tps })) - .font(.caption2) - .foreground(t.colors.tertiary_label); - return zigui.VStack(.{ - row, - leading(cap.paddingInsets(.{ .leading = 2, .top = 0, .bottom = 0, .trailing = 0 })), - }).spacing(3).frameMaxWidth(); - } - return row; -} - -/// A sidebar chat row: leading title, a rounded accent fill when selected, and a -/// subtle trailing delete affordance. Built as siblings (not a wrapping button) -/// so the title taps select and the "×" taps delete without overlap. -fn sidebarRow(st: *AppState, i: usize, title: []const u8, active: bool) zigui.View { - const fg = if (active) t.colors.on_accent else t.colors.label; - const del_color = if (active) t.colors.on_accent.withAlpha(0.7) else t.colors.tertiary_label; - const title_area = zigui.HStack(.{ - zigui.Text(title).font(.subheadline).foreground(fg), - zigui.Spacer(), - }).frameMaxWidth().onTap(.{ .ctx = rowCtx(st, i), .func = onSelectRow }); - const del = zigui.Text("×").foreground(del_color).onTap(.{ .ctx = rowCtx(st, i), .func = onDeleteRow }); - - var row = zigui.HStack(.{ title_area, del }) - .spacing(6) - .paddingInsets(.{ .top = 6, .leading = 8, .bottom = 6, .trailing = 8 }) - .cornerRadius(8) - .frameMaxWidth(); - if (active) row = row.background(t.colors.accent); - return row; -} - -/// A subtle, full-width bordered button (macOS `.bordered` style). -fn borderedButton(label: []const u8, cb: zigui.Callback) zigui.View { - return zigui.HStack(.{ zigui.Spacer(), zigui.Text(label).foreground(t.colors.accent), zigui.Spacer() }) - .paddingInsets(.{ .top = 7, .leading = 10, .bottom = 7, .trailing = 10 }) - .background(t.colors.control_background) - .cornerRadius(8) - .border(t.colors.separator, t.metrics.hairline) - .onTap(cb) - .frameMaxWidth(); -} - -fn sidebarView(st: *AppState) zigui.View { - const fa = st.frame_arena.allocator(); - var rows: std.ArrayList(zigui.View) = .empty; - rows.append(fa, leading(zigui.Text("CHATS").font(.caption2).foreground(t.colors.tertiary_label)) - .paddingInsets(.{ .top = 2, .leading = 8, .bottom = 4, .trailing = 8 })) catch {}; - - for (st.sessions.items, 0..) |s, i| { - const title = if (s.title.items.len == 0) "New Chat" else s.title.items; - rows.append(fa, sidebarRow(st, i, title, i == st.active)) catch {}; - } - - rows.append(fa, zigui.Spacer()) catch {}; - rows.append(fa, borderedButton("+ New Chat", zigui.actionCtx(AppState, st, AppState.newChat))) catch {}; - return zigui.VStack(rows.items) - .spacing(2) - .paddingInsets(.{ .top = 12, .leading = 8, .bottom = 10, .trailing = 8 }) - .frameMaxWidth() - .frameMaxHeight(); -} - -/// The circular send / stop button (accent up-arrow, or red stop while streaming). -fn sendButton(st: *AppState) zigui.View { - const streaming = st.client.isStreaming(); - const cb = if (streaming) - zigui.actionCtx(AppState, st, AppState.stop) - else - zigui.actionCtx(AppState, st, AppState.send); - const fill = if (streaming) t.colors.destructive else t.colors.accent; - const glyph: []const u8 = if (streaming) "■" else "↑"; - return zigui.ZStack(.{ - zigui.Circle(fill).frame(34, 34), - zigui.Text(glyph).font(.headline).foreground(t.colors.on_accent), - }).frame(34, 34).onTap(cb); -} - -fn detailView(st: *AppState) zigui.View { - const sess = st.activeSession(); - const title = if (sess) |s| (if (s.title.items.len == 0) "New Chat" else s.title.items) else "Chat"; - - const dot_color = if (st.status) statusGreen() else t.colors.destructive; - const header = zigui.HStack(.{ - zigui.Text(title).font(.title3), - zigui.Spacer(), - zigui.Text(st.model.text()).font(.caption).foreground(t.colors.secondary_label), - zigui.Circle(dot_color).frame(8, 8), - zigui.components.ButtonRoled("Settings", .plain, zigui.actionCtx(AppState, st, showSettings)), - }).spacing(10).paddingInsets(.{ .top = 10, .leading = 16, .bottom = 10, .trailing = 12 }).frameMaxWidth(); - - // Transcript. - var bubbles: zigui.View = zigui.Spacer(); - if (sess) |s| { - bubbles = zigui.VStack(.{zigui.ForEach(s.messages.items, bubbleView)}) - .spacing(12) - .padding(16) - .frameMaxWidth(); - } - const transcript = zigui.ScrollViewState(&st.scroll, bubbles) - .frameMaxWidth() - .frameMaxHeight(); - - // Input bar: a pill-shaped field + circular send button. - const input_field = zigui.TextField("Message…", &st.input) - .onSubmit(zigui.actionCtx(AppState, st, AppState.send)) - .frameMaxWidth() - .frameHeight(38) - .cornerRadius(19); - const input_bar = zigui.HStack(.{ input_field, sendButton(st) }) - .spacing(8) - .paddingInsets(.{ .top = 8, .leading = 12, .bottom = 10, .trailing = 12 }) - .frameMaxWidth(); - - return zigui.VStack(.{ - header, - zigui.Divider(), - transcript, - zigui.Divider(), - input_bar, - }).frameMaxWidth().frameMaxHeight(); -} - -fn showSettings(st: *AppState) void { - st.show_settings.set(true); -} -fn hideSettings(st: *AppState) void { - st.show_settings.set(false); -} - -fn field(label: []const u8, control: zigui.View) zigui.View { - return zigui.VStack(.{ - leading(zigui.Text(label).font(.subheadline).foreground(t.colors.secondary_label)), - control.frameMaxWidth(), - }).spacing(5).frameMaxWidth(); -} - -fn settingsForm(st: *AppState) zigui.View { - // A grouped "card" section like macOS settings. - const card = zigui.VStack(.{ - field("Server URL", zigui.TextField(default_url, &st.server_url)), - field("Model", zigui.TextField(default_model, &st.model)), - field( - zigui.components.fmt("Temperature: {d:.2}", .{st.temperature.get()}), - zigui.Slider(st.temperature.binding(), 0, 2), - ), - field("System Prompt", zigui.TextField("System prompt", &st.system_prompt)), - }).spacing(14) - .padding(16) - .background(t.colors.secondary_background) - .cornerRadius(10) - .border(t.colors.separator, t.metrics.hairline) - .frameMaxWidth(); - - return zigui.VStack(.{ - leading(zigui.Text("Settings").font(.title3)), - card, - zigui.HStack(.{ - zigui.Spacer(), - zigui.Button("Done", zigui.actionCtx(AppState, st, hideSettings)), - }).frameMaxWidth(), - }).spacing(16).padding(20).frameWidth(440); -} - -fn body(st: *AppState) zigui.View { - _ = st.frame_arena.reset(.retain_capacity); - - // Pump the streaming socket. Pin to the bottom while following a live reply. - const was_at_bottom = st.scroll.atBottom(); - _ = st.client.poll(); - if (st.client.isStreaming() and was_at_bottom) st.scroll.offset = 1_000_000; - - // Detect stream completion: finalize the assistant bubble + refresh status. - const streaming_now = st.client.isStreaming(); - if (st.was_streaming and !streaming_now) { - if (st.pending) |am| { - am.streaming = false; - am.prompt_tokens = st.client.usage.prompt_tokens; - am.completion_tokens = st.client.usage.completion_tokens; - const elapsed = @as(f32, @floatFromInt(st.client.elapsed_ms)) / 1000.0; - am.tps = if (elapsed > 0) @as(f32, @floatFromInt(am.completion_tokens)) / elapsed else 0; - st.pending = null; - } - st.status = chat.checkHealth(st.server_url.text()); - st.was_streaming = false; - } - - // Keep the tray dot in sync with the server status. - if (st.status != g_tray_status) { - g_tray_status = st.status; - refreshTrayIcon(st.gpa, st.status); - } - - return zigui.NavigationSplitView(sidebarView(st), detailView(st), t.colors.secondary_background) - .alert(st.show_settings.binding(), settingsForm(st)); -} - -// --- busy-poll hook --------------------------------------------------------- - -var g_app: ?*AppState = null; -fn busyCheck() bool { - return if (g_app) |a| a.client.isStreaming() else false; -} - -// --- entry point ------------------------------------------------------------ - -fn runSmoke(gpa: std.mem.Allocator, url: []const u8, model: []const u8, prompt: []const u8) void { - var client = chat.ChatClient.init(gpa); - defer client.deinit(); - const messages = [_]chat.WireMessage{.{ .role = "user", .content = prompt }}; - const params = chat.Params{ .model = model, .temperature = 0.7, .max_tokens = 256 }; - const reply = client.request(url, &messages, params) catch |err| { - std.debug.print("smoke: request to {s} failed: {s} — is mlx-serve running?\n", .{ url, @errorName(err) }); - return; - }; - defer gpa.free(reply); - std.debug.print("{s}\n", .{reply}); -} - -/// Headless exercise of the streaming SSE path (the GUI's primary path): start a -/// stream and pump `poll()` to EOF, printing the accumulated reply. Mirrors what -/// `body` does each frame, minus the window. -fn runStreamSmoke(gpa: std.mem.Allocator, url: []const u8, model: []const u8, prompt: []const u8) void { - var client = chat.ChatClient.init(gpa); - defer client.deinit(); - var content: std.ArrayList(u8) = .empty; - defer content.deinit(gpa); - const messages = [_]chat.WireMessage{.{ .role = "user", .content = prompt }}; - const params = chat.Params{ .model = model, .temperature = 0.7, .max_tokens = 256 }; - - client.start(url, &content, messages[0..], params); - var spins: usize = 0; - while (client.isStreaming() and spins < 100_000) : (spins += 1) { - _ = client.poll(); - chat.sleepMs(8); - } - std.debug.print("{s}\n", .{content.items}); - std.debug.print("[stream: {d} completion tokens, {d} ms]\n", .{ client.usage.completion_tokens, client.elapsed_ms }); -} - -// --- headless screenshot (dev tool) ----------------------------------------- -// Renders one frame of the real `body` to a BMP without opening a window, so the -// UI can be inspected/iterated on headlessly: -// zig build llm-chat -Doptimize=ReleaseFast -// ./zig-out/bin/llm-chat --screenshot /tmp/chat.bmp -// then convert with `sips -s format png`. - -fn addMockMsg(s: *Session, gpa: std.mem.Allocator, role: Role, text: []const u8) *Message { - const m = Message.create(gpa, role) catch unreachable; - m.content.appendSlice(gpa, text) catch {}; - s.messages.append(gpa, m) catch {}; - return m; -} - -fn populateMock(st: *AppState) void { - const gpa = st.gpa; - const s = st.sessions.items[0]; - s.title.clearRetainingCapacity(); - s.title.appendSlice(gpa, "Rewrite chat in Zig") catch {}; - _ = addMockMsg(s, gpa, .user, "Can you rewrite the SwiftUI chat app in zigui?"); - const a1 = addMockMsg(s, gpa, .assistant, "Absolutely. We reuse the pure layout engine, add width-dependent WrappedText for bubbles, and stream tokens over SSE so the transcript fills in live. Bubbles word-wrap to the pane and pin to the bottom while generating."); - a1.completion_tokens = 96; - a1.tps = 47.3; - _ = addMockMsg(s, gpa, .user, "Make it look like SwiftUI."); - const a2 = addMockMsg(s, gpa, .assistant, "On it — accent bubbles on the right, a sidebar with a rounded selection highlight, and a pill-shaped input with a round send button."); - a2.completion_tokens = 38; - a2.tps = 44.1; - st.sessions.append(gpa, Session.create(gpa, "Weekend ideas") catch return) catch {}; - st.status = true; -} - -fn renderScreenshot(gpa: std.mem.Allocator, st: *AppState, path: [:0]const u8) !void { - const w: u32 = 900; - const h: u32 = 650; - var font = zigui.Font.default(); - var cache = zigui.GlyphCache.init(gpa, &font.face); - defer cache.deinit(); - var arena_state = std.heap.ArenaAllocator.init(gpa); - defer arena_state.deinit(); - const arena = arena_state.allocator(); - - zigui.beginBuild(arena); - const root = body(st); - zigui.endBuild(); - - var hits: std.ArrayList(zigui.HitRegion) = .empty; - var overlays: std.ArrayList(zigui.OverlayReq) = .empty; - var scrolls: std.ArrayList(zigui.ScrollRegion) = .empty; - var ctx = zigui.Context.initFull(t, &cache, arena, &hits, &overlays, null); - ctx.scroll_regions = &scrolls; - - var canvas = zigui.Canvas.init(arena); - const full = zigui.Rect{ .x = 0, .y = 0, .width = @floatFromInt(w), .height = @floatFromInt(h) }; - try canvas.fillRect(full, t.colors.window_background); - try zigui.render(&ctx, root, full, &canvas); - - var fb = try zigui.Framebuffer.init(gpa, w, h); - defer fb.deinit(); - fb.clear(t.colors.window_background); - try zigui.raster.render(gpa, &fb, canvas.commands.items); - writeBmp(path, &fb); -} - -fn writeBmp(path: [:0]const u8, fb: *const zigui.Framebuffer) void { - const w = fb.width; - const h = fb.height; - const stride = w * 3 + (4 - (w * 3) % 4) % 4; - const img_size = stride * h; - const f = cstdio.fopen(path.ptr, "wb") orelse return; - defer _ = cstdio.fclose(f); - - var hdr = [_]u8{0} ** 54; - hdr[0] = 'B'; - hdr[1] = 'M'; - std.mem.writeInt(u32, hdr[2..6], @intCast(54 + img_size), .little); - std.mem.writeInt(u32, hdr[10..14], 54, .little); - std.mem.writeInt(u32, hdr[14..18], 40, .little); - std.mem.writeInt(i32, hdr[18..22], @intCast(w), .little); - std.mem.writeInt(i32, hdr[22..26], @intCast(h), .little); - std.mem.writeInt(u16, hdr[26..28], 1, .little); - std.mem.writeInt(u16, hdr[28..30], 24, .little); - std.mem.writeInt(u32, hdr[34..38], @intCast(img_size), .little); - _ = cstdio.fwrite(&hdr, 1, 54, f); - - const row = std.heap.page_allocator.alloc(u8, stride) catch return; - defer std.heap.page_allocator.free(row); - @memset(row, 0); - var y: u32 = h; - while (y > 0) { - y -= 1; // BMP rows are stored bottom-up - var x: u32 = 0; - while (x < w) : (x += 1) { - const px = fb.at(x, y).toRgba8(); - row[x * 3 + 0] = px.b; - row[x * 3 + 1] = px.g; - row[x * 3 + 2] = px.r; - } - _ = cstdio.fwrite(row.ptr, 1, stride, f); - } -} - -// --- system tray ------------------------------------------------------------- -// A menu-bar / tray icon whose dot mirrors the server `/health` status, plus a -// menu (Open / New Chat / Settings / Quit). The icon is drawn with zigui's own -// rasterizer and handed to SDL as RGBA pixels. Built once at startup (the tray -// is retained/imperative, not part of the per-frame view tree). - -const tray_icon_px: u32 = 22; -var g_tray: ?app.Tray = null; -var g_tray_status: bool = false; - -/// Rasterize a filled status dot to RGBA (transparent background) using the same -/// pipeline that draws the window — the synergy that makes a zigui-drawn tray -/// icon trivial. -fn statusIconRGBA(gpa: std.mem.Allocator, size: u32, color: zigui.Color) ![]u8 { - var canvas = zigui.Canvas.init(gpa); - defer canvas.deinit(); - const s: f32 = @floatFromInt(size); - try canvas.fillCircle(.{ .x = s / 2, .y = s / 2 }, s / 2 - 2, color); - var fb = try zigui.Framebuffer.init(gpa, size, size); - defer fb.deinit(); - fb.clear(zigui.Color.transparent); - try zigui.raster.render(gpa, &fb, canvas.commands.items); - return fb.toRgba8Alloc(gpa); -} - -fn refreshTrayIcon(gpa: std.mem.Allocator, up: bool) void { - if (g_tray) |*tr| { - const px = statusIconRGBA(gpa, tray_icon_px, if (up) statusGreen() else t.colors.destructive) catch return; - defer gpa.free(px); - tr.setIcon(px, @intCast(tray_icon_px), @intCast(tray_icon_px)); - } -} - -fn openSettingsFromTray(st: *AppState) void { - st.show_settings.set(true); - app.showWindow(); -} - -fn setupTray(gpa: std.mem.Allocator, st: *AppState) void { - const px = statusIconRGBA(gpa, tray_icon_px, if (st.status) statusGreen() else t.colors.destructive) catch return; - defer gpa.free(px); - const created = app.Tray.create(gpa, px, @intCast(tray_icon_px), @intCast(tray_icon_px), "zigui — LLM Chat") catch return; - g_tray = created; - g_tray_status = st.status; - const m = g_tray.?.menu(); - m.addItem("Open", zigui.action(app.showWindow)); - m.addItem("New Chat", zigui.actionCtx(AppState, st, AppState.newChat)); - m.addItem("Settings", zigui.actionCtx(AppState, st, openSettingsFromTray)); - m.addSeparator(); - m.addItem("Quit", zigui.action(app.quit)); -} - -pub fn main(init: std.process.Init.Minimal) !void { - const gpa = std.heap.page_allocator; - - // Args: --smoke "", --url , --model . (Zig 0.16 passes - // them via the entry-point `Init.Minimal`.) - var smoke_prompt: ?[]const u8 = null; - var url: []const u8 = default_url; - var model: []const u8 = default_model; - var stream = false; - var screenshot_path: ?[:0]const u8 = null; - var open_settings = false; - // iterateAllocator (not iterate): the simple iterator @compileErrors on - // Windows, where argv must be decoded from the WTF-16 command line. - var arg_it = try std.process.Args.iterateAllocator(init.args, gpa); - defer arg_it.deinit(); - _ = arg_it.next(); // skip argv[0] - while (arg_it.next()) |a| { - if (std.mem.eql(u8, a, "--smoke")) { - if (arg_it.next()) |p| smoke_prompt = p; - } else if (std.mem.eql(u8, a, "--url")) { - if (arg_it.next()) |u| url = u; - } else if (std.mem.eql(u8, a, "--model")) { - if (arg_it.next()) |m| model = m; - } else if (std.mem.eql(u8, a, "--stream")) { - stream = true; - } else if (std.mem.eql(u8, a, "--screenshot")) { - if (arg_it.next()) |p| screenshot_path = p; - } else if (std.mem.eql(u8, a, "--settings")) { - open_settings = true; - } - } - - if (smoke_prompt) |p| { - if (stream) runStreamSmoke(gpa, url, model, p) else runSmoke(gpa, url, model, p); - return; - } - - var st = AppState{ - .gpa = gpa, - .input = zigui.TextFieldState.init(gpa), - .show_settings = zigui.State(bool).init(gpa, false), - .server_url = zigui.TextFieldState.init(gpa), - .model = zigui.TextFieldState.init(gpa), - .system_prompt = zigui.TextFieldState.init(gpa), - .temperature = zigui.State(f32).init(gpa, 0.7), - .client = chat.ChatClient.init(gpa), - .frame_arena = std.heap.ArenaAllocator.init(gpa), - }; - defer { - for (st.sessions.items) |s| s.destroy(gpa); - st.sessions.deinit(gpa); - st.input.deinit(); - st.show_settings.deinit(); - st.server_url.deinit(); - st.model.deinit(); - st.system_prompt.deinit(); - st.temperature.deinit(); - st.client.deinit(); - st.frame_arena.deinit(); - } - try st.server_url.setText(url); - try st.model.setText(model); - try st.system_prompt.setText(default_system); - st.sessions.append(gpa, try Session.create(gpa, "New Chat")) catch {}; - - if (screenshot_path) |path| { - populateMock(&st); - if (open_settings) st.show_settings.set(true); - renderScreenshot(gpa, &st, path) catch |e| std.debug.print("screenshot failed: {s}\n", .{@errorName(e)}); - return; - } - - st.status = chat.checkHealth(st.server_url.text()); - g_app = &st; - app.setBusyCheck(&busyCheck); - - setupTray(gpa, &st); - defer if (g_tray) |*tr| tr.deinit(); - - try app.run(gpa, AppState, &st, .{ - .title = "zigui — LLM Chat", - .width = 900, - .height = 650, - .hide_on_close = true, // close button hides to the tray; Quit from the tray - }, body); -} diff --git a/examples/screenshot/main.zig b/examples/screenshot/main.zig deleted file mode 100644 index cd1efd2..0000000 --- a/examples/screenshot/main.zig +++ /dev/null @@ -1,193 +0,0 @@ -//! Headless screenshot generator — renders a representative zigui chat UI to a -//! BMP using **only the pure `zigui` module + libc** (no SDL, no window). CI -//! builds and runs this on Linux, macOS, and Windows to produce a per-OS -//! screenshot artifact, so you can confirm the rendering is identical across -//! platforms. -//! -//! zig build run-screenshot -- out.bmp -//! -//! It deliberately mirrors the `llm-chat` look (accent bubbles, rounded sidebar -//! selection, pill input + circular send) using public components. - -const std = @import("std"); -const zigui = @import("zigui"); - -// libc file write (std.fs needs the new std.Io model in Zig 0.16). -const cstdio = @cImport({ - @cInclude("stdio.h"); -}); - -const t = zigui.default_theme; - -fn noop() zigui.Callback { - return zigui.action(struct { - fn f() void {} - }.f); -} - -/// Left-align a view inside a full-width row (the frame layout centers by -/// default, so a trailing Spacer is how you push to the leading edge). -fn leading(v: zigui.View) zigui.View { - return zigui.HStack(.{ v, zigui.Spacer() }).frameMaxWidth(); -} - -/// Cap a view's width (chat bubbles shouldn't span the whole pane). -fn maxWidth(v: zigui.View, w: f32) zigui.View { - var out = v; - var f = out.mods.frame orelse zigui.view.FrameSpec{}; - f.max_width = w; - out.mods.frame = f; - return out; -} - -fn bubble(text: []const u8, is_user: bool) zigui.View { - const bg = if (is_user) t.colors.accent else t.colors.control_background; - const fg = if (is_user) t.colors.on_accent else t.colors.label; - const b = maxWidth( - zigui.WrappedText(text) - .foreground(fg) - .paddingInsets(.{ .top = 8, .leading = 12, .bottom = 8, .trailing = 12 }) - .background(bg) - .cornerRadius(14), - 420, - ); - return if (is_user) - zigui.HStack(.{ zigui.Spacer(), b }).frameMaxWidth() - else - zigui.HStack(.{ b, zigui.Spacer() }).frameMaxWidth(); -} - -fn sidebarRow(title: []const u8, active: bool) zigui.View { - const fg = if (active) t.colors.on_accent else t.colors.label; - var row = zigui.HStack(.{ zigui.Text(title).font(.subheadline).foreground(fg), zigui.Spacer() }) - .paddingInsets(.{ .top = 6, .leading = 8, .bottom = 6, .trailing = 8 }) - .cornerRadius(8) - .frameMaxWidth(); - if (active) row = row.background(t.colors.accent); - return row; -} - -fn buildUI(input: *zigui.TextFieldState) zigui.View { - const sidebar = zigui.VStack(.{ - leading(zigui.Text("CHATS").font(.caption2).foreground(t.colors.tertiary_label)), - sidebarRow("Rewrite chat in Zig", true), - sidebarRow("Weekend ideas", false), - zigui.Spacer(), - }).spacing(2) - .paddingInsets(.{ .top = 12, .leading = 8, .bottom = 10, .trailing = 8 }) - .frameMaxWidth() - .frameMaxHeight(); - - const header = zigui.HStack(.{ - zigui.Text("Rewrite chat in Zig").font(.title3), - zigui.Spacer(), - zigui.Text("gemma-4-12b").font(.caption).foreground(t.colors.secondary_label), - zigui.Circle(zigui.Color.fromRgb8(52, 199, 89)).frame(8, 8), - zigui.components.ButtonRoled("Settings", .plain, noop()), - }).spacing(10).paddingInsets(.{ .top = 10, .leading = 16, .bottom = 10, .trailing = 12 }).frameMaxWidth(); - - const transcript = zigui.ScrollView(zigui.VStack(.{ - bubble("Can you rewrite the SwiftUI chat app in zigui?", true), - zigui.VStack(.{ - bubble("Absolutely. We reuse the pure layout engine, add width-dependent WrappedText for bubbles, and stream tokens over SSE so the transcript fills in live.", false), - leading(zigui.Text("96 tokens · 47 tok/s").font(.caption2).foreground(t.colors.tertiary_label)), - }).spacing(3).frameMaxWidth(), - bubble("Make it look like SwiftUI.", true), - bubble("On it — accent bubbles on the right, a sidebar with a rounded selection highlight, and a pill input with a round send button.", false), - }).spacing(12).padding(16).frameMaxWidth()).frameMaxWidth().frameMaxHeight(); - - const send = zigui.ZStack(.{ - zigui.Circle(t.colors.accent).frame(34, 34), - zigui.Text("↑").font(.headline).foreground(t.colors.on_accent), - }).frame(34, 34); - const input_bar = zigui.HStack(.{ - zigui.TextField("Message…", input).frameMaxWidth().frameHeight(38).cornerRadius(19), - send, - }).spacing(8).paddingInsets(.{ .top = 8, .leading = 12, .bottom = 10, .trailing = 12 }).frameMaxWidth(); - - const detail = zigui.VStack(.{ header, zigui.Divider(), transcript, zigui.Divider(), input_bar }) - .frameMaxWidth() - .frameMaxHeight(); - - return zigui.NavigationSplitView(sidebar, detail, t.colors.secondary_background); -} - -fn writeBmp(path: [:0]const u8, fb: *const zigui.Framebuffer) void { - const w = fb.width; - const h = fb.height; - const stride = w * 3 + (4 - (w * 3) % 4) % 4; - const img_size = stride * h; - const f = cstdio.fopen(path.ptr, "wb") orelse return; - defer _ = cstdio.fclose(f); - - var hdr = [_]u8{0} ** 54; - hdr[0] = 'B'; - hdr[1] = 'M'; - std.mem.writeInt(u32, hdr[2..6], @intCast(54 + img_size), .little); - std.mem.writeInt(u32, hdr[10..14], 54, .little); - std.mem.writeInt(u32, hdr[14..18], 40, .little); - std.mem.writeInt(i32, hdr[18..22], @intCast(w), .little); - std.mem.writeInt(i32, hdr[22..26], @intCast(h), .little); - std.mem.writeInt(u16, hdr[26..28], 1, .little); - std.mem.writeInt(u16, hdr[28..30], 24, .little); - std.mem.writeInt(u32, hdr[34..38], @intCast(img_size), .little); - _ = cstdio.fwrite(&hdr, 1, 54, f); - - const row = std.heap.page_allocator.alloc(u8, stride) catch return; - defer std.heap.page_allocator.free(row); - @memset(row, 0); - var y: u32 = h; - while (y > 0) { - y -= 1; // BMP rows are bottom-up - var x: u32 = 0; - while (x < w) : (x += 1) { - const px = fb.at(x, y).toRgba8(); - row[x * 3 + 0] = px.b; - row[x * 3 + 1] = px.g; - row[x * 3 + 2] = px.r; - } - _ = cstdio.fwrite(row.ptr, 1, stride, f); - } -} - -pub fn main(init: std.process.Init.Minimal) !void { - const gpa = std.heap.page_allocator; - - var out: [:0]const u8 = "zigui-screenshot.bmp"; - // iterateAllocator (not iterate): the simple iterator @compileErrors on - // Windows, where argv must be decoded from the WTF-16 command line. - var it = try std.process.Args.iterateAllocator(init.args, gpa); - defer it.deinit(); - _ = it.next(); // argv[0] - while (it.next()) |a| out = a; // last positional arg = output path - - var input = zigui.TextFieldState.init(gpa); - defer input.deinit(); - try input.setText("Anything else to add?"); - - const w: u32 = 900; - const h: u32 = 650; - var font = zigui.Font.default(); - var cache = zigui.GlyphCache.init(gpa, &font.face); - defer cache.deinit(); - var arena = std.heap.ArenaAllocator.init(gpa); - defer arena.deinit(); - - zigui.beginBuild(arena.allocator()); - const root = buildUI(&input); - zigui.endBuild(); - - var hits: std.ArrayList(zigui.HitRegion) = .empty; - var ctx = zigui.Context.init(t, &cache, arena.allocator(), &hits); - var canvas = zigui.Canvas.init(arena.allocator()); - const full = zigui.Rect{ .x = 0, .y = 0, .width = @floatFromInt(w), .height = @floatFromInt(h) }; - try canvas.fillRect(full, t.colors.window_background); - try zigui.render(&ctx, root, full, &canvas); - - var fb = try zigui.Framebuffer.init(gpa, w, h); - defer fb.deinit(); - fb.clear(t.colors.window_background); - try zigui.raster.render(gpa, &fb, canvas.commands.items); - writeBmp(out, &fb); - std.debug.print("wrote {s} ({d}x{d})\n", .{ out, w, h }); -} diff --git a/examples/settings/main.zig b/examples/settings/main.zig deleted file mode 100644 index 61c522b..0000000 --- a/examples/settings/main.zig +++ /dev/null @@ -1,99 +0,0 @@ -//! The v1 milestone demo: a macOS-like Settings screen exercising toggles, -//! sliders, a stepper, a text field, a progress bar, grouped "cards", and live -//! two-way state binding. Run with `zig build settings`. - -const std = @import("std"); -const zigui = @import("zigui"); -const app = @import("zigui_app"); - -const t = zigui.default_theme; - -const AppState = struct { - wifi: zigui.State(bool), - bluetooth: zigui.State(bool), - notifications: zigui.State(bool), - brightness: zigui.State(f32), - volume: zigui.State(f32), - copies: zigui.State(i64), - appearance: zigui.State(i64), - name: zigui.TextFieldState, -}; - -const appearance_options = [_][]const u8{ "Light", "Dark", "Auto" }; - -/// A label on the leading edge with a control pushed to the trailing edge. -fn settingRow(title: []const u8, control: zigui.View) zigui.View { - return zigui.HStack(.{ zigui.Text(title), zigui.Spacer(), control }).frameMaxWidth(); -} - -/// A grouped, rounded "card" container like macOS settings sections. -fn card(content: zigui.View) zigui.View { - return content - .padding(14) - .background(t.colors.control_background) - .cornerRadius(10) - .border(t.colors.separator, t.metrics.hairline) - .frameMaxWidth(); -} - -fn body(st: *AppState) zigui.View { - return zigui.VStack(.{ - zigui.Text("Settings").font(.large_title).frameMaxWidth(), - - card(zigui.VStack(.{ - settingRow("Wi‑Fi", zigui.Toggle("", st.wifi.binding())), - zigui.Divider(), - settingRow("Bluetooth", zigui.Toggle("", st.bluetooth.binding())), - zigui.Divider(), - settingRow("Notifications", zigui.Toggle("", st.notifications.binding())), - }).spacing(10)), - - card(zigui.VStack(.{ - settingRow("Brightness", zigui.Slider(st.brightness.binding(), 0, 1).frameWidth(180)), - zigui.Divider(), - settingRow("Volume", zigui.Slider(st.volume.binding(), 0, 1).frameWidth(180)), - }).spacing(10)), - - card(zigui.VStack(.{ - settingRow("Appearance", zigui.Picker(st.appearance.binding(), &appearance_options).frameWidth(220)), - zigui.Divider(), - settingRow("Name", zigui.TextField("Your name", &st.name).frameWidth(200)), - zigui.Divider(), - settingRow("Copies", zigui.Stepper("", st.copies.binding(), 0, 99, 1)), - }).spacing(10)), - - card(zigui.VStack(.{ - zigui.Text("Storage — 62% used") - .foreground(t.colors.secondary_label) - .frameMaxWidth(), - zigui.ProgressView(0.62).frameMaxWidth(), - }).spacing(8)), - - zigui.Spacer(), - }).spacing(16).padding(20).frameMaxWidth(); -} - -pub fn main() !void { - const alloc = std.heap.page_allocator; - var st = AppState{ - .wifi = zigui.State(bool).init(alloc, true), - .bluetooth = zigui.State(bool).init(alloc, false), - .notifications = zigui.State(bool).init(alloc, true), - .brightness = zigui.State(f32).init(alloc, 0.7), - .volume = zigui.State(f32).init(alloc, 0.4), - .copies = zigui.State(i64).init(alloc, 1), - .appearance = zigui.State(i64).init(alloc, 0), - .name = zigui.TextFieldState.init(alloc), - }; - defer { - st.wifi.deinit(); - st.bluetooth.deinit(); - st.notifications.deinit(); - st.brightness.deinit(); - st.volume.deinit(); - st.copies.deinit(); - st.appearance.deinit(); - st.name.deinit(); - } - try app.run(alloc, AppState, &st, .{ .title = "zigui — Settings", .width = 460, .height = 620 }, body); -} diff --git a/examples/showcase/main.zig b/examples/showcase/main.zig index b5e2b0f..bde75da 100644 --- a/examples/showcase/main.zig +++ b/examples/showcase/main.zig @@ -1,36 +1,142 @@ -//! A grab-bag demo exercising the post-v0 features together: a -//! `NavigationSplitView` shell, a `TabView`, a `LazyVGrid`, a `.sheet` overlay, -//! a `Menu`, a frosted `Material` background, and an animated value driven by the -//! runtime animator. Build with `zig build showcase` (run with `run-showcase`). +//! The zigui showcase — a single window that exercises (almost) every public +//! component, styled for the macOS 26 "Liquid Glass" look. A frosted `Sidebar` +//! switches between panels (controls, text & icons, shapes & media, layout, +//! table, the multi-line editor, overlays, effects, and a navigation stack), and +//! the footer carries a **light/dark switcher** and an **accent theme switcher** +//! that recolor the whole window live. +//! +//! Build with `zig build showcase` (run with `zig build run-showcase`). +//! Headless: `showcase --screenshot [section]` renders one frame. const std = @import("std"); const zigui = @import("zigui"); const app = @import("zigui_app"); -const t = zigui.default_theme; +// libc file write for the headless `--screenshot` path (std.fs needs std.Io in 0.16). +const cstdio = @cImport({ + @cInclude("stdio.h"); + @cInclude("time.h"); // clock() for --bench (std.time.Timer needs std.Io in 0.16) +}); -const swatches = [_]zigui.Color{ - zigui.Color.fromRgb8(255, 99, 71), zigui.Color.fromRgb8(255, 165, 0), - zigui.Color.fromRgb8(60, 179, 113), zigui.Color.fromRgb8(70, 130, 180), - zigui.Color.fromRgb8(147, 112, 219), zigui.Color.fromRgb8(255, 105, 180), - zigui.Color.fromRgb8(0, 206, 209), zigui.Color.fromRgb8(154, 205, 50), +// ── Theme: appearance (light/dark) × accent ─────────────────────────────────── + +/// Accent palette for the theme switcher. Each recolors `accent` + `selection`. +const accents = [_]struct { name: []const u8, color: zigui.Color }{ + .{ .name = "Blue", .color = zigui.Color.fromRgb8(0, 122, 255) }, + .{ .name = "Purple", .color = zigui.Color.fromRgb8(147, 112, 219) }, + .{ .name = "Pink", .color = zigui.Color.fromRgb8(255, 55, 95) }, + .{ .name = "Green", .color = zigui.Color.fromRgb8(40, 180, 99) }, + .{ .name = "Orange", .color = zigui.Color.fromRgb8(255, 149, 0) }, +}; +/// One tappable accent color dot; the selected one gets a ring (macOS-style). +fn accentSwatch(st: *AppState, comptime i: usize) zigui.View { + const sel = st.accent.get() == @as(i64, @intCast(i)); + const c = accents[i].color; + const dot = if (sel) zigui.ZStack(.{ + zigui.Circle(t().colors.label).frame(22, 22), + zigui.Circle(t().colors.window_background).frame(18, 18), + zigui.Circle(c).frame(14, 14), + }).frame(22, 22) else zigui.ZStack(.{ + zigui.Circle(c).frame(16, 16), + }).frame(22, 22); + return dot.onTap(zigui.selectAction(st.accent.binding(), @intCast(i))); +} + +fn accentSwatches(st: *AppState) zigui.View { + return zigui.HStack(.{ + accentSwatch(st, 0), accentSwatch(st, 1), accentSwatch(st, 2), + accentSwatch(st, 3), accentSwatch(st, 4), zigui.Spacer(), + }).spacing(10).frameMaxWidth(); +} + +/// The live theme, recomputed by `themeProvider` from the app state each frame. +/// `g_app` lets the provider read the appearance/accent selections (the provider +/// runs before `body`, so every view built this frame sees the same theme). +var g_theme = zigui.default_theme; +var g_app: ?*AppState = null; + +/// The built-in theme families offered by the switcher, in display order. +const families = zigui.theme_registry.all; +/// Their display names, for the family switcher's radio list. +const family_names = blk: { + var names: [families.len][]const u8 = undefined; + for (families, 0..) |f, i| names[i] = f.displayName(); + break :blk names; }; +fn themeProvider() zigui.Theme { + const st = g_app orelse return g_theme; + // On the first frame (now that SDL is up), seed the appearance from the OS. + if (!st.os_synced) { + st.dark.set(app.systemTheme() == .dark); + st.os_synced = true; + } + const fi: usize = @intCast(std.math.clamp(st.theme_family.get(), 0, families.len - 1)); + const scheme: zigui.ColorScheme = if (st.dark.get()) .dark else .light; + var th = zigui.themeForScheme(families[fi], scheme); + // The accent switcher recolors the tint/selection across every theme. + const ai: usize = @intCast(std.math.clamp(st.accent.get(), 0, accents.len - 1)); + th.colors.accent = accents[ai].color; + th.colors.selection = accents[ai].color; + g_theme = th; + return th; +} + +/// Shorthand for the live theme inside view builders. +inline fn t() zigui.Theme { + return g_theme; +} + +// ── App state ───────────────────────────────────────────────────────────────── + const AppState = struct { - section: zigui.State(i64), // which sidebar item is selected - tab: zigui.State(i64), // selected tab on the Home page + // Navigation / appearance + section: zigui.State(i64), + dark: zigui.State(bool), + accent: zigui.State(i64), + /// Index into `families` — the active theme family (macOS, Win10, …). + theme_family: zigui.State(i64), + /// Set once the appearance has been seeded from the OS preference. + os_synced: bool, + nav: zigui.NavState, + + // Controls + toggle_a: zigui.State(bool), + toggle_b: zigui.State(bool), + slider: zigui.State(f32), + stepper: zigui.State(i64), + picker: zigui.State(i64), + segmented: zigui.State(i64), + radio: zigui.State(i64), + name: zigui.TextFieldState, + + // Layout + tab: zigui.State(i64), + + // Table + table_sel: zigui.State(i64), + table_scroll: zigui.ScrollState, + + // Effects / overlays + progress: zigui.State(f32), show_sheet: zigui.State(bool), + show_alert: zigui.State(bool), + show_popover: zigui.State(bool), menu_open: zigui.State(bool), - progress: zigui.State(f32), // animated value - fn gotoHome(self: *AppState) void { - self.section.set(0); - } - fn gotoGrid(self: *AppState) void { - self.section.set(1); + // Per-panel page scroll + the multi-line editor + page_scroll: zigui.ScrollState, + editor: zigui.TextFieldState, + editor_scroll: zigui.ScrollState, + + fn clearNav(self: *AppState) void { + while (self.nav.depth() > 0) self.nav.pop(); } - fn gotoAbout(self: *AppState) void { - self.section.set(2); + fn animate(self: *AppState) void { + if (app.animator()) |a| { + const target: f32 = if (self.progress.get() > 0.5) 0 else 1; + a.animateTo(&self.progress, target, 0.6, .ease_in_out) catch {}; + } } fn openSheet(self: *AppState) void { self.show_sheet.set(true); @@ -38,116 +144,633 @@ const AppState = struct { fn closeSheet(self: *AppState) void { self.show_sheet.set(false); } - fn animate(self: *AppState) void { - if (app.animator()) |a| { - const target: f32 = if (self.progress.get() > 0.5) 0 else 1; - a.animateTo(&self.progress, target, 0.6, .ease_in_out) catch {}; + fn openAlert(self: *AppState) void { + self.show_alert.set(true); + } + fn closeAlert(self: *AppState) void { + self.show_alert.set(false); + } + fn togglePopover(self: *AppState) void { + self.show_popover.set(!self.show_popover.get()); + } + fn noop(_: *AppState) void {} +}; + +// ── Static demo data ────────────────────────────────────────────────────────── + +const swatches = [_]zigui.Color{ + zigui.Color.fromRgb8(255, 99, 71), zigui.Color.fromRgb8(255, 165, 0), + zigui.Color.fromRgb8(60, 179, 113), zigui.Color.fromRgb8(70, 130, 180), + zigui.Color.fromRgb8(147, 112, 219), zigui.Color.fromRgb8(255, 105, 180), + zigui.Color.fromRgb8(0, 206, 209), zigui.Color.fromRgb8(154, 205, 50), +}; + +const icon_gallery = [_]zigui.IconName{ + .home, .search, .settings, .user, .users, .heart, + .star, .bell, .mail, .calendar, .clock, .folder, + .file, .image, .download, .upload, .trash, .edit, + .copy, .share, .lock, .unlock, .eye, .eye_off, + .play, .pause, .sun, .moon, .sparkles, .zap, + .cpu, .shield_check, .badge_check, .info, .check, .send, +}; + +// A 64×64 RGBA gradient built at comptime so the `Image` component has something +// to show without shipping an asset. +const demo_image = blk: { + @setEvalBranchQuota(200000); + var px: [64 * 64 * 4]u8 = undefined; + var y: usize = 0; + while (y < 64) : (y += 1) { + var x: usize = 0; + while (x < 64) : (x += 1) { + const i = (y * 64 + x) * 4; + px[i + 0] = @intCast(x * 4); + px[i + 1] = @intCast(y * 4); + px[i + 2] = @intCast(255 - x * 3); + px[i + 3] = 255; } } + break :blk px; }; -fn swatchCell(col: zigui.Color) zigui.View { - return zigui.RoundedRectangle(col, 8).frameHeight(48); +const scroll_labels = blk: { + var arr: [20][]const u8 = undefined; + for (0..20) |i| arr[i] = std.fmt.comptimePrint("Row {d}", .{i + 1}); + break :blk arr; +}; + +const table_columns = [_]zigui.TableColumn{ + .{ .title = "Name" }, + .{ .title = "Role" }, + .{ .title = "Age", .width = 64 }, +}; +const table_rows = [_][]const []const u8{ + &.{ "Ada Lovelace", "Mathematician", "36" }, + &.{ "Alan Turing", "Computer Scientist", "41" }, + &.{ "Grace Hopper", "Rear Admiral", "45" }, + &.{ "Katherine Johnson", "Mathematician", "52" }, + &.{ "Margaret Hamilton", "Engineer", "33" }, +}; + +// ── Shared building blocks ───────────────────────────────────────────────────── + +fn header(title: []const u8) zigui.View { + return zigui.HStack(.{ zigui.Text(title).font(.large_title), zigui.Spacer() }).frameMaxWidth(); } -fn overviewTab(st: *AppState) zigui.View { +/// A titled, rounded "liquid glass" card. +fn card(title: []const u8, content: zigui.View) zigui.View { return zigui.VStack(.{ - zigui.Text("A frosted card over a gradient:") - .foreground(t.colors.secondary_label) - .frameMaxWidth(), - zigui.ZStack(.{ - zigui.LinearGradient(t.colors.accent, t.colors.destructive, .{ .x = 0, .y = 0 }, .{ .x = 1, .y = 1 }) - .frameMaxWidth() - .frameHeight(130), - zigui.VStack(.{ - zigui.Text("Material").font(.headline), - zigui.ProgressView(st.progress.get()).frameWidth(160), - }).spacing(8).padding(16).backgroundMaterial(.regular).cornerRadius(12), - }), zigui.HStack(.{ - zigui.Button("Animate", zigui.actionCtx(AppState, st, AppState.animate)), - zigui.Button("Show sheet", zigui.actionCtx(AppState, st, AppState.openSheet)), - }), - }).spacing(12).frameMaxWidth(); + zigui.Text(title).font(.headline).foreground(t().colors.secondary_label), + zigui.Spacer(), + }).frameMaxWidth(), + content.frameMaxWidth(), + }).spacing(10) + .padding(14) + .background(t().colors.control_background) + .cornerRadius(t().metrics.corner_radius) + .border(t().colors.separator, t().metrics.hairline) + .frameMaxWidth(); +} + +/// A leading label with the control pushed to the trailing edge. +fn row(title: []const u8, control: zigui.View) zigui.View { + return zigui.HStack(.{ zigui.Text(title), zigui.Spacer(), control }).frameMaxWidth(); +} + +// ── Panels ────────────────────────────────────────────────────────────────── + +const picker_options = [_][]const u8{ "Small", "Medium", "Large" }; +const seg_options = [_][]const u8{ "Day", "Week", "Month" }; +const radio_options = [_][]const u8{ "Automatic", "On", "Off" }; + +fn controlsPanel(st: *AppState) zigui.View { + return zigui.VStack(.{ + header("Controls"), + card("Buttons", zigui.VStack(.{ + zigui.HStack(.{ + zigui.Button("Primary", zigui.actionCtx(AppState, st, AppState.animate)), + zigui.ButtonRoled("Delete", .destructive, zigui.actionCtx(AppState, st, AppState.noop)), + zigui.ButtonRoled("Plain", .plain, zigui.actionCtx(AppState, st, AppState.noop)), + zigui.Spacer(), + }).spacing(10), + zigui.HStack(.{ + zigui.Button("Disabled", zigui.actionCtx(AppState, st, AppState.noop)).disabled(true), + zigui.IconButton(.heart, 18, zigui.actionCtx(AppState, st, AppState.noop)), + zigui.IconButton(.trash, 18, zigui.actionCtx(AppState, st, AppState.noop)), + zigui.Spacer(), + }).spacing(10), + }).spacing(10)), + card("Toggles", zigui.VStack(.{ + row("Wi‑Fi", zigui.Toggle("", st.toggle_a.binding())), + zigui.Divider(), + row("Notifications", zigui.Toggle("", st.toggle_b.binding())), + }).spacing(8)), + card("Slider & Progress", zigui.VStack(.{ + zigui.Slider(st.slider.binding(), 0, 1).frameMaxWidth(), + zigui.ProgressView(st.slider.get()).frameMaxWidth(), + }).spacing(12)), + card("Stepper & Pickers", zigui.VStack(.{ + row("Quantity", zigui.Stepper("", st.stepper.binding(), 0, 10, 1)), + zigui.Divider(), + row("Size", zigui.Picker(st.picker.binding(), &picker_options).frameWidth(220)), + zigui.Divider(), + row("Range", zigui.Picker(st.segmented.binding(), &seg_options).frameWidth(220)), + }).spacing(8)), + card("Radio group", zigui.RadioGroup(st.radio.binding(), &radio_options)), + card("Text field", zigui.VStack(.{ + zigui.TextField("Type your name…", &st.name).frameHeight(36).cornerRadius(8).frameMaxWidth(), + zigui.Text("Right-click for Cut / Copy / Paste / Select All.") + .font(.footnote).foreground(t().colors.secondary_label), + }).spacing(8)), + }).spacing(16); +} + +fn iconCell(icon: zigui.IconName) zigui.View { + return zigui.Icon(icon, 22, t().colors.label).frameHeight(34).frameMaxWidth(); +} + +fn textPanel(_: *AppState) zigui.View { + return zigui.VStack(.{ + header("Text & Icons"), + card("Type scale", zigui.VStack(.{ + leading(zigui.Text("Large Title").font(.large_title)), + leading(zigui.Text("Title").font(.title)), + leading(zigui.Text("Headline").font(.headline)), + leading(zigui.Text("Body — the quick brown fox.").font(.body)), + leading(zigui.Text("Footnote").font(.footnote).foreground(t().colors.secondary_label)), + }).spacing(6)), + card("Wrapped paragraph", zigui.WrappedText( + "WrappedText word-wraps to the width it is given, growing taller as the " ++ + "column narrows. Reach for it for paragraphs, descriptions, captions.", + ).foreground(t().colors.label)), + card("Label", leading(zigui.Label("Tagged item", t().colors.accent))), + card("Icon gallery", zigui.LazyVGrid(6, 10, &icon_gallery, iconCell).frameMaxWidth()), + }).spacing(16); +} + +/// Left-align a view inside a full-width row (frame layout centers by default). +fn leading(v: zigui.View) zigui.View { + return zigui.HStack(.{ v, zigui.Spacer() }).frameMaxWidth(); } -fn statsTab(st: *AppState) zigui.View { +fn swatchCell(col: zigui.Color) zigui.View { + return zigui.RoundedRectangle(col, 8).frameHeight(44); +} + +fn shapesPanel(_: *AppState) zigui.View { return zigui.VStack(.{ - zigui.Text("Animated value").font(.headline), - zigui.ProgressView(st.progress.get()).frameMaxWidth(), - zigui.Slider(st.progress.binding(), 0, 1).frameMaxWidth(), + header("Shapes & Media"), + card("Primitives", zigui.HStack(.{ + zigui.Rectangle(swatches[3]).frame(56, 56), + zigui.RoundedRectangle(swatches[2], 12).frame(56, 56), + zigui.Circle(swatches[0]).frame(56, 56), + zigui.Capsule(swatches[4]).frame(80, 36), + zigui.Ellipse(swatches[6]).frame(80, 56), + zigui.Spacer(), + }).spacing(12)), + card("LinearGradient", zigui.LinearGradient( + swatches[5], + swatches[1], + .{ .x = 0, .y = 0 }, + .{ .x = 1, .y = 1 }, + ).frameMaxWidth().frameHeight(90).cornerRadius(12)), + card("Dividers", zigui.VStack(.{ + leading(zigui.Text("above")), + zigui.Divider(), + zigui.HStack(.{ + zigui.Text("left"), + zigui.VDivider().frameHeight(20), + zigui.Text("right"), + zigui.Spacer(), + }).spacing(12), + }).spacing(8)), + card("Image (generated)", zigui.HStack(.{ + zigui.Image(.{ .width = 64, .height = 64, .pixels = &demo_image }).frame(64, 64), + zigui.WrappedText("A 64×64 RGBA gradient built at comptime and drawn via the Image component."), + }).spacing(12)), + card("Material (frosted over gradient)", zigui.ZStack(.{ + zigui.LinearGradient(t().colors.accent, swatches[5], .{ .x = 0, .y = 0 }, .{ .x = 1, .y = 1 }) + .frameMaxWidth().frameHeight(120), + zigui.Text("Regular material").font(.headline) + .padding(16).backgroundMaterial(.regular).cornerRadius(12), + })), + }).spacing(16); +} + +fn scrollRow(label: []const u8) zigui.View { + return zigui.HStack(.{ zigui.Text(label), zigui.Spacer() }).frameMaxWidth(); +} + +fn listRow(icon: zigui.IconName, label: []const u8) zigui.View { + return zigui.HStack(.{ + zigui.Icon(icon, 16, t().colors.accent), + zigui.Text(label), + zigui.Spacer(), + zigui.Icon(.chevron_right, 14, t().colors.tertiary_label), }).spacing(10).padding(8).frameMaxWidth(); } -fn homeDetail(st: *AppState) zigui.View { +fn layoutPanel(st: *AppState) zigui.View { const tabs = [_]zigui.Tab{ - .{ .label = "Overview", .content = overviewTab(st) }, - .{ .label = "Stats", .content = statsTab(st) }, + .{ .label = "Scroll", .content = zigui.ScrollView(zigui.VStack(.{ + zigui.ForEach(&scroll_labels, scrollRow), + }).spacing(6).padding(8)) }, + .{ .label = "Info", .content = zigui.WrappedText( + "TabView reuses Picker for its bar; the body switches on the bound " ++ + "selection. The first tab nests a ScrollView with 20 rows.", + ).padding(12) }, }; return zigui.VStack(.{ - zigui.HStack(.{ - zigui.Text("Home").font(.title), + header("Layout"), + card("HStack · Spacer · ZStack", zigui.VStack(.{ + zigui.HStack(.{ zigui.Text("leading"), zigui.Spacer(), zigui.Text("trailing") }).frameMaxWidth(), + zigui.ZStack(.{ + zigui.RoundedRectangle(swatches[7], 10).frameMaxWidth().frameHeight(60), + zigui.Text("ZStack overlay").foreground(zigui.Color.white).font(.headline), + }), + }).spacing(10)), + card("LazyVGrid", zigui.LazyVGrid(4, 8, &swatches, swatchCell).frameMaxWidth()), + card("LazyHGrid", zigui.LazyHGrid(2, 8, &swatches, swatchCell).frameHeight(96)), + card("List", zigui.VStack(.{ + listRow(.user, "Profile"), + zigui.Divider(), + listRow(.bell, "Notifications"), + zigui.Divider(), + listRow(.lock, "Privacy"), + }).spacing(0)), + card("TabView + ScrollView", zigui.TabView(st.tab.binding(), &tabs).frameMaxWidth().frameHeight(170)), + }).spacing(16); +} + +fn tablePanel(st: *AppState) zigui.View { + return zigui.VStack(.{ + header("Table"), + zigui.Text("A multi-column table with a header and selectable rows:") + .foreground(t().colors.secondary_label).frameMaxWidth(), + zigui.Table(&table_columns, &table_rows, st.table_sel.binding()) + .background(t().colors.control_background) + .cornerRadius(t().metrics.corner_radius) + .border(t().colors.separator, t().metrics.hairline) + .frameMaxWidth() + .frameMaxHeight(), + }).spacing(12).padding(20).frameMaxWidth().frameMaxHeight(); +} + +fn editorPanel(st: *AppState) zigui.View { + return zigui.VStack(.{ + header("Text editor"), + zigui.Text("Multi-line editing with selection, line numbers, and mouse drag. Click in and type.") + .font(.footnote).foreground(t().colors.secondary_label).frameMaxWidth(), + zigui.TextEditor(&st.editor, &st.editor_scroll, true).frameMaxWidth().frameMaxHeight(), + }).spacing(12).padding(20).frameMaxWidth().frameMaxHeight(); +} + +fn overlaysPanel(st: *AppState) zigui.View { + return zigui.VStack(.{ + header("Overlays"), + card("Modal presentations", zigui.HStack(.{ + zigui.Button("Show sheet", zigui.actionCtx(AppState, st, AppState.openSheet)), + zigui.Button("Show alert", zigui.actionCtx(AppState, st, AppState.openAlert)), zigui.Spacer(), - zigui.Menu("Options", &st.menu_open, .{ + }).spacing(10)), + card("Menu", zigui.HStack(.{ + zigui.Menu("Options ▾", &st.menu_open, .{ zigui.Button("Animate", zigui.actionCtx(AppState, st, AppState.animate)), + zigui.Button("Show sheet", zigui.actionCtx(AppState, st, AppState.openSheet)), }), - }).frameMaxWidth(), - zigui.TabView(st.tab.binding(), &tabs).frameMaxWidth(), - }).spacing(12).padding(16).frameMaxWidth(); + zigui.Spacer(), + })), + card("Popover", zigui.HStack(.{ + zigui.Button("Toggle popover", zigui.actionCtx(AppState, st, AppState.togglePopover)) + .popover(st.show_popover.binding(), zigui.VStack(.{ + zigui.Text("Popover").font(.headline), + zigui.WrappedText("Anchored near its trigger; tap outside to dismiss."), + }).spacing(8).padding(14).frameWidth(220)), + zigui.Spacer(), + })), + }).spacing(16); } -fn detail(st: *AppState) zigui.View { - return switch (st.section.get()) { - 1 => zigui.VStack(.{ - zigui.Text("Swatches").font(.title).frameMaxWidth(), - zigui.LazyVGrid(4, 8, &swatches, swatchCell).frameMaxWidth(), +fn effectsPanel(st: *AppState) zigui.View { + return zigui.VStack(.{ + header("Effects"), + card("Animation", zigui.VStack(.{ + zigui.ProgressView(st.progress.get()).frameMaxWidth(), + zigui.HStack(.{ + zigui.Button("Animate value", zigui.actionCtx(AppState, st, AppState.animate)), + zigui.Spacer(), + }), + }).spacing(10)), + card("Opacity · border · corner radius", zigui.HStack(.{ + zigui.RoundedRectangle(swatches[0], 10).frame(64, 64).opacity(0.4), + zigui.RoundedRectangle(swatches[2], 10).frame(64, 64).border(t().colors.label, 2), + zigui.RoundedRectangle(swatches[3], 24).frame(64, 64), zigui.Spacer(), - }).spacing(12).padding(16).frameMaxWidth(), - 2 => zigui.VStack(.{ - zigui.Text("About").font(.title).frameMaxWidth(), - zigui.Text("A SwiftUI-like UI library in pure Zig.") - .foreground(t.colors.secondary_label) - .frameMaxWidth(), + }).spacing(12)), + }).spacing(16); +} + +fn navPanel(st: *AppState) zigui.View { + if (st.nav.top()) |route| { + return zigui.VStack(.{ + leading(zigui.NavBackButton("‹ Back", &st.nav)), + header("Detail"), + zigui.WrappedText(zigui.view.fmt( + "You pushed route {d}. NavState holds the route stack; NavBackButton pops it.", + .{route}, + )), zigui.Spacer(), - }).spacing(12).padding(16).frameMaxWidth(), - else => homeDetail(st), + }).spacing(12).padding(20).frameMaxWidth().frameMaxHeight(); + } + return zigui.VStack(.{ + header("Navigation"), + zigui.Text("Tap a row to push a detail view onto the stack.") + .foreground(t().colors.secondary_label).frameMaxWidth(), + card("Stack", zigui.VStack(.{ + zigui.NavigationLink("Inbox", 1, &st.nav).frameMaxWidth(), + zigui.NavigationLink("Archive", 2, &st.nav).frameMaxWidth(), + zigui.NavigationLink("Trash", 3, &st.nav).frameMaxWidth(), + }).spacing(6)), + zigui.Spacer(), + }).spacing(16).frameMaxHeight(); +} + +// ── Shell ───────────────────────────────────────────────────────────────────── + +const sidebar_items = [_]zigui.SidebarItem{ + .{ .label = "Controls", .icon = .settings }, + .{ .label = "Text & Icons", .icon = .sparkles }, + .{ .label = "Shapes & Media", .icon = .image }, + .{ .label = "Layout", .icon = .boxes }, + .{ .label = "Table", .icon = .folder }, + .{ .label = "Editor", .icon = .edit }, + .{ .label = "Overlays", .icon = .menu }, + .{ .label = "Effects", .icon = .zap }, + .{ .label = "Navigation", .icon = .send }, +}; + +fn detailPanel(st: *AppState) zigui.View { + const panel = switch (st.section.get()) { + 1 => textPanel(st), + 2 => shapesPanel(st), + 3 => layoutPanel(st), + 4 => return tablePanel(st), // manages its own scroll + 5 => return editorPanel(st), // editor fills; no outer scroll + 6 => overlaysPanel(st), + 7 => effectsPanel(st), + 8 => return navPanel(st).padding(20), + else => controlsPanel(st), }; + // Every long panel scrolls as a whole so nothing is clipped off-screen. + return zigui.ScrollViewState(&st.page_scroll, panel.padding(20).frameMaxWidth()) + .frameMaxWidth() + .frameMaxHeight(); } -fn body(st: *AppState) zigui.View { - const sidebar = zigui.VStack(.{ - zigui.Text("zigui").font(.headline).padding(8), - zigui.Button("Home", zigui.actionCtx(AppState, st, AppState.gotoHome)).frameMaxWidth(), - zigui.Button("Grid", zigui.actionCtx(AppState, st, AppState.gotoGrid)).frameMaxWidth(), - zigui.Button("About", zigui.actionCtx(AppState, st, AppState.gotoAbout)).frameMaxWidth(), +fn sidebar(st: *AppState) zigui.View { + return zigui.VStack(.{ + zigui.HStack(.{ + zigui.Icon(.boxes, 20, t().colors.accent), + zigui.Text("zigui").font(.headline), + zigui.Spacer(), + }).spacing(8).paddingInsets(.{ .top = 8, .leading = 10, .bottom = 4, .trailing = 8 }), + zigui.Sidebar(&sidebar_items, st.section.binding()), zigui.Spacer(), - }).spacing(6).padding(10).frameMaxHeight(); + zigui.Divider(), + // Theme family + appearance + accent switchers. + zigui.VStack(.{ + leading(zigui.Text("Theme").font(.footnote).foreground(t().colors.secondary_label)), + zigui.RadioGroup(st.theme_family.binding(), &family_names), + }).spacing(6).padding(6), + row("Dark mode", zigui.Toggle("", st.dark.binding())).padding(6), + zigui.VStack(.{ + leading(zigui.Text("Accent").font(.footnote).foreground(t().colors.secondary_label)), + accentSwatches(st), + }).spacing(6).padding(6), + }).spacing(4).padding(8).frameMaxHeight().backgroundMaterial(.thin); +} + +fn body(st: *AppState) zigui.View { + // Switching away from the Navigation panel resets its push/pop stack. + if (st.section.get() != 8) st.clearNav(); const sheet_content = zigui.VStack(.{ zigui.Text("Sheet").font(.title), - zigui.Text("Tap outside to dismiss.").foreground(t.colors.secondary_label), - zigui.Button("Done", zigui.actionCtx(AppState, st, AppState.closeSheet)), - }).spacing(12).padding(20); + zigui.WrappedText("A bottom sheet drawn over a dimming scrim. Tap outside or Done to dismiss."), + zigui.HStack(.{ + zigui.Spacer(), + zigui.Button("Done", zigui.actionCtx(AppState, st, AppState.closeSheet)), + }).frameMaxWidth(), + }).spacing(12).padding(20).frameWidth(340); + + const alert_content = zigui.VStack(.{ + zigui.Icon(.info, 28, t().colors.accent), + zigui.Text("Heads up").font(.headline), + zigui.WrappedText("A centered alert. Long unbreakable tokens wrap too: " ++ + "/Users/david/models/unsloth/FLUX.2-klein-4B-GGUF.safetensors"), + zigui.Button("OK", zigui.actionCtx(AppState, st, AppState.closeAlert)), + }).spacing(12).padding(20).frameWidth(300); - return zigui.NavigationSplitView(sidebar, detail(st), t.colors.secondary_background) - .sheet(st.show_sheet.binding(), sheet_content); + return zigui.NavigationSplitView(sidebar(st), detailPanel(st), t().colors.secondary_background) + .sheet(st.show_sheet.binding(), sheet_content) + .alert(st.show_alert.binding(), alert_content); } -pub fn main() !void { +// ── Headless screenshot (no window) ─────────────────────────────────────────── + +fn writeBmp(path: [:0]const u8, fb: *const zigui.Framebuffer) void { + const w = fb.width; + const h = fb.height; + const stride = w * 3 + (4 - (w * 3) % 4) % 4; + const img_size = stride * h; + const f = cstdio.fopen(path.ptr, "wb") orelse return; + defer _ = cstdio.fclose(f); + var hdr = [_]u8{0} ** 54; + hdr[0] = 'B'; + hdr[1] = 'M'; + std.mem.writeInt(u32, hdr[2..6], @intCast(54 + img_size), .little); + std.mem.writeInt(u32, hdr[10..14], 54, .little); + std.mem.writeInt(u32, hdr[14..18], 40, .little); + std.mem.writeInt(i32, hdr[18..22], @intCast(w), .little); + std.mem.writeInt(i32, hdr[22..26], @intCast(h), .little); + std.mem.writeInt(u16, hdr[26..28], 1, .little); + std.mem.writeInt(u16, hdr[28..30], 24, .little); + std.mem.writeInt(u32, hdr[34..38], @intCast(img_size), .little); + _ = cstdio.fwrite(&hdr, 1, 54, f); + const scanline = std.heap.page_allocator.alloc(u8, stride) catch return; + defer std.heap.page_allocator.free(scanline); + @memset(scanline, 0); + var y: u32 = h; + while (y > 0) { + y -= 1; // BMP rows are bottom-up + var x: u32 = 0; + while (x < w) : (x += 1) { + const px = fb.at(x, y).toRgba8(); + scanline[x * 3 + 0] = px.b; + scanline[x * 3 + 1] = px.g; + scanline[x * 3 + 2] = px.r; + } + _ = cstdio.fwrite(scanline.ptr, 1, stride, f); + } +} + +fn screenshot(gpa: std.mem.Allocator, st: *AppState, out: [:0]const u8, bench: usize) !void { + const w: u32 = 900; + const h: u32 = 640; + const theme = themeProvider(); + var font = zigui.Font.default(); + var cache = zigui.GlyphCache.init(gpa, &font.face); + defer cache.deinit(); + var icon_font = zigui.Font.icons(); + var icon_cache = zigui.GlyphCache.init(gpa, &icon_font.face); + defer icon_cache.deinit(); + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + + zigui.setThemeTokens(theme); + zigui.beginBuild(arena.allocator()); + const root = body(st); + zigui.endBuild(); + + var hits: std.ArrayList(zigui.HitRegion) = .empty; + var overlays: std.ArrayList(zigui.OverlayReq) = .empty; + var ctx = zigui.Context.init(theme, &cache, arena.allocator(), &hits); + ctx.icon_cache = &icon_cache; + ctx.overlays = &overlays; // so sheets/alerts/popovers are drawn headlessly too + var canvas = zigui.Canvas.init(arena.allocator()); + const full = zigui.Rect{ .x = 0, .y = 0, .width = @floatFromInt(w), .height = @floatFromInt(h) }; + try canvas.fillRect(full, theme.colors.window_background); + try zigui.render(&ctx, root, full, &canvas); + + var fb = try zigui.Framebuffer.init(gpa, w, h); + defer fb.deinit(); + fb.clear(theme.colors.window_background); + try zigui.raster.render(gpa, &fb, canvas.commands.items); + writeBmp(out, &fb); + std.debug.print("wrote {s} ({d}x{d})\n", .{ out, w, h }); + + // `--bench N`: re-rasterize the same frame N times and report ms/frame, + // for iterating on rasterizer performance without a window. + if (bench > 0) { + const t0 = cstdio.clock(); + var i: usize = 0; + while (i < bench) : (i += 1) { + fb.clear(theme.colors.window_background); + try zigui.raster.render(gpa, &fb, canvas.commands.items); + } + const elapsed: f64 = @as(f64, @floatFromInt(cstdio.clock() - t0)) / @as(f64, @floatFromInt(cstdio.CLOCKS_PER_SEC)); + const ms = elapsed * 1000.0 / @as(f64, @floatFromInt(bench)); + std.debug.print("raster: {d:.2} ms/frame over {d} frames ({d} commands)\n", .{ ms, bench, canvas.commands.items.len }); + + // And the f32 -> RGBA8 conversion that runs on every present. + const rgba = try gpa.alloc(u8, @as(usize, w) * h * 4); + defer gpa.free(rgba); + const t1 = cstdio.clock(); + var j: usize = 0; + while (j < bench) : (j += 1) fb.toRgba8(rgba); + const conv: f64 = @as(f64, @floatFromInt(cstdio.clock() - t1)) / @as(f64, @floatFromInt(cstdio.CLOCKS_PER_SEC)); + std.debug.print("toRgba8: {d:.2} ms/frame\n", .{conv * 1000.0 / @as(f64, @floatFromInt(bench))}); + } +} + +pub fn main(init: std.process.Init.Minimal) !void { const alloc = std.heap.page_allocator; var st = AppState{ .section = zigui.State(i64).init(alloc, 0), + .dark = zigui.State(bool).init(alloc, false), + .accent = zigui.State(i64).init(alloc, 0), + .theme_family = zigui.State(i64).init(alloc, 0), + .os_synced = false, + .nav = zigui.NavState.init(alloc), + .toggle_a = zigui.State(bool).init(alloc, true), + .toggle_b = zigui.State(bool).init(alloc, false), + .slider = zigui.State(f32).init(alloc, 0.5), + .stepper = zigui.State(i64).init(alloc, 2), + .picker = zigui.State(i64).init(alloc, 1), + .segmented = zigui.State(i64).init(alloc, 0), + .radio = zigui.State(i64).init(alloc, 0), + .name = zigui.TextFieldState.init(alloc), .tab = zigui.State(i64).init(alloc, 0), + .table_sel = zigui.State(i64).init(alloc, 0), + .table_scroll = .{}, + .progress = zigui.State(f32).init(alloc, 0.3), .show_sheet = zigui.State(bool).init(alloc, false), + .show_alert = zigui.State(bool).init(alloc, false), + .show_popover = zigui.State(bool).init(alloc, false), .menu_open = zigui.State(bool).init(alloc, false), - .progress = zigui.State(f32).init(alloc, 0.3), + .page_scroll = .{}, + .editor = zigui.TextFieldState.init(alloc), + .editor_scroll = .{}, }; defer { st.section.deinit(); + st.dark.deinit(); + st.accent.deinit(); + st.theme_family.deinit(); + st.nav.deinit(); + st.toggle_a.deinit(); + st.toggle_b.deinit(); + st.slider.deinit(); + st.stepper.deinit(); + st.picker.deinit(); + st.segmented.deinit(); + st.radio.deinit(); + st.name.deinit(); st.tab.deinit(); + st.table_sel.deinit(); + st.progress.deinit(); st.show_sheet.deinit(); + st.show_alert.deinit(); + st.show_popover.deinit(); st.menu_open.deinit(); - st.progress.deinit(); + st.editor.deinit(); + } + st.editor.setText( + "// zigui TextEditor\n" ++ + "fn greet(name: []const u8) void {\n" ++ + " std.debug.print(\"Hello, {s}!\\n\", .{name});\n" ++ + "}\n\n" ++ + "Select with the mouse, use arrows + Shift,\n" ++ + "and scroll with the wheel.\n", + ) catch {}; + g_app = &st; + app.setThemeProvider(themeProvider); + + // `--screenshot [section]` renders one frame headlessly (no window). + var shot: ?[:0]const u8 = null; + var section: i64 = 0; + var bench: usize = 0; + var it = try std.process.Args.iterateAllocator(init.args, alloc); + defer it.deinit(); + _ = it.next(); // argv[0] + while (it.next()) |a| { + if (std.mem.eql(u8, a, "--screenshot")) { + shot = it.next(); + } else if (std.mem.eql(u8, a, "--dark")) { + st.dark.set(true); + } else if (std.mem.eql(u8, a, "--theme")) { + if (it.next()) |n| st.theme_family.set(std.fmt.parseInt(i64, n, 10) catch 0); + } else if (std.mem.eql(u8, a, "--accent")) { + if (it.next()) |n| st.accent.set(std.fmt.parseInt(i64, n, 10) catch 0); + } else if (std.mem.eql(u8, a, "--bench")) { + if (it.next()) |n| bench = std.fmt.parseInt(usize, n, 10) catch 0; + } else if (std.mem.eql(u8, a, "--sheet")) { + st.show_sheet.set(true); + } else if (std.mem.eql(u8, a, "--alert")) { + st.show_alert.set(true); + } else { + section = std.fmt.parseInt(i64, a, 10) catch section; + } } - try app.run(alloc, AppState, &st, .{ .title = "zigui — Showcase", .width = 760, .height = 520 }, body); + if (shot) |out| { + st.section.set(section); + // Headless: honor the explicit --dark/--theme flags instead of probing + // the OS (SDL isn't initialized on the screenshot path). + st.os_synced = true; + return screenshot(alloc, &st, out, bench); + } + + try app.run(alloc, AppState, &st, .{ .title = "zigui — Showcase", .width = 980, .height = 660 }, body); } diff --git a/src/app.zig b/src/app.zig index 16f5473..c9492e7 100644 --- a/src/app.zig +++ b/src/app.zig @@ -51,6 +51,16 @@ pub fn quit() void { if (g_running) |r| r.* = false; } +/// Whether a window-close request hides to the tray (true) or quits (false). +/// Mirrors `Config.hide_on_close`, but can be toggled at runtime from a tray +/// checkbox. +pub fn hideOnClose() bool { + return g_hide_on_close; +} +pub fn setHideOnClose(v: bool) void { + g_hide_on_close = v; +} + /// The running app's animator, exposed so view callbacks can start animations /// (e.g. `app.animator().?.animateTo(&state, 1, 0.3, .ease_in_out)`). Set for the /// duration of `run`. Mirrors the focused-field thread-local pattern in `view`. @@ -68,6 +78,48 @@ pub fn setBusyCheck(f: ?*const fn () bool) void { g_busy_fn = f; } +/// An optional theme provider, queried once per frame so the app can switch +/// light/dark (or any theme) live without restarting. When null the loop uses +/// the static `Config.theme`. Set by examples via `setThemeProvider`. +var g_theme_fn: ?*const fn () zigui.Theme = null; +pub fn setThemeProvider(f: ?*const fn () zigui.Theme) void { + g_theme_fn = f; +} + +/// An optional per-frame hook, invoked once each main-loop iteration (before the +/// frame is drawn). Use it to push app state into retained OS surfaces that live +/// outside the view tree — e.g. refreshing a system-tray menu's labels/icon. +/// Runs on the main thread, so it is safe to call SDL tray setters from here. +var g_frame_fn: ?*const fn () void = null; +pub fn setFrameHook(f: ?*const fn () void) void { + g_frame_fn = f; +} + +/// The OS-level light/dark preference, reported by SDL. Cross-platform: on +/// macOS it reads `AppleInterfaceStyle`, on Windows the `AppsUseLightTheme` +/// registry value, and on Linux the XDG `org.freedesktop.appearance` portal. +/// `.unknown` when the platform can't report one — callers should fall back. +/// +/// An OS theme change emits `SDL_EVENT_SYSTEM_THEME_CHANGED`, which wakes the +/// run loop (every event triggers a rebuild on the next iteration), so an app +/// that re-queries this each frame follows the OS live without extra wiring. +pub const SystemTheme = enum { unknown, light, dark }; +pub fn systemTheme() SystemTheme { + return switch (c.SDL_GetSystemTheme()) { + c.SDL_SYSTEM_THEME_LIGHT => .light, + c.SDL_SYSTEM_THEME_DARK => .dark, + else => .unknown, + }; +} + +/// The OS color scheme as a `zigui.ColorScheme`, ready to hand to +/// `zigui.themeForScheme(family, app.colorScheme())`. Falls back to `.light` +/// when the platform can't report a preference. Re-query it each frame (or in a +/// theme provider) to follow the OS live. +pub fn colorScheme() zigui.ColorScheme { + return if (systemTheme() == .dark) .dark else .light; +} + /// An optional application key handler, called first on every key-down with the /// SDL keycode (`SDLK_*`) and modifier mask (`SDL_KMOD_*`, both reachable via /// `app.c`). Return true to mark the key consumed — the loop then skips its @@ -78,6 +130,83 @@ pub fn setKeyHandler(f: ?*const fn (key: u32, mods: u16) bool) void { g_key_fn = f; } +/// When true, the loop prints rolling frame-time stats (last/avg/max ms + the +/// equivalent fps) to stderr ~once per second. Off by default; also enabled at +/// startup if the `ZIGUI_FRAME_LOG` environment variable is set. Useful for +/// profiling the software rasterizer without a GPU profiler. +/// The cursor's last known position in logical points, or null when it has left +/// the window. Fed into `Context.hover_point` each frame for hover highlights. +var g_hover_point: ?zigui.geometry.Point = null; + +var g_frame_log: bool = false; +pub fn setFrameLog(on: bool) void { + g_frame_log = on; +} + +/// Rolling per-second render-time accumulator (see `g_frame_log`). +const FrameStats = struct { + count: u32 = 0, + sum_ns: u64 = 0, + max_ns: u64 = 0, + last_report_ms: u64 = 0, + + fn record(self: *FrameStats, ns: u64, now_ms: u64) void { + self.count += 1; + self.sum_ns += ns; + if (ns > self.max_ns) self.max_ns = ns; + if (self.last_report_ms == 0) self.last_report_ms = now_ms; + if (now_ms - self.last_report_ms >= 1000 and self.count > 0) { + const last_ms = @as(f64, @floatFromInt(ns)) / 1.0e6; + const avg_ms = @as(f64, @floatFromInt(self.sum_ns)) / @as(f64, @floatFromInt(self.count)) / 1.0e6; + const max_ms = @as(f64, @floatFromInt(self.max_ns)) / 1.0e6; + std.debug.print("[zigui] render: last {d:.2}ms avg {d:.2}ms max {d:.2}ms ({d} frames, {d:.0} fps-equiv)\n", .{ + last_ms, avg_ms, max_ms, self.count, if (avg_ms > 0) 1000.0 / avg_ms else 0, + }); + self.count = 0; + self.sum_ns = 0; + self.max_ns = 0; + self.last_report_ms = now_ms; + } + } +}; + +// --------------------------------------------------------------------------- +// Clipboard (SDL3's cross-platform clipboard: NSPasteboard / Win32 / X11/Wayland). +// --------------------------------------------------------------------------- + +/// Put UTF-8 `text` on the system clipboard. `text` need not be NUL-terminated. +pub fn setClipboardText(allocator: std.mem.Allocator, text: []const u8) void { + const z = allocator.dupeZ(u8, text) catch return; + defer allocator.free(z); + _ = c.SDL_SetClipboardText(z.ptr); +} + +/// Read UTF-8 text from the system clipboard. Returns an allocator-owned copy the +/// caller must free, or null when the clipboard is empty. (SDL returns an empty +/// string rather than null on failure; we normalize that to null.) +pub fn getClipboardText(allocator: std.mem.Allocator) ?[]u8 { + const p = c.SDL_GetClipboardText(); + defer c.SDL_free(p); + if (p == null) return null; + const s = std.mem.span(p); + if (s.len == 0) return null; + return allocator.dupe(u8, s) catch null; +} + +/// Copy the focused field's current selection to the clipboard (no-op if empty). +fn clipboardCopy(f: *zigui.TextFieldState) void { + const sel = f.selectedText(); + if (sel.len == 0) return; + setClipboardText(f.allocator, sel); +} + +/// Insert clipboard text at the focused field's caret, replacing any selection. +fn clipboardPaste(f: *zigui.TextFieldState) void { + const s = getClipboardText(f.allocator) orelse return; + defer f.allocator.free(s); + f.insert(s) catch {}; +} + // --------------------------------------------------------------------------- // System tray / menu bar (cross-platform via SDL3's native tray API: // NSStatusItem on macOS, Shell_NotifyIcon on Windows, StatusNotifierItem on @@ -112,23 +241,49 @@ fn makeSurface(rgba: []const u8, w: c_int, h: c_int) ?*c.SDL_Surface { return surf; } +/// A handle to a single tray menu entry, returned by the `add*` builders so its +/// label / enabled / checked state can be mutated later (the menu is retained +/// and OS-drawn, not rebuilt per frame). SDL copies the label string, so a +/// transient buffer is fine. Call these on the main thread (e.g. a frame hook). +pub const TrayEntry = struct { + entry: *c.SDL_TrayEntry, + + pub fn setLabel(self: TrayEntry, label: [:0]const u8) void { + c.SDL_SetTrayEntryLabel(self.entry, label.ptr); + } + pub fn setEnabled(self: TrayEntry, enabled: bool) void { + c.SDL_SetTrayEntryEnabled(self.entry, enabled); + } + pub fn setChecked(self: TrayEntry, checked: bool) void { + c.SDL_SetTrayEntryChecked(self.entry, checked); + } +}; + /// A submenu / menu handle. Entries are appended in order; a button entry fires /// its `zigui.Callback` when clicked. pub const TrayMenu = struct { owner: *Tray, menu: *c.SDL_TrayMenu, - pub fn addItem(self: TrayMenu, label: [:0]const u8, cb: zigui.Callback) void { - const e = c.SDL_InsertTrayEntryAt(self.menu, -1, label.ptr, c.SDL_TRAYENTRY_BUTTON) orelse return; + pub fn addItem(self: TrayMenu, label: [:0]const u8, cb: zigui.Callback) ?TrayEntry { + const e = c.SDL_InsertTrayEntryAt(self.menu, -1, label.ptr, c.SDL_TRAYENTRY_BUTTON) orelse return null; self.owner.bind(e, cb); + return .{ .entry = e }; + } + /// A non-interactive label row (a disabled button), useful as a status line. + pub fn addLabel(self: TrayMenu, label: [:0]const u8) ?TrayEntry { + const e = c.SDL_InsertTrayEntryAt(self.menu, -1, label.ptr, c.SDL_TRAYENTRY_BUTTON) orelse return null; + c.SDL_SetTrayEntryEnabled(e, false); + return .{ .entry = e }; } /// A checkbox entry; `cb` fires on toggle (read the new state from the entry /// via your own model, or keep it in sync with `TrayEntry.setChecked`). - pub fn addCheckItem(self: TrayMenu, label: [:0]const u8, checked: bool, cb: zigui.Callback) void { + pub fn addCheckItem(self: TrayMenu, label: [:0]const u8, checked: bool, cb: zigui.Callback) ?TrayEntry { var flags: u32 = c.SDL_TRAYENTRY_CHECKBOX; if (checked) flags |= c.SDL_TRAYENTRY_CHECKED; - const e = c.SDL_InsertTrayEntryAt(self.menu, -1, label.ptr, flags) orelse return; + const e = c.SDL_InsertTrayEntryAt(self.menu, -1, label.ptr, flags) orelse return null; self.owner.bind(e, cb); + return .{ .entry = e }; } pub fn addSeparator(self: TrayMenu) void { _ = c.SDL_InsertTrayEntryAt(self.menu, -1, null, 0); @@ -238,9 +393,18 @@ pub fn run( _ = c.SDL_StartTextInput(window); var font = zigui.Font.default(); + // Bundled monochrome emoji font, wired as a fallback so codepoints Inter + // lacks (emoji, etc.) still render. Lives for the whole loop, so the + // `&emoji_font.face` pointer stays valid. + var emoji_font = zigui.Font.emoji(); + font.face.fallback = &emoji_font.face; var cache = zigui.GlyphCache.init(gpa, &font.face); defer cache.deinit(); + var icon_font = zigui.Font.icons(); + var icon_cache = zigui.GlyphCache.init(gpa, &icon_font.face); + defer icon_cache.deinit(); + var arena_state = std.heap.ArenaAllocator.init(gpa); defer arena_state.deinit(); @@ -257,6 +421,29 @@ pub fn run( var tex_h: c_int = 0; defer if (texture) |t| c.SDL_DestroyTexture(t); + // Persistent render buffers (reused across frames; freed at shutdown). + var fb: zigui.Framebuffer = .empty; + defer fb.deinit(); + var rgba: []u8 = &.{}; + defer if (rgba.len > 0) gpa.free(rgba); + const perf_freq = c.SDL_GetPerformanceFrequency(); + var stats: FrameStats = .{}; + // Opt-in frame profiling via env var (in addition to `setFrameLog`). + if (c.SDL_getenv("ZIGUI_FRAME_LOG") != null) g_frame_log = true; + + // Let the (platform-free) context menu copy/paste via the SDL clipboard. + zigui.setClipboardOps(.{ .copy = clipboardCopy, .paste = clipboardPaste }); + + // A dedicated arena + scratch region lists for redraws issued from the macOS + // live-resize event watch, so a watch-triggered frame never aliases the main + // loop's arena (which is mid-use when the watch fires). The lists are backed + // by `resize_arena` (their items hold arena-allocated callbacks), so they are + // never deinit'd with gpa — `resize_arena.deinit()` reclaims everything. + var resize_arena = std.heap.ArenaAllocator.init(gpa); + defer resize_arena.deinit(); + var resize_hits: std.ArrayList(zigui.HitRegion) = .empty; + var resize_scrolls: std.ArrayList(zigui.ScrollRegion) = .empty; + var running = true; // Expose the window/loop to tray callbacks and the close handler. g_window = window; @@ -267,68 +454,189 @@ pub fn run( g_running = null; } + // Everything one `drawFrame` needs, shared by the main loop and the resize + // watch (which run on the same thread, never concurrently). + const Frame = struct { + gpa: std.mem.Allocator, + window: *c.SDL_Window, + renderer: *c.SDL_Renderer, + cache: *zigui.GlyphCache, + icon_cache: *zigui.GlyphCache, + cfg: Config, + st: *AppState, + texture: *?*c.SDL_Texture, + tex_w: *c_int, + tex_h: *c_int, + // Persistent pixel buffers reused across frames (realloc only on resize), + // so steady-state frames allocate no per-frame framebuffer/RGBA scratch. + fb: *zigui.Framebuffer, + rgba: *[]u8, + perf_freq: u64, + stats: *FrameStats, + resize_arena: *std.heap.ArenaAllocator, + resize_hits: *std.ArrayList(zigui.HitRegion), + resize_scrolls: *std.ArrayList(zigui.ScrollRegion), + }; + + const Render = struct { + /// Build, rasterize and present one frame into `arena_state`, recording + /// hit/scroll regions into the given lists. Returns false (no present) if + /// the window is hidden or has a degenerate size. + fn drawFrame( + fr: *Frame, + frame_arena: *std.heap.ArenaAllocator, + hits: *std.ArrayList(zigui.HitRegion), + scrolls: *std.ArrayList(zigui.ScrollRegion), + ) bool { + if ((c.SDL_GetWindowFlags(fr.window) & c.SDL_WINDOW_HIDDEN) != 0) return false; + // Logical size (points) drives layout; pixel size drives the + // framebuffer so output is crisp on Retina. Their ratio is the scale. + var lw: c_int = 0; + var lh: c_int = 0; + _ = c.SDL_GetWindowSize(fr.window, &lw, &lh); + var pw: c_int = 0; + var ph: c_int = 0; + _ = c.SDL_GetWindowSizeInPixels(fr.window, &pw, &ph); + if (lw <= 0 or lh <= 0 or pw <= 0 or ph <= 0) return false; + const t0 = c.SDL_GetPerformanceCounter(); + const scale: f32 = @as(f32, @floatFromInt(pw)) / @as(f32, @floatFromInt(lw)); + const uw: u32 = @intCast(pw); + const uh: u32 = @intCast(ph); + + // Frame clock for time-based UI (auto-hiding scrollbars, etc.). + zigui.setFrameTime(c.SDL_GetTicks()); + + // Live theme: query the provider (if any) so light/dark switches take + // effect without a restart. Read it after `body` builds the tree so the + // framebuffer clear and Context agree with whatever the body painted. + _ = frame_arena.reset(.retain_capacity); + const arena = frame_arena.allocator(); + const theme = if (g_theme_fn) |f| f() else fr.cfg.theme; + // Publish the active theme's selection tints so composed constructors + // (Sidebar/Table/RadioGroup) pick up dark mode and custom accents. + zigui.setThemeTokens(theme); + zigui.beginBuild(arena); + const root = body(fr.st); + zigui.endBuild(); + + // Reset to empty (not clearRetainingCapacity): the previous buffer was + // arena-allocated and the reset above reclaimed it, so keeping the old + // pointer would alias freshly handed-out arena memory. + hits.* = .empty; + scrolls.* = .empty; + var overlays: std.ArrayList(zigui.OverlayReq) = .empty; + var ctx = zigui.Context.initFull(theme, fr.cache, arena, hits, &overlays, null); + ctx.scroll_regions = scrolls; + ctx.icon_cache = fr.icon_cache; + ctx.hover_point = g_hover_point; + + var canvas = zigui.Canvas.init(arena); + // No background fill command here: the framebuffer's `clear` below paints + // the window background with a fast @memset, so emitting a full-window + // fill_rrect would just re-rasterize every pixel through the SDF path. + zigui.renderScaled(&ctx, root, .{ .x = 0, .y = 0, .width = @floatFromInt(lw), .height = @floatFromInt(lh) }, scale, &canvas) catch {}; + + // Persistent framebuffer + RGBA buffer: reallocated only when the pixel + // size changes, so a steady-state frame does zero pixel-buffer allocation. + fr.fb.ensureSize(fr.gpa, uw, uh) catch return false; + fr.fb.clear(theme.colors.window_background); + zigui.raster.render(arena, fr.fb, canvas.commands.items) catch return false; + const need: usize = @as(usize, uw) * @as(usize, uh) * 4; + if (fr.rgba.*.len != need) { + if (fr.rgba.*.len > 0) fr.gpa.free(fr.rgba.*); + fr.rgba.* = fr.gpa.alloc(u8, need) catch return false; + } + fr.fb.toRgba8(fr.rgba.*); + + if (fr.texture.* == null or fr.tex_w.* != pw or fr.tex_h.* != ph) { + if (fr.texture.*) |t| c.SDL_DestroyTexture(t); + fr.texture.* = c.SDL_CreateTexture(fr.renderer, c.SDL_PIXELFORMAT_ABGR8888, c.SDL_TEXTUREACCESS_STREAMING, pw, ph); + fr.tex_w.* = pw; + fr.tex_h.* = ph; + } + _ = c.SDL_UpdateTexture(fr.texture.*, null, fr.rgba.*.ptr, @intCast(uw * 4)); + _ = c.SDL_RenderClear(fr.renderer); + _ = c.SDL_RenderTexture(fr.renderer, fr.texture.*, null, null); + _ = c.SDL_RenderPresent(fr.renderer); + + if (g_frame_log) { + const t1 = c.SDL_GetPerformanceCounter(); + const ns = (t1 -% t0) *% 1_000_000_000 / fr.perf_freq; + fr.stats.record(ns, c.SDL_GetTicks()); + } + return true; + } + + /// SDL event watch: fires synchronously while events are pumped, INCLUDING + /// during the macOS modal live-resize loop (when the main loop is blocked + /// in SDL_WaitEvent). Redraw on resize/expose so content reflows live + /// instead of the old texture being stretched until the mouse is released. + fn resizeWatch(userdata: ?*anyopaque, ev: [*c]c.SDL_Event) callconv(.c) bool { + const fr: *Frame = @ptrCast(@alignCast(userdata.?)); + switch (ev.*.type) { + c.SDL_EVENT_WINDOW_RESIZED, + c.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED, + c.SDL_EVENT_WINDOW_EXPOSED, + => _ = drawFrame(fr, fr.resize_arena, fr.resize_hits, fr.resize_scrolls), + else => {}, + } + return true; + } + }; + + var frame = Frame{ + .gpa = gpa, + .window = window, + .renderer = renderer, + .cache = &cache, + .icon_cache = &icon_cache, + .cfg = cfg, + .st = st, + .texture = &texture, + .tex_w = &tex_w, + .tex_h = &tex_h, + .fb = &fb, + .rgba = &rgba, + .perf_freq = perf_freq, + .stats = &stats, + .resize_arena = &resize_arena, + .resize_hits = &resize_hits, + .resize_scrolls = &resize_scrolls, + }; + _ = c.SDL_AddEventWatch(Render.resizeWatch, &frame); + defer c.SDL_RemoveEventWatch(Render.resizeWatch, &frame); + while (running) { + // Per-frame hook (e.g. refresh the system-tray menu). Runs whether or not + // the window is visible, so the tray tracks status even when hidden. + if (g_frame_fn) |f| f(); + // When hidden (e.g. closed-to-tray), don't render — just pump events so - // tray entries and a re-open still work, and block efficiently. + // tray entries and a re-open still work, and block efficiently. While a + // busy predicate is set (e.g. a generation in flight), wake on a ~60fps + // timeout so the frame hook keeps the tray status live. if ((c.SDL_GetWindowFlags(window) & c.SDL_WINDOW_HIDDEN) != 0) { const no_hits: []const zigui.HitRegion = &.{}; const no_scrolls: []const zigui.ScrollRegion = &.{}; + const busy = if (g_busy_fn) |f| f() else false; var ev: c.SDL_Event = undefined; - if (c.SDL_WaitEvent(&ev)) handleEvent(&ev, &running, no_hits, no_scrolls); + if (busy) { + if (c.SDL_WaitEventTimeout(&ev, 16)) handleEvent(&ev, &running, no_hits, no_scrolls); + } else { + if (c.SDL_WaitEvent(&ev)) handleEvent(&ev, &running, no_hits, no_scrolls); + } while (c.SDL_PollEvent(&ev)) handleEvent(&ev, &running, no_hits, no_scrolls); continue; } - // Logical size (points) drives layout; pixel size drives the framebuffer - // so output is crisp on Retina. Their ratio is the content scale. - var lw: c_int = 0; - var lh: c_int = 0; - _ = c.SDL_GetWindowSize(window, &lw, &lh); - var pw: c_int = 0; - var ph: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph); - if (lw <= 0 or lh <= 0 or pw <= 0 or ph <= 0) { - _ = waitOne(&running, st, cfg); - continue; - } - const scale: f32 = @as(f32, @floatFromInt(pw)) / @as(f32, @floatFromInt(lw)); - const uw: u32 = @intCast(pw); - const uh: u32 = @intCast(ph); - - // (Re)build the view tree for this frame. - _ = arena_state.reset(.retain_capacity); - const arena = arena_state.allocator(); - zigui.beginBuild(arena); - const root = body(st); - zigui.endBuild(); + // Fresh per-frame region lists (arena-backed via drawFrame); discarded at + // the next arena reset, exactly like the pre-refactor loop. var hits: std.ArrayList(zigui.HitRegion) = .empty; - var overlays: std.ArrayList(zigui.OverlayReq) = .empty; var scrolls: std.ArrayList(zigui.ScrollRegion) = .empty; - var ctx = zigui.Context.initFull(cfg.theme, &cache, arena, &hits, &overlays, null); - ctx.scroll_regions = &scrolls; - - var canvas = zigui.Canvas.init(arena); - // Background in device pixels (renderScaled only scales the view commands - // it appends, so the backdrop must already be in pixel space). - try canvas.fillRect(.{ .x = 0, .y = 0, .width = @floatFromInt(uw), .height = @floatFromInt(uh) }, cfg.theme.colors.window_background); - zigui.renderScaled(&ctx, root, .{ .x = 0, .y = 0, .width = @floatFromInt(lw), .height = @floatFromInt(lh) }, scale, &canvas) catch {}; - - var fb = try zigui.Framebuffer.init(arena, uw, uh); - fb.clear(cfg.theme.colors.window_background); - try zigui.raster.render(arena, &fb, canvas.commands.items); - const rgba = try fb.toRgba8Alloc(arena); - - // Upload to an SDL streaming texture (sized in device pixels) and present. - if (texture == null or tex_w != pw or tex_h != ph) { - if (texture) |t| c.SDL_DestroyTexture(t); - texture = c.SDL_CreateTexture(renderer, c.SDL_PIXELFORMAT_ABGR8888, c.SDL_TEXTUREACCESS_STREAMING, pw, ph); - tex_w = pw; - tex_h = ph; + if (!Render.drawFrame(&frame, &arena_state, &hits, &scrolls)) { + waitOne(&running, st, cfg); + continue; } - _ = c.SDL_UpdateTexture(texture, null, rgba.ptr, @intCast(uw * 4)); - _ = c.SDL_RenderClear(renderer); - _ = c.SDL_RenderTexture(renderer, texture, null, null); - _ = c.SDL_RenderPresent(renderer); // Event handling. Hit/scroll regions stay in logical points (renderScaled // leaves them unscaled) and SDL reports mouse coordinates in points too, @@ -336,7 +644,7 @@ pub fn run( // busy predicate is set (e.g. a streaming request in flight), wake on a // ~60fps timeout and rebuild each frame; otherwise block for input. var ev: c.SDL_Event = undefined; - const busy = anim.active() or (if (g_busy_fn) |f| f() else false); + const busy = anim.active() or zigui.scrollbarsAnimating(c.SDL_GetTicks()) or (if (g_busy_fn) |f| f() else false); if (busy) { if (c.SDL_WaitEventTimeout(&ev, 16)) handleEvent(&ev, &running, hits.items, scrolls.items); while (c.SDL_PollEvent(&ev)) handleEvent(&ev, &running, hits.items, scrolls.items); @@ -362,6 +670,9 @@ fn waitOne(running: *bool, st: anytype, cfg: Config) void { } fn handleEvent(ev: *c.SDL_Event, running: *bool, hits: []const zigui.HitRegion, scrolls: []const zigui.ScrollRegion) void { + // With frame logging on, also log which events wake the loop (each wake + // costs a full rebuild+raster) so redraw storms can be attributed. + if (g_frame_log) std.debug.print("[zigui] event 0x{x}\n", .{ev.type}); switch (ev.type) { c.SDL_EVENT_QUIT => running.* = false, // The close button: hide-to-tray when configured, else quit. (⌘Q still @@ -371,17 +682,41 @@ fn handleEvent(ev: *c.SDL_Event, running: *bool, hits: []const zigui.HitRegion, }, c.SDL_EVENT_MOUSE_BUTTON_DOWN => { const p = zigui.geometry.Point{ .x = ev.button.x, .y = ev.button.y }; - zigui.clearFocus(); // a click defocuses; dispatch may refocus a field - zigui.endDrag(); // reset any stale drag; dispatchTap re-arms over an editor - _ = zigui.dispatchTap(hits, p); + if (ev.button.button == c.SDL_BUTTON_RIGHT) { + // Right-click pops the text context menu when over an editable + // field; elsewhere it just dismisses any open menu. + if (zigui.fieldAt(hits, p)) |field| { + zigui.openContextMenu(field, p); + } else { + zigui.closeContextMenu(); + } + } else if (zigui.contextMenuOpen()) { + // A left-click while the menu is open routes to the menu (an item + // or the dismiss region) without disturbing focus. + _ = zigui.dispatchTap(hits, p); + } else { + zigui.clearFocus(); // a click defocuses; dispatch may refocus a field + zigui.endDrag(); // reset any stale drag; dispatchTap re-arms over an editor + // In a text field, double-click selects the word and triple-click + // selects all; any other target falls back to a normal tap. + if (ev.button.clicks >= 3 and zigui.dispatchTripleClick(hits, p)) {} else if (ev.button.clicks == 2 and zigui.dispatchDoubleClick(hits, p)) {} else { + _ = zigui.dispatchTap(hits, p); + } + } }, // Drag the mouse with the left button held to extend a text selection. c.SDL_EVENT_MOUSE_MOTION => { + const mp = zigui.geometry.Point{ .x = ev.motion.x, .y = ev.motion.y }; + g_hover_point = mp; // drives Modifiers.hover_fill highlights + // Track the hovered context-menu item (the loop redraws after this + // event, so the highlight follows the cursor). + if (zigui.contextMenuOpen()) zigui.hoverContextMenu(hits, mp); if ((ev.motion.state & c.SDL_BUTTON_LMASK) != 0) { - zigui.dispatchDrag(hits, .{ .x = ev.motion.x, .y = ev.motion.y }); + zigui.dispatchDrag(hits, mp); } }, c.SDL_EVENT_MOUSE_BUTTON_UP => zigui.endDrag(), + c.SDL_EVENT_WINDOW_MOUSE_LEAVE => g_hover_point = null, c.SDL_EVENT_MOUSE_WHEEL => { var mx: f32 = 0; var my: f32 = 0; @@ -400,7 +735,37 @@ fn handleEvent(ev: *c.SDL_Event, running: *bool, hits: []const zigui.HitRegion, if (g_key_fn) |kf| { if (kf(ev.key.key, @intCast(ev.key.mod))) return; } + // Escape closes an open context menu before anything else sees the key. + if (zigui.contextMenuOpen() and ev.key.key == c.SDLK_ESCAPE) { + zigui.closeContextMenu(); + return; + } if (zigui.focusedField()) |f| { + // Clipboard shortcuts (⌘ on macOS, Ctrl elsewhere) take priority + // over plain editing. With the command/ctrl modifier held SDL does + // not emit a matching TEXT_INPUT, so consuming the key here is safe. + if ((ev.key.mod & (c.SDL_KMOD_GUI | c.SDL_KMOD_CTRL)) != 0) { + switch (ev.key.key) { + c.SDLK_C => { + clipboardCopy(f); + return; + }, + c.SDLK_X => { + clipboardCopy(f); + _ = f.deleteSelection(); + return; + }, + c.SDLK_V => { + clipboardPaste(f); + return; + }, + c.SDLK_A => { + f.selectAll(); + return; + }, + else => {}, + } + } const shift = (ev.key.mod & c.SDL_KMOD_SHIFT) != 0; switch (ev.key.key) { c.SDLK_BACKSPACE => f.backspace(), diff --git a/src/components.zig b/src/components.zig index f1522a6..e09e537 100644 --- a/src/components.zig +++ b/src/components.zig @@ -25,6 +25,12 @@ pub const LazyVGrid = view.LazyVGrid; pub const LazyHGrid = view.LazyHGrid; pub const Tab = view.Tab; pub const TabView = view.TabView; +pub const Sidebar = view.Sidebar; +pub const SidebarItem = view.SidebarItem; +pub const RadioGroup = view.RadioGroup; +pub const Table = view.Table; +pub const TableColumn = view.TableColumn; +pub const selectAction = view.selectAction; pub const NavigationSplitView = view.NavigationSplitView; pub const NavigationLink = view.NavigationLink; pub const NavBackButton = view.NavBackButton; @@ -37,6 +43,9 @@ pub const Text = view.Text; pub const WrappedText = view.WrappedText; pub const Label = view.Label; pub const Image = view.Image; +pub const Icon = view.Icon; +pub const IconButton = view.IconButton; +pub const IconName = view.IconName; pub const TextField = view.TextField; pub const TextFieldState = view.TextFieldState; pub const TextEditor = view.TextEditor; diff --git a/src/components/collections.zig b/src/components/collections.zig new file mode 100644 index 0000000..38c4c8c --- /dev/null +++ b/src/components/collections.zig @@ -0,0 +1,168 @@ +//! macOS-style collection views: a source-list `Sidebar`, a `RadioGroup`, and a +//! multi-column `Table`. All pure composition over the view primitives; +//! selection reuses `selectAction`, and accent/hover tints come from the +//! build-time theme tokens (`view.buildTokens()`). + +const view = @import("../view/view.zig"); +const Color = @import("../render/color.zig").Color; +const icons = @import("../icons.zig"); + +const View = view.View; +const Binding = view.Binding; + +// --------------------------------------------------------------------------- +// Sidebar +// --------------------------------------------------------------------------- + +/// One row of a `Sidebar`: a title and an optional leading icon. Mirrors +/// SwiftUI's `Label(item, systemImage:)` rows in a `.sidebar`-styled `List`. +pub const SidebarItem = struct { label: []const u8, icon: ?icons.Icon = null }; + +/// A macOS 26 source-list sidebar: a vertical list of selectable rows with a +/// rounded "liquid glass" selection highlight, a leading icon, and a live hover +/// fill. Pure composition — each row is an `HStack` with an `onTap(selectAction)` +/// that drives `selection`. Drop it inside a `NavigationSplitView`'s sidebar pane +/// (ideally over a `Material`). +pub fn Sidebar(items: []const SidebarItem, selection: Binding(i64)) View { + const bt = view.buildTokens(); + const rows = view.buildAlloc().alloc(View, items.len) catch @panic("oom"); + for (items, 0..) |item, i| { + const is_sel = selection.get() == @as(i64, @intCast(i)); + const fg: ?Color = if (is_sel) bt.on_accent else null; + // Allocate the row's children in the build arena — `makeStackFromSlice` + // keeps the slice, so a stack-local array would dangle after we return. + const contents = view.buildAlloc().alloc(View, 3) catch @panic("oom"); + var k: usize = 0; + if (item.icon) |ic| { + contents[k] = view.Icon(ic, 15, fg); + k += 1; + } + var label = view.Text(item.label); + if (fg) |c| label = label.foreground(c); + contents[k] = label; + k += 1; + contents[k] = view.Spacer(); + k += 1; + var rowv = view.makeStackFromSlice(.horizontal, 8, .center, contents[0..k]) + .paddingInsets(.{ .top = 6, .leading = 10, .bottom = 6, .trailing = 8 }) + .frameMaxWidth() + .cornerRadius(8) // sidebar selection radius + .onTap(view.selectAction(selection, @intCast(i))); + if (is_sel) { + rowv = rowv.background(bt.accent); + } else { + rowv = rowv.hoverFill(bt.hover); + } + rows[i] = rowv; + } + return view.makeStackFromSlice(.vertical, 2, .leading, rows).frameMaxWidth(); +} + +// --------------------------------------------------------------------------- +// RadioGroup +// --------------------------------------------------------------------------- + +/// A vertical radio-button group bound to a selected index (SwiftUI's +/// `.pickerStyle(.radioGroup)`). Pure composition: each option is a row with a +/// concentric-circle indicator (a filled accent dot when selected) and a tap that +/// sets `selection`. +pub fn RadioGroup(selection: Binding(i64), options: []const []const u8) View { + const rows = view.buildAlloc().alloc(View, options.len) catch @panic("oom"); + for (options, 0..) |opt, i| { + const is_sel = selection.get() == @as(i64, @intCast(i)); + rows[i] = view.HStack(.{ + radioIndicator(is_sel), + view.Text(opt), + view.Spacer(), + }).spacing(8) + .frameMaxWidth() + .onTap(view.selectAction(selection, @intCast(i))); + } + return view.makeStackFromSlice(.vertical, 8, .leading, rows).frameMaxWidth(); +} + +/// The 16×16 radio dot: an accent-filled disc with a white center when selected, +/// or a hollow ring when not. Built from layered `Circle`s (which fill, centered, +/// inside a `ZStack`). +fn radioIndicator(selected: bool) View { + const bt = view.buildTokens(); + if (selected) { + return view.ZStack(.{ + view.Circle(bt.accent).frame(16, 16), + view.Circle(bt.on_accent).frame(6, 6), + }).frame(16, 16); + } + return view.ZStack(.{ + view.Circle(Color.black.withAlpha(0.25)).frame(16, 16), + view.Circle(Color.white).frame(13, 13), + }).frame(16, 16); +} + +// --------------------------------------------------------------------------- +// Table +// --------------------------------------------------------------------------- + +/// One column of a `Table`: a header title and the width the column occupies. A +/// `null` width makes the column flexible (it shares the leftover space evenly +/// with the other flexible columns). +pub const TableColumn = struct { title: []const u8, width: ?f32 = null }; + +/// A multi-column data table (SwiftUI `Table`): a header row over scrollable data +/// rows, with an optional single-selection binding that highlights the selected +/// row. `rows[r][c]` is the text for row `r`, column `c`. Pure composition over +/// stacks; selectable rows reuse `selectAction`. Wrap it in a fixed `.frameHeight` +/// to get the scrollable, bordered look. +pub fn Table(columns: []const TableColumn, rows: []const []const []const u8, selection: ?Binding(i64)) View { + const bt = view.buildTokens(); + // Header + const header_cells = view.buildAlloc().alloc(View, columns.len) catch @panic("oom"); + for (columns, 0..) |col, c| header_cells[c] = tableCell(view.Text(col.title).font(.subheadline), col.width, true); + const header = view.makeStackFromSlice(.horizontal, 0, .center, header_cells) + .paddingInsets(.{ .top = 5, .leading = 8, .bottom = 5, .trailing = 8 }) + .frameMaxWidth(); + + // Body rows + const row_views = view.buildAlloc().alloc(View, rows.len) catch @panic("oom"); + for (rows, 0..) |row, r| { + const cells = view.buildAlloc().alloc(View, columns.len) catch @panic("oom"); + const is_sel = if (selection) |s| s.get() == @as(i64, @intCast(r)) else false; + const fg: ?Color = if (is_sel) bt.on_accent else null; + for (columns, 0..) |col, c| { + const txt = if (c < row.len) row[c] else ""; + var cellv = view.Text(txt); + if (fg) |fc| cellv = cellv.foreground(fc); + cells[c] = tableCell(cellv, col.width, false); + } + var rv = view.makeStackFromSlice(.horizontal, 0, .center, cells) + .paddingInsets(.{ .top = 5, .leading = 8, .bottom = 5, .trailing = 8 }) + .frameMaxWidth(); + if (selection) |s| { + rv = rv.onTap(view.selectAction(s, @intCast(r))); + if (is_sel) { + rv = rv.background(bt.accent); + } else if (r % 2 == 1) { + // Subtle zebra striping like a macOS table. + rv = rv.background(bt.row_stripe); + } + } else if (r % 2 == 1) { + rv = rv.background(bt.row_stripe); + } + row_views[r] = rv; + } + const body = view.makeStackFromSlice(.vertical, 0, .leading, row_views).frameMaxWidth(); + + return view.VStack(.{ + header, + view.Divider(), + view.ScrollView(body).frameMaxWidth().frameMaxHeight(), + }).spacing(0).frameMaxWidth(); +} + +fn tableCell(content: View, width: ?f32, leading: bool) View { + _ = leading; + // Left-align the cell's content (macOS tables are leading-aligned); the frame + // default centers, so a trailing Spacer pushes content to the leading edge. + const v = view.HStack(.{ content, view.Spacer() }); + if (width) |w| return v.frameWidth(w); + return v.frameMaxWidth(); +} diff --git a/src/components/grid.zig b/src/components/grid.zig new file mode 100644 index 0000000..0d53d8a --- /dev/null +++ b/src/components/grid.zig @@ -0,0 +1,52 @@ +//! Lazy grids: `LazyVGrid`/`LazyHGrid` lay items into even tracks by +//! composition (a stack of stacks), so they need no new layout primitive. Short +//! final rows/columns are padded with invisible cells to keep tracks aligned. + +const view = @import("../view/view.zig"); + +const View = view.View; + +/// A grid that grows vertically: `items` are mapped to cells via `mapFn` and +/// laid out left-to-right, top-to-bottom into `columns` even columns. Built by +/// composition — a `VStack` of `HStack` rows. Each cell is `.frameMaxWidth()` so +/// columns share width evenly; the trailing slots of a short final row are +/// filled with invisible cells so every column stays aligned. `spacing` applies +/// between both rows and columns. +pub fn LazyVGrid(columns: usize, spc: f32, items: anytype, comptime mapFn: anytype) View { + const cols = if (columns == 0) 1 else columns; + const n = items.len; + const rows = if (n == 0) 0 else (n + cols - 1) / cols; // ceil(n/cols) + const row_views = view.buildAlloc().alloc(View, rows) catch @panic("oom"); + var r: usize = 0; + while (r < rows) : (r += 1) { + const cells = view.buildAlloc().alloc(View, cols) catch @panic("oom"); + var ci: usize = 0; + while (ci < cols) : (ci += 1) { + const idx = r * cols + ci; + cells[ci] = if (idx < n) mapFn(items[idx]).frameMaxWidth() else view.Empty().frameMaxWidth(); + } + row_views[r] = view.makeStackFromSlice(.horizontal, spc, .center, cells); + } + return view.makeStackFromSlice(.vertical, spc, .center, row_views); +} + +/// A grid that grows horizontally: the transpose of `LazyVGrid`. `items` fill +/// `rows` even rows top-to-bottom, then wrap to the next column. Built as an +/// `HStack` of `VStack` columns; each cell is `.frameMaxHeight()`. +pub fn LazyHGrid(rows: usize, spc: f32, items: anytype, comptime mapFn: anytype) View { + const rws = if (rows == 0) 1 else rows; + const n = items.len; + const cols = if (n == 0) 0 else (n + rws - 1) / rws; // ceil(n/rows) + const col_views = view.buildAlloc().alloc(View, cols) catch @panic("oom"); + var col: usize = 0; + while (col < cols) : (col += 1) { + const cells = view.buildAlloc().alloc(View, rws) catch @panic("oom"); + var ri: usize = 0; + while (ri < rws) : (ri += 1) { + const idx = col * rws + ri; + cells[ri] = if (idx < n) mapFn(items[idx]).frameMaxHeight() else view.Empty().frameMaxHeight(); + } + col_views[col] = view.makeStackFromSlice(.vertical, spc, .center, cells); + } + return view.makeStackFromSlice(.horizontal, spc, .center, col_views); +} diff --git a/src/components/list.zig b/src/components/list.zig new file mode 100644 index 0000000..4ba15e9 --- /dev/null +++ b/src/components/list.zig @@ -0,0 +1,26 @@ +//! `List`: a SwiftUI-style scrolling vertical container with hairline dividers +//! between rows and grouped-row padding. + +const view = @import("../view/view.zig"); + +const View = view.View; + +/// A SwiftUI-style List: a scrolling vertical container with hairline dividers +/// between rows and grouped-background styling. +pub fn List(rows: anytype) View { + const views = view.toViews(rows); + const flat = view.flattenGroups(views); + // interleave dividers + var with_dividers = view.buildAlloc().alloc(View, if (flat.len == 0) 0 else flat.len * 2 - 1) catch @panic("oom"); + var i: usize = 0; + for (flat, 0..) |row, idx| { + with_dividers[i] = row.paddingInsets(.{ .top = 6, .leading = 12, .bottom = 6, .trailing = 12 }).frameMaxWidth(); + i += 1; + if (idx + 1 < flat.len) { + with_dividers[i] = view.Divider(); + i += 1; + } + } + const stack = view.makeStackFromSlice(.vertical, 0, .leading, with_dividers); + return view.ScrollView(stack); +} diff --git a/src/components/menu.zig b/src/components/menu.zig new file mode 100644 index 0000000..6eb0d06 --- /dev/null +++ b/src/components/menu.zig @@ -0,0 +1,30 @@ +//! Pop-up menus: a button (or arbitrary trigger) that toggles a popover of item +//! views. Built from a `.popover` over an app-owned `State(bool)` — no dedicated +//! overlay style or hit action. + +const view = @import("../view/view.zig"); +const state = @import("../state/state.zig"); + +const View = view.View; + +fn toggleBoolState(s: *state.State(bool)) void { + s.set(!s.get()); +} + +/// A button that toggles a popover menu of `items` (a tuple or `[]const View`). +/// `open` is app-owned `State(bool)` tracking whether the menu is shown; tapping +/// the button toggles it, tapping the scrim dismisses it. +pub fn Menu(label: []const u8, open: *state.State(bool), items: anytype) View { + const content = view.VStack(items).padding(6); + return view.Button(label, view.actionCtx(state.State(bool), open, toggleBoolState)) + .popover(open.binding(), content); +} + +/// Attach a popover menu of `items` to an arbitrary `trigger` view: tapping the +/// trigger toggles the menu. The right-click/long-press analogue of `Menu`. +pub fn ContextMenu(trigger: View, open: *state.State(bool), items: anytype) View { + const content = view.VStack(items).padding(6); + return trigger + .onTap(view.actionCtx(state.State(bool), open, toggleBoolState)) + .popover(open.binding(), content); +} diff --git a/src/components/navigation.zig b/src/components/navigation.zig new file mode 100644 index 0000000..52a4911 --- /dev/null +++ b/src/components/navigation.zig @@ -0,0 +1,79 @@ +//! Navigation components: a master/detail split view and a route-stack push/pop +//! flow. All pure composition over the view primitives — "navigation" is just +//! mutating an app-owned `NavState` that the per-frame `body` switches on. + +const std = @import("std"); +const view = @import("../view/view.zig"); +const Color = @import("../render/color.zig").Color; + +const View = view.View; +const Callback = view.Callback; +const Allocator = std.mem.Allocator; + +/// A navigation route stack for `NavigationStack`-style flows. Owned by the app +/// (like `TextFieldState`), so it survives across frames; the per-frame body +/// switches on `top()` to choose the screen. Routes are opaque integer tokens +/// the app interprets. Because the tree is rebuilt every frame, "navigation" is +/// just mutating this stack. +pub const NavState = struct { + stack: std.ArrayList(i64) = .empty, + allocator: Allocator, + + pub fn init(allocator: Allocator) NavState { + return .{ .allocator = allocator }; + } + pub fn deinit(self: *NavState) void { + self.stack.deinit(self.allocator); + } + pub fn push(self: *NavState, route: i64) void { + self.stack.append(self.allocator, route) catch {}; + } + pub fn pop(self: *NavState) void { + if (self.stack.items.len > 0) _ = self.stack.pop(); + } + /// The current (top-most) route, or null when at the root. + pub fn top(self: *const NavState) ?i64 { + const n = self.stack.items.len; + return if (n == 0) null else self.stack.items[n - 1]; + } + pub fn depth(self: *const NavState) usize { + return self.stack.items.len; + } +}; + +/// A two-column master/detail layout: a fixed-width `sidebar` pane (filled with +/// `sidebar_fill` so it stays themable), a vertical hairline, and a `detail` +/// pane that fills the remaining width. Pure composition over `HStack`. For the +/// macOS 26 look, fill the sidebar with a `Material` (frosted glass) and put a +/// `Sidebar` list inside it. +pub fn NavigationSplitView(sidebar: View, detail: View, sidebar_fill: Color) View { + return view.makeStack(.horizontal, 0, .center, .{ + sidebar.frameWidth(220).frameMaxHeight().background(sidebar_fill), + view.VDivider(), + detail.frameMaxWidth().frameMaxHeight(), + }); +} + +/// Closure context for a `NavigationLink` tap: a (NavState, route) pair bound at +/// build time. Allocated in the per-frame build arena, which outlives the frame +/// until the next rebuild, so the hit region's callback can safely read it. +const NavPushCtx = struct { nav: *NavState, route: i64 }; + +fn navPushThunk(p: ?*anyopaque) void { + const ctx: *NavPushCtx = @ptrCast(@alignCast(p.?)); + ctx.nav.push(ctx.route); +} + +/// A button that, when tapped, pushes `route` onto `nav`'s route stack. Reuses +/// the plain `.callback` hit action (no new interaction kind needed). +pub fn NavigationLink(label: []const u8, route: i64, nav: *NavState) View { + const ctx = view.buildAlloc().create(NavPushCtx) catch @panic("oom"); + ctx.* = .{ .nav = nav, .route = route }; + return view.Button(label, .{ .ctx = ctx, .func = navPushThunk }); +} + +/// A button that pops the top route off `nav` (the navigation "back" button). +/// Render it in the screen's top bar when `nav.depth() > 0`. +pub fn NavBackButton(label: []const u8, nav: *NavState) View { + return view.ButtonRoled(label, .plain, view.actionCtx(NavState, nav, NavState.pop)); +} diff --git a/src/components/tabs.zig b/src/components/tabs.zig new file mode 100644 index 0000000..38faa42 --- /dev/null +++ b/src/components/tabs.zig @@ -0,0 +1,37 @@ +//! Tabbed container: a segmented control on top, a hairline, then the selected +//! tab's content. Pure composition — the bar is a `Picker` over the tab labels, +//! so selecting a segment drives the same interaction (and styling) as `Picker`. + +const std = @import("std"); +const view = @import("../view/view.zig"); + +const View = view.View; +const Binding = view.Binding; + +/// One page of a `TabView`: a title for its tab-bar segment plus the content +/// shown when that tab is selected. +pub const Tab = struct { label: []const u8, content: View }; + +/// A tabbed container: a centered segmented control at the top, a hairline, then +/// the selected tab's content filling the space below. The bar is a `Picker` +/// over the tab labels, so tapping a segment drives the same `.select` +/// interaction as `Picker` (and the `selection` binding is what the body +/// switches on). Rebuilt each frame, so "switching tabs" is just the binding +/// changing. +pub fn TabView(selection: Binding(i64), tabs: []const Tab) View { + const n = tabs.len; + if (n == 0) return view.Empty(); + const hi: i64 = @intCast(n - 1); + const sel: usize = @intCast(std.math.clamp(selection.get(), 0, hi)); + const labels = view.buildAlloc().alloc([]const u8, n) catch @panic("oom"); + for (tabs, 0..) |tab, i| labels[i] = tab.label; + // Center the segmented control like a macOS tab bar (it sizes to its content). + const bar = view.HStack(.{ view.Spacer(), view.Picker(selection, labels), view.Spacer() }) + .paddingInsets(.{ .top = 6, .leading = 8, .bottom = 6, .trailing = 8 }) + .frameMaxWidth(); + return view.VStack(.{ + bar, + view.Divider(), + tabs[sel].content.frameMaxWidth().frameMaxHeight(), + }).spacing(0); +} diff --git a/src/components/text_buffer.zig b/src/components/text_buffer.zig new file mode 100644 index 0000000..f235111 --- /dev/null +++ b/src/components/text_buffer.zig @@ -0,0 +1,397 @@ +//! The editable-text model shared by `TextField` and `TextEditor`: a +//! `TextFieldState` (buffer + caret + selection + vertical-motion machinery) and +//! the pure UTF-8 line/column geometry it rides on. No view/render dependency +//! beyond the font metrics used for tab-aware pixel math, so it is unit-testable +//! in isolation and reused by both the single-line field and the multi-line +//! editor. + +const std = @import("std"); +const ttf = @import("../text/ttf.zig"); +const shape = @import("../text/shape.zig"); +const view = @import("../view/view.zig"); + +const Allocator = std.mem.Allocator; +const Callback = view.Callback; + +/// Character class for word-wise selection (double-click). Runs of the same +/// class are selected together; UTF-8 multi-byte sequences count as `word` so +/// accented/CJK text groups naturally. +const CharClass = enum { word, space, other }; +fn charClass(b: u8) CharClass { + if (b >= 0x80) return .word; + return switch (b) { + ' ', '\t', '\n', '\r' => .space, + 'a'...'z', 'A'...'Z', '0'...'9', '_' => .word, + else => .other, + }; +} + +/// Editable text buffer + caret for a `TextField` or `TextEditor`. Owned by the +/// app (like a `State`), so editing survives across frames. Key events call its +/// methods. Beyond a caret it tracks an optional selection (`sel_anchor`) and, +/// for multi-line editors, the machinery for vertical motion and caret-follow +/// scrolling. +pub const TextFieldState = struct { + buffer: std.ArrayList(u8) = .empty, + caret: usize = 0, // byte index + /// Selection anchor (byte index). When non-null and different from `caret`, + /// the selection is the ordered range [min, max]. A plain (un-shifted) move + /// or any edit collapses it (back to `null`). + sel_anchor: ?usize = null, + focused: bool = false, + /// True for a `TextEditor` (multi-line). The event loop then inserts a + /// newline on Enter (instead of submitting) and routes Up/Down/Home/End/Tab. + /// Set by `paintTextEditor` each frame. + multiline: bool = false, + /// Preferred column (codepoints from the line start) for vertical motion, so + /// a run of Up/Down keeps the original column across shorter lines. Reset by + /// any horizontal move or edit. + pref_col: ?usize = null, + /// The caret seen at the previous paint. `TextEditor` auto-scrolls to follow + /// the caret only when it actually moved, so the wheel can scroll freely + /// otherwise. + last_caret: usize = 0, + /// Bumped on every mutation so an app can cheaply detect "modified since + /// saved" without diffing the buffer (see `examples/edit`). + revision: u64 = 0, + /// The `.onSubmit` callback for this field, refreshed during paint while the + /// field is focused so `submitFocused()` (called from the event loop on + /// Enter) can fire it without the view tree. See `paintTextField`. + on_submit: ?Callback = null, + allocator: Allocator, + + pub fn init(allocator: Allocator) TextFieldState { + return .{ .allocator = allocator }; + } + pub fn deinit(self: *TextFieldState) void { + self.buffer.deinit(self.allocator); + } + pub fn text(self: *const TextFieldState) []const u8 { + return self.buffer.items; + } + pub fn setText(self: *TextFieldState, s: []const u8) !void { + self.buffer.clearRetainingCapacity(); + try self.buffer.appendSlice(self.allocator, s); + self.caret = self.buffer.items.len; + self.sel_anchor = null; + self.pref_col = null; + self.last_caret = self.caret; + self.revision +%= 1; + } + + // --- selection --------------------------------------------------------- + + /// The current selection as an ordered byte range, or null when the caret is + /// a plain insertion point (no anchor, or an empty selection). + pub fn selectionRange(self: *const TextFieldState) ?struct { start: usize, end: usize } { + const a = self.sel_anchor orelse return null; + if (a == self.caret) return null; + return .{ .start = @min(a, self.caret), .end = @max(a, self.caret) }; + } + pub fn hasSelection(self: *const TextFieldState) bool { + return self.selectionRange() != null; + } + /// The selected text as a slice into the buffer, or an empty slice when there + /// is no selection. Borrowed — valid only until the next mutation. + pub fn selectedText(self: *const TextFieldState) []const u8 { + const r = self.selectionRange() orelse return self.buffer.items[0..0]; + return self.buffer.items[r.start..r.end]; + } + pub fn selectAll(self: *TextFieldState) void { + if (self.buffer.items.len == 0) { + self.sel_anchor = null; + return; + } + self.sel_anchor = 0; + self.caret = self.buffer.items.len; + self.pref_col = null; + } + /// Select the word (or run of whitespace/punctuation) containing the byte + /// `index` — the double-click gesture. Leaves an ordered selection with the + /// caret at its end. + pub fn selectWordAt(self: *TextFieldState, index: usize) void { + const items = self.buffer.items; + if (items.len == 0) { + self.sel_anchor = null; + self.caret = 0; + return; + } + var idx = @min(index, items.len); + if (idx == items.len) idx = prevCpStart(items, idx); // classify the last char + const cls = charClass(items[idx]); + var start = idx; + while (start > 0) { + const prev = prevCpStart(items, start); + if (charClass(items[prev]) != cls) break; + start = prev; + } + var stop = idx; + while (stop < items.len) { + if (charClass(items[stop]) != cls) break; + const len = std.unicode.utf8ByteSequenceLength(items[stop]) catch 1; + stop = @min(stop + len, items.len); + } + self.sel_anchor = start; + self.caret = stop; + self.pref_col = null; + } + /// Delete the selected range, if any. Returns true if something was removed. + pub fn deleteSelection(self: *TextFieldState) bool { + const r = self.selectionRange() orelse { + self.sel_anchor = null; + return false; + }; + const n = r.end - r.start; + std.mem.copyForwards(u8, self.buffer.items[r.start..], self.buffer.items[r.end..]); + self.buffer.shrinkRetainingCapacity(self.buffer.items.len - n); + self.caret = r.start; + self.sel_anchor = null; + self.pref_col = null; + self.revision +%= 1; + return true; + } + + // --- editing ----------------------------------------------------------- + + pub fn insert(self: *TextFieldState, s: []const u8) !void { + _ = self.deleteSelection(); + try self.buffer.insertSlice(self.allocator, self.caret, s); + self.caret += s.len; + self.sel_anchor = null; + self.pref_col = null; + self.revision +%= 1; + } + pub fn backspace(self: *TextFieldState) void { + if (self.deleteSelection()) return; + if (self.caret == 0) return; + const start = prevCpStart(self.buffer.items, self.caret); + const n = self.caret - start; + std.mem.copyForwards(u8, self.buffer.items[start..], self.buffer.items[self.caret..]); + self.buffer.shrinkRetainingCapacity(self.buffer.items.len - n); + self.caret = start; + self.pref_col = null; + self.revision +%= 1; + } + /// Forward-delete (the Delete key): remove the codepoint after the caret. + pub fn deleteForward(self: *TextFieldState) void { + if (self.deleteSelection()) return; + const items = self.buffer.items; + if (self.caret >= items.len) return; + const len = std.unicode.utf8ByteSequenceLength(items[self.caret]) catch 1; + const stop = @min(self.caret + len, items.len); + std.mem.copyForwards(u8, self.buffer.items[self.caret..], self.buffer.items[stop..]); + self.buffer.shrinkRetainingCapacity(items.len - (stop - self.caret)); + self.pref_col = null; + self.revision +%= 1; + } + + // --- caret movement (extend = Shift held, growing the selection) ------- + + fn beginExtend(self: *TextFieldState, extend: bool) void { + if (extend) { + if (self.sel_anchor == null) self.sel_anchor = self.caret; + } else self.sel_anchor = null; + } + pub fn moveLeft(self: *TextFieldState, extend: bool) void { + self.pref_col = null; + if (!extend) { + if (self.selectionRange()) |r| { + self.caret = r.start; + self.sel_anchor = null; + return; + } + } + self.beginExtend(extend); + if (self.caret > 0) self.caret = prevCpStart(self.buffer.items, self.caret); + } + pub fn moveRight(self: *TextFieldState, extend: bool) void { + self.pref_col = null; + if (!extend) { + if (self.selectionRange()) |r| { + self.caret = r.end; + self.sel_anchor = null; + return; + } + } + self.beginExtend(extend); + const items = self.buffer.items; + if (self.caret < items.len) { + const len = std.unicode.utf8ByteSequenceLength(items[self.caret]) catch 1; + self.caret = @min(self.caret + len, items.len); + } + } + pub fn home(self: *TextFieldState, extend: bool) void { + self.beginExtend(extend); + self.caret = lineStartIndex(self.buffer.items, self.caret); + self.pref_col = null; + } + pub fn end(self: *TextFieldState, extend: bool) void { + self.beginExtend(extend); + self.caret = lineEndIndex(self.buffer.items, self.caret); + self.pref_col = null; + } + pub fn moveUp(self: *TextFieldState, extend: bool) void { + self.beginExtend(extend); + const b = self.buffer.items; + const ls = lineStartIndex(b, self.caret); + if (self.pref_col == null) self.pref_col = columnOf(b, self.caret); + if (ls == 0) { + self.caret = 0; // already on the first line + return; + } + const prev_end = ls - 1; // the '\n' that ends the previous line + const prev_start = lineStartIndex(b, prev_end); + self.caret = indexForColumn(b, prev_start, prev_end, self.pref_col.?); + } + pub fn moveDown(self: *TextFieldState, extend: bool) void { + self.beginExtend(extend); + const b = self.buffer.items; + const le = lineEndIndex(b, self.caret); + if (self.pref_col == null) self.pref_col = columnOf(b, self.caret); + if (le >= b.len) { + self.caret = b.len; // already on the last line + return; + } + const next_start = le + 1; + const next_end = lineEndIndex(b, next_start); + self.caret = indexForColumn(b, next_start, next_end, self.pref_col.?); + } + /// The caret's 1-based line and column (counting codepoints), for a status bar. + pub fn lineCol(self: *const TextFieldState) struct { line: usize, col: usize } { + return .{ + .line = lineIndexOf(self.buffer.items, self.caret) + 1, + .col = columnOf(self.buffer.items, self.caret) + 1, + }; + } +}; + +// --------------------------------------------------------------------------- +// Pure line/column geometry over a UTF-8 byte buffer +// --------------------------------------------------------------------------- +// Lines are separated by '\n'; a column counts codepoints from the line start. +// Shared by `TextFieldState` movement and the editor's painting. + +pub fn prevCpStart(bytes: []const u8, i: usize) usize { + var j = i; + if (j == 0) return 0; + j -= 1; + while (j > 0 and (bytes[j] & 0xC0) == 0x80) j -= 1; // skip UTF-8 continuation bytes + return j; +} +pub fn lineStartIndex(bytes: []const u8, i: usize) usize { + var j = @min(i, bytes.len); + while (j > 0 and bytes[j - 1] != '\n') j -= 1; + return j; +} +pub fn lineEndIndex(bytes: []const u8, i: usize) usize { + var j = @min(i, bytes.len); + while (j < bytes.len and bytes[j] != '\n') j += 1; + return j; +} +pub fn columnOf(bytes: []const u8, i: usize) usize { + const lim = @min(i, bytes.len); + var col: usize = 0; + var j = lineStartIndex(bytes, lim); + while (j < lim) : (j += 1) { + if ((bytes[j] & 0xC0) != 0x80) col += 1; // count non-continuation bytes + } + return col; +} +pub fn lineIndexOf(bytes: []const u8, i: usize) usize { + const lim = @min(i, bytes.len); + var n: usize = 0; + for (bytes[0..lim]) |b| { + if (b == '\n') n += 1; + } + return n; +} +pub fn countLines(bytes: []const u8) usize { + var n: usize = 1; + for (bytes) |b| { + if (b == '\n') n += 1; + } + return n; +} +/// The byte index `col` codepoints into the line spanning [line_start, line_end]. +pub fn indexForColumn(bytes: []const u8, line_start: usize, line_end: usize, col: usize) usize { + var j = line_start; + var c: usize = 0; + while (j < line_end and c < col) { + j += std.unicode.utf8ByteSequenceLength(bytes[j]) catch 1; + c += 1; + } + return @min(j, line_end); +} +/// The byte range [start, end) of the `n`-th line (0-based), clamped to the last. +pub fn nthLineRange(bytes: []const u8, n: usize) struct { start: usize, end: usize } { + var start: usize = 0; + var seen: usize = 0; + var j: usize = 0; + while (j < bytes.len) : (j += 1) { + if (bytes[j] == '\n') { + if (seen == n) return .{ .start = start, .end = j }; + seen += 1; + start = j + 1; + } + } + return .{ .start = start, .end = bytes.len }; +} + +// --------------------------------------------------------------------------- +// Tab-aware horizontal metrics (shared by caret, selection, and click math) +// --------------------------------------------------------------------------- + +/// Editor tab width, in spaces (tabs snap to the next multiple of this). +pub const editor_tab_size: f32 = 4; + +/// The advance of a single space and a tab stop at `px`, used to lay out the +/// editor's monospace-ish tab handling. +pub fn editorTabMetrics(face: *const ttf.Font, px: f32) struct { space: f32, tab: f32 } { + const space = shape.measureLineWidth(face, " ", px); + return .{ .space = space, .tab = space * editor_tab_size }; +} + +/// Pixel width of `slice`, honoring tab stops (so `\t` advances to the next +/// multiple of the tab width rather than drawing a `.notdef` box). Shared by the +/// caret, selection, and click math so they all agree. +pub fn editorPrefixWidth(face: *const ttf.Font, px: f32, slice: []const u8) f32 { + const sc = face.scaleForPixelSize(px); + const tab_w = editorTabMetrics(face, px).tab; + var x: f32 = 0; + var i: usize = 0; + while (i < slice.len) { + const cp_len = std.unicode.utf8ByteSequenceLength(slice[i]) catch 1; + const e = @min(i + cp_len, slice.len); + if (slice[i] == '\t') { + x = (@floor(x / tab_w) + 1) * tab_w; + } else { + const cp = std.unicode.utf8Decode(slice[i..e]) catch slice[i]; + x += @as(f32, @floatFromInt(face.advanceWidth(face.glyphIndex(cp)))) * sc; + } + i = e; + } + return x; +} + +/// The byte offset within `line` whose glyph boundary is nearest to pixel +/// `target_x` (measured from the line's left edge). Tab-aware; used for click- +/// to-position. +pub fn caretInLine(face: *const ttf.Font, px: f32, line: []const u8, target_x: f32) usize { + if (target_x <= 0) return 0; + const sc = face.scaleForPixelSize(px); + const tab_w = editorTabMetrics(face, px).tab; + var x: f32 = 0; + var i: usize = 0; + while (i < line.len) { + const cp_len = std.unicode.utf8ByteSequenceLength(line[i]) catch 1; + const e = @min(i + cp_len, line.len); + const adv = if (line[i] == '\t') + (@floor(x / tab_w) + 1) * tab_w - x + else + @as(f32, @floatFromInt(face.advanceWidth(face.glyphIndex(std.unicode.utf8Decode(line[i..e]) catch line[i])))) * sc; + if (target_x < x + adv / 2) return i; // nearer this cell's left edge + x += adv; + i = e; + } + return line.len; +} diff --git a/src/icons.zig b/src/icons.zig new file mode 100644 index 0000000..c48db99 --- /dev/null +++ b/src/icons.zig @@ -0,0 +1,160 @@ +//! The bundled icon catalog. `Icon` names a glyph in the subset of the Lucide +//! icon font shipped as `assets/fonts/icons.ttf` (see `assets/fonts/NOTICE.md` +//! for licensing). Each tag's integer value is the glyph's codepoint in the +//! font's Private Use Area, so rendering an icon is just `glyphIndex(codepoint)` +//! through the ordinary text path — icons are tintable and HiDPI-crisp for free. +//! +//! Use them via the `Icon`/`IconButton` view constructors in `view.zig`, e.g. +//! `Icon(.heart, 18, theme.colors.accent)` or `IconButton(.trash, 18, onTap)`. +//! Enum-literal inference means call sites rarely spell `Icon` out — `.heart` +//! resolves against the parameter type. +//! +//! Regenerate (font + this enum) from /tmp/zigui-icons via the subset script; +//! `assets/fonts/icons-rows.json` records the [zig_name, lucide_name, codepoint] +//! rows the build was generated from. + +const std = @import("std"); + +/// A glyph in the bundled icon font. The value is its PUA codepoint. +pub const Icon = enum(u21) { + /// lucide `circle-alert` + alert = 0xE077, + /// lucide `arrow-down` + arrow_down = 0xE042, + /// lucide `arrow-left` + arrow_left = 0xE048, + /// lucide `arrow-right` + arrow_right = 0xE049, + /// lucide `arrow-up` + arrow_up = 0xE04A, + /// lucide `audio-lines` + audio_lines = 0xE55A, + /// lucide `badge-check` + badge_check = 0xE241, + /// lucide `bell` + bell = 0xE059, + /// lucide `bookmark` + bookmark = 0xE060, + /// lucide `boxes` + boxes = 0xE2D0, + /// lucide `calendar` + calendar = 0xE063, + /// lucide `check` + check = 0xE06C, + /// lucide `chevron-down` + chevron_down = 0xE06D, + /// lucide `chevron-left` + chevron_left = 0xE06E, + /// lucide `chevron-right` + chevron_right = 0xE06F, + /// lucide `chevron-up` + chevron_up = 0xE070, + /// lucide `clock` + clock = 0xE087, + /// lucide `x` + close = 0xE1B2, + /// lucide `copy` + copy = 0xE09E, + /// lucide `cpu` + cpu = 0xE0A9, + /// lucide `download` + download = 0xE0B2, + /// lucide `pencil` + edit = 0xE1F9, + /// lucide `eye` + eye = 0xE0BA, + /// lucide `eye-off` + eye_off = 0xE0BB, + /// lucide `file` + file = 0xE0C0, + /// lucide `film` + film = 0xE0D0, + /// lucide `folder` + folder = 0xE0D7, + /// lucide `hard-drive` + hard_drive = 0xE0ED, + /// lucide `heart` + heart = 0xE0F2, + /// lucide `circle-help` + help = 0xE082, + /// lucide `house` + home = 0xE0F5, + /// lucide `image` + image = 0xE0F6, + /// lucide `info` + info = 0xE0F9, + /// lucide `list` + list = 0xE106, + /// lucide `loader` + loader = 0xE109, + /// lucide `lock` + lock = 0xE10B, + /// lucide `mail` + mail = 0xE10F, + /// lucide `menu` + menu = 0xE115, + /// lucide `message-circle` + message_circle = 0xE116, + /// lucide `minus` + minus = 0xE11C, + /// lucide `moon` + moon = 0xE11E, + /// lucide `ellipsis` + more_horizontal = 0xE0B6, + /// lucide `ellipsis-vertical` + more_vertical = 0xE0B7, + /// lucide `pause` + pause = 0xE12E, + /// lucide `play` + play = 0xE13C, + /// lucide `plus` + plus = 0xE13D, + /// lucide `refresh-cw` + refresh = 0xE145, + /// lucide `scroll-text` + scroll_text = 0xE45F, + /// lucide `search` + search = 0xE151, + /// lucide `send` + send = 0xE152, + /// lucide `settings` + settings = 0xE154, + /// lucide `share-2` + share = 0xE156, + /// lucide `shield-check` + shield_check = 0xE1FF, + /// lucide `sparkles` + sparkles = 0xE412, + /// lucide `star` + star = 0xE176, + /// lucide `sun` + sun = 0xE178, + /// lucide `trash-2` + trash = 0xE18E, + /// lucide `lock-open` + unlock = 0xE10C, + /// lucide `upload` + upload = 0xE19E, + /// lucide `user` + user = 0xE19F, + /// lucide `users` + users = 0xE1A4, + /// lucide `volume-2` + volume = 0xE1AB, + /// lucide `wand-2` + wand = 0xE357, + /// lucide `zap` + zap = 0xE1B4, + + /// The font codepoint this icon maps to. + pub fn codepoint(self: Icon) u21 { + return @intFromEnum(self); + } +}; + +test "icons: every tag maps into the Private Use Area" { + for (std.enums.values(Icon)) |ic| { + const cp = ic.codepoint(); + try std.testing.expect(cp >= 0xE000 and cp <= 0xF8FF); + } +} diff --git a/src/render/color.zig b/src/render/color.zig index 3baeba1..398a5f0 100644 --- a/src/render/color.zig +++ b/src/render/color.zig @@ -84,6 +84,21 @@ pub const Color = struct { pub fn multiplyAlpha(c: Color, factor: f32) Color { return c.withAlpha(c.a * factor); } + /// Lighten toward white by `amount` (0..1), preserving alpha. Used for the + /// top sheen of "liquid glass" controls. + pub fn lighten(c: Color, amount: f32) Color { + return .{ + .r = c.r + (1 - c.r) * amount, + .g = c.g + (1 - c.g) * amount, + .b = c.b + (1 - c.b) * amount, + .a = c.a, + }; + } + /// Darken toward black by `amount` (0..1), preserving alpha. + pub fn darken(c: Color, amount: f32) Color { + const k = 1 - amount; + return .{ .r = c.r * k, .g = c.g * k, .b = c.b * k, .a = c.a }; + } /// Linear interpolation per channel; t in 0..1. pub fn lerp(a: Color, b: Color, t: f32) Color { return .{ diff --git a/src/render/raster.zig b/src/render/raster.zig index 0e69fdd..48e3570 100644 --- a/src/render/raster.zig +++ b/src/render/raster.zig @@ -22,13 +22,32 @@ pub const Framebuffer = struct { pixels: []Color, allocator: Allocator, + /// A zero-size framebuffer holding no allocation. Pair with `ensureSize` to + /// grow it lazily and reuse the buffer across frames. + pub const empty: Framebuffer = .{ .width = 0, .height = 0, .pixels = &.{}, .allocator = undefined }; + pub fn init(allocator: Allocator, width: u32, height: u32) !Framebuffer { const px = try allocator.alloc(Color, width * height); @memset(px, Color.transparent); return .{ .width = width, .height = height, .pixels = px, .allocator = allocator }; } pub fn deinit(self: *Framebuffer) void { - self.allocator.free(self.pixels); + if (self.pixels.len > 0) self.allocator.free(self.pixels); + self.* = .empty; + } + + /// Resize for reuse: reallocates the pixel buffer only when the pixel count + /// actually changes, so steady-state frames (same window size) allocate + /// nothing. The contents are left undefined — call `clear` afterward. + pub fn ensureSize(self: *Framebuffer, allocator: Allocator, width: u32, height: u32) !void { + const need = @as(usize, width) * @as(usize, height); + if (need != self.pixels.len) { + if (self.pixels.len > 0) allocator.free(self.pixels); + self.pixels = try allocator.alloc(Color, need); + } + self.width = width; + self.height = height; + self.allocator = allocator; } pub fn clear(self: *Framebuffer, color: Color) void { @memset(self.pixels, color); @@ -42,13 +61,19 @@ pub const Framebuffer = struct { fn blend(self: *Framebuffer, x: u32, y: u32, src: Color, coverage: f32) void { if (coverage <= 0) return; const i = y * self.width + x; + // Opaque pixel at full coverage: plain store, no compositing math. + if (coverage >= 1 and src.a >= 1) { + self.pixels[i] = src; + return; + } const s = src.withAlpha(src.a * coverage); self.pixels[i] = s.over(self.pixels[i]); } - /// Export to a tightly-packed RGBA8 buffer (caller owns the memory). - pub fn toRgba8Alloc(self: *const Framebuffer, allocator: Allocator) ![]u8 { - const out = try allocator.alloc(u8, self.width * self.height * 4); + /// Convert into a caller-provided tightly-packed RGBA8 buffer (reused across + /// frames). `out.len` must be `width * height * 4`. + pub fn toRgba8(self: *const Framebuffer, out: []u8) void { + std.debug.assert(out.len == self.width * self.height * 4); for (self.pixels, 0..) |c, i| { const p = c.toRgba8(); out[i * 4 + 0] = p.r; @@ -56,6 +81,12 @@ pub const Framebuffer = struct { out[i * 4 + 2] = p.b; out[i * 4 + 3] = p.a; } + } + + /// Export to a freshly-allocated RGBA8 buffer (caller owns the memory). + pub fn toRgba8Alloc(self: *const Framebuffer, allocator: Allocator) ![]u8 { + const out = try allocator.alloc(u8, self.width * self.height * 4); + self.toRgba8(out); return out; } }; @@ -233,7 +264,16 @@ fn clipCoverage(clips: []const Clip, px: f32, py: f32) f32 { return cov; } -const PixelBounds = struct { x0: u32, y0: u32, x1: u32, y1: u32 }; +const PixelBounds = struct { + x0: u32, + y0: u32, + x1: u32, + y1: u32, + + fn empty(self: PixelBounds) bool { + return self.x1 <= self.x0 or self.y1 <= self.y0; + } +}; /// Integer pixel range covering `rect` (expanded by `pad` for AA), clamped to fb. fn boundsOf(fb: *const Framebuffer, rect: Rect, pad: f32) PixelBounds { @@ -249,31 +289,132 @@ fn boundsOf(fb: *const Framebuffer, rect: Rect, pad: f32) PixelBounds { }; } +/// Per-command clip analysis: the pixel bounds of `rect` (padded for AA) +/// intersected with every clip rect — so fully-clipped pixels (e.g. scrolled- +/// away content) are never visited — plus whether any per-pixel clip coverage +/// is still needed. When the padded shape sits fully inside every clip's safe +/// interior (inset by radius/2 + 1, where coverage is provably 1), the inner +/// loops skip `clipCoverage` entirely. +const ClippedBounds = struct { bounds: PixelBounds, check_clip: bool }; + +fn clippedBoundsOf(fb: *const Framebuffer, clips: []const Clip, rect: Rect, pad: f32) ClippedBounds { + var b = boundsOf(fb, rect, pad); + var check = false; + for (clips) |cl| { + const cb = boundsOf(fb, cl.rect, 1); + b.x0 = @max(b.x0, cb.x0); + b.y0 = @max(b.y0, cb.y0); + b.x1 = @min(b.x1, cb.x1); + b.y1 = @min(b.y1, cb.y1); + if (!check) { + const inset = cl.radius / 2 + 1; + check = rect.minX() - pad < cl.rect.minX() + inset or + rect.maxX() + pad > cl.rect.maxX() - inset or + rect.minY() - pad < cl.rect.minY() + inset or + rect.maxY() + pad > cl.rect.maxY() - inset; + } + } + return .{ .bounds = b, .check_clip = check }; +} + +/// For the row of pixel centers at `py`, the half-width around `rect.midX()` +/// within which fill coverage is exactly 1 (sd <= -0.5), or null when no pixel +/// of this row is fully covered. `r` must already be clamped to the half-size. +fn fullSpanHalfWidth(rect: Rect, r: f32, py: f32) ?f32 { + const qy = @abs(py - rect.midY()) - (rect.height / 2 - r); + const rr = r - 0.5; + if (qy > rr) return null; // top/bottom AA fringe (or outside) + if (qy >= 0) { + // corner band: the full-coverage span follows the inset corner circle + return (rect.width / 2 - r) + @sqrt(rr * rr - qy * qy); + } + return rect.width / 2 - 0.5; +} + fn fillShape(fb: *Framebuffer, clips: []const Clip, rect: Rect, radius: f32, paint: Paint) void { - const b = boundsOf(fb, rect, 1); + const cc = clippedBoundsOf(fb, clips, rect, 1); + const b = cc.bounds; + if (b.empty()) return; + const r = @min(radius, @min(rect.width, rect.height) / 2); + // Solid paints — and vertical gradients, which all the theme sheens are — + // have one color per row, computed outside the inner loop. + const row_const = switch (paint) { + .solid => true, + .gradient => |g| g.start.x == g.end.x, + }; var y = b.y0; while (y < b.y1) : (y += 1) { + const py = @as(f32, @floatFromInt(y)) + 0.5; + const row_color = if (row_const) paint.colorAt(rect.midX(), py) else Color.transparent; + + // The span of pixels this row where shape coverage is exactly 1: those + // skip the SDF; an opaque row-constant color becomes a plain @memset. + var sx0 = b.x1; + var sx1 = b.x1; + if (fullSpanHalfWidth(rect, r, py)) |half| { + const fx0 = rect.midX() - half; + const fx1 = rect.midX() + half; + if (fx1 > fx0) { + sx0 = std.math.clamp(@as(u32, @intFromFloat(@max(0, @ceil(fx0 - 0.5)))), b.x0, b.x1); + sx1 = std.math.clamp(@as(u32, @intFromFloat(@max(0, @floor(fx1 - 0.5) + 1))), sx0, b.x1); + } + } + + const row_idx = y * fb.width; var x = b.x0; while (x < b.x1) : (x += 1) { + if (x >= sx0 and x < sx1 and !cc.check_clip) { + // Fully-covered interior run. + if (row_const and row_color.a >= 1) { + @memset(fb.pixels[row_idx + sx0 .. row_idx + sx1], row_color); + } else { + var ix = x; + while (ix < sx1) : (ix += 1) { + const c = if (row_const) row_color else paint.colorAt(@as(f32, @floatFromInt(ix)) + 0.5, py); + fb.blend(ix, y, c, 1); + } + } + x = sx1 - 1; // resume the AA fringe after the span + continue; + } const px = @as(f32, @floatFromInt(x)) + 0.5; - const py = @as(f32, @floatFromInt(y)) + 0.5; - const cov = fillCoverage(px, py, rect, radius) * clipCoverage(clips, px, py); - if (cov > 0) fb.blend(x, y, paint.colorAt(px, py), cov); + var cov = if (x >= sx0 and x < sx1) 1 else fillCoverage(px, py, rect, r); + if (cc.check_clip and cov > 0) cov *= clipCoverage(clips, px, py); + if (cov > 0) { + const c = if (row_const) row_color else paint.colorAt(px, py); + fb.blend(x, y, c, cov); + } } } } fn strokeShape(fb: *Framebuffer, clips: []const Clip, rect: Rect, radius: f32, width: f32, color: Color) void { const half = width / 2; - const b = boundsOf(fb, rect, half + 1); + const cc = clippedBoundsOf(fb, clips, rect, half + 1); + const b = cc.bounds; + if (b.empty()) return; + const r = @min(radius, @min(rect.width, rect.height) / 2); + // Rows clear of the top/bottom edges and corners only have coverage in two + // narrow bands around the vertical edges — skip the hollow interior. + const band = half + 1.5; + const mid_y0 = rect.minY() + r + band; + const mid_y1 = rect.maxY() - r - band; + const lx1: u32 = @intFromFloat(std.math.clamp(@ceil(rect.minX() + band), 0, @as(f32, @floatFromInt(fb.width)))); + const rx0: u32 = @intFromFloat(std.math.clamp(@floor(rect.maxX() - band), 0, @as(f32, @floatFromInt(fb.width)))); var y = b.y0; while (y < b.y1) : (y += 1) { + const py = @as(f32, @floatFromInt(y)) + 0.5; + const edge_row = py <= mid_y0 or py >= mid_y1; var x = b.x0; while (x < b.x1) : (x += 1) { + if (!edge_row and x >= lx1 and x < rx0) { + x = rx0 - 1; // jump the hollow interior to the right band + continue; + } const px = @as(f32, @floatFromInt(x)) + 0.5; - const py = @as(f32, @floatFromInt(y)) + 0.5; - const d = @abs(sdRoundBox(px, py, rect, radius)) - half; - const cov = std.math.clamp(0.5 - d, 0, 1) * clipCoverage(clips, px, py); + const d = @abs(sdRoundBox(px, py, rect, r)) - half; + var cov = std.math.clamp(0.5 - d, 0, 1); + if (cc.check_clip and cov > 0) cov *= clipCoverage(clips, px, py); if (cov > 0) fb.blend(x, y, color, cov); } } @@ -297,7 +438,9 @@ fn drawLine(fb: *Framebuffer, clips: []const Clip, a: Point, b: Point, width: f3 const minx = @min(a.x, b.x); const miny = @min(a.y, b.y); const rect = Rect{ .x = minx, .y = miny, .width = @abs(b.x - a.x), .height = @abs(b.y - a.y) }; - const bounds = boundsOf(fb, rect, half + 1); + const cc = clippedBoundsOf(fb, clips, rect, half + 1); + const bounds = cc.bounds; + if (bounds.empty()) return; var y = bounds.y0; while (y < bounds.y1) : (y += 1) { var x = bounds.x0; @@ -305,7 +448,8 @@ fn drawLine(fb: *Framebuffer, clips: []const Clip, a: Point, b: Point, width: f3 const px = @as(f32, @floatFromInt(x)) + 0.5; const py = @as(f32, @floatFromInt(y)) + 0.5; const d = sdSegment(px, py, a, b) - half; - const cov = std.math.clamp(0.5 - d, 0, 1) * clipCoverage(clips, px, py); + var cov = std.math.clamp(0.5 - d, 0, 1); + if (cc.check_clip and cov > 0) cov *= clipCoverage(clips, px, py); if (cov > 0) fb.blend(x, y, color, cov); } } @@ -313,22 +457,29 @@ fn drawLine(fb: *Framebuffer, clips: []const Clip, a: Point, b: Point, width: f3 fn drawGlyph(fb: *Framebuffer, clips: []const Clip, rect: Rect, color: Color, cov: canvas_mod.Coverage) void { if (cov.width == 0 or cov.height == 0) return; - const b = boundsOf(fb, rect, 0); + const cc = clippedBoundsOf(fb, clips, rect, 0); + const b = cc.bounds; + if (b.empty()) return; + // pixel -> coverage texel (nearest); divisions hoisted out of the loops + const usc = @as(f32, @floatFromInt(cov.width)) / rect.width; + const vsc = @as(f32, @floatFromInt(cov.height)) / rect.height; var y = b.y0; while (y < b.y1) : (y += 1) { + const py = @as(f32, @floatFromInt(y)) + 0.5; + const v = (py - rect.y) * vsc; + if (v < 0) continue; + const vi: u32 = @intFromFloat(v); + if (vi >= cov.height) continue; + const row = cov.data[vi * cov.width ..]; var x = b.x0; while (x < b.x1) : (x += 1) { const px = @as(f32, @floatFromInt(x)) + 0.5; - const py = @as(f32, @floatFromInt(y)) + 0.5; - // map pixel -> coverage texel (nearest) - const u = (px - rect.x) / rect.width * @as(f32, @floatFromInt(cov.width)); - const v = (py - rect.y) / rect.height * @as(f32, @floatFromInt(cov.height)); - if (u < 0 or v < 0) continue; + const u = (px - rect.x) * usc; + if (u < 0) continue; const ui: u32 = @intFromFloat(u); - const vi: u32 = @intFromFloat(v); - if (ui >= cov.width or vi >= cov.height) continue; - const a = @as(f32, @floatFromInt(cov.data[vi * cov.width + ui])) / 255.0; - const c = a * clipCoverage(clips, px, py); + if (ui >= cov.width) continue; + var c = @as(f32, @floatFromInt(row[ui])) / 255.0; + if (cc.check_clip and c > 0) c *= clipCoverage(clips, px, py); if (c > 0) fb.blend(x, y, color, c); } } @@ -336,19 +487,25 @@ fn drawGlyph(fb: *Framebuffer, clips: []const Clip, rect: Rect, color: Color, co fn drawImage(fb: *Framebuffer, clips: []const Clip, rect: Rect, img: canvas_mod.Image) void { if (img.width == 0 or img.height == 0) return; - const b = boundsOf(fb, rect, 0); + const cc = clippedBoundsOf(fb, clips, rect, 0); + const b = cc.bounds; + if (b.empty()) return; + const usc = @as(f32, @floatFromInt(img.width)) / rect.width; + const vsc = @as(f32, @floatFromInt(img.height)) / rect.height; var y = b.y0; while (y < b.y1) : (y += 1) { + const py = @as(f32, @floatFromInt(y)) + 0.5; + const v = (py - rect.y) * vsc; + if (v < 0) continue; + const vi: u32 = @intFromFloat(v); + if (vi >= img.height) continue; var x = b.x0; while (x < b.x1) : (x += 1) { const px = @as(f32, @floatFromInt(x)) + 0.5; - const py = @as(f32, @floatFromInt(y)) + 0.5; - const u = (px - rect.x) / rect.width * @as(f32, @floatFromInt(img.width)); - const v = (py - rect.y) / rect.height * @as(f32, @floatFromInt(img.height)); - if (u < 0 or v < 0) continue; + const u = (px - rect.x) * usc; + if (u < 0) continue; const ui: u32 = @intFromFloat(u); - const vi: u32 = @intFromFloat(v); - if (ui >= img.width or vi >= img.height) continue; + if (ui >= img.width) continue; const idx = (vi * img.width + ui) * 4; const src = Color.fromRgba8( img.pixels[idx + 0], @@ -356,7 +513,8 @@ fn drawImage(fb: *Framebuffer, clips: []const Clip, rect: Rect, img: canvas_mod. img.pixels[idx + 2], img.pixels[idx + 3], ); - fb.blend(x, y, src, clipCoverage(clips, px, py)); + const cov: f32 = if (cc.check_clip) clipCoverage(clips, px, py) else 1; + fb.blend(x, y, src, cov); } } } diff --git a/src/text/atlas.zig b/src/text/atlas.zig index 255f096..b577b44 100644 --- a/src/text/atlas.zig +++ b/src/text/atlas.zig @@ -9,9 +9,12 @@ const ttf = @import("ttf.zig"); const Allocator = std.mem.Allocator; pub const GlyphCache = struct { - const Key = struct { glyph: u16, size_milli: u32 }; + // Glyph indices are per-face, so the face is part of the key: one cache can + // hold glyphs from the primary face and any fallback (emoji) faces. + const Key = struct { face: usize, glyph: u16, size_milli: u32 }; const Map = std.AutoHashMapUnmanaged(Key, ttf.RasterGlyph); + /// Primary face — the default for `get` and what metrics callers read. face: *const ttf.Font, map: Map = .empty, allocator: Allocator, @@ -26,15 +29,25 @@ pub const GlyphCache = struct { self.map.deinit(self.allocator); } - /// Get (rasterizing & caching on miss) the coverage bitmap for a glyph at a - /// pixel size. The returned pointer is valid until the cache is mutated for - /// the same size class or deinited. + /// Get (rasterizing & caching on miss) the coverage bitmap for a glyph of + /// the primary face at a pixel size. The returned pointer is valid until the + /// cache is mutated for the same size class or deinited. pub fn get(self: *GlyphCache, glyph: u16, pixel_size: f32) !*const ttf.RasterGlyph { - const key = Key{ .glyph = glyph, .size_milli = @intFromFloat(@round(pixel_size * 1000)) }; + return self.getFace(self.face, glyph, pixel_size); + } + + /// Like `get`, but for a glyph of an arbitrary `face` (e.g. a fallback emoji + /// face returned by `ttf.Font.resolve`). Rasterizes at that face's own scale. + pub fn getFace(self: *GlyphCache, face: *const ttf.Font, glyph: u16, pixel_size: f32) !*const ttf.RasterGlyph { + const key = Key{ + .face = @intFromPtr(face), + .glyph = glyph, + .size_milli = @intFromFloat(@round(pixel_size * 1000)), + }; const gop = try self.map.getOrPut(self.allocator, key); if (!gop.found_existing) { - const scale = self.face.scaleForPixelSize(pixel_size); - gop.value_ptr.* = try self.face.rasterizeGlyph(self.allocator, glyph, scale); + const scale = face.scaleForPixelSize(pixel_size); + gop.value_ptr.* = try face.rasterizeGlyph(self.allocator, glyph, scale); } return gop.value_ptr; } diff --git a/src/text/font.zig b/src/text/font.zig index b904cfe..12a9ca8 100644 --- a/src/text/font.zig +++ b/src/text/font.zig @@ -23,6 +23,18 @@ pub const Font = struct { pub fn default() Font { return .{ .face = ttf.Font.parse(ttf.inter_ttf) catch unreachable }; } + /// The bundled icon font (a Lucide subset). Its glyphs live in the Private + /// Use Area; see `src/icons.zig`. The embedded blob is known-good. + pub fn icons() Font { + return .{ .face = ttf.Font.parse(ttf.icon_ttf) catch unreachable }; + } + /// The bundled monochrome emoji font (Noto Emoji, OFL). Wire it as a + /// fallback via `face.fallback` so codepoints the primary font lacks (emoji, + /// etc.) render through the normal coverage path. The embedded blob is + /// known-good. + pub fn emoji() Font { + return .{ .face = ttf.Font.parse(ttf.emoji_ttf) catch unreachable }; + } pub fn fromBytes(bytes: []const u8) !Font { return .{ .face = try ttf.Font.parse(bytes) }; } @@ -58,7 +70,7 @@ pub fn drawText( const glyphs = try shape.layoutLine(canvas.allocator, face, text, pixel_size, origin.x); defer canvas.allocator.free(glyphs); for (glyphs) |pg| { - const raster = try cache.get(pg.glyph, pixel_size); + const raster = try cache.getFace(pg.face, pg.glyph, pixel_size); if (raster.width == 0 or raster.height == 0) continue; // whitespace const rect = Rect{ .x = pg.x + @as(f32, @floatFromInt(raster.left)), @@ -94,7 +106,7 @@ pub fn drawTextScaled( const glyphs = try shape.layoutLine(canvas.allocator, face, text, pixel_size, origin.x); defer canvas.allocator.free(glyphs); for (glyphs) |pg| { - const raster = try cache.get(pg.glyph, device_px); + const raster = try cache.getFace(pg.face, pg.glyph, device_px); if (raster.width == 0 or raster.height == 0) continue; // whitespace const rect = Rect{ .x = pg.x + @as(f32, @floatFromInt(raster.left)) / scale, @@ -110,6 +122,40 @@ pub fn drawTextScaled( } } +/// Draw a single icon glyph (`codepoint` in the icon font's PUA) centered in +/// `box`, sized to `box`'s smaller side, tinted `color`. Coverage is rasterized +/// at device resolution (`box side * scale`) but the quad is emitted in point +/// space, so the later ×`scale` in `view.renderScaled` lands it crisply — the +/// same HiDPI trick as `drawTextScaled`. No-op for a blank glyph. +pub fn drawIcon( + canvas: *Canvas, + cache: *atlas.GlyphCache, + codepoint: u21, + box: Rect, + scale: f32, + color: Color, +) !void { + const face = cache.face; + const side = @min(box.width, box.height); + // Icon glyphs are designed on the em square, so rasterize the glyph at the + // box side (em == side) and center the inked bitmap within the box. + const raster = try cache.get(face.glyphIndex(codepoint), side * scale); + if (raster.width == 0 or raster.height == 0) return; // blank / missing glyph + const w = @as(f32, @floatFromInt(raster.width)) / scale; + const h = @as(f32, @floatFromInt(raster.height)) / scale; + const rect = Rect{ + .x = box.x + (box.width - w) / 2, + .y = box.y + (box.height - h) / 2, + .width = w, + .height = h, + }; + try canvas.drawGlyph(rect, color, .{ + .width = raster.width, + .height = raster.height, + .data = raster.data, + }); +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -117,6 +163,27 @@ pub fn drawTextScaled( const testing = std.testing; const raster_mod = @import("../render/raster.zig"); +test "Font: icons parses and maps a known PUA codepoint to a glyph" { + const font = Font.icons(); + // lucide `heart` lives at U+E0F2 in the bundled subset. + try testing.expect(font.face.glyphIndex(0xE0F2) != 0); +} + +test "drawIcon: inks pixels for a real icon and is a no-op for a blank slot" { + const font = Font.icons(); + var cache = atlas.GlyphCache.init(testing.allocator, &font.face); + defer cache.deinit(); + var canvas = Canvas.init(testing.allocator); + defer canvas.deinit(); + const box = Rect{ .x = 0, .y = 0, .width = 24, .height = 24 }; + try drawIcon(&canvas, &cache, 0xE0F2, box, 1, Color.black); // heart + try testing.expect(canvas.count() == 1); + canvas.clearCommands(); + // A codepoint not in the subset maps to .notdef/blank → no command. + try drawIcon(&canvas, &cache, 0x0041, box, 1, Color.black); // 'A' + try testing.expect(canvas.count() == 0); +} + test "Font: default parses and measures text" { const font = Font.default(); const size = font.measure("Hello", 16); @@ -157,6 +224,32 @@ test "drawText: actually inks pixels when rasterized" { try testing.expect(dark > 20); } +test "drawText: renders an emoji through the fallback face" { + var font = Font.default(); + var emoji_font = Font.emoji(); + font.face.fallback = &emoji_font.face; + var cache = atlas.GlyphCache.init(testing.allocator, &font.face); + defer cache.deinit(); + var canvas = Canvas.init(testing.allocator); + defer canvas.deinit(); + // "A😀" — one Latin glyph from Inter, one emoji from the fallback face. + try drawText(&canvas, &cache, "A\u{1F600}", 48, Color.black, .{ .x = 0, .y = 0 }); + try testing.expectEqual(@as(usize, 2), canvas.count()); + + var fb = try raster_mod.Framebuffer.init(testing.allocator, 128, 64); + defer fb.deinit(); + fb.clear(Color.white); + try raster_mod.render(testing.allocator, &fb, canvas.commands.items); + // The emoji must ink real pixels (right half of the canvas, past 'A'). + var dark: u32 = 0; + for (0..fb.height) |yy| { + for (64..fb.width) |xx| { + if (fb.at(@intCast(xx), @intCast(yy)).luminance() < 0.5) dark += 1; + } + } + try testing.expect(dark > 20); +} + test "drawText: whitespace produces no glyph commands" { const font = Font.default(); var cache = atlas.GlyphCache.init(testing.allocator, &font.face); diff --git a/src/text/shape.zig b/src/text/shape.zig index 74baba5..77bc5c4 100644 --- a/src/text/shape.zig +++ b/src/text/shape.zig @@ -10,19 +10,24 @@ const Allocator = std.mem.Allocator; pub const PositionedGlyph = struct { glyph: u16, + /// Face the glyph belongs to (the primary face or one of its fallbacks). + /// Glyph indices are per-face, so rasterize from this, not the line's face. + face: *const ttf.Font, /// Pen x at the glyph's origin (before its left-side bearing), in pixels. x: f32, advance: f32, }; -/// Measure the advance width (pixels) of a single line of text. +/// Measure the advance width (pixels) of a single line of text. Each codepoint +/// is resolved through the face's fallback chain, using the resolving face's own +/// units-per-em scale (fonts differ), so fallback glyphs advance correctly. pub fn measureLineWidth(face: *const ttf.Font, text: []const u8, pixel_size: f32) f32 { - const scale = face.scaleForPixelSize(pixel_size); var width: f32 = 0; var it = codepoints(text); while (it.next()) |cp| { - const g = face.glyphIndex(cp); - width += @as(f32, @floatFromInt(face.advanceWidth(g))) * scale; + const r = face.resolve(cp); + const scale = r.face.scaleForPixelSize(pixel_size); + width += @as(f32, @floatFromInt(r.face.advanceWidth(r.glyph))) * scale; } return width; } @@ -44,15 +49,15 @@ pub fn ascentPixels(face: *const ttf.Font, pixel_size: f32) f32 { /// Lay out a single line into positioned glyphs starting at pen x = `start_x`. /// Caller owns the returned slice. pub fn layoutLine(allocator: Allocator, face: *const ttf.Font, text: []const u8, pixel_size: f32, start_x: f32) ![]PositionedGlyph { - const scale = face.scaleForPixelSize(pixel_size); var out: std.ArrayList(PositionedGlyph) = .empty; errdefer out.deinit(allocator); var pen = start_x; var it = codepoints(text); while (it.next()) |cp| { - const g = face.glyphIndex(cp); - const adv = @as(f32, @floatFromInt(face.advanceWidth(g))) * scale; - try out.append(allocator, .{ .glyph = g, .x = pen, .advance = adv }); + const r = face.resolve(cp); + const scale = r.face.scaleForPixelSize(pixel_size); + const adv = @as(f32, @floatFromInt(r.face.advanceWidth(r.glyph))) * scale; + try out.append(allocator, .{ .glyph = r.glyph, .face = r.face, .x = pen, .advance = adv }); pen += adv; } return out.toOwnedSlice(allocator); @@ -61,8 +66,9 @@ pub fn layoutLine(allocator: Allocator, face: *const ttf.Font, text: []const u8, pub const WrappedLine = struct { start: usize, end: usize, width: f32 }; /// Greedy word-wrap of `text` to `max_width` pixels. Returns byte ranges into -/// `text` for each line. Words are split on ASCII spaces; an over-long word is -/// placed on its own line (no mid-word breaking). Caller owns the slice. +/// `text` for each line. Words are split on ASCII spaces; a word wider than a +/// whole line on its own (a path, a URL) is broken at codepoint boundaries. +/// Caller owns the slice. pub fn wrapText(allocator: Allocator, face: *const ttf.Font, text: []const u8, pixel_size: f32, max_width: f32) ![]WrappedLine { var lines: std.ArrayList(WrappedLine) = .empty; errdefer lines.deinit(allocator); @@ -87,6 +93,30 @@ pub fn wrapText(allocator: Allocator, face: *const ttf.Font, text: []const u8, p }); line_start = word_start; } + // An unbreakable word that overflows a whole line by itself: emit + // codepoint-boundary chunks; the final chunk stays as the open line so + // following words can join it. + if (line_start == word_start and + measureLineWidth(face, text[word_start..word_end], pixel_size) > max_width) + { + var it = codepoints(text[word_start..word_end]); + var chunk_start = word_start; + var chunk_w: f32 = 0; + while (true) { + const off = word_start + it.i; + const cp = it.next() orelse break; + const r = face.resolve(cp); + const adv = @as(f32, @floatFromInt(r.face.advanceWidth(r.glyph))) * r.face.scaleForPixelSize(pixel_size); + if (chunk_w + adv > max_width and off > chunk_start) { + try lines.append(allocator, .{ .start = chunk_start, .end = off, .width = chunk_w }); + chunk_start = off; + chunk_w = adv; + } else { + chunk_w += adv; + } + } + line_start = chunk_start; + } line_end = word_end; // handle explicit newline / spaces @@ -182,6 +212,30 @@ test "shape: wrapText breaks on width and on newlines" { try testing.expectEqual(@as(usize, 2), nl.len); } +test "shape: wrapText breaks an unbreakable over-long word mid-token" { + var face = try ttf.Font.parse(ttf.inter_ttf); + // A path-like token with no spaces, wrapped far narrower than its width. + const token = "/Users/david/models/unsloth/FLUX.2-klein-4B-GGUF.safetensors"; + const max = measureLineWidth(&face, token, 16) / 3; + const lines = try wrapText(testing.allocator, &face, token, 16, max); + defer testing.allocator.free(lines); + try testing.expect(lines.len >= 3); + // every emitted line fits, and together they cover the whole token + var covered: usize = 0; + for (lines) |l| { + try testing.expect(l.width <= max); + covered += l.end - l.start; + } + try testing.expectEqual(token.len, covered); + + // a broken word's tail still shares its line with the following word + const text = "/an/over/long/unbreakable/path then words"; + const max2 = measureLineWidth(&face, text, 16) / 2; + const mixed = try wrapText(testing.allocator, &face, text, 16, max2); + defer testing.allocator.free(mixed); + for (mixed) |l| try testing.expect(l.width <= max2 + 0.01); +} + test "shape: invalid UTF-8 yields a measurable result (no crash)" { var face = try ttf.Font.parse(ttf.inter_ttf); const bad = [_]u8{ 0xFF, 'A' }; diff --git a/src/text/ttf.zig b/src/text/ttf.zig index 7b8bd02..d8b3a09 100644 --- a/src/text/ttf.zig +++ b/src/text/ttf.zig @@ -66,6 +66,11 @@ pub const Font = struct { ascent: i16, descent: i16, line_gap: i16, + /// Optional fallback face, consulted by `resolve` for codepoints this font + /// lacks (e.g. a monochrome emoji font behind a Latin UI font). The pointee + /// must outlive this font. Glyph indices are per-face, so callers must + /// rasterize each glyph from the face `resolve` returns — not this one. + fallback: ?*const Font = null, pub fn parse(data: []const u8) ParseError!Font { if (data.len < 12) return error.InvalidFont; @@ -149,6 +154,23 @@ pub const Font = struct { return best orelse error.UnsupportedFormat; } + /// A codepoint resolved to the face that actually carries its glyph. + pub const Resolved = struct { face: *const Font, glyph: u16 }; + + /// Resolve a codepoint to a (face, glyph) pair: this font if it has the + /// glyph, else the `fallback` chain. Returns this font's .notdef (glyph 0) + /// when nothing has it. Since glyph indices are per-face, render the glyph + /// from the returned `face`, not necessarily `self`. + pub fn resolve(self: *const Font, codepoint: u21) Resolved { + const g = self.glyphIndex(codepoint); + if (g != 0) return .{ .face = self, .glyph = g }; + if (self.fallback) |fb| { + const fg = fb.glyphIndex(codepoint); + if (fg != 0) return .{ .face = fb, .glyph = fg }; + } + return .{ .face = self, .glyph = g }; + } + /// Map a Unicode codepoint to a glyph index (0 = .notdef / missing). pub fn glyphIndex(self: Font, codepoint: u21) u16 { const be = Be{ .data = self.data }; @@ -582,6 +604,8 @@ fn windingInside(edges: []const Edge, sx: f32, sy: f32) bool { const testing = std.testing; pub const inter_ttf = @embedFile("inter_font"); +pub const icon_ttf = @embedFile("icon_font"); +pub const emoji_ttf = @embedFile("emoji_font"); fn coverageSum(g: RasterGlyph) u64 { var s: u64 = 0; @@ -596,6 +620,31 @@ test "ttf: parse Inter header" { try testing.expect(font.ascent > 0); } +test "ttf: emoji font parses and resolves via fallback chain" { + const inter = try Font.parse(inter_ttf); + var emoji = try Font.parse(emoji_ttf); + // The bundled emoji font carries a real glyph for U+1F600 (😀)… + try testing.expect(emoji.glyphIndex(0x1F600) != 0); + // …which Inter lacks, so resolve falls through to it. + try testing.expect(inter.glyphIndex(0x1F600) == 0); + + var primary = inter; + primary.fallback = &emoji; + const r = primary.resolve(0x1F600); + try testing.expect(r.face == &emoji); + try testing.expect(r.glyph == emoji.glyphIndex(0x1F600)); + // ASCII still resolves to the primary face. + const a = primary.resolve('A'); + try testing.expect(a.face == &primary); + try testing.expect(a.glyph == inter.glyphIndex('A')); + // A rasterized emoji glyph actually inks pixels. + const scale = emoji.scaleForPixelSize(48); + const g = try emoji.rasterizeGlyph(testing.allocator, r.glyph, scale); + defer g.deinit(testing.allocator); + try testing.expect(g.width > 5 and g.height > 5); + try testing.expect(coverageSum(g) > 0); +} + test "ttf: cmap maps ASCII letters to nonzero glyphs" { const font = try Font.parse(inter_ttf); try testing.expect(font.glyphIndex('A') != 0); diff --git a/src/theme/kde.zig b/src/theme/kde.zig new file mode 100644 index 0000000..2681683 --- /dev/null +++ b/src/theme/kde.zig @@ -0,0 +1,214 @@ +//! KDE Plasma theme (Breeze): the Linux desktop look — soft neutral surfaces, +//! a cyan-blue accent, gently rounded 3px corners, and subtle vertical +//! gradients with thin borders. Light (Breeze) & dark (Breeze Dark) follow the +//! OS appearance. + +const std = @import("std"); +const theme = @import("theme.zig"); +const Color = @import("../render/color.zig").Color; +const geom = @import("../layout/geometry.zig"); + +const Theme = theme.Theme; +const Typography = theme.Typography; +const Metrics = theme.Metrics; +const Surface = theme.Surface; +const ControlState = theme.ControlState; +const Role = theme.Role; +const Rect = geom.Rect; +const Err = std.mem.Allocator.Error; + +/// Noto Sans / Oxygen scale. +const typography = Typography{ + .large_title = .{ .size = 24, .weight = .bold }, + .title = .{ .size = 20, .weight = .regular }, + .title2 = .{ .size = 16, .weight = .regular }, + .title3 = .{ .size = 14, .weight = .medium }, + .headline = .{ .size = 13, .weight = .bold }, + .body = .{ .size = 13, .weight = .regular }, + .callout = .{ .size = 12, .weight = .regular }, + .subheadline = .{ .size = 12, .weight = .regular }, + .footnote = .{ .size = 11, .weight = .regular }, + .caption = .{ .size = 11, .weight = .regular }, + .caption2 = .{ .size = 10, .weight = .regular }, +}; + +/// Breeze rounds corners by ~3px and uses a slightly tall control. +const metrics = Metrics{ + .corner_radius = 6, + .control_corner_radius = 3, + .panel_corner_radius = 8, + .selection_corner_radius = 3, + .window_corner_radius = 6, + .control_height = 28, + .hairline = 1, +}; + +// --------------------------------------------------------------------------- +// Painter — subtle gradients + thin borders +// --------------------------------------------------------------------------- + +fn button(s: Surface, rect: Rect, role: Role, st: ControlState) Err!Color { + const r = s.metrics.control_corner_radius; + if (role == .plain) return s.palette.accent; + if (role == .destructive) { + try s.vGradient(rect, r, s.palette.destructive.lighten(0.08), s.palette.destructive.darken(0.06)); + try s.stroke(rect, r, 1, s.palette.destructive.darken(0.15)); + return s.palette.on_accent; + } + // Breeze buttons: a faint top-lit gradient over the control face with a thin + // border; hover lifts toward the accent. + const base = s.palette.control_track; + const top = if (st.hovered) base.lighten(0.12) else base.lighten(0.05); + try s.vGradient(rect, r, top, base.darken(0.04)); + const border = if (st.hovered or st.focused) s.palette.accent else s.palette.control_border; + try s.stroke(rect, r, 1, border); + return s.palette.label; +} + +fn field(s: Surface, rect: Rect, radius: f32, st: ControlState) Err!void { + try s.fill(rect, radius, s.palette.control_background); + const c = if (st.focused) s.palette.accent else s.palette.control_border; + const w: f32 = if (st.focused) 2 else 1; + try s.stroke(rect, radius, w, c); +} + +fn segmentedTrack(s: Surface, rect: Rect) Err!void { + const r = s.metrics.control_corner_radius; + try s.fill(rect, r, s.palette.control_track); + try s.stroke(rect, r, 1, s.palette.control_border); +} + +fn segmentedSelection(s: Surface, seg: Rect) Err!Color { + const inner = seg.insetBy(2, 2); + const r = s.metrics.control_corner_radius - 1; + try s.vGradient(inner, r, s.palette.accent.lighten(0.06), s.palette.accent.darken(0.04)); + return s.palette.on_accent; +} + +fn switchTrack(s: Surface, rect: Rect, on: bool) Err!void { + const r = rect.height / 2; + if (on) { + try s.fill(rect, r, s.palette.accent); + } else { + try s.fill(rect, r, s.palette.control_track.darken(0.04)); + try s.stroke(rect, r, 1, s.palette.control_border); + } +} + +fn switchKnob(s: Surface, knob: Rect, on: bool) Err!void { + _ = on; + // A pale circular handle with a thin Breeze border. + const r = knob.width / 2; + try s.fillCircle(.{ .x = knob.midX(), .y = knob.midY() }, r, s.palette.control_background); + try s.stroke(knob, r, 1, s.palette.control_border); +} + +fn slider(s: Surface, track: Rect, frac: f32, knob: Rect, st: ControlState) Err!void { + _ = st; + const tr = track.height / 2; + try s.fill(track, tr, s.palette.control_track.darken(0.04)); + try s.stroke(track, tr, 1, s.palette.control_border); + const filled = Rect{ .x = track.x, .y = track.y, .width = track.width * frac, .height = track.height }; + try s.fill(filled, tr, s.palette.accent); + const r = knob.width / 2; + try s.fillCircle(.{ .x = knob.midX(), .y = knob.midY() }, r, s.palette.control_background); + try s.stroke(knob, r, 1, s.palette.control_border); +} + +fn stepperBox(s: Surface, rect: Rect, st: ControlState) Err!void { + _ = st; + const r = s.metrics.control_corner_radius; + try s.vGradient(rect, r, s.palette.control_track.lighten(0.05), s.palette.control_track.darken(0.04)); + try s.stroke(rect, r, 1, s.palette.control_border); +} + +fn progress(s: Surface, rect: Rect, frac: f32) Err!void { + const r = rect.height / 2; + try s.fill(rect, r, s.palette.control_track.darken(0.04)); + try s.stroke(rect, r, 1, s.palette.control_border); + const filled = Rect{ .x = rect.x, .y = rect.y, .width = rect.width * std.math.clamp(frac, 0, 1), .height = rect.height }; + try s.fill(filled, r, s.palette.accent); +} + +fn panel(s: Surface, rect: Rect, radius: f32) Err!void { + try s.fill(rect, radius, s.palette.window_background); + try s.stroke(rect, radius, 1, s.palette.control_border); +} + +pub const painter = theme.Painter{ + .button = button, + .field = field, + .segmentedTrack = segmentedTrack, + .segmentedSelection = segmentedSelection, + .switchTrack = switchTrack, + .switchKnob = switchKnob, + .slider = slider, + .stepperBox = stepperBox, + .progress = progress, + .panel = panel, +}; + +// --------------------------------------------------------------------------- +// Themes +// --------------------------------------------------------------------------- + +pub const light = Theme{ + .name = "KDE Plasma", + .scheme = .light, + .typography = typography, + .metrics = metrics, + .painter = painter, + .colors = .{ + .accent = Color.fromRgb8(61, 174, 233), // Breeze blue + .label = Color.fromRgb8(35, 38, 41), // Breeze "Text" + .secondary_label = Color.fromRgb8(91, 95, 99), + .tertiary_label = Color.fromRgb8(136, 140, 144), + .on_accent = Color.white, + .window_background = Color.fromRgb8(239, 240, 241), // Breeze Window + .secondary_background = Color.fromRgb8(227, 229, 231), + .control_background = Color.fromRgb8(252, 252, 252), // Breeze View + .separator = Color.fromRgb8(188, 192, 196), + .selection = Color.fromRgb8(61, 174, 233), + .destructive = Color.fromRgb8(218, 68, 83), // Breeze red + .hover = Color.fromRgb8(61, 174, 233).withAlpha(0.12), + .control_border = Color.fromRgb8(188, 192, 196), + .glass = Color.fromRgb8(239, 240, 241).withAlpha(0.85), + .control_track = Color.fromRgb8(239, 240, 241), + }, +}; + +pub const dark = Theme{ + .name = "KDE Plasma", + .scheme = .dark, + .typography = typography, + .metrics = metrics, + .painter = painter, + .colors = .{ + .accent = Color.fromRgb8(61, 174, 233), + .label = Color.fromRgb8(252, 252, 252), + .secondary_label = Color.fromRgb8(189, 195, 199), + .tertiary_label = Color.fromRgb8(127, 140, 141), + .on_accent = Color.white, + .window_background = Color.fromRgb8(42, 46, 50), // Breeze Dark Window + .secondary_background = Color.fromRgb8(49, 54, 59), + .control_background = Color.fromRgb8(35, 38, 41), // Breeze Dark View + .separator = Color.fromRgb8(81, 86, 91), + .selection = Color.fromRgb8(61, 174, 233), + .destructive = Color.fromRgb8(218, 68, 83), + .hover = Color.fromRgb8(61, 174, 233).withAlpha(0.18), + .control_border = Color.fromRgb8(81, 86, 91), + .glass = Color.fromRgb8(42, 46, 50).withAlpha(0.85), + .control_track = Color.fromRgb8(49, 54, 59), + }, +}; + +const testing = std.testing; + +test "kde: breeze blue accent and rounded controls" { + try testing.expect(light.colors.accent.b > light.colors.accent.r); + try testing.expectEqual(@as(f32, 3), light.metrics.control_corner_radius); +} + +test "kde: light and dark differ" { + try testing.expect(light.colors.window_background.isDark() != dark.colors.window_background.isDark()); +} diff --git a/src/theme/macos.zig b/src/theme/macos.zig index 248b909..2fb00a4 100644 --- a/src/theme/macos.zig +++ b/src/theme/macos.zig @@ -1,12 +1,19 @@ -//! Default macOS theme presets (light & dark), tuned to resemble Apple's system -//! appearance. Values approximate macOS's dynamic system colors and the SF Pro -//! macOS type scale. No Apple assets are used — only color/size tokens. +//! macOS theme (light & dark): Apple's "Liquid Glass" appearance — translucent +//! surfaces, vertical gradient sheens, and bright edge rims. The painter draws +//! the chrome; palette values approximate macOS's dynamic system colors and the +//! SF Pro type scale. No Apple assets are used — only color/size tokens. +const std = @import("std"); const theme = @import("theme.zig"); const Color = @import("../render/color.zig").Color; +const geom = @import("../layout/geometry.zig"); + const Theme = theme.Theme; -const TextStyle = theme.TextStyle; const Typography = theme.Typography; +const Surface = theme.Surface; +const ControlState = theme.ControlState; +const Role = theme.Role; +const Rect = geom.Rect; /// macOS type scale (point sizes / weights roughly matching the system). const typography = Typography{ @@ -23,39 +30,180 @@ const typography = Typography{ .caption2 = .{ .size = 10, .weight = .regular }, }; +// --------------------------------------------------------------------------- +// Painter — the Liquid Glass look +// --------------------------------------------------------------------------- + +/// The Liquid Glass treatment shared by buttons, the selected segment, and the +/// switch track: a vertical sheen over `bg`, a bright rim, and a specular +/// highlight tucked under the top edge so the capsule reads as curved glass. +fn glassCapsule(s: Surface, rect: Rect, radius: f32, bg: Color, dim: f32) std.mem.Allocator.Error!void { + try s.vGradient(rect, radius, bg.lighten(0.22).multiplyAlpha(dim), bg.darken(0.06).multiplyAlpha(dim)); + try s.stroke(rect, radius, s.metrics.hairline, Color.white.withAlpha(0.30 * dim)); + const inset = @min(radius, rect.width / 2); + try s.lineSeg( + .{ .x = rect.x + inset, .y = rect.y + 1.5 }, + .{ .x = rect.maxX() - inset, .y = rect.y + 1.5 }, + 1, + Color.white.withAlpha(0.35 * dim), + ); +} + +fn button(s: Surface, rect: Rect, role: Role, st: ControlState) std.mem.Allocator.Error!Color { + const dim: f32 = if (st.disabled) 0.4 else 1.0; + if (role == .plain) return s.palette.accent; + var bg = if (role == .destructive) s.palette.destructive else s.palette.accent; + if (st.pressed) bg = bg.darken(0.08) else if (st.hovered) bg = bg.lighten(0.06); + // macOS 26 buttons are full capsules, not rounded rects. + try glassCapsule(s, rect, rect.height / 2, bg, dim); + return s.palette.on_accent; +} + +fn field(s: Surface, rect: Rect, radius: f32, st: ControlState) std.mem.Allocator.Error!void { + try s.fill(rect, radius, s.palette.control_background); + const c = if (st.focused) s.palette.accent else s.palette.separator; + const w: f32 = if (st.focused) 2 else s.metrics.hairline; + try s.stroke(rect, radius, w, c); +} + +fn segmentedTrack(s: Surface, rect: Rect) std.mem.Allocator.Error!void { + // A recessed translucent capsule trough with a hairline rim. + const r = rect.height / 2; + try s.fill(rect, r, s.palette.control_track.over(s.palette.window_background)); + try s.stroke(rect, r, s.metrics.hairline, s.palette.control_border); +} + +fn segmentedSelection(s: Surface, seg: Rect) std.mem.Allocator.Error!Color { + // The selected segment floats as an accent glass capsule (the macOS 26 + // tinted segmented style), so its label flips to `on_accent`. + const inner = seg.insetBy(2, 2); + try glassCapsule(s, inner, inner.height / 2, s.palette.accent, 1); + return s.palette.on_accent; +} + +fn switchTrack(s: Surface, rect: Rect, on: bool) std.mem.Allocator.Error!void { + const r = rect.height / 2; + if (on) { + // The on track gets the same glass treatment as a button. + try glassCapsule(s, rect, r, s.palette.accent, 1); + } else { + try s.fill(rect, r, s.palette.control_track.over(s.palette.control_background)); + try s.stroke(rect, r, s.metrics.hairline, s.palette.control_border); + } +} + +fn switchKnob(s: Surface, knob: Rect, on: bool) std.mem.Allocator.Error!void { + _ = on; // the knob looks the same on or off; its position encodes the state + const r = knob.width / 2; + try s.fillCircle(.{ .x = knob.midX(), .y = knob.midY() }, r, Color.white); + // a faint rim grounds the knob against the track + try s.stroke(knob, r, s.metrics.hairline, Color.black.withAlpha(0.10)); +} + +fn slider(s: Surface, track: Rect, frac: f32, knob: Rect, st: ControlState) std.mem.Allocator.Error!void { + _ = st; + const tr = track.height / 2; + try s.fill(track, tr, s.palette.separator.over(s.palette.control_background)); + const filled = Rect{ .x = track.x, .y = track.y, .width = track.width * frac, .height = track.height }; + try s.fill(filled, tr, s.palette.accent); + const r = knob.width / 2; + try s.fillCircle(.{ .x = knob.midX(), .y = knob.midY() }, r, Color.white); + try s.stroke(knob, r, 1, s.palette.separator); +} + +fn stepperBox(s: Surface, rect: Rect, st: ControlState) std.mem.Allocator.Error!void { + _ = st; + const r = s.metrics.control_corner_radius; + try s.fill(rect, r, s.palette.control_background); + try s.stroke(rect, r, s.metrics.hairline, s.palette.separator); +} + +fn progress(s: Surface, rect: Rect, frac: f32) std.mem.Allocator.Error!void { + const r = rect.height / 2; + try s.fill(rect, r, s.palette.separator.over(s.palette.control_background)); + const filled = Rect{ .x = rect.x, .y = rect.y, .width = rect.width * std.math.clamp(frac, 0, 1), .height = rect.height }; + try s.fill(filled, r, s.palette.accent); +} + +fn panel(s: Surface, rect: Rect, radius: f32) std.mem.Allocator.Error!void { + try s.fill(rect, radius, s.palette.control_background); + try s.stroke(rect, radius, s.metrics.hairline, s.palette.separator); + // The glass bevel: a soft specular along the top inside edge (invisible on + // a white light-mode panel, a subtle catch-light on a dark one). + const inset = @min(radius, rect.width / 2); + try s.lineSeg( + .{ .x = rect.x + inset, .y = rect.y + 1 }, + .{ .x = rect.maxX() - inset, .y = rect.y + 1 }, + 1, + Color.white.withAlpha(0.12), + ); +} + +pub const painter = theme.Painter{ + .button = button, + .field = field, + .segmentedTrack = segmentedTrack, + .segmentedSelection = segmentedSelection, + .switchTrack = switchTrack, + .switchKnob = switchKnob, + .slider = slider, + .stepperBox = stepperBox, + .progress = progress, + .panel = panel, +}; + +// --------------------------------------------------------------------------- +// Themes +// --------------------------------------------------------------------------- + pub const light = Theme{ + .name = "macOS", .scheme = .light, .typography = typography, + .painter = painter, .colors = .{ .accent = Color.fromRgb8(0, 122, 255), // System Blue .label = Color.black.withAlpha(0.85), // labelColor .secondary_label = Color.black.withAlpha(0.50), .tertiary_label = Color.black.withAlpha(0.26), .on_accent = Color.white, - .window_background = Color.fromRgb8(236, 236, 236), - .secondary_background = Color.fromRgb8(246, 246, 246), + // macOS 26 windows read brighter and a touch warmer than the flat grey. + .window_background = Color.fromRgb8(242, 242, 247), + .secondary_background = Color.fromRgb8(250, 250, 252), .control_background = Color.white, .separator = Color.black.withAlpha(0.10), .selection = Color.fromRgb8(0, 122, 255), .destructive = Color.fromRgb8(255, 59, 48), // System Red + // Liquid Glass + .hover = Color.black.withAlpha(0.06), + .control_border = Color.black.withAlpha(0.08), + .glass = Color.white.withAlpha(0.65), + .control_track = Color.black.withAlpha(0.06), }, }; pub const dark = Theme{ + .name = "macOS", .scheme = .dark, .typography = typography, + .painter = painter, .colors = .{ .accent = Color.fromRgb8(10, 132, 255), // System Blue (dark) .label = Color.white.withAlpha(0.85), .secondary_label = Color.white.withAlpha(0.55), .tertiary_label = Color.white.withAlpha(0.25), .on_accent = Color.white, - .window_background = Color.fromRgb8(30, 30, 30), - .secondary_background = Color.fromRgb8(40, 40, 40), - .control_background = Color.fromRgb8(44, 44, 46), + .window_background = Color.fromRgb8(28, 28, 30), + .secondary_background = Color.fromRgb8(38, 38, 41), + .control_background = Color.fromRgb8(54, 54, 58), .separator = Color.white.withAlpha(0.15), .selection = Color.fromRgb8(10, 132, 255), .destructive = Color.fromRgb8(255, 69, 58), + // Liquid Glass + .hover = Color.white.withAlpha(0.10), + .control_border = Color.white.withAlpha(0.14), + .glass = Color.fromRgb8(60, 60, 64).withAlpha(0.55), + .control_track = Color.white.withAlpha(0.10), }, }; @@ -66,7 +214,6 @@ pub const default = light; // Tests // --------------------------------------------------------------------------- -const std = @import("std"); const testing = std.testing; test "macos: light and dark differ in background and label" { diff --git a/src/theme/mui.zig b/src/theme/mui.zig new file mode 100644 index 0000000..650d38d --- /dev/null +++ b/src/theme/mui.zig @@ -0,0 +1,222 @@ +//! Material UI theme (Google Material Design): flat, ink-on-paper surfaces with +//! a bold primary color, 4px rounded corners, "contained" filled buttons, thin +//! tracks with circular thumbs, and outlined inputs. Light & dark follow the OS +//! appearance (Material's baseline `#1976d2` primary on light, `#90caf9` on +//! dark). The renderer has no box-shadow primitive, so elevation is suggested +//! with subtle tints/borders rather than drop shadows. + +const std = @import("std"); +const theme = @import("theme.zig"); +const Color = @import("../render/color.zig").Color; +const geom = @import("../layout/geometry.zig"); + +const Theme = theme.Theme; +const Typography = theme.Typography; +const Metrics = theme.Metrics; +const Surface = theme.Surface; +const ControlState = theme.ControlState; +const Role = theme.Role; +const Rect = geom.Rect; +const Err = std.mem.Allocator.Error; + +/// Roboto-ish scale: Material's type ramp, scaled for a desktop UI. +const typography = Typography{ + .large_title = .{ .size = 28, .weight = .medium }, + .title = .{ .size = 24, .weight = .regular }, + .title2 = .{ .size = 20, .weight = .medium }, + .title3 = .{ .size = 18, .weight = .regular }, + .headline = .{ .size = 16, .weight = .medium }, + .body = .{ .size = 14, .weight = .regular }, + .callout = .{ .size = 14, .weight = .regular }, + .subheadline = .{ .size = 13, .weight = .regular }, + .footnote = .{ .size = 12, .weight = .regular }, + .caption = .{ .size = 12, .weight = .regular }, + .caption2 = .{ .size = 11, .weight = .regular }, +}; + +/// Material rounds by 4px and uses a 36px-tall control (the spec button height). +const metrics = Metrics{ + .corner_radius = 8, + .control_corner_radius = 4, + .panel_corner_radius = 4, + .selection_corner_radius = 4, + .window_corner_radius = 0, + .control_height = 36, + .hairline = 1, +}; + +// --------------------------------------------------------------------------- +// Painter — flat fills, a strong primary, circular thumbs +// --------------------------------------------------------------------------- + +fn button(s: Surface, rect: Rect, role: Role, st: ControlState) Err!Color { + // A "text button": label-only, no container. + if (role == .plain) return s.palette.accent; + const r = s.metrics.control_corner_radius; + if (st.disabled) { + // Material disables to neutral grey (action.disabledBackground), not a + // faded primary. + try s.fill(rect, r, s.palette.label.withAlpha(0.12)); + return s.palette.label.withAlpha(0.38); + } + const base = if (role == .destructive) s.palette.destructive else s.palette.accent; + // Material darkens the container on hover (an action-state overlay). + const fillc = if (st.hovered) base.darken(0.08) else base; + try s.fill(rect, r, fillc); + return s.palette.on_accent; +} + +fn field(s: Surface, rect: Rect, radius: f32, st: ControlState) Err!void { + // The Material "outlined" text field: a thin box that thickens and tints to + // the primary color on focus. + try s.fill(rect, radius, s.palette.control_background); + const c = if (st.focused) s.palette.accent else s.palette.control_border; + const w: f32 = if (st.focused) 2 else 1; + try s.stroke(rect, radius, w, c); +} + +fn segmentedTrack(s: Surface, rect: Rect) Err!void { + // A toggle-button group: an outlined container. + const r = s.metrics.control_corner_radius; + try s.fill(rect, r, s.palette.control_background); + try s.stroke(rect, r, 1, s.palette.control_border); +} + +fn segmentedSelection(s: Surface, seg: Rect) Err!Color { + // The selected toggle button is a primary-tinted cell with primary text + // (MUI's ToggleButton selected state). + try s.fill(seg.insetBy(1, 1), s.metrics.control_corner_radius, s.palette.accent.withAlpha(0.12)); + return s.palette.accent; +} + +fn switchTrack(s: Surface, rect: Rect, on: bool) Err!void { + // The Material switch is a thin track the thumb overhangs — not a full- + // height iOS pill. On tints to a translucent primary; off is a grey wash. + const h: f32 = 14; + const track = Rect{ .x = rect.x + 2, .y = rect.midY() - h / 2, .width = rect.width - 4, .height = h }; + const c = if (on) s.palette.accent.withAlpha(0.5) else s.palette.control_track; + try s.fill(track, h / 2, c); +} + +fn switchKnob(s: Surface, knob: Rect, on: bool) Err!void { + // The thumb is wider than the track. On: solid primary. Off: a pale disc + // with a hairline border standing in for the spec's elevation shadow. + const r = knob.width / 2; + const off_thumb = if (s.scheme == .dark) Color.fromRgb8(189, 189, 189) else Color.white; + const c = if (on) s.palette.accent else off_thumb; + try s.fillCircle(.{ .x = knob.midX(), .y = knob.midY() }, r, c); + if (!on) try s.stroke(knob, r, 1, s.palette.control_border); +} + +fn slider(s: Surface, track: Rect, frac: f32, knob: Rect, st: ControlState) Err!void { + _ = st; + const tr = track.height / 2; + // An unfilled rail in a faded primary, a solid primary fill, a primary thumb. + try s.fill(track, tr, s.palette.accent.withAlpha(0.38)); + const filled = Rect{ .x = track.x, .y = track.y, .width = track.width * frac, .height = track.height }; + try s.fill(filled, tr, s.palette.accent); + try s.fillCircle(.{ .x = knob.midX(), .y = knob.midY() }, knob.width / 2, s.palette.accent); +} + +fn stepperBox(s: Surface, rect: Rect, st: ControlState) Err!void { + _ = st; + const r = s.metrics.control_corner_radius; + try s.fill(rect, r, s.palette.control_background); + try s.stroke(rect, r, 1, s.palette.control_border); +} + +fn progress(s: Surface, rect: Rect, frac: f32) Err!void { + const r = rect.height / 2; + try s.fill(rect, r, s.palette.accent.withAlpha(0.3)); + const filled = Rect{ .x = rect.x, .y = rect.y, .width = rect.width * std.math.clamp(frac, 0, 1), .height = rect.height }; + try s.fill(filled, r, s.palette.accent); +} + +fn panel(s: Surface, rect: Rect, radius: f32) Err!void { + // A raised surface (card/menu/dialog). No shadow primitive, so a thin border + // grounds it against the background. + try s.fill(rect, radius, s.palette.control_background); + try s.stroke(rect, radius, 1, s.palette.separator); +} + +pub const painter = theme.Painter{ + .button = button, + .field = field, + .segmentedTrack = segmentedTrack, + .segmentedSelection = segmentedSelection, + .switchTrack = switchTrack, + .switchKnob = switchKnob, + .slider = slider, + .stepperBox = stepperBox, + .progress = progress, + .panel = panel, +}; + +// --------------------------------------------------------------------------- +// Themes +// --------------------------------------------------------------------------- + +pub const light = Theme{ + .name = "Material", + .scheme = .light, + .typography = typography, + .metrics = metrics, + .painter = painter, + .colors = .{ + .accent = Color.fromRgb8(25, 118, 210), // Material primary (#1976d2) + .label = Color.black.withAlpha(0.87), // text primary + .secondary_label = Color.black.withAlpha(0.60), + .tertiary_label = Color.black.withAlpha(0.38), + .on_accent = Color.white, + .window_background = Color.fromRgb8(250, 250, 250), // grey 50 + .secondary_background = Color.white, + .control_background = Color.white, + .separator = Color.black.withAlpha(0.12), // divider + .selection = Color.fromRgb8(25, 118, 210), + .destructive = Color.fromRgb8(211, 47, 47), // error (#d32f2f) + .hover = Color.black.withAlpha(0.04), + .control_border = Color.black.withAlpha(0.23), // outlined input border + .glass = Color.white.withAlpha(0.7), + .control_track = Color.black.withAlpha(0.38), // switch-off track (spec) + }, +}; + +pub const dark = Theme{ + .name = "Material", + .scheme = .dark, + .typography = typography, + .metrics = metrics, + .painter = painter, + .colors = .{ + .accent = Color.fromRgb8(144, 202, 249), // dark primary (#90caf9) + .label = Color.white, + .secondary_label = Color.white.withAlpha(0.70), + .tertiary_label = Color.white.withAlpha(0.50), + .on_accent = Color.black.withAlpha(0.87), // dark text on the light-blue primary + .window_background = Color.fromRgb8(18, 18, 18), // Material dark background + .secondary_background = Color.fromRgb8(30, 30, 30), + .control_background = Color.fromRgb8(30, 30, 30), // surface (#1e1e1e) + .separator = Color.white.withAlpha(0.12), + .selection = Color.fromRgb8(144, 202, 249), + .destructive = Color.fromRgb8(244, 67, 54), // error (#f44336) + .hover = Color.white.withAlpha(0.08), + .control_border = Color.white.withAlpha(0.23), + .glass = Color.fromRgb8(30, 30, 30).withAlpha(0.8), + .control_track = Color.white.withAlpha(0.3), // switch-off track (spec) + }, +}; + +const testing = std.testing; + +test "mui: material blue primary and 4px controls" { + try testing.expect(light.colors.accent.b > light.colors.accent.r); + try testing.expectEqual(@as(f32, 4), light.metrics.control_corner_radius); +} + +test "mui: light and dark backgrounds differ" { + try testing.expect(light.colors.window_background.isDark() != dark.colors.window_background.isDark()); +} + +test "mui: dark mode uses dark text on the light primary" { + try testing.expect(dark.colors.on_accent.isDark()); +} diff --git a/src/theme/registry.zig b/src/theme/registry.zig new file mode 100644 index 0000000..5386804 --- /dev/null +++ b/src/theme/registry.zig @@ -0,0 +1,80 @@ +//! Theme registry: the catalog of built-in theme families and the logic that +//! resolves one to a concrete `Theme` for the current OS color scheme. +//! +//! A *family* (macOS, Windows 10, …) ships a light palette and, when the era +//! supports it, a dark one. `forScheme` picks the right palette — falling back +//! to light for families with no dark mode (Windows 2000) — so the app can wire +//! the OS appearance (`app.systemTheme()`) straight through. + +const std = @import("std"); +const theme = @import("theme.zig"); +const macos = @import("macos.zig"); +const win2000 = @import("win2000.zig"); +const windows10 = @import("windows10.zig"); +const kde = @import("kde.zig"); +const mui = @import("mui.zig"); + +const Theme = theme.Theme; +const ColorScheme = theme.ColorScheme; + +/// The built-in theme families. +pub const Family = enum { + macos, + windows10, + win2000, + kde, + mui, + + /// A human-readable name for menus/switchers. + pub fn displayName(self: Family) []const u8 { + return switch (self) { + .macos => "macOS", + .windows10 => "Windows 10", + .win2000 => "Windows 2000", + .kde => "KDE Plasma", + .mui => "Material", + }; + } + + /// Whether this family has a dark palette (vs. light-only). + pub fn supportsDark(self: Family) bool { + return self != .win2000; + } +}; + +/// Resolve a family to a concrete `Theme` for `scheme`. Families without a dark +/// mode ignore a `.dark` request and stay on their light palette. +pub fn forScheme(family: Family, scheme: ColorScheme) Theme { + const dark = scheme == .dark and family.supportsDark(); + return switch (family) { + .macos => if (dark) macos.dark else macos.light, + .windows10 => if (dark) windows10.dark else windows10.light, + .win2000 => win2000.light, + .kde => if (dark) kde.dark else kde.light, + .mui => if (dark) mui.dark else mui.light, + }; +} + +/// All families, in display order (handy for building a theme switcher). +pub const all = [_]Family{ .macos, .windows10, .win2000, .kde, .mui }; + +const testing = std.testing; + +test "registry: win2000 stays light even in dark mode" { + const t = forScheme(.win2000, .dark); + try testing.expectEqual(ColorScheme.light, t.scheme); + try testing.expect(!Family.win2000.supportsDark()); +} + +test "registry: macOS follows the requested scheme" { + try testing.expectEqual(ColorScheme.dark, forScheme(.macos, .dark).scheme); + try testing.expectEqual(ColorScheme.light, forScheme(.macos, .light).scheme); +} + +test "registry: every family resolves and names itself" { + for (all) |f| { + const t = forScheme(f, .light); + try testing.expect(t.name.len > 0); + try testing.expect(f.displayName().len > 0); + } +} diff --git a/src/theme/theme.zig b/src/theme/theme.zig index 121491a..caee007 100644 --- a/src/theme/theme.zig +++ b/src/theme/theme.zig @@ -1,11 +1,36 @@ -//! Theme: the design-token vocabulary that gives zigui its look. A `Theme` -//! bundles semantic color roles, a typographic scale, and layout metrics. -//! Components read tokens from the active theme rather than hard-coding values, -//! so the whole UI restyles by swapping one struct (see `theme/macos.zig` for -//! the default macOS light/dark presets). +//! Theme: the design-token vocabulary *and* drawing strategy that give a zigui +//! app its look. A `Theme` bundles three things: +//! +//! * a **`Palette`** of semantic color roles (resolved for one `ColorScheme`), +//! * a typographic scale and layout **`Metrics`**, and +//! * a **`Painter`** — a small vtable of functions that draw the *chrome* of +//! the theme-defining controls (buttons, switches, fields, segmented +//! controls). +//! +//! Splitting the palette from the painter is what lets very different looks +//! coexist: macOS draws translucent "liquid glass" with gradient sheens, while +//! Windows 2000 chisels raised/sunken bevels from the same token vocabulary. +//! Components read tokens and call the painter rather than hard-coding a look, +//! so the whole UI restyles by swapping one `Theme`. +//! +//! A `Painter` depends only on the renderer (`Canvas`, `Color`, geometry) and +//! these tokens — never on the view layer — so the two compose without a +//! dependency cycle: the view layer owns text, layout, and interaction; the +//! painter owns decoration. +//! +//! Concrete themes live alongside this file (`macos.zig`, `win2000.zig`, +//! `windows10.zig`, `kde.zig`); `registry.zig` selects one for the OS color +//! scheme. const std = @import("std"); const Color = @import("../render/color.zig").Color; +const geom = @import("../layout/geometry.zig"); +const canvas_mod = @import("../render/canvas.zig"); + +const Allocator = std.mem.Allocator; +const Rect = geom.Rect; +const Point = geom.Point; +const Canvas = canvas_mod.Canvas; pub const ColorScheme = enum { light, dark }; @@ -30,9 +55,15 @@ pub const TextStyle = struct { line_height: f32 = 1.2, }; -/// Semantic color roles. Names follow macOS's dynamic system colors so the same -/// role resolves to an appropriate value in light or dark mode. -pub const Colors = struct { +/// The semantic appearance of a control, used by buttons (and reusable by other +/// roled controls). `plain` is a borderless, label-only button. +pub const Role = enum { normal, destructive, plain }; + +/// Semantic color roles, resolved for a single `ColorScheme`. The first block +/// follows macOS's dynamic system colors; the bevel block at the end is only +/// meaningful for chiseled (Windows-9x/2000) painters and is defaulted so flat +/// themes can ignore it. +pub const Palette = struct { /// The accent / tint color (macOS "System Blue" by default). accent: Color, /// Primary text/content color (labelColor). @@ -53,10 +84,35 @@ pub const Colors = struct { selection: Color, /// Destructive / error (System Red). destructive: Color, + + // --- Translucent "liquid glass" roles (macOS) ------------------------- + /// A subtle fill painted behind a control/row while the cursor hovers it. + hover: Color, + /// The bright hairline edge along the top/sides of a glass control. + control_border: Color, + /// A translucent tint laid over blurred content for glass panels. + glass: Color, + /// Fill for an unselected segmented/glass control track. + control_track: Color, + + // --- Chiseled bevel roles (Windows 2000 / 9x) ------------------------- + // The 3D edge colors of the classic raised/sunken look. Defaulted so flat + // and glass themes need not spell them out. + /// The face color of a raised control (the button body). + control_face: Color = Color.fromRgb8(212, 208, 200), + /// Brightest bevel edge (outer top-left of a raised control). + control_highlight: Color = Color.white, + /// Light bevel edge (inner top-left). + control_light: Color = Color.fromRgb8(223, 223, 223), + /// Shadow bevel edge (inner bottom-right). + control_shadow: Color = Color.fromRgb8(128, 128, 128), + /// Darkest bevel edge (outer bottom-right). + control_dark_shadow: Color = Color.fromRgb8(64, 64, 64), + /// Label color on a raised/grey control (vs. `on_accent` for tinted ones). + on_control: Color = Color.black, }; -/// The typographic scale, mirroring SwiftUI's `Font.TextStyle` cases at macOS -/// point sizes. +/// The typographic scale, mirroring SwiftUI's `Font.TextStyle` cases. pub const Typography = struct { large_title: TextStyle, title: TextStyle, @@ -85,22 +141,121 @@ pub const Metrics = struct { /// Default content padding. padding: f32 = 8, /// Corner radius for cards / grouped containers. - corner_radius: f32 = 8, + corner_radius: f32 = 12, /// Corner radius for push buttons and controls. - control_corner_radius: f32 = 6, + control_corner_radius: f32 = 7, + /// Corner radius for large panels (sidebars, sheets, popovers). + panel_corner_radius: f32 = 16, /// Standard control (button/field) height. control_height: f32 = 28, + /// Rounded selection-highlight radius for sidebar / list rows. + selection_corner_radius: f32 = 8, /// Window corner radius. window_corner_radius: f32 = 10, /// Hairline thickness for separators/borders at 1x. hairline: f32 = 1, }; +// --------------------------------------------------------------------------- +// Painter: the per-theme drawing strategy +// --------------------------------------------------------------------------- + +/// The interaction state of a control, passed to painter functions so a theme +/// can render pressed/hovered/focused/disabled variants. +pub const ControlState = struct { + pressed: bool = false, + hovered: bool = false, + focused: bool = false, + disabled: bool = false, +}; + +/// Everything a painter needs to draw chrome: the target `canvas`, the active +/// `palette`/`metrics`, the `scheme` (light/dark), and the inherited `opacity`. +/// Convenience methods apply `opacity` so painters read declaratively. +pub const Surface = struct { + canvas: *Canvas, + palette: *const Palette, + metrics: *const Metrics, + scheme: ColorScheme, + opacity: f32 = 1, + + pub fn fill(s: Surface, rect: Rect, radius: f32, c: Color) Allocator.Error!void { + try s.canvas.fillRoundedRect(rect, radius, c.multiplyAlpha(s.opacity)); + } + pub fn stroke(s: Surface, rect: Rect, radius: f32, width: f32, c: Color) Allocator.Error!void { + try s.canvas.strokeRoundedRect(rect, radius, width, c.multiplyAlpha(s.opacity)); + } + /// A top-to-bottom (vertical) gradient filling `rect`. + pub fn vGradient(s: Surface, rect: Rect, radius: f32, top: Color, bottom: Color) Allocator.Error!void { + try s.canvas.push(.{ .linear_gradient = .{ + .rect = rect, + .radius = radius, + .start = .{ .x = rect.x, .y = rect.y }, + .end = .{ .x = rect.x, .y = rect.maxY() }, + .c0 = top.multiplyAlpha(s.opacity), + .c1 = bottom.multiplyAlpha(s.opacity), + } }); + } + pub fn lineSeg(s: Surface, a: Point, b: Point, width: f32, c: Color) Allocator.Error!void { + try s.canvas.line(a, b, width, c.multiplyAlpha(s.opacity)); + } + pub fn fillCircle(s: Surface, center: Point, r: f32, c: Color) Allocator.Error!void { + try s.canvas.fillCircle(center, r, c.multiplyAlpha(s.opacity)); + } +}; + +/// A vtable of chrome-drawing functions — the heart of a theme's identity. Each +/// draws only decoration (fills, borders, bevels, gradients); the view layer +/// draws labels, lays things out, and registers hit regions around them. +pub const Painter = struct { + /// Draw a push-button's background/border in `rect`, then return the color + /// its label should be drawn in (so a theme controls both at once). + button: *const fn (s: Surface, rect: Rect, role: Role, st: ControlState) Allocator.Error!Color, + /// Draw a text-input surface (background + border) with the given corner + /// `radius`; `st.focused` selects the focused appearance. + field: *const fn (s: Surface, rect: Rect, radius: f32, st: ControlState) Allocator.Error!void, + /// Draw the recessed track behind a whole segmented control. + segmentedTrack: *const fn (s: Surface, rect: Rect) Allocator.Error!void, + /// Draw the highlight chip behind the selected segment (`seg`), then return + /// the color its label should be drawn in (an accent-filled chip needs + /// `on_accent` text; a pale chip keeps `label`). + segmentedSelection: *const fn (s: Surface, seg: Rect) Allocator.Error!Color, + /// Draw an on/off switch track. The sliding knob is drawn separately by + /// `switchKnob` (the view layer owns the knob *geometry* and calls both in + /// sequence). + switchTrack: *const fn (s: Surface, rect: Rect, on: bool) Allocator.Error!void, + /// Draw the toggle's sliding thumb within `knob` (a square bounding the + /// circular knob); `on` lets a theme tint it differently per state. + switchKnob: *const fn (s: Surface, knob: Rect, on: bool) Allocator.Error!void, + /// Draw a slider: the unfilled `track`, the filled portion (`track` scaled by + /// `frac` in 0..1), and the handle within `knob`. + slider: *const fn (s: Surface, track: Rect, frac: f32, knob: Rect, st: ControlState) Allocator.Error!void, + /// Draw the +/- control box chrome (fill + border/bevel); the divider and + /// glyph lines are drawn by the view layer. + stepperBox: *const fn (s: Surface, rect: Rect, st: ControlState) Allocator.Error!void, + /// Draw a determinate progress bar: the track, then the filled portion + /// (`rect` scaled by `frac` in 0..1). + progress: *const fn (s: Surface, rect: Rect, frac: f32) Allocator.Error!void, + /// Draw the frame (background + border/bevel) for a floating panel — sheets, + /// alerts, popovers, and menus — with the given corner `radius`. + panel: *const fn (s: Surface, rect: Rect, radius: f32) Allocator.Error!void, +}; + +// --------------------------------------------------------------------------- +// Theme +// --------------------------------------------------------------------------- + +/// A fully-resolved theme for one `ColorScheme`: a palette, a type scale, layout +/// metrics, and the painter that gives it its look. Built per scheme by the +/// concrete theme modules; `registry.zig` picks one for the OS appearance. pub const Theme = struct { + /// Human-readable name of the theme family (e.g. "macOS", "Windows 2000"). + name: []const u8 = "zigui", scheme: ColorScheme, - colors: Colors, + colors: Palette, typography: Typography, metrics: Metrics = .{}, + painter: Painter, /// Resolve a named text style. pub fn font(self: Theme, name: enum { @@ -148,5 +303,13 @@ test "Theme: font() resolves named styles" { test "Metrics: sensible defaults" { const m = Metrics{}; try testing.expectEqual(@as(f32, 8), m.spacing); - try testing.expectEqual(@as(f32, 6), m.control_corner_radius); + try testing.expectEqual(@as(f32, 7), m.control_corner_radius); + // Liquid Glass rounds panels more than controls. + try testing.expect(m.panel_corner_radius > m.corner_radius); +} + +test "Palette: bevel tokens default for non-chiseled themes" { + const macos = @import("macos.zig"); + // macOS doesn't set bevel tokens, so they fall back to the struct defaults. + try testing.expect(macos.light.colors.control_highlight.approxEql(Color.white, 0.001)); } diff --git a/src/theme/win2000.zig b/src/theme/win2000.zig new file mode 100644 index 0000000..485efad --- /dev/null +++ b/src/theme/win2000.zig @@ -0,0 +1,217 @@ +//! Windows 2000 theme: the classic "Luna-less" chiseled look — silver 3D-face +//! controls with raised/sunken bevels drawn from four edge colors, a navy +//! selection highlight, and square corners. Light-only (Windows 2000 had no +//! dark mode), so `registry.zig` keeps it on the light palette regardless of the +//! OS appearance. + +const std = @import("std"); +const theme = @import("theme.zig"); +const Color = @import("../render/color.zig").Color; +const geom = @import("../layout/geometry.zig"); + +const Theme = theme.Theme; +const Typography = theme.Typography; +const Metrics = theme.Metrics; +const Surface = theme.Surface; +const ControlState = theme.ControlState; +const Role = theme.Role; +const Rect = geom.Rect; +const Err = std.mem.Allocator.Error; + +/// Tahoma-ish UI scale: small, dense text like the classic Windows shell. +const typography = Typography{ + .large_title = .{ .size = 20, .weight = .bold }, + .title = .{ .size = 17, .weight = .bold }, + .title2 = .{ .size = 15, .weight = .bold }, + .title3 = .{ .size = 13, .weight = .bold }, + .headline = .{ .size = 12, .weight = .bold }, + .body = .{ .size = 12, .weight = .regular }, + .callout = .{ .size = 12, .weight = .regular }, + .subheadline = .{ .size = 11, .weight = .regular }, + .footnote = .{ .size = 11, .weight = .regular }, + .caption = .{ .size = 10, .weight = .regular }, + .caption2 = .{ .size = 10, .weight = .regular }, +}; + +/// Square corners, 1px hairlines, a slightly taller default control. +const metrics = Metrics{ + .corner_radius = 0, + .control_corner_radius = 0, + .panel_corner_radius = 0, + .selection_corner_radius = 0, + .window_corner_radius = 0, + .control_height = 23, + .hairline = 1, +}; + +// --------------------------------------------------------------------------- +// Bevels — the heart of the chiseled look +// --------------------------------------------------------------------------- + +/// Draw one 1px ring: top & left edges in `tl`, bottom & right edges in `br`. +fn ring(s: Surface, r: Rect, tl: Color, br: Color) Err!void { + try s.fill(.{ .x = r.x, .y = r.y, .width = r.width, .height = 1 }, 0, tl); // top + try s.fill(.{ .x = r.x, .y = r.y, .width = 1, .height = r.height }, 0, tl); // left + try s.fill(.{ .x = r.x, .y = r.maxY() - 1, .width = r.width, .height = 1 }, 0, br); // bottom + try s.fill(.{ .x = r.maxX() - 1, .y = r.y, .width = 1, .height = r.height }, 0, br); // right +} + +/// A two-ring 3D bevel. `raised` (a button at rest) lights the top-left and +/// shadows the bottom-right; sunken (a pressed button, or an input well) swaps +/// them so the surface reads as pushed in. +fn bevel(s: Surface, rect: Rect, raised: bool) Err!void { + const p = s.palette; + if (raised) { + try ring(s, rect, p.control_highlight, p.control_dark_shadow); + try ring(s, rect.insetBy(1, 1), p.control_light, p.control_shadow); + } else { + try ring(s, rect, p.control_shadow, p.control_highlight); + try ring(s, rect.insetBy(1, 1), p.control_dark_shadow, p.control_light); + } +} + +// --------------------------------------------------------------------------- +// Painter +// --------------------------------------------------------------------------- + +fn button(s: Surface, rect: Rect, role: Role, st: ControlState) Err!Color { + if (role == .plain) return s.palette.accent; + try s.fill(rect, 0, s.palette.control_face); + try bevel(s, rect, !st.pressed); + return s.palette.on_control; +} + +fn field(s: Surface, rect: Rect, radius: f32, st: ControlState) Err!void { + _ = radius; // always square + _ = st; + try s.fill(rect, 0, s.palette.control_background); + try bevel(s, rect, false); // an input is a sunken well +} + +fn segmentedTrack(s: Surface, rect: Rect) Err!void { + try s.fill(rect, 0, s.palette.control_face); + try bevel(s, rect, true); +} + +fn segmentedSelection(s: Surface, seg: Rect) Err!Color { + // The selected tab/segment reads as a depressed button. + try s.fill(seg, 0, s.palette.control_face); + try bevel(s, seg, false); + return s.palette.on_control; +} + +fn switchTrack(s: Surface, rect: Rect, on: bool) Err!void { + // Classic Windows has no switches — a boolean control is a *checkbox*: a + // 13px sunken white well on the trailing edge with a black check when on. + const box: f32 = 13; + const b = Rect{ .x = rect.maxX() - box, .y = rect.midY() - box / 2, .width = box, .height = box }; + try s.fill(b, 0, s.palette.control_background); + try bevel(s, b, false); + if (on) { + // The check: a short down-stroke meeting a longer up-stroke. + const mid = geom.Point{ .x = b.x + 5.5, .y = b.y + 9 }; + try s.lineSeg(.{ .x = b.x + 3, .y = b.y + 6.5 }, mid, 1.8, s.palette.on_control); + try s.lineSeg(mid, .{ .x = b.x + 10, .y = b.y + 3.5 }, 1.8, s.palette.on_control); + } +} + +fn switchKnob(s: Surface, knob: Rect, on: bool) Err!void { + // The checkbox is drawn entirely by `switchTrack`; there is no thumb. + _ = s; + _ = knob; + _ = on; +} + +fn slider(s: Surface, track: Rect, frac: f32, knob: Rect, st: ControlState) Err!void { + _ = st; + // A sunken groove with a navy fill, and a raised square handle. + try s.fill(track, 0, s.palette.control_background); + try bevel(s, track, false); + const filled = Rect{ .x = track.x, .y = track.y, .width = track.width * frac, .height = track.height }; + try s.fill(filled, 0, s.palette.accent); + try s.fill(knob, 0, s.palette.control_face); + try bevel(s, knob, true); +} + +fn stepperBox(s: Surface, rect: Rect, st: ControlState) Err!void { + _ = st; + try s.fill(rect, 0, s.palette.control_face); + try bevel(s, rect, true); // a raised control box +} + +fn progress(s: Surface, rect: Rect, frac: f32) Err!void { + try s.fill(rect, 0, s.palette.control_background); + try bevel(s, rect, false); // a sunken trough + const filled = Rect{ .x = rect.x, .y = rect.y, .width = rect.width * std.math.clamp(frac, 0, 1), .height = rect.height }; + try s.fill(filled, 0, s.palette.accent); +} + +fn panel(s: Surface, rect: Rect, radius: f32) Err!void { + _ = radius; // always square + try s.fill(rect, 0, s.palette.control_face); + try bevel(s, rect, true); // a raised window/panel +} + +pub const painter = theme.Painter{ + .button = button, + .field = field, + .segmentedTrack = segmentedTrack, + .segmentedSelection = segmentedSelection, + .switchTrack = switchTrack, + .switchKnob = switchKnob, + .slider = slider, + .stepperBox = stepperBox, + .progress = progress, + .panel = panel, +}; + +// --------------------------------------------------------------------------- +// Theme (light only) +// --------------------------------------------------------------------------- + +pub const light = Theme{ + .name = "Windows 2000", + .scheme = .light, + .typography = typography, + .metrics = metrics, + .painter = painter, + .colors = .{ + .accent = Color.fromRgb8(10, 36, 106), // classic navy selection + .label = Color.black, + .secondary_label = Color.fromRgb8(64, 64, 64), + .tertiary_label = Color.fromRgb8(128, 128, 128), + .on_accent = Color.white, + .window_background = Color.fromRgb8(212, 208, 200), // 3DFACE + .secondary_background = Color.fromRgb8(212, 208, 200), + .control_background = Color.white, // edit fields / lists + .separator = Color.fromRgb8(128, 128, 128), + .selection = Color.fromRgb8(10, 36, 106), + .destructive = Color.fromRgb8(170, 0, 0), + .hover = Color.fromRgb8(10, 36, 106).withAlpha(0.12), + .control_border = Color.fromRgb8(128, 128, 128), + .glass = Color.fromRgb8(212, 208, 200).withAlpha(0.95), + .control_track = Color.fromRgb8(212, 208, 200), + // Bevel edges (the 3D scheme colors). + .control_face = Color.fromRgb8(212, 208, 200), + .control_highlight = Color.white, + .control_light = Color.fromRgb8(227, 224, 219), + .control_shadow = Color.fromRgb8(128, 128, 128), + .control_dark_shadow = Color.fromRgb8(64, 64, 64), + .on_control = Color.black, + }, +}; + +// Windows 2000 had no dark mode; the registry maps any scheme to `light`. +pub const dark = light; + +const testing = std.testing; + +test "win2000: square corners and silver face" { + try testing.expectEqual(@as(f32, 0), light.metrics.control_corner_radius); + try testing.expect(light.colors.window_background.approxEql(Color.fromRgb8(212, 208, 200), 0.01)); +} + +test "win2000: selection is navy" { + const a = light.colors.accent; + try testing.expect(a.b > a.r and a.b > a.g and a.isDark()); +} diff --git a/src/theme/windows10.zig b/src/theme/windows10.zig new file mode 100644 index 0000000..84968ca --- /dev/null +++ b/src/theme/windows10.zig @@ -0,0 +1,205 @@ +//! Windows 10 theme: the flat "Metro/Fluent-lite" look — solid fills, 1px +//! borders, square corners, and a single bright accent. Light & dark follow the +//! OS appearance. + +const std = @import("std"); +const theme = @import("theme.zig"); +const Color = @import("../render/color.zig").Color; +const geom = @import("../layout/geometry.zig"); + +const Theme = theme.Theme; +const Typography = theme.Typography; +const Metrics = theme.Metrics; +const Surface = theme.Surface; +const ControlState = theme.ControlState; +const Role = theme.Role; +const Rect = geom.Rect; +const Err = std.mem.Allocator.Error; + +/// Segoe UI scale: clean, slightly larger than the Win2000 shell. +const typography = Typography{ + .large_title = .{ .size = 24, .weight = .light }, + .title = .{ .size = 20, .weight = .regular }, + .title2 = .{ .size = 16, .weight = .regular }, + .title3 = .{ .size = 14, .weight = .semibold }, + .headline = .{ .size = 13, .weight = .semibold }, + .body = .{ .size = 13, .weight = .regular }, + .callout = .{ .size = 12, .weight = .regular }, + .subheadline = .{ .size = 12, .weight = .regular }, + .footnote = .{ .size = 11, .weight = .regular }, + .caption = .{ .size = 11, .weight = .regular }, + .caption2 = .{ .size = 10, .weight = .regular }, +}; + +/// Flat and square, with a comfortable control height. +const metrics = Metrics{ + .corner_radius = 0, + .control_corner_radius = 2, + .panel_corner_radius = 0, + .selection_corner_radius = 0, + .window_corner_radius = 0, + .control_height = 26, + .hairline = 1, +}; + +// --------------------------------------------------------------------------- +// Painter — flat fills + 1px borders +// --------------------------------------------------------------------------- + +fn button(s: Surface, rect: Rect, role: Role, st: ControlState) Err!Color { + const r = s.metrics.control_corner_radius; + if (role == .plain) return s.palette.accent; + if (role == .destructive) { + try s.fill(rect, r, s.palette.destructive); + return s.palette.on_accent; + } + // Default Win10 buttons are a neutral fill with a flat border; the accent is + // reserved for the hover/focus border. + try s.fill(rect, r, s.palette.control_track); + const border = if (st.hovered or st.focused) s.palette.accent else s.palette.control_border; + try s.stroke(rect, r, 1, border); + return s.palette.label; +} + +fn field(s: Surface, rect: Rect, radius: f32, st: ControlState) Err!void { + try s.fill(rect, radius, s.palette.control_background); + const c = if (st.focused) s.palette.accent else s.palette.separator; + const w: f32 = if (st.focused) 2 else 1; + try s.stroke(rect, radius, w, c); +} + +fn segmentedTrack(s: Surface, rect: Rect) Err!void { + const r = s.metrics.control_corner_radius; + try s.fill(rect, r, s.palette.control_track); + try s.stroke(rect, r, 1, s.palette.control_border); +} + +fn segmentedSelection(s: Surface, seg: Rect) Err!Color { + // The selected segment is a solid accent block with accent-contrast text. + try s.fill(seg.insetBy(1, 1), s.metrics.control_corner_radius, s.palette.accent); + return s.palette.on_accent; +} + +fn switchTrack(s: Surface, rect: Rect, on: bool) Err!void { + const r = rect.height / 2; + if (on) { + try s.fill(rect, r, s.palette.accent); + } else { + try s.fill(rect, r, s.palette.control_track); + try s.stroke(rect, r, 1, s.palette.label); + } +} + +fn switchKnob(s: Surface, knob: Rect, on: bool) Err!void { + // A flat circular thumb: bright on the accent track, neutral on the off track. + const c = if (on) s.palette.on_accent else s.palette.label; + try s.fillCircle(.{ .x = knob.midX(), .y = knob.midY() }, knob.width / 2, c); +} + +fn slider(s: Surface, track: Rect, frac: f32, knob: Rect, st: ControlState) Err!void { + _ = st; + const tr = track.height / 2; + try s.fill(track, tr, s.palette.control_track); + const filled = Rect{ .x = track.x, .y = track.y, .width = track.width * frac, .height = track.height }; + try s.fill(filled, tr, s.palette.accent); + // A solid accent thumb. + try s.fillCircle(.{ .x = knob.midX(), .y = knob.midY() }, knob.width / 2, s.palette.accent); +} + +fn stepperBox(s: Surface, rect: Rect, st: ControlState) Err!void { + _ = st; + const r = s.metrics.control_corner_radius; + try s.fill(rect, r, s.palette.control_background); + try s.stroke(rect, r, 1, s.palette.control_border); +} + +fn progress(s: Surface, rect: Rect, frac: f32) Err!void { + const r = rect.height / 2; + try s.fill(rect, r, s.palette.control_track); + const filled = Rect{ .x = rect.x, .y = rect.y, .width = rect.width * std.math.clamp(frac, 0, 1), .height = rect.height }; + try s.fill(filled, r, s.palette.accent); +} + +fn panel(s: Surface, rect: Rect, radius: f32) Err!void { + _ = radius; // always square + try s.fill(rect, 0, s.palette.control_background); + try s.stroke(rect, 0, 1, s.palette.control_border); +} + +pub const painter = theme.Painter{ + .button = button, + .field = field, + .segmentedTrack = segmentedTrack, + .segmentedSelection = segmentedSelection, + .switchTrack = switchTrack, + .switchKnob = switchKnob, + .slider = slider, + .stepperBox = stepperBox, + .progress = progress, + .panel = panel, +}; + +// --------------------------------------------------------------------------- +// Themes +// --------------------------------------------------------------------------- + +pub const light = Theme{ + .name = "Windows 10", + .scheme = .light, + .typography = typography, + .metrics = metrics, + .painter = painter, + .colors = .{ + .accent = Color.fromRgb8(0, 120, 215), // Windows 10 blue + .label = Color.fromRgb8(0, 0, 0), + .secondary_label = Color.fromRgb8(96, 96, 96), + .tertiary_label = Color.fromRgb8(140, 140, 140), + .on_accent = Color.white, + .window_background = Color.fromRgb8(240, 240, 240), + .secondary_background = Color.fromRgb8(230, 230, 230), + .control_background = Color.white, + .separator = Color.fromRgb8(204, 204, 204), + .selection = Color.fromRgb8(0, 120, 215), + .destructive = Color.fromRgb8(232, 17, 35), + .hover = Color.fromRgb8(229, 241, 251), // Win10 hover wash + .control_border = Color.fromRgb8(173, 173, 173), + .glass = Color.white.withAlpha(0.8), + .control_track = Color.fromRgb8(225, 225, 225), + }, +}; + +pub const dark = Theme{ + .name = "Windows 10", + .scheme = .dark, + .typography = typography, + .metrics = metrics, + .painter = painter, + .colors = .{ + .accent = Color.fromRgb8(96, 205, 255), // brighter accent on dark + .label = Color.white, + .secondary_label = Color.white.withAlpha(0.6), + .tertiary_label = Color.white.withAlpha(0.4), + .on_accent = Color.fromRgb8(0, 0, 0), + .window_background = Color.fromRgb8(32, 32, 32), + .secondary_background = Color.fromRgb8(43, 43, 43), + .control_background = Color.fromRgb8(51, 51, 51), + .separator = Color.fromRgb8(80, 80, 80), + .selection = Color.fromRgb8(0, 120, 215), + .destructive = Color.fromRgb8(255, 99, 71), + .hover = Color.white.withAlpha(0.08), + .control_border = Color.fromRgb8(96, 96, 96), + .glass = Color.fromRgb8(43, 43, 43).withAlpha(0.85), + .control_track = Color.fromRgb8(60, 60, 60), + }, +}; + +const testing = std.testing; + +test "windows10: flat with a bright accent" { + try testing.expect(light.colors.accent.b > light.colors.accent.r); + try testing.expectEqual(@as(f32, 2), light.metrics.control_corner_radius); +} + +test "windows10: light and dark backgrounds differ" { + try testing.expect(light.colors.window_background.isDark() != dark.colors.window_background.isDark()); +} diff --git a/src/view/view.zig b/src/view/view.zig index b46ccf5..44eb528 100644 --- a/src/view/view.zig +++ b/src/view/view.zig @@ -27,8 +27,24 @@ const shape = @import("../text/shape.zig"); const font_mod = @import("../text/font.zig"); const ttf = @import("../text/ttf.zig"); const state = @import("../state/state.zig"); +const icons = @import("../icons.zig"); + +// Component modules. The view layer is the engine + a thin facade: each module +// in `components/` owns a cohesive set of constructors (and, where relevant, +// their painting), and the public names are re-exported below. +const navigation = @import("../components/navigation.zig"); +const tabs_mod = @import("../components/tabs.zig"); +const menu_mod = @import("../components/menu.zig"); +const grid = @import("../components/grid.zig"); +const list_mod = @import("../components/list.zig"); +const collections = @import("../components/collections.zig"); +const text_buffer = @import("../components/text_buffer.zig"); pub const Binding = state.Binding; +/// The icon catalog enum. Call sites usually rely on enum-literal inference +/// (`Icon(.heart, …)`) and never spell this out; it's exported for the rare +/// explicit annotation. The `Icon`/`IconButton` constructors are below. +pub const IconName = icons.Icon; const Allocator = std.mem.Allocator; const Rect = geom.Rect; @@ -39,6 +55,27 @@ const Alignment = geom.Alignment; const Theme = theme_mod.Theme; const inf = std.math.inf(f32); +// --- Facade re-exports from component modules ------------------------------ +// These keep the historical `view.X` public names stable while the +// implementations live in `components/`. +pub const NavState = navigation.NavState; +pub const NavigationSplitView = navigation.NavigationSplitView; +pub const NavigationLink = navigation.NavigationLink; +pub const NavBackButton = navigation.NavBackButton; +pub const Tab = tabs_mod.Tab; +pub const TabView = tabs_mod.TabView; +pub const Menu = menu_mod.Menu; +pub const ContextMenu = menu_mod.ContextMenu; +pub const LazyVGrid = grid.LazyVGrid; +pub const LazyHGrid = grid.LazyHGrid; +pub const List = list_mod.List; +pub const Sidebar = collections.Sidebar; +pub const SidebarItem = collections.SidebarItem; +pub const RadioGroup = collections.RadioGroup; +pub const Table = collections.Table; +pub const TableColumn = collections.TableColumn; +pub const TextFieldState = text_buffer.TextFieldState; + // --------------------------------------------------------------------------- // Callbacks // --------------------------------------------------------------------------- @@ -71,6 +108,25 @@ pub fn actionCtx(comptime T: type, ctx: *T, comptime f: fn (*T) void) Callback { }.g }; } +/// Closure context for `selectAction`: a (binding, value) pair bound at build +/// time and stored in the per-frame build arena, which outlives the frame until +/// the next rebuild, so a hit region's callback can safely read it. +const SelectCtx = struct { binding: Binding(i64), value: i64 }; + +fn selectThunk(p: ?*anyopaque) void { + const c: *SelectCtx = @ptrCast(@alignCast(p.?)); + c.binding.set(c.value); +} + +/// A `Callback` that sets `binding` to `value` — the building block for selection +/// in composed controls (sidebar rows, radio options, table rows). Lets those +/// stay pure composition over `onTap` without a dedicated `HitAction`. +pub fn selectAction(binding: Binding(i64), value: i64) Callback { + const c = buildAlloc().create(SelectCtx) catch @panic("oom"); + c.* = .{ .binding = binding, .value = value }; + return .{ .ctx = c, .func = selectThunk }; +} + // --------------------------------------------------------------------------- // Fills, fonts, modifiers // --------------------------------------------------------------------------- @@ -84,14 +140,17 @@ pub const Material = enum { regular, thick, - pub const Spec = struct { tint: Color, sigma: f32 }; + /// A blur `sigma` and an `alpha_scale` applied to the active theme's + /// `Palette.glass` tint (so the frost is scheme-correct: light glass in light + /// mode, dark glass in dark mode). + pub const Spec = struct { alpha_scale: f32, sigma: f32 }; pub fn spec(self: Material) Spec { return switch (self) { - .ultra_thin => .{ .tint = Color.white.withAlpha(0.20), .sigma = 8 }, - .thin => .{ .tint = Color.white.withAlpha(0.35), .sigma = 10 }, - .regular => .{ .tint = Color.white.withAlpha(0.55), .sigma = 12 }, - .thick => .{ .tint = Color.white.withAlpha(0.75), .sigma = 14 }, + .ultra_thin => .{ .alpha_scale = 0.35, .sigma = 8 }, + .thin => .{ .alpha_scale = 0.55, .sigma = 10 }, + .regular => .{ .alpha_scale = 0.85, .sigma = 12 }, + .thick => .{ .alpha_scale = 1.0, .sigma = 14 }, }; } }; @@ -158,11 +217,18 @@ pub const Modifiers = struct { border_width: f32 = 1, opacity: f32 = 1, on_tap: ?Callback = null, + /// A background painted only while the cursor is within this view's frame + /// (see `Context.hover_point`). Gives menu rows / list rows a live hover + /// highlight without per-widget state. + hover_fill: ?Color = null, /// Fired when a focused `TextField` is submitted (Enter). Stashed on the /// field's `TextFieldState` during paint; see `submitFocused`. on_submit: ?Callback = null, disabled: bool = false, - overlay: ?OverlayMod = null, + /// Overlays presented from this view (sheets/alerts/popovers). A slice so a + /// view can stack more than one — chaining `.sheet(...).alert(...)` appends + /// rather than overwriting. + overlay: []const OverlayMod = &.{}, /// Overrides the auto-derived accessibility label for this view. a11y_label: ?[]const u8 = null, /// Hides this view (and its subtree) from the accessibility tree. @@ -185,7 +251,9 @@ pub const StackData = struct { alignment: Alignment, children: []const View, }; -pub const ButtonRole = enum { normal, destructive, plain }; +/// The semantic appearance of a button. Defined by the theme layer (so painters +/// can switch on it) and re-exported here for view-side call sites. +pub const ButtonRole = theme_mod.Role; pub const ButtonData = struct { label: []const u8, action: Callback, role: ButtonRole = .normal }; pub const ToggleData = struct { value: Binding(bool), label: []const u8 = "" }; @@ -194,318 +262,22 @@ pub const StepperData = struct { value: Binding(i64), label: []const u8 = "", mi pub const ProgressData = struct { value: f32 = 0, label: []const u8 = "" }; pub const ImageData = struct { image: canvas_mod.Image }; pub const LabelData = struct { title: []const u8, symbol_color: Color }; +/// A glyph from the bundled icon font, tinted `color` (null = inherit the +/// environment's foreground) and laid out as a `size`×`size` square. +pub const IconData = struct { icon: icons.Icon, size: f32, color: ?Color = null }; +/// A tappable icon: the glyph centered in a square control with a tap callback. +pub const IconButtonData = struct { icon: icons.Icon, size: f32, action: Callback }; pub const ScrollData = struct { axis: engine.Direction, offset: f32, content: *const View }; -/// Editable text buffer + caret for a `TextField` or `TextEditor`. Owned by the -/// app (like a `State`), so editing survives across frames. Key events call its -/// methods. Beyond a caret it tracks an optional selection (`sel_anchor`) and, -/// for multi-line editors, the machinery for vertical motion and caret-follow -/// scrolling. -pub const TextFieldState = struct { - buffer: std.ArrayList(u8) = .empty, - caret: usize = 0, // byte index - /// Selection anchor (byte index). When non-null and different from `caret`, - /// the selection is the ordered range [min, max]. A plain (un-shifted) move - /// or any edit collapses it (back to `null`). - sel_anchor: ?usize = null, - focused: bool = false, - /// True for a `TextEditor` (multi-line). The event loop then inserts a - /// newline on Enter (instead of submitting) and routes Up/Down/Home/End/Tab. - /// Set by `paintTextEditor` each frame. - multiline: bool = false, - /// Preferred column (codepoints from the line start) for vertical motion, so - /// a run of Up/Down keeps the original column across shorter lines. Reset by - /// any horizontal move or edit. - pref_col: ?usize = null, - /// The caret seen at the previous paint. `TextEditor` auto-scrolls to follow - /// the caret only when it actually moved, so the wheel can scroll freely - /// otherwise. - last_caret: usize = 0, - /// Bumped on every mutation so an app can cheaply detect "modified since - /// saved" without diffing the buffer (see `examples/edit`). - revision: u64 = 0, - /// The `.onSubmit` callback for this field, refreshed during paint while the - /// field is focused so `submitFocused()` (called from the event loop on - /// Enter) can fire it without the view tree. See `paintTextField`. - on_submit: ?Callback = null, - allocator: Allocator, - - pub fn init(allocator: Allocator) TextFieldState { - return .{ .allocator = allocator }; - } - pub fn deinit(self: *TextFieldState) void { - self.buffer.deinit(self.allocator); - } - pub fn text(self: *const TextFieldState) []const u8 { - return self.buffer.items; - } - pub fn setText(self: *TextFieldState, s: []const u8) !void { - self.buffer.clearRetainingCapacity(); - try self.buffer.appendSlice(self.allocator, s); - self.caret = self.buffer.items.len; - self.sel_anchor = null; - self.pref_col = null; - self.last_caret = self.caret; - self.revision +%= 1; - } - - // --- selection --------------------------------------------------------- - - /// The current selection as an ordered byte range, or null when the caret is - /// a plain insertion point (no anchor, or an empty selection). - pub fn selectionRange(self: *const TextFieldState) ?struct { start: usize, end: usize } { - const a = self.sel_anchor orelse return null; - if (a == self.caret) return null; - return .{ .start = @min(a, self.caret), .end = @max(a, self.caret) }; - } - pub fn hasSelection(self: *const TextFieldState) bool { - return self.selectionRange() != null; - } - pub fn selectAll(self: *TextFieldState) void { - if (self.buffer.items.len == 0) { - self.sel_anchor = null; - return; - } - self.sel_anchor = 0; - self.caret = self.buffer.items.len; - self.pref_col = null; - } - /// Delete the selected range, if any. Returns true if something was removed. - pub fn deleteSelection(self: *TextFieldState) bool { - const r = self.selectionRange() orelse { - self.sel_anchor = null; - return false; - }; - const n = r.end - r.start; - std.mem.copyForwards(u8, self.buffer.items[r.start..], self.buffer.items[r.end..]); - self.buffer.shrinkRetainingCapacity(self.buffer.items.len - n); - self.caret = r.start; - self.sel_anchor = null; - self.pref_col = null; - self.revision +%= 1; - return true; - } - - // --- editing ----------------------------------------------------------- - - pub fn insert(self: *TextFieldState, s: []const u8) !void { - _ = self.deleteSelection(); - try self.buffer.insertSlice(self.allocator, self.caret, s); - self.caret += s.len; - self.sel_anchor = null; - self.pref_col = null; - self.revision +%= 1; - } - pub fn backspace(self: *TextFieldState) void { - if (self.deleteSelection()) return; - if (self.caret == 0) return; - const start = prevCpStart(self.buffer.items, self.caret); - const n = self.caret - start; - std.mem.copyForwards(u8, self.buffer.items[start..], self.buffer.items[self.caret..]); - self.buffer.shrinkRetainingCapacity(self.buffer.items.len - n); - self.caret = start; - self.pref_col = null; - self.revision +%= 1; - } - /// Forward-delete (the Delete key): remove the codepoint after the caret. - pub fn deleteForward(self: *TextFieldState) void { - if (self.deleteSelection()) return; - const items = self.buffer.items; - if (self.caret >= items.len) return; - const len = std.unicode.utf8ByteSequenceLength(items[self.caret]) catch 1; - const stop = @min(self.caret + len, items.len); - std.mem.copyForwards(u8, self.buffer.items[self.caret..], self.buffer.items[stop..]); - self.buffer.shrinkRetainingCapacity(items.len - (stop - self.caret)); - self.pref_col = null; - self.revision +%= 1; - } - - // --- caret movement (extend = Shift held, growing the selection) ------- - - fn beginExtend(self: *TextFieldState, extend: bool) void { - if (extend) { - if (self.sel_anchor == null) self.sel_anchor = self.caret; - } else self.sel_anchor = null; - } - pub fn moveLeft(self: *TextFieldState, extend: bool) void { - self.pref_col = null; - if (!extend) { - if (self.selectionRange()) |r| { - self.caret = r.start; - self.sel_anchor = null; - return; - } - } - self.beginExtend(extend); - if (self.caret > 0) self.caret = prevCpStart(self.buffer.items, self.caret); - } - pub fn moveRight(self: *TextFieldState, extend: bool) void { - self.pref_col = null; - if (!extend) { - if (self.selectionRange()) |r| { - self.caret = r.end; - self.sel_anchor = null; - return; - } - } - self.beginExtend(extend); - const items = self.buffer.items; - if (self.caret < items.len) { - const len = std.unicode.utf8ByteSequenceLength(items[self.caret]) catch 1; - self.caret = @min(self.caret + len, items.len); - } - } - pub fn home(self: *TextFieldState, extend: bool) void { - self.beginExtend(extend); - self.caret = lineStartIndex(self.buffer.items, self.caret); - self.pref_col = null; - } - pub fn end(self: *TextFieldState, extend: bool) void { - self.beginExtend(extend); - self.caret = lineEndIndex(self.buffer.items, self.caret); - self.pref_col = null; - } - pub fn moveUp(self: *TextFieldState, extend: bool) void { - self.beginExtend(extend); - const b = self.buffer.items; - const ls = lineStartIndex(b, self.caret); - if (self.pref_col == null) self.pref_col = columnOf(b, self.caret); - if (ls == 0) { - self.caret = 0; // already on the first line - return; - } - const prev_end = ls - 1; // the '\n' that ends the previous line - const prev_start = lineStartIndex(b, prev_end); - self.caret = indexForColumn(b, prev_start, prev_end, self.pref_col.?); - } - pub fn moveDown(self: *TextFieldState, extend: bool) void { - self.beginExtend(extend); - const b = self.buffer.items; - const le = lineEndIndex(b, self.caret); - if (self.pref_col == null) self.pref_col = columnOf(b, self.caret); - if (le >= b.len) { - self.caret = b.len; // already on the last line - return; - } - const next_start = le + 1; - const next_end = lineEndIndex(b, next_start); - self.caret = indexForColumn(b, next_start, next_end, self.pref_col.?); - } - /// The caret's 1-based line and column (counting codepoints), for a status bar. - pub fn lineCol(self: *const TextFieldState) struct { line: usize, col: usize } { - return .{ - .line = lineIndexOf(self.buffer.items, self.caret) + 1, - .col = columnOf(self.buffer.items, self.caret) + 1, - }; - } -}; - -fn prevCpStart(bytes: []const u8, i: usize) usize { - var j = i; - if (j == 0) return 0; - j -= 1; - while (j > 0 and (bytes[j] & 0xC0) == 0x80) j -= 1; // skip UTF-8 continuation bytes - return j; -} - -// --- pure line/column geometry over a UTF-8 byte buffer -------------------- -// Lines are separated by '\n'; a column counts codepoints from the line start. -// Shared by `TextFieldState` movement and `paintTextEditor`. - -fn lineStartIndex(bytes: []const u8, i: usize) usize { - var j = @min(i, bytes.len); - while (j > 0 and bytes[j - 1] != '\n') j -= 1; - return j; -} -fn lineEndIndex(bytes: []const u8, i: usize) usize { - var j = @min(i, bytes.len); - while (j < bytes.len and bytes[j] != '\n') j += 1; - return j; -} -fn columnOf(bytes: []const u8, i: usize) usize { - const lim = @min(i, bytes.len); - var col: usize = 0; - var j = lineStartIndex(bytes, lim); - while (j < lim) : (j += 1) { - if ((bytes[j] & 0xC0) != 0x80) col += 1; // count non-continuation bytes - } - return col; -} -fn lineIndexOf(bytes: []const u8, i: usize) usize { - const lim = @min(i, bytes.len); - var n: usize = 0; - for (bytes[0..lim]) |b| { - if (b == '\n') n += 1; - } - return n; -} -fn countLines(bytes: []const u8) usize { - var n: usize = 1; - for (bytes) |b| { - if (b == '\n') n += 1; - } - return n; -} -/// The byte index `col` codepoints into the line spanning [line_start, line_end]. -fn indexForColumn(bytes: []const u8, line_start: usize, line_end: usize, col: usize) usize { - var j = line_start; - var c: usize = 0; - while (j < line_end and c < col) { - j += std.unicode.utf8ByteSequenceLength(bytes[j]) catch 1; - c += 1; - } - return @min(j, line_end); -} -/// The byte range [start, end) of the `n`-th line (0-based), clamped to the last. -fn nthLineRange(bytes: []const u8, n: usize) struct { start: usize, end: usize } { - var start: usize = 0; - var seen: usize = 0; - var j: usize = 0; - while (j < bytes.len) : (j += 1) { - if (bytes[j] == '\n') { - if (seen == n) return .{ .start = start, .end = j }; - seen += 1; - start = j + 1; - } - } - return .{ .start = start, .end = bytes.len }; -} -/// Editor tab width, in spaces (tabs snap to the next multiple of this). -const editor_tab_size: f32 = 4; - -/// The advance of a single space and a tab stop at `px`, used to lay out the -/// editor's monospace-ish tab handling. -fn editorTabMetrics(face: *const ttf.Font, px: f32) struct { space: f32, tab: f32 } { - const space = shape.measureLineWidth(face, " ", px); - return .{ .space = space, .tab = space * editor_tab_size }; -} - -/// Pixel width of `slice`, honoring tab stops (so `\t` advances to the next -/// multiple of the tab width rather than drawing a `.notdef` box). Shared by the -/// caret, selection, and click math so they all agree. -fn editorPrefixWidth(face: *const ttf.Font, px: f32, slice: []const u8) f32 { - const sc = face.scaleForPixelSize(px); - const tab_w = editorTabMetrics(face, px).tab; - var x: f32 = 0; - var i: usize = 0; - while (i < slice.len) { - const cp_len = std.unicode.utf8ByteSequenceLength(slice[i]) catch 1; - const e = @min(i + cp_len, slice.len); - if (slice[i] == '\t') { - x = (@floor(x / tab_w) + 1) * tab_w; - } else { - const cp = std.unicode.utf8Decode(slice[i..e]) catch slice[i]; - x += @as(f32, @floatFromInt(face.advanceWidth(face.glyphIndex(cp)))) * sc; - } - i = e; - } - return x; -} +// TextFieldState + the pure UTF-8 line/column geometry and tab-aware +// metrics moved to `components/text_buffer.zig` (re-exported as +// `view.TextFieldState`). `drawEditorLine` below stays here because it +// needs the render context. /// Draw a single editor line, rendering tabs as gaps to the next tab stop /// (drawing text between tabs run-by-run) so caret/selection x's line up with it. fn drawEditorLine(ctx: *const Context, canvas: *Canvas, line: []const u8, px: f32, color: Color, origin: Point) void { - const tab_w = editorTabMetrics(ctx.cache.face, px).tab; + const tab_w = text_buffer.editorTabMetrics(ctx.cache.face, px).tab; var x: f32 = 0; var seg_start: usize = 0; for (line, 0..) |ch, i| { @@ -522,60 +294,6 @@ fn drawEditorLine(ctx: *const Context, canvas: *Canvas, line: []const u8, px: f3 if (tail.len > 0) drawTextC(ctx, canvas, tail, px, color, .{ .x = origin.x + x, .y = origin.y }) catch {}; } -/// The byte offset within `line` whose glyph boundary is nearest to pixel -/// `target_x` (measured from the line's left edge). Tab-aware; used for click- -/// to-position. -fn caretInLine(face: *const ttf.Font, px: f32, line: []const u8, target_x: f32) usize { - if (target_x <= 0) return 0; - const sc = face.scaleForPixelSize(px); - const tab_w = editorTabMetrics(face, px).tab; - var x: f32 = 0; - var i: usize = 0; - while (i < line.len) { - const cp_len = std.unicode.utf8ByteSequenceLength(line[i]) catch 1; - const e = @min(i + cp_len, line.len); - const adv = if (line[i] == '\t') - (@floor(x / tab_w) + 1) * tab_w - x - else - @as(f32, @floatFromInt(face.advanceWidth(face.glyphIndex(std.unicode.utf8Decode(line[i..e]) catch line[i])))) * sc; - if (target_x < x + adv / 2) return i; // nearer this cell's left edge - x += adv; - i = e; - } - return line.len; -} - -/// A navigation route stack for `NavigationStack`-style flows. Owned by the app -/// (like `TextFieldState`), so it survives across frames; the per-frame body -/// switches on `top()` to choose the screen. Routes are opaque integer tokens -/// the app interprets. Because the tree is rebuilt every frame, "navigation" is -/// just mutating this stack. -pub const NavState = struct { - stack: std.ArrayList(i64) = .empty, - allocator: Allocator, - - pub fn init(allocator: Allocator) NavState { - return .{ .allocator = allocator }; - } - pub fn deinit(self: *NavState) void { - self.stack.deinit(self.allocator); - } - pub fn push(self: *NavState, route: i64) void { - self.stack.append(self.allocator, route) catch {}; - } - pub fn pop(self: *NavState) void { - if (self.stack.items.len > 0) _ = self.stack.pop(); - } - /// The current (top-most) route, or null when at the root. - pub fn top(self: *const NavState) ?i64 { - const n = self.stack.items.len; - return if (n == 0) null else self.stack.items[n - 1]; - } - pub fn depth(self: *const NavState) usize { - return self.stack.items.len; - } -}; - /// Scroll position for a `ScrollViewState`, owned by the app (like /// `TextFieldState`) so it survives across frames and can be driven by the event /// loop (mouse wheel) and by code (auto-follow). `content_h`/`viewport_h` are @@ -585,6 +303,10 @@ pub const ScrollState = struct { offset: f32 = 0, content_h: f32 = 0, viewport_h: f32 = 0, + /// Wall-clock time (ms, in the app's `setFrameTime` clock) of the most recent + /// scroll. The overlay scrollbar shows only briefly after this, then fades out + /// (see `paintScrollbar`). 0 means "never scrolled" → no bar. + last_active_ms: u64 = 0, /// The furthest the content can be scrolled (0 when it fits the viewport). pub fn maxOffset(self: *const ScrollState) f32 { @@ -635,6 +357,8 @@ pub const Kind = union(enum) { stepper: StepperData, progress: ProgressData, image: ImageData, + icon: IconData, + icon_button: IconButtonData, label: LabelData, scroll: ScrollData, /// A vertical scroll view whose offset lives in an app-owned `ScrollState` @@ -754,6 +478,13 @@ pub const View = struct { v.mods.on_tap = cb; return v; } + /// Paint `color` behind this view only while the cursor hovers it (honoring + /// the view's `corner_radius`). Pair with `onTap` for menu/list rows. + pub fn hoverFill(self: View, color: Color) View { + var v = self; + v.mods.hover_fill = color; + return v; + } /// Fire `cb` when this (focused) `TextField` is submitted with Enter. pub fn onSubmit(self: View, cb: Callback) View { var v = self; @@ -780,8 +511,14 @@ pub const View = struct { fn withOverlay(self: View, presented: Binding(bool), content: View, style: OverlayStyle) View { const boxed = buildAlloc().create(View) catch @panic("oom"); boxed.* = content; + // Append to any overlays already attached so `.sheet(...).alert(...)` keeps + // both rather than the second clobbering the first. + const old = self.mods.overlay; + const list = buildAlloc().alloc(OverlayMod, old.len + 1) catch @panic("oom"); + @memcpy(list[0..old.len], old); + list[old.len] = .{ .presented = presented, .content = boxed, .style = style }; var v = self; - v.mods.overlay = .{ .presented = presented, .content = boxed, .style = style }; + v.mods.overlay = list; return v; } /// Override the accessibility label exposed for this view. @@ -802,6 +539,15 @@ pub const View = struct { if (v.kind == .stack) v.kind.stack.spacing = s; return v; } + + /// Override a stack's cross-axis alignment (default `.center`). For a + /// `VStack` this controls horizontal alignment of its children, e.g. + /// `.leading` to left-align them. + pub fn alignment(self: View, a: Alignment) View { + var v = self; + if (v.kind == .stack) v.kind.stack.alignment = a; + return v; + } }; // --------------------------------------------------------------------------- @@ -810,6 +556,32 @@ pub const View = struct { threadlocal var current_arena: ?Allocator = null; +/// The handful of theme colors that *composed* (theme-less) constructors need at +/// build time — chiefly selection tints for `Sidebar`/`Table`/`RadioGroup`. +/// Constructors have no `Context`, so the app publishes these once per frame via +/// `setThemeTokens`. The defaults match the macOS light theme, so headless tests +/// and un-wired callers render correctly without any setup. +pub const BuildTokens = struct { + accent: Color = Color.fromRgb8(0, 122, 255), + on_accent: Color = Color.white, + /// Subtle fill behind a hovered row. + hover: Color = Color.black.withAlpha(0.06), + /// Alternating-row stripe in tables. + row_stripe: Color = Color.black.withAlpha(0.03), +}; +threadlocal var build_tokens: BuildTokens = .{}; + +/// Publish the active theme's build-time tokens for composed constructors. Call +/// once per frame (the app does this in its build step) before building views. +pub fn setThemeTokens(t: Theme) void { + build_tokens = .{ + .accent = t.colors.accent, + .on_accent = t.colors.on_accent, + .hover = t.colors.hover, + .row_stripe = if (t.scheme == .dark) Color.white.withAlpha(0.04) else Color.black.withAlpha(0.03), + }; +} + /// Set the arena used by view constructors for the duration of building a view /// tree. The framework calls this each frame; tests call it directly. pub fn beginBuild(arena: Allocator) void { @@ -818,10 +590,17 @@ pub fn beginBuild(arena: Allocator) void { pub fn endBuild() void { current_arena = null; } -fn buildAlloc() Allocator { +/// The per-frame build arena. Public so component modules in `components/` can +/// allocate the slices/children their constructors own. +pub fn buildAlloc() Allocator { return current_arena orelse @panic("zigui: view constructed outside beginBuild()/endBuild()"); } +/// The active build-time theme tokens, for composed (theme-less) constructors. +pub fn buildTokens() BuildTokens { + return build_tokens; +} + pub fn Text(s: []const u8) View { return .{ .kind = .{ .text = .{ .string = s } } }; } @@ -882,6 +661,18 @@ pub fn ProgressView(value: f32) View { pub fn Image(image: canvas_mod.Image) View { return .{ .kind = .{ .image = .{ .image = image } } }; } +/// A glyph from the bundled icon set, laid out as a `size`×`size` square and +/// tinted `color` (pass `null` to inherit the surrounding `.foreground`). Call +/// sites lean on enum-literal inference: `Icon(.heart, 18, null)`. +pub fn Icon(icon: icons.Icon, size: f32, color: ?Color) View { + return .{ .kind = .{ .icon = .{ .icon = icon, .size = size, .color = color } } }; +} +/// A tappable icon (toolbar/affordance style): the glyph centered in a square +/// control with a tap callback. The icon inherits the environment foreground; +/// compose `.foreground`/`.padding`/`.background` for styling. +pub fn IconButton(icon: icons.Icon, size: f32, on_tap: Callback) View { + return .{ .kind = .{ .icon_button = .{ .icon = icon, .size = size, .action = on_tap } } }; +} /// A title with a small leading symbol swatch (placeholder for an icon set). pub fn Label(title: []const u8, symbol_color: Color) View { return .{ .kind = .{ .label = .{ .title = title, .symbol_color = symbol_color } } }; @@ -934,147 +725,9 @@ pub fn ScrollViewState(scroll_state: *ScrollState, content: View) View { return .{ .kind = .{ .scroll_state = .{ .state = scroll_state, .content = child } } }; } -/// A SwiftUI-style List: a scrolling vertical container with hairline dividers -/// between rows and grouped-background styling. -pub fn List(rows: anytype) View { - const views = toViews(rows); - const flat = flattenGroups(views); - // interleave dividers - var with_dividers = buildAlloc().alloc(View, if (flat.len == 0) 0 else flat.len * 2 - 1) catch @panic("oom"); - var i: usize = 0; - for (flat, 0..) |row, idx| { - with_dividers[i] = row.paddingInsets(.{ .top = 6, .leading = 12, .bottom = 6, .trailing = 12 }).frameMaxWidth(); - i += 1; - if (idx + 1 < flat.len) { - with_dividers[i] = Divider(); - i += 1; - } - } - const stack = makeStackFromSlice(.vertical, 0, .leading, with_dividers); - return ScrollView(stack); -} - -/// A grid that grows vertically: `items` are mapped to cells via `mapFn` and -/// laid out left-to-right, top-to-bottom into `columns` even columns. Built by -/// composition — a `VStack` of `HStack` rows — so it needs no new layout -/// primitive. Each cell is `.frameMaxWidth()` so columns share width evenly; -/// the trailing slots of a short final row are filled with invisible cells so -/// every column stays aligned. `spacing` applies between both rows and columns. -pub fn LazyVGrid(columns: usize, spc: f32, items: anytype, comptime mapFn: anytype) View { - const cols = if (columns == 0) 1 else columns; - const n = items.len; - const rows = if (n == 0) 0 else (n + cols - 1) / cols; // ceil(n/cols) - const row_views = buildAlloc().alloc(View, rows) catch @panic("oom"); - var r: usize = 0; - while (r < rows) : (r += 1) { - const cells = buildAlloc().alloc(View, cols) catch @panic("oom"); - var ci: usize = 0; - while (ci < cols) : (ci += 1) { - const idx = r * cols + ci; - cells[ci] = if (idx < n) mapFn(items[idx]).frameMaxWidth() else Empty().frameMaxWidth(); - } - row_views[r] = makeStackFromSlice(.horizontal, spc, .center, cells); - } - return makeStackFromSlice(.vertical, spc, .center, row_views); -} - -/// A grid that grows horizontally: the transpose of `LazyVGrid`. `items` fill -/// `rows` even rows top-to-bottom, then wrap to the next column. Built as an -/// `HStack` of `VStack` columns; each cell is `.frameMaxHeight()`. -pub fn LazyHGrid(rows: usize, spc: f32, items: anytype, comptime mapFn: anytype) View { - const rws = if (rows == 0) 1 else rows; - const n = items.len; - const cols = if (n == 0) 0 else (n + rws - 1) / rws; // ceil(n/rows) - const col_views = buildAlloc().alloc(View, cols) catch @panic("oom"); - var col: usize = 0; - while (col < cols) : (col += 1) { - const cells = buildAlloc().alloc(View, rws) catch @panic("oom"); - var ri: usize = 0; - while (ri < rws) : (ri += 1) { - const idx = col * rws + ri; - cells[ri] = if (idx < n) mapFn(items[idx]).frameMaxHeight() else Empty().frameMaxHeight(); - } - col_views[col] = makeStackFromSlice(.vertical, spc, .center, cells); - } - return makeStackFromSlice(.horizontal, spc, .center, col_views); -} - -/// One page of a `TabView`: a title for its tab-bar segment plus the content -/// shown when that tab is selected. -pub const Tab = struct { label: []const u8, content: View }; - -/// A tabbed container: shows the content of the selected tab above a segmented -/// tab bar. Built by composition — the bar is a `Picker` over the tab labels, so -/// tapping a segment drives the same `.select` interaction as `Picker` (and the -/// `selection` binding is what the body switches on). Rebuilt each frame, so -/// "switching tabs" is just the binding changing. -pub fn TabView(selection: Binding(i64), tabs: []const Tab) View { - const n = tabs.len; - if (n == 0) return Empty(); - const hi: i64 = @intCast(n - 1); - const sel: usize = @intCast(std.math.clamp(selection.get(), 0, hi)); - const labels = buildAlloc().alloc([]const u8, n) catch @panic("oom"); - for (tabs, 0..) |tab, i| labels[i] = tab.label; - const bar = Picker(selection, labels).frameMaxWidth(); - return VStack(.{ tabs[sel].content.frameMaxWidth(), Divider(), bar }); -} - -/// A two-column master/detail layout: a fixed-width `sidebar` pane (filled with -/// `sidebar_fill` so it stays themable), a vertical hairline, and a `detail` -/// pane that fills the remaining width. Pure composition over `HStack`. -pub fn NavigationSplitView(sidebar: View, detail: View, sidebar_fill: Color) View { - return makeStack(.horizontal, 0, .center, .{ - sidebar.frameWidth(220).frameMaxHeight().background(sidebar_fill), - VDivider(), - detail.frameMaxWidth().frameMaxHeight(), - }); -} - -/// Closure context for a `NavigationLink` tap: a (NavState, route) pair bound at -/// build time. Allocated in the per-frame build arena, which outlives the frame -/// until the next rebuild, so the hit region's callback can safely read it. -const NavPushCtx = struct { nav: *NavState, route: i64 }; - -fn navPushThunk(p: ?*anyopaque) void { - const ctx: *NavPushCtx = @ptrCast(@alignCast(p.?)); - ctx.nav.push(ctx.route); -} - -/// A button that, when tapped, pushes `route` onto `nav`'s route stack. Reuses -/// the plain `.callback` hit action (no new interaction kind needed). -pub fn NavigationLink(label: []const u8, route: i64, nav: *NavState) View { - const ctx = buildAlloc().create(NavPushCtx) catch @panic("oom"); - ctx.* = .{ .nav = nav, .route = route }; - return Button(label, .{ .ctx = ctx, .func = navPushThunk }); -} - -/// A button that pops the top route off `nav` (the navigation "back" button). -/// Render it in the screen's top bar when `nav.depth() > 0`. -pub fn NavBackButton(label: []const u8, nav: *NavState) View { - return ButtonRoled(label, .plain, actionCtx(NavState, nav, NavState.pop)); -} +// List, LazyVGrid/LazyHGrid, TabView, Sidebar/RadioGroup/Table moved to +// `components/{list,grid,tabs,collections}.zig` (re-exported via the facade). -fn toggleBoolState(s: *state.State(bool)) void { - s.set(!s.get()); -} - -/// A button that toggles a popover menu of `items` (a tuple or `[]const View`). -/// `open` is app-owned `State(bool)` tracking whether the menu is shown; tapping -/// the button toggles it, tapping the scrim dismisses it. -pub fn Menu(label: []const u8, open: *state.State(bool), items: anytype) View { - const content = VStack(items).padding(6); - return Button(label, actionCtx(state.State(bool), open, toggleBoolState)) - .popover(open.binding(), content); -} - -/// Attach a popover menu of `items` to an arbitrary `trigger` view: tapping the -/// trigger toggles the menu. The right-click/long-press analogue of `Menu`. -pub fn ContextMenu(trigger: View, open: *state.State(bool), items: anytype) View { - const content = VStack(items).padding(6); - return trigger - .onTap(actionCtx(state.State(bool), open, toggleBoolState)) - .popover(open.binding(), content); -} pub fn VStack(children: anytype) View { return makeStack(.vertical, 8, .center, children); @@ -1086,11 +739,13 @@ pub fn ZStack(children: anytype) View { return makeStack(.depth, 0, .center, children); } -fn makeStack(direction: engine.Direction, spc: f32, alignment: Alignment, children: anytype) View { +pub fn makeStack(direction: engine.Direction, spc: f32, alignment: Alignment, children: anytype) View { return makeStackFromSlice(direction, spc, alignment, toViews(children)); } -fn makeStackFromSlice(direction: engine.Direction, spc: f32, alignment: Alignment, views: []const View) View { +/// Build a stack `View` from an already-allocated slice of children. Public so +/// composite components in `components/` can assemble rows/columns. +pub fn makeStackFromSlice(direction: engine.Direction, spc: f32, alignment: Alignment, views: []const View) View { return .{ .kind = .{ .stack = .{ .direction = direction, .spacing = spc, @@ -1112,7 +767,9 @@ pub fn fmt(comptime f: []const u8, args: anytype) []const u8 { return std.fmt.allocPrint(buildAlloc(), f, args) catch @panic("oom"); } -fn toViews(children: anytype) []View { +/// Normalize a tuple literal or `[]const View` into an owned `[]View` in the +/// build arena. Public for composite components that accept variadic children. +pub fn toViews(children: anytype) []View { const T = @TypeOf(children); // Accept either a tuple literal `.{a, b}` or a slice `[]const View`. if (T == []const View or T == []View) { @@ -1126,7 +783,9 @@ fn toViews(children: anytype) []View { return out; } -fn flattenGroups(views: []const View) []const View { +/// Splice any `.group` children (from `ForEach`) into a flat child list. +/// Public so composite containers (e.g. `List`) can flatten before laying out. +pub fn flattenGroups(views: []const View) []const View { var total: usize = 0; for (views) |v| total += if (v.kind == .group) v.kind.group.len else 1; if (total == views.len) return views; // nothing to flatten @@ -1185,12 +844,12 @@ pub const TextClick = struct { /// the document (a point above/below/left maps to the nearest edge). fn caretIndexAt(tc: TextClick, p: Point) usize { const txt = tc.state.text(); - const total = countLines(txt); + const total = text_buffer.countLines(txt); const rel_y = p.y - tc.origin.y + tc.scroll_y; var li: usize = if (rel_y <= 0) 0 else @intFromFloat(rel_y / tc.line_height); if (li >= total) li = total - 1; - const r = nthLineRange(txt, li); - return r.start + caretInLine(tc.face, tc.px, txt[r.start..r.end], p.x - tc.origin.x); + const r = text_buffer.nthLineRange(txt, li); + return r.start + text_buffer.caretInLine(tc.face, tc.px, txt[r.start..r.end], p.x - tc.origin.x); } pub const HitRegion = struct { rect: Rect, action: HitAction, disabled: bool }; @@ -1256,6 +915,14 @@ pub const Context = struct { scroll_regions: ?*std.ArrayList(ScrollRegion) = null, /// Device-pixel scale used by `renderScaled` (1 = logical points == pixels). scale: f32 = 1, + /// The cursor's current location in layout points, or null when the pointer + /// is outside the window. Drives `Modifiers.hover_fill`. The app updates it + /// on mouse-motion (and the main loop already redraws per event, so hover + /// highlights track the cursor for free). + hover_point: ?Point = null, + /// Glyph cache for the bundled icon font (`Font.icons()`). When null, `Icon` + /// and `IconButton` paint nothing — wire it (app + tests) to draw icons. + icon_cache: ?*atlas.GlyphCache = null, pub fn init(theme: Theme, cache: *atlas.GlyphCache, arena_alloc: Allocator, hits: *std.ArrayList(HitRegion)) Context { return .{ @@ -1283,6 +950,20 @@ pub const Context = struct { ctx.a11y = a11y; return ctx; } + + /// Build a `theme.Surface` for the painter to draw chrome into — bundling + /// the canvas with the active palette/metrics/scheme and the inherited + /// opacity. The palette/metrics are borrowed from `self.theme`, valid for + /// the duration of the painter call. + pub fn surface(self: *const Context, canvas: *Canvas) theme_mod.Surface { + return .{ + .canvas = canvas, + .palette = &self.theme.colors, + .metrics = &self.theme.metrics, + .scheme = self.theme.scheme, + .opacity = self.opacity, + }; + } }; fn resolvedFontSize(ctx: *const Context, v: View) f32 { @@ -1315,6 +996,8 @@ const progress_h: f32 = 6; const stepper_w: f32 = 68; const label_icon: f32 = 14; const label_gap: f32 = 6; +/// Padding around the glyph in an `IconButton`, enlarging its tap target. +const icon_button_pad: f32 = 6; fn buttonSize(ctx: *const Context, b: ButtonData) Size { const px = ctx.theme.typography.body.size; @@ -1461,6 +1144,11 @@ fn buildContentNode(ctx: *const Context, v: View) Allocator.Error!engine.Node { .width = @floatFromInt(im.image.width), .height = @floatFromInt(im.image.height), }) }, + .icon => |ic| return .{ .leaf = engine.SizingHints.fixedSize(.{ .width = ic.size, .height = ic.size }) }, + .icon_button => |ib| { + const s = ib.size + 2 * icon_button_pad; + return .{ .leaf = engine.SizingHints.fixedSize(.{ .width = s, .height = s }) }; + }, .label => |l| { const px = resolvedFontSize(ctx, v); const tw = shape.measureLineWidth(ctx.cache.face, l.title, px); @@ -1554,6 +1242,8 @@ pub fn render(ctx: *const Context, v: View, rect: Rect, canvas: *Canvas) !void { try drawOverlay(ctx, req, rect, canvas); } } + // The text-field context menu sits above everything, including app overlays. + try drawContextMenu(ctx, rect, canvas); } /// Render `v` for a HiDPI display: lay out and paint in logical *points* (so all @@ -1645,11 +1335,14 @@ fn drawOverlay(ctx: *const Context, req: OverlayReq, root: Rect, canvas: *Canvas .popover => anchoredRect(root, req.anchor, size), }; - // 4. Panel background + hairline border, then the content on top. + // 4. Panel frame (theme-drawn), then the content on top, clipped to the + // panel so rigid children (e.g. an unwrappable Text) can't paint past + // the rounded edge. const radius = ctx.theme.metrics.corner_radius; - try canvas.fillRoundedRect(target, radius, ctx.theme.colors.control_background); - try canvas.strokeRoundedRect(target, radius, ctx.theme.metrics.hairline, ctx.theme.colors.separator); + try ctx.theme.painter.panel(ctx.surface(canvas), target, radius); + try canvas.pushClip(target, radius); try renderInto(ctx, req.content, target, canvas); + try canvas.popClip(); } fn centerRect(root: Rect, size: Size) Rect { @@ -1693,7 +1386,18 @@ fn paint(ctx: *const Context, v: View, lr: engine.LayoutResult, canvas: *Canvas) const op = child_ctx.opacity; // Background fill (behind content, spanning the padded frame). - if (v.mods.background) |fill| try paintFill(canvas, outer, v.mods.corner_radius, fill, op); + if (v.mods.background) |fill| try paintFill(canvas, outer, v.mods.corner_radius, fill, op, &child_ctx.theme.colors); + + // Hover highlight: painted over the background while the cursor is inside + // this view (and it isn't disabled). Cheap point-in-rect test per frame. + if (v.mods.hover_fill) |hc| { + if (!child_ctx.disabled) { + if (ctx.hover_point) |hp| { + if (outer.contains(hp)) + try paintFill(canvas, outer, v.mods.corner_radius, .{ .color = hc }, op, &child_ctx.theme.colors); + } + } + } // Descend through the layout wrappers introduced by padding/frame. var clr = lr; @@ -1711,10 +1415,11 @@ fn paint(ctx: *const Context, v: View, lr: engine.LayoutResult, canvas: *Canvas) } // A presented overlay is not drawn inline — it is enqueued for the drain - // pass in `render`, anchored to this view's frame. - if (v.mods.overlay) |ov| { - if (ov.presented.get()) { - if (ctx.overlays) |overlays| { + // pass in `render`, anchored to this view's frame. A view can carry several + // (e.g. both `.sheet` and `.alert`); each is enqueued when its binding is on. + if (ctx.overlays) |overlays| { + for (v.mods.overlay) |ov| { + if (ov.presented.get()) { try overlays.append(ctx.arena, .{ .content = ov.content.*, .style = ov.style, @@ -1754,7 +1459,7 @@ fn paintContent(ctx: *const Context, v: View, clr: engine.LayoutResult, canvas: paintWrappedText(ctx, v, wt, rect, canvas) catch {}; try emitA11y(ctx, v, rect, .static_text, wt.string, ""); }, - .shape => |sh| try paintShape(canvas, sh, rect, op), + .shape => |sh| try paintShape(ctx, sh, rect, op, canvas), .button => |b| { try paintButton(ctx, b, rect, canvas); try emitA11y(ctx, v, rect, .button, b.label, ""); @@ -1774,6 +1479,14 @@ fn paintContent(ctx: *const Context, v: View, clr: engine.LayoutResult, canvas: try canvas.drawImage(rect, im.image); try emitA11y(ctx, v, rect, .image, "", ""); }, + .icon => |ic| { + try paintIcon(ctx, ic, rect, canvas); + try emitA11y(ctx, v, rect, .image, "", ""); + }, + .icon_button => |ib| { + try paintIconButton(ctx, ib, rect, canvas); + try emitA11y(ctx, v, rect, .button, "", ""); + }, .label => |l| { try paintLabel(ctx, l, v, rect, canvas); try emitA11y(ctx, v, rect, .static_text, l.title, ""); @@ -1804,7 +1517,7 @@ fn paintContent(ctx: *const Context, v: View, clr: engine.LayoutResult, canvas: } } -fn paintFill(canvas: *Canvas, rect: Rect, radius: f32, fill: Fill, op: f32) !void { +fn paintFill(canvas: *Canvas, rect: Rect, radius: f32, fill: Fill, op: f32, palette: *const theme_mod.Palette) !void { switch (fill) { .color => |c| try canvas.fillRoundedRect(rect, radius, c.multiplyAlpha(op)), .linear_gradient => |g| { @@ -1822,33 +1535,37 @@ fn paintFill(canvas: *Canvas, rect: Rect, radius: f32, fill: Fill, op: f32) !voi }, .material => |m| { const s = m.spec(); - try canvas.blurRect(rect, radius, s.sigma, s.tint.multiplyAlpha(op)); + // The frost tint comes from the active theme's (scheme-correct) glass + // role, scaled per material level. + const tint = palette.glass.multiplyAlpha(s.alpha_scale); + try canvas.blurRect(rect, radius, s.sigma, tint.multiplyAlpha(op)); }, } } -fn paintShape(canvas: *Canvas, sh: ShapeData, rect: Rect, op: f32) !void { +fn paintShape(ctx: *const Context, sh: ShapeData, rect: Rect, op: f32, canvas: *Canvas) !void { + const palette = &ctx.theme.colors; switch (sh.shape) { - .rect => try paintFill(canvas, rect, 0, sh.fill, op), - .rounded_rect => try paintFill(canvas, rect, sh.corner_radius, sh.fill, op), - .capsule => try paintFill(canvas, rect, @min(rect.width, rect.height) / 2, sh.fill, op), - .ellipse => try paintFill(canvas, rect, @min(rect.width, rect.height) / 2, sh.fill, op), + .rect => try paintFill(canvas, rect, 0, sh.fill, op, palette), + .rounded_rect => try paintFill(canvas, rect, sh.corner_radius, sh.fill, op, palette), + .capsule => try paintFill(canvas, rect, @min(rect.width, rect.height) / 2, sh.fill, op, palette), + .ellipse => try paintFill(canvas, rect, @min(rect.width, rect.height) / 2, sh.fill, op, palette), .circle => { const r = @min(rect.width, rect.height) / 2; const sq = Rect{ .x = rect.midX() - r, .y = rect.midY() - r, .width = 2 * r, .height = 2 * r }; - try paintFill(canvas, sq, r, sh.fill, op); + try paintFill(canvas, sq, r, sh.fill, op, palette); }, } } fn paintButton(ctx: *const Context, b: ButtonData, rect: Rect, canvas: *Canvas) !void { - const m = ctx.theme.metrics; const dim: f32 = if (ctx.disabled) 0.4 else 1.0; - if (b.role != .plain) { - const bg = if (b.role == .destructive) ctx.theme.colors.destructive else ctx.theme.colors.accent; - try canvas.fillRoundedRect(rect, m.control_corner_radius, bg.multiplyAlpha(ctx.opacity * dim)); - } - const label_color = if (b.role == .plain) ctx.theme.colors.accent else ctx.theme.colors.on_accent; + // The theme's painter draws the button chrome and tells us the label color. + const hovered = if (ctx.hover_point) |hp| rect.contains(hp) else false; + const label_color = try ctx.theme.painter.button(ctx.surface(canvas), rect, b.role, .{ + .disabled = ctx.disabled, + .hovered = hovered and !ctx.disabled, + }); const px = ctx.theme.typography.body.size; const lw = shape.measureLineWidth(ctx.cache.face, b.label, px); const lh = shape.lineHeight(ctx.cache.face, px); @@ -1886,29 +1603,31 @@ fn paintToggle(ctx: *const Context, t: ToggleData, rect: Rect, canvas: *Canvas) // switch on the trailing edge const sw = Rect{ .x = rect.maxX() - switch_w, .y = vcenter(rect, switch_h), .width = switch_w, .height = switch_h }; const on = t.value.get(); - const track = if (on) ctx.theme.colors.accent else ctx.theme.colors.separator.over(ctx.theme.colors.control_background); - try canvas.fillRoundedRect(sw, switch_h / 2, track.multiplyAlpha(op)); + // The view layer owns the knob *geometry*; the theme draws both the track + // and the sliding thumb so the whole control matches the family. + const s = ctx.surface(canvas); + try ctx.theme.painter.switchTrack(s, sw, on); const knob_r = switch_h / 2 - 2; const knob_cx = if (on) sw.maxX() - knob_r - 2 else sw.x + knob_r + 2; - try canvas.fillCircle(.{ .x = knob_cx, .y = sw.midY() }, knob_r, Color.white.multiplyAlpha(op)); + const knob_rect = Rect{ .x = knob_cx - knob_r, .y = sw.midY() - knob_r, .width = 2 * knob_r, .height = 2 * knob_r }; + try ctx.theme.painter.switchKnob(s, knob_rect, on); try ctx.hit_regions.append(ctx.arena, .{ .rect = rect, .action = .{ .toggle = t.value }, .disabled = ctx.disabled }); } fn paintSlider(ctx: *const Context, s: SliderData, rect: Rect, canvas: *Canvas) !void { - const op = ctx.opacity; const r = slider_knob_r; const track = Rect{ .x = rect.x + r, .y = rect.midY() - slider_track_h / 2, .width = rect.width - 2 * r, .height = slider_track_h }; const denom = if (s.max - s.min == 0) 1 else s.max - s.min; const frac = std.math.clamp((s.value.get() - s.min) / denom, 0, 1); - // unfilled track - try canvas.fillRoundedRect(track, slider_track_h / 2, ctx.theme.colors.separator.over(ctx.theme.colors.control_background).multiplyAlpha(op)); - // filled portion - const filled = Rect{ .x = track.x, .y = track.y, .width = track.width * frac, .height = track.height }; - try canvas.fillRoundedRect(filled, slider_track_h / 2, ctx.theme.colors.accent.multiplyAlpha(op)); - // knob + // The view layer owns the geometry (track + knob square); the theme draws + // the groove, fill, and handle in its own style. const knob_cx = track.x + track.width * frac; - try canvas.fillCircle(.{ .x = knob_cx, .y = rect.midY() }, r, Color.white.multiplyAlpha(op)); - try canvas.strokeRoundedRect(.{ .x = knob_cx - r, .y = rect.midY() - r, .width = 2 * r, .height = 2 * r }, r, 1, ctx.theme.colors.separator.multiplyAlpha(op)); + const knob_rect = Rect{ .x = knob_cx - r, .y = rect.midY() - r, .width = 2 * r, .height = 2 * r }; + const hovered = if (ctx.hover_point) |hp| rect.contains(hp) else false; + try ctx.theme.painter.slider(ctx.surface(canvas), track, frac, knob_rect, .{ + .disabled = ctx.disabled, + .hovered = hovered and !ctx.disabled, + }); try ctx.hit_regions.append(ctx.arena, .{ .rect = rect, .action = .{ .slider = .{ .binding = s.value, .min = s.min, .max = s.max, .track = track } }, @@ -1925,8 +1644,12 @@ fn paintStepper(ctx: *const Context, s: StepperData, rect: Rect, canvas: *Canvas drawTextC(ctx, canvas, s.label, px, ctx.foreground.multiplyAlpha(op), .{ .x = rect.x, .y = vcenter(rect, lh) }) catch {}; } const ctrl = Rect{ .x = rect.maxX() - stepper_w, .y = rect.y, .width = stepper_w, .height = rect.height }; - try canvas.fillRoundedRect(ctrl, m.control_corner_radius, ctx.theme.colors.control_background.multiplyAlpha(op)); - try canvas.strokeRoundedRect(ctrl, m.control_corner_radius, m.hairline, ctx.theme.colors.separator.multiplyAlpha(op)); + const hovered = if (ctx.hover_point) |hp| ctrl.contains(hp) else false; + // The theme draws the box chrome; the divider and +/- glyphs stay here. + try ctx.theme.painter.stepperBox(ctx.surface(canvas), ctrl, .{ + .disabled = ctx.disabled, + .hovered = hovered and !ctx.disabled, + }); const half = ctrl.width / 2; const minus = Rect{ .x = ctrl.x, .y = ctrl.y, .width = half, .height = ctrl.height }; const plus = Rect{ .x = ctrl.x + half, .y = ctrl.y, .width = half, .height = ctrl.height }; @@ -1941,11 +1664,8 @@ fn paintStepper(ctx: *const Context, s: StepperData, rect: Rect, canvas: *Canvas } fn paintProgress(ctx: *const Context, pr: ProgressData, rect: Rect, canvas: *Canvas) !void { - const op = ctx.opacity; const bar = Rect{ .x = rect.x, .y = rect.midY() - progress_h / 2, .width = rect.width, .height = progress_h }; - try canvas.fillRoundedRect(bar, progress_h / 2, ctx.theme.colors.separator.over(ctx.theme.colors.control_background).multiplyAlpha(op)); - const filled = Rect{ .x = bar.x, .y = bar.y, .width = bar.width * std.math.clamp(pr.value, 0, 1), .height = bar.height }; - try canvas.fillRoundedRect(filled, progress_h / 2, ctx.theme.colors.accent.multiplyAlpha(op)); + try ctx.theme.painter.progress(ctx.surface(canvas), bar, pr.value); } fn paintLabel(ctx: *const Context, l: LabelData, v: View, rect: Rect, canvas: *Canvas) !void { @@ -1957,6 +1677,22 @@ fn paintLabel(ctx: *const Context, l: LabelData, v: View, rect: Rect, canvas: *C drawTextC(ctx, canvas, l.title, px, ctx.foreground.multiplyAlpha(op), .{ .x = rect.x + label_icon + label_gap, .y = vcenter(rect, lh) }) catch {}; } +fn paintIcon(ctx: *const Context, ic: IconData, rect: Rect, canvas: *Canvas) !void { + const cache = ctx.icon_cache orelse return; // no icon font wired -> draw nothing + const color = (ic.color orelse ctx.foreground).multiplyAlpha(ctx.opacity); + // The glyph is centered in its laid-out square (`rect`); `drawIcon` sizes it + // to the smaller side and handles the HiDPI coverage trick. + font_mod.drawIcon(canvas, cache, ic.icon.codepoint(), rect, ctx.scale, color) catch {}; +} + +fn paintIconButton(ctx: *const Context, ib: IconButtonData, rect: Rect, canvas: *Canvas) !void { + const dim: f32 = if (ctx.disabled) 0.4 else 1; + const color = ctx.foreground.multiplyAlpha(ctx.opacity * dim); + const box = rect.insetBy(icon_button_pad, icon_button_pad); + font_mod.drawIcon(canvas, ctx.icon_cache orelse return, ib.icon.codepoint(), box, ctx.scale, color) catch {}; + try ctx.hit_regions.append(ctx.arena, .{ .rect = rect, .action = .{ .callback = ib.action }, .disabled = ctx.disabled }); +} + /// Paint wrapped text: re-wrap to the laid-out rect width and draw each line, /// stacked by line height, top-aligned within `rect`. fn paintWrappedText(ctx: *const Context, v: View, wt: WrappedTextData, rect: Rect, canvas: *Canvas) !void { @@ -1978,13 +1714,12 @@ fn paintTextField(ctx: *const Context, v: View, tf: TextFieldData, rect: Rect, c // Honor an explicit `.cornerRadius(...)` so fields can be pill-shaped; fall // back to the theme's control radius. const radius = if (v.mods.corner_radius > 0) v.mods.corner_radius else m.control_corner_radius; - try canvas.fillRoundedRect(rect, radius, ctx.theme.colors.control_background.multiplyAlpha(op)); const focused = tf.state.focused; // Keep the focused field's submit callback current so `submitFocused` (fired // from the event loop on Enter) can reach it without the view tree. if (focused) tf.state.on_submit = v.mods.on_submit; - const border = if (focused) ctx.theme.colors.accent else ctx.theme.colors.separator; - try canvas.strokeRoundedRect(rect, radius, if (focused) 2 else m.hairline, border.multiplyAlpha(op)); + // The theme draws the input surface (background + border). + try ctx.theme.painter.field(ctx.surface(canvas), rect, radius, .{ .focused = focused }); const px = ctx.theme.typography.body.size; const lh = shape.lineHeight(ctx.cache.face, px); @@ -1992,6 +1727,16 @@ fn paintTextField(ctx: *const Context, v: View, tf: TextFieldData, rect: Rect, c const pad: f32 = @max(8, @min(radius, 14)); const ty = vcenter(rect, lh); const content = tf.state.text(); + + // Selection highlight, drawn behind the text so the glyphs stay readable. + if (focused) { + if (tf.state.selectionRange()) |s| { + const hx0 = rect.x + pad + shape.measureLineWidth(ctx.cache.face, content[0..s.start], px); + const hx1 = rect.x + pad + shape.measureLineWidth(ctx.cache.face, content[0..s.end], px); + try canvas.fillRect(.{ .x = hx0, .y = ty, .width = @max(1, hx1 - hx0), .height = lh }, ctx.theme.colors.selection.multiplyAlpha(op)); + } + } + if (content.len == 0 and tf.placeholder.len > 0) { drawTextC(ctx, canvas, tf.placeholder, px, ctx.theme.colors.tertiary_label.multiplyAlpha(op), .{ .x = rect.x + pad, .y = ty }) catch {}; } else { @@ -2002,7 +1747,21 @@ fn paintTextField(ctx: *const Context, v: View, tf: TextFieldData, rect: Rect, c const caret_x = rect.x + pad + shape.measureLineWidth(ctx.cache.face, content[0..tf.state.caret], px); try canvas.line(.{ .x = caret_x, .y = ty + 2 }, .{ .x = caret_x, .y = ty + lh - 2 }, 1, ctx.theme.colors.accent.multiplyAlpha(op)); } - try ctx.hit_regions.append(ctx.arena, .{ .rect = rect, .action = .{ .focus = tf.state }, .disabled = ctx.disabled }); + // A `text_click` region (not a bare `.focus`) so a single-line field also + // supports click-to-place-caret, drag-selection, and double-click word + // selection — its one line starts at the text origin with no scroll. + try ctx.hit_regions.append(ctx.arena, .{ + .rect = rect, + .action = .{ .text_click = .{ + .state = tf.state, + .face = ctx.cache.face, + .px = px, + .origin = .{ .x = rect.x + pad, .y = ty }, + .scroll_y = 0, + .line_height = lh, + } }, + .disabled = ctx.disabled, + }); } /// Width of the line-number gutter: enough for `nlines`' digits plus padding. @@ -2032,12 +1791,11 @@ fn paintTextEditor(ctx: *const Context, v: View, ed: TextEditorData, rect: Rect, const focused = st.focused; const radius = if (v.mods.corner_radius > 0) v.mods.corner_radius else m.control_corner_radius; - try canvas.fillRoundedRect(rect, radius, ctx.theme.colors.control_background.multiplyAlpha(op)); - const border_c = if (focused) ctx.theme.colors.accent else ctx.theme.colors.separator; - try canvas.strokeRoundedRect(rect, radius, if (focused) 1.5 else m.hairline, border_c.multiplyAlpha(op)); + // The theme draws the editor surface (background + border). + try ctx.theme.painter.field(ctx.surface(canvas), rect, radius, .{ .focused = focused }); const text = st.text(); - const nlines = countLines(text); + const nlines = text_buffer.countLines(text); const pad: f32 = 8; const gutter_w: f32 = if (ed.line_numbers) gutterWidth(face, px, nlines, pad) else pad; @@ -2048,7 +1806,7 @@ fn paintTextEditor(ctx: *const Context, v: View, ed: TextEditorData, rect: Rect, // Scroll geometry, then auto-follow the caret only when it actually moved. ed.scroll.content_h = @as(f32, @floatFromInt(nlines)) * lh; ed.scroll.viewport_h = view_h; - const caret_line = lineIndexOf(text, st.caret); + const caret_line = text_buffer.lineIndexOf(text, st.caret); if (st.caret != st.last_caret) { const cy = @as(f32, @floatFromInt(caret_line)) * lh; if (cy < ed.scroll.offset) { @@ -2079,7 +1837,7 @@ fn paintTextEditor(ctx: *const Context, v: View, ed: TextEditorData, rect: Rect, var pos: usize = 0; while (line_no < nlines) : (line_no += 1) { const lstart = pos; - const lend = lineEndIndex(text, lstart); + const lend = text_buffer.lineEndIndex(text, lstart); pos = lend + 1; // advance past the '\n' (or one past the end for the last line) const y = top - off + @as(f32, @floatFromInt(line_no)) * lh; if (y + lh < top or y > top + view_h) continue; // fully offscreen @@ -2089,8 +1847,8 @@ fn paintTextEditor(ctx: *const Context, v: View, ed: TextEditorData, rect: Rect, if (s.start <= lend and s.end >= lstart) { const a = @max(s.start, lstart); const b = @min(s.end, lend); - const hx0 = text_x + editorPrefixWidth(face, px, text[lstart..a]); - var hx1 = text_x + editorPrefixWidth(face, px, text[lstart..b]); + const hx0 = text_x + text_buffer.editorPrefixWidth(face, px, text[lstart..a]); + var hx1 = text_x + text_buffer.editorPrefixWidth(face, px, text[lstart..b]); if (s.end > lend) hx1 += space_w; // a selected line-break reads as a trailing sliver try canvas.fillRect(.{ .x = hx0, .y = y, .width = @max(1, hx1 - hx0), .height = lh }, sel_c); } @@ -2106,7 +1864,7 @@ fn paintTextEditor(ctx: *const Context, v: View, ed: TextEditorData, rect: Rect, if (lend > lstart) drawEditorLine(ctx, canvas, text[lstart..lend], px, fg, .{ .x = text_x, .y = y }); if (focused and line_no == caret_line) { - const cx = text_x + editorPrefixWidth(face, px, text[lstart..st.caret]); + const cx = text_x + text_buffer.editorPrefixWidth(face, px, text[lstart..st.caret]); try canvas.line(.{ .x = cx, .y = y + 1 }, .{ .x = cx, .y = y + lh - 1 }, 1, ctx.theme.colors.accent.multiplyAlpha(op)); } } @@ -2130,23 +1888,26 @@ fn paintTextEditor(ctx: *const Context, v: View, ed: TextEditorData, rect: Rect, fn paintPicker(ctx: *const Context, pk: PickerData, rect: Rect, canvas: *Canvas) !void { const op = ctx.opacity; - const m = ctx.theme.metrics; const n = pk.options.len; if (n == 0) return; - // segmented-control background - try canvas.fillRoundedRect(rect, m.control_corner_radius, ctx.theme.colors.separator.over(ctx.theme.colors.window_background).multiplyAlpha(op)); + const s = ctx.surface(canvas); + // The theme draws the segmented track and the selected-segment chip. + try ctx.theme.painter.segmentedTrack(s, rect); const seg_w = rect.width / @as(f32, @floatFromInt(n)); const px = ctx.theme.typography.body.size; const lh = shape.lineHeight(ctx.cache.face, px); const selected = pk.selection.get(); for (pk.options, 0..) |opt, i| { const seg = Rect{ .x = rect.x + @as(f32, @floatFromInt(i)) * seg_w, .y = rect.y, .width = seg_w, .height = rect.height }; - if (@as(i64, @intCast(i)) == selected) { - const inner = seg.insetBy(2, 2); - try canvas.fillRoundedRect(inner, m.control_corner_radius - 1, ctx.theme.colors.control_background.multiplyAlpha(op)); - } + const is_sel = @as(i64, @intCast(i)) == selected; + // The theme draws the selection chip and dictates its label color (an + // accent-filled chip needs `on_accent` text, a pale chip keeps `label`). + const seg_text = if (is_sel) + try ctx.theme.painter.segmentedSelection(s, seg) + else + ctx.theme.colors.secondary_label; const tw = shape.measureLineWidth(ctx.cache.face, opt, px); - drawTextC(ctx, canvas, opt, px, ctx.foreground.multiplyAlpha(op), .{ + drawTextC(ctx, canvas, opt, px, seg_text.multiplyAlpha(op), .{ .x = seg.x + (seg.width - tw) / 2, .y = vcenter(seg, lh), }) catch {}; @@ -2192,6 +1953,40 @@ fn paintScrollState(ctx: *const Context, sd: ScrollStateData, rect: Rect, canvas if (ctx.scroll_regions) |regions| { try regions.append(ctx.arena, .{ .rect = rect, .state = sd.state }); } + try paintScrollbar(ctx, sd.state, rect, canvas); +} + +/// Draw a slim, rounded scroll indicator on the right edge of `rect` when the +/// content overflows. The thumb's length and position track the visible +/// fraction, like a native overlay scrollbar. No-op when everything fits. +fn paintScrollbar(ctx: *const Context, ss: *const ScrollState, rect: Rect, canvas: *Canvas) Allocator.Error!void { + const max = ss.maxOffset(); + if (max <= 0.5 or rect.height <= 0) return; // fits → nothing to show + + // Auto-hide: only visible briefly after the last scroll, then fade out. + if (ss.last_active_ms == 0 or g_frame_time_ms == 0) return; // never scrolled / headless + const since = g_frame_time_ms -| ss.last_active_ms; + if (since >= scrollbar_visible_ms) return; // fully hidden + const fade: f32 = if (since <= scrollbar_hold_ms) + 1 + else + 1 - @as(f32, @floatFromInt(since - scrollbar_hold_ms)) / @as(f32, @floatFromInt(scrollbar_fade_ms)); + + const track_inset: f32 = 2; + const width: f32 = 4; + const track_h = rect.height - track_inset * 2; + if (track_h <= 0) return; + + const visible_frac = std.math.clamp(rect.height / ss.content_h, 0.06, 1); + const thumb_h = @max(24, track_h * visible_frac); + const scroll_frac = std.math.clamp(ss.offset / max, 0, 1); + const thumb_y = rect.y + track_inset + (track_h - thumb_h) * scroll_frac; + const thumb_x = rect.x + rect.width - width - track_inset; + + const thumb = Rect{ .x = thumb_x, .y = thumb_y, .width = width, .height = thumb_h }; + // A muted thumb that reads on both light and dark surfaces. + const col = ctx.theme.colors.secondary_label.withAlpha(0.45 * ctx.opacity * fade); + try canvas.fillRoundedRect(thumb, width / 2, col); } // --------------------------------------------------------------------------- @@ -2203,29 +1998,38 @@ fn paintScrollState(ctx: *const Context, sd: ScrollStateData, rect: Rect, canvas /// the current frame's hit regions on each motion event (so scrolling mid-drag /// stays correct). Managed by the app/event loop. threadlocal var g_drag: ?*TextFieldState = null; +/// The `Binding.ctx` of a slider being dragged (mouse held after a knob/track +/// press). Identifies the slider's hit region across rebuilds, the way `g_drag` +/// uses the `*TextFieldState` pointer for a text drag. +threadlocal var g_slider_drag: ?*anyopaque = null; /// End an in-progress mouse drag-selection (the app calls this on mouse-up). pub fn endDrag() void { g_drag = null; + g_slider_drag = null; } -/// Continue a drag-selection started by a mouse-down on a `TextEditor`: extend -/// the dragging field's caret to `p` (leaving its anchor put, so the selection -/// grows). The field's `text_click` region is re-emitted every frame, so this -/// re-reads the current origin/scroll. No-op when no drag is active. +/// Continue a drag started by a mouse-down on a `TextEditor` (extend the +/// caret) or a `Slider` (set its value from `p.x`). The dragged control's hit +/// region is re-emitted every frame, so this re-reads the current geometry. +/// No-op when no drag is active. pub fn dispatchDrag(regions: []const HitRegion, p: Point) void { - const target = g_drag orelse return; + if (g_drag == null and g_slider_drag == null) return; var i = regions.len; while (i > 0) { i -= 1; const r = regions[i]; if (r.disabled) continue; switch (r.action) { - .text_click => |tc| if (tc.state == target) { + .text_click => |tc| if (tc.state == g_drag) { tc.state.caret = caretIndexAt(tc, p); tc.state.pref_col = null; return; }, + .slider => |s| if (s.binding.ctx == g_slider_drag) { + s.binding.set(sliderValueForX(s.track, s.min, s.max, p.x)); + return; + }, else => {}, } } @@ -2246,6 +2050,49 @@ pub fn dispatchTap(regions: []const HitRegion, p: Point) bool { return false; } +/// Handle a double-click at `p`: if the top-most region under it is an editable +/// text field, select the word there and return true. Otherwise returns false so +/// the caller can fall back to a normal tap. +pub fn dispatchDoubleClick(regions: []const HitRegion, p: Point) bool { + var i = regions.len; + while (i > 0) { + i -= 1; + const r = regions[i]; + if (r.disabled or !r.rect.contains(p)) continue; + switch (r.action) { + .text_click => |tc| { + setFocus(tc.state); + tc.state.selectWordAt(caretIndexAt(tc, p)); + g_drag = null; // a double-click selects a word; it does not arm a drag + return true; + }, + else => return false, // a non-text control is on top — let the caller tap it + } + } + return false; +} + +/// Handle a triple-click at `p`: if the top-most region under it is an editable +/// text field, select all of its text and return true. Otherwise returns false. +pub fn dispatchTripleClick(regions: []const HitRegion, p: Point) bool { + var i = regions.len; + while (i > 0) { + i -= 1; + const r = regions[i]; + if (r.disabled or !r.rect.contains(p)) continue; + switch (r.action) { + .text_click => |tc| { + setFocus(tc.state); + tc.state.selectAll(); + g_drag = null; + return true; + }, + else => return false, + } + } + return false; +} + /// Points scrolled per unit of wheel delta. Negative so a wheel-up (positive /// `dy` in SDL) moves the content toward the top (decreasing offset), matching /// platform convention. @@ -2260,17 +2107,51 @@ pub fn dispatchScroll(regions: []const ScrollRegion, p: Point, dy: f32) bool { const r = regions[i]; if (r.rect.contains(p)) { r.state.scrollBy(dy * scroll_speed); + // Surface the overlay scrollbar and keep the loop awake long enough + // for it to fade back out (see scrollbarsAnimating / paintScrollbar). + r.state.last_active_ms = g_frame_time_ms; + g_scrollbar_until_ms = g_frame_time_ms + scrollbar_visible_ms; return true; } } return false; } +// --- Auto-hiding overlay scrollbars ----------------------------------------- +// The bar appears on scroll, holds for `scrollbar_hold_ms`, then fades over +// `scrollbar_fade_ms` and disappears. `scrollbar_visible_ms` is the full window. + +const scrollbar_hold_ms: u64 = 1600; +const scrollbar_fade_ms: u64 = 400; +const scrollbar_visible_ms: u64 = scrollbar_hold_ms + scrollbar_fade_ms; + +/// Wall-clock time (ms) of the current frame, set by the app via `setFrameTime`. +/// 0 in headless/tests, which keeps scrollbars hidden there. +var g_frame_time_ms: u64 = 0; +/// Time (ms) until which at least one scrollbar is still visible or fading. +var g_scrollbar_until_ms: u64 = 0; + +/// The app calls this once per frame with its millisecond clock so scrollbars +/// can time their auto-hide. +pub fn setFrameTime(ms: u64) void { + g_frame_time_ms = ms; +} + +/// True while a recently-scrolled viewport's overlay scrollbar is still on +/// screen (or fading). The app's loop wakes at ~60fps while this holds so the +/// bar fades out instead of freezing on screen. +pub fn scrollbarsAnimating(now_ms: u64) bool { + return now_ms < g_scrollbar_until_ms; +} + fn performAction(a: HitAction, p: Point) void { switch (a) { .callback => |cb| cb.call(), .toggle => |b| b.set(!b.get()), - .slider => |s| s.binding.set(sliderValueForX(s.track, s.min, s.max, p.x)), + .slider => |s| { + s.binding.set(sliderValueForX(s.track, s.min, s.max, p.x)); + g_slider_drag = s.binding.ctx; // arm a drag so mouse-move tracks the knob + }, .step => |s| { const next = std.math.clamp(s.binding.get() + s.delta, s.min, s.max); s.binding.set(next); @@ -2310,6 +2191,175 @@ pub fn clearFocus() void { g_focused = null; } +// --------------------------------------------------------------------------- +// Text-field context menu (right-click → Cut / Copy / Paste / Select All). +// Drawn by zigui (not a native OS menu) as a top-level popup in `render`, so it +// works for every app and reuses the existing hit-region/dispatch machinery. +// Cut/Copy/Paste need the clipboard, which lives in the SDL layer (`app.zig`); +// the app registers function pointers via `setClipboardOps` so this core code +// stays platform-free. +// --------------------------------------------------------------------------- + +/// Clipboard operations the app injects so the context menu can copy/paste. +pub const ClipboardOps = struct { + /// Put the field's current selection on the system clipboard. + copy: *const fn (*TextFieldState) void, + /// Insert the clipboard's text at the field's caret (replacing any selection). + paste: *const fn (*TextFieldState) void, +}; +var g_clipboard: ?ClipboardOps = null; +pub fn setClipboardOps(ops: ClipboardOps) void { + g_clipboard = ops; +} + +const TextMenuState = struct { field: *TextFieldState, pos: Point, hover: ?MenuAction = null }; +threadlocal var g_ctxmenu: ?TextMenuState = null; + +pub fn contextMenuOpen() bool { + return g_ctxmenu != null; +} +pub fn closeContextMenu() void { + g_ctxmenu = null; +} + +/// The top-most editable field whose hit region contains `p`, or null. The app's +/// right-click handler calls this to decide whether to pop the text menu. +pub fn fieldAt(regions: []const HitRegion, p: Point) ?*TextFieldState { + var i = regions.len; + while (i > 0) { + i -= 1; + const r = regions[i]; + if (r.disabled or !r.rect.contains(p)) continue; + switch (r.action) { + .focus => |fs| return fs, // single-line TextField + .text_click => |tc| return tc.state, // multi-line TextEditor + else => {}, + } + } + return null; +} + +/// Open the text context menu for `field` at layout point `p`. Focuses the field +/// so the menu's actions (and a follow-up paste) target it. +pub fn openContextMenu(field: *TextFieldState, p: Point) void { + setFocus(field); + g_ctxmenu = .{ .field = field, .pos = p }; +} + +/// Update which menu item the cursor is over (for the hover highlight). The app +/// calls this on mouse motion while the menu is open; `regions` are the current +/// frame's hit regions (which include the menu's item rows). A no-op if closed. +pub fn hoverContextMenu(regions: []const HitRegion, p: Point) void { + if (g_ctxmenu == null) return; + var found: ?MenuAction = null; + var i = regions.len; + while (i > 0) { + i -= 1; + const r = regions[i]; + if (r.disabled or !r.rect.contains(p)) continue; + // The first (top-most) region under the cursor decides: a menu item row + // sets the hover; anything else (e.g. the dismiss region) clears it. + switch (r.action) { + .callback => |cb| if (cb.func == &menuPerform) { + const it: *MenuItem = @ptrCast(@alignCast(cb.ctx.?)); + found = it.action; + }, + else => {}, + } + break; + } + if (g_ctxmenu) |*cm| cm.hover = found; +} + +fn eqAction(h: ?MenuAction, a: MenuAction) bool { + return if (h) |x| x == a else false; +} + +const MenuAction = enum { cut, copy, paste, select_all }; +const MenuItem = struct { field: *TextFieldState, action: MenuAction }; + +fn menuPerform(p: ?*anyopaque) void { + const it: *MenuItem = @ptrCast(@alignCast(p.?)); + const f = it.field; + switch (it.action) { + .cut => { + if (g_clipboard) |ops| ops.copy(f); + _ = f.deleteSelection(); + }, + .copy => if (g_clipboard) |ops| ops.copy(f), + .paste => if (g_clipboard) |ops| ops.paste(f), + .select_all => f.selectAll(), + } + closeContextMenu(); +} + +fn menuDismiss(_: ?*anyopaque) void { + closeContextMenu(); +} + +fn menuRow(ctx: *const Context, field: *TextFieldState, label: []const u8, shortcut: []const u8, act: MenuAction, enabled: bool, hovered: bool) View { + // A hovered (always enabled) row paints an accent highlight with inverted text; + // disabled rows stay dimmed and un-highlighted. + const label_fg = if (hovered) ctx.theme.colors.on_accent else if (enabled) ctx.theme.colors.label else ctx.theme.colors.tertiary_label; + const short_fg = if (hovered) ctx.theme.colors.on_accent else ctx.theme.colors.tertiary_label; + var row = HStack(.{ + Text(label).foreground(label_fg), + Spacer(), + Text(shortcut).font(.caption).foreground(short_fg), + }).spacing(20).paddingInsets(.{ .top = 5, .leading = 10, .bottom = 5, .trailing = 10 }).frameMaxWidth(); + if (hovered) row = row.background(ctx.theme.colors.accent).cornerRadius(5); + if (enabled) { + const cx = ctx.arena.create(MenuItem) catch return row; + cx.* = .{ .field = field, .action = act }; + row = row.onTap(.{ .ctx = cx, .func = menuPerform }); + } + return row; +} + +fn buildContextMenu(ctx: *const Context, field: *TextFieldState) View { + const has_sel = field.hasSelection(); + const hov: ?MenuAction = if (g_ctxmenu) |cm| cm.hover else null; + const rows = ctx.arena.alloc(View, 5) catch return Text(""); + rows[0] = menuRow(ctx, field, "Cut", "⌘X", .cut, has_sel, eqAction(hov, .cut)); + rows[1] = menuRow(ctx, field, "Copy", "⌘C", .copy, has_sel, eqAction(hov, .copy)); + rows[2] = menuRow(ctx, field, "Paste", "⌘V", .paste, true, eqAction(hov, .paste)); + rows[3] = Divider(); + rows[4] = menuRow(ctx, field, "Select All", "⌘A", .select_all, field.text().len > 0, eqAction(hov, .select_all)); + return VStack(rows).spacing(2).paddingInsets(.{ .top = 5, .leading = 6, .bottom = 5, .trailing = 6 }).frameMaxWidth(); +} + +/// Draw the open context menu (if any) as a top-level popup: a full-frame +/// dismiss region, then a positioned panel of items. Called by `render` after +/// the app's overlays so it sits on top and wins tap dispatch. +fn drawContextMenu(ctx: *const Context, root: Rect, canvas: *Canvas) Allocator.Error!void { + const cm = g_ctxmenu orelse return; + // Dismiss region beneath the menu: a tap anywhere not on an item closes it. + // Appended before the items (added by renderInto below), so back-to-front + // dispatch gives the items priority. + try ctx.hit_regions.append(ctx.arena, .{ .rect = root, .action = .{ .callback = .{ .func = menuDismiss } }, .disabled = false }); + + // The framework calls `render` after `endBuild`, so the build arena is closed. + // Re-open it (saving/restoring) just long enough to construct the menu's views. + const saved_arena = current_arena; + current_arena = ctx.arena; + defer current_arena = saved_arena; + + const menu = buildContextMenu(ctx, cm.field); + const menu_w: f32 = 210; + const size = measure(ctx, menu, .{ .width = menu_w, .height = null }) catch return; + var x = cm.pos.x; + if (x + size.width > root.maxX()) x = root.maxX() - size.width; + if (x < root.x) x = root.x; + var y = cm.pos.y; + if (y + size.height > root.maxY()) y = root.maxY() - size.height; + if (y < root.y) y = root.y; + const target = Rect{ .x = x, .y = y, .width = size.width, .height = size.height }; + + const radius = ctx.theme.metrics.corner_radius; + try ctx.theme.painter.panel(ctx.surface(canvas), target, radius); + try renderInto(ctx, menu, target, canvas); +} + /// Submit the focused text field: fire its `.onSubmit` callback if it has one. /// A no-op when nothing is focused or no callback was set. The app calls this on /// Enter. @@ -2328,28 +2378,36 @@ const raster = @import("../render/raster.zig"); const TestEnv = struct { font: font_mod.Font, + icon_font: font_mod.Font, cache: atlas.GlyphCache, + icon_cache: atlas.GlyphCache, hits: std.ArrayList(HitRegion), arena_state: std.heap.ArenaAllocator, fn init() TestEnv { return .{ .font = font_mod.Font.default(), + .icon_font = font_mod.Font.icons(), .cache = undefined, + .icon_cache = undefined, .hits = .empty, .arena_state = std.heap.ArenaAllocator.init(testing.allocator), }; } fn setup(self: *TestEnv) void { self.cache = atlas.GlyphCache.init(testing.allocator, &self.font.face); + self.icon_cache = atlas.GlyphCache.init(testing.allocator, &self.icon_font.face); beginBuild(self.arena_state.allocator()); } fn ctx(self: *TestEnv) Context { - return Context.init(@import("../theme/macos.zig").light, &self.cache, self.arena_state.allocator(), &self.hits); + var c = Context.init(@import("../theme/macos.zig").light, &self.cache, self.arena_state.allocator(), &self.hits); + c.icon_cache = &self.icon_cache; + return c; } fn deinit(self: *TestEnv) void { endBuild(); self.cache.deinit(); + self.icon_cache.deinit(); // hit regions are appended via the arena, freed by arena_state.deinit() self.arena_state.deinit(); } @@ -2577,6 +2635,82 @@ test "view: Slider sets value from tap position" { try testing.expect(s.get() < 0.2); } +test "view: Slider follows mouse drag after press" { + var env = TestEnv.init(); + env.setup(); + defer env.deinit(); + var c = env.ctx(); + var s = state.State(f32).init(testing.allocator, 0); + defer s.deinit(); + var fb = try renderToFb(&env, &c, Slider(s.binding(), 0, 1), .{ .x = 0, .y = 0, .width = 100, .height = 20 }, 100, 20); + defer fb.deinit(); + + // Press near the left arms a drag. + try testing.expect(dispatchTap(env.hits.items, .{ .x = 5, .y = 10 })); + try testing.expect(s.get() < 0.2); + // Dragging the mouse right (button held) tracks the knob. + dispatchDrag(env.hits.items, .{ .x = 95, .y = 10 }); + try testing.expect(s.get() > 0.8); + // Dragging past the right edge clamps to max, not beyond. + dispatchDrag(env.hits.items, .{ .x = 500, .y = 10 }); + try testing.expectApproxEqAbs(@as(f32, 1.0), s.get(), 0.001); + // After release, a stray move no longer changes the value. + endDrag(); + dispatchDrag(env.hits.items, .{ .x = 5, .y = 10 }); + try testing.expectApproxEqAbs(@as(f32, 1.0), s.get(), 0.001); +} + +test "view: Icon paints a glyph from the bundled set" { + var env = TestEnv.init(); + env.setup(); + defer env.deinit(); + var c = env.ctx(); + var fb = try renderToFb(&env, &c, Icon(.heart, 24, Color.red), .{ .x = 0, .y = 0, .width = 24, .height = 24 }, 24, 24); + defer fb.deinit(); + // Some red pixels were inked somewhere in the box. + var inked: usize = 0; + var y: u32 = 0; + while (y < 24) : (y += 1) { + var x: u32 = 0; + while (x < 24) : (x += 1) { + if (fb.at(x, y).r > 0.3) inked += 1; + } + } + try testing.expect(inked > 0); +} + +test "view: IconButton fires its callback on tap" { + var env = TestEnv.init(); + env.setup(); + defer env.deinit(); + var c = env.ctx(); + const Ctr = struct { + var hits: u32 = 0; + fn tap(_: ?*anyopaque) void { + hits += 1; + } + }; + Ctr.hits = 0; + const cb = Callback{ .func = Ctr.tap }; + var fb = try renderToFb(&env, &c, IconButton(.trash, 20, cb), .{ .x = 0, .y = 0, .width = 32, .height = 32 }, 32, 32); + defer fb.deinit(); + try testing.expect(dispatchTap(env.hits.items, .{ .x = 16, .y = 16 })); + try testing.expectEqual(@as(u32, 1), Ctr.hits); +} + +test "view: Icon paints nothing when no icon cache is wired" { + var env = TestEnv.init(); + env.setup(); + defer env.deinit(); + var c = env.ctx(); + c.icon_cache = null; // simulate an app that didn't wire the icon font + var canvas = Canvas.init(testing.allocator); + defer canvas.deinit(); + try render(&c, Icon(.heart, 24, Color.red), .{ .x = 0, .y = 0, .width = 24, .height = 24 }, &canvas); + // No glyph command emitted for the icon. + for (canvas.commands.items) |cmd| try testing.expect(cmd != .glyph); +} + test "view: Stepper increments and clamps" { var env = TestEnv.init(); env.setup(); @@ -2895,12 +3029,12 @@ test "view: editorPrefixWidth snaps tabs to tab stops" { defer env.deinit(); const face = &env.font.face; const px: f32 = 14; - const tab_w = editorTabMetrics(face, px).tab; + const tab_w = text_buffer.editorTabMetrics(face, px).tab; // A leading tab advances exactly one tab stop. - try testing.expectApproxEqAbs(tab_w, editorPrefixWidth(face, px, "\t"), 0.01); + try testing.expectApproxEqAbs(tab_w, text_buffer.editorPrefixWidth(face, px, "\t"), 0.01); // Text then a tab snaps forward to the *next* stop (never less than the text). const w_ab = shape.measureLineWidth(face, "ab", px); - const after = editorPrefixWidth(face, px, "ab\t"); + const after = text_buffer.editorPrefixWidth(face, px, "ab\t"); try testing.expect(after > w_ab); try testing.expectApproxEqAbs(@as(f32, 0), @mod(after, tab_w), 0.01); } @@ -2927,7 +3061,7 @@ test "view: TextEditor paints line numbers and places the caret on click" { const lh = shape.lineHeight(&env.font.face, c.theme.typography.body.size); try testing.expect(dispatchTap(env.hits.items, .{ .x = 200, .y = 8 + lh + 2 })); try testing.expect(ed.focused); - try testing.expectEqual(@as(usize, 1), lineIndexOf(ed.text(), ed.caret)); + try testing.expectEqual(@as(usize, 1), text_buffer.lineIndexOf(ed.text(), ed.caret)); } test "view: TextEditor mouse drag selects a range" { @@ -3032,6 +3166,80 @@ test "view: Picker selects a segment on tap" { try testing.expectEqual(@as(i64, 2), sel.get()); } +test "view: RadioGroup selects an option on tap" { + var env = TestEnv.init(); + env.setup(); + defer env.deinit(); + var c = env.ctx(); + var sel = state.State(i64).init(testing.allocator, 0); + defer sel.deinit(); + const opts = [_][]const u8{ "One", "Two", "Three" }; + var canvas = Canvas.init(testing.allocator); + defer canvas.deinit(); + try render(&c, RadioGroup(sel.binding(), &opts), .{ .x = 0, .y = 0, .width = 200, .height = 120 }, &canvas); + try testing.expectEqual(@as(usize, 3), env.hits.items.len); // one tap target per option + // tapping the second row sets the binding to index 1 + try testing.expect(dispatchTap(env.hits.items, env.hits.items[1].rect.center())); + try testing.expectEqual(@as(i64, 1), sel.get()); +} + +test "view: Sidebar selects a row on tap and highlights the selection" { + var env = TestEnv.init(); + env.setup(); + defer env.deinit(); + var c = env.ctx(); + var sel = state.State(i64).init(testing.allocator, 0); + defer sel.deinit(); + const items = [_]SidebarItem{ + .{ .label = "Inbox", .icon = .mail }, + .{ .label = "Sent" }, + .{ .label = "Drafts" }, + }; + var canvas = Canvas.init(testing.allocator); + defer canvas.deinit(); + try render(&c, Sidebar(&items, sel.binding()), .{ .x = 0, .y = 0, .width = 220, .height = 300 }, &canvas); + try testing.expectEqual(@as(usize, 3), env.hits.items.len); + // the selected (row 0) draws an accent highlight behind it + var has_accent = false; + for (canvas.commands.items) |cmd| { + if (cmd == .fill_rrect and cmd.fill_rrect.color.approxEql(c.theme.colors.accent, 0.05)) has_accent = true; + } + try testing.expect(has_accent); + // tapping the third row selects it + try testing.expect(dispatchTap(env.hits.items, env.hits.items[2].rect.center())); + try testing.expectEqual(@as(i64, 2), sel.get()); +} + +test "view: Table shows a header + rows and selects a row on tap" { + var env = TestEnv.init(); + env.setup(); + defer env.deinit(); + var c = env.ctx(); + var sel = state.State(i64).init(testing.allocator, -1); + defer sel.deinit(); + const cols = [_]TableColumn{ .{ .title = "Name" }, .{ .title = "Age", .width = 60 } }; + const rows = [_][]const []const u8{ + &.{ "Ada", "36" }, + &.{ "Alan", "41" }, + &.{ "Grace", "45" }, + }; + var canvas = Canvas.init(testing.allocator); + defer canvas.deinit(); + try render(&c, Table(&cols, &rows, sel.binding()), .{ .x = 0, .y = 0, .width = 300, .height = 200 }, &canvas); + // selection enabled -> one callback tap target per data row (header has none) + var row_hits: usize = 0; + var second: ?Rect = null; + for (env.hits.items) |hr| { + if (hr.action == .callback) { + if (row_hits == 1) second = hr.rect; + row_hits += 1; + } + } + try testing.expectEqual(@as(usize, 3), row_hits); + try testing.expect(dispatchTap(env.hits.items, second.?.center())); + try testing.expectEqual(@as(i64, 1), sel.get()); +} + fn gridCell(cell_color: Color) View { return Rectangle(cell_color).frameHeight(20); } @@ -3389,6 +3597,41 @@ test "view: sheet enqueues an overlay, draws a scrim over content, dismisses on try testing.expect(fb.at(100, 20).luminance() < 0.95); } +test "view: chaining .sheet and .alert keeps both overlays (neither clobbers)" { + var env = TestEnv.init(); + env.setup(); + defer env.deinit(); + var c = env.ctx(); + var ovs: std.ArrayList(OverlayReq) = .empty; + c.overlays = &ovs; + var sheet_on = state.State(bool).init(testing.allocator, true); + defer sheet_on.deinit(); + var alert_on = state.State(bool).init(testing.allocator, false); + defer alert_on.deinit(); + + var canvas = Canvas.init(testing.allocator); + defer canvas.deinit(); + const root = Rect{ .x = 0, .y = 0, .width = 200, .height = 200 }; + const base = Text("Base") + .sheet(sheet_on.binding(), Rectangle(Color.red).frame(80, 40)) + .alert(alert_on.binding(), Rectangle(Color.blue).frame(80, 40)); + + // Only the sheet is presented → exactly one overlay, and it's the sheet. + try render(&c, base, root, &canvas); + try testing.expectEqual(@as(usize, 1), ovs.items.len); + try testing.expectEqual(OverlayStyle.sheet, ovs.items[0].style); + + // Turn the alert on too → both present (the .alert did not overwrite .sheet). + ovs.clearRetainingCapacity(); + env.hits.clearRetainingCapacity(); + canvas.clearCommands(); + alert_on.set(true); + try render(&c, base, root, &canvas); + try testing.expectEqual(@as(usize, 2), ovs.items.len); + try testing.expectEqual(OverlayStyle.sheet, ovs.items[0].style); + try testing.expectEqual(OverlayStyle.alert, ovs.items[1].style); +} + test "view: popover content is positioned near its (top) anchor" { var env = TestEnv.init(); env.setup(); @@ -3453,6 +3696,155 @@ test "view: nested overlays drain once each without recursion" { try testing.expectEqual(OverlayStyle.alert, ovs.items[1].style); } +test "view: double-click selects the word; TextField exposes text_click" { + var env = TestEnv.init(); + env.setup(); + defer env.deinit(); + var c = env.ctx(); + + var field = TextFieldState.init(testing.allocator); + defer field.deinit(); + try field.setText("foo barbaz qux"); + + // selectWordAt expands to the word boundaries around an index. + field.selectWordAt(5); // inside "barbaz" + const r = field.selectionRange().?; + try testing.expectEqualStrings("barbaz", field.text()[r.start..r.end]); + + // A run of whitespace selects together (not a word). + field.selectWordAt(3); // the space after "foo" + const rs = field.selectionRange().?; + try testing.expectEqualStrings(" ", field.text()[rs.start..rs.end]); + + // The single-line field now registers a `text_click`, so a double-click + // dispatch near the left edge selects the first word. + var canvas = Canvas.init(testing.allocator); + defer canvas.deinit(); + try render(&c, TextField("ph", &field), .{ .x = 0, .y = 0, .width = 200, .height = 40 }, &canvas); + try testing.expectEqual(@as(usize, 1), env.hits.items.len); + try testing.expect(env.hits.items[0].action == .text_click); + try testing.expect(dispatchDoubleClick(env.hits.items, .{ .x = 12, .y = 20 })); + const r2 = field.selectionRange().?; + try testing.expectEqualStrings("foo", field.text()[r2.start..r2.end]); + + // Triple-click selects the whole field. + try testing.expect(dispatchTripleClick(env.hits.items, .{ .x = 12, .y = 20 })); + const r3 = field.selectionRange().?; + try testing.expectEqual(@as(usize, 0), r3.start); + try testing.expectEqual(field.text().len, r3.end); +} + +test "view: TextField selection highlight is visible" { + var env = TestEnv.init(); + env.setup(); + defer env.deinit(); + var c = env.ctx(); + + var field = TextFieldState.init(testing.allocator); + defer field.deinit(); + try field.setText("hello"); + setFocus(&field); + defer clearFocus(); + + const rect = Rect{ .x = 0, .y = 0, .width = 140, .height = 36 }; + + // The focus border is also blue, so diff a no-selection render against a + // fully-selected one — the difference is the highlight block. + field.sel_anchor = null; // just a caret + var fb0 = try renderToFb(&env, &c, TextField("ph", &field), rect, 140, 36); + defer fb0.deinit(); + const before = countBlue(&fb0); + + env.hits.clearRetainingCapacity(); + field.selectAll(); + var fb1 = try renderToFb(&env, &c, TextField("ph", &field), rect, 140, 36); + defer fb1.deinit(); + const after = countBlue(&fb1); + + try testing.expect(after > before + 50); // the selection adds a solid blue block +} + +test "view: text context menu — fieldAt, render, actions, clipboard hook" { + var env = TestEnv.init(); + env.setup(); + defer env.deinit(); + var c = env.ctx(); + + var field = TextFieldState.init(testing.allocator); + defer field.deinit(); + try field.setText("hello world"); + + // Record clipboard calls routed through the injected ops. + const Rec = struct { + var copied: bool = false; + var pasted: bool = false; + fn copy(f: *TextFieldState) void { + _ = f; + copied = true; + } + fn paste(f: *TextFieldState) void { + _ = f; + pasted = true; + } + }; + Rec.copied = false; + Rec.pasted = false; + setClipboardOps(.{ .copy = Rec.copy, .paste = Rec.paste }); + defer g_clipboard = null; + defer closeContextMenu(); // never leak menu state into later tests, even on failure + + // fieldAt finds the single-line TextField under a point (it registers a + // `text_click` region). + var canvas = Canvas.init(testing.allocator); + defer canvas.deinit(); + try render(&c, TextField("ph", &field), .{ .x = 0, .y = 0, .width = 400, .height = 300 }, &canvas); + try testing.expect(fieldAt(env.hits.items, .{ .x = 20, .y = 15 }) == &field); + + // Open the menu and re-render. Crucially, build the arg view, then `endBuild` + // BEFORE render — exactly as the framework does — so this guards the bug where + // the menu's own views were constructed after the build arena had closed. + env.hits.clearRetainingCapacity(); + field.selectAll(); // a selection enables Cut/Copy so they register hit regions + openContextMenu(&field, .{ .x = 10, .y = 10 }); + try testing.expect(contextMenuOpen()); + const menu_arg = TextField("ph", &field); + endBuild(); + defer beginBuild(env.arena_state.allocator()); // restore for env.deinit()'s endBuild() + try render(&c, menu_arg, .{ .x = 0, .y = 0, .width = 400, .height = 300 }, &canvas); + try testing.expect(env.hits.items.len >= 4); // field + dismiss + enabled items + + // Hover: pointing at the Copy item's region sets the menu's hovered action. + var copy_rect: ?Rect = null; + for (env.hits.items) |r| switch (r.action) { + .callback => |cb| if (cb.func == &menuPerform) { + const it: *MenuItem = @ptrCast(@alignCast(cb.ctx.?)); + if (it.action == .copy) copy_rect = r.rect; + }, + else => {}, + }; + try testing.expect(copy_rect != null); + hoverContextMenu(env.hits.items, .{ .x = copy_rect.?.x + 5, .y = copy_rect.?.y + 5 }); + try testing.expect(eqAction(g_ctxmenu.?.hover, .copy)); + // Pointing at empty space (the dismiss region) clears the hover. + hoverContextMenu(env.hits.items, .{ .x = 380, .y = 280 }); + try testing.expect(g_ctxmenu.?.hover == null); + + // Copy fires the hook (no mutation) and closes the menu. + var copy_item = MenuItem{ .field = &field, .action = .copy }; + menuPerform(©_item); + try testing.expect(Rec.copied); + try testing.expect(!contextMenuOpen()); + try testing.expectEqualStrings("hello world", field.text()); + + // Select All then Cut copies and clears the buffer. + field.selectAll(); + try testing.expect(field.hasSelection()); + var cut_item = MenuItem{ .field = &field, .action = .cut }; + menuPerform(&cut_item); + try testing.expect(Rec.copied); + try testing.expectEqual(@as(usize, 0), field.text().len); +} + test "view: Menu toggles a popover of items" { var env = TestEnv.init(); env.setup(); @@ -3491,11 +3883,13 @@ test "view: TabView shows the selected tab and switches on tab-bar tap" { .{ .label = "One", .content = Rectangle(Color.red).frame(80, 40) }, .{ .label = "Two", .content = Rectangle(Color.blue).frame(80, 40) }, }; - // tab 0 selected -> red content shows + // tab 0 selected -> red content shows. (The tab bar's selected segment is + // an accent chip, so some blue is always present; the blue *content* is + // asserted as a large relative jump after switching tabs.) var fb0 = try renderToFb(&env, &c, TabView(sel.binding(), &tabs), .{ .x = 0, .y = 0, .width = 200, .height = 100 }, 200, 100); defer fb0.deinit(); try testing.expect(countRed(&fb0) > 100); - try testing.expect(countBlue(&fb0) < 20); + const blue_with_red_tab = countBlue(&fb0); // tap the second tab-bar segment (a .select hit region targeting value 1) var tapped = false; @@ -3509,11 +3903,12 @@ test "view: TabView shows the selected tab and switches on tab-bar tap" { try testing.expect(tapped); try testing.expectEqual(@as(i64, 1), sel.get()); - // re-render -> tab 1 (blue) content now shows + // re-render -> tab 1 (blue) content now shows, red is gone env.hits.clearRetainingCapacity(); var fb1 = try renderToFb(&env, &c, TabView(sel.binding(), &tabs), .{ .x = 0, .y = 0, .width = 200, .height = 100 }, 200, 100); defer fb1.deinit(); - try testing.expect(countBlue(&fb1) > 100); + try testing.expect(countBlue(&fb1) > blue_with_red_tab + 100); + try testing.expect(countRed(&fb1) < 100); } test "view: List wraps rows in a scroll + divider-separated stack" { @@ -3527,3 +3922,101 @@ test "view: List wraps rows in a scroll + divider-separated stack" { // 2 rows + 1 divider between them try testing.expectEqual(@as(usize, 3), inner.kind.stack.children.len); } + +// ── Painter seam ──────────────────────────────────────────────────────────── + +test "painter: macOS draws glass where Win2000 chisels a bevel" { + var env = TestEnv.init(); + env.setup(); + defer env.deinit(); + const macos = @import("../theme/macos.zig"); + const win2000 = @import("../theme/win2000.zig"); + const tree = Button("OK", action(Counter.inc)); + + // macOS button: a blue glass gradient. + var mc = env.ctx(); + mc.theme = macos.light; + const ms = try measure(&mc, tree, .unspecified); + const mw: u32 = @intFromFloat(@ceil(ms.width)); + const mh: u32 = @intFromFloat(@ceil(ms.height)); + env.hits.clearRetainingCapacity(); + var mfb = try renderToFb(&env, &mc, tree, Rect.fromOriginSize(.{}, ms), mw, mh); + defer mfb.deinit(); + + // Win2000 button: a silver face with a dark-shadow bottom bevel. + var wc = env.ctx(); + wc.theme = win2000.light; + const ws = try measure(&wc, tree, .unspecified); + const ww: u32 = @intFromFloat(@ceil(ws.width)); + const wh: u32 = @intFromFloat(@ceil(ws.height)); + env.hits.clearRetainingCapacity(); + var wfb = try renderToFb(&env, &wc, tree, Rect.fromOriginSize(.{}, ws), ww, wh); + defer wfb.deinit(); + + // Win2000's outer bottom edge is the dark-shadow grey; macOS's body is blue. + const w_bottom = wfb.at(ww / 2, wh - 1); + try testing.expect(w_bottom.approxEql(win2000.light.colors.control_dark_shadow, 0.2)); + const m_mid = mfb.at(mw / 2, mh / 2); + try testing.expect(m_mid.b > m_mid.r); // glass gradient is blue, not grey + // ...and that same spot in Win2000 is the neutral silver face, not blue. + const w_mid = wfb.at(ww / 2, wh / 2); + try testing.expect(!(w_mid.b > w_mid.r + 0.1)); +} + +test "painter: Material tint follows the color scheme" { + var env = TestEnv.init(); + env.setup(); + defer env.deinit(); + const macos = @import("../theme/macos.zig"); + // A frosted panel over a known (white) backdrop: the glass tint comes from + // the theme's scheme-correct `Palette.glass`, so light vs dark differ. + const tree = Empty().frame(40, 40).backgroundMaterial(.regular); + const rect = Rect{ .x = 0, .y = 0, .width = 40, .height = 40 }; + + var lc = env.ctx(); + lc.theme = macos.light; + var lfb = try renderToFb(&env, &lc, tree, rect, 40, 40); + defer lfb.deinit(); + + var dc = env.ctx(); + dc.theme = macos.dark; + var dfb = try renderToFb(&env, &dc, tree, rect, 40, 40); + defer dfb.deinit(); + + // Light glass keeps the white backdrop bright; dark glass darkens it. + try testing.expect(lfb.at(20, 20).luminance() > dfb.at(20, 20).luminance() + 0.2); +} + +test "painter: every family paints all controls without error" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const families = [_]Theme{ + @import("../theme/macos.zig").light, + @import("../theme/macos.zig").dark, + @import("../theme/win2000.zig").light, + @import("../theme/windows10.zig").light, + @import("../theme/windows10.zig").dark, + @import("../theme/kde.zig").light, + @import("../theme/kde.zig").dark, + @import("../theme/mui.zig").light, + @import("../theme/mui.zig").dark, + }; + const r = Rect{ .x = 0, .y = 0, .width = 60, .height = 24 }; + const knob = Rect{ .x = 0, .y = 0, .width = 18, .height = 18 }; + for (families) |th| { + var canvas = Canvas.init(arena.allocator()); + const s = theme_mod.Surface{ .canvas = &canvas, .palette = &th.colors, .metrics = &th.metrics, .scheme = th.scheme }; + const p = th.painter; + _ = try p.button(s, r, .normal, .{}); + try p.field(s, r, 4, .{}); + try p.segmentedTrack(s, r); + _ = try p.segmentedSelection(s, r); + try p.switchTrack(s, r, true); + try p.switchKnob(s, knob, true); + try p.slider(s, r, 0.5, knob, .{}); + try p.stepperBox(s, r, .{}); + try p.progress(s, r, 0.5); + try p.panel(s, r, 8); + try testing.expect(canvas.commands.items.len > 0); + } +} diff --git a/src/zigui.zig b/src/zigui.zig index 833e848..75bbd70 100644 --- a/src/zigui.zig +++ b/src/zigui.zig @@ -7,7 +7,10 @@ const std = @import("std"); -pub const version = std.SemanticVersion{ .major = 0, .minor = 0, .patch = 0 }; +/// The library version. `build.zig` reads it from `build.zig.zon` (the package +/// manifest) and injects it here, so the manifest is the single source of truth +/// — `zigui.version`, the `zig fetch` tag, and the manifest can never drift. +pub const version = std.SemanticVersion.parse(@import("build_options").version) catch unreachable; // Foundation modules are re-exported here as they are implemented (TDD: a // module is referenced for test discovery only once it exists). @@ -51,16 +54,32 @@ pub const ttf = @import("text/ttf.zig"); pub const atlas = @import("text/atlas.zig"); pub const shape = @import("text/shape.zig"); pub const font = @import("text/font.zig"); +pub const icons = @import("icons.zig"); pub const Font = font.Font; pub const GlyphCache = atlas.GlyphCache; pub const drawText = font.drawText; pub const theme = @import("theme/theme.zig"); pub const macos = @import("theme/macos.zig"); +pub const win2000 = @import("theme/win2000.zig"); +pub const windows10 = @import("theme/windows10.zig"); +pub const kde = @import("theme/kde.zig"); +pub const mui = @import("theme/mui.zig"); +pub const theme_registry = @import("theme/registry.zig"); pub const Theme = theme.Theme; +pub const Palette = theme.Palette; +pub const Painter = theme.Painter; +// Painter-authoring types — so third parties can implement a theme's chrome. +pub const Surface = theme.Surface; +pub const ControlState = theme.ControlState; +pub const Role = theme.Role; pub const TextStyle = theme.TextStyle; pub const FontWeight = theme.FontWeight; pub const ColorScheme = theme.ColorScheme; +/// Built-in theme families (macOS, Windows 10, Windows 2000, KDE Plasma). +pub const ThemeFamily = theme_registry.Family; +/// Resolve a theme family to a concrete `Theme` for the OS color scheme. +pub const themeForScheme = theme_registry.forScheme; /// The default theme (macOS light). pub const default_theme = macos.light; @@ -72,9 +91,13 @@ pub const action = view.action; pub const actionCtx = view.actionCtx; pub const HitRegion = view.HitRegion; pub const dispatchTap = view.dispatchTap; +pub const dispatchDoubleClick = view.dispatchDoubleClick; +pub const dispatchTripleClick = view.dispatchTripleClick; pub const ScrollState = view.ScrollState; pub const ScrollRegion = view.ScrollRegion; pub const dispatchScroll = view.dispatchScroll; +pub const setFrameTime = view.setFrameTime; +pub const scrollbarsAnimating = view.scrollbarsAnimating; pub const dispatchDrag = view.dispatchDrag; pub const endDrag = view.endDrag; pub const render = view.render; @@ -92,6 +115,15 @@ pub const setFocus = view.setFocus; pub const clearFocus = view.clearFocus; pub const submitFocused = view.submitFocused; +// Text-field context menu (right-click Cut/Copy/Paste/Select All). +pub const ClipboardOps = view.ClipboardOps; +pub const setClipboardOps = view.setClipboardOps; +pub const fieldAt = view.fieldAt; +pub const openContextMenu = view.openContextMenu; +pub const closeContextMenu = view.closeContextMenu; +pub const contextMenuOpen = view.contextMenuOpen; +pub const hoverContextMenu = view.hoverContextMenu; + // Public component constructors (also available via `zigui.components.*`). pub const components = @import("components.zig"); pub const Text = view.Text; @@ -100,9 +132,12 @@ pub const VStack = view.VStack; pub const HStack = view.HStack; pub const ZStack = view.ZStack; pub const Spacer = view.Spacer; +pub const MinSpacer = view.MinSpacer; pub const Divider = view.Divider; pub const ForEach = view.ForEach; pub const Button = view.Button; +pub const ButtonRoled = view.ButtonRoled; +pub const Empty = view.Empty; pub const Toggle = view.Toggle; pub const Slider = view.Slider; pub const Stepper = view.Stepper; @@ -113,13 +148,25 @@ pub const TextFieldState = view.TextFieldState; pub const TextEditor = view.TextEditor; pub const Label = view.Label; pub const Image = view.Image; +pub const Icon = view.Icon; +pub const IconButton = view.IconButton; +pub const IconName = view.IconName; pub const ScrollView = view.ScrollView; pub const ScrollViewState = view.ScrollViewState; +pub const ScrollViewOffset = view.ScrollViewOffset; pub const List = view.List; pub const LazyVGrid = view.LazyVGrid; pub const LazyHGrid = view.LazyHGrid; pub const Tab = view.Tab; pub const TabView = view.TabView; +pub const Sidebar = view.Sidebar; +pub const SidebarItem = view.SidebarItem; +pub const RadioGroup = view.RadioGroup; +pub const Table = view.Table; +pub const TableColumn = view.TableColumn; +pub const selectAction = view.selectAction; +pub const setThemeTokens = view.setThemeTokens; +pub const BuildTokens = view.BuildTokens; pub const NavigationSplitView = view.NavigationSplitView; pub const NavigationLink = view.NavigationLink; pub const NavBackButton = view.NavBackButton; @@ -139,3 +186,9 @@ test { std.testing.refAllDecls(@This()); _ = @import("integration_test.zig"); } + +test "version is parsed from the package manifest" { + // Derived from build.zig.zon via build_options; a malformed manifest version + // would have already failed the comptime parse. Guard against a 0.0.0 stub. + try std.testing.expect(version.major != 0 or version.minor != 0 or version.patch != 0); +}