chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:29:49 +08:00
commit 505bac6b53
1470 changed files with 457757 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
.DS_Store
# Zig build output for example apps.
.zig-cache/
zig-out/
# Generated frontend dependencies and build output.
node_modules/
.next/
out/
dist/
next-env.d.ts
package-lock.json
# Local iOS build products.
Libraries/
# Prepared example music (tools/prepare-example-music.sh). The committed
# music-manifest.zon describes the catalog; the audio itself stays local.
soundboard/assets/music/
+49
View File
@@ -0,0 +1,49 @@
# Native SDK Examples
Most examples here are zero-config apps: `app.zon` + `src/` (+ `assets/`) and nothing else. The `native` CLI owns their build — run any of them straight from its directory:
```sh
native dev # build and run with hot reload
native test # run the app's test suite
native build # produce a ReleaseFast binary in zig-out/bin/
```
(In this repository the CLI is `zig-out/bin/native`, built by `zig build` at the root.) A handful of examples own a `build.zig` because they genuinely outgrow the generated graph — each one's build file opens with the reason.
## Zero-config apps (native-rendered)
| Example | Shows |
| --- | --- |
| `habits` | The smallest markup app: one `.native` view, a plain-form Model/Msg/update. |
| `calculator` | A complete small app: markup keypad, text-field keyboard path, chrome shortcuts, theming. |
| `notes` | Persistence through the effects channel: debounced writes, restore on boot, dialogs, search. |
| `kanban` | Builder-view boards with drag interactions. |
| `feed` | Windowed 100k-row list virtualization with runtime-owned scrolling. |
| `soundboard` | Album grid with decoded cover art, context menus, timers, and a custom theme. |
| `deck` | Two model-declared windows and a dense track ledger. |
| `markdown-viewer` | Real file I/O through effects, hidden-inset titlebar retrofit, preview + editor. |
| `system-monitor` | Live process sampling, confirmation dialogs, a settings window. |
| `gpu-surface` | A Metal-backed GPU surface composed beside native controls and WebView content. |
| `gpu-dashboard` | Native chrome, a GPU surface, and a retained canvas display list. |
| `gpu-components` | The retained GPU widget controls in one native-first component lab. |
| `canvas-preview` | Canvas + WebView in one window, panes snapped to canvas anchors, a status item. |
| `effects-probe` | The effect system live: spawn/fetch/file effects, cancellation, worker wakes. |
## Examples that own their build
| Example | Why it keeps a build.zig |
| --- | --- |
| `hello` | Smallest WebView shell, with the SDK module wiring spelled out by hand. |
| `webview` | Bridge commands, window APIs, security policy, automation, and optional CEF engine flags. |
| `command-app` | One command routed from toolbar, menu, tray, shortcut, and bridge entry points. |
| `capabilities` | Guarded OS services: notifications, clipboard, credentials, dialogs, file drops. |
| `native-shell` | Native toolbar/sidebar/statusbar chrome around a WebView content area. |
| `native-panels` | Split native panels and stacked native controls around WebView content. |
| `browser` | Layered WebViews for isolated page content, engine link flags wired by hand. |
| `next`, `react`, `svelte`, `vue` | Frontend projects with managed install/build/dev-server steps. |
| `ui-inbox` | The builder-view inbox; its `-Dmobile` lib step feeds the mobile host shims. |
| `mobile-canvas` | Builds the mobile embed static library consumed by the iOS/Android canvas shims. |
`mobile-shell`, `ios`, and `android` are mobile host projects (Xcode/Gradle shells plus shared `app.zon` metadata) rather than desktop app directories.
Start with `habits` for the native-rendered markup path, or `hello` for the WebView path. Move to `webview` when you need native commands or WebView policy, `capabilities` for guarded OS services, the GPU trio when you want custom-rendered or retained-canvas panes, and a frontend example when building a real web frontend.
+44
View File
@@ -0,0 +1,44 @@
# Native SDK ai-chat-ts example
A chat client for an OpenAI-compatible chat-completions endpoint, authored entirely in **TypeScript + Native markup**. Zero Zig: the logic tier is the app-core subset under `src/`, transpiled to native at build time as one module; `src/app.native` is the whole view tier and `app.zon` the manifest. The build detects `src/core.ts` in the tree and stages the wiring itself; no JS runtime ships in the binary.
This is the reference answer to "can a TypeScript core call an AI API?": the network surface is one `Cmd.fetch` with a real `Authorization: Bearer <key>` header built at runtime from the launch environment, the JSON wire format is pure byte math in the subset, and because the whole exchange is effect data, a recorded conversation **replays byte-identically with zero network and zero env reads** — the e2e suite pins the exact request bytes and replays a two-turn conversation, transport failure and retry included, with no endpoint in the room and none of the launch variables set.
The core is two modules plus one SDK library:
- `src/core.ts` — the entry module: Model (the conversation, the composer, the request phase, the launch configuration), Msg, update, the env channel, and every exported binding helper.
- `src/api.ts` — the chat-completions wire format over bytes: request encoding (JSON escaping included) and response parsing (`choices[0].message.content` on success, `error.message` on failure; anything malformed is `null`, never a half-parsed conversation).
- `@native-sdk/core/text` — the SDK's byte-splice text engine, transpiled in for the composer's caret/selection/IME fidelity.
```sh
NATIVE_SDK_CHAT_ENDPOINT="http://127.0.0.1:11434/v1/chat/completions" \
NATIVE_SDK_CHAT_MODEL="<your model name>" \
NATIVE_SDK_CHAT_API_KEY="local" \
native dev # run the real app
native dev --core --script dev-script.ndjson # the core-logic loop under node - no renderer, no network
native check # subset-check the core's import graph + markup + app.zon
```
The end-to-end proof battery lives in the SDK repo (`tests/ts-core/ai_chat_e2e_tests.zig`, run by `zig build test-ts-core-e2e`): it drives this example's real core and shipping markup headlessly through the teaching state (zero fetches without configuration), a scripted conversation with the request bytes pinned (`Authorization` header included), the in-flight guard, every failure shape, and record→replay with the launch variables unset and changed.
## Configuration: the env channel
The endpoint, model, and key arrive through the core's `envMsgs` channel — one journaled Msg per variable at install. The core never reads the environment (that would break determinism), **no endpoint is baked in, and no key exists anywhere in this tree**: until all three variables are present and non-empty, the app shows a setup panel naming exactly what is missing and issues zero requests.
- **`NATIVE_SDK_CHAT_ENDPOINT`** — the full chat-completions URL (for a local runtime, typically `http://127.0.0.1:<port>/v1/chat/completions`).
- **`NATIVE_SDK_CHAT_MODEL`** — the model name the endpoint expects in the request body.
- **`NATIVE_SDK_CHAT_API_KEY`** — the bearer token, sent as a standard `Authorization: Bearer <key>` header. Local OpenAI-compatible runtimes ignore auth; any placeholder satisfies the guard.
Record/replay journals these deliveries: a session recorded with the variables set replays byte-identically on a machine where they are unset or different — the recorded values feed from the journal, and replay never reads the environment.
## Where this example is honest about v1 boundaries
Every line below is a decided posture, listed on purpose:
- **The reply arrives whole, not streamed.** `Cmd.fetch` is buffered by design in v1 — one request, one `{ status, body }` result Msg. The UI shows an honest waiting state instead of a token stream. The effect engine underneath already frames streamed response bodies into line Msgs (the Zig effects channel's `.stream` fetch — exactly the shape SSE token streams arrive in); surfacing that in the TS Cmd vocabulary is the named roadmap item. Buffered is also what makes the replay trick trivial: one journaled result per request.
- **A failed request keeps the conversation.** Every failure shape — a non-200 status (the endpoint's own `error.message` surfaces when the body carries one), a 200 whose body does not parse, a transport failure with its machine-readable reason — lands in one failed state with the history intact and a Retry that re-sends the same conversation.
- **One request in flight, by construction.** `phase === "sending"` guards every send path in update (the Send button binds the same guard), and the `"chat"` effect key would reject a duplicate at the engine even if update misbehaved. A send blocked by the guard loses nothing — the draft survives.
- **Long conversations eventually hit the request bound.** The engine's fetch body bound is 64 KiB; a conversation that outgrows it is rejected by the engine at runtime and lands in the failed state with a reason. Clear starts fresh. (History trimming/summarizing is app policy, deliberately not built in here.)
- **The conversation is not persisted.** The Model is the session; `Cmd.writeFile` + a boot-time `Cmd.readFile` is the standard persistence pattern when an app wants history across launches.
- **Desktop only.** TypeScript cores build desktop apps today.
- **The encoder's helpers return byte arrays instead of appending to a shared buffer.** Local mutation ends at the first escape — an array passed to another function is no longer yours to mutate (the NS1051 "mutates after the array escaped" rule) — so `encodeChatRequest` assembles the request from values its helpers return, in one literal, rather than handing a parts buffer around between pushes.
+39
View File
@@ -0,0 +1,39 @@
.{
.id = "dev.native_sdk.ai_chat_ts",
.name = "ai-chat-ts",
.display_name = "AI Chat TS",
.description = "A chat client for an OpenAI-compatible endpoint, authored entirely in TypeScript + Native markup.",
.version = "0.1.0",
.platforms = .{"macos"},
// The network permission covers the one effect the app performs:
// the buffered `Cmd.fetch` exchange with the configured
// chat-completions endpoint. Nothing else leaves the process.
.permissions = .{ "view", "network" },
.capabilities = .{ "native_views", "gpu_surfaces" },
// The stock theme pack, composed with the live system appearance —
// light/dark follows the OS with no core code.
.shell = .{
.windows = .{
.{
.label = "main",
.title = "AI Chat TS",
.width = 760,
.height = 640,
.min_width = 560,
.min_height = 420,
.restore_state = false,
.restore_policy = "center_on_primary",
.views = .{
.{ .label = "chat-canvas", .kind = "gpu_surface", .fill = true, .role = "Chat canvas", .accessibility_label = "AI chat conversation", .gpu_backend = "metal", .gpu_pixel_format = "bgra8_unorm", .gpu_present_mode = "timer", .gpu_alpha_mode = "opaque", .gpu_color_space = "srgb", .gpu_vsync = true },
},
},
},
},
.security = .{
.navigation = .{
.allowed_origins = .{ "zero://app", "zero://inline" },
.external_links = .{ .action = "deny" },
},
},
.web_engine = "system",
}
+39
View File
@@ -0,0 +1,39 @@
# The chat client's core-logic loop, headless: replay with
# native dev --core --script dev-script.ndjson
# Msgs dispatch into update; the endpoint's answers are ordinary Msgs, so
# the responses are fed back by hand exactly as the transcript's
# `cmd fetch ...` lines invite — the same loop the native app runs, with
# you standing in for the network.
# The launch configuration arrives through the env channel as ordinary
# Msgs (under the real app the generated wiring dispatches these from the
# environment at install). A local placeholder endpoint - nothing dials
# out under the core host.
{"kind":"endpoint_set","value":{"$bytes":"http://127.0.0.1:11434/v1/chat/completions"}}
{"kind":"model_set","value":{"$bytes":"local-model"}}
{"kind":"key_set","value":{"$bytes":"local"}}
# Type a message (the composer runs the SDK byte-splice text engine) and
# send. The transcript shows the fetch command whole: POST, the endpoint,
# the runtime-built "authorization: Bearer <key>" header, and the JSON
# body - system prompt first, then the history.
{"kind":"draft_edit","edit":{"kind":"insert_text","text":{"$bytes":"Say hi in two words"}}}
{"kind":"send"}
# The endpoint's answer, fed back by hand: choices[0].message.content
# parses into the assistant turn (escapes decode - note the \n).
{"kind":"chat_response","status":200,"body":{"$bytes":"{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"Hi\\nthere!\"}}]}"}}
# A second turn grows the history: watch the request body carry both
# earlier turns before the new question.
{"kind":"draft_edit","edit":{"kind":"insert_text","text":{"$bytes":"And a follow-up?"}}}
{"kind":"send"}
# This time the endpoint fails with its own error body - the failed
# state keeps the history and surfaces error.message as the reason.
{"kind":"chat_response","status":500,"body":{"$bytes":"{\"error\":{\"message\":\"model overloaded\",\"type\":\"server_error\"}}"}}
# Retry re-sends the SAME conversation (no new turn); a success resolves
# it into the fourth turn.
{"kind":"retry"}
{"kind":"chat_response","status":200,"body":{"$bytes":"{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"Certainly.\"}}]}"}}
+425
View File
@@ -0,0 +1,425 @@
// ai-chat-ts api module: the OpenAI-compatible chat-completions wire
// format in pure subset TypeScript over bytes — request encoding on the
// way out, response parsing on the way back. No JSON runtime exists in a
// core (the binary carries no JS engine), and none is needed: the request
// is a byte concatenation with one escape routine, and the response walk
// reads exactly the two fields the app uses (`choices[0].message.content`
// on success, `error.message` on failure) and refuses everything
// malformed with `null` — a body that does not parse is a failed request,
// never a half-parsed conversation.
//
// Everything here is deterministic byte math, which is what makes the
// announcement trick work: the exact request bytes are pinned in the e2e
// suite, and a recorded conversation replays byte-identically with no
// network in the room.
import { asciiBytes } from "@native-sdk/core";
export type Bytes = Uint8Array;
export type Role = "user" | "assistant";
/// One conversation turn. Kept in the Model, so history is ordinary
/// committed state — record→replay carries the whole conversation.
export interface Turn {
readonly id: number;
readonly role: Role;
readonly text: Bytes;
}
/// How deep a response's nesting may go before the scanner refuses it —
/// a bound on recursion, not on honest responses (a chat-completions
/// body nests four levels).
const MAX_JSON_DEPTH = 64;
// ------------------------------------------------------------- request
/// The chat-completions request body:
/// `{"model":…,"messages":[{"role":"system","content":…},…]}` with the
/// system prompt first and every conversation turn after it, in order.
/// The caller supplies the model name from the launch environment; the
/// turns are the Model's history including the just-appended user turn.
/// Helpers RETURN their bytes and this one builder assembles them in a
/// single literal — a parts buffer handed around between appends would
/// end its ownership at the first hand-off (a passed array escapes), so
/// each turn arrives pre-concatenated from `encodeTurn` instead.
export function encodeChatRequest(modelName: Bytes, systemPrompt: Bytes, turns: readonly Turn[]): Bytes {
return concatAll([
asciiBytes('{"model":'),
jsonString(modelName),
asciiBytes(',"messages":[{"role":"system","content":'),
jsonString(systemPrompt),
asciiBytes("}"),
...turns.map((turn) => encodeTurn(turn)),
asciiBytes("]}"),
]);
}
/// One conversation turn as its complete message-object bytes:
/// `,{"role":…,"content":…}` — comma included, since every turn follows
/// the system message.
function encodeTurn(turn: Turn): Bytes {
const open =
turn.role === "user"
? asciiBytes(',{"role":"user","content":')
: asciiBytes(',{"role":"assistant","content":');
return concatAll([open, jsonString(turn.text), asciiBytes("}")]);
}
/// A JSON string literal (quotes included) from UTF-8 text bytes. Two
/// passes over one classification: measure, then fill — the fill writes
/// only the array this function just created (a buffer passed to a
/// helper would have escaped and become immutable), so every escape is
/// written inline. Non-ASCII UTF-8 bytes pass through raw (valid JSON).
export function jsonString(text: Bytes): Bytes {
let len = 2;
for (const b of text) {
if (b === 0x22 || b === 0x5c || b === 0x08 || b === 0x09 || b === 0x0a || b === 0x0c || b === 0x0d) {
len += 2;
} else if (b < 0x20) {
len += 6; // \u00XX
} else {
len += 1;
}
}
const out = new Uint8Array(len);
out[0] = 0x22;
let at = 1;
for (const b of text) {
if (b === 0x22 || b === 0x5c) {
out[at] = 0x5c;
out[at + 1] = b;
at += 2;
} else if (b === 0x08 || b === 0x09 || b === 0x0a || b === 0x0c || b === 0x0d) {
out[at] = 0x5c;
out[at + 1] = escapeLetter(b);
at += 2;
} else if (b < 0x20) {
out[at] = 0x5c;
out[at + 1] = 0x75;
out[at + 2] = 0x30;
out[at + 3] = 0x30;
out[at + 4] = hexDigit((b >> 4) & 0xf);
out[at + 5] = hexDigit(b & 0xf);
at += 6;
} else {
out[at] = b;
at += 1;
}
}
out[at] = 0x22;
return out;
}
/// The letter of a two-byte JSON escape: \b \t \n \f \r.
function escapeLetter(b: number): number {
if (b === 0x08) return 0x62;
if (b === 0x09) return 0x74;
if (b === 0x0a) return 0x6e;
if (b === 0x0c) return 0x66;
return 0x72;
}
function hexDigit(value: number): number {
return value < 10 ? 0x30 + value : 0x57 + value; // 0-9, a-f
}
export function concatAll(parts: readonly Uint8Array[]): Bytes {
let total = 0;
for (const part of parts) total += part.length;
const out = new Uint8Array(total);
let at = 0;
for (const part of parts) {
out.set(part, at);
at += part.length;
}
return out;
}
/// The `Authorization` header's value: `Bearer <key>`, built at runtime
/// from the launch-supplied key. Header VALUES may be runtime bytes
/// (`Cmd.fetch` header names stay compile-time), so the token rides the
/// standard header every hosted provider expects — never the URL, never
/// a server access log.
export function bearerToken(apiKey: Bytes): Bytes {
return concatAll([asciiBytes("Bearer "), apiKey]);
}
// ------------------------------------------------------------ response
/// `choices[0].message.content` from a chat-completions success body, or
/// null when the body is not that shape (malformed JSON, empty choices,
/// a non-string content) — the caller turns null into the failed state.
export function parseChatContent(body: Bytes): Bytes | null {
let at = skipWs(body, 0);
if (at >= body.length || body[at] !== 0x7b) return null; // {
const choicesAt = memberValue(body, at, asciiBytes("choices"));
if (choicesAt === -1) return null;
let cursor = skipWs(body, choicesAt);
if (cursor >= body.length || body[cursor] !== 0x5b) return null; // [
cursor = skipWs(body, cursor + 1);
if (cursor >= body.length || body[cursor] === 0x5d) return null; // empty choices
if (body[cursor] !== 0x7b) return null;
const messageAt = memberValue(body, cursor, asciiBytes("message"));
if (messageAt === -1) return null;
cursor = skipWs(body, messageAt);
if (cursor >= body.length || body[cursor] !== 0x7b) return null;
const contentAt = memberValue(body, cursor, asciiBytes("content"));
if (contentAt === -1) return null;
cursor = skipWs(body, contentAt);
if (cursor >= body.length || body[cursor] !== 0x22) return null; // a string
return decodeJsonString(body, cursor);
}
/// `error.message` from a chat-completions error body, or null when the
/// body carries no such field (the caller falls back to the HTTP status).
export function parseErrorMessage(body: Bytes): Bytes | null {
let at = skipWs(body, 0);
if (at >= body.length || body[at] !== 0x7b) return null;
const errorAt = memberValue(body, at, asciiBytes("error"));
if (errorAt === -1) return null;
let cursor = skipWs(body, errorAt);
if (cursor >= body.length || body[cursor] !== 0x7b) return null;
const messageAt = memberValue(body, cursor, asciiBytes("message"));
if (messageAt === -1) return null;
cursor = skipWs(body, messageAt);
if (cursor >= body.length || body[cursor] !== 0x22) return null;
return decodeJsonString(body, cursor);
}
function skipWs(b: Bytes, at: number): number {
let i = at;
while (i < b.length && (b[i] === 0x20 || b[i] === 0x09 || b[i] === 0x0a || b[i] === 0x0d)) i += 1;
return i;
}
/// With `at` on an object's `{`, the index of the named member's value —
/// or -1 when the key is absent or the object is malformed. Keys in the
/// chat wire format are plain ASCII identifiers, so key comparison is
/// raw-byte (a key that needed escapes simply never matches).
function memberValue(b: Bytes, at: number, key: Bytes): number {
let i = skipWs(b, at + 1);
if (i < b.length && b[i] === 0x7d) return -1; // empty object
for (let guard = 0; guard < 4096; guard++) {
if (i >= b.length || b[i] !== 0x22) return -1;
const keyStart = i + 1;
const keyEnd = rawStringEnd(b, i);
if (keyEnd === -1) return -1;
i = skipWs(b, keyEnd + 1);
if (i >= b.length || b[i] !== 0x3a) return -1; // :
i = skipWs(b, i + 1);
if (bytesEqualRange(b, keyStart, keyEnd, key)) return i;
i = skipValue(b, i, 0);
if (i === -1) return -1;
i = skipWs(b, i);
if (i >= b.length) return -1;
if (b[i] === 0x7d) return -1; // } — key not present
if (b[i] !== 0x2c) return -1; // ,
i = skipWs(b, i + 1);
}
return -1;
}
function bytesEqualRange(b: Bytes, start: number, end: number, key: Bytes): boolean {
if (end - start !== key.length) return false;
for (let i = 0; i < key.length; i++) {
if (b[start + i] !== key[i]) return false;
}
return true;
}
/// The index of a string's closing quote (escape-aware, undecoded), with
/// `at` on the opening quote — or -1 when the string never closes.
function rawStringEnd(b: Bytes, at: number): number {
let i = at + 1;
while (i < b.length) {
if (b[i] === 0x5c) {
i += 2;
} else if (b[i] === 0x22) {
return i;
} else {
i += 1;
}
}
return -1;
}
/// The index just past any JSON value at `at` — or -1 on malformed input
/// (including nesting past MAX_JSON_DEPTH: a recursion bound, so a
/// hostile body cannot walk the stack off a cliff).
function skipValue(b: Bytes, at: number, depth: number): number {
if (depth > MAX_JSON_DEPTH) return -1;
if (at >= b.length) return -1;
const c = b[at];
if (c === 0x22) {
const end = rawStringEnd(b, at);
return end === -1 ? -1 : end + 1;
}
if (c === 0x7b || c === 0x5b) {
const close = c === 0x7b ? 0x7d : 0x5d;
let i = skipWs(b, at + 1);
if (i < b.length && b[i] === close) return i + 1;
for (let guard = 0; guard < 65536; guard++) {
if (c === 0x7b) {
if (i >= b.length || b[i] !== 0x22) return -1;
const keyEnd = rawStringEnd(b, i);
if (keyEnd === -1) return -1;
i = skipWs(b, keyEnd + 1);
if (i >= b.length || b[i] !== 0x3a) return -1;
i = skipWs(b, i + 1);
}
i = skipValue(b, i, depth + 1);
if (i === -1) return -1;
i = skipWs(b, i);
if (i >= b.length) return -1;
if (b[i] === close) return i + 1;
if (b[i] !== 0x2c) return -1;
i = skipWs(b, i + 1);
}
return -1;
}
if (c === 0x74) return literalEnd(b, at, asciiBytes("true"));
if (c === 0x66) return literalEnd(b, at, asciiBytes("false"));
if (c === 0x6e) return literalEnd(b, at, asciiBytes("null"));
// A number: sign, digits, dot, exponent — consume the token greedily
// (structural validity is all the walk needs).
if (c === 0x2d || (c >= 0x30 && c <= 0x39)) {
let i = at + 1;
while (i < b.length) {
const d = b[i];
const numeric =
(d >= 0x30 && d <= 0x39) || d === 0x2e || d === 0x65 || d === 0x45 || d === 0x2b || d === 0x2d;
if (!numeric) break;
i += 1;
}
return i;
}
return -1;
}
function literalEnd(b: Bytes, at: number, literal: Bytes): number {
if (at + literal.length > b.length) return -1;
if (!bytesEqualRange(b, at, at + literal.length, literal)) return -1;
return at + literal.length;
}
// ----------------------------------------------------- string decoding
/// One decoded step of a JSON string body at `i`: a raw byte copies
/// through as itself (UTF-8 passes raw), an escape resolves to a code
/// point (`\uXXXX` surrogate pairs combined; a lone surrogate becomes
/// U+FFFD). `point` is -1 for a raw byte (copy `raw` through), and null
/// means the escape is malformed.
interface DecodeStep {
readonly next: number;
/// The code point an escape resolved to; -1 when the step is a raw
/// byte copy.
readonly point: number;
/// The raw byte to copy when `point` is -1.
readonly raw: number;
}
function decodeStep(b: Bytes, i: number): DecodeStep | null {
if (b[i] !== 0x5c) return { next: i + 1, point: -1, raw: b[i] };
if (i + 1 >= b.length) return null;
const e = b[i + 1];
if (e === 0x22) return { next: i + 2, point: 0x22, raw: 0 };
if (e === 0x5c) return { next: i + 2, point: 0x5c, raw: 0 };
if (e === 0x2f) return { next: i + 2, point: 0x2f, raw: 0 };
if (e === 0x62) return { next: i + 2, point: 0x08, raw: 0 };
if (e === 0x66) return { next: i + 2, point: 0x0c, raw: 0 };
if (e === 0x6e) return { next: i + 2, point: 0x0a, raw: 0 };
if (e === 0x72) return { next: i + 2, point: 0x0d, raw: 0 };
if (e === 0x74) return { next: i + 2, point: 0x09, raw: 0 };
if (e !== 0x75) return null;
const high = hexQuad(b, i + 2);
if (high === -1) return null;
if (high >= 0xd800 && high <= 0xdbff) {
if (i + 11 < b.length && b[i + 6] === 0x5c && b[i + 7] === 0x75) {
const low = hexQuad(b, i + 8);
if (low >= 0xdc00 && low <= 0xdfff) {
return { next: i + 12, point: 0x10000 + ((high - 0xd800) << 10) + (low - 0xdc00), raw: 0 };
}
}
return { next: i + 6, point: 0xfffd, raw: 0 }; // lone high surrogate
}
if (high >= 0xdc00 && high <= 0xdfff) return { next: i + 6, point: 0xfffd, raw: 0 }; // lone low surrogate
return { next: i + 6, point: high, raw: 0 };
}
function utf8Length(point: number): number {
if (point < 0x80) return 1;
if (point < 0x800) return 2;
if (point < 0x10000) return 3;
return 4;
}
/// The decoded UTF-8 bytes of a JSON string with `at` on its opening
/// quote — or null when the string is malformed. Measure-then-fill over
/// decodeStep; the fill writes only the array this function created.
function decodeJsonString(b: Bytes, at: number): Bytes | null {
let i = at + 1;
let len = 0;
let closed = false;
while (i < b.length) {
if (b[i] === 0x22) {
closed = true;
break;
}
const step = decodeStep(b, i);
if (step === null) return null;
len += step.point === -1 ? 1 : utf8Length(step.point);
i = step.next;
}
if (!closed) return null;
const out = new Uint8Array(len);
i = at + 1;
let w = 0;
while (i < b.length && b[i] !== 0x22) {
const step = decodeStep(b, i);
if (step === null) return null;
if (step.point === -1) {
out[w] = step.raw;
w += 1;
} else if (step.point < 0x80) {
out[w] = step.point;
w += 1;
} else if (step.point < 0x800) {
out[w] = 0xc0 | (step.point >> 6);
out[w + 1] = 0x80 | (step.point & 0x3f);
w += 2;
} else if (step.point < 0x10000) {
out[w] = 0xe0 | (step.point >> 12);
out[w + 1] = 0x80 | ((step.point >> 6) & 0x3f);
out[w + 2] = 0x80 | (step.point & 0x3f);
w += 3;
} else {
out[w] = 0xf0 | (step.point >> 18);
out[w + 1] = 0x80 | ((step.point >> 12) & 0x3f);
out[w + 2] = 0x80 | ((step.point >> 6) & 0x3f);
out[w + 3] = 0x80 | (step.point & 0x3f);
w += 4;
}
i = step.next;
}
return out;
}
/// Four hex digits at `at` as a number, or -1 when any is not hex.
function hexQuad(b: Bytes, at: number): number {
let value = 0;
for (let i = 0; i < 4; i++) {
if (at + i >= b.length) return -1;
const d = hexValue(b[at + i]);
if (d === -1) return -1;
value = value * 16 + d;
}
return value;
}
function hexValue(c: number): number {
if (c >= 0x30 && c <= 0x39) return c - 0x30;
if (c >= 0x61 && c <= 0x66) return c - 0x57;
if (c >= 0x41 && c <= 0x46) return c - 0x37;
return -1;
}
+120
View File
@@ -0,0 +1,120 @@
<!-- The chat client's whole view tier: one markup file over the TS core's
model, bound by the names core.ts wrote (fields and exported helpers
bind verbatim). The
header carries the model badge and Clear, the conversation is a
controlled scroll of role bubbles (user right/accent, assistant
left/surface) with honest sending and failed rows, and the composer
is a text-field on the core's byte-splice engine — Enter and the
Send button dispatch the same `send` arm. Until the three launch
variables arrive through the env channel, the teaching panel
explains the setup and the app issues zero requests. -->
<column background="background">
<row height="52" padding="12" gap="10" cross="center" background="surface" label="Chat header">
<text label="AI Chat"><span weight="bold" scale="1.1">AI Chat</span></text>
<badge variant="secondary">{modelLabel}</badge>
<spacer grow="1" />
<if test="{sending}">
<text size="sm" foreground="text_muted">waiting for the model…</text>
</if>
<button size="sm" variant="ghost" icon="trash" disabled="{clearDisabled}" on-press="clear" label="Clear conversation">Clear</button>
</row>
<separator />
<if test="{unconfigured}">
<!-- The teaching state: no endpoint is baked in and no request ever
leaves an unconfigured app — the panel names exactly what is
missing. -->
<column grow="1" padding="24" main="center" cross="center" label="Setup">
<panel padding="24" background="surface" radius="lg" width="520" label="Connect a model">
<column gap="12">
<text><span weight="bold">Connect a model</span></text>
<text size="sm" foreground="text_muted">This app talks to an OpenAI-compatible chat-completions endpoint. Set all three variables and relaunch — the core reads them once, at install, through the env channel.</text>
<row gap="8" cross="center">
<text size="sm" grow="1">NATIVE_SDK_CHAT_ENDPOINT</text>
<if test="{endpointMissing}">
<text size="sm" foreground="destructive">missing</text>
</if>
<else>
<text size="sm" foreground="success">set</text>
</else>
</row>
<row gap="8" cross="center">
<text size="sm" grow="1">NATIVE_SDK_CHAT_MODEL</text>
<if test="{modelMissing}">
<text size="sm" foreground="destructive">missing</text>
</if>
<else>
<text size="sm" foreground="success">set</text>
</else>
</row>
<row gap="8" cross="center">
<text size="sm" grow="1">NATIVE_SDK_CHAT_API_KEY</text>
<if test="{keyMissing}">
<text size="sm" foreground="destructive">missing</text>
</if>
<else>
<text size="sm" foreground="success">set</text>
</else>
</row>
<text size="sm" foreground="text_muted">The README shows the full setup, including local OpenAI-compatible runtimes.</text>
</column>
</panel>
</column>
</if>
<else>
<scroll grow="1" label="Conversation" value="{chatScrollTop}" on-scroll="chat_scrolled">
<column padding="24" gap="10">
<if test="{emptyConversation}">
<panel padding="24" background="surface" radius="lg" label="Empty conversation">
<column gap="6">
<text>Ask anything</text>
<text size="sm" foreground="text_muted">The reply arrives whole — responses are buffered in v1, not streamed.</text>
</column>
</panel>
</if>
<for each="turnRows" as="t" key="id">
<if test="{t.user}">
<row key="{t.id}" gap="8" label="You said">
<spacer grow="1" min-width="64" />
<panel padding="12" background="accent" radius="lg">
<text wrap="true" foreground="accent_text">{t.text}</text>
</panel>
</row>
</if>
<else>
<row key="{t.id}" gap="8" label="The model said">
<panel padding="12" background="surface" radius="lg">
<text wrap="true">{t.text}</text>
</panel>
<spacer grow="1" min-width="64" />
</row>
</else>
</for>
<if test="{sending}">
<row gap="8" label="Reply pending">
<panel padding="12" background="surface" radius="lg">
<text foreground="text_muted">…</text>
</panel>
<spacer grow="1" min-width="64" />
</row>
</if>
<if test="{failed}">
<panel padding="12" background="surface" radius="lg" label="Request failed">
<row gap="10" cross="center">
<icon name="alert" width="14" height="14" foreground="destructive" />
<column gap="2" grow="1">
<text size="sm" foreground="destructive">Request failed</text>
<text size="sm" foreground="text_muted">{failReasonLabel}</text>
</column>
<button size="sm" variant="ghost" icon="refresh-cw" on-press="retry" label="Retry request">Retry</button>
</row>
</panel>
</if>
</column>
</scroll>
<separator />
<row padding="12" gap="8" cross="center" background="surface" label="Composer">
<text-field grow="1" text="{draftText}" placeholder="Message the model…" on-input="draft_edit" on-submit="send" label="Message" />
<button variant="primary" icon="send" disabled="{sendDisabled}" on-press="send" label="Send message">Send</button>
</row>
</else>
</column>
+401
View File
@@ -0,0 +1,401 @@
// ai-chat-ts core: a chat client for an OpenAI-compatible chat-completions
// endpoint, authored entirely in the TypeScript app-core subset. Zero Zig
// in this tree: the build transpiles this module and src/api.ts,
// src/app.native is the whole view, app.zon the manifest.
//
// The core is two modules plus one SDK library, all under src/:
//
// core.ts (this file) Model, Msg, update, the wiring channels, and
// every exported binding helper — the entry module is the
// app's public face (markup and node both see its exports)
// api.ts the chat-completions wire format in pure bytes: request
// encoding, response parsing (choices[0].message.content and
// error.message — exactly the fields the app reads)
// @native-sdk/core/text the SDK's byte-splice text engine, transpiled
// in for the composer's caret/selection/IME fidelity
//
// The whole network surface is ONE effect: `Cmd.fetch` on the "chat" key,
// buffered (fetch streaming is consciously not in v1 — the reply arrives
// whole; the README frames the roadmap). The in-flight discipline is
// model-first: `phase === "sending"` blocks every re-send in update, so a
// second request cannot exist while one is out — and the "chat" key backs
// that up at the engine (a duplicate live key would be rejected, never
// doubled).
//
// The endpoint, model name, and API key arrive through the `envMsgs`
// channel as journaled Msgs at install — the core never reads the
// environment (NS1005), which is exactly why a recorded conversation
// replays byte-identically on a machine with none of the variables set.
import { Cmd, asciiBytes, type EnvMsg } from "@native-sdk/core";
import {
applyTextInputEvent,
clampedInsertEvent,
trimAsciiSpaces,
type TextEditState,
type TextInputEvent,
} from "@native-sdk/core/text";
// The SDK-provided scroll-state record (the shape markup's on-scroll
// matches structurally - imported, so no in-file mirror can drift).
import { type ScrollState } from "@native-sdk/core/events";
import {
bearerToken,
encodeChatRequest,
parseChatContent,
parseErrorMessage,
type Bytes,
type Turn,
} from "./api.ts";
/// The conversation's standing instruction, first in every request's
/// message list. One constant, versioned with the app — not model state,
/// so replay and the request pins never depend on it drifting.
const SYSTEM_PROMPT = asciiBytes(
"You are a helpful assistant inside a native desktop app. Answer concisely, in plain text.",
);
/// The composer's byte capacity — comfortably under the engine's 64 KiB
/// request-body bound with a long conversation around it.
const MAX_DRAFT = 4096;
/// Assigning the scroll binding a value past the content clamps to the
/// bottom — how a new message keeps the latest turn in view.
const SCROLL_BOTTOM = 1000000;
// -------------------------------------------------------------- composer
// The fixed-capacity editor state for the message field: the SDK text
// engine does the byte splicing; this wrapper is the app's flat committed
// shape for it (compStart -1 = no composition). Immutable: composerApply
// returns a new value.
export interface ComposerDraft {
readonly bytes: Bytes;
readonly anchor: number;
readonly focus: number;
readonly compStart: number; // -1 when no composition
readonly compEnd: number;
}
function composerInit(): ComposerDraft {
return { bytes: new Uint8Array(0), anchor: 0, focus: 0, compStart: -1, compEnd: -1 };
}
function composerState(d: ComposerDraft): TextEditState {
return {
text: d.bytes,
selection: { anchor: d.anchor, focus: d.focus },
composition: d.compStart >= 0 ? { start: d.compStart, end: d.compEnd } : null,
};
}
function composerApply(d: ComposerDraft, event: TextInputEvent): ComposerDraft {
const state = composerState(d);
const next = applyTextInputEvent(state, event, MAX_DRAFT);
if (next === null) {
// Over-capacity: clamp an insert to the bytes that fit (refuse-whole
// for everything else) — the runtime TextBuffer's contract.
const clamped = clampedInsertEvent(state, event, MAX_DRAFT);
if (clamped === null) return d;
const nextClamped = applyTextInputEvent(state, clamped, MAX_DRAFT);
if (nextClamped === null) return d;
return {
bytes: nextClamped.text,
anchor: nextClamped.selection.anchor,
focus: nextClamped.selection.focus,
compStart: nextClamped.composition !== null ? nextClamped.composition.start : -1,
compEnd: nextClamped.composition !== null ? nextClamped.composition.end : -1,
};
}
return {
bytes: next.text,
anchor: next.selection.anchor,
focus: next.selection.focus,
compStart: next.composition !== null ? next.composition.start : -1,
compEnd: next.composition !== null ? next.composition.end : -1,
};
}
// ------------------------------------------------------------------ model
export type Phase = "idle" | "sending" | "failed";
export interface Model {
/// The conversation, oldest first — user and assistant turns alike.
/// Committed state, so record→replay carries the whole conversation.
readonly turns: readonly Turn[];
readonly nextId: number;
/// The request lifecycle: `sending` is the in-flight guard (every
/// re-send path checks it), `failed` keeps the history and shows the
/// reason until the next send.
readonly phase: Phase;
/// Why the last request failed: the transport reason (`timed_out`,
/// `connect_failed`, ...), the endpoint's own error.message, or the
/// HTTP status line — never empty in the failed phase.
readonly failReason: Bytes;
readonly draft: ComposerDraft;
/// The launch configuration (the envMsgs channel): the full
/// chat-completions URL, the model name, and the API key. All three
/// empty until their variables arrive; the app teaches setup until
/// every one is non-empty.
readonly endpoint: Bytes;
readonly modelName: Bytes;
readonly apiKey: Bytes;
/// The conversation scroll offset, echoed from markup's `on-scroll`
/// and pushed past the content on every new turn (the clamp lands it
/// at the bottom) — the controlled-scroll shape.
readonly chatScrollTop: number;
}
export function initialModel(): Model {
return {
turns: [],
nextId: 1,
phase: "idle",
failReason: new Uint8Array(0),
draft: composerInit(),
endpoint: new Uint8Array(0),
modelName: new Uint8Array(0),
apiKey: new Uint8Array(0),
chatScrollTop: 0,
};
}
// -------------------------------------------------------------------- msg
export type Msg =
| { readonly kind: "draft_edit"; readonly edit: TextInputEvent }
/// The send gesture: the composer's Enter (markup `on-submit`) and the
/// Send button dispatch the same arm.
| { readonly kind: "send" }
/// Re-issue the failed request over the history as it stands (the
/// unanswered user turn is already the last entry).
| { readonly kind: "retry" }
| { readonly kind: "clear" }
/// The delivered HTTP response, any status — the fetch ok arm.
| { readonly kind: "chat_response"; readonly status: number; readonly body: Bytes }
/// The transport failure — the fetch err arm's machine-readable reason.
| { readonly kind: "chat_failed"; readonly reason: Bytes }
| { readonly kind: "chat_scrolled"; readonly scroll: ScrollState }
| { readonly kind: "endpoint_set"; readonly value: Bytes }
| { readonly kind: "model_set"; readonly value: Bytes }
| { readonly kind: "key_set"; readonly value: Bytes };
// --------------------------------------------------- host-event channels
/// The launch configuration channel: each variable present at launch
/// dispatches one journaled Msg right after boot. NO default endpoint
/// and NO baked key exist anywhere in this tree — an unconfigured app
/// says so on screen instead of dialing a stranger.
export const envMsgs: readonly EnvMsg<Msg>[] = [
{ env: "NATIVE_SDK_CHAT_ENDPOINT", msg: "endpoint_set" },
{ env: "NATIVE_SDK_CHAT_MODEL", msg: "model_set" },
{ env: "NATIVE_SDK_CHAT_API_KEY", msg: "key_set" },
];
/// Update-only state: host-fired Msg arms and the fields markup reads
/// through the exported derived helpers instead of directly.
export const viewUnbound = [
"chat_response",
"chat_failed",
"endpoint_set",
"model_set",
"key_set",
"turns",
"nextId",
"phase",
"failReason",
"draft",
"endpoint",
"modelName",
"apiKey",
] as const;
// ---------------------------------------------------------------- derived
function isConfigured(model: Model): boolean {
return model.endpoint.length > 0 && model.modelName.length > 0 && model.apiKey.length > 0;
}
/// The teaching state: some launch variable is missing, so the app can
/// only explain how to connect a model — and issues zero requests.
export function unconfigured(model: Model): boolean {
return !isConfigured(model);
}
export function endpointMissing(model: Model): boolean {
return model.endpoint.length === 0;
}
export function modelMissing(model: Model): boolean {
return model.modelName.length === 0;
}
export function keyMissing(model: Model): boolean {
return model.apiKey.length === 0;
}
export function sending(model: Model): boolean {
return model.phase === "sending";
}
export function failed(model: Model): boolean {
return model.phase === "failed";
}
export function failReasonLabel(model: Model): Bytes {
return model.failReason;
}
export function draftText(model: Model): Bytes {
return model.draft.bytes;
}
export function emptyConversation(model: Model): boolean {
return model.turns.length === 0;
}
/// The header's model badge: the configured name, or the gap it teaches.
export function modelLabel(model: Model): Bytes {
return model.modelName.length > 0 ? model.modelName : asciiBytes("no model configured");
}
export function sendDisabled(model: Model): boolean {
return model.phase === "sending" || !isConfigured(model);
}
export function clearDisabled(model: Model): boolean {
return model.phase === "sending" || model.turns.length === 0;
}
/// One conversation row for markup's `for each`: the role flag picks the
/// bubble side and colors.
export interface TurnRow {
readonly id: number;
readonly user: boolean;
readonly text: Bytes;
}
export function turnRows(model: Model): readonly TurnRow[] {
return model.turns.map((t) => ({ id: t.id, user: t.role === "user", text: t.text }));
}
// ----------------------------------------------------------------- update
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
switch (msg.kind) {
case "draft_edit":
return { ...model, draft: composerApply(model.draft, msg.edit) };
case "send": {
// The in-flight guard: one request at a time, by model state — a
// second send while one is out is a no-op, so the "chat" key can
// never collide at the engine.
if (!isConfigured(model) || model.phase === "sending") return model;
const text = trimAsciiSpaces(model.draft.bytes);
if (text.length === 0) return model;
const turns: readonly Turn[] = [...model.turns, { id: model.nextId, role: "user", text: text }];
return [
{
...model,
turns: turns,
nextId: model.nextId + 1,
phase: "sending",
failReason: new Uint8Array(0),
draft: composerInit(),
chatScrollTop: SCROLL_BOTTOM,
},
Cmd.fetch(
{
url: model.endpoint,
method: "POST",
// The bearer token is a RUNTIME header value (built from the
// launch-supplied key); header names stay compile-time.
headers: { authorization: bearerToken(model.apiKey), "content-type": "application/json" },
body: encodeChatRequest(model.modelName, SYSTEM_PROMPT, turns),
timeoutMs: 120000,
},
{ key: "chat", ok: "chat_response", err: "chat_failed" },
),
];
}
case "retry": {
// Re-send the conversation as it stands: only from the failed
// state, and only when the last turn is the unanswered user turn.
if (model.phase !== "failed" || !isConfigured(model)) return model;
if (model.turns.length === 0) return model;
if (model.turns[model.turns.length - 1].role !== "user") return model;
return [
{ ...model, phase: "sending", failReason: new Uint8Array(0) },
Cmd.fetch(
{
url: model.endpoint,
method: "POST",
headers: { authorization: bearerToken(model.apiKey), "content-type": "application/json" },
body: encodeChatRequest(model.modelName, SYSTEM_PROMPT, model.turns),
timeoutMs: 120000,
},
{ key: "chat", ok: "chat_response", err: "chat_failed" },
),
];
}
case "clear": {
if (model.phase === "sending" || model.turns.length === 0) return model;
return {
...model,
turns: [],
nextId: 1,
phase: "idle",
failReason: new Uint8Array(0),
chatScrollTop: 0,
};
}
case "chat_response": {
// The "chat" key carries exactly one live request and the sending
// guard blocks re-sends, so a response outside the sending phase
// can only be stale — drop it rather than corrupt the history.
if (model.phase !== "sending") return model;
if (msg.status === 200) {
const content = parseChatContent(msg.body);
if (content === null) {
// A 200 whose body is not a chat completion is a failed
// request, never a half-parsed conversation.
return {
...model,
phase: "failed",
failReason: asciiBytes("the response did not parse as a chat completion"),
};
}
return {
...model,
turns: [...model.turns, { id: model.nextId, role: "assistant", text: content }],
nextId: model.nextId + 1,
phase: "idle",
chatScrollTop: SCROLL_BOTTOM,
};
}
// Any other status is a delivered response whose meaning is "the
// endpoint said no": surface its own error.message when the body
// carries one, the bare status line when it does not.
const message = parseErrorMessage(msg.body);
return {
...model,
phase: "failed",
failReason: message ?? asciiBytes(`the endpoint answered HTTP ${msg.status}`),
};
}
case "chat_failed":
// The transport reason is machine-readable (`timed_out`,
// `connect_failed`, `truncated`, ...) — shown as-is, never silence.
return { ...model, phase: "failed", failReason: msg.reason };
case "chat_scrolled":
// The controlled-scroll echo: the applied offset lands in the
// model, so the next rebuild's `value` binding never fights the
// runtime.
return { ...model, chatScrollTop: msg.scroll.offset };
case "endpoint_set":
return { ...model, endpoint: msg.value };
case "model_set":
return { ...model, modelName: msg.value };
case "key_set":
return { ...model, apiKey: msg.value };
}
}
+54
View File
@@ -0,0 +1,54 @@
# Android Example
A minimal Android mobile shell that embeds a Native SDK static library through JNI. The example keeps a native Android header above a WebView workspace and routes native navigation/header actions through the Native SDK command path.
Android views own the mobile shell layout: native header sizing, density-aware resize events, touch forwarding, and the `WebView` workspace. The Native SDK runtime is driven through JNI calls into the C ABI.
## Build the native library
Build or package an Android static library from the repository root, then copy it into this example:
```bash
zig build lib -Dtarget=aarch64-linux-android
mkdir -p examples/android/app/src/main/cpp/lib
cp zig-out/lib/libnative-sdk.a examples/android/app/src/main/cpp/lib/libnative-sdk.a
```
The CMake project expects the library at `app/src/main/cpp/lib/libnative-sdk.a` and the C header at `app/src/main/cpp/native_sdk.h`.
## Run
Open `examples/android` in Android Studio, or build from the command line with a configured Android SDK:
```bash
./gradlew :app:assembleDebug
```
Install on an emulator or device:
```bash
./gradlew :app:installDebug
```
## Files
- `app/src/main/java/dev/native_sdk/examples/android/MainActivity.kt` hosts native Android chrome, a WebView workspace, a `SurfaceView`, and the JNI bridge.
- `app/src/main/cpp/native_sdk_jni.c` forwards JNI calls to the Native SDK C ABI.
- `app/src/main/cpp/CMakeLists.txt` imports `libnative-sdk.a` and builds the JNI shared library.
- `app.zon` records the mobile example metadata for Native SDK tooling.
## Host lifecycle
- `onCreate` loads the JNI library, creates the native shell, then starts the Native SDK app.
- `onResume` and `onPause` forward activation lifecycle with `native_sdk_app_activate` and `native_sdk_app_deactivate`.
- `surfaceChanged` forwards size, display density, safe-area insets, keyboard inset, and the Android `Surface`, then requests a frame.
- Orientation and screen-size changes stay in the same activity so the embedded runtime is not recreated during rotation.
- The activity uses `windowSoftInputMode="adjustResize"` so Android owns keyboard avoidance and relayouts the content area.
- The native Back and Refresh buttons call `nativeCommand` with stable mobile command IDs, update status from `native_sdk_app_last_command_count`, and request a frame.
- The Android system Back action dispatches `mobile.back` through the same command path.
- `onTouchEvent` forwards pointer id, phase, position, and pressure.
- The JNI bridge exposes hardware key, committed text, and IME composition entry points for GPU/widget text fields.
- The embedded C ABI can expose retained GPU/widget accessibility semantics by indexed snapshot and dispatch widget accessibility actions for Android accessibility providers.
- `surfaceDestroyed` and `onDestroy` stop and destroy the app.
The `app.zon` shell view tree describes this header and WebView workspace. Native mobile layout is still implemented in Kotlin so Android owns soft-keyboard relayout, Back handling, orientation changes, and activity lifecycle while the Native SDK receives the viewport metrics needed for GPU/widget layout.
+30
View File
@@ -0,0 +1,30 @@
.{
.id = "dev.native_sdk.android-example",
.name = "android-example",
.display_name = "Android Example",
.version = "0.1.0",
.platforms = .{ "android" },
.permissions = .{},
.capabilities = .{ "webview", "native_views", "native_module" },
.commands = .{
.{ .id = "mobile.back", .title = "Back" },
.{ .id = "mobile.refresh", .title = "Refresh" },
},
.shell = .{
.windows = .{
.{
.label = "main",
.title = "Mobile Shell",
.views = .{
.{ .label = "mobile-header", .kind = "toolbar", .edge = "top", .height = 104, .role = "toolbar" },
.{ .label = "mobile-title", .kind = "label", .parent = "mobile-header", .text = "Mobile Shell", .accessibility_label = "Mobile Shell" },
.{ .label = "mobile-status", .kind = "statusbar", .edge = "bottom", .height = 28, .text = "Native commands ready" },
.{ .label = "mobile-back", .kind = "button", .parent = "mobile-header", .text = "Back", .command = "mobile.back" },
.{ .label = "mobile-refresh", .kind = "button", .parent = "mobile-header", .text = "Refresh", .command = "mobile.refresh" },
.{ .label = "workspace", .kind = "webview", .url = "zero://app/index.html", .fill = true },
},
},
},
},
.web_engine = "system",
}
+29
View File
@@ -0,0 +1,29 @@
plugins {
id "com.android.application"
id "org.jetbrains.kotlin.android"
}
android {
namespace "dev.native_sdk.examples.android"
compileSdk 35
defaultConfig {
applicationId "dev.native_sdk.examples.android"
minSdk 26
targetSdk 35
versionCode 1
versionName "0.1.0"
externalNativeBuild {
cmake {
arguments "-DANDROID_STL=c++_shared"
}
}
}
externalNativeBuild {
cmake {
path "src/main/cpp/CMakeLists.txt"
}
}
}
@@ -0,0 +1,16 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="Android Example"
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,12 @@
cmake_minimum_required(VERSION 3.22.1)
project(native_sdk_android_example C)
add_library(native-sdk STATIC IMPORTED)
set_target_properties(native-sdk PROPERTIES
IMPORTED_LOCATION "${CMAKE_CURRENT_SOURCE_DIR}/lib/libnative-sdk.a"
)
add_library(native_sdk_example SHARED native_sdk_jni.c)
target_include_directories(native_sdk_example PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}")
target_link_libraries(native_sdk_example native-sdk android log)
@@ -0,0 +1,277 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
enum {
NATIVE_SDK_WIDGET_ROLE_NONE = 0,
NATIVE_SDK_WIDGET_ROLE_GROUP = 1,
NATIVE_SDK_WIDGET_ROLE_TEXT = 2,
NATIVE_SDK_WIDGET_ROLE_IMAGE = 3,
NATIVE_SDK_WIDGET_ROLE_BUTTON = 4,
NATIVE_SDK_WIDGET_ROLE_TEXTBOX = 5,
NATIVE_SDK_WIDGET_ROLE_TOOLTIP = 6,
NATIVE_SDK_WIDGET_ROLE_DIALOG = 7,
NATIVE_SDK_WIDGET_ROLE_MENU = 8,
NATIVE_SDK_WIDGET_ROLE_MENUITEM = 9,
NATIVE_SDK_WIDGET_ROLE_LIST = 10,
NATIVE_SDK_WIDGET_ROLE_LISTITEM = 11,
NATIVE_SDK_WIDGET_ROLE_ROW = 12,
NATIVE_SDK_WIDGET_ROLE_GRID = 13,
NATIVE_SDK_WIDGET_ROLE_GRIDCELL = 14,
NATIVE_SDK_WIDGET_ROLE_TAB = 15,
NATIVE_SDK_WIDGET_ROLE_CHECKBOX = 16,
NATIVE_SDK_WIDGET_ROLE_SWITCH = 17,
NATIVE_SDK_WIDGET_ROLE_SLIDER = 18,
NATIVE_SDK_WIDGET_ROLE_PROGRESSBAR = 19,
};
enum {
NATIVE_SDK_WIDGET_FLAG_FOCUSED = 1u << 0,
NATIVE_SDK_WIDGET_FLAG_HOVERED = 1u << 1,
NATIVE_SDK_WIDGET_FLAG_PRESSED = 1u << 2,
NATIVE_SDK_WIDGET_FLAG_SELECTED = 1u << 3,
NATIVE_SDK_WIDGET_FLAG_DISABLED = 1u << 4,
NATIVE_SDK_WIDGET_FLAG_FOCUSABLE = 1u << 5,
NATIVE_SDK_WIDGET_FLAG_EXPANDED = 1u << 6,
NATIVE_SDK_WIDGET_FLAG_COLLAPSED = 1u << 7,
NATIVE_SDK_WIDGET_FLAG_REQUIRED = 1u << 8,
NATIVE_SDK_WIDGET_FLAG_READ_ONLY = 1u << 9,
NATIVE_SDK_WIDGET_FLAG_INVALID = 1u << 10,
};
enum {
NATIVE_SDK_WIDGET_ACTION_FOCUS = 1u << 0,
NATIVE_SDK_WIDGET_ACTION_PRESS = 1u << 1,
NATIVE_SDK_WIDGET_ACTION_TOGGLE = 1u << 2,
NATIVE_SDK_WIDGET_ACTION_INCREMENT = 1u << 3,
NATIVE_SDK_WIDGET_ACTION_DECREMENT = 1u << 4,
NATIVE_SDK_WIDGET_ACTION_SET_TEXT = 1u << 5,
NATIVE_SDK_WIDGET_ACTION_SET_SELECTION = 1u << 6,
NATIVE_SDK_WIDGET_ACTION_SELECT = 1u << 7,
NATIVE_SDK_WIDGET_ACTION_DRAG = 1u << 8,
NATIVE_SDK_WIDGET_ACTION_DROP_FILES = 1u << 9,
NATIVE_SDK_WIDGET_ACTION_DISMISS = 1u << 10,
};
enum {
NATIVE_SDK_WIDGET_ACTION_KIND_FOCUS = 0,
NATIVE_SDK_WIDGET_ACTION_KIND_PRESS = 1,
NATIVE_SDK_WIDGET_ACTION_KIND_TOGGLE = 2,
NATIVE_SDK_WIDGET_ACTION_KIND_INCREMENT = 3,
NATIVE_SDK_WIDGET_ACTION_KIND_DECREMENT = 4,
NATIVE_SDK_WIDGET_ACTION_KIND_SET_TEXT = 5,
NATIVE_SDK_WIDGET_ACTION_KIND_SET_SELECTION = 6,
NATIVE_SDK_WIDGET_ACTION_KIND_SET_COMPOSITION = 7,
NATIVE_SDK_WIDGET_ACTION_KIND_COMMIT_COMPOSITION = 8,
NATIVE_SDK_WIDGET_ACTION_KIND_CANCEL_COMPOSITION = 9,
NATIVE_SDK_WIDGET_ACTION_KIND_SELECT = 10,
NATIVE_SDK_WIDGET_ACTION_KIND_DRAG = 11,
NATIVE_SDK_WIDGET_ACTION_KIND_DROP_FILES = 12,
NATIVE_SDK_WIDGET_ACTION_KIND_DISMISS = 13,
};
enum {
NATIVE_SDK_GPU_SURFACE_STATUS_UNAVAILABLE = 0,
NATIVE_SDK_GPU_SURFACE_STATUS_INITIALIZING = 1,
NATIVE_SDK_GPU_SURFACE_STATUS_READY = 2,
NATIVE_SDK_GPU_SURFACE_STATUS_LOST = 3,
};
typedef struct native_sdk_widget_semantics {
uint64_t id;
uint64_t parent_id;
int role;
uint32_t flags;
uint32_t actions;
float x;
float y;
float width;
float height;
float value;
int has_value;
const char *label;
uintptr_t label_len;
const char *text;
uintptr_t text_len;
const char *placeholder;
uintptr_t placeholder_len;
intptr_t text_selection_start;
intptr_t text_selection_end;
intptr_t text_composition_start;
intptr_t text_composition_end;
intptr_t grid_row_index;
intptr_t grid_column_index;
intptr_t grid_row_count;
intptr_t grid_column_count;
intptr_t list_item_index;
intptr_t list_item_count;
float scroll_offset;
float scroll_viewport_extent;
float scroll_content_extent;
int has_scroll;
} native_sdk_widget_semantics_t;
typedef struct native_sdk_widget_text_geometry {
uint64_t id;
int has_caret_bounds;
float caret_x;
float caret_y;
float caret_width;
float caret_height;
int has_selection_bounds;
float selection_x;
float selection_y;
float selection_width;
float selection_height;
uintptr_t selection_rect_count;
int has_composition_bounds;
float composition_x;
float composition_y;
float composition_width;
float composition_height;
uintptr_t composition_rect_count;
} native_sdk_widget_text_geometry_t;
typedef struct native_sdk_widget_action {
uint64_t id;
int action;
const char *text;
uintptr_t text_len;
uintptr_t selection_anchor;
uintptr_t selection_focus;
int has_selection;
} native_sdk_widget_action_t;
typedef struct native_sdk_canvas_pixels {
uintptr_t width;
uintptr_t height;
uintptr_t byte_len;
} native_sdk_canvas_pixels_t;
// Result of native_sdk_app_render_pixels_damage: the surface dimensions
// plus the damaged region the call wrote into the caller's RETAINED
// buffer, in device pixels. damage_width == 0 (or damage_height == 0)
// means nothing changed since the previous call: the buffer already
// shows the current frame and the host skips its upload entirely.
typedef struct native_sdk_canvas_pixels_damage {
uintptr_t width;
uintptr_t height;
uintptr_t byte_len;
uintptr_t damage_x;
uintptr_t damage_y;
uintptr_t damage_width;
uintptr_t damage_height;
// The retained-canvas revision the buffer now REFLECTS: gate re-renders
// on canvas_revision != this value (a change whose frame has not
// presented yet reports the OLD revision with empty damage - call again
// next tick), never on your own last sighting of canvas_revision.
uint64_t revision;
} native_sdk_canvas_pixels_damage_t;
typedef struct native_sdk_text_input_state {
int active;
uint64_t widget_id;
float x;
float y;
float width;
float height;
} native_sdk_text_input_state_t;
typedef struct native_sdk_viewport_state {
float width;
float height;
float scale;
int has_surface;
float safe_top;
float safe_right;
float safe_bottom;
float safe_left;
float keyboard_top;
float keyboard_right;
float keyboard_bottom;
float keyboard_left;
float content_x;
float content_y;
float content_width;
float content_height;
} native_sdk_viewport_state_t;
typedef struct native_sdk_gpu_frame_state {
uint64_t surface_id;
uint64_t window_id;
float width;
float height;
float scale;
uint64_t frame_index;
uint64_t timestamp_ns;
uint64_t frame_interval_ns;
uint64_t input_timestamp_ns;
uint64_t input_latency_ns;
uint64_t input_latency_budget_ns;
uintptr_t input_latency_budget_exceeded_count;
int input_latency_budget_ok;
uint64_t first_frame_latency_ns;
uint64_t first_frame_latency_budget_ns;
uintptr_t first_frame_latency_budget_exceeded_count;
int first_frame_latency_budget_ok;
int nonblank;
uint32_t sample_color;
int status;
int vsync;
uint64_t canvas_revision;
uintptr_t canvas_command_count;
int canvas_frame_requires_render;
int canvas_frame_full_repaint;
uintptr_t canvas_frame_batch_count;
uintptr_t canvas_frame_budget_exceeded_count;
int canvas_frame_budget_ok;
uint64_t widget_revision;
uintptr_t widget_node_count;
uintptr_t widget_semantics_count;
} native_sdk_gpu_frame_state_t;
void *native_sdk_app_create(void);
void native_sdk_app_destroy(void *app);
void native_sdk_app_start(void *app);
void native_sdk_app_activate(void *app);
void native_sdk_app_deactivate(void *app);
void native_sdk_app_stop(void *app);
void native_sdk_app_resize(void *app, float width, float height, float scale, void *surface);
void native_sdk_app_viewport(void *app, float width, float height, float scale, void *surface, float safe_top, float safe_right, float safe_bottom, float safe_left, float keyboard_top, float keyboard_right, float keyboard_bottom, float keyboard_left);
int native_sdk_app_viewport_state(void *app, native_sdk_viewport_state_t *out);
int native_sdk_app_gpu_frame_state(void *app, native_sdk_gpu_frame_state_t *out);
void native_sdk_app_touch(void *app, uint64_t id, int phase, float x, float y, float pressure);
void native_sdk_app_scroll(void *app, uint64_t id, float x, float y, float delta_x, float delta_y);
void native_sdk_app_key(void *app, int phase, const char *key, uintptr_t key_len, const char *text, uintptr_t text_len, uint32_t modifiers_mask);
void native_sdk_app_text(void *app, const char *text, uintptr_t len);
void native_sdk_app_ime(void *app, int kind, const char *text, uintptr_t len, intptr_t cursor);
void native_sdk_app_command(void *app, const char *name, uintptr_t len);
void native_sdk_app_frame(void *app);
void native_sdk_app_set_asset_root(void *app, const char *path, uintptr_t len);
void native_sdk_app_set_asset_entry(void *app, const char *path, uintptr_t len);
uintptr_t native_sdk_app_last_command_count(void *app);
const char *native_sdk_app_last_command_name(void *app);
const char *native_sdk_app_last_error_name(void *app);
uintptr_t native_sdk_app_widget_semantics_count(void *app);
int native_sdk_app_widget_semantics_at(void *app, uintptr_t index, native_sdk_widget_semantics_t *out);
int native_sdk_app_widget_semantics_by_id(void *app, uint64_t id, native_sdk_widget_semantics_t *out);
int native_sdk_app_widget_text_geometry(void *app, uint64_t id, native_sdk_widget_text_geometry_t *out);
int native_sdk_app_widget_action(void *app, const native_sdk_widget_action_t *action);
int native_sdk_app_text_input_state(void *app, native_sdk_text_input_state_t *out);
// Platform text measurement for layout: returns the typographic width of a
// single-line UTF-8 run at `size` for `font_id` (1 = sans, 2 = mono),
// measured with the same font resolution presentation draws with. Return a
// negative value to fall back to the deterministic estimator (e.g. invalid
// UTF-8). Register before native_sdk_app_start; pass NULL to fall back to
// the estimator on the next layout.
typedef double (*native_sdk_text_measure_fn)(void *context, uint64_t font_id, double size, const char *text, uintptr_t text_len);
int native_sdk_app_set_text_measure(void *app, native_sdk_text_measure_fn measure, void *context);
int native_sdk_app_set_automation_dir(void *app, const char *path, uintptr_t len);
int native_sdk_app_render_pixel_size(void *app, float scale, native_sdk_canvas_pixels_t *out);
int native_sdk_app_render_pixels(void *app, float scale, uint8_t *pixels, uintptr_t pixels_len, native_sdk_canvas_pixels_t *out);
// Incremental sibling of native_sdk_app_render_pixels for a host that
// keeps `pixels` RETAINED across calls (one buffer, one consumer): the
// fast path copies only the pixels changed since the previous call —
// captured off the runtime's own dirty-scissored raster, no second
// render — and reports that region; the first call (and any size or
// scale change) fills the whole buffer with full damage.
int native_sdk_app_render_pixels_damage(void *app, float scale, uint8_t *pixels, uintptr_t pixels_len, native_sdk_canvas_pixels_damage_t *out);
@@ -0,0 +1,345 @@
#include <jni.h>
#include <stdint.h>
#include <string.h>
#include "native_sdk.h"
static jbyteArray native_sdk_jni_bytes(JNIEnv *env, const char *ptr, uintptr_t len) {
jbyteArray out = (*env)->NewByteArray(env, (jsize)len);
if (!out) return NULL;
if (ptr && len > 0) {
(*env)->SetByteArrayRegion(env, out, 0, (jsize)len, (const jbyte *)ptr);
}
return out;
}
JNIEXPORT jlong JNICALL Java_dev_native_1sdk_examples_android_MainActivity_nativeCreate(JNIEnv *env, jobject self) {
(void)env;
(void)self;
return (jlong)native_sdk_app_create();
}
JNIEXPORT void JNICALL Java_dev_native_1sdk_examples_android_MainActivity_nativeDestroy(JNIEnv *env, jobject self, jlong app) {
(void)env;
(void)self;
native_sdk_app_destroy((void *)app);
}
JNIEXPORT void JNICALL Java_dev_native_1sdk_examples_android_MainActivity_nativeStart(JNIEnv *env, jobject self, jlong app) {
(void)env;
(void)self;
native_sdk_app_start((void *)app);
}
JNIEXPORT void JNICALL Java_dev_native_1sdk_examples_android_MainActivity_nativeActivate(JNIEnv *env, jobject self, jlong app) {
(void)env;
(void)self;
native_sdk_app_activate((void *)app);
}
JNIEXPORT void JNICALL Java_dev_native_1sdk_examples_android_MainActivity_nativeDeactivate(JNIEnv *env, jobject self, jlong app) {
(void)env;
(void)self;
native_sdk_app_deactivate((void *)app);
}
JNIEXPORT void JNICALL Java_dev_native_1sdk_examples_android_MainActivity_nativeStop(JNIEnv *env, jobject self, jlong app) {
(void)env;
(void)self;
native_sdk_app_stop((void *)app);
}
JNIEXPORT void JNICALL Java_dev_native_1sdk_examples_android_MainActivity_nativeResize(JNIEnv *env, jobject self, jlong app, jfloat width, jfloat height, jfloat scale, jobject surface) {
(void)env;
(void)self;
native_sdk_app_resize((void *)app, width, height, scale, surface);
}
JNIEXPORT void JNICALL Java_dev_native_1sdk_examples_android_MainActivity_nativeViewport(JNIEnv *env, jobject self, jlong app, jfloat width, jfloat height, jfloat scale, jobject surface, jfloat safe_top, jfloat safe_right, jfloat safe_bottom, jfloat safe_left, jfloat keyboard_top, jfloat keyboard_right, jfloat keyboard_bottom, jfloat keyboard_left) {
(void)env;
(void)self;
native_sdk_app_viewport((void *)app, width, height, scale, surface, safe_top, safe_right, safe_bottom, safe_left, keyboard_top, keyboard_right, keyboard_bottom, keyboard_left);
}
JNIEXPORT void JNICALL Java_dev_native_1sdk_examples_android_MainActivity_nativeTouch(JNIEnv *env, jobject self, jlong app, jlong id, jint phase, jfloat x, jfloat y, jfloat pressure) {
(void)env;
(void)self;
native_sdk_app_touch((void *)app, (uint64_t)id, phase, x, y, pressure);
}
JNIEXPORT void JNICALL Java_dev_native_1sdk_examples_android_MainActivity_nativeScroll(JNIEnv *env, jobject self, jlong app, jlong id, jfloat x, jfloat y, jfloat delta_x, jfloat delta_y) {
(void)env;
(void)self;
native_sdk_app_scroll((void *)app, (uint64_t)id, x, y, delta_x, delta_y);
}
JNIEXPORT void JNICALL Java_dev_native_1sdk_examples_android_MainActivity_nativeKey(JNIEnv *env, jobject self, jlong app, jint phase, jstring key, jstring text, jint modifiers) {
(void)self;
const char *key_chars = key ? (*env)->GetStringUTFChars(env, key, NULL) : NULL;
const char *text_chars = text ? (*env)->GetStringUTFChars(env, text, NULL) : NULL;
native_sdk_app_key((void *)app, phase, key_chars, key_chars ? strlen(key_chars) : 0, text_chars, text_chars ? strlen(text_chars) : 0, (uint32_t)modifiers);
if (key_chars) (*env)->ReleaseStringUTFChars(env, key, key_chars);
if (text_chars) (*env)->ReleaseStringUTFChars(env, text, text_chars);
}
JNIEXPORT void JNICALL Java_dev_native_1sdk_examples_android_MainActivity_nativeText(JNIEnv *env, jobject self, jlong app, jstring text) {
(void)self;
const char *text_chars = (*env)->GetStringUTFChars(env, text, NULL);
if (!text_chars) return;
native_sdk_app_text((void *)app, text_chars, strlen(text_chars));
(*env)->ReleaseStringUTFChars(env, text, text_chars);
}
JNIEXPORT void JNICALL Java_dev_native_1sdk_examples_android_MainActivity_nativeIme(JNIEnv *env, jobject self, jlong app, jint kind, jstring text, jlong cursor) {
(void)self;
const char *text_chars = text ? (*env)->GetStringUTFChars(env, text, NULL) : NULL;
native_sdk_app_ime((void *)app, kind, text_chars, text_chars ? strlen(text_chars) : 0, (intptr_t)cursor);
if (text_chars) (*env)->ReleaseStringUTFChars(env, text, text_chars);
}
JNIEXPORT jint JNICALL Java_dev_native_1sdk_examples_android_MainActivity_nativeCommand(JNIEnv *env, jobject self, jlong app, jstring command) {
(void)self;
const char *command_chars = (*env)->GetStringUTFChars(env, command, NULL);
if (!command_chars) return 0;
native_sdk_app_command((void *)app, command_chars, strlen(command_chars));
(*env)->ReleaseStringUTFChars(env, command, command_chars);
return (jint)native_sdk_app_last_command_count((void *)app);
}
JNIEXPORT void JNICALL Java_dev_native_1sdk_examples_android_MainActivity_nativeFrame(JNIEnv *env, jobject self, jlong app) {
(void)env;
(void)self;
native_sdk_app_frame((void *)app);
}
JNIEXPORT jboolean JNICALL Java_dev_native_1sdk_examples_android_MainActivity_nativeGpuFrameState(JNIEnv *env, jobject self, jlong app, jlongArray longs, jintArray ints, jfloatArray floats) {
(void)self;
if (!longs || !ints || !floats) return JNI_FALSE;
if ((*env)->GetArrayLength(env, longs) < 19 || (*env)->GetArrayLength(env, ints) < 9 || (*env)->GetArrayLength(env, floats) < 3) return JNI_FALSE;
native_sdk_gpu_frame_state_t state;
memset(&state, 0, sizeof(state));
if (!native_sdk_app_gpu_frame_state((void *)app, &state)) return JNI_FALSE;
const jlong long_values[19] = {
(jlong)state.surface_id,
(jlong)state.window_id,
(jlong)state.frame_index,
(jlong)state.timestamp_ns,
(jlong)state.frame_interval_ns,
(jlong)state.input_timestamp_ns,
(jlong)state.input_latency_ns,
(jlong)state.input_latency_budget_ns,
(jlong)state.input_latency_budget_exceeded_count,
(jlong)state.first_frame_latency_ns,
(jlong)state.first_frame_latency_budget_ns,
(jlong)state.first_frame_latency_budget_exceeded_count,
(jlong)state.canvas_revision,
(jlong)state.canvas_command_count,
(jlong)state.canvas_frame_batch_count,
(jlong)state.canvas_frame_budget_exceeded_count,
(jlong)state.widget_revision,
(jlong)state.widget_node_count,
(jlong)state.widget_semantics_count,
};
const jint int_values[9] = {
(jint)state.input_latency_budget_ok,
(jint)state.first_frame_latency_budget_ok,
(jint)state.nonblank,
(jint)state.sample_color,
(jint)state.status,
(jint)state.vsync,
(jint)state.canvas_frame_requires_render,
(jint)state.canvas_frame_full_repaint,
(jint)state.canvas_frame_budget_ok,
};
const jfloat float_values[3] = {
(jfloat)state.width,
(jfloat)state.height,
(jfloat)state.scale,
};
(*env)->SetLongArrayRegion(env, longs, 0, 19, long_values);
(*env)->SetIntArrayRegion(env, ints, 0, 9, int_values);
(*env)->SetFloatArrayRegion(env, floats, 0, 3, float_values);
return JNI_TRUE;
}
JNIEXPORT jint JNICALL Java_dev_native_1sdk_examples_android_MainActivity_nativeWidgetSemanticsCount(JNIEnv *env, jobject self, jlong app) {
(void)env;
(void)self;
return (jint)native_sdk_app_widget_semantics_count((void *)app);
}
JNIEXPORT jboolean JNICALL Java_dev_native_1sdk_examples_android_MainActivity_nativeWidgetSemanticsFields(JNIEnv *env, jobject self, jlong app, jint index, jlongArray ids, jintArray ints, jfloatArray floats) {
(void)self;
if (!ids || !ints || !floats) return JNI_FALSE;
if ((*env)->GetArrayLength(env, ids) < 12 || (*env)->GetArrayLength(env, ints) < 5 || (*env)->GetArrayLength(env, floats) < 8) return JNI_FALSE;
native_sdk_widget_semantics_t node;
memset(&node, 0, sizeof(node));
if (!native_sdk_app_widget_semantics_at((void *)app, (uintptr_t)index, &node)) return JNI_FALSE;
const jlong id_values[12] = {
(jlong)node.id,
(jlong)node.parent_id,
(jlong)node.text_selection_start,
(jlong)node.text_selection_end,
(jlong)node.text_composition_start,
(jlong)node.text_composition_end,
(jlong)node.grid_row_index,
(jlong)node.grid_column_index,
(jlong)node.grid_row_count,
(jlong)node.grid_column_count,
(jlong)node.list_item_index,
(jlong)node.list_item_count,
};
const jint int_values[5] = {
(jint)node.role,
(jint)node.flags,
(jint)node.actions,
(jint)node.has_value,
(jint)node.has_scroll,
};
const jfloat float_values[8] = {
(jfloat)node.x,
(jfloat)node.y,
(jfloat)node.width,
(jfloat)node.height,
(jfloat)node.value,
(jfloat)node.scroll_offset,
(jfloat)node.scroll_viewport_extent,
(jfloat)node.scroll_content_extent,
};
(*env)->SetLongArrayRegion(env, ids, 0, 12, id_values);
(*env)->SetIntArrayRegion(env, ints, 0, 5, int_values);
(*env)->SetFloatArrayRegion(env, floats, 0, 8, float_values);
return JNI_TRUE;
}
JNIEXPORT jbyteArray JNICALL Java_dev_native_1sdk_examples_android_MainActivity_nativeWidgetSemanticsLabel(JNIEnv *env, jobject self, jlong app, jint index) {
(void)self;
native_sdk_widget_semantics_t node;
memset(&node, 0, sizeof(node));
if (!native_sdk_app_widget_semantics_at((void *)app, (uintptr_t)index, &node)) return native_sdk_jni_bytes(env, "", 0);
return native_sdk_jni_bytes(env, node.label, node.label_len);
}
JNIEXPORT jbyteArray JNICALL Java_dev_native_1sdk_examples_android_MainActivity_nativeWidgetSemanticsText(JNIEnv *env, jobject self, jlong app, jint index) {
(void)self;
native_sdk_widget_semantics_t node;
memset(&node, 0, sizeof(node));
if (!native_sdk_app_widget_semantics_at((void *)app, (uintptr_t)index, &node)) return native_sdk_jni_bytes(env, "", 0);
return native_sdk_jni_bytes(env, node.text, node.text_len);
}
JNIEXPORT jboolean JNICALL Java_dev_native_1sdk_examples_android_MainActivity_nativeWidgetSemanticsByIdFields(JNIEnv *env, jobject self, jlong app, jlong id, jlongArray ids, jintArray ints, jfloatArray floats) {
(void)self;
if (!ids || !ints || !floats) return JNI_FALSE;
if ((*env)->GetArrayLength(env, ids) < 12 || (*env)->GetArrayLength(env, ints) < 5 || (*env)->GetArrayLength(env, floats) < 8) return JNI_FALSE;
native_sdk_widget_semantics_t node;
memset(&node, 0, sizeof(node));
if (!native_sdk_app_widget_semantics_by_id((void *)app, (uint64_t)id, &node)) return JNI_FALSE;
const jlong id_values[12] = {
(jlong)node.id,
(jlong)node.parent_id,
(jlong)node.text_selection_start,
(jlong)node.text_selection_end,
(jlong)node.text_composition_start,
(jlong)node.text_composition_end,
(jlong)node.grid_row_index,
(jlong)node.grid_column_index,
(jlong)node.grid_row_count,
(jlong)node.grid_column_count,
(jlong)node.list_item_index,
(jlong)node.list_item_count,
};
const jint int_values[5] = {
(jint)node.role,
(jint)node.flags,
(jint)node.actions,
(jint)node.has_value,
(jint)node.has_scroll,
};
const jfloat float_values[8] = {
(jfloat)node.x,
(jfloat)node.y,
(jfloat)node.width,
(jfloat)node.height,
(jfloat)node.value,
(jfloat)node.scroll_offset,
(jfloat)node.scroll_viewport_extent,
(jfloat)node.scroll_content_extent,
};
(*env)->SetLongArrayRegion(env, ids, 0, 12, id_values);
(*env)->SetIntArrayRegion(env, ints, 0, 5, int_values);
(*env)->SetFloatArrayRegion(env, floats, 0, 8, float_values);
return JNI_TRUE;
}
JNIEXPORT jbyteArray JNICALL Java_dev_native_1sdk_examples_android_MainActivity_nativeWidgetSemanticsByIdLabel(JNIEnv *env, jobject self, jlong app, jlong id) {
(void)self;
native_sdk_widget_semantics_t node;
memset(&node, 0, sizeof(node));
if (!native_sdk_app_widget_semantics_by_id((void *)app, (uint64_t)id, &node)) return native_sdk_jni_bytes(env, "", 0);
return native_sdk_jni_bytes(env, node.label, node.label_len);
}
JNIEXPORT jbyteArray JNICALL Java_dev_native_1sdk_examples_android_MainActivity_nativeWidgetSemanticsByIdText(JNIEnv *env, jobject self, jlong app, jlong id) {
(void)self;
native_sdk_widget_semantics_t node;
memset(&node, 0, sizeof(node));
if (!native_sdk_app_widget_semantics_by_id((void *)app, (uint64_t)id, &node)) return native_sdk_jni_bytes(env, "", 0);
return native_sdk_jni_bytes(env, node.text, node.text_len);
}
JNIEXPORT jboolean JNICALL Java_dev_native_1sdk_examples_android_MainActivity_nativeWidgetTextGeometry(JNIEnv *env, jobject self, jlong app, jlong id, jintArray ints, jfloatArray floats) {
(void)self;
if (!ints || !floats) return JNI_FALSE;
if ((*env)->GetArrayLength(env, ints) < 5 || (*env)->GetArrayLength(env, floats) < 12) return JNI_FALSE;
native_sdk_widget_text_geometry_t geometry;
memset(&geometry, 0, sizeof(geometry));
if (!native_sdk_app_widget_text_geometry((void *)app, (uint64_t)id, &geometry)) return JNI_FALSE;
const jint int_values[5] = {
(jint)geometry.has_caret_bounds,
(jint)geometry.has_selection_bounds,
(jint)geometry.selection_rect_count,
(jint)geometry.has_composition_bounds,
(jint)geometry.composition_rect_count,
};
const jfloat float_values[12] = {
(jfloat)geometry.caret_x,
(jfloat)geometry.caret_y,
(jfloat)geometry.caret_width,
(jfloat)geometry.caret_height,
(jfloat)geometry.selection_x,
(jfloat)geometry.selection_y,
(jfloat)geometry.selection_width,
(jfloat)geometry.selection_height,
(jfloat)geometry.composition_x,
(jfloat)geometry.composition_y,
(jfloat)geometry.composition_width,
(jfloat)geometry.composition_height,
};
(*env)->SetIntArrayRegion(env, ints, 0, 5, int_values);
(*env)->SetFloatArrayRegion(env, floats, 0, 12, float_values);
return JNI_TRUE;
}
JNIEXPORT jboolean JNICALL Java_dev_native_1sdk_examples_android_MainActivity_nativeWidgetAction(JNIEnv *env, jobject self, jlong app, jlong id, jint action, jstring text, jlong selection_anchor, jlong selection_focus, jboolean has_selection) {
(void)self;
const char *text_chars = text ? (*env)->GetStringUTFChars(env, text, NULL) : NULL;
native_sdk_widget_action_t request;
memset(&request, 0, sizeof(request));
request.id = (uint64_t)id;
request.action = (int)action;
request.text = text_chars;
request.text_len = text_chars ? strlen(text_chars) : 0;
request.selection_anchor = (uintptr_t)selection_anchor;
request.selection_focus = (uintptr_t)selection_focus;
request.has_selection = has_selection ? 1 : 0;
const int ok = native_sdk_app_widget_action((void *)app, &request);
if (text_chars) (*env)->ReleaseStringUTFChars(env, text, text_chars);
return ok ? JNI_TRUE : JNI_FALSE;
}
@@ -0,0 +1,840 @@
package dev.native_sdk.examples.android
import android.app.Activity
import android.content.Context
import android.content.res.Configuration
import android.graphics.Color
import android.graphics.Rect
import android.os.Build
import android.os.Bundle
import android.text.InputType
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.View
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import android.view.accessibility.AccessibilityNodeProvider
import android.view.inputmethod.BaseInputConnection
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import android.view.inputmethod.InputMethodManager
import android.webkit.WebView
import android.widget.Button
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
class MainActivity : Activity(), SurfaceHolder.Callback {
private var nativeApp: Long = 0
private lateinit var statusLabel: TextView
private lateinit var widgetSurface: WidgetSurfaceView
private var currentSurfaceHolder: SurfaceHolder? = null
private var lastTouchX: Float = 0f
private var lastTouchY: Float = 0f
private var lastTouchActive: Boolean = false
data class WidgetSemantics(
val id: Long,
val parentId: Long,
val role: Int,
val flags: Int,
val actions: Int,
val x: Float,
val y: Float,
val width: Float,
val height: Float,
val value: Float?,
val label: String,
val text: String,
val textSelectionStart: Long,
val textSelectionEnd: Long,
val textCompositionStart: Long,
val textCompositionEnd: Long,
val gridRowIndex: Long,
val gridColumnIndex: Long,
val gridRowCount: Long,
val gridColumnCount: Long,
val listItemIndex: Long,
val listItemCount: Long,
val scrollOffset: Float,
val scrollViewportExtent: Float,
val scrollContentExtent: Float,
val hasScroll: Boolean,
)
data class WidgetTextGeometry(
val id: Long,
val hasCaretBounds: Boolean,
val caretX: Float,
val caretY: Float,
val caretWidth: Float,
val caretHeight: Float,
val hasSelectionBounds: Boolean,
val selectionX: Float,
val selectionY: Float,
val selectionWidth: Float,
val selectionHeight: Float,
val selectionRectCount: Int,
val hasCompositionBounds: Boolean,
val compositionX: Float,
val compositionY: Float,
val compositionWidth: Float,
val compositionHeight: Float,
val compositionRectCount: Int,
)
private inner class WidgetSurfaceView : SurfaceView(this@MainActivity) {
private val provider = WidgetAccessibilityProvider(this)
init {
importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_YES
isFocusable = true
}
override fun getAccessibilityNodeProvider(): AccessibilityNodeProvider = provider
override fun onCheckIsTextEditor(): Boolean = true
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection {
outAttrs.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
outAttrs.imeOptions = EditorInfo.IME_ACTION_DONE
return object : BaseInputConnection(this, true) {
override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean {
return dispatchCommittedWidgetText(text?.toString().orEmpty())
}
override fun setComposingText(text: CharSequence?, newCursorPosition: Int): Boolean {
return dispatchWidgetIme(WIDGET_IME_SET_COMPOSITION, text?.toString().orEmpty(), newCursorPosition.toLong())
}
override fun finishComposingText(): Boolean {
return dispatchWidgetIme(WIDGET_IME_COMMIT_COMPOSITION, "", 0)
}
override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
if (beforeLength > 0 && afterLength == 0) return dispatchWidgetKey("backspace")
if (afterLength > 0 && beforeLength == 0) return dispatchWidgetKey("delete")
return super.deleteSurroundingText(beforeLength, afterLength)
}
override fun sendKeyEvent(event: KeyEvent): Boolean {
if (event.action != KeyEvent.ACTION_DOWN) return super.sendKeyEvent(event)
return when (event.keyCode) {
KeyEvent.KEYCODE_DEL -> dispatchWidgetKey("backspace")
KeyEvent.KEYCODE_FORWARD_DEL -> dispatchWidgetKey("delete")
else -> super.sendKeyEvent(event)
}
}
}
}
override fun onTouchEvent(event: MotionEvent): Boolean {
return handleWidgetTouch(event)
}
fun notifyWidgetSemanticsChanged() {
invalidate()
provider.notifyWidgetSemanticsChanged()
}
}
private inner class WidgetAccessibilityProvider(private val host: View) : AccessibilityNodeProvider() {
private var accessibilityFocusedId: Long = 0
override fun createAccessibilityNodeInfo(virtualViewId: Int): AccessibilityNodeInfo? {
val nodes = widgetSemanticsSnapshot()
return if (virtualViewId == View.NO_ID) {
createHostNode(nodes)
} else {
(widgetSemanticsById(virtualViewId.toLong()) ?: nodes.firstOrNull { it.id.toInt() == virtualViewId })?.let { createWidgetNode(it, nodes) }
}
}
override fun performAction(virtualViewId: Int, action: Int, arguments: Bundle?): Boolean {
if (virtualViewId == View.NO_ID) return false
val node = widgetSemanticsById(virtualViewId.toLong()) ?: return false
val id = node.id
val handled = when (action) {
AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS -> {
accessibilityFocusedId = id
host.invalidate()
sendVirtualEvent(id, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED)
true
}
AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS -> {
if (accessibilityFocusedId == id) accessibilityFocusedId = 0
host.invalidate()
sendVirtualEvent(id, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED)
true
}
AccessibilityNodeInfo.ACTION_FOCUS -> {
if (widgetSupportsAction(node, WIDGET_ACTION_FOCUS)) dispatchWidgetAction(id, WIDGET_ACTION_KIND_FOCUS) else false
}
AccessibilityNodeInfo.ACTION_CLICK -> performWidgetClick(id)
AccessibilityNodeInfo.ACTION_SELECT -> {
if (widgetSupportsAction(node, WIDGET_ACTION_SELECT)) dispatchWidgetAction(id, WIDGET_ACTION_KIND_SELECT) else false
}
AccessibilityNodeInfo.ACTION_SCROLL_FORWARD -> {
if (widgetSupportsAction(node, WIDGET_ACTION_INCREMENT)) dispatchWidgetAction(id, WIDGET_ACTION_KIND_INCREMENT) else false
}
AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD -> {
if (widgetSupportsAction(node, WIDGET_ACTION_DECREMENT)) dispatchWidgetAction(id, WIDGET_ACTION_KIND_DECREMENT) else false
}
AccessibilityNodeInfo.ACTION_DISMISS -> {
if (widgetSupportsAction(node, WIDGET_ACTION_DISMISS)) dispatchWidgetAction(id, WIDGET_ACTION_KIND_DISMISS) else false
}
AccessibilityNodeInfo.ACTION_SET_TEXT -> {
val text = arguments?.getCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE)?.toString()
if (text != null && widgetSupportsAction(node, WIDGET_ACTION_SET_TEXT)) dispatchWidgetAction(id, WIDGET_ACTION_KIND_SET_TEXT, text) else false
}
AccessibilityNodeInfo.ACTION_SET_SELECTION -> {
val start = arguments?.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT, -1) ?: -1
val end = arguments?.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT, -1) ?: -1
if (start >= 0 && end >= 0 && widgetSupportsAction(node, WIDGET_ACTION_SET_SELECTION)) {
dispatchWidgetAction(id, WIDGET_ACTION_KIND_SET_SELECTION, selectionAnchor = start.toLong(), selectionFocus = end.toLong(), hasSelection = true)
} else {
false
}
}
else -> false
}
if (handled) host.invalidate()
if (handled) updateSoftKeyboardForFocusedWidget()
return handled
}
fun notifyWidgetSemanticsChanged() {
val event = AccessibilityEvent.obtain(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED)
event.setSource(host)
event.packageName = packageName
event.contentChangeTypes = AccessibilityEvent.CONTENT_CHANGE_TYPE_SUBTREE
host.parent?.requestSendAccessibilityEvent(host, event)
}
private fun createHostNode(nodes: List<WidgetSemantics>): AccessibilityNodeInfo {
val info = AccessibilityNodeInfo.obtain(host)
host.onInitializeAccessibilityNodeInfo(info)
info.className = SurfaceView::class.java.name
for (node in nodes.filter { it.parentId == 0L }) {
info.addChild(host, node.id.toInt())
}
return info
}
private fun createWidgetNode(node: WidgetSemantics, nodes: List<WidgetSemantics>): AccessibilityNodeInfo {
val info = AccessibilityNodeInfo.obtain()
val virtualId = node.id.toInt()
val parentNode = nodes.firstOrNull { it.id == node.parentId }
info.setSource(host, virtualId)
if (parentNode != null) {
info.setParent(host, parentNode.id.toInt())
} else {
info.setParent(host)
}
for (child in nodes.filter { it.parentId == node.id }) {
info.addChild(host, child.id.toInt())
}
info.packageName = packageName
info.className = widgetAccessibilityClassName(node)
info.contentDescription = node.label.ifEmpty { node.text }
if (node.text.isNotEmpty()) info.text = node.text
info.isVisibleToUser = host.isShown
info.isEnabled = (node.flags and WIDGET_FLAG_DISABLED) == 0
info.isFocusable = (node.flags and WIDGET_FLAG_FOCUSABLE) != 0
info.isFocused = (node.flags and WIDGET_FLAG_FOCUSED) != 0
info.isAccessibilityFocused = accessibilityFocusedId == node.id
info.isSelected = (node.flags and WIDGET_FLAG_SELECTED) != 0
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
info.stateDescription = widgetStateDescription(node)
}
info.isCheckable = node.role == WIDGET_ROLE_CHECKBOX || node.role == WIDGET_ROLE_SWITCH
info.isChecked = info.isCheckable && widgetValueSelected(node)
info.isClickable = widgetSupportsAnyAction(node, WIDGET_ACTION_PRESS or WIDGET_ACTION_TOGGLE or WIDGET_ACTION_SELECT)
info.isEditable = node.role == WIDGET_ROLE_TEXTBOX && (node.flags and WIDGET_FLAG_READ_ONLY) == 0
info.isScrollable = node.hasScroll
if (node.value != null) {
info.setRangeInfo(AccessibilityNodeInfo.RangeInfo.obtain(AccessibilityNodeInfo.RangeInfo.RANGE_TYPE_FLOAT, 0f, 1f, node.value))
}
setCollectionInfo(info, node, nodes)
setCollectionItemInfo(info, node)
if (node.textSelectionStart >= 0 && node.textSelectionEnd >= 0) {
info.setTextSelection(node.textSelectionStart.toInt(), node.textSelectionEnd.toInt())
}
if (accessibilityFocusedId == node.id) {
info.addAction(AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS)
} else {
info.addAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS)
}
if (info.isFocusable) info.addAction(AccessibilityNodeInfo.ACTION_FOCUS)
if (info.isClickable) info.addAction(AccessibilityNodeInfo.ACTION_CLICK)
if (widgetSupportsAction(node, WIDGET_ACTION_SELECT)) info.addAction(AccessibilityNodeInfo.ACTION_SELECT)
if (widgetSupportsAction(node, WIDGET_ACTION_INCREMENT)) info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD)
if (widgetSupportsAction(node, WIDGET_ACTION_DECREMENT)) info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD)
if (widgetSupportsAction(node, WIDGET_ACTION_DISMISS)) info.addAction(AccessibilityNodeInfo.ACTION_DISMISS)
if (widgetSupportsAction(node, WIDGET_ACTION_SET_TEXT)) info.addAction(AccessibilityNodeInfo.ACTION_SET_TEXT)
if (widgetSupportsAction(node, WIDGET_ACTION_SET_SELECTION)) info.addAction(AccessibilityNodeInfo.ACTION_SET_SELECTION)
info.setBoundsInParent(boundsInParent(node, parentNode))
val location = IntArray(2)
host.getLocationOnScreen(location)
val screenBounds = Rect(node.x.toInt(), node.y.toInt(), (node.x + node.width).toInt(), (node.y + node.height).toInt())
info.setBoundsInScreen(Rect(screenBounds.left + location[0], screenBounds.top + location[1], screenBounds.right + location[0], screenBounds.bottom + location[1]))
return info
}
private fun performWidgetClick(id: Long): Boolean {
val node = widgetSemanticsById(id) ?: return false
return when {
widgetSupportsAction(node, WIDGET_ACTION_TOGGLE) -> dispatchWidgetAction(id, WIDGET_ACTION_KIND_TOGGLE)
widgetSupportsAction(node, WIDGET_ACTION_PRESS) -> dispatchWidgetAction(id, WIDGET_ACTION_KIND_PRESS)
widgetSupportsAction(node, WIDGET_ACTION_SELECT) -> dispatchWidgetAction(id, WIDGET_ACTION_KIND_SELECT)
else -> false
}
}
private fun setCollectionInfo(info: AccessibilityNodeInfo, node: WidgetSemantics, nodes: List<WidgetSemantics>) {
if (node.role == WIDGET_ROLE_GRID && node.gridRowCount >= 0 && node.gridColumnCount >= 0) {
info.setCollectionInfo(AccessibilityNodeInfo.CollectionInfo.obtain(node.gridRowCount.toInt(), node.gridColumnCount.toInt(), false))
} else if (node.role == WIDGET_ROLE_LIST) {
val childCount = nodes.count { it.parentId == node.id && it.role == WIDGET_ROLE_LISTITEM }
val itemCount = if (node.listItemCount >= 0) node.listItemCount.toInt() else childCount
if (itemCount > 0) info.setCollectionInfo(AccessibilityNodeInfo.CollectionInfo.obtain(itemCount, 1, false))
}
}
private fun setCollectionItemInfo(info: AccessibilityNodeInfo, node: WidgetSemantics) {
if (node.gridRowIndex >= 0 && node.gridColumnIndex >= 0) {
info.setCollectionItemInfo(AccessibilityNodeInfo.CollectionItemInfo.obtain(node.gridRowIndex.toInt(), 1, node.gridColumnIndex.toInt(), 1, false, info.isSelected))
} else if (node.listItemIndex >= 0) {
info.setCollectionItemInfo(AccessibilityNodeInfo.CollectionItemInfo.obtain(node.listItemIndex.toInt(), 1, 0, 1, false, info.isSelected))
}
}
private fun boundsInParent(node: WidgetSemantics, parent: WidgetSemantics?): Rect {
val parentX = parent?.x ?: 0f
val parentY = parent?.y ?: 0f
return Rect(
(node.x - parentX).toInt(),
(node.y - parentY).toInt(),
(node.x - parentX + node.width).toInt(),
(node.y - parentY + node.height).toInt(),
)
}
private fun widgetValueSelected(node: WidgetSemantics): Boolean {
return node.value != null && node.value >= 0.5f
}
private fun widgetStateDescription(node: WidgetSemantics): String? {
val states = ArrayList<String>()
if ((node.flags and WIDGET_FLAG_EXPANDED) != 0) states.add("Expanded")
if ((node.flags and WIDGET_FLAG_COLLAPSED) != 0) states.add("Collapsed")
if ((node.flags and WIDGET_FLAG_REQUIRED) != 0) states.add("Required")
if ((node.flags and WIDGET_FLAG_READ_ONLY) != 0) states.add("Read only")
if ((node.flags and WIDGET_FLAG_INVALID) != 0) states.add("Invalid")
return if (states.isEmpty()) null else states.joinToString(", ")
}
private fun sendVirtualEvent(id: Long, type: Int) {
val event = AccessibilityEvent.obtain(type)
event.setSource(host, id.toInt())
event.packageName = packageName
host.parent?.requestSendAccessibilityEvent(host, event)
}
private fun widgetAccessibilityClassName(node: WidgetSemantics): String {
return when (node.role) {
WIDGET_ROLE_BUTTON, WIDGET_ROLE_MENUITEM -> "android.widget.Button"
WIDGET_ROLE_TEXTBOX -> "android.widget.EditText"
WIDGET_ROLE_CHECKBOX -> "android.widget.CheckBox"
WIDGET_ROLE_SWITCH -> "android.widget.Switch"
WIDGET_ROLE_SLIDER -> "android.widget.SeekBar"
WIDGET_ROLE_PROGRESSBAR -> "android.widget.ProgressBar"
WIDGET_ROLE_IMAGE -> "android.widget.ImageView"
WIDGET_ROLE_LIST -> "android.widget.ListView"
else -> "android.view.View"
}
}
private fun widgetSupportsAction(node: WidgetSemantics, action: Int): Boolean {
return (node.actions and action) != 0
}
private fun widgetSupportsAnyAction(node: WidgetSemantics, actions: Int): Boolean {
return (node.actions and actions) != 0
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
System.loadLibrary("native_sdk_example")
widgetSurface = WidgetSurfaceView()
widgetSurface.holder.addCallback(this)
val header = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(Color.rgb(245, 246, 248))
setPadding(32, 28, 32, 24)
}
val title = TextView(this).apply {
text = "Mobile Shell"
textSize = 24f
setTextColor(Color.rgb(24, 24, 27))
}
val subtitle = TextView(this).apply {
text = "Native header with WebView workspace"
textSize = 14f
setTextColor(Color.rgb(95, 102, 114))
setPadding(0, 6, 0, 0)
}
statusLabel = TextView(this).apply {
text = "Native commands ready"
textSize = 13f
setTextColor(Color.rgb(95, 102, 114))
setPadding(0, 12, 0, 0)
}
val actions = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
setPadding(0, 12, 0, 0)
}
val back = Button(this).apply {
text = "Back"
setOnClickListener {
dispatchNativeCommand("mobile.back")
}
}
val refresh = Button(this).apply {
text = "Refresh"
setOnClickListener {
dispatchNativeCommand("mobile.refresh")
}
}
actions.addView(back, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
actions.addView(refresh, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
header.addView(title)
header.addView(subtitle)
header.addView(statusLabel)
header.addView(actions)
val webView = WebView(this).apply {
settings.javaScriptEnabled = false
loadDataWithBaseURL(null, html, "text/html", "UTF-8", null)
}
val content = FrameLayout(this)
content.addView(widgetSurface, FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
))
content.addView(webView, FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
))
val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(Color.WHITE)
}
root.addView(header, LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
))
root.addView(content, LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
0,
1f,
))
setContentView(root)
nativeApp = nativeCreate()
nativeStart(nativeApp)
refreshWidgetSemanticsStatus()
}
private fun dispatchNativeCommand(command: String) {
if (nativeApp == 0L) return
val count = nativeCommand(nativeApp, command)
if (::statusLabel.isInitialized) {
statusLabel.text = "Command $count: $command"
}
nativeFrame(nativeApp)
refreshWidgetSemanticsStatus()
}
override fun onResume() {
super.onResume()
if (nativeApp != 0L) {
nativeActivate(nativeApp)
}
}
override fun onPause() {
if (nativeApp != 0L) {
nativeDeactivate(nativeApp)
}
super.onPause()
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
currentSurfaceHolder = holder
sendViewport(width, height, holder.surface)
nativeFrame(nativeApp)
refreshWidgetSemanticsStatus()
}
override fun surfaceCreated(holder: SurfaceHolder) = Unit
override fun surfaceDestroyed(holder: SurfaceHolder) {
if (currentSurfaceHolder == holder) currentSurfaceHolder = null
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
if (nativeApp != 0L) {
nativeFrame(nativeApp)
refreshWidgetSemanticsStatus()
}
}
private fun widgetSemanticsSnapshot(): List<WidgetSemantics> {
if (nativeApp == 0L) return emptyList()
val count = nativeWidgetSemanticsCount(nativeApp)
val items = mutableListOf<WidgetSemantics>()
for (index in 0 until count) {
widgetSemanticsAt(index)?.let { items.add(it) }
}
return items
}
private fun widgetSemanticsAt(index: Int): WidgetSemantics? {
val ids = LongArray(12)
val ints = IntArray(5)
val floats = FloatArray(8)
if (!nativeWidgetSemanticsFields(nativeApp, index, ids, ints, floats)) return null
return widgetSemanticsFromNative(
ids,
ints,
floats,
String(nativeWidgetSemanticsLabel(nativeApp, index), Charsets.UTF_8),
String(nativeWidgetSemanticsText(nativeApp, index), Charsets.UTF_8),
)
}
private fun widgetSemanticsById(id: Long): WidgetSemantics? {
val ids = LongArray(12)
val ints = IntArray(5)
val floats = FloatArray(8)
if (!nativeWidgetSemanticsByIdFields(nativeApp, id, ids, ints, floats)) return null
return widgetSemanticsFromNative(
ids,
ints,
floats,
String(nativeWidgetSemanticsByIdLabel(nativeApp, id), Charsets.UTF_8),
String(nativeWidgetSemanticsByIdText(nativeApp, id), Charsets.UTF_8),
)
}
private fun widgetSemanticsFromNative(ids: LongArray, ints: IntArray, floats: FloatArray, label: String, text: String): WidgetSemantics {
return WidgetSemantics(
id = ids[0],
parentId = ids[1],
role = ints[0],
flags = ints[1],
actions = ints[2],
x = floats[0],
y = floats[1],
width = floats[2],
height = floats[3],
value = if (ints[3] != 0) floats[4] else null,
label = label,
text = text,
textSelectionStart = ids[2],
textSelectionEnd = ids[3],
textCompositionStart = ids[4],
textCompositionEnd = ids[5],
gridRowIndex = ids[6],
gridColumnIndex = ids[7],
gridRowCount = ids[8],
gridColumnCount = ids[9],
listItemIndex = ids[10],
listItemCount = ids[11],
scrollOffset = floats[5],
scrollViewportExtent = floats[6],
scrollContentExtent = floats[7],
hasScroll = ints[4] != 0,
)
}
private fun widgetTextGeometry(id: Long): WidgetTextGeometry? {
val ints = IntArray(5)
val floats = FloatArray(12)
if (!nativeWidgetTextGeometry(nativeApp, id, ints, floats)) return null
return WidgetTextGeometry(
id = id,
hasCaretBounds = ints[0] != 0,
caretX = floats[0],
caretY = floats[1],
caretWidth = floats[2],
caretHeight = floats[3],
hasSelectionBounds = ints[1] != 0,
selectionX = floats[4],
selectionY = floats[5],
selectionWidth = floats[6],
selectionHeight = floats[7],
selectionRectCount = ints[2],
hasCompositionBounds = ints[3] != 0,
compositionX = floats[8],
compositionY = floats[9],
compositionWidth = floats[10],
compositionHeight = floats[11],
compositionRectCount = ints[4],
)
}
private fun dispatchWidgetAction(
id: Long,
action: Int,
text: String? = null,
selectionAnchor: Long = 0,
selectionFocus: Long = 0,
hasSelection: Boolean = false,
): Boolean {
if (nativeApp == 0L) return false
val ok = nativeWidgetAction(nativeApp, id, action, text, selectionAnchor, selectionFocus, hasSelection)
if (ok) {
nativeFrame(nativeApp)
refreshWidgetSemanticsStatus()
}
return ok
}
private fun refreshWidgetSemanticsStatus() {
if (nativeApp == 0L || !::statusLabel.isInitialized) return
statusLabel.contentDescription = "Accessible items: ${widgetSemanticsSnapshot().size}"
if (::widgetSurface.isInitialized) widgetSurface.notifyWidgetSemanticsChanged()
}
private fun sendViewport(width: Int, height: Int, surface: Any) {
if (nativeApp == 0L) return
val density = resources.displayMetrics.density
val insets = window.decorView.rootWindowInsets
val safeTop = ((insets?.systemWindowInsetTop ?: 0).toFloat()) / density
val safeRight = ((insets?.systemWindowInsetRight ?: 0).toFloat()) / density
val safeBottom = ((insets?.systemWindowInsetBottom ?: 0).toFloat()) / density
val safeLeft = ((insets?.systemWindowInsetLeft ?: 0).toFloat()) / density
nativeViewport(
nativeApp,
width.toFloat(),
height.toFloat(),
density,
surface,
safeTop,
safeRight,
safeBottom,
safeLeft,
0f,
0f,
keyboardBottomInset(density),
0f,
)
}
private fun keyboardBottomInset(density: Float): Float {
val visibleFrame = Rect()
window.decorView.getWindowVisibleDisplayFrame(visibleFrame)
val hiddenBottom = (window.decorView.rootView.height - visibleFrame.bottom).coerceAtLeast(0)
return if (hiddenBottom > (100 * density).toInt()) hiddenBottom.toFloat() / density else 0f
}
override fun onTouchEvent(event: MotionEvent): Boolean {
return handleWidgetTouch(event)
}
private fun handleWidgetTouch(event: MotionEvent): Boolean {
if (nativeApp == 0L || event.pointerCount == 0) return false
val pointerIndex = event.actionIndex.coerceIn(0, event.pointerCount - 1)
val pointerId = event.getPointerId(pointerIndex).toLong()
val x = event.getX(pointerIndex)
val y = event.getY(pointerIndex)
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
lastTouchX = x
lastTouchY = y
lastTouchActive = true
}
MotionEvent.ACTION_MOVE -> {
if (lastTouchActive) nativeScroll(nativeApp, pointerId, x, y, lastTouchX - x, lastTouchY - y)
lastTouchX = x
lastTouchY = y
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
lastTouchActive = false
}
}
nativeTouch(
nativeApp,
pointerId,
event.actionMasked,
x,
y,
event.getPressure(pointerIndex),
)
nativeFrame(nativeApp)
refreshWidgetSemanticsStatus()
updateSoftKeyboardForFocusedWidget()
return true
}
private fun dispatchCommittedWidgetText(text: String): Boolean {
if (nativeApp == 0L) return false
if (text.isEmpty()) return true
nativeText(nativeApp, text)
nativeFrame(nativeApp)
refreshWidgetSemanticsStatus()
return true
}
private fun dispatchWidgetIme(kind: Int, text: String, cursor: Long): Boolean {
if (nativeApp == 0L) return false
nativeIme(nativeApp, kind, text, cursor)
nativeFrame(nativeApp)
refreshWidgetSemanticsStatus()
return true
}
private fun dispatchWidgetKey(key: String): Boolean {
if (nativeApp == 0L) return false
nativeKey(nativeApp, 0, key, "", 0)
nativeKey(nativeApp, 1, key, "", 0)
nativeFrame(nativeApp)
refreshWidgetSemanticsStatus()
return true
}
private fun focusedTextWidget(): WidgetSemantics? {
return widgetSemanticsSnapshot().firstOrNull {
it.role == WIDGET_ROLE_TEXTBOX && (it.flags and WIDGET_FLAG_FOCUSED) != 0
}
}
private fun updateSoftKeyboardForFocusedWidget() {
if (!::widgetSurface.isInitialized) return
val input = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
if (focusedTextWidget() != null) {
widgetSurface.requestFocus()
input.showSoftInput(widgetSurface, InputMethodManager.SHOW_IMPLICIT)
} else {
input.hideSoftInputFromWindow(widgetSurface.windowToken, 0)
}
}
override fun onBackPressed() {
if (nativeApp != 0L) {
dispatchNativeCommand("mobile.back")
return
}
super.onBackPressed()
}
override fun onDestroy() {
if (nativeApp != 0L) {
nativeStop(nativeApp)
nativeDestroy(nativeApp)
nativeApp = 0
}
super.onDestroy()
}
external fun nativeCreate(): Long
external fun nativeDestroy(app: Long)
external fun nativeStart(app: Long)
external fun nativeActivate(app: Long)
external fun nativeDeactivate(app: Long)
external fun nativeStop(app: Long)
external fun nativeResize(app: Long, width: Float, height: Float, scale: Float, surface: Any)
external fun nativeViewport(app: Long, width: Float, height: Float, scale: Float, surface: Any, safeTop: Float, safeRight: Float, safeBottom: Float, safeLeft: Float, keyboardTop: Float, keyboardRight: Float, keyboardBottom: Float, keyboardLeft: Float)
external fun nativeTouch(app: Long, id: Long, phase: Int, x: Float, y: Float, pressure: Float)
external fun nativeScroll(app: Long, id: Long, x: Float, y: Float, deltaX: Float, deltaY: Float)
external fun nativeKey(app: Long, phase: Int, key: String, text: String, modifiers: Int)
external fun nativeText(app: Long, text: String)
external fun nativeIme(app: Long, kind: Int, text: String, cursor: Long)
external fun nativeCommand(app: Long, command: String): Int
external fun nativeFrame(app: Long)
external fun nativeGpuFrameState(app: Long, longs: LongArray, ints: IntArray, floats: FloatArray): Boolean
external fun nativeWidgetSemanticsCount(app: Long): Int
external fun nativeWidgetSemanticsFields(app: Long, index: Int, ids: LongArray, ints: IntArray, floats: FloatArray): Boolean
external fun nativeWidgetSemanticsLabel(app: Long, index: Int): ByteArray
external fun nativeWidgetSemanticsText(app: Long, index: Int): ByteArray
external fun nativeWidgetSemanticsByIdFields(app: Long, id: Long, ids: LongArray, ints: IntArray, floats: FloatArray): Boolean
external fun nativeWidgetSemanticsByIdLabel(app: Long, id: Long): ByteArray
external fun nativeWidgetSemanticsByIdText(app: Long, id: Long): ByteArray
external fun nativeWidgetTextGeometry(app: Long, id: Long, ints: IntArray, floats: FloatArray): Boolean
external fun nativeWidgetAction(app: Long, id: Long, action: Int, text: String?, selectionAnchor: Long, selectionFocus: Long, hasSelection: Boolean): Boolean
companion object {
private const val WIDGET_ROLE_BUTTON = 4
private const val WIDGET_ROLE_TEXTBOX = 5
private const val WIDGET_ROLE_MENUITEM = 9
private const val WIDGET_ROLE_LIST = 10
private const val WIDGET_ROLE_LISTITEM = 11
private const val WIDGET_ROLE_GRID = 13
private const val WIDGET_ROLE_IMAGE = 3
private const val WIDGET_ROLE_CHECKBOX = 16
private const val WIDGET_ROLE_SWITCH = 17
private const val WIDGET_ROLE_SLIDER = 18
private const val WIDGET_ROLE_PROGRESSBAR = 19
private const val WIDGET_FLAG_FOCUSED = 1 shl 0
private const val WIDGET_FLAG_SELECTED = 1 shl 3
private const val WIDGET_FLAG_DISABLED = 1 shl 4
private const val WIDGET_FLAG_FOCUSABLE = 1 shl 5
private const val WIDGET_FLAG_EXPANDED = 1 shl 6
private const val WIDGET_FLAG_COLLAPSED = 1 shl 7
private const val WIDGET_FLAG_REQUIRED = 1 shl 8
private const val WIDGET_FLAG_READ_ONLY = 1 shl 9
private const val WIDGET_FLAG_INVALID = 1 shl 10
private const val WIDGET_ACTION_FOCUS = 1 shl 0
private const val WIDGET_ACTION_PRESS = 1 shl 1
private const val WIDGET_ACTION_TOGGLE = 1 shl 2
private const val WIDGET_ACTION_INCREMENT = 1 shl 3
private const val WIDGET_ACTION_DECREMENT = 1 shl 4
private const val WIDGET_ACTION_SET_TEXT = 1 shl 5
private const val WIDGET_ACTION_SET_SELECTION = 1 shl 6
private const val WIDGET_ACTION_SELECT = 1 shl 7
private const val WIDGET_ACTION_DRAG = 1 shl 8
private const val WIDGET_ACTION_DROP_FILES = 1 shl 9
private const val WIDGET_ACTION_DISMISS = 1 shl 10
private const val WIDGET_ACTION_KIND_FOCUS = 0
private const val WIDGET_ACTION_KIND_PRESS = 1
private const val WIDGET_ACTION_KIND_TOGGLE = 2
private const val WIDGET_ACTION_KIND_INCREMENT = 3
private const val WIDGET_ACTION_KIND_DECREMENT = 4
private const val WIDGET_ACTION_KIND_SET_TEXT = 5
private const val WIDGET_ACTION_KIND_SET_SELECTION = 6
private const val WIDGET_ACTION_KIND_SET_COMPOSITION = 7
private const val WIDGET_ACTION_KIND_COMMIT_COMPOSITION = 8
private const val WIDGET_ACTION_KIND_CANCEL_COMPOSITION = 9
private const val WIDGET_ACTION_KIND_SELECT = 10
private const val WIDGET_ACTION_KIND_DRAG = 11
private const val WIDGET_ACTION_KIND_DROP_FILES = 12
private const val WIDGET_ACTION_KIND_DISMISS = 13
private const val WIDGET_IME_SET_COMPOSITION = 0
private const val WIDGET_IME_COMMIT_COMPOSITION = 1
private const val WIDGET_IME_CANCEL_COMPOSITION = 2
private const val html = """
<!doctype html>
<meta name="viewport" content="width=device-width, initial-scale=1">
<body style="margin:0;font-family:system-ui,sans-serif;background:#f7f8fa;color:#18181b">
<main style="padding:28px 22px;display:grid;gap:16px">
<h1 style="margin:0;font-size:30px">Workspace</h1>
<p style="margin:0;color:#5f6672;line-height:1.5">This content is rendered by Android WebView while the header remains native Android UI.</p>
<section style="display:grid;gap:10px">
<div style="padding:14px;border:1px solid #e1e5ea;border-radius:8px;background:white">Inbox review</div>
<div style="padding:14px;border:1px solid #e1e5ea;border-radius:8px;background:white">Sync queue</div>
<div style="padding:14px;border:1px solid #e1e5ea;border-radius:8px;background:white">Offline cache</div>
</section>
</main>
</body>
"""
}
}
@@ -0,0 +1,6 @@
<resources>
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:windowLightStatusBar">true</item>
<item name="android:colorAccent">#2563EB</item>
</style>
</resources>
+4
View File
@@ -0,0 +1,4 @@
plugins {
id "com.android.application" version "8.5.0" apply false
id "org.jetbrains.kotlin.android" version "2.0.20" apply false
}
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "NativeSdkAndroidExample"
include ":app"
+23
View File
@@ -0,0 +1,23 @@
# Browser Example
A no-build vanilla HTML/CSS/JS example that uses the layered WebViews API for isolated page content.
Run it from this directory:
```sh
zig build run
```
Or from the repository root:
```sh
zig build run-browser
```
The root command uses this example's system-WebView default unless you explicitly pass `-Dweb-engine=chromium`.
The frontend lives in `frontend/` and is served directly as `zero://app/index.html`.
The page content runs in an isolated child WebView. Page WebViews do not receive `window.zero`; all navigation and sizing is controlled by the browser chrome through the handle returned by `window.zero.webviews.create()`.
This example allows all navigation origins so it can browse arbitrary pages. Treat that as demo-only policy, not a production template. The chrome page keeps a strict CSP; arbitrary page navigation is controlled by the native navigation policy. Chromium/CEF builds are currently macOS-only; use the system WebView backend on Linux and Windows for this example. `main` WebView layering is best effort and may be ignored by a backend. Windows WebView support is still in progress and does not yet support the `main` WebView resizing this example uses.
+23
View File
@@ -0,0 +1,23 @@
.{
.id = "dev.native_sdk.browser",
.name = "browser",
.display_name = "Browser Example",
.version = "0.1.0",
.platforms = .{ "macos", "linux" },
.permissions = .{ "window" },
.capabilities = .{ "webview", "js_bridge" },
.security = .{
.navigation = .{
.allowed_origins = .{ "*" },
.external_links = .{ .action = "deny" },
},
},
.web_engine = "system",
.cef = .{ .dir = "third_party/cef/macos", .auto_install = false },
.shortcuts = .{
.{ .id = "zoom-in", .key = "=", .modifiers = .{ "primary" } },
.{ .id = "zoom-out", .key = "-", .modifiers = .{ "primary" } },
.{ .id = "zoom-reset", .key = "0", .modifiers = .{ "primary" } },
.{ .id = "reload", .key = "r", .modifiers = .{ "primary" } },
},
}
+342
View File
@@ -0,0 +1,342 @@
// This example owns its build: it hand-wires the framework modules, platform hosts, and the -Dweb-engine/-Dcef-dir engine link flags that the generated app graph does not expose.
const std = @import("std");
const PlatformOption = enum {
auto,
null,
macos,
linux,
windows,
};
const TraceOption = enum {
off,
events,
runtime,
all,
};
const WebEngineOption = enum {
system,
chromium,
};
const default_native_sdk_path = "../../";
const app_exe_name = "browser";
pub fn build(b: *std.Build) void {
const target = nativeSdkTarget(b);
const optimize = b.standardOptimizeOption(.{});
const platform_option = b.option(PlatformOption, "platform", "Desktop backend: auto, null, macos, linux, windows") orelse .auto;
const trace_option = b.option(TraceOption, "trace", "Trace output: off, events, runtime, all") orelse .events;
const automation_enabled = b.option(bool, "automation", "Enable Native SDK automation artifacts") orelse false;
const web_engine_override = b.option(WebEngineOption, "web-engine", "Override app.zon web engine: system, chromium");
const cef_dir_override = b.option([]const u8, "cef-dir", "Override CEF root directory for Chromium builds");
const cef_auto_install_override = b.option(bool, "cef-auto-install", "Override app.zon CEF auto-install setting");
const native_sdk_path = b.option([]const u8, "native-sdk-path", "Path to the Native SDK framework checkout") orelse default_native_sdk_path;
const selected_platform: PlatformOption = switch (platform_option) {
.auto => if (target.result.os.tag == .macos) .macos else if (target.result.os.tag == .linux) .linux else if (target.result.os.tag == .windows) .windows else .null,
else => platform_option,
};
if (selected_platform == .macos and target.result.os.tag != .macos) @panic("-Dplatform=macos requires a macOS target");
if (selected_platform == .linux and target.result.os.tag != .linux) @panic("-Dplatform=linux requires a Linux target");
if (selected_platform == .windows and target.result.os.tag != .windows) @panic("-Dplatform=windows requires a Windows target");
const web_engine = web_engine_override orelse WebEngineOption.system;
const cef_dir = cef_dir_override orelse defaultCefDir(selected_platform, "third_party/cef/macos");
const cef_auto_install = cef_auto_install_override orelse false;
if (web_engine == .chromium and selected_platform != .macos) {
@panic("-Dweb-engine=chromium currently requires -Dplatform=macos");
}
const native_sdk_mod = nativeSdkModule(b, target, optimize, native_sdk_path);
const options = b.addOptions();
options.addOption([]const u8, "platform", switch (selected_platform) {
.auto => unreachable,
.null => "null",
.macos => "macos",
.linux => "linux",
.windows => "windows",
});
options.addOption([]const u8, "trace", @tagName(trace_option));
options.addOption([]const u8, "web_engine", @tagName(web_engine));
options.addOption(bool, "automation", automation_enabled);
const options_mod = options.createModule();
const runner_mod = localModule(b, target, optimize, "src/runner.zig");
runner_mod.addImport("native_sdk", native_sdk_mod);
runner_mod.addImport("build_options", options_mod);
runner_mod.addImport("app_manifest_zon", b.createModule(.{ .root_source_file = b.path("app.zon") }));
const app_mod = localModule(b, target, optimize, "src/main.zig");
app_mod.addImport("native_sdk", native_sdk_mod);
app_mod.addImport("runner", runner_mod);
const exe = b.addExecutable(.{
.name = app_exe_name,
.root_module = app_mod,
});
linkPlatform(b, target, app_mod, exe, selected_platform, web_engine, native_sdk_path, cef_dir, cef_auto_install);
b.installArtifact(exe);
const run = b.addRunArtifact(exe);
addCefRuntimeRunFiles(b, target, run, exe, web_engine, cef_dir);
addWebView2RuntimeRunFiles(b, target, run, web_engine, native_sdk_path);
const run_step = b.step("run", "Run the browser example");
run_step.dependOn(&run.step);
const tests = b.addTest(.{ .root_module = app_mod });
const test_step = b.step("test", "Run tests");
test_step.dependOn(&b.addRunArtifact(tests).step);
}
fn nativeSdkTarget(b: *std.Build) std.Build.ResolvedTarget {
const target = b.standardTargetOptions(.{});
if (target.result.os.tag != .macos) return target;
if (b.sysroot == null) b.sysroot = macosSdkPath(b) orelse b.sysroot;
var query = target.query;
query.os_tag = .macos;
query.os_version_min = .{ .semver = .{ .major = 11, .minor = 0, .patch = 0 } };
return b.resolveTargetQuery(query);
}
fn macosSdkPath(b: *std.Build) ?[]const u8 {
if (b.graph.environ_map.get("SDKROOT")) |sdkroot| {
if (sdkroot.len > 0) return sdkroot;
}
const result = std.process.run(b.allocator, b.graph.io, .{
.argv = &.{ "xcrun", "--sdk", "macosx", "--show-sdk-path" },
.stdout_limit = .limited(4096),
.stderr_limit = .limited(4096),
}) catch return null;
defer b.allocator.free(result.stderr);
if (result.term != .exited or result.term.exited != 0) {
b.allocator.free(result.stdout);
return null;
}
return std.mem.trimEnd(u8, result.stdout, "\r\n");
}
fn localModule(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, path: []const u8) *std.Build.Module {
return b.createModule(.{
.root_source_file = b.path(path),
.target = target,
.optimize = optimize,
});
}
fn nativeSdkPath(b: *std.Build, native_sdk_path: []const u8, sub_path: []const u8) std.Build.LazyPath {
return .{ .cwd_relative = b.pathJoin(&.{ native_sdk_path, sub_path }) };
}
fn nativeSdkModule(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, native_sdk_path: []const u8) *std.Build.Module {
const geometry_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/geometry/root.zig");
const assets_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/assets/root.zig");
const app_dirs_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/app_dirs/root.zig");
const trace_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/trace/root.zig");
const app_manifest_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/app_manifest/root.zig");
const diagnostics_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/diagnostics/root.zig");
const platform_info_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/platform_info/root.zig");
const json_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/json/root.zig");
const canvas_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/canvas/root.zig");
canvas_mod.addImport("geometry", geometry_mod);
canvas_mod.addImport("json", json_mod);
const debug_mod = externalModule(b, target, optimize, native_sdk_path, "src/debug/root.zig");
debug_mod.addImport("app_dirs", app_dirs_mod);
debug_mod.addImport("trace", trace_mod);
const native_sdk_mod = externalModule(b, target, optimize, native_sdk_path, "src/root.zig");
native_sdk_mod.addImport("geometry", geometry_mod);
native_sdk_mod.addImport("assets", assets_mod);
native_sdk_mod.addImport("app_dirs", app_dirs_mod);
native_sdk_mod.addImport("trace", trace_mod);
native_sdk_mod.addImport("app_manifest", app_manifest_mod);
native_sdk_mod.addImport("diagnostics", diagnostics_mod);
native_sdk_mod.addImport("platform_info", platform_info_mod);
native_sdk_mod.addImport("json", json_mod);
native_sdk_mod.addImport("canvas", canvas_mod);
return native_sdk_mod;
}
fn externalModule(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, native_sdk_path: []const u8, path: []const u8) *std.Build.Module {
return b.createModule(.{
.root_source_file = nativeSdkPath(b, native_sdk_path, path),
.target = target,
.optimize = optimize,
});
}
fn linkPlatform(b: *std.Build, target: std.Build.ResolvedTarget, app_mod: *std.Build.Module, exe: *std.Build.Step.Compile, platform: PlatformOption, web_engine: WebEngineOption, native_sdk_path: []const u8, cef_dir: []const u8, cef_auto_install: bool) void {
if (platform == .macos) {
switch (web_engine) {
.system => {
const sdk_include = if (b.sysroot) |sysroot| b.fmt("-I{s}/usr/include", .{sysroot}) else "";
const flags: []const []const u8 = if (b.sysroot) |sysroot| &.{ "-fobjc-arc", "-fno-sanitize=builtin", "-ObjC", "-mmacosx-version-min=11.0", "-isysroot", sysroot, sdk_include } else &.{ "-fobjc-arc", "-fno-sanitize=builtin", "-ObjC", "-mmacosx-version-min=11.0" };
app_mod.addCSourceFile(.{ .file = nativeSdkPath(b, native_sdk_path, "src/platform/macos/appkit_host.m"), .flags = flags });
app_mod.linkFramework("WebKit", .{});
},
.chromium => {
const cef_check = addCefCheck(b, target, cef_dir);
if (cef_auto_install) {
const cef_auto = b.addSystemCommand(&.{ "native", "cef", "install", "--dir", cef_dir });
cef_check.step.dependOn(&cef_auto.step);
}
exe.step.dependOn(&cef_check.step);
const include_arg = b.fmt("-I{s}", .{cef_dir});
const define_arg = b.fmt("-DNATIVE_SDK_CEF_DIR=\"{s}\"", .{cef_dir});
// The SDK's usr/include must stay a system include dir (searched after zig's
// bundled libc++/libc headers). A plain -I shadows libc++'s <string.h>/<math.h>
// wrappers in ObjC++ and surfaces SDK nullability gaps as a diagnostic flood.
const sdk_include = if (b.sysroot) |sysroot| b.fmt("-isystem{s}/usr/include", .{sysroot}) else "";
const flags: []const []const u8 = if (b.sysroot) |sysroot| &.{ "-fobjc-arc", "-fno-sanitize=builtin", "-ObjC++", "-std=c++17", "-stdlib=libc++", "-mmacosx-version-min=11.0", "-isysroot", sysroot, sdk_include, include_arg, define_arg } else &.{ "-fobjc-arc", "-fno-sanitize=builtin", "-ObjC++", "-std=c++17", "-stdlib=libc++", "-mmacosx-version-min=11.0", include_arg, define_arg };
app_mod.addCSourceFile(.{ .file = nativeSdkPath(b, native_sdk_path, "src/platform/macos/cef_host.mm"), .flags = flags });
app_mod.addObjectFile(b.path(b.fmt("{s}/libcef_dll_wrapper/libcef_dll_wrapper.a", .{cef_dir})));
app_mod.addFrameworkPath(b.path(b.fmt("{s}/Release", .{cef_dir})));
app_mod.linkFramework("Chromium Embedded Framework", .{});
app_mod.addRPath(.{ .cwd_relative = "@executable_path/Frameworks" });
},
}
if (b.sysroot) |sysroot| app_mod.addFrameworkPath(.{ .cwd_relative = b.pathJoin(&.{ sysroot, "System/Library/Frameworks" }) });
app_mod.linkFramework("AppKit", .{});
// The audio playback service (the AppKit host's single AVPlayer).
app_mod.linkFramework("AVFoundation", .{});
// Spectrum analysis of the app's own playback: the MediaToolbox
// audio tap hands the player's PCM to the host, and Accelerate
// (vDSP) turns it into band magnitudes.
app_mod.linkFramework("MediaToolbox", .{});
app_mod.linkFramework("Accelerate", .{});
app_mod.linkFramework("Foundation", .{});
app_mod.linkFramework("CoreText", .{});
app_mod.linkFramework("UniformTypeIdentifiers", .{});
app_mod.linkFramework("Security", .{});
app_mod.linkFramework("Metal", .{});
app_mod.linkFramework("QuartzCore", .{});
app_mod.linkSystemLibrary("c", .{});
if (web_engine == .chromium) app_mod.linkSystemLibrary("c++", .{});
} else if (platform == .linux) {
switch (web_engine) {
.system => {
app_mod.addCSourceFile(.{ .file = nativeSdkPath(b, native_sdk_path, "src/platform/linux/gtk_host.c"), .flags = &.{} });
app_mod.linkSystemLibrary("gtk4", .{});
app_mod.linkSystemLibrary("webkitgtk-6.0", .{});
app_mod.linkSystemLibrary("dl", .{});
},
.chromium => {
const cef_check = addCefCheck(b, target, cef_dir);
if (cef_auto_install) {
const cef_auto = b.addSystemCommand(&.{ "native", "cef", "install", "--dir", cef_dir });
cef_check.step.dependOn(&cef_auto.step);
}
exe.step.dependOn(&cef_check.step);
const include_arg = b.fmt("-I{s}", .{cef_dir});
const define_arg = b.fmt("-DNATIVE_SDK_CEF_DIR=\"{s}\"", .{cef_dir});
app_mod.addCSourceFile(.{ .file = nativeSdkPath(b, native_sdk_path, "src/platform/linux/cef_host.cpp"), .flags = &.{ "-std=c++17", include_arg, define_arg } });
app_mod.addObjectFile(b.path(b.fmt("{s}/libcef_dll_wrapper/libcef_dll_wrapper.a", .{cef_dir})));
app_mod.addLibraryPath(b.path(b.fmt("{s}/Release", .{cef_dir})));
app_mod.linkSystemLibrary("cef", .{});
app_mod.addRPath(.{ .cwd_relative = "$ORIGIN" });
},
}
app_mod.linkSystemLibrary("c", .{});
if (web_engine == .chromium) app_mod.linkSystemLibrary("stdc++", .{});
} else if (platform == .windows) {
switch (web_engine) {
.system => {
// The vendored WebView2 SDK header (third_party/webview2)
// turns on the host's embedded-WebView layer; the host
// fails the compile by design if it cannot be found.
app_mod.addIncludePath(nativeSdkPath(b, native_sdk_path, "third_party/webview2/include"));
app_mod.addCSourceFile(.{ .file = nativeSdkPath(b, native_sdk_path, "src/platform/windows/webview2_host.cpp"), .flags = &.{"-std=c++17"} });
// WebView2Loader.dll rides next to the installed app
// executable: the host loads it at runtime to discover
// the machine's WebView2 runtime. Canvas apps never
// touch it.
const loader = b.addInstallBinFile(nativeSdkPath(b, native_sdk_path, webView2LoaderSubPath(target)), "WebView2Loader.dll");
b.getInstallStep().dependOn(&loader.step);
},
.chromium => {
const cef_check = addCefCheck(b, target, cef_dir);
if (cef_auto_install) {
const cef_auto = b.addSystemCommand(&.{ "native", "cef", "install", "--dir", cef_dir });
cef_check.step.dependOn(&cef_auto.step);
}
exe.step.dependOn(&cef_check.step);
const include_arg = b.fmt("-I{s}", .{cef_dir});
const define_arg = b.fmt("-DNATIVE_SDK_CEF_DIR=\"{s}\"", .{cef_dir});
app_mod.addCSourceFile(.{ .file = nativeSdkPath(b, native_sdk_path, "src/platform/windows/cef_host.cpp"), .flags = &.{ "-std=c++17", include_arg, define_arg } });
app_mod.addObjectFile(b.path(b.fmt("{s}/libcef_dll_wrapper/libcef_dll_wrapper.lib", .{cef_dir})));
app_mod.addLibraryPath(b.path(b.fmt("{s}/Release", .{cef_dir})));
},
}
app_mod.linkSystemLibrary("c", .{});
app_mod.linkSystemLibrary("c++", .{});
app_mod.linkSystemLibrary("user32", .{});
app_mod.linkSystemLibrary("gdi32", .{});
app_mod.linkSystemLibrary("imm32", .{});
app_mod.linkSystemLibrary("comctl32", .{});
app_mod.linkSystemLibrary("ole32", .{});
app_mod.linkSystemLibrary("oleacc", .{});
app_mod.linkSystemLibrary("shell32", .{});
// The audio backend: Media Foundation (session + source resolver
// + streaming audio renderer) and WinHTTP (the cache fill).
app_mod.linkSystemLibrary("mf", .{});
app_mod.linkSystemLibrary("mfplat", .{});
app_mod.linkSystemLibrary("winhttp", .{});
if (web_engine == .chromium) app_mod.linkSystemLibrary("libcef", .{});
}
}
/// The vendored WebView2Loader.dll for the target architecture, relative
/// to the framework root.
fn webView2LoaderSubPath(target: std.Build.ResolvedTarget) []const u8 {
return if (target.result.cpu.arch == .aarch64)
"third_party/webview2/arm64/WebView2Loader.dll"
else
"third_party/webview2/x64/WebView2Loader.dll";
}
/// `zig build run` executes the cached artifact, which has no installed
/// WebView2Loader.dll beside it; the vendored loader's directory goes on
/// the run step's PATH so the host's LoadLibrary resolves it in dev runs.
fn addWebView2RuntimeRunFiles(b: *std.Build, target: std.Build.ResolvedTarget, run: *std.Build.Step.Run, web_engine: WebEngineOption, native_sdk_path: []const u8) void {
if (web_engine != .system) return;
if (target.result.os.tag != .windows) return;
const loader_dir = std.fs.path.dirname(webView2LoaderSubPath(target)).?;
run.addPathDir(b.pathFromRoot(b.pathJoin(&.{ native_sdk_path, loader_dir })));
}
fn addCefRuntimeRunFiles(b: *std.Build, target: std.Build.ResolvedTarget, run: *std.Build.Step.Run, exe: *std.Build.Step.Compile, web_engine: WebEngineOption, cef_dir: []const u8) void {
if (web_engine != .chromium or target.result.os.tag != .macos) return;
const copy = b.addSystemCommand(&.{
"sh", "-c",
b.fmt(
\\set -e
\\exe="$0"
\\exe_dir="$(dirname "$exe")"
\\mkdir -p "zig-out/Frameworks" "zig-out/bin/Frameworks" ".zig-cache/o/Frameworks" "$exe_dir"
\\cp -R "{s}/Release/Chromium Embedded Framework.framework" "zig-out/Frameworks/"
\\cp -R "{s}/Release/Chromium Embedded Framework.framework" "zig-out/bin/Frameworks/"
\\cp -R "{s}/Release/Chromium Embedded Framework.framework" ".zig-cache/o/Frameworks/"
, .{ cef_dir, cef_dir, cef_dir }),
});
copy.addFileArg(exe.getEmittedBin());
run.step.dependOn(&copy.step);
}
fn addCefCheck(b: *std.Build, target: std.Build.ResolvedTarget, cef_dir: []const u8) *std.Build.Step.Run {
const script = switch (target.result.os.tag) {
.macos => b.fmt("test -f \"{s}/include/cef_app.h\" && test -d \"{s}/Release/Chromium Embedded Framework.framework\" && test -f \"{s}/libcef_dll_wrapper/libcef_dll_wrapper.a\" || {{ echo \"missing CEF dependency for -Dweb-engine=chromium\" >&2; echo \"Fix with: native cef install --dir {s}\" >&2; exit 1; }}", .{ cef_dir, cef_dir, cef_dir, cef_dir }),
.linux => b.fmt("test -f \"{s}/include/cef_app.h\" && test -f \"{s}/Release/libcef.so\" && test -f \"{s}/libcef_dll_wrapper/libcef_dll_wrapper.a\" || {{ echo \"missing CEF dependency for -Dweb-engine=chromium\" >&2; echo \"Fix with: native cef install --dir {s}\" >&2; exit 1; }}", .{ cef_dir, cef_dir, cef_dir, cef_dir }),
.windows => b.fmt("test -f \"{s}/include/cef_app.h\" && test -f \"{s}/Release/libcef.dll\" && test -f \"{s}/libcef_dll_wrapper/libcef_dll_wrapper.lib\" || {{ echo \"missing CEF dependency for -Dweb-engine=chromium\" >&2; echo \"Fix with: native cef install --dir {s}\" >&2; exit 1; }}", .{ cef_dir, cef_dir, cef_dir, cef_dir }),
else => "echo unsupported CEF target >&2; exit 1",
};
return b.addSystemCommand(&.{ "sh", "-c", script });
}
fn defaultCefDir(platform: PlatformOption, configured: []const u8) []const u8 {
if (!std.mem.eql(u8, configured, "third_party/cef/macos")) return configured;
return switch (platform) {
.linux => "third_party/cef/linux",
.windows => "third_party/cef/windows",
else => configured,
};
}
+584
View File
@@ -0,0 +1,584 @@
var PAGE_WEBVIEW_LABEL = "page";
var PAGE_WEBVIEW_LAYER = 0;
var CHROME_WEBVIEW_LAYER = 10;
var form = document.querySelector("#browser-form");
var addressInput = document.querySelector("#url-input");
var addressBar = document.querySelector("#address-bar");
var addressIcon = document.querySelector("#address-icon");
var suggestions = document.querySelector("#suggestions");
var backButton = document.querySelector("#back");
var forwardButton = document.querySelector("#forward");
var reloadButton = document.querySelector("#reload");
var statusText = document.querySelector("#status-text");
var emptyState = document.querySelector("#empty-state");
var errorState = document.querySelector("#error-state");
var errorTitle = document.querySelector("#error-title");
var errorDetail = document.querySelector("#error-detail");
var errorRetry = document.querySelector("#error-retry");
var toolbar = document.querySelector("#toolbar");
var pageWebView = null;
var currentUrl = "";
var navHistory = [];
var historyIndex = -1;
var visitedUrls = [];
var resizeHandle = 0;
var resizeInFlight = false;
var resizeRequested = false;
var viewportPollHandle = 0;
var isLoading = false;
var statusTimer = null;
var zoomLevel = 1.0;
var ZOOM_STEP = 0.1;
var ZOOM_MIN = 0.25;
var ZOOM_MAX = 5.0;
var suggestActive = -1;
var suggestFiltered = [];
// The main WebView is later resized to chrome height, so keep the original
// full-window viewport for positioning the child page WebView.
var browserViewport = {
width: Math.max(1, Math.floor(window.innerWidth)),
height: Math.max(1, Math.floor(window.innerHeight)),
};
var browserViewportRevision = 0;
var lastNativeResizeAt = 0;
var nativeViewportInset = null;
function setStatus(message, autohide) {
clearTimeout(statusTimer);
statusText.textContent = message;
if (autohide) {
statusTimer = setTimeout(function () {
statusText.textContent = "";
}, autohide);
}
}
function setLoading(loading) {
isLoading = loading;
if (loading) {
addressBar.classList.remove("load-complete");
addressBar.classList.add("loading");
} else {
addressBar.classList.remove("loading");
addressBar.classList.add("load-complete");
setTimeout(function () {
addressBar.classList.remove("load-complete");
}, 600);
}
}
function updateSecurityIndicator(url) {
if (url.startsWith("https://")) {
addressBar.classList.add("secure");
} else {
addressBar.classList.remove("secure");
}
}
function normalizeUrl(value) {
var trimmed = value.trim();
if (!trimmed) return "https://example.com";
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) return trimmed;
if (/^[a-z0-9]([a-z0-9-]*\.)+[a-z]{2,}/i.test(trimmed)) return "https://" + trimmed;
return "https://" + trimmed;
}
function canonicalUrlKey(value) {
var trimmed = String(value || "").trim();
if (!trimmed) return "";
try {
return new URL(trimmed).href;
} catch (_) {
return trimmed.toLowerCase();
}
}
function toolbarHeight() {
var toolbarRect = toolbar.getBoundingClientRect();
return Math.ceil(toolbarRect.height);
}
function chromeHeight() {
var height = toolbarHeight();
if (!suggestions.hidden) {
var suggestionsRect = suggestions.getBoundingClientRect();
height = Math.max(height, Math.ceil(suggestionsRect.bottom + 8));
}
return height;
}
function pageFrame() {
var top = toolbarHeight();
top = Math.min(top, Math.max(0, browserViewport.height - 1));
return {
x: 0,
y: top,
width: browserViewport.width,
height: Math.max(1, Math.floor(browserViewport.height - top)),
};
}
function chromeFrame() {
var height = chromeHeight();
return {
x: 0,
y: 0,
width: browserViewport.width,
height: Math.max(1, Math.min(height, browserViewport.height)),
};
}
function updateBrowserViewport(width, height) {
var nextWidth = Math.max(1, Math.floor(width));
var nextHeight = Math.max(1, Math.floor(height));
if (browserViewport.width === nextWidth && browserViewport.height === nextHeight) return false;
browserViewport = { width: nextWidth, height: nextHeight };
browserViewportRevision++;
return true;
}
function mainWindowInfo(windows) {
if (!Array.isArray(windows)) return null;
for (var i = 0; i < windows.length; i++) {
if (windows[i] && (windows[i].id === 1 || windows[i].label === "main")) return windows[i];
}
return windows[0] || null;
}
async function syncBrowserViewport() {
if (lastNativeResizeAt && Date.now() - lastNativeResizeAt < 1000) return false;
var startedAtRevision = browserViewportRevision;
try {
var info = mainWindowInfo(await window.zero.windows.list());
if (!info) return false;
if (browserViewportRevision !== startedAtRevision) return false;
var nativeWidth = Math.max(1, Math.floor(info.width));
var nativeHeight = Math.max(1, Math.floor(info.height));
if (!nativeViewportInset) {
nativeViewportInset = {
width: nativeWidth - browserViewport.width,
height: nativeHeight - browserViewport.height,
};
}
return updateBrowserViewport(
nativeWidth - nativeViewportInset.width,
nativeHeight - nativeViewportInset.height
);
} catch (error) {
console.error("Failed to sync browser viewport", error);
return false;
}
}
async function updateChromeOverlay() {
var frame = chromeFrame();
await window.zero.webviews.setFrame({ label: "main", frame: frame });
try {
await window.zero.webviews.setLayer({ label: "main", layer: CHROME_WEBVIEW_LAYER });
} catch (error) {
console.warn("Main WebView layering is not supported on this backend", error);
}
}
function updateHistoryButtons() {
backButton.disabled = historyIndex <= 0;
forwardButton.disabled = historyIndex < 0 || historyIndex >= navHistory.length - 1;
}
function remember(url) {
if (navHistory[historyIndex] === url) return;
navHistory = navHistory.slice(0, historyIndex + 1);
navHistory.push(url);
historyIndex = navHistory.length - 1;
updateHistoryButtons();
}
function updateAddressFromPage(url, options) {
options = options || {};
if (!url || canonicalUrlKey(url) === canonicalUrlKey(currentUrl)) return;
currentUrl = url;
addressInput.value = url;
updateSecurityIndicator(url);
hideError();
trackVisited(url);
if (options.record !== false) remember(url);
}
function trackVisited(url) {
var key = canonicalUrlKey(url);
if (!key) return;
for (var i = 0; i < visitedUrls.length; i++) {
if (canonicalUrlKey(visitedUrls[i]) === key) {
visitedUrls.splice(i, 1);
break;
}
}
visitedUrls.unshift(url);
if (visitedUrls.length > 200) visitedUrls.length = 200;
}
function hostFromUrl(url) {
try {
return new URL(url).hostname;
} catch (_) {
return url;
}
}
// ── Suggestions ──
function escapeHtml(str) {
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function highlightMatch(text, query) {
if (!query) return escapeHtml(text);
var lower = text.toLowerCase();
var qLower = query.toLowerCase();
var idx = lower.indexOf(qLower);
if (idx === -1) return escapeHtml(text);
var before = text.slice(0, idx);
var match = text.slice(idx, idx + query.length);
var after = text.slice(idx + query.length);
return escapeHtml(before) + "<mark>" + escapeHtml(match) + "</mark>" + escapeHtml(after);
}
function filterSuggestions(query) {
if (!query) return [];
var q = query.toLowerCase();
var results = [];
var seen = Object.create(null);
for (var i = 0; i < visitedUrls.length; i++) {
var url = visitedUrls[i];
var key = canonicalUrlKey(url);
var matches = url.toLowerCase().indexOf(q) !== -1 || key.toLowerCase().indexOf(q) !== -1;
if (seen[key] || !matches) continue;
seen[key] = true;
results.push(url);
if (results.length >= 8) break;
}
return results;
}
function renderSuggestions(query) {
suggestFiltered = filterSuggestions(query);
suggestActive = -1;
if (suggestFiltered.length === 0) {
suggestions.hidden = true;
scheduleResize();
return;
}
var html = "";
for (var i = 0; i < suggestFiltered.length; i++) {
var url = suggestFiltered[i];
var host = hostFromUrl(url);
html +=
'<div class="suggestion" data-index="' + i + '">' +
'<div class="suggestion-icon">' +
'<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="5.5" stroke="currentColor" stroke-width="1.1"/><path d="M5 5.5h4M5 7h4M5 8.5h2.5" stroke="currentColor" stroke-width="0.9" stroke-linecap="round"/></svg>' +
'</div>' +
'<span class="suggestion-url">' + highlightMatch(url, query) + '</span>' +
'<span class="suggestion-host">' + escapeHtml(host) + '</span>' +
'</div>';
}
suggestions.innerHTML = html;
suggestions.hidden = false;
scheduleResize();
}
function hideSuggestions() {
var wasVisible = !suggestions.hidden;
suggestions.hidden = true;
suggestActive = -1;
suggestFiltered = [];
if (wasVisible) scheduleResize();
}
function setSuggestActive(index) {
var items = suggestions.querySelectorAll(".suggestion");
for (var i = 0; i < items.length; i++) {
items[i].classList.toggle("active", i === index);
}
suggestActive = index;
if (index >= 0 && index < suggestFiltered.length) {
addressInput.value = suggestFiltered[index];
}
}
suggestions.addEventListener("mousedown", function (event) {
event.preventDefault();
var item = event.target.closest(".suggestion");
if (!item) return;
var idx = parseInt(item.getAttribute("data-index"), 10);
if (idx >= 0 && idx < suggestFiltered.length) {
var url = suggestFiltered[idx];
hideSuggestions();
navigateTo(url);
}
});
// ── Error state ──
function showError(url, message) {
var host = hostFromUrl(url);
errorTitle.textContent = "Can\u2019t connect to " + host;
errorDetail.textContent = message || "The site could not be reached. Check the address or try again.";
errorState.hidden = false;
emptyState.hidden = true;
}
function hideError() {
errorState.hidden = true;
}
async function closePageWebView() {
if (!pageWebView) return;
try {
await pageWebView.close();
} catch (error) {
console.error("Failed to close page WebView", error);
setStatus(error && error.message ? error.message : "Failed to close page WebView", 3000);
}
pageWebView = null;
}
// ── Navigation ──
async function ensurePageWebView(url) {
await syncBrowserViewport();
var frame = pageFrame();
if (!pageWebView) {
await updateChromeOverlay();
pageWebView = await window.zero.webviews.create({
label: PAGE_WEBVIEW_LABEL,
url: url,
frame: frame,
layer: PAGE_WEBVIEW_LAYER,
transparent: false,
bridge: false,
});
await updateChromeOverlay();
startViewportPolling();
emptyState.hidden = true;
} else {
await pageWebView.setFrame(frame);
await pageWebView.navigate(url);
}
}
async function navigateTo(url, options) {
options = options || {};
var target = normalizeUrl(url);
addressInput.value = target;
updateSecurityIndicator(target);
hideError();
setLoading(true);
setStatus("Loading " + hostFromUrl(target) + "\u2026");
try {
await ensurePageWebView(target);
currentUrl = target;
trackVisited(target);
if (options.record !== false) remember(target);
setLoading(false);
setStatus(hostFromUrl(target), 2000);
} catch (error) {
setLoading(false);
await closePageWebView();
var msg = error && error.message ? error.message : "Navigation failed";
showError(target, msg);
setStatus("Failed to load " + hostFromUrl(target));
}
}
async function applyResize() {
if (!pageWebView) return;
await updateChromeOverlay();
var frame = pageFrame();
await pageWebView.setFrame(frame);
}
async function flushResizeQueue() {
if (resizeInFlight) {
resizeRequested = true;
return;
}
resizeInFlight = true;
try {
do {
resizeRequested = false;
await applyResize();
} while (resizeRequested);
} catch (error) {
setStatus(error.message || "Failed to resize page WebView");
} finally {
resizeInFlight = false;
if (resizeRequested) flushResizeQueue();
}
}
function scheduleResize() {
resizeRequested = true;
if (resizeHandle) cancelAnimationFrame(resizeHandle);
resizeHandle = requestAnimationFrame(function () {
resizeHandle = 0;
flushResizeQueue();
});
}
function startViewportPolling() {
if (viewportPollHandle) return;
viewportPollHandle = setInterval(async function () {
if (!pageWebView) return;
if (await syncBrowserViewport()) scheduleResize();
}, 250);
}
window.zero.on("resize", function (detail) {
if (!detail) return;
lastNativeResizeAt = Date.now();
var changed = updateBrowserViewport(detail.width, detail.height);
if (changed) scheduleResize();
});
window.zero.on("webview:navigate", function (detail) {
if (!detail || detail.label !== PAGE_WEBVIEW_LABEL) return;
updateAddressFromPage(detail.url);
});
// ── Event listeners ──
form.addEventListener("submit", function (event) {
event.preventDefault();
hideSuggestions();
addressInput.blur();
navigateTo(addressInput.value);
});
addressInput.addEventListener("focus", function () {
addressBar.classList.add("focused");
addressInput.select();
renderSuggestions(addressInput.value);
});
addressInput.addEventListener("blur", function () {
addressBar.classList.remove("focused");
hideSuggestions();
});
addressInput.addEventListener("input", function () {
renderSuggestions(addressInput.value);
});
addressInput.addEventListener("keydown", function (event) {
if (event.key === "Escape") {
if (!suggestions.hidden) {
hideSuggestions();
addressInput.value = currentUrl || addressInput.value;
return;
}
addressInput.value = currentUrl || addressInput.value;
addressInput.blur();
return;
}
if (suggestions.hidden || suggestFiltered.length === 0) return;
if (event.key === "ArrowDown") {
event.preventDefault();
var next = suggestActive + 1;
if (next >= suggestFiltered.length) next = 0;
setSuggestActive(next);
} else if (event.key === "ArrowUp") {
event.preventDefault();
var prev = suggestActive - 1;
if (prev < 0) prev = suggestFiltered.length - 1;
setSuggestActive(prev);
} else if (event.key === "Enter" && suggestActive >= 0) {
event.preventDefault();
var url = suggestFiltered[suggestActive];
hideSuggestions();
navigateTo(url);
}
});
backButton.addEventListener("click", function () {
if (historyIndex <= 0) return;
historyIndex -= 1;
updateHistoryButtons();
navigateTo(navHistory[historyIndex], { record: false });
});
forwardButton.addEventListener("click", function () {
if (historyIndex >= navHistory.length - 1) return;
historyIndex += 1;
updateHistoryButtons();
navigateTo(navHistory[historyIndex], { record: false });
});
reloadButton.addEventListener("click", function () {
if (isLoading) {
setLoading(false);
setStatus("Stopped", 2000);
return;
}
reloadPage();
});
errorRetry.addEventListener("click", function () {
reloadPage();
});
function reloadPage() {
navigateTo(currentUrl || addressInput.value, { record: false });
}
async function applyZoom(level) {
zoomLevel = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(level * 100) / 100));
if (!pageWebView) return;
try {
await pageWebView.setZoom(zoomLevel);
var pct = Math.round(zoomLevel * 100);
setStatus(pct + "%", 1200);
} catch (error) {
console.error("Failed to zoom page WebView", error);
setStatus(error && error.message ? error.message : "Failed to zoom page WebView", 3000);
}
}
window.addEventListener("keydown", function (event) {
var isMod = event.metaKey || event.ctrlKey;
if (!isMod) return;
if (event.key === "=" || event.key === "+") {
event.preventDefault();
applyZoom(zoomLevel + ZOOM_STEP);
} else if (event.key === "-") {
event.preventDefault();
applyZoom(zoomLevel - ZOOM_STEP);
} else if (event.key === "0") {
event.preventDefault();
applyZoom(1.0);
}
});
window.zero.on("shortcut", function (detail) {
if (!detail) return;
if (detail.command === "zoom-in") {
applyZoom(zoomLevel + ZOOM_STEP);
} else if (detail.command === "zoom-out") {
applyZoom(zoomLevel - ZOOM_STEP);
} else if (detail.command === "zoom-reset") {
applyZoom(1.0);
} else if (detail.command === "reload") {
reloadPage();
}
});
window.addEventListener("resize", function () {
scheduleResize();
});
window.addEventListener("DOMContentLoaded", function () {
navigateTo(addressInput.value);
});
+81
View File
@@ -0,0 +1,81 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'">
<title>Zero Browser</title>
<link rel="stylesheet" href="./styles.css">
</head>
<body>
<main class="browser-shell">
<div class="toolbar" id="toolbar">
<div class="toolbar-row">
<div class="nav-group">
<button class="nav-btn" id="back" type="button" aria-label="Back" disabled>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M10 3L5 8l5 5" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button class="nav-btn" id="forward" type="button" aria-label="Forward" disabled>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M6 3l5 5-5 5" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
<form class="address-bar-wrapper" id="browser-form">
<div class="address-bar" id="address-bar">
<div class="address-icon" id="address-icon">
<svg class="icon-lock" width="14" height="14" viewBox="0 0 14 14" fill="none">
<rect x="2.5" y="6" width="9" height="6.5" rx="1.5" stroke="currentColor" stroke-width="1.3"/>
<path d="M4.5 6V4.5a2.5 2.5 0 015 0V6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>
<svg class="icon-globe" width="14" height="14" viewBox="0 0 14 14" fill="none">
<circle cx="7" cy="7" r="5.5" stroke="currentColor" stroke-width="1.2"/>
<ellipse cx="7" cy="7" rx="2.8" ry="5.5" stroke="currentColor" stroke-width="1.2"/>
<path d="M1.5 7h11M2 4.5h10M2 9.5h10" stroke="currentColor" stroke-width="1"/>
</svg>
</div>
<input id="url-input" type="text" spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="none" enterkeyhint="go" value="https://example.com" aria-label="Address" placeholder="Search or enter address">
<button class="reload-btn" id="reload" type="button" aria-label="Reload">
<svg class="icon-reload" width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M11.5 7a4.5 4.5 0 1 1-1.26-3.12" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><path d="M10.5 1v3.25h-3.25" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
<svg class="icon-stop" width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M4 4l6 6M10 4l-6 6" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
</button>
<div class="loading-bar" id="loading-bar"></div>
</div>
<div class="suggestions" id="suggestions" hidden></div>
</form>
</div>
</div>
<section class="page-stage" aria-label="Browser page WebView">
<div class="empty-state" id="empty-state">
<div class="empty-icon">
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
<circle cx="24" cy="24" r="20" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
<circle cx="24" cy="24" r="12" stroke="currentColor" stroke-width="1.5" opacity="0.2"/>
<circle cx="24" cy="24" r="4" fill="currentColor" opacity="0.15"/>
</svg>
</div>
<p class="empty-title">Loading page</p>
<p class="empty-sub">A native page WebView will appear here</p>
</div>
<div class="error-state" id="error-state" hidden>
<div class="error-icon">
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
<circle cx="24" cy="24" r="20" stroke="currentColor" stroke-width="1.5"/>
<path d="M24 16v10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<circle cx="24" cy="32" r="1.5" fill="currentColor"/>
</svg>
</div>
<p class="error-title" id="error-title">Can't connect to the server</p>
<p class="error-detail" id="error-detail">The site could not be reached.</p>
<button class="error-retry" id="error-retry" type="button">Try again</button>
</div>
</section>
<div class="status-bar hidden" id="status" role="status" aria-live="polite">
<span class="status-text" id="status-text">Ready</span>
</div>
</main>
<script src="./app.js"></script>
</body>
</html>
+458
View File
@@ -0,0 +1,458 @@
:root {
--toolbar-bg: rgba(251, 251, 254, 0.82);
--toolbar-border: rgba(0, 0, 0, 0.08);
--toolbar-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.06);
--bar-bg: rgba(0, 0, 0, 0.04);
--bar-bg-hover: rgba(0, 0, 0, 0.06);
--bar-border: rgba(0, 0, 0, 0.08);
--bar-focus-ring: rgba(59, 130, 246, 0.4);
--bar-focus-bg: #fff;
--text-primary: #1a1a1a;
--text-secondary: rgba(0, 0, 0, 0.45);
--text-tertiary: rgba(0, 0, 0, 0.3);
--nav-hover: rgba(0, 0, 0, 0.06);
--nav-active: rgba(0, 0, 0, 0.1);
--stage-bg: #f5f5f7;
--status-bg: rgba(255, 255, 255, 0.88);
--status-border: rgba(0, 0, 0, 0.06);
--accent: #007aff;
--accent-text: #0066d6;
--loading-bar: #007aff;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--toolbar-bg: rgba(40, 40, 44, 0.82);
--toolbar-border: rgba(255, 255, 255, 0.08);
--toolbar-shadow: 0 1px 3px rgba(0, 0, 0, 0.2), 0 8px 24px rgba(0, 0, 0, 0.15);
--bar-bg: rgba(255, 255, 255, 0.07);
--bar-bg-hover: rgba(255, 255, 255, 0.1);
--bar-border: rgba(255, 255, 255, 0.1);
--bar-focus-ring: rgba(100, 160, 255, 0.4);
--bar-focus-bg: rgba(30, 30, 34, 0.95);
--text-primary: #f0f0f0;
--text-secondary: rgba(255, 255, 255, 0.5);
--text-tertiary: rgba(255, 255, 255, 0.25);
--nav-hover: rgba(255, 255, 255, 0.08);
--nav-active: rgba(255, 255, 255, 0.14);
--stage-bg: #1c1c1e;
--status-bg: rgba(44, 44, 48, 0.9);
--status-border: rgba(255, 255, 255, 0.08);
--accent: #4da3ff;
--accent-text: #6bb3ff;
--loading-bar: #4da3ff;
}
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
overflow: hidden;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif;
font-size: 13px;
line-height: 1;
color: var(--text-primary);
background: transparent;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
button, input { font: inherit; }
button { cursor: pointer; border: 0; background: none; }
/* ── Shell ── */
.browser-shell {
position: relative;
height: 100vh;
display: flex;
flex-direction: column;
}
/* ── Toolbar ── */
.toolbar {
position: relative;
z-index: 10;
flex-shrink: 0;
background: var(--toolbar-bg);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border-bottom: 0.5px solid var(--toolbar-border);
box-shadow: var(--toolbar-shadow);
padding: 0 14px;
}
.toolbar-row {
display: flex;
align-items: center;
gap: 8px;
height: 48px;
}
/* ── Navigation ── */
.nav-group {
display: flex;
align-items: center;
gap: 2px;
-webkit-app-region: no-drag;
}
.nav-btn {
width: 30px;
height: 30px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary);
transition: background 0.12s ease, opacity 0.12s ease;
}
.nav-btn:not(:disabled):hover { background: var(--nav-hover); }
.nav-btn:not(:disabled):active { background: var(--nav-active); }
.nav-btn:disabled {
opacity: 0.28;
cursor: default;
}
/* ── Address Bar ── */
.address-bar-wrapper {
flex: 1;
min-width: 0;
-webkit-app-region: no-drag;
}
.address-bar {
position: relative;
display: flex;
align-items: center;
height: 34px;
border-radius: 8px;
background: var(--bar-bg);
border: 1px solid transparent;
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
overflow: hidden;
}
.address-bar:hover {
background: var(--bar-bg-hover);
}
.address-bar.focused {
background: var(--bar-focus-bg);
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--bar-focus-ring);
}
.address-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
flex-shrink: 0;
color: var(--text-tertiary);
transition: color 0.2s ease;
}
.address-bar.secure .address-icon { color: var(--accent-text); }
.address-icon .icon-globe { display: block; }
.address-icon .icon-lock { display: none; }
.address-bar.secure .address-icon .icon-globe { display: none; }
.address-bar.secure .address-icon .icon-lock { display: block; }
#url-input {
flex: 1;
min-width: 0;
height: 100%;
border: 0;
outline: 0;
background: transparent;
color: var(--text-primary);
font-size: 13px;
font-weight: 400;
letter-spacing: 0.01em;
padding: 0 2px;
text-overflow: ellipsis;
}
#url-input::placeholder {
color: var(--text-tertiary);
}
#url-input::selection {
background: rgba(59, 130, 246, 0.25);
}
.reload-btn {
width: 34px;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--text-secondary);
border-radius: 0 7px 7px 0;
transition: color 0.12s ease, background 0.12s ease;
}
.reload-btn svg {
flex-shrink: 0;
}
.reload-btn:hover {
color: var(--text-primary);
background: var(--nav-hover);
}
.reload-btn:active {
background: var(--nav-active);
}
.reload-btn .icon-stop { display: none; }
.address-bar.loading .reload-btn .icon-reload { display: none; }
.address-bar.loading .reload-btn .icon-stop { display: block; }
/* ── Suggestions ── */
.address-bar-wrapper {
position: relative;
}
.suggestions {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 100;
max-height: 280px;
overflow-y: auto;
overscroll-behavior: contain;
border-radius: 10px;
background: var(--status-bg);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border: 0.5px solid var(--toolbar-border);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.14), 0 1px 3px rgba(0, 0, 0, 0.06);
padding: 4px;
animation: suggestions-in 0.15s ease;
}
.suggestions[hidden] { display: none; }
@keyframes suggestions-in {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.suggestion {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 10px;
border-radius: 7px;
cursor: pointer;
transition: background 0.08s ease;
}
.suggestion:hover,
.suggestion.active {
background: var(--nav-hover);
}
.suggestion-icon {
flex-shrink: 0;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
}
.suggestion-url {
flex: 1;
min-width: 0;
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.suggestion-url mark {
background: none;
color: var(--accent);
font-weight: 600;
}
.suggestion-host {
flex-shrink: 0;
font-size: 11px;
color: var(--text-tertiary);
}
/* ── Loading Bar ── */
.loading-bar {
position: absolute;
bottom: 0;
left: 0;
height: 2px;
width: 0;
background: var(--loading-bar);
border-radius: 0 1px 1px 0;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.address-bar.loading .loading-bar {
opacity: 1;
animation: loading-progress 2.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
@keyframes loading-progress {
0% { width: 0; }
10% { width: 25%; }
30% { width: 50%; }
50% { width: 70%; }
70% { width: 82%; }
90% { width: 90%; }
100% { width: 94%; }
}
.address-bar.load-complete .loading-bar {
width: 100% !important;
opacity: 0;
transition: width 0.2s ease, opacity 0.4s ease 0.2s;
animation: none;
}
/* ── Page Stage ── */
.page-stage {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: var(--text-tertiary);
user-select: none;
}
.empty-state[hidden] { display: none; }
.empty-icon {
margin-bottom: 4px;
animation: pulse-ring 3s ease-in-out infinite;
}
@keyframes pulse-ring {
0%, 100% { opacity: 0.7; transform: scale(1); }
50% { opacity: 1; transform: scale(1.04); }
}
.empty-title {
font-size: 15px;
font-weight: 600;
color: var(--text-secondary);
letter-spacing: -0.01em;
}
.empty-sub {
font-size: 12px;
line-height: 1.4;
}
/* ── Error State ── */
.error-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
user-select: none;
animation: error-in 0.3s ease;
}
.error-state[hidden] { display: none; }
@keyframes error-in {
from { opacity: 0; transform: scale(0.97); }
to { opacity: 1; transform: scale(1); }
}
.error-icon {
margin-bottom: 6px;
color: var(--text-tertiary);
}
.error-title {
font-size: 17px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.02em;
}
.error-detail {
font-size: 13px;
line-height: 1.5;
color: var(--text-secondary);
text-align: center;
max-width: 320px;
}
.error-retry {
margin-top: 8px;
padding: 7px 20px;
border-radius: 7px;
border: 0;
font-size: 13px;
font-weight: 500;
color: #fff;
background: var(--accent);
cursor: pointer;
transition: filter 0.12s ease;
}
.error-retry:hover { filter: brightness(1.1); }
.error-retry:active { filter: brightness(0.9); }
/* ── Status Bar ── */
.status-bar {
position: fixed;
left: 8px;
bottom: 8px;
z-index: 20;
max-width: min(480px, calc(100vw - 16px));
padding: 5px 10px;
border-radius: 6px;
background: var(--status-bg);
backdrop-filter: saturate(160%) blur(12px);
-webkit-backdrop-filter: saturate(160%) blur(12px);
border: 0.5px solid var(--status-border);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
font-size: 11px;
color: var(--text-secondary);
opacity: 1;
transform: translateY(0);
transition: opacity 0.3s ease, transform 0.3s ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status-bar.hidden {
opacity: 0;
transform: translateY(4px);
pointer-events: none;
}
+60
View File
@@ -0,0 +1,60 @@
const std = @import("std");
const runner = @import("runner");
const native_sdk = @import("native_sdk");
pub const panic = std.debug.FullPanic(native_sdk.debug.capturePanic);
const app_permissions = [_][]const u8{native_sdk.security.permission_window};
const bridge_origins = [_][]const u8{"zero://app"};
const window_permission = [_][]const u8{native_sdk.security.permission_window};
const builtin_policies = [_]native_sdk.BridgeCommandPolicy{
.{ .name = "native-sdk.window.list", .permissions = &window_permission, .origins = &bridge_origins },
.{ .name = "native-sdk.webview.create", .permissions = &window_permission, .origins = &bridge_origins },
.{ .name = "native-sdk.webview.list", .permissions = &window_permission, .origins = &bridge_origins },
.{ .name = "native-sdk.webview.setFrame", .permissions = &window_permission, .origins = &bridge_origins },
.{ .name = "native-sdk.webview.navigate", .permissions = &window_permission, .origins = &bridge_origins },
.{ .name = "native-sdk.webview.setZoom", .permissions = &window_permission, .origins = &bridge_origins },
.{ .name = "native-sdk.webview.setLayer", .permissions = &window_permission, .origins = &bridge_origins },
.{ .name = "native-sdk.webview.close", .permissions = &window_permission, .origins = &bridge_origins },
};
const BrowserApp = struct {
fn app(self: *@This()) native_sdk.App {
return .{
.context = self,
.name = "browser",
.source = native_sdk.frontend.productionSource(.{
.dist = "frontend",
.entry = "index.html",
.origin = "zero://app",
.spa_fallback = false,
}),
};
}
};
pub fn main(init: std.process.Init) !void {
var app = BrowserApp{};
try runner.runWithOptions(app.app(), .{
.app_name = "Zero Browser",
.window_title = "Zero Browser",
.bundle_id = "dev.native_sdk.browser",
.builtin_bridge = .{ .enabled = true, .commands = &builtin_policies },
.security = .{
.permissions = &app_permissions,
.navigation = .{
.allowed_origins = &.{"*"},
.external_links = .{ .action = .deny },
},
},
}, init);
}
test "browser app serves static frontend assets" {
var state = BrowserApp{};
const app = state.app();
try std.testing.expectEqualStrings("browser", app.name);
try std.testing.expectEqual(native_sdk.WebViewSourceKind.assets, app.source.kind);
try std.testing.expectEqualStrings("frontend", app.source.asset_options.?.root_path);
try std.testing.expectEqualStrings("index.html", app.source.asset_options.?.entry);
}
+240
View File
@@ -0,0 +1,240 @@
const std = @import("std");
const build_options = @import("build_options");
const native_sdk = @import("native_sdk");
const app_manifest = @import("app_manifest_zon");
const manifest_shortcuts = if (@hasField(@TypeOf(app_manifest), "shortcuts")) app_manifest.shortcuts else .{};
const manifest_windows = if (@hasField(@TypeOf(app_manifest), "windows")) app_manifest.windows else .{};
pub const RunOptions = struct {
app_name: []const u8,
window_title: []const u8 = "",
bundle_id: []const u8,
icon_path: []const u8 = "assets/icon.png",
bridge: ?native_sdk.BridgeDispatcher = null,
builtin_bridge: native_sdk.BridgePolicy = .{},
security: native_sdk.SecurityPolicy = .{},
shortcuts: ?[]const native_sdk.Shortcut = null,
fn appInfo(self: RunOptions, buffers: *StateBuffers) native_sdk.AppInfo {
var info: native_sdk.AppInfo = .{
.app_name = self.app_name,
// The identity the OS shows (application menu, Dock, About
// panel) reads straight from app.zon at comptime, so dev
// runs carry the same display name and version a packaged
// bundle gets from its Info.plist.
.display_name = manifestStringField("display_name"),
.version = manifestStringField("version"),
.description = manifestStringField("description"),
.has_web_content = manifestHasWebContent(),
.window_title = self.window_title,
.bundle_id = self.bundle_id,
.icon_path = self.icon_path,
};
const windows = manifestWindowOptions(buffers);
if (windows.len > 0) {
info.main_window = windows[0];
info.windows = windows;
}
return info;
}
fn resolvedShortcuts(self: RunOptions, storage: *ShortcutStorage) []const native_sdk.Shortcut {
return self.shortcuts orelse storage.fromManifest();
}
};
const ShortcutStorage = struct {
shortcuts: [native_sdk.platform.max_shortcuts]native_sdk.Shortcut = undefined,
fn fromManifest(self: *ShortcutStorage) []const native_sdk.Shortcut {
comptime {
if (manifest_shortcuts.len > native_sdk.platform.max_shortcuts) {
@compileError("app.zon defines too many shortcuts");
}
}
inline for (manifest_shortcuts, 0..) |shortcut, index| {
self.shortcuts[index] = .{
.id = shortcut.id,
.key = shortcut.key,
.modifiers = shortcutModifiers(shortcut),
};
}
return self.shortcuts[0..manifest_shortcuts.len];
}
};
const StateBuffers = struct {
restored_windows: [native_sdk.platform.max_windows]native_sdk.WindowOptions = undefined,
};
/// A top-level app.zon string field (`display_name`, `version`,
/// `description`), or "" when the manifest omits it — optional identity
/// stays optional all the way into `AppInfo`.
fn manifestStringField(comptime field: []const u8) []const u8 {
if (comptime !@hasField(@TypeOf(app_manifest), field)) return "";
const value = @field(app_manifest, field);
if (comptime @TypeOf(value) == @TypeOf(null)) return "";
return value;
}
/// Whether app.zon declares web content: the `webview` capability or a
/// `frontend` block. Hosts build honest default menus from this — web
/// items like Reload only exist when a webview can answer them, so
/// canvas-only apps never ship dead menu items.
fn manifestHasWebContent() bool {
if (comptime @hasField(@TypeOf(app_manifest), "frontend")) return true;
if (comptime !@hasField(@TypeOf(app_manifest), "capabilities")) return false;
inline for (app_manifest.capabilities) |capability| {
if (comptime std.mem.eql(u8, capability, "webview")) return true;
}
return false;
}
fn manifestWindowOptions(buffers: *StateBuffers) []const native_sdk.WindowOptions {
comptime {
if (manifest_windows.len > native_sdk.platform.max_windows) {
@compileError("app.zon defines too many windows");
}
}
inline for (manifest_windows, 0..) |window, index| {
buffers.restored_windows[index] = manifestWindow(window, index);
}
return buffers.restored_windows[0..manifest_windows.len];
}
fn manifestWindow(comptime window: anytype, comptime index: usize) native_sdk.WindowOptions {
return .{
.id = index + 1,
.label = windowLabel(window, index),
.title = windowTitle(window),
.default_frame = native_sdk.geometry.RectF.init(
windowFloat(window, "x", 0),
windowFloat(window, "y", 0),
windowFloat(window, "width", 720),
windowFloat(window, "height", 480),
),
.resizable = windowBool(window, "resizable", true),
.restore_state = windowBool(window, "restore_state", true),
.restore_policy = windowRestorePolicy(window),
};
}
fn windowLabel(comptime window: anytype, comptime index: usize) []const u8 {
if (comptime @hasField(@TypeOf(window), "label")) return window.label;
return if (index == 0) "main" else "window";
}
fn windowTitle(comptime window: anytype) []const u8 {
if (comptime !@hasField(@TypeOf(window), "title")) return "";
const title = window.title;
if (comptime @TypeOf(title) == @TypeOf(null)) return "";
return title;
}
fn windowFloat(comptime window: anytype, comptime field: []const u8, comptime default_value: f32) f32 {
if (comptime @hasField(@TypeOf(window), field)) return @field(window, field);
return default_value;
}
fn windowBool(comptime window: anytype, comptime field: []const u8, comptime default_value: bool) bool {
if (comptime @hasField(@TypeOf(window), field)) return @field(window, field);
return default_value;
}
fn windowRestorePolicy(comptime window: anytype) native_sdk.WindowRestorePolicy {
if (comptime !@hasField(@TypeOf(window), "restore_policy")) return .clamp_to_visible_screen;
const value = window.restore_policy;
if (comptime std.mem.eql(u8, value, "clamp_to_visible_screen")) return .clamp_to_visible_screen;
if (comptime std.mem.eql(u8, value, "center_on_primary")) return .center_on_primary;
@compileError("unknown app.zon window restore_policy");
}
fn shortcutModifiers(comptime shortcut: anytype) native_sdk.ShortcutModifiers {
const values = if (@hasField(@TypeOf(shortcut), "modifiers")) shortcut.modifiers else .{};
var modifiers: native_sdk.ShortcutModifiers = .{};
inline for (values) |value| {
const modifier: []const u8 = value;
if (comptime std.mem.eql(u8, modifier, "primary")) {
modifiers.primary = true;
} else if (comptime std.mem.eql(u8, modifier, "command")) {
modifiers.command = true;
} else if (comptime std.mem.eql(u8, modifier, "control")) {
modifiers.control = true;
} else if (comptime std.mem.eql(u8, modifier, "option") or std.mem.eql(u8, modifier, "alt")) {
modifiers.option = true;
} else if (comptime std.mem.eql(u8, modifier, "shift")) {
modifiers.shift = true;
} else {
@compileError("unknown app.zon shortcut modifier");
}
}
return modifiers;
}
pub fn runWithOptions(app: native_sdk.App, options: RunOptions, init: std.process.Init) !void {
if (comptime std.mem.eql(u8, build_options.platform, "macos")) {
try runMacos(app, options, init);
} else if (comptime std.mem.eql(u8, build_options.platform, "linux")) {
try runLinux(app, options, init);
} else if (comptime std.mem.eql(u8, build_options.platform, "windows")) {
try runWindows(app, options, init);
} else {
try runNull(app, options, init);
}
}
fn runNull(app: native_sdk.App, options: RunOptions, init: std.process.Init) !void {
var buffers: StateBuffers = undefined;
const app_info = options.appInfo(&buffers);
var null_platform = native_sdk.NullPlatform.initWithOptions(.{}, webEngine(), app_info);
try runRuntime(app, options, init, null_platform.platform());
}
fn runMacos(app: native_sdk.App, options: RunOptions, init: std.process.Init) !void {
var buffers: StateBuffers = undefined;
const app_info = options.appInfo(&buffers);
var mac_platform = try native_sdk.platform.macos.MacPlatform.initWithOptions(native_sdk.geometry.SizeF.init(1120, 780), webEngine(), app_info);
defer mac_platform.deinit();
try runRuntime(app, options, init, mac_platform.platform());
}
fn runLinux(app: native_sdk.App, options: RunOptions, init: std.process.Init) !void {
var buffers: StateBuffers = undefined;
const app_info = options.appInfo(&buffers);
var linux_platform = try native_sdk.platform.linux.LinuxPlatform.initWithOptions(native_sdk.geometry.SizeF.init(960, 720), webEngine(), app_info);
defer linux_platform.deinit();
try runRuntime(app, options, init, linux_platform.platform());
}
fn runWindows(app: native_sdk.App, options: RunOptions, init: std.process.Init) !void {
var buffers: StateBuffers = undefined;
const app_info = options.appInfo(&buffers);
var windows_platform = try native_sdk.platform.windows.WindowsPlatform.initWithOptions(native_sdk.geometry.SizeF.init(960, 720), webEngine(), app_info);
defer windows_platform.deinit();
try runRuntime(app, options, init, windows_platform.platform());
}
fn runRuntime(app: native_sdk.App, options: RunOptions, init: std.process.Init, platform: native_sdk.Platform) !void {
var shortcut_storage: ShortcutStorage = .{};
const shortcuts = options.resolvedShortcuts(&shortcut_storage);
// The Runtime is tens of megabytes; construct on the heap (default
// main-thread stacks overflow on a stack instance).
const runtime = try std.heap.page_allocator.create(native_sdk.Runtime);
defer std.heap.page_allocator.destroy(runtime);
native_sdk.Runtime.initAt(runtime, .{
.platform = platform,
.bridge = options.bridge,
.builtin_bridge = options.builtin_bridge,
.security = options.security,
.shortcuts = shortcuts,
.automation = if (build_options.automation) native_sdk.automation.Server.init(init.io, ".zig-cache/native-sdk-automation", options.window_title) else null,
});
try runtime.run(app);
}
fn webEngine() native_sdk.WebEngine {
if (comptime std.mem.eql(u8, build_options.web_engine, "chromium")) return .chromium;
return .system;
}
+63
View File
@@ -0,0 +1,63 @@
# Native SDK calculator example
A real four-function calculator built to showcase precision Native SDK layout: the classic keypad grid with exact 66x54 keys, a live expression + result display with the last calculation remembered above it, full keyboard input, and a chromeless window that is nothing but keys and digits. The window is fixed at 320x490 — every frame in it is deliberate, and the test suite asserts the keypad's frames to the point.
## Design brief
The calculator is a precision instrument in the house register: pure neutrals, hairline borders, and exactly one accent — action blue — that appears only when something is live. Nothing at rest is colored.
- **Surfaces.** Paper white and true graphite. Light: white keys lifted off a near-white window by hairlines. Dark: graphite keys on near-black. Key faces separate by one gray step, never by shadows.
- **The strong column.** The operator column and equals are the inverted monochrome keys — near-black faces with white glyphs in light mode, near-white faces with dark glyphs in dark mode. The board reads black-and-white until an operator goes pending, when that key fills with the accent.
- **One accent.** Action blue marks live state only: the pending operator, the press flash on any key, the focus ring on the expression line. It is the sole color in the app.
- **The readout.** The result is tight monospace digits (the bundled mono face), right-aligned, the loudest thing in the window; the memory line above it is the same mono two sizes down. Digits align column-over-column as you type.
- **No mark, no chrome.** Hidden-inset titlebar; the top band is an empty window drag region. No logo, no wordmark, no placeholder text, no app-name label, no theme control — the identity comes from the craft: the readout, the key rhythm, the tight mono numerals. The app follows the system appearance live.
## Arithmetic model (documented, tested)
**Immediate execution**, the model every desk calculator uses: `2 + 3 × 4 =` is `(2 + 3) × 4 = 20` — each operator applies the one before it, there is no precedence. On top of that:
- Chained operators evaluate live (`2 + 3 ×` shows `5` the moment × lands); pressing a second operator with no operand just switches it.
- `=` repeats: `2 + 3 = = =` walks 5, 8, 11. `5 + =` uses the display as the missing operand (10).
- `%` divides the current operand by 100 (no additive-percent special case — that is the whole rule).
- `±` negates the entry while typing, or the standing value otherwise.
- Backspace edits the number being typed, down to `0` and never past it; results are not editable.
- Division by zero (and any non-finite result, including `0 ÷ 0`) shows **Error** with the failing calculation in the expression line; operators go inert until AC — or any digit, which starts fresh.
All arithmetic is f64 with honest display formatting (`formatValue`): integers print exactly up to 12 digits, fractions round to at most 10 decimals for display only (the model keeps full precision — `0.1 + 0.2` shows `0.3`, continues as the exact sum), and anything beyond the 12-digit window prints in scientific notation. Typed entries cap at 12 significant digits, like the desk calculators the model imitates.
## Keyboard (the seam, documented)
The expression line is a real `text_field` and it is the app's keyboard path: click it (or Tab to it) and digits, `+ - * x / . , % =`, backspace, and enter all flow through the widget keyboard path as `TextInputEvent`s that `update` parses into calculator keys — the field's text is model-derived, so unknown characters can never appear. `c` clears. **Escape is a chrome shortcut** (`native_sdk.Shortcut`, mapped through `on_command`) so AC works with no widget focused at all; unmodified character keys deliberately cannot be chrome shortcuts, which is why the text-entry seam carries them.
## Authoring split (markup-first)
- `src/keypad.native` — the entire keypad, key by key: function keys `secondary`, the operator column and equals `primary` (the inverted monochrome column), digits default surfaces, the pending operator highlighted via a model-sourced `selected=`. Markup message payloads are bindings, so each key dispatches its own void `Msg` arm — which also reads exactly like the keypad it is.
- `src/view.zig` — the Zig-only sections: the drag band (hidden-inset titlebar, `window_drag`, deliberately empty) and the display block, because the big result line needs a scaled, right-aligned monospace paragraph (markup text tops out at the `lg` body size). Also documented there: text fields are start-aligned by the engine (caret math), so the expression line stays left-aligned.
- `src/model.zig` — the whole engine and the **plain-form TEA update**: no effects, no timers, no I/O. This is the smallest real Native SDK app shape.
- `src/theme.zig` — the neutral palettes for both modes, the inverted-monochrome operator column, and the one blue accent through `controls.button_primary.active_background`; high-contrast falls back to the framework palettes, and keypad glyphs render at 18px via `typography.button_size`.
## Run
```sh
native dev
```
Click the expression line and type `12+7⏎`, or press the keys. Escape clears from anywhere. The app follows the system appearance live — flip the OS to dark and the board follows.
Run the deterministic suite (exhaustive arithmetic through `msgForPointer` on every key, keyboard through real `gpu_surface_input` events, the Escape shortcut through the platform event path, formatting, theming, markup engine parity, snapshot assertions, and the exact-frame keypad layout check):
```sh
native test -Dplatform=null
```
Verify live through the automation harness:
```sh
native build -Dautomation=true
./zig-out/bin/calculator &
native automate assert 'gpu_nonblank=true' 'role=button name="Equals"' 'role=textbox name="Expression"'
# Keyboard rides the focused expression field: focus it (widget-click its
# id from the snapshot), then type 9 × 9 ⏎ and watch the result land.
native automate widget-key calc-canvas 9 9 && native automate widget-key calc-canvas x x && native automate widget-key calc-canvas 9 9 && native automate widget-key calc-canvas enter
native automate assert 'role=text name="81"'
```
+38
View File
@@ -0,0 +1,38 @@
.{
.id = "dev.native_sdk.calculator",
.name = "calculator",
.display_name = "Calculator",
.description = "A keyboard-first calculator in a compact fixed window.",
.version = "0.1.0",
.platforms = .{"macos"},
.permissions = .{ "view", "command" },
.capabilities = .{ "native_views", "gpu_surfaces" },
.shortcuts = .{
.{ .id = "clear", .key = "escape" },
},
.shell = .{
.windows = .{
.{
.label = "main",
.title = "Calculator",
.width = 320,
.height = 490,
.resizable = false,
.restore_state = false,
.restore_policy = "center_on_primary",
.titlebar = "hidden_inset",
.views = .{
.{ .label = "calc-canvas", .kind = "gpu_surface", .fill = true, .role = "Calculator canvas", .accessibility_label = "Calculator", .gpu_backend = "metal", .gpu_pixel_format = "bgra8_unorm", .gpu_present_mode = "timer", .gpu_alpha_mode = "opaque", .gpu_color_space = "srgb", .gpu_vsync = true },
},
},
},
},
.security = .{
.navigation = .{
.allowed_origins = .{ "zero://app", "zero://inline" },
.external_links = .{ .action = "deny" },
},
},
.web_engine = "system",
.cef = .{ .dir = "third_party/cef/macos", .auto_install = false },
}
+40
View File
@@ -0,0 +1,40 @@
<!-- The calculator keypad: the classic four-column grid, spelled out
key by key (markup message payloads are bindings, so every key
dispatches its own void Msg arm — which also reads exactly like the
keypad it is). Function keys are `secondary`, the operator column and
equals are `primary` (the inverted monochrome column), digits are
default surfaces. The pending operator highlights through
`selected=` — the one accent — sourced from the model so it survives
rebuilds. Key metrics (66x54, gap 8, the wide 140 zero) line up with
the display width in view.zig: 4*66 + 3*8 = 288 points. -->
<column gap="8" label="Keypad">
<row gap="8">
<button width="66" height="54" variant="secondary" on-press="clear" label="All clear">AC</button>
<button width="66" height="54" variant="secondary" on-press="negate" label="Toggle sign">±</button>
<button width="66" height="54" variant="secondary" on-press="percent" label="Percent">%</button>
<button width="66" height="54" variant="primary" selected="{dividePending}" on-press="divide" label="Divide">÷</button>
</row>
<row gap="8">
<button width="66" height="54" on-press="d7">7</button>
<button width="66" height="54" on-press="d8">8</button>
<button width="66" height="54" on-press="d9">9</button>
<button width="66" height="54" variant="primary" selected="{multiplyPending}" on-press="multiply" label="Multiply">×</button>
</row>
<row gap="8">
<button width="66" height="54" on-press="d4">4</button>
<button width="66" height="54" on-press="d5">5</button>
<button width="66" height="54" on-press="d6">6</button>
<button width="66" height="54" variant="primary" selected="{subtractPending}" on-press="subtract" label="Subtract"></button>
</row>
<row gap="8">
<button width="66" height="54" on-press="d1">1</button>
<button width="66" height="54" on-press="d2">2</button>
<button width="66" height="54" on-press="d3">3</button>
<button width="66" height="54" variant="primary" selected="{addPending}" on-press="add" label="Add">+</button>
</row>
<row gap="8">
<button width="140" height="54" on-press="d0" label="Zero">0</button>
<button width="66" height="54" on-press="dot" label="Decimal point">.</button>
<button width="66" height="54" variant="primary" on-press="equals" label="Equals">=</button>
</row>
</column>
+167
View File
@@ -0,0 +1,167 @@
//! calculator: a real four-function calculator showcasing precision
//! Native SDK layout — the classic keypad grid, a live expression +
//! result display, keyboard input, and a chromeless hidden-inset window
//! in the house register: pure neutrals, one action-blue accent.
//!
//! Authoring split (markup-first): the entire keypad is a `.native` view
//! compiled at comptime; the Zig views are the drag band (the
//! hidden-inset titlebar's drag region, deliberately empty) and the
//! display block, whose right-aligned mono result paragraph needs
//! monospace and weight spans markup does not carry (its size is the
//! display typography rung markup shares). See `src/view.zig`.
//!
//! Keyboard: the expression line is a real `text_field` — click it (or
//! Tab to it) and digits, operators, backspace, and enter flow through
//! the widget keyboard path as calculator keys. Escape is a chrome
//! shortcut (`clear`) so AC works from anywhere, focused or not; plain
//! character keys cannot be chrome shortcuts by design, which is why the
//! text-entry seam carries them.
//!
//! Update is the plain TEA form: no effects, no timers, no I/O — the
//! smallest real Native SDK app shape.
const std = @import("std");
const runner = @import("runner");
const native_sdk = @import("native_sdk");
pub const panic = std.debug.FullPanic(native_sdk.debug.capturePanic);
const canvas = native_sdk.canvas;
const geometry = native_sdk.geometry;
const model_mod = @import("model.zig");
const theme = @import("theme.zig");
const view_mod = @import("view.zig");
pub const Model = model_mod.Model;
pub const Msg = model_mod.Msg;
pub const update = model_mod.update;
pub const rootView = view_mod.rootView;
pub const canvas_label = "calc-canvas";
pub const window_width: f32 = 320;
pub const window_height: f32 = 490;
const app_permissions = [_][]const u8{ native_sdk.security.permission_command, native_sdk.security.permission_view };
const shell_views = [_]native_sdk.ShellView{
.{ .label = canvas_label, .kind = .gpu_surface, .fill = true, .role = "Calculator canvas", .accessibility_label = "Calculator", .gpu_backend = .metal, .gpu_pixel_format = .bgra8_unorm, .gpu_present_mode = .timer, .gpu_alpha_mode = .@"opaque", .gpu_color_space = .srgb, .gpu_vsync = true },
};
const shell_windows = [_]native_sdk.ShellWindow{.{
.label = "main",
.title = "Calculator",
.width = window_width,
.height = window_height,
// Precision layout: the keypad grid is sized in points, so the
// window is fixed (like every calculator worth using). No titlebar:
// the in-canvas drag band carries the window (see view.zig), and
// app.zon's startup window declares the same style.
.resizable = false,
.restore_state = false,
.titlebar = .hidden_inset,
.views = &shell_views,
}};
pub const shell_scene: native_sdk.ShellConfig = .{ .windows = &shell_windows };
/// Chrome shortcuts: Escape clears from anywhere (no widget focus
/// needed). Character keys deliberately cannot be unmodified chrome
/// shortcuts, so digits/operators ride the expression field instead.
pub const app_shortcuts = [_]native_sdk.Shortcut{
.{ .id = "clear", .key = "escape" },
};
pub fn onCommand(name: []const u8) ?Msg {
if (std.mem.eql(u8, name, "clear")) return .clear;
return null;
}
// -------------------------------------------------------------------- app
pub const CalculatorApp = native_sdk.UiApp(Model, Msg);
/// App-registered fonts (the registered-font seam): the display face
/// behind `theme.display_font_id`, registered on the installing frame so
/// the first layout already measures with it. The bytes are the
/// SDK-bundled Geist Mono face — a real app would `@embedFile` its own
/// asset here; the registration path is identical.
const app_fonts = [_]CalculatorApp.FontRegistration{.{
.id = theme.display_font_id,
.name = "GeistMono-Regular.ttf",
.ttf = canvas.font_ttf.geist_mono_bytes,
}};
pub fn calculatorOptions() CalculatorApp.Options {
return .{
.name = "calculator",
.scene = shell_scene,
.canvas_label = canvas_label,
.update = update,
.view = rootView,
.tokens_fn = tokensFromModel,
.fonts = &app_fonts,
.on_appearance = onAppearance,
.on_command = onCommand,
};
}
/// Design tokens derive from the model's theme preference plus the
/// OS-reported appearance (scheme, contrast, reduced motion).
pub fn tokensFromModel(model: *const Model) canvas.DesignTokens {
return theme.tokens(model.colorScheme(), model.appearance.high_contrast, model.appearance.reduce_motion);
}
/// System appearance changes land in the model so `tokens_fn` re-derives;
/// the `auto` theme preference follows them live.
fn onAppearance(appearance: native_sdk.Appearance) ?Msg {
return Msg{ .set_appearance = appearance };
}
// ----------------------------------------------------------------- mobile
/// Mobile embed seam (compiled when a build wires this app through the
/// framework's `addMobileLib` helper — see examples/ui-inbox for a build
/// that keeps such a `lib` step): the same Model/Msg/update/view compiled
/// into the embed static library with the canonical single-surface mobile
/// scene.
pub fn initModel() Model {
return .{};
}
pub fn mobileOptions() CalculatorApp.Options {
return .{
.name = "calculator",
.scene = native_sdk.embed.mobile_shell_scene,
.canvas_label = native_sdk.embed.mobile_gpu_surface_label,
.update = update,
.view = rootView,
.tokens_fn = tokensFromModel,
.fonts = &app_fonts,
.on_appearance = onAppearance,
.on_command = onCommand,
};
}
// ------------------------------------------------------------------- main
pub fn main(init: std.process.Init) !void {
const app_state = try std.heap.page_allocator.create(CalculatorApp);
defer std.heap.page_allocator.destroy(app_state);
app_state.* = CalculatorApp.init(std.heap.page_allocator, .{}, calculatorOptions());
defer app_state.deinit();
try runner.runWithOptions(app_state.app(), .{
.app_name = "calculator",
.window_title = "Calculator",
.bundle_id = "dev.native_sdk.calculator",
.default_frame = geometry.RectF.init(0, 0, window_width, window_height),
.restore_state = false,
.js_window_api = false,
.shortcuts = &app_shortcuts,
.security = .{
.permissions = &app_permissions,
.navigation = .{ .allowed_origins = &.{ "zero://inline", "zero://app" } },
},
}, init);
}
test {
_ = @import("tests.zig");
}
+524
View File
@@ -0,0 +1,524 @@
//! calculator model: a classic immediate-execution four-function
//! calculator (the model every desk calculator uses: `2 + 3 × 4 =` is
//! `(2 + 3) × 4 = 20` — each operator applies the one before it; there is
//! no precedence). All arithmetic is f64; display formatting is honest
//! about it (see `formatValue`): integers print exactly up to 12 digits,
//! fractions round to at most 10 decimals for DISPLAY only (the model
//! keeps full precision), and anything non-finite or beyond the 12-digit
//! window prints in scientific notation or as "Error".
//!
//! Update is the plain TEA form — no effects, no timers, no I/O. The
//! whole app is a pure `(Model, Msg) -> Model` fold, which is the point:
//! this is the smallest real Native SDK app.
//!
//! Fixed capacities (loud by design):
//! - 12 significant digits per typed entry (extra digits are ignored,
//! exactly like a 12-digit desk calculator)
//! - one pending operator + one remembered "repeat equals" operation
const std = @import("std");
const native_sdk = @import("native_sdk");
const canvas = native_sdk.canvas;
// ---------------------------------------------------------------- engine
pub const Op = enum {
add,
subtract,
multiply,
divide,
pub fn glyph(op: Op) []const u8 {
return switch (op) {
.add => "+",
.subtract => "",
.multiply => "×",
.divide => "÷",
};
}
pub fn apply(op: Op, a: f64, b: f64) f64 {
return switch (op) {
.add => a + b,
.subtract => a - b,
.multiply => a * b,
// 0/0 is NaN and x/0 is ±inf; both format as "Error".
.divide => a / b,
};
}
};
/// Digits a user may type into one number: the classic 12-digit window.
pub const max_entry_digits = 12;
/// The number being typed, kept as the typed characters (not an f64) so
/// "0.10" round-trips exactly as typed and backspace is a char pop.
pub const Entry = struct {
/// Digit and dot characters in typed order ("12.5"); the sign lives
/// in `negative` so ± never rewrites the buffer.
chars: [max_entry_digits + 1]u8 = undefined,
len: usize = 0,
negative: bool = false,
pub fn reset(entry: *Entry) void {
entry.len = 0;
entry.negative = false;
}
pub fn digitCount(entry: *const Entry) usize {
var count: usize = 0;
for (entry.chars[0..entry.len]) |char| {
if (char != '.') count += 1;
}
return count;
}
pub fn hasDot(entry: *const Entry) bool {
return std.mem.indexOfScalar(u8, entry.chars[0..entry.len], '.') != null;
}
/// Append one digit. A lone "0" is replaced rather than extended
/// (typing 0 0 7 shows 7, not 007); past 12 digits input is ignored.
pub fn pushDigit(entry: *Entry, digit: u8) void {
if (entry.digitCount() >= max_entry_digits) return;
if (entry.len == 1 and entry.chars[0] == '0') {
entry.chars[0] = '0' + digit;
return;
}
entry.chars[entry.len] = '0' + digit;
entry.len += 1;
}
/// Append the decimal point; a leading dot becomes "0." and a second
/// dot is ignored.
pub fn pushDot(entry: *Entry) void {
if (entry.hasDot()) return;
if (entry.len == 0) {
entry.chars[0] = '0';
entry.len = 1;
}
if (entry.len + 1 > entry.chars.len) return;
entry.chars[entry.len] = '.';
entry.len += 1;
}
pub fn pop(entry: *Entry) void {
if (entry.len > 0) entry.len -= 1;
}
pub fn value(entry: *const Entry) f64 {
var text_slice = entry.chars[0..entry.len];
// A trailing dot ("12.") is still mid-entry; parse without it.
if (text_slice.len > 0 and text_slice[text_slice.len - 1] == '.') {
text_slice = text_slice[0 .. text_slice.len - 1];
}
const magnitude = if (text_slice.len == 0) 0 else std.fmt.parseFloat(f64, text_slice) catch 0;
return if (entry.negative) -magnitude else magnitude;
}
/// The display form: sign + typed characters, "0" when empty.
pub fn text(entry: *const Entry, arena: std.mem.Allocator) []const u8 {
const digits: []const u8 = if (entry.len == 0) "0" else entry.chars[0..entry.len];
if (!entry.negative) return arena.dupe(u8, digits) catch "0";
return std.fmt.allocPrint(arena, "-{s}", .{digits}) catch "0";
}
};
/// One completed calculation, kept as operands so the memory line
/// reformats from source values, never from stored strings.
pub const Calculation = struct {
a: f64,
op: Op,
b: f64,
failed: bool,
};
// ----------------------------------------------------------------- model
pub const Key = enum {
d0,
d1,
d2,
d3,
d4,
d5,
d6,
d7,
d8,
d9,
dot,
add,
subtract,
multiply,
divide,
equals,
percent,
negate,
clear,
backspace,
};
pub const Msg = union(enum) {
// One void arm per calculator key: markup message payloads are
// bindings, not literals, so each keypad button declares its own tag.
d0,
d1,
d2,
d3,
d4,
d5,
d6,
d7,
d8,
d9,
dot,
add,
subtract,
multiply,
divide,
equals,
percent,
negate,
clear,
/// Keyboard input through the expression field (the widget keyboard
/// path): every typed character and backspace arrives here.
typed: canvas.TextInputEvent,
/// System appearance (scheme, contrast, reduced motion) flowing in
/// through `on_appearance`; the app follows it live.
set_appearance: native_sdk.Appearance,
};
pub const Model = struct {
// Engine state.
acc: f64 = 0,
pending: ?Op = null,
entry: Entry = .{},
entry_active: bool = false,
/// The standing value shown when nothing is being typed (a result,
/// an operand echo, or 0 at boot).
value: f64 = 0,
err: bool = false,
/// The last completed calculation (the memory line). Also holds the
/// failing calculation while `err` is set.
last: ?Calculation = null,
/// Pressing = again repeats the last operation on the current value
/// (classic behavior: 2 + 3 = = = walks 5, 8, 11).
repeat: ?struct { op: Op, operand: f64 } = null,
// Appearance state (the app follows the system; no in-window theme
// control by design).
appearance: native_sdk.Appearance = .{},
// ------------------------------------------------------------ queries
pub fn colorScheme(model: *const Model) native_sdk.ColorScheme {
return model.appearance.color_scheme;
}
fn currentOperand(model: *const Model) f64 {
return if (model.entry_active) model.entry.value() else model.value;
}
/// The big display line.
pub fn displayText(model: *const Model, arena: std.mem.Allocator) []const u8 {
if (model.err) return "Error";
if (model.entry_active) return model.entry.text(arena);
return fmtValue(arena, model.value);
}
/// The live expression (the text field's content): the pending
/// operation as it builds, or the failing one while in error.
pub fn expressionText(model: *const Model, arena: std.mem.Allocator) []const u8 {
if (model.err) {
const calc = model.last orelse return "";
return std.fmt.allocPrint(arena, "{s} {s} {s}", .{
fmtValue(arena, calc.a), calc.op.glyph(), fmtValue(arena, calc.b),
}) catch "";
}
const op = model.pending orelse return "";
if (model.entry_active) {
return std.fmt.allocPrint(arena, "{s} {s} {s}", .{
fmtValue(arena, model.acc), op.glyph(), model.entry.text(arena),
}) catch "";
}
return std.fmt.allocPrint(arena, "{s} {s}", .{ fmtValue(arena, model.acc), op.glyph() }) catch "";
}
/// The memory line above the display: the last completed calculation.
pub fn memoryText(model: *const Model, arena: std.mem.Allocator) []const u8 {
const calc = model.last orelse return "";
const result: []const u8 = if (calc.failed) "Error" else fmtValue(arena, calc.op.apply(calc.a, calc.b));
return std.fmt.allocPrint(arena, "{s} {s} {s} = {s}", .{
fmtValue(arena, calc.a), calc.op.glyph(), fmtValue(arena, calc.b), result,
}) catch "";
}
// Operator highlights for the keypad (`selected=` bindings).
pub fn addPending(model: *const Model) bool {
return model.pending == .add and !model.err;
}
pub fn subtractPending(model: *const Model) bool {
return model.pending == .subtract and !model.err;
}
pub fn multiplyPending(model: *const Model) bool {
return model.pending == .multiply and !model.err;
}
pub fn dividePending(model: *const Model) bool {
return model.pending == .divide and !model.err;
}
// ------------------------------------------------------------ pressing
pub fn press(model: *Model, key: Key) void {
switch (key) {
.d0 => model.pressDigit(0),
.d1 => model.pressDigit(1),
.d2 => model.pressDigit(2),
.d3 => model.pressDigit(3),
.d4 => model.pressDigit(4),
.d5 => model.pressDigit(5),
.d6 => model.pressDigit(6),
.d7 => model.pressDigit(7),
.d8 => model.pressDigit(8),
.d9 => model.pressDigit(9),
.dot => model.pressDot(),
.add => model.pressOp(.add),
.subtract => model.pressOp(.subtract),
.multiply => model.pressOp(.multiply),
.divide => model.pressOp(.divide),
.equals => model.pressEquals(),
.percent => model.pressPercent(),
.negate => model.pressNegate(),
.clear => model.clearAll(),
.backspace => model.pressBackspace(),
}
}
/// A digit clears an error and starts fresh (friendlier than the
/// classic AC-only recovery; AC works too).
fn pressDigit(model: *Model, digit: u8) void {
if (model.err) model.clearAll();
model.startEntryIfNeeded();
model.entry.pushDigit(digit);
}
fn pressDot(model: *Model) void {
if (model.err) model.clearAll();
model.startEntryIfNeeded();
model.entry.pushDot();
}
fn startEntryIfNeeded(model: *Model) void {
if (model.entry_active) return;
model.entry.reset();
model.entry_active = true;
}
fn pressOp(model: *Model, op: Op) void {
if (model.err) return;
if (model.pending) |pending_op| {
if (model.entry_active) {
// Chain: apply the previous operator first (immediate
// execution), then wait for the next operand.
if (!model.commit(model.acc, pending_op, model.entry.value())) return;
}
// No operand typed: the press just switches the operator.
} else {
model.acc = model.currentOperand();
model.value = model.acc;
}
model.pending = op;
model.entry_active = false;
model.repeat = null;
}
fn pressEquals(model: *Model) void {
if (model.err) return;
if (model.pending) |op| {
// "5 + =" uses the display value as the missing operand,
// exactly like a desk calculator.
const operand = model.currentOperand();
model.repeat = .{ .op = op, .operand = operand };
model.pending = null;
_ = model.commit(model.acc, op, operand);
return;
}
if (model.repeat) |again| {
_ = model.commit(model.currentOperand(), again.op, again.operand);
return;
}
// Bare equals: normalize whatever is typed into the value.
model.value = model.currentOperand();
model.entry_active = false;
}
/// Apply one operation, record it as the memory line, and either
/// adopt the result or enter the error state. Returns false on error.
fn commit(model: *Model, a: f64, op: Op, b: f64) bool {
const result = op.apply(a, b);
const failed = !std.math.isFinite(result);
model.last = .{ .a = a, .op = op, .b = b, .failed = failed };
model.entry_active = false;
if (failed) {
model.err = true;
model.pending = null;
model.repeat = null;
return false;
}
model.acc = result;
model.value = result;
return true;
}
/// Percent divides the current operand by 100 (documented model: no
/// additive-percent special case).
fn pressPercent(model: *Model) void {
if (model.err) return;
model.value = model.currentOperand() / 100;
model.entry_active = false;
}
fn pressNegate(model: *Model) void {
if (model.err) return;
if (model.entry_active) {
model.entry.negative = !model.entry.negative;
return;
}
model.value = -model.value;
if (model.pending == null) model.acc = model.value;
}
/// Backspace edits the number being typed; results and echoes are
/// not editable (classic behavior).
fn pressBackspace(model: *Model) void {
if (model.err) return;
if (!model.entry_active) return;
model.entry.pop();
}
pub fn clearAll(model: *Model) void {
model.acc = 0;
model.pending = null;
model.entry.reset();
model.entry_active = false;
model.value = 0;
model.err = false;
model.last = null;
model.repeat = null;
}
// ------------------------------------------------------------ keyboard
/// One typed character from the expression field. Unknown characters
/// are ignored; the model-owned field text never shows them.
pub fn applyChar(model: *Model, char: u21) void {
switch (char) {
'0'...'9' => model.press(@enumFromInt(@intFromEnum(Key.d0) + (char - '0'))),
'.', ',' => model.press(.dot),
'+' => model.press(.add),
'-', '' => model.press(.subtract),
'*', 'x', 'X', '×' => model.press(.multiply),
'/', '÷' => model.press(.divide),
'%' => model.press(.percent),
'=' => model.press(.equals),
'c', 'C' => model.press(.clear),
else => {},
}
}
pub fn applyTyped(model: *Model, edit: canvas.TextInputEvent) void {
switch (edit) {
.insert_text => |text_bytes| {
var iterator = std.unicode.Utf8Iterator{ .bytes = text_bytes, .i = 0 };
while (iterator.nextCodepoint()) |char| model.applyChar(char);
},
.delete_backward, .delete_word_backward => model.press(.backspace),
// Caret moves, selections, and forward deletes have no
// calculator meaning; the field text is model-derived, so
// ignoring them keeps it stable.
else => {},
}
}
};
// ---------------------------------------------------------------- update
pub fn update(model: *Model, msg: Msg) void {
switch (msg) {
.d0 => model.press(.d0),
.d1 => model.press(.d1),
.d2 => model.press(.d2),
.d3 => model.press(.d3),
.d4 => model.press(.d4),
.d5 => model.press(.d5),
.d6 => model.press(.d6),
.d7 => model.press(.d7),
.d8 => model.press(.d8),
.d9 => model.press(.d9),
.dot => model.press(.dot),
.add => model.press(.add),
.subtract => model.press(.subtract),
.multiply => model.press(.multiply),
.divide => model.press(.divide),
.equals => model.press(.equals),
.percent => model.press(.percent),
.negate => model.press(.negate),
.clear => model.press(.clear),
.typed => |edit| model.applyTyped(edit),
.set_appearance => |appearance| model.appearance = appearance,
}
}
// ------------------------------------------------------------ formatting
/// Longest formatted value: sign + 12 integer digits + dot + 10 decimals,
/// or a scientific form — 32 bytes covers both with slack.
pub const max_value_chars = 32;
/// Honest f64 display formatting:
/// - non-finite -> "Error"
/// - integers with |v| < 1e12 print exactly ("84", "-2048")
/// - |v| >= 1e12 or 0 < |v| < 1e-9 print in scientific notation
/// - everything else prints with up to 10 decimals, trailing zeros
/// trimmed ("0.3", "3.1415926536") — display rounding only, the
/// model keeps full f64 precision
pub fn formatValue(buffer: []u8, value: f64) []const u8 {
if (!std.math.isFinite(value)) return "Error";
// Normalize negative zero for display.
const v = if (value == 0) 0 else value;
const magnitude = @abs(v);
if (v == @trunc(v) and magnitude < 1e12) {
return std.fmt.bufPrint(buffer, "{d:.0}", .{v}) catch "Error";
}
if (magnitude >= 1e12 or magnitude < 1e-9) {
const printed = std.fmt.bufPrint(buffer, "{e:.6}", .{v}) catch return "Error";
return trimScientific(printed);
}
const printed = std.fmt.bufPrint(buffer, "{d:.10}", .{v}) catch return "Error";
return trimFraction(printed);
}
pub fn fmtValue(arena: std.mem.Allocator, value: f64) []const u8 {
var buffer: [max_value_chars]u8 = undefined;
return arena.dupe(u8, formatValue(&buffer, value)) catch "";
}
/// "0.3000000000" -> "0.3"; "3.0000000000" -> "3".
fn trimFraction(printed: []u8) []const u8 {
const dot = std.mem.indexOfScalar(u8, printed, '.') orelse return printed;
var end = printed.len;
while (end > dot + 1 and printed[end - 1] == '0') end -= 1;
if (end == dot + 1) end = dot;
return printed[0..end];
}
/// "1.500000e12" -> "1.5e12"; "1.000000e15" -> "1e15".
fn trimScientific(printed: []u8) []const u8 {
const e_index = std.mem.indexOfScalar(u8, printed, 'e') orelse return printed;
const mantissa = trimFraction(printed[0..e_index]);
const exponent = printed[e_index..];
// The mantissa was trimmed in place; move the exponent up against it.
std.mem.copyForwards(u8, printed[mantissa.len..], exponent);
return printed[0 .. mantissa.len + exponent.len];
}
+769
View File
@@ -0,0 +1,769 @@
//! calculator tests: exhaustive arithmetic through the real dispatch
//! paths — every keypad button via `msgForPointer`, keyboard input via
//! real `gpu_surface_input` events through the runtime's focus + text
//! routing, the Escape chrome shortcut through the platform event path —
//! plus formatting, theming, markup engine parity, automation snapshot
//! assertions, and an exact-frame precision check of the keypad grid.
const std = @import("std");
const native_sdk = @import("native_sdk");
const main = @import("main.zig");
const model_mod = @import("model.zig");
const theme = @import("theme.zig");
const view_mod = @import("view.zig");
const canvas = native_sdk.canvas;
const geometry = native_sdk.geometry;
const testing = std.testing;
const Model = main.Model;
const Msg = main.Msg;
const Ui = view_mod.Ui;
const App = main.CalculatorApp;
// ------------------------------------------------------------- tree utils
fn buildTree(arena: std.mem.Allocator, model: *const Model) !Ui.Tree {
var ui = Ui.init(arena);
return ui.finalizeWithTokens(view_mod.rootView(&ui, model), main.tokensFromModel(model));
}
fn findByText(widget: canvas.Widget, kind: canvas.WidgetKind, text: []const u8) ?canvas.Widget {
if (widget.kind == kind and std.mem.eql(u8, widget.text, text)) return widget;
for (widget.children) |child| {
if (findByText(child, kind, text)) |found| return found;
}
return null;
}
fn findByKind(widget: canvas.Widget, kind: canvas.WidgetKind) ?canvas.Widget {
if (widget.kind == kind) return widget;
for (widget.children) |child| {
if (findByKind(child, kind)) |found| return found;
}
return null;
}
/// Press one keypad button by its face text, through the tree's pointer
/// dispatch — exactly what a click resolves to.
const PressHarness = struct {
arena: std.mem.Allocator,
model: *Model,
fn press(self: PressHarness, face: []const u8) !void {
const tree = try buildTree(self.arena, self.model);
const button = findByText(tree.root, .button, face) orelse return error.ButtonNotFound;
const msg = tree.msgForPointer(button.id, .up) orelse return error.NoPointerMsg;
main.update(self.model, msg);
}
/// Press a whole sequence: "12.5+4=" presses each face in order
/// (multi-byte faces like × or ÷ are pressed via `press` directly).
fn presses(self: PressHarness, faces: []const []const u8) !void {
for (faces) |face| try self.press(face);
}
fn display(self: PressHarness) ![]const u8 {
return self.model.displayText(self.arena);
}
fn expression(self: PressHarness) ![]const u8 {
return self.model.expressionText(self.arena);
}
fn memory(self: PressHarness) ![]const u8 {
return self.model.memoryText(self.arena);
}
};
// -------------------------------------------------------------- app utils
const surface_size = geometry.SizeF.init(main.window_width, main.window_height);
const LiveApp = struct {
harness: *native_sdk.TestHarness(),
app_state: *App,
app: native_sdk.App,
fn start() !LiveApp {
const harness = try native_sdk.TestHarness().create(testing.allocator, .{ .size = surface_size });
errdefer harness.destroy(testing.allocator);
harness.null_platform.gpu_surfaces = true;
const app_state = try testing.allocator.create(App);
errdefer testing.allocator.destroy(app_state);
app_state.* = App.init(std.heap.page_allocator, .{}, main.calculatorOptions());
app_state.effects.executor = .fake;
const app = app_state.app();
try harness.start(app);
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{
.label = main.canvas_label,
.size = surface_size,
.scale_factor = 1,
.frame_index = 1,
.timestamp_ns = 1_000_000,
.nonblank = true,
} });
return .{ .harness = harness, .app_state = app_state, .app = app };
}
fn stop(self: LiveApp) void {
self.app_state.deinit();
testing.allocator.destroy(self.app_state);
self.harness.destroy(testing.allocator);
}
fn layoutNode(self: LiveApp, kind: canvas.WidgetKind) !canvas.WidgetLayoutNode {
const layout = try self.harness.runtime.canvasWidgetLayout(1, main.canvas_label);
for (layout.nodes) |node| {
if (node.widget.kind == kind) return node;
}
return error.WidgetNotFound;
}
/// Click at a point through the full input pipeline (down + up), the
/// same seam real pointer events use — including focus updates.
fn click(self: LiveApp, point: geometry.PointF) !void {
try self.harness.runtime.dispatchPlatformEvent(self.app, .{ .gpu_surface_input = .{
.label = main.canvas_label,
.kind = .pointer_down,
.x = point.x,
.y = point.y,
} });
try self.harness.runtime.dispatchPlatformEvent(self.app, .{ .gpu_surface_input = .{
.label = main.canvas_label,
.kind = .pointer_up,
.x = point.x,
.y = point.y,
} });
}
/// One keyboard key through the real input pipeline (focus routing,
/// keyboard + text-input events, app dispatch).
fn key(self: LiveApp, name: []const u8, text: []const u8) !void {
try self.harness.runtime.dispatchPlatformEvent(self.app, .{ .gpu_surface_input = .{
.label = main.canvas_label,
.kind = .key_down,
.key = name,
.text = text,
} });
}
fn typeChars(self: LiveApp, chars: []const u8) !void {
for (chars) |char| {
const text: [1]u8 = .{char};
try self.key(&text, &text);
}
}
fn displayText(self: LiveApp, arena: std.mem.Allocator) []const u8 {
return self.app_state.model.displayText(arena);
}
};
// ------------------------------------------------------------ arithmetic
test "every keypad button dispatches a typed message through msgForPointer" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
var model = Model{};
const h = PressHarness{ .arena = arena_state.allocator(), .model = &model };
// All 19 faces resolve to a pointer message.
const faces = [_][]const u8{ "AC", "±", "%", "÷", "7", "8", "9", "×", "4", "5", "6", "", "1", "2", "3", "+", "0", ".", "=" };
const tree = try buildTree(h.arena, &model);
for (faces) |face| {
const button = findByText(tree.root, .button, face) orelse return error.ButtonNotFound;
try testing.expect(tree.msgForPointer(button.id, .up) != null);
// A press phase alone never dispatches; release does.
try testing.expect(tree.msgForPointer(button.id, .down) == null);
}
// A real calculation, one button at a time: 12.5 + 4 = 16.5.
try h.presses(&.{ "1", "2", ".", "5" });
try testing.expectEqualStrings("12.5", try h.display());
try h.press("+");
try testing.expectEqualStrings("12.5 +", try h.expression());
try h.press("4");
try testing.expectEqualStrings("12.5 + 4", try h.expression());
try h.press("=");
try testing.expectEqualStrings("16.5", try h.display());
try testing.expectEqualStrings("12.5 + 4 = 16.5", try h.memory());
try testing.expectEqualStrings("", try h.expression());
}
test "immediate execution: chains apply left to right, no precedence" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
var model = Model{};
const h = PressHarness{ .arena = arena_state.allocator(), .model = &model };
// 2 + 3 × 4 = 20 (not 14): × applies the pending + first.
try h.presses(&.{ "2", "+", "3" });
try h.press("×");
try testing.expectEqualStrings("5", try h.display()); // intermediate shown live
try testing.expectEqualStrings("5 ×", try h.expression());
try h.presses(&.{ "4", "=" });
try testing.expectEqualStrings("20", try h.display());
try testing.expectEqualStrings("5 × 4 = 20", try h.memory());
// Pressing another operator with no operand just switches it.
model.clearAll();
try h.presses(&.{ "2", "+" });
try h.press("×");
try testing.expectEqualStrings("2 ×", try h.expression());
try h.presses(&.{ "3", "=" });
try testing.expectEqualStrings("6", try h.display());
// "5 + =" uses the display as the missing operand: 10.
model.clearAll();
try h.presses(&.{ "5", "+", "=" });
try testing.expectEqualStrings("10", try h.display());
// Repeat equals replays the last operation: 2 + 3 = 5, =, 8, =, 11.
model.clearAll();
try h.presses(&.{ "2", "+", "3", "=" });
try testing.expectEqualStrings("5", try h.display());
try h.press("=");
try testing.expectEqualStrings("8", try h.display());
try h.press("=");
try testing.expectEqualStrings("11", try h.display());
try testing.expectEqualStrings("8 + 3 = 11", try h.memory());
// A typed number then equals repeats onto it: 7 = -> 7 + 3 = 10.
try h.presses(&.{ "7", "=" });
try testing.expectEqualStrings("10", try h.display());
// Subtraction and division chains hold up: 100 30 ÷ 2 = 35.
model.clearAll();
try h.presses(&.{ "1", "0", "0", "", "3", "0", "÷", "2", "=" });
try testing.expectEqualStrings("35", try h.display());
}
test "decimal entry: single dot, leading zeros, twelve-digit window, backspace" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
var model = Model{};
const h = PressHarness{ .arena = arena_state.allocator(), .model = &model };
// A leading dot shows as "0." and a second dot is ignored.
try h.press(".");
try testing.expectEqualStrings("0.", try h.display());
try h.presses(&.{ "5", "." });
try testing.expectEqualStrings("0.5", try h.display());
// 0.1 + 0.2 displays 0.3 (display rounding; the model keeps f64).
model.clearAll();
try h.presses(&.{ ".", "1", "+", ".", "2", "=" });
try testing.expectEqualStrings("0.3", try h.display());
// Leading zeros collapse: 0 0 7 types as 7.
model.clearAll();
try h.presses(&.{ "0", "0", "7" });
try testing.expectEqualStrings("7", try h.display());
// The entry window is 12 digits; the 13th is ignored.
model.clearAll();
for (0..13) |_| try h.press("9");
try testing.expectEqualStrings("999999999999", try h.display());
// Backspace edits the typed entry down to a bare zero, never past it.
model.clearAll();
try h.presses(&.{ "1", "2", "3" });
model.press(.backspace);
try testing.expectEqualStrings("12", try h.display());
model.press(.backspace);
model.press(.backspace);
try testing.expectEqualStrings("0", try h.display());
model.press(.backspace);
try testing.expectEqualStrings("0", try h.display());
// Results are not editable: backspace after = is a no-op.
model.clearAll();
try h.presses(&.{ "8", "×", "8", "=" });
model.press(.backspace);
try testing.expectEqualStrings("64", try h.display());
}
test "divide by zero shows Error, freezes operators, and recovers" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
var model = Model{};
const h = PressHarness{ .arena = arena_state.allocator(), .model = &model };
try h.presses(&.{ "1", "2", "÷", "0", "=" });
try testing.expectEqualStrings("Error", try h.display());
try testing.expectEqualStrings("12 ÷ 0", try h.expression());
try testing.expectEqualStrings("12 ÷ 0 = Error", try h.memory());
// Operators, equals, percent, sign, and backspace are inert in error.
try h.presses(&.{ "+", "=", "%", "±" });
model.press(.backspace);
try testing.expectEqualStrings("Error", try h.display());
// A digit starts fresh; so does AC.
try h.press("4");
try testing.expectEqualStrings("4", try h.display());
try testing.expect(!model.err);
try h.presses(&.{ "÷", "0", "=" });
try testing.expectEqualStrings("Error", try h.display());
try h.press("AC");
try testing.expectEqualStrings("0", try h.display());
try testing.expectEqualStrings("", try h.memory());
// 0 ÷ 0 (NaN) is an error too, and errors surface mid-chain: the
// failing operator press itself shows Error.
try h.presses(&.{ "0", "÷", "0", "=" });
try testing.expectEqualStrings("Error", try h.display());
model.clearAll();
try h.presses(&.{ "5", "÷", "0", "+" });
try testing.expectEqualStrings("Error", try h.display());
}
test "percent and sign toggle on entries, results, and pending operands" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
var model = Model{};
const h = PressHarness{ .arena = arena_state.allocator(), .model = &model };
// Percent divides the current entry by 100 (the documented model).
try h.presses(&.{ "5", "0", "%" });
try testing.expectEqualStrings("0.5", try h.display());
// Percent of a result: 200 + 100 = 300, % -> 3.
model.clearAll();
try h.presses(&.{ "2", "0", "0", "+", "1", "0", "0", "=", "%" });
try testing.expectEqualStrings("3", try h.display());
// Percent then equals uses the percented value as the operand:
// 80 × 25 % = -> 80 × 0.25 = 20.
model.clearAll();
try h.presses(&.{ "8", "0", "×", "2", "5", "%", "=" });
try testing.expectEqualStrings("20", try h.display());
// Sign toggles the entry while typing (including a bare zero).
model.clearAll();
try h.presses(&.{ "4", "2", "±" });
try testing.expectEqualStrings("-42", try h.display());
try h.press("±");
try testing.expectEqualStrings("42", try h.display());
model.clearAll();
try h.press("±");
try testing.expectEqualStrings("0", try h.display());
// Sign toggles a result and the negated value feeds the next op.
model.clearAll();
try h.presses(&.{ "6", "×", "7", "=", "±" });
try testing.expectEqualStrings("-42", try h.display());
try h.presses(&.{ "+", "2", "=" });
try testing.expectEqualStrings("-40", try h.display());
// Sign while an operator is pending negates the standing operand.
model.clearAll();
try h.presses(&.{ "9", "+", "3", "±" });
try testing.expectEqualStrings("-3", try h.display());
try h.press("=");
try testing.expectEqualStrings("6", try h.display());
}
test "the pending operator highlights on the keypad and clears on equals" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
var model = Model{};
const h = PressHarness{ .arena = arena_state.allocator(), .model = &model };
try h.presses(&.{ "8", "×" });
var tree = try buildTree(h.arena, &model);
try testing.expect(findByText(tree.root, .button, "×").?.state.selected);
try testing.expect(!findByText(tree.root, .button, "+").?.state.selected);
try h.press("+");
tree = try buildTree(h.arena, &model);
try testing.expect(!findByText(tree.root, .button, "×").?.state.selected);
try testing.expect(findByText(tree.root, .button, "+").?.state.selected);
try h.presses(&.{ "2", "=" });
tree = try buildTree(h.arena, &model);
try testing.expect(!findByText(tree.root, .button, "+").?.state.selected);
}
// ------------------------------------------------------------ formatting
test "formatValue is honest about f64" {
var buffer: [model_mod.max_value_chars]u8 = undefined;
// Integers print exactly up to the 12-digit window.
try testing.expectEqualStrings("0", model_mod.formatValue(&buffer, 0));
try testing.expectEqualStrings("0", model_mod.formatValue(&buffer, -0.0));
try testing.expectEqualStrings("84", model_mod.formatValue(&buffer, 84));
try testing.expectEqualStrings("-2048", model_mod.formatValue(&buffer, -2048));
try testing.expectEqualStrings("999999999999", model_mod.formatValue(&buffer, 999999999999));
// Fractions trim to at most 10 decimals.
try testing.expectEqualStrings("0.3", model_mod.formatValue(&buffer, 0.1 + 0.2));
try testing.expectEqualStrings("0.5", model_mod.formatValue(&buffer, 0.5));
try testing.expectEqualStrings("-12.25", model_mod.formatValue(&buffer, -12.25));
try testing.expectEqualStrings("3.3333333333", model_mod.formatValue(&buffer, 10.0 / 3.0));
try testing.expectEqualStrings("3", model_mod.formatValue(&buffer, 3.0000000000000004));
// Beyond the window: scientific, with the mantissa trimmed.
try testing.expectEqualStrings("1e12", model_mod.formatValue(&buffer, 1e12));
try testing.expectEqualStrings("2.5e13", model_mod.formatValue(&buffer, 2.5e13));
try testing.expectEqualStrings("-1.5e15", model_mod.formatValue(&buffer, -1.5e15));
try testing.expectEqualStrings("1e-10", model_mod.formatValue(&buffer, 1e-10));
// Non-finite is an error, never digits.
try testing.expectEqualStrings("Error", model_mod.formatValue(&buffer, std.math.inf(f64)));
try testing.expectEqualStrings("Error", model_mod.formatValue(&buffer, -std.math.inf(f64)));
try testing.expectEqualStrings("Error", model_mod.formatValue(&buffer, std.math.nan(f64)));
}
// -------------------------------------------------------------- keyboard
test "keyboard input flows through the focused expression field" {
const live = try LiveApp.start();
defer live.stop();
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const model = &live.app_state.model;
// Unfocused, keystrokes go nowhere (the honest seam: focus first).
try live.typeChars("5");
try testing.expectEqualStrings("0", live.displayText(arena));
// Click the expression field to focus it, then type a calculation.
const field = try live.layoutNode(.text_field);
try live.click(geometry.PointF.init(
field.frame.x + field.frame.width / 2,
field.frame.y + field.frame.height / 2,
));
try live.typeChars("12+7");
try testing.expectEqualStrings("12 + 7", model.expressionText(arena));
try live.key("enter", "");
try testing.expectEqualStrings("19", live.displayText(arena));
// Backspace edits the entry through the same path.
try live.typeChars("305");
try live.key("backspace", "");
try testing.expectEqualStrings("30", live.displayText(arena));
// Operator aliases: * x / and = all mean what a keyboard means.
try live.typeChars("*4=");
try testing.expectEqualStrings("120", live.displayText(arena));
try live.typeChars("81/9=");
try testing.expectEqualStrings("9", live.displayText(arena));
try live.typeChars("6x7=");
try testing.expectEqualStrings("42", live.displayText(arena));
// Unknown characters are ignored; the model text never shows them.
try live.typeChars("e#5");
try testing.expectEqualStrings("5", live.displayText(arena));
// 'c' clears.
try live.typeChars("c");
try testing.expectEqualStrings("0", live.displayText(arena));
try testing.expect(model.last == null);
}
test "the Escape chrome shortcut clears without any widget focus" {
// The registered shortcut is valid platform-side (escape may be
// unmodified; character keys may not).
for (main.app_shortcuts) |shortcut| {
try native_sdk.platform.validateShortcut(shortcut);
}
try testing.expect(main.onCommand("clear") != null);
try testing.expect(main.onCommand("unknown") == null);
const live = try LiveApp.start();
defer live.stop();
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
// Work through pointer presses only — no widget ever gets keyboard
// focus — then fire the shortcut like the platform would.
var model = &live.app_state.model;
model.press(.d9);
model.press(.multiply);
model.press(.d9);
try testing.expectEqualStrings("9", live.displayText(arena));
try live.harness.runtime.dispatchPlatformEvent(live.app, .{ .shortcut = .{
.id = "clear",
.key = "escape",
} });
try testing.expectEqualStrings("0", live.displayText(arena));
try testing.expect(model.pending == null);
}
// ---------------------------------------------------------------- theming
test "system appearance drives the custom tokens live" {
const live = try LiveApp.start();
defer live.stop();
const app_state = live.app_state;
// Default: light system appearance = custom light palette.
try testing.expectEqualDeep(theme.light_colors, main.tokensFromModel(&app_state.model).colors);
// The OS flips to dark; the app follows it, live, into the runtime -
// there is no in-window theme control by design.
try live.harness.runtime.dispatchPlatformEvent(live.app, .{ .appearance_changed = .{ .color_scheme = .dark } });
try testing.expectEqualDeep(theme.dark_colors, (try live.harness.runtime.canvasWidgetDesignTokens(1, main.canvas_label)).colors);
// And back to light.
try live.harness.runtime.dispatchPlatformEvent(live.app, .{ .appearance_changed = .{ .color_scheme = .light } });
try testing.expectEqualDeep(theme.light_colors, (try live.harness.runtime.canvasWidgetDesignTokens(1, main.canvas_label)).colors);
// The pending operator is the one accent: primary keys at rest are
// the inverted monochrome, selected fills with accent.
const tokens = main.tokensFromModel(&app_state.model);
try testing.expectEqualDeep(theme.light_colors.accent, tokens.controls.button_primary.active_background.?);
// High contrast falls back to the framework palette (accessibility
// beats brand) and restores the brand palette when it lifts.
try live.harness.runtime.dispatchPlatformEvent(live.app, .{ .appearance_changed = .{ .color_scheme = .dark, .high_contrast = true } });
try testing.expectEqualDeep(canvas.ColorTokens.highContrastDark(), (try live.harness.runtime.canvasWidgetDesignTokens(1, main.canvas_label)).colors);
}
// ----------------------------------------------------------------- markup
test "markup engine parity: the keypad builds identical trees" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var model = Model{};
model.press(.d8);
model.press(.multiply);
inline for (.{
.{ view_mod.keypad_markup, view_mod.CompiledKeypadView },
}) |case| {
var interpreter = try canvas.MarkupView(Model, Msg).init(arena, case[0]);
var compiled_ui = Ui.init(arena);
const compiled = try compiled_ui.finalize(case[1].build(&compiled_ui, &model));
var interpreted_ui = Ui.init(arena);
const interpreted = try interpreted_ui.finalize(try interpreter.build(&interpreted_ui, &model));
var compiled_ids: std.ArrayListUnmanaged(canvas.ObjectId) = .empty;
defer compiled_ids.deinit(testing.allocator);
var interpreted_ids: std.ArrayListUnmanaged(canvas.ObjectId) = .empty;
defer interpreted_ids.deinit(testing.allocator);
try collectIds(compiled.root, &compiled_ids, testing.allocator);
try collectIds(interpreted.root, &interpreted_ids, testing.allocator);
try testing.expectEqualSlices(canvas.ObjectId, interpreted_ids.items, compiled_ids.items);
try testing.expectEqual(interpreted.handlers.len, compiled.handlers.len);
}
}
fn collectIds(widget: canvas.Widget, ids: *std.ArrayListUnmanaged(canvas.ObjectId), allocator: std.mem.Allocator) !void {
try ids.append(allocator, widget.id);
for (widget.children) |child| try collectIds(child, ids, allocator);
}
// ------------------------------------------------------------- precision
test "the keypad grid lays out to exact frames inside the fixed window" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
var model = Model{};
const tree = try buildTree(arena_state.allocator(), &model);
var nodes: [512]canvas.WidgetLayoutNode = undefined;
// Lay out with the THEME tokens, exactly like the running app: the
// result line sits on the display typography rung the theme tunes
// to the keypad column (36px), so the display block's height — and
// therefore every key row's y — depends on them.
const layout = try canvas.layoutWidgetTreeWithTokens(tree.root, geometry.RectF.init(0, 0, main.window_width, main.window_height), main.tokensFromModel(&model), &nodes);
try testing.expect(layout.nodes.len > 0);
try testing.expect(layout.nodes.len < 128); // tiny app, tiny tree
const pad = view_mod.window_padding;
const kw = view_mod.key_width;
const gap = view_mod.key_gap;
const columns = [_]f32{ pad, pad + kw + gap, pad + (kw + gap) * 2, pad + (kw + gap) * 3 };
var buttons_seen: usize = 0;
var rows_top: [5]f32 = @splat(0);
var rows_seen: usize = 0;
for (layout.nodes) |node| {
if (node.widget.kind != .button) continue;
buttons_seen += 1;
// Every key is exactly 66x54 — except the double-width zero.
const expected_width: f32 = if (std.mem.eql(u8, node.widget.text, "0")) view_mod.zero_width else kw;
try testing.expectEqual(expected_width, node.frame.width);
try testing.expectEqual(view_mod.key_height, node.frame.height);
// Every key sits on one of the four column x positions.
var on_column = false;
for (columns) |x| {
if (node.frame.x == x) on_column = true;
}
try testing.expect(on_column);
// Nothing escapes the window (right edge and bottom edge).
try testing.expect(node.frame.x + node.frame.width <= main.window_width - pad);
try testing.expect(node.frame.y + node.frame.height <= main.window_height - pad + 0.5);
// Track distinct row tops.
var known_row = false;
for (rows_top[0..rows_seen]) |top| {
if (top == node.frame.y) known_row = true;
}
if (!known_row) {
rows_top[rows_seen] = node.frame.y;
rows_seen += 1;
}
}
try testing.expectEqual(@as(usize, 19), buttons_seen);
try testing.expectEqual(@as(usize, 5), rows_seen);
// Rows are evenly spaced at key height + gap.
std.mem.sort(f32, rows_top[0..rows_seen], {}, std.sort.asc(f32));
for (rows_top[1..rows_seen], 0..) |top, index| {
try testing.expectEqual(rows_top[index] + view_mod.key_height + gap, top);
}
// The display column spans exactly the keypad width, right-aligned
// to the same edge.
const field = findByKind(tree.root, .text_field).?;
for (layout.nodes) |node| {
if (node.widget.id != field.id) continue;
try testing.expectEqual(pad, node.frame.x);
try testing.expectEqual(view_mod.content_width, node.frame.width);
}
}
test "layout audit sweep: nothing clips, overlaps, or escapes" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
// A live expression + result so the display block audits with real
// content, not the empty boot state.
var model = Model{};
const h = PressHarness{ .arena = arena_state.allocator(), .model = &model };
try h.presses(&.{ "1", "2", "8", "×", "9", "6", "=" });
const tree = try buildTree(arena_state.allocator(), &model);
// The window is fixed (precision keypad), so the sweep collapses to
// one size; density variants and the pseudo-locale text expansion
// still run against the machined geometry.
try canvas.expectLayoutAuditSweepClean(testing.allocator, tree.root, .{
.tokens = main.tokensFromModel(&model),
.min_size = surface_size,
.default_size = surface_size,
.large_size = surface_size,
});
}
test "a11y audit sweep: every interactive widget is named, reachable, and unambiguous" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
// The same live state the layout sweep audits: a real expression and
// result, so dynamic labels are the ones assistive tech would hear.
var model = Model{};
const h = PressHarness{ .arena = arena_state.allocator(), .model = &model };
try h.presses(&.{ "1", "2", "8", "×", "9", "6", "=" });
const tree = try buildTree(arena_state.allocator(), &model);
try canvas.expectA11yAuditSweepClean(testing.allocator, tree.root, .{
.tokens = main.tokensFromModel(&model),
.min_size = surface_size,
.default_size = surface_size,
.large_size = surface_size,
});
}
// ------------------------------------------------------------- snapshots
test "automation snapshot names every key and mirrors the display" {
const live = try LiveApp.start();
defer live.stop();
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var snapshot = live.harness.runtime.automationSnapshot("Calculator");
const key_names = [_][]const u8{ "All clear", "Toggle sign", "Percent", "Divide", "Multiply", "Subtract", "Add", "Equals", "Zero", "Decimal point", "7", "8", "9", "4", "5", "6", "1", "2", "3" };
for (key_names) |name| {
try testing.expect(snapshotButton(snapshot, name) != null);
}
try testing.expect(snapshotWidget(snapshot, "textbox", "Expression") != null);
// Click 7 × 6 = through the automation widget-click path and watch
// the snapshot's result text follow.
try clickSnapshotButton(live, &snapshot, "7");
try clickSnapshotButton(live, &snapshot, "Multiply");
try clickSnapshotButton(live, &snapshot, "6");
try clickSnapshotButton(live, &snapshot, "Equals");
try testing.expectEqualStrings("42", live.displayText(arena));
// The result paragraph's semantic label IS the value, so the
// snapshot names it "42" — assistive tech reads the result directly.
snapshot = live.harness.runtime.automationSnapshot("Calculator");
try testing.expect(snapshotWidget(snapshot, "text", "42") != null);
// The memory line follows too.
try testing.expect(snapshotWidget(snapshot, "text", "Last calculation") != null);
}
fn snapshotWidget(snapshot: native_sdk.automation.snapshot.Input, role: []const u8, name: []const u8) ?native_sdk.automation.snapshot.Widget {
for (snapshot.widgets) |widget| {
if (std.mem.eql(u8, widget.role, role) and std.mem.eql(u8, widget.name, name)) return widget;
}
return null;
}
fn snapshotButton(snapshot: native_sdk.automation.snapshot.Input, name: []const u8) ?native_sdk.automation.snapshot.Widget {
return snapshotWidget(snapshot, "button", name);
}
fn clickSnapshotButton(live: LiveApp, snapshot: *native_sdk.automation.snapshot.Input, name: []const u8) !void {
snapshot.* = live.harness.runtime.automationSnapshot("Calculator");
const button = snapshotButton(snapshot.*, name) orelse return error.ButtonNotFound;
var command_buffer: [96]u8 = undefined;
const command = try std.fmt.bufPrint(&command_buffer, "widget-click {s} {d}", .{ main.canvas_label, button.id });
try live.harness.runtime.dispatchAutomationCommand(live.app, command);
}
// Env-gated homepage screenshot renderer (skipped by default, never in
// CI): the docs-homepage showcase state — a finished calculation so the
// display, memory line, and keypad all carry real content — once per
// color scheme, same state in both. PNGs land in
// /tmp/homepage-shots/calculator-{light,dark}-artifacts/. To use:
//
// HOMEPAGE_SHOTS=1 zig build test
test "render homepage screenshots (env-gated)" {
if (!envGateSet("HOMEPAGE_SHOTS")) return error.SkipZigTest;
const io = testing.io;
const live = try LiveApp.start();
defer live.stop();
// 128 × 96 = 12,288 through the real dispatch path, then an operator
// pending so the shot is genuinely mid-calculation: the memory line
// holds the finished multiply, the expression line shows the pending
// "12288 +", and the + key wears its live highlight.
for ([_]Msg{ .d1, .d2, .d8, .multiply, .d9, .d6, .equals, .add }) |msg| {
try live.app_state.dispatch(&live.harness.runtime, 1, msg);
}
// The app follows the system appearance: drive the platform event
// once per scheme, the same channel the OS uses.
try live.harness.runtime.dispatchPlatformEvent(live.app, .{ .appearance_changed = .{ .color_scheme = .light } });
live.harness.runtime.options.automation = native_sdk.automation.Server.init(io, "/tmp/homepage-shots/calculator-light-artifacts", "Calculator");
try live.harness.runtime.dispatchAutomationCommand(live.app, "screenshot calc-canvas 2");
try live.harness.runtime.dispatchPlatformEvent(live.app, .{ .appearance_changed = .{ .color_scheme = .dark } });
live.harness.runtime.options.automation = native_sdk.automation.Server.init(io, "/tmp/homepage-shots/calculator-dark-artifacts", "Calculator");
try live.harness.runtime.dispatchAutomationCommand(live.app, "screenshot calc-canvas 2");
}
/// Env-gated dump switch. `std.c.getenv` needs libc, which this test
/// build only links on targets whose platform layer pulls it in; when
/// libc is absent the gate reads as unset and the gated test skips.
fn envGateSet(name: [*:0]const u8) bool {
if (comptime !@import("builtin").link_libc) return false;
return std.c.getenv(name) != null;
}
+138
View File
@@ -0,0 +1,138 @@
//! calculator theme: the house register — pure neutrals, hairline
//! borders, and exactly one accent (action blue) that only appears when
//! something is live: the pending operator and the press flash on any
//! key. The focus ring stays the register's neutral mid-gray. The
//! operator column and equals are the inverted monochrome keys
//! (near-black faces on light, near-white faces on dark) — the same
//! treatment as the register's monochrome primary — so the board reads
//! black-and-white at rest.
//!
//! High-contrast requests fall back to the framework's high-contrast
//! palettes (accessibility beats brand) and reduce-motion zeroes the
//! motion tokens through the theme options. Keypad glyphs render at 18px
//! through `typography.button_size`; the pending operator fills with the
//! accent through `controls.button_primary.active_background`.
const native_sdk = @import("native_sdk");
const canvas = native_sdk.canvas;
const Color = canvas.Color;
/// The app-registered face behind every mono run (the display-rung
/// result line, the memory readout): the bundled Geist Mono bytes registered
/// through `Options.fonts` under an app-owned id, exercising the
/// registered-font seam end to end — this id flows from the token
/// through layout, both renderers, and (on macOS) the host's font
/// resolution, so the display inks the exact registered face even where
/// the family is not installed system-wide.
pub const display_font_id: canvas.FontId = canvas.min_registered_font_id;
/// The display rung tuned to the keypad column: 36px mono digits (0.6 em
/// pitch) keep the 12-digit entry window inside the 288pt content width.
pub const display_size: f32 = 36;
pub fn tokens(scheme: native_sdk.ColorScheme, high_contrast: bool, reduce_motion: bool) canvas.DesignTokens {
var out = canvas.DesignTokens.theme(.{
.color_scheme = switch (scheme) {
.light => .light,
.dark => .dark,
},
.contrast = if (high_contrast) .high else .standard,
.reduce_motion = reduce_motion,
});
if (!high_contrast) {
out.colors = switch (scheme) {
.light => light_colors,
.dark => dark_colors,
};
// The strong column: operator keys and equals invert to the
// monochrome extreme of each scheme; the pending operator (and
// any press) fills with the one accent.
out.controls.button_primary = switch (scheme) {
.light => .{
.background = Color.rgb8(23, 23, 23),
.hover_background = Color.rgb8(38, 38, 38),
.active_background = light_colors.accent,
.foreground = Color.rgb8(250, 250, 250),
.border = Color.rgb8(23, 23, 23),
},
.dark => .{
.background = Color.rgb8(229, 229, 229),
.hover_background = Color.rgb8(250, 250, 250),
.active_background = dark_colors.accent,
.foreground = Color.rgb8(23, 23, 23),
.border = Color.rgb8(229, 229, 229),
},
};
}
// Calculator keys carry 18px glyphs at every key size (button
// labels hold one size across the control ladder).
out.typography.button_size = 18;
// The result line sits on the display typography rung, themed to
// 36px: at the mono pitch (0.6 em) the 12-digit entry window needs
// 12 x 21.6 = 259pt of the 288pt column, which the 48px default
// would overrun. One token move recolors the whole rung.
out.typography.display_size = display_size;
// Mono runs resolve through the app-registered face (see
// `display_font_id`).
out.typography.mono_font_id = display_font_id;
out.radius = .{ .sm = 7, .md = 10, .lg = 14, .xl = 18 };
out.pixel_snap = .{ .geometry = true, .text = true, .scale = 1 };
return out;
}
/// Paper white on the register's neutral scale: white keys lifted off a
/// near-white window by hairlines; action blue (the register's blue
/// primary, oklch(0.488 0.243 264.376) = #1447e6) as the only color.
/// Every neutral is a scale anchor, so the board sits on exactly the
/// same gray foundation as the default theme — only the accent differs.
pub const light_colors = canvas.ColorTokens{
.background = Color.rgb8(250, 250, 250),
.surface = Color.rgb8(255, 255, 255),
.surface_subtle = Color.rgb8(245, 245, 245),
.surface_pressed = Color.rgb8(229, 229, 229),
.text = Color.rgb8(10, 10, 10),
.text_muted = Color.rgb8(115, 115, 115),
.border = Color.rgb8(229, 229, 229),
.accent = Color.rgb8(20, 71, 230),
.accent_text = Color.rgb8(239, 246, 255),
.destructive = Color.rgb8(231, 0, 11),
.destructive_text = Color.rgb8(250, 250, 250),
.success = Color.rgb8(22, 163, 74),
.success_text = Color.rgb8(250, 250, 250),
.warning = Color.rgb8(217, 119, 6),
.warning_text = Color.rgb8(250, 250, 250),
.focus_ring = Color.rgb8(161, 161, 161),
.shadow = Color.rgba8(0, 0, 0, 26),
.disabled = Color.rgb8(245, 245, 245),
};
/// True graphite on the register's dark neutrals: translucent-white
/// hairlines and pressed washes (they brighten what they overlap instead
/// of muddying it); the accent lifts to the scale's bright-blue step
/// (oklch(0.623 0.214 259.815) = #2b7fff) so the pending-operator flash
/// is unmistakable on graphite keys.
pub const dark_colors = canvas.ColorTokens{
.background = Color.rgb8(10, 10, 10),
.surface = Color.rgb8(23, 23, 23),
.surface_subtle = Color.rgb8(38, 38, 38),
.surface_pressed = Color.rgba8(255, 255, 255, 38),
.text = Color.rgb8(250, 250, 250),
.text_muted = Color.rgb8(161, 161, 161),
.border = Color.rgba8(255, 255, 255, 26),
.accent = Color.rgb8(43, 127, 255),
// Near-black on the bright accent (6.0:1) — the light near-white
// pairing only reaches 3.5:1 on this step.
.accent_text = Color.rgb8(10, 10, 10),
.destructive = Color.rgb8(255, 100, 103),
.destructive_text = Color.rgb8(250, 250, 250),
.success = Color.rgb8(34, 197, 94),
.success_text = Color.rgb8(9, 9, 11),
.warning = Color.rgb8(245, 158, 11),
.warning_text = Color.rgb8(9, 9, 11),
.info = Color.rgb8(167, 139, 250),
.info_text = Color.rgb8(9, 9, 11),
.focus_ring = Color.rgb8(115, 115, 115),
.shadow = Color.rgba8(0, 0, 0, 150),
.disabled = Color.rgb8(38, 38, 38),
};
+136
View File
@@ -0,0 +1,136 @@
//! calculator views. Markup-first: the whole keypad is a compiled
//! `.native` view; this file holds the sections the closed markup grammar
//! cannot express — the drag band (the hidden-inset titlebar's
//! `window_drag` region: empty by design, the window has no chrome) and
//! the display block, whose result paragraph needs monospace and weight
//! spans the closed markup grammar does not carry (its SIZE is the
//! display typography rung, shared with markup) — plus the root view
//! composing all three.
//!
//! The display's expression line is a real `text_field` and it is the
//! app's keyboard seam: focusing it (click, or Tab) routes every typed
//! character through the widget keyboard path as `TextInputEvent`s that
//! `update` parses as calculator keys, backspace edits the entry, and
//! enter submits as equals. The field's text is model-derived (the live
//! expression), so unknown characters can never appear in it.
const std = @import("std");
const native_sdk = @import("native_sdk");
const model_mod = @import("model.zig");
const canvas = native_sdk.canvas;
pub const Model = model_mod.Model;
pub const Msg = model_mod.Msg;
pub const Ui = canvas.Ui(Msg);
pub const keypad_markup = @embedFile("keypad.native");
pub const CompiledKeypadView = canvas.CompiledMarkupView(Model, Msg, keypad_markup);
// Keypad metrics (kept in lockstep with keypad.native; the layout test
// asserts the rendered frames match these numbers exactly).
pub const key_width: f32 = 66;
pub const key_height: f32 = 54;
pub const key_gap: f32 = 8;
pub const zero_width: f32 = key_width * 2 + key_gap; // 140
pub const content_width: f32 = key_width * 4 + key_gap * 3; // 288
pub const window_padding: f32 = 16;
/// The drag band under the hidden-inset titlebar: tall enough to clear
/// the window controls in the leading corner.
pub const band_height: f32 = 24;
// The big result line renders at the display typography rung; the theme
// tunes the rung to the keypad geometry (`theme.display_size`).
// ----------------------------------------------------------------- root
pub fn rootView(ui: *Ui, model: *const Model) Ui.Node {
return ui.column(.{
.padding = window_padding,
.gap = 14,
.grow = 1,
.style_tokens = .{ .background = .background },
}, .{
dragBand(ui),
displayView(ui, model),
CompiledKeypadView.build(ui, model),
});
}
/// The chromeless top band: the window drag region (hidden-inset
/// titlebar). Deliberately empty — no logo, no label; the identity is
/// the readout and the key rhythm, and the window controls own the
/// leading corner.
fn dragBand(ui: *Ui) Ui.Node {
return ui.row(.{
.height = band_height,
.window_drag = true,
.semantics = .{ .label = "Window drag area" },
}, .{
ui.spacer(1),
});
}
// ---------------------------------------------------------------- display
/// Memory line, expression field, and the big result — all sitting
/// directly on the window background so the digits carry the design.
fn displayView(ui: *Ui, model: *const Model) Ui.Node {
return ui.column(.{ .gap = 4, .semantics = .{ .label = "Display" } }, .{
memoryLine(ui, model),
expressionField(ui, model),
resultLine(ui, model),
});
}
/// The last completed calculation, right-aligned and quiet, in the same
/// mono as the result so digits stack column-over-column. The explicit
/// height keeps the layout steady while it is empty.
fn memoryLine(ui: *Ui, model: *const Model) Ui.Node {
var node = ui.paragraph(.{
.width = content_width,
.height = 18,
.size = .sm,
.style_tokens = .{ .foreground = .text_muted },
.semantics = .{ .label = "Last calculation" },
}, &.{
.{ .text = model.memoryText(ui.arena), .monospace = true },
});
node.widget.text_alignment = .end;
return node;
}
/// The live expression and the keyboard seam (see the module doc). The
/// field blends into the background until focused, when the accent
/// focus ring shows exactly where keystrokes go. No placeholder: an
/// empty expression line is quiet, not chatty.
fn expressionField(ui: *Ui, model: *const Model) Ui.Node {
return ui.el(.text_field, .{
.width = content_width,
.height = 32,
.text = model.expressionText(ui.arena),
.on_input = Ui.inputMsg(.typed),
.on_submit = .equals,
.style_tokens = .{ .background = .background, .border_color = .background },
.semantics = .{ .label = "Expression" },
}, .{});
}
/// The result: one mono span at the display rung, right-aligned. The
/// explicit width spans the whole content column — it is the alignment
/// box that keeps the right-aligned result flush with the keypad edge.
/// Its semantic label IS the value, so assistive tech (and the
/// automation snapshot) reads the result directly.
fn resultLine(ui: *Ui, model: *const Model) Ui.Node {
const value = model.displayText(ui.arena);
var node = ui.paragraph(.{
.width = content_width,
.size = .display,
.semantics = .{ .label = value },
}, &.{
.{ .text = value, .weight = .medium, .monospace = true },
});
node.widget.text_alignment = .end;
return node;
}
+26
View File
@@ -0,0 +1,26 @@
# canvas-preview
The "native, web, or both per window" proof: one window hosting a Native SDK canvas (Metal-backed toolbar + sidebar rendered by the widget engine) and a live platform webview side by side.
- The webview is an ordinary scene shell view (`kind = .webview`, parented to the canvas view). The build's web engine backs it: WKWebView with `-Dweb-engine=system` (default), bundled Chromium with `-Dweb-engine=cef` — the pane seam below is engine-agnostic, though gpu_surface canvases currently require the system engine on macOS.
- `UiApp.Options.web_panes` keeps the webview snapped to a canvas widget's layout frame — the empty panel carrying the `preview-pane` semantics label — through install, rebuilds, and resizes.
- The model owns `url` + `reload_token` (the CenterPane/Preview-tab consumer shape): switching pages navigates, bumping the token reloads.
- `UiApp.Options.status_item` installs a menu-bar extra (macOS `NSStatusItem`) whose menu items dispatch the same `on_command` commands as the toolbar buttons.
Run it:
```sh
native dev
```
Automation smoke (from the repo root):
```sh
zig build test-canvas-preview-smoke
```
Headless tests:
```sh
native test
```
+32
View File
@@ -0,0 +1,32 @@
.{
.id = "dev.native_sdk.canvas_preview",
.name = "canvas-preview",
.display_name = "Canvas Preview",
.version = "0.1.0",
.platforms = .{"macos"},
.capabilities = .{ "native_views", "gpu_surfaces", "webview", "tray" },
.shell = .{
.windows = .{
.{
.label = "main",
.title = "Native SDK Canvas Preview",
.width = 960,
.height = 640,
.restore_state = false,
.restore_policy = "center_on_primary",
.views = .{
.{ .label = "preview-canvas", .kind = "gpu_surface", .fill = true, .role = "Canvas chrome", .accessibility_label = "Canvas Preview chrome", .gpu_backend = "metal", .gpu_pixel_format = "bgra8_unorm", .gpu_present_mode = "timer", .gpu_alpha_mode = "opaque", .gpu_color_space = "srgb", .gpu_vsync = true },
.{ .label = "preview", .kind = "webview", .parent = "preview-canvas", .url = "https://example.com/", .x = 240, .y = 76, .width = 704, .height = 548, .layer = 20 },
},
},
},
},
.security = .{
.navigation = .{
.allowed_origins = .{ "zero://app", "zero://inline", "https://example.com", "https://native-sdk.dev" },
.external_links = .{ .action = "deny" },
},
},
.web_engine = "system",
.cef = .{ .dir = "third_party/cef/macos", .auto_install = false },
}
+209
View File
@@ -0,0 +1,209 @@
//! canvas-preview: the "both architectures in one window" dogfood app.
//!
//! One window hosts a native-sdk canvas (native-rendered toolbar and
//! sidebar on a Metal-backed gpu_surface) and a live platform webview
//! side by side. The webview is declared in the scene like any other
//! shell view; `UiApp.Options.web_panes` then keeps it snapped to a
//! canvas widget's layout frame (the empty panel carrying the
//! `preview-pane` semantics label) and drives navigation from the model
//! — the model owns `url` + `reload_token`, exactly the CenterPane
//! consumer shape a Preview tab needs.
//!
//! The same app installs a menu-bar extra (`status_item`): an
//! NSStatusItem whose menu items dispatch the same commands the
//! toolbar buttons do, through the ordinary `on_command` path.
const std = @import("std");
const runner = @import("runner");
const native_sdk = @import("native_sdk");
pub const panic = std.debug.FullPanic(native_sdk.debug.capturePanic);
const canvas = native_sdk.canvas;
const geometry = native_sdk.geometry;
pub const canvas_label = "preview-canvas";
pub const webview_label = "preview";
pub const pane_anchor = "preview-pane";
pub const example_url = "https://example.com/";
pub const docs_url = "https://native-sdk.dev/";
pub const example_command = "app.example";
pub const docs_command = "app.docs";
pub const reload_command = "app.reload";
const window_width: f32 = 960;
const window_height: f32 = 640;
const sidebar_width: f32 = 224;
const toolbar_height: f32 = 56;
pub const shell_views = [_]native_sdk.ShellView{
.{ .label = canvas_label, .kind = .gpu_surface, .fill = true, .role = "Canvas chrome", .accessibility_label = "Canvas Preview chrome", .gpu_backend = .metal, .gpu_pixel_format = .bgra8_unorm, .gpu_present_mode = .timer, .gpu_alpha_mode = .@"opaque", .gpu_color_space = .srgb, .gpu_vsync = true },
// The live webview: a scene view like any other. Parented to the
// canvas view so pane frames share the canvas coordinate space; the
// initial frame is a placeholder the first rebuild replaces with the
// anchor widget's layout frame.
.{ .label = webview_label, .kind = .webview, .parent = canvas_label, .url = example_url, .x = sidebar_width + 16, .y = toolbar_height + 20, .width = window_width - sidebar_width - 32, .height = window_height - toolbar_height - 36, .layer = 20 },
};
pub const shell_windows = [_]native_sdk.ShellWindow{.{
.label = "main",
.title = "Native SDK Canvas Preview",
.width = window_width,
.height = window_height,
.restore_state = false,
.views = &shell_views,
}};
pub const shell_scene: native_sdk.ShellConfig = .{ .windows = &shell_windows };
// ------------------------------------------------------------------ model
pub const Page = enum {
example,
docs,
pub fn url(page: Page) []const u8 {
return switch (page) {
.example => example_url,
.docs => docs_url,
};
}
pub fn title(page: Page) []const u8 {
return switch (page) {
.example => "Example",
.docs => "Docs",
};
}
};
pub const Model = struct {
page: Page = .example,
reload_token: u64 = 0,
reload_count: u32 = 0,
gpu_frames_seen: bool = false,
};
pub const Msg = union(enum) {
show_example,
show_docs,
reload,
frame_presented,
};
pub fn update(model: *Model, msg: Msg) void {
switch (msg) {
.show_example => model.page = .example,
.show_docs => model.page = .docs,
.reload => {
model.reload_token += 1;
model.reload_count += 1;
},
.frame_presented => model.gpu_frames_seen = true,
}
}
// ------------------------------------------------------------------- view
const PreviewApp = native_sdk.UiApp(Model, Msg);
pub const PreviewUi = canvas.Ui(Msg);
pub fn view(ui: *PreviewUi, model: *const Model) PreviewUi.Node {
return ui.column(.{ .gap = 0, .style_tokens = .{ .background = .background } }, .{
ui.row(.{ .height = toolbar_height, .padding = 12, .gap = 8, .cross = .center }, .{
ui.text(.{ .size = .lg }, "Canvas Preview"),
ui.spacer(1),
ui.button(.{ .variant = if (model.page == .example) .primary else .secondary, .on_press = .show_example }, "Example"),
ui.button(.{ .variant = if (model.page == .docs) .primary else .secondary, .on_press = .show_docs }, "Docs"),
ui.button(.{ .on_press = .reload }, "Reload"),
}),
ui.row(.{ .grow = 1, .gap = 0 }, .{
ui.column(.{ .width = sidebar_width, .padding = 12, .gap = 8, .style_tokens = .{ .background = .surface } }, .{
ui.text(.{ .style_tokens = .{ .foreground = .text_muted } }, "Native canvas rail"),
ui.text(.{}, ui.fmt("URL: {s}", .{model.page.url()})),
ui.text(.{}, ui.fmt("Reloads: {d}", .{model.reload_count})),
ui.spacer(1),
ui.statusBar(.{}, if (model.gpu_frames_seen) "canvas presenting + webview live" else "waiting for first frame"),
}),
// The webview region: an empty panel whose layout frame the
// runtime maps onto the webview's bounds every rebuild.
ui.panel(.{ .grow = 1, .semantics = .{ .label = pane_anchor } }, .{}),
}),
});
}
// ------------------------------------------------------ webview pane seam
pub fn panes(model: *const Model, out: []PreviewApp.WebViewPane) usize {
out[0] = .{
.label = webview_label,
.anchor = pane_anchor,
.url = model.page.url(),
.reload_token = model.reload_token,
};
return 1;
}
// -------------------------------------------------------------- commands
pub fn command(name: []const u8) ?Msg {
if (std.mem.eql(u8, name, example_command)) return .show_example;
if (std.mem.eql(u8, name, docs_command)) return .show_docs;
if (std.mem.eql(u8, name, reload_command)) return .reload;
return null;
}
fn onFrame(model: *const Model, frame: native_sdk.platform.GpuFrame) ?Msg {
_ = frame;
if (model.gpu_frames_seen) return null;
return .frame_presented;
}
/// Menu-bar extra (macOS NSStatusItem): the same commands the toolbar
/// dispatches, reachable while the window is in the background.
pub const status_items = [_]native_sdk.TrayMenuItem{
.{ .id = 1, .label = "Show Example", .command = example_command },
.{ .id = 2, .label = "Show Docs", .command = docs_command },
.{ .separator = true },
.{ .id = 3, .label = "Reload Preview", .command = reload_command },
};
pub fn options() PreviewApp.Options {
return .{
.name = "canvas-preview",
.scene = shell_scene,
.canvas_label = canvas_label,
.update = update,
.view = view,
.web_panes = panes,
.on_command = command,
.on_frame = onFrame,
.status_item = .{
.title = "NS",
.tooltip = "Native SDK Canvas Preview",
.items = &status_items,
},
};
}
// -------------------------------------------------------------------- app
pub fn main(init: std.process.Init) !void {
const app_state = try std.heap.page_allocator.create(PreviewApp);
defer std.heap.page_allocator.destroy(app_state);
app_state.* = PreviewApp.init(std.heap.page_allocator, .{}, options());
defer app_state.deinit();
try runner.runWithOptions(app_state.app(), .{
.app_name = "canvas-preview",
.window_title = "Native SDK Canvas Preview",
.bundle_id = "dev.native_sdk.canvas_preview",
.default_frame = geometry.RectF.init(0, 0, window_width, window_height),
.restore_state = false,
.js_window_api = false,
.security = .{
.navigation = .{ .allowed_origins = &.{ "zero://inline", "zero://app", "https://example.com", "https://native-sdk.dev" } },
},
}, init);
}
test {
_ = @import("tests.zig");
}
+154
View File
@@ -0,0 +1,154 @@
const std = @import("std");
const native_sdk = @import("native_sdk");
const main = @import("main.zig");
const canvas = native_sdk.canvas;
const geometry = native_sdk.geometry;
const testing = std.testing;
const Model = main.Model;
const Msg = main.Msg;
const PreviewApp = native_sdk.UiApp(Model, Msg);
const preview_origins = [_][]const u8{ "zero://inline", "zero://app", "https://example.com", "https://native-sdk.dev" };
fn createApp() !*PreviewApp {
const app_state = try testing.allocator.create(PreviewApp);
app_state.* = PreviewApp.init(std.heap.page_allocator, .{}, main.options());
return app_state;
}
fn startedHarness(app_state: *PreviewApp) !*native_sdk.TestHarness() {
const harness = try native_sdk.TestHarness().create(testing.allocator, .{ .size = geometry.SizeF.init(960, 640) });
errdefer harness.destroy(testing.allocator);
harness.null_platform.gpu_surfaces = true;
harness.runtime.options.security.navigation.allowed_origins = &preview_origins;
const app = app_state.app();
try harness.start(app);
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{
.label = main.canvas_label,
.size = geometry.SizeF.init(960, 640),
.scale_factor = 1,
.frame_index = 1,
.timestamp_ns = 1_000_000,
.nonblank = true,
} });
return harness;
}
fn previewWebView(harness: *native_sdk.TestHarness()) !native_sdk.platform.ViewInfo {
var views_buffer: [8]native_sdk.platform.ViewInfo = undefined;
const views = harness.runtime.listViews(1, &views_buffer);
for (views) |view| {
if (std.mem.eql(u8, view.label, main.webview_label)) return view;
}
return error.TestUnexpectedResult;
}
test "the scene hosts a canvas and a webview in one window, with no implicit main webview" {
try testing.expectEqual(@as(usize, 2), main.shell_views.len);
try testing.expect(main.shell_views[0].kind == .gpu_surface);
try testing.expect(main.shell_views[1].kind == .webview);
const app_state = try createApp();
defer testing.allocator.destroy(app_state);
defer app_state.deinit();
const harness = try startedHarness(app_state);
defer harness.destroy(testing.allocator);
// Both architectures live in window 1 — and the canvas-first scene
// never grows an implicit full-window main webview behind the canvas.
try testing.expect(harness.runtime.loaded_source == null);
var views_buffer: [8]native_sdk.platform.ViewInfo = undefined;
const views = harness.runtime.listViews(1, &views_buffer);
try testing.expectEqual(@as(usize, 2), views.len);
var saw_canvas = false;
var saw_webview = false;
for (views) |view| {
if (view.kind == .gpu_surface and std.mem.eql(u8, view.label, main.canvas_label)) saw_canvas = true;
if (view.kind == .webview and std.mem.eql(u8, view.label, main.webview_label)) saw_webview = true;
try testing.expect(!std.mem.eql(u8, view.label, "main"));
}
try testing.expect(saw_canvas);
try testing.expect(saw_webview);
}
test "the webview pane tracks the anchor widget frame through install and resize" {
const app_state = try createApp();
defer testing.allocator.destroy(app_state);
defer app_state.deinit();
const harness = try startedHarness(app_state);
defer harness.destroy(testing.allocator);
const layout = try harness.runtime.canvasWidgetLayout(1, main.canvas_label);
var anchor_frame: ?geometry.RectF = null;
for (layout.nodes) |node| {
if (std.mem.eql(u8, node.widget.semantics.label, main.pane_anchor)) anchor_frame = node.frame;
}
try testing.expect(anchor_frame != null);
const webview = try previewWebView(harness);
try testing.expectApproxEqAbs(anchor_frame.?.x, webview.frame.x, 0.5);
try testing.expectApproxEqAbs(anchor_frame.?.y, webview.frame.y, 0.5);
try testing.expectApproxEqAbs(anchor_frame.?.width, webview.frame.width, 0.5);
try testing.expectApproxEqAbs(anchor_frame.?.height, webview.frame.height, 0.5);
try harness.runtime.dispatchPlatformEvent(app_state.app(), .{ .gpu_surface_resized = .{
.label = main.canvas_label,
.window_id = 1,
.frame = geometry.RectF.init(0, 0, 1200, 800),
.scale_factor = 1,
} });
const resized = try previewWebView(harness);
try testing.expect(resized.frame.width > webview.frame.width);
try testing.expect(resized.frame.height > webview.frame.height);
}
test "toolbar and status-item commands navigate and reload the webview" {
const app_state = try createApp();
defer testing.allocator.destroy(app_state);
defer app_state.deinit();
const harness = try startedHarness(app_state);
defer harness.destroy(testing.allocator);
try testing.expectEqualStrings(main.example_url, (try previewWebView(harness)).url);
const navigations_after_install = harness.null_platform.webview_navigate_count;
// Docs via the command path (toolbar button / menu / status item all
// dispatch the same command names).
try harness.runtime.dispatchPlatformEvent(app_state.app(), .{ .menu_command = .{ .name = main.docs_command, .window_id = 1 } });
try testing.expect(app_state.model.page == .docs);
try testing.expectEqualStrings(main.docs_url, (try previewWebView(harness)).url);
try testing.expectEqual(navigations_after_install + 1, harness.null_platform.webview_navigate_count);
// The menu-bar extra is installed with the declared items; selecting
// "Reload Preview" (id 3) renavigates the current URL.
try testing.expectEqual(@as(usize, 1), harness.null_platform.trayCreateCount());
try testing.expectEqualStrings("NS", harness.null_platform.lastTrayTitle());
try testing.expectEqual(@as(usize, main.status_items.len), harness.null_platform.trayItems().len);
try harness.runtime.dispatchPlatformEvent(app_state.app(), .{ .tray_action = 3 });
try testing.expectEqual(@as(u32, 1), app_state.model.reload_count);
try testing.expectEqualStrings(main.docs_url, (try previewWebView(harness)).url);
try testing.expectEqual(navigations_after_install + 2, harness.null_platform.webview_navigate_count);
}
test "the view lays out through the canvas engine with the pane anchor mounted" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var model = Model{ .page = .docs };
var ui = main.PreviewUi.init(arena);
const tree = try ui.finalize(main.view(&ui, &model));
var nodes: [256]canvas.WidgetLayoutNode = undefined;
const layout = try canvas.layoutWidgetTree(tree.root, geometry.RectF.init(0, 0, 960, 640), &nodes);
var anchor_frame: ?geometry.RectF = null;
for (layout.nodes) |node| {
if (std.mem.eql(u8, node.widget.semantics.label, main.pane_anchor)) anchor_frame = node.frame;
}
try testing.expect(anchor_frame != null);
try testing.expect(anchor_frame.?.width > 400);
try testing.expect(anchor_frame.?.height > 400);
try testing.expect(anchor_frame.?.x >= 224);
}
+31
View File
@@ -0,0 +1,31 @@
# Native SDK capabilities example
This example shows guarded OS capabilities from trusted WebView code:
- Platform support discovery.
- Open URL, reveal path, and recent document OS services.
- Notifications.
- Clipboard text read and write.
- Message dialogs.
- Credential set, get, and delete.
- File-drop events delivered to Zig and the WebView event bridge.
- File association and custom URL scheme packaging metadata.
- App activation and deactivation events.
Run with the system backend:
```sh
zig build run -Dplatform=macos -Dweb-engine=system
```
Run the headless test path:
```sh
zig build test -Dplatform=null
```
Run all native-first example tests from the repository root:
```sh
zig build test-examples-native
```
+62
View File
@@ -0,0 +1,62 @@
.{
.id = "dev.native_sdk.capabilities",
.name = "capabilities",
.display_name = "Capabilities",
.version = "0.1.0",
.platforms = .{ "macos", "linux" },
.permissions = .{ "window", "network", "filesystem", "notifications", "dialog", "clipboard", "credentials" },
.capabilities = .{
"webview",
"js_bridge",
"native_views",
"open_url",
"reveal_path",
"recent_documents",
"notifications",
"dialog",
"clipboard",
"credentials",
"file_drops",
"file_associations",
"url_schemes",
"app_activation_events",
},
.file_associations = .{
.{
.name = "Native SDK Capability Document",
.role = "viewer",
.extensions = .{ "zncap" },
.mime_types = .{ "application/vnd.native-sdk.capability+json" },
},
},
.url_schemes = .{
.{ .scheme = "native-sdk-capabilities" },
},
.shell = .{
.windows = .{
.{
.label = "main",
.title = "Native SDK Capabilities",
.width = 900,
.height = 620,
.restore_policy = "center_on_primary",
.views = .{
.{ .label = "main", .kind = "webview", .url = "zero://inline", .fill = true },
.{ .label = "statusbar", .kind = "statusbar", .edge = "bottom", .height = 34, .role = "Status" },
.{ .label = "status-label", .kind = "label", .parent = "statusbar", .x = 14, .y = 8, .width = 640, .height = 18, .text = "Ready." },
},
},
},
},
.security = .{
.navigation = .{
.allowed_origins = .{ "zero://app", "zero://inline" },
.external_links = .{
.action = "open_system_browser",
.allowed_urls = .{ "https://example.com/docs/*" },
},
},
},
.web_engine = "system",
.cef = .{ .dir = "third_party/cef/macos", .auto_install = false },
}
+453
View File
@@ -0,0 +1,453 @@
// This example owns its build: it hand-wires the framework modules, src/runner.zig, and the -Dtrace/-Ddebug-overlay/-Djs-bridge/-Dweb-engine options that the generated app graph does not expose.
const std = @import("std");
const PlatformOption = enum {
auto,
null,
macos,
linux,
windows,
};
const TraceOption = enum {
off,
events,
runtime,
all,
};
const WebEngineOption = enum {
system,
chromium,
};
const default_native_sdk_path = "../../";
const app_exe_name = "capabilities";
pub fn build(b: *std.Build) void {
const target = nativeSdkTarget(b);
const optimize = b.standardOptimizeOption(.{});
const platform_option = b.option(PlatformOption, "platform", "Desktop backend: auto, null, macos, linux, windows") orelse .auto;
const trace_option = b.option(TraceOption, "trace", "Trace output: off, events, runtime, all") orelse .events;
const debug_overlay = b.option(bool, "debug-overlay", "Enable debug overlay output") orelse false;
const automation_enabled = b.option(bool, "automation", "Enable Native SDK automation artifacts") orelse false;
const js_bridge_enabled = b.option(bool, "js-bridge", "Enable optional JavaScript bridge stubs") orelse false;
const web_engine_override = b.option(WebEngineOption, "web-engine", "Override app.zon web engine: system, chromium");
const cef_dir_override = b.option([]const u8, "cef-dir", "Override CEF root directory for Chromium builds");
const cef_auto_install_override = b.option(bool, "cef-auto-install", "Override app.zon CEF auto-install setting");
const native_sdk_path = b.option([]const u8, "native-sdk-path", "Path to the Native SDK framework checkout") orelse default_native_sdk_path;
const selected_platform: PlatformOption = switch (platform_option) {
.auto => if (target.result.os.tag == .macos) .macos else if (target.result.os.tag == .linux) .linux else if (target.result.os.tag == .windows) .windows else .null,
else => platform_option,
};
if (selected_platform == .macos and target.result.os.tag != .macos) {
@panic("-Dplatform=macos requires a macOS target");
}
if (selected_platform == .linux and target.result.os.tag != .linux) {
@panic("-Dplatform=linux requires a Linux target");
}
if (selected_platform == .windows and target.result.os.tag != .windows) {
@panic("-Dplatform=windows requires a Windows target");
}
const app_web_engine = appWebEngineConfig();
const web_engine = web_engine_override orelse app_web_engine.web_engine;
const cef_dir = cef_dir_override orelse defaultCefDir(selected_platform, app_web_engine.cef_dir);
const cef_auto_install = cef_auto_install_override orelse app_web_engine.cef_auto_install;
if (web_engine == .chromium and selected_platform != .macos) {
@panic("-Dweb-engine=chromium currently requires -Dplatform=macos");
}
const native_sdk_mod = nativeSdkModule(b, target, optimize, native_sdk_path);
const options = b.addOptions();
options.addOption([]const u8, "platform", switch (selected_platform) {
.auto => unreachable,
.null => "null",
.macos => "macos",
.linux => "linux",
.windows => "windows",
});
options.addOption([]const u8, "trace", @tagName(trace_option));
options.addOption([]const u8, "web_engine", @tagName(web_engine));
options.addOption(bool, "debug_overlay", debug_overlay);
options.addOption(bool, "automation", automation_enabled);
options.addOption(bool, "js_bridge", js_bridge_enabled);
const options_mod = options.createModule();
const manifest_mod = b.createModule(.{ .root_source_file = b.path("app.zon") });
const runner_mod = localModule(b, target, optimize, "src/runner.zig");
runner_mod.addImport("native_sdk", native_sdk_mod);
runner_mod.addImport("build_options", options_mod);
runner_mod.addImport("app_manifest_zon", manifest_mod);
const app_mod = localModule(b, target, optimize, "src/main.zig");
app_mod.addImport("native_sdk", native_sdk_mod);
app_mod.addImport("runner", runner_mod);
app_mod.addImport("app_manifest_zon", manifest_mod);
const exe = b.addExecutable(.{
.name = app_exe_name,
.root_module = app_mod,
});
linkPlatform(b, target, app_mod, exe, selected_platform, web_engine, native_sdk_path, cef_dir, cef_auto_install);
b.installArtifact(exe);
const run = b.addRunArtifact(exe);
addCefRuntimeRunFiles(b, target, run, exe, web_engine, cef_dir);
addWebView2RuntimeRunFiles(b, target, run, web_engine, native_sdk_path);
const run_step = b.step("run", "Run the app");
run_step.dependOn(&run.step);
const tests = b.addTest(.{ .root_module = app_mod });
const test_step = b.step("test", "Run tests");
test_step.dependOn(&b.addRunArtifact(tests).step);
}
fn nativeSdkTarget(b: *std.Build) std.Build.ResolvedTarget {
const target = b.standardTargetOptions(.{});
if (target.result.os.tag != .macos) return target;
if (b.sysroot == null) {
b.sysroot = macosSdkPath(b) orelse b.sysroot;
}
var query = target.query;
query.os_tag = .macos;
query.os_version_min = .{ .semver = .{ .major = 11, .minor = 0, .patch = 0 } };
return b.resolveTargetQuery(query);
}
fn macosSdkPath(b: *std.Build) ?[]const u8 {
if (b.graph.environ_map.get("SDKROOT")) |sdkroot| {
if (sdkroot.len > 0) return sdkroot;
}
const result = std.process.run(b.allocator, b.graph.io, .{
.argv = &.{ "xcrun", "--sdk", "macosx", "--show-sdk-path" },
.stdout_limit = .limited(4096),
.stderr_limit = .limited(4096),
}) catch return null;
defer b.allocator.free(result.stderr);
if (result.term != .exited or result.term.exited != 0) {
b.allocator.free(result.stdout);
return null;
}
return std.mem.trimEnd(u8, result.stdout, "\r\n");
}
fn localModule(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, path: []const u8) *std.Build.Module {
return b.createModule(.{
.root_source_file = b.path(path),
.target = target,
.optimize = optimize,
});
}
fn nativeSdkPath(b: *std.Build, native_sdk_path: []const u8, sub_path: []const u8) std.Build.LazyPath {
return .{ .cwd_relative = b.pathJoin(&.{ native_sdk_path, sub_path }) };
}
fn nativeSdkModule(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, native_sdk_path: []const u8) *std.Build.Module {
const geometry_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/geometry/root.zig");
const assets_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/assets/root.zig");
const app_dirs_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/app_dirs/root.zig");
const trace_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/trace/root.zig");
const app_manifest_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/app_manifest/root.zig");
const diagnostics_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/diagnostics/root.zig");
const platform_info_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/platform_info/root.zig");
const json_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/json/root.zig");
const canvas_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/canvas/root.zig");
canvas_mod.addImport("geometry", geometry_mod);
canvas_mod.addImport("json", json_mod);
const debug_mod = externalModule(b, target, optimize, native_sdk_path, "src/debug/root.zig");
debug_mod.addImport("app_dirs", app_dirs_mod);
debug_mod.addImport("trace", trace_mod);
const native_sdk_mod = externalModule(b, target, optimize, native_sdk_path, "src/root.zig");
native_sdk_mod.addImport("geometry", geometry_mod);
native_sdk_mod.addImport("assets", assets_mod);
native_sdk_mod.addImport("app_dirs", app_dirs_mod);
native_sdk_mod.addImport("trace", trace_mod);
native_sdk_mod.addImport("app_manifest", app_manifest_mod);
native_sdk_mod.addImport("diagnostics", diagnostics_mod);
native_sdk_mod.addImport("platform_info", platform_info_mod);
native_sdk_mod.addImport("json", json_mod);
native_sdk_mod.addImport("canvas", canvas_mod);
return native_sdk_mod;
}
fn externalModule(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, native_sdk_path: []const u8, path: []const u8) *std.Build.Module {
return b.createModule(.{
.root_source_file = nativeSdkPath(b, native_sdk_path, path),
.target = target,
.optimize = optimize,
});
}
fn linkPlatform(b: *std.Build, target: std.Build.ResolvedTarget, app_mod: *std.Build.Module, exe: *std.Build.Step.Compile, platform: PlatformOption, web_engine: WebEngineOption, native_sdk_path: []const u8, cef_dir: []const u8, cef_auto_install: bool) void {
if (platform == .macos) {
switch (web_engine) {
.system => {
const sdk_include = if (b.sysroot) |sysroot| b.fmt("-I{s}/usr/include", .{sysroot}) else "";
const flags: []const []const u8 = if (b.sysroot) |sysroot| &.{ "-fobjc-arc", "-fno-sanitize=builtin", "-ObjC", "-mmacosx-version-min=11.0", "-isysroot", sysroot, sdk_include } else &.{ "-fobjc-arc", "-fno-sanitize=builtin", "-ObjC", "-mmacosx-version-min=11.0" };
app_mod.addCSourceFile(.{ .file = nativeSdkPath(b, native_sdk_path, "src/platform/macos/appkit_host.m"), .flags = flags });
app_mod.linkFramework("WebKit", .{});
},
.chromium => {
const cef_check = addCefCheck(b, target, cef_dir);
if (cef_auto_install) {
const cef_auto = b.addSystemCommand(&.{ "native", "cef", "install", "--dir", cef_dir });
cef_check.step.dependOn(&cef_auto.step);
}
exe.step.dependOn(&cef_check.step);
const include_arg = b.fmt("-I{s}", .{cef_dir});
const define_arg = b.fmt("-DNATIVE_SDK_CEF_DIR=\"{s}\"", .{cef_dir});
// The SDK's usr/include must stay a system include dir (searched after zig's
// bundled libc++/libc headers). A plain -I shadows libc++'s <string.h>/<math.h>
// wrappers in ObjC++ and surfaces SDK nullability gaps as a diagnostic flood.
const sdk_include = if (b.sysroot) |sysroot| b.fmt("-isystem{s}/usr/include", .{sysroot}) else "";
const flags: []const []const u8 = if (b.sysroot) |sysroot| &.{ "-fobjc-arc", "-fno-sanitize=builtin", "-ObjC++", "-std=c++17", "-stdlib=libc++", "-mmacosx-version-min=11.0", "-isysroot", sysroot, sdk_include, include_arg, define_arg } else &.{ "-fobjc-arc", "-fno-sanitize=builtin", "-ObjC++", "-std=c++17", "-stdlib=libc++", "-mmacosx-version-min=11.0", include_arg, define_arg };
app_mod.addCSourceFile(.{ .file = nativeSdkPath(b, native_sdk_path, "src/platform/macos/cef_host.mm"), .flags = flags });
app_mod.addObjectFile(b.path(b.fmt("{s}/libcef_dll_wrapper/libcef_dll_wrapper.a", .{cef_dir})));
app_mod.addFrameworkPath(b.path(b.fmt("{s}/Release", .{cef_dir})));
app_mod.linkFramework("Chromium Embedded Framework", .{});
app_mod.addRPath(.{ .cwd_relative = "@executable_path/Frameworks" });
},
}
if (b.sysroot) |sysroot| {
app_mod.addFrameworkPath(.{ .cwd_relative = b.pathJoin(&.{ sysroot, "System/Library/Frameworks" }) });
}
app_mod.linkFramework("AppKit", .{});
// The audio playback service (the AppKit host's single AVPlayer).
app_mod.linkFramework("AVFoundation", .{});
// Spectrum analysis of the app's own playback: the MediaToolbox
// audio tap hands the player's PCM to the host, and Accelerate
// (vDSP) turns it into band magnitudes.
app_mod.linkFramework("MediaToolbox", .{});
app_mod.linkFramework("Accelerate", .{});
app_mod.linkFramework("Foundation", .{});
app_mod.linkFramework("CoreText", .{});
app_mod.linkFramework("UniformTypeIdentifiers", .{});
app_mod.linkFramework("Security", .{});
app_mod.linkFramework("Metal", .{});
app_mod.linkFramework("QuartzCore", .{});
app_mod.linkSystemLibrary("c", .{});
if (web_engine == .chromium) app_mod.linkSystemLibrary("c++", .{});
} else if (platform == .linux) {
switch (web_engine) {
.system => {
app_mod.addCSourceFile(.{ .file = nativeSdkPath(b, native_sdk_path, "src/platform/linux/gtk_host.c"), .flags = &.{} });
app_mod.linkSystemLibrary("gtk4", .{});
app_mod.linkSystemLibrary("webkitgtk-6.0", .{});
app_mod.linkSystemLibrary("dl", .{});
},
.chromium => {
const cef_check = addCefCheck(b, target, cef_dir);
if (cef_auto_install) {
const cef_auto = b.addSystemCommand(&.{ "native", "cef", "install", "--dir", cef_dir });
cef_check.step.dependOn(&cef_auto.step);
}
exe.step.dependOn(&cef_check.step);
const include_arg = b.fmt("-I{s}", .{cef_dir});
const define_arg = b.fmt("-DNATIVE_SDK_CEF_DIR=\"{s}\"", .{cef_dir});
app_mod.addCSourceFile(.{ .file = nativeSdkPath(b, native_sdk_path, "src/platform/linux/cef_host.cpp"), .flags = &.{ "-std=c++17", include_arg, define_arg } });
app_mod.addObjectFile(b.path(b.fmt("{s}/libcef_dll_wrapper/libcef_dll_wrapper.a", .{cef_dir})));
app_mod.addLibraryPath(b.path(b.fmt("{s}/Release", .{cef_dir})));
app_mod.linkSystemLibrary("cef", .{});
app_mod.addRPath(.{ .cwd_relative = "$ORIGIN" });
},
}
app_mod.linkSystemLibrary("c", .{});
if (web_engine == .chromium) app_mod.linkSystemLibrary("stdc++", .{});
} else if (platform == .windows) {
switch (web_engine) {
.system => {
// The vendored WebView2 SDK header (third_party/webview2)
// turns on the host's embedded-WebView layer; the host
// fails the compile by design if it cannot be found.
app_mod.addIncludePath(nativeSdkPath(b, native_sdk_path, "third_party/webview2/include"));
app_mod.addCSourceFile(.{ .file = nativeSdkPath(b, native_sdk_path, "src/platform/windows/webview2_host.cpp"), .flags = &.{"-std=c++17"} });
// WebView2Loader.dll rides next to the installed app
// executable: the host loads it at runtime to discover
// the machine's WebView2 runtime. Canvas apps never
// touch it.
const loader = b.addInstallBinFile(nativeSdkPath(b, native_sdk_path, webView2LoaderSubPath(target)), "WebView2Loader.dll");
b.getInstallStep().dependOn(&loader.step);
},
.chromium => {
const cef_check = addCefCheck(b, target, cef_dir);
if (cef_auto_install) {
const cef_auto = b.addSystemCommand(&.{ "native", "cef", "install", "--dir", cef_dir });
cef_check.step.dependOn(&cef_auto.step);
}
exe.step.dependOn(&cef_check.step);
const include_arg = b.fmt("-I{s}", .{cef_dir});
const define_arg = b.fmt("-DNATIVE_SDK_CEF_DIR=\"{s}\"", .{cef_dir});
app_mod.addCSourceFile(.{ .file = nativeSdkPath(b, native_sdk_path, "src/platform/windows/cef_host.cpp"), .flags = &.{ "-std=c++17", include_arg, define_arg } });
app_mod.addObjectFile(b.path(b.fmt("{s}/libcef_dll_wrapper/libcef_dll_wrapper.lib", .{cef_dir})));
app_mod.addLibraryPath(b.path(b.fmt("{s}/Release", .{cef_dir})));
},
}
app_mod.linkSystemLibrary("c", .{});
app_mod.linkSystemLibrary("c++", .{});
app_mod.linkSystemLibrary("user32", .{});
app_mod.linkSystemLibrary("gdi32", .{});
app_mod.linkSystemLibrary("imm32", .{});
app_mod.linkSystemLibrary("comctl32", .{});
app_mod.linkSystemLibrary("ole32", .{});
app_mod.linkSystemLibrary("oleacc", .{});
app_mod.linkSystemLibrary("shell32", .{});
// The audio backend: Media Foundation (session + source resolver
// + streaming audio renderer) and WinHTTP (the cache fill).
app_mod.linkSystemLibrary("mf", .{});
app_mod.linkSystemLibrary("mfplat", .{});
app_mod.linkSystemLibrary("winhttp", .{});
if (web_engine == .chromium) app_mod.linkSystemLibrary("libcef", .{});
}
}
/// The vendored WebView2Loader.dll for the target architecture, relative
/// to the framework root.
fn webView2LoaderSubPath(target: std.Build.ResolvedTarget) []const u8 {
return if (target.result.cpu.arch == .aarch64)
"third_party/webview2/arm64/WebView2Loader.dll"
else
"third_party/webview2/x64/WebView2Loader.dll";
}
/// `zig build run` executes the cached artifact, which has no installed
/// WebView2Loader.dll beside it; the vendored loader's directory goes on
/// the run step's PATH so the host's LoadLibrary resolves it in dev runs.
fn addWebView2RuntimeRunFiles(b: *std.Build, target: std.Build.ResolvedTarget, run: *std.Build.Step.Run, web_engine: WebEngineOption, native_sdk_path: []const u8) void {
if (web_engine != .system) return;
if (target.result.os.tag != .windows) return;
const loader_dir = std.fs.path.dirname(webView2LoaderSubPath(target)).?;
run.addPathDir(b.pathFromRoot(b.pathJoin(&.{ native_sdk_path, loader_dir })));
}
fn addCefRuntimeRunFiles(b: *std.Build, target: std.Build.ResolvedTarget, run: *std.Build.Step.Run, exe: *std.Build.Step.Compile, web_engine: WebEngineOption, cef_dir: []const u8) void {
if (web_engine != .chromium) return;
if (target.result.os.tag != .macos) return;
const copy = b.addSystemCommand(&.{
"sh", "-c",
b.fmt(
\\set -e
\\exe="$0"
\\exe_dir="$(dirname "$exe")"
\\rm -rf "zig-out/Frameworks/Chromium Embedded Framework.framework" "zig-out/bin/Frameworks/Chromium Embedded Framework.framework" ".zig-cache/o/Frameworks/Chromium Embedded Framework.framework" &&
\\mkdir -p "zig-out/Frameworks" "zig-out/bin/Frameworks" ".zig-cache/o/Frameworks" "$exe_dir" &&
\\cp -R "{s}/Release/Chromium Embedded Framework.framework" "zig-out/Frameworks/" &&
\\cp -R "{s}/Release/Chromium Embedded Framework.framework" "zig-out/bin/Frameworks/" &&
\\cp -R "{s}/Release/Chromium Embedded Framework.framework" ".zig-cache/o/Frameworks/" &&
\\cp "{s}/Release/Chromium Embedded Framework.framework/Libraries/libEGL.dylib" "$exe_dir/" &&
\\cp "{s}/Release/Chromium Embedded Framework.framework/Libraries/libGLESv2.dylib" "$exe_dir/" &&
\\cp "{s}/Release/Chromium Embedded Framework.framework/Libraries/libvk_swiftshader.dylib" "$exe_dir/" &&
\\cp "{s}/Release/Chromium Embedded Framework.framework/Libraries/vk_swiftshader_icd.json" "$exe_dir/"
, .{ cef_dir, cef_dir, cef_dir, cef_dir, cef_dir, cef_dir, cef_dir }),
});
copy.addFileArg(exe.getEmittedBin());
run.step.dependOn(&copy.step);
}
fn addCefCheck(b: *std.Build, target: std.Build.ResolvedTarget, cef_dir: []const u8) *std.Build.Step.Run {
const script = switch (target.result.os.tag) {
.macos => b.fmt(
\\test -f "{s}/include/cef_app.h" &&
\\test -d "{s}/Release/Chromium Embedded Framework.framework" &&
\\test -f "{s}/libcef_dll_wrapper/libcef_dll_wrapper.a" || {{
\\ echo "missing CEF dependency for -Dweb-engine=chromium" >&2
\\ echo "Fix with: native cef install --dir {s}" >&2
\\ exit 1
\\}}
, .{ cef_dir, cef_dir, cef_dir, cef_dir }),
.linux => b.fmt(
\\test -f "{s}/include/cef_app.h" &&
\\test -f "{s}/Release/libcef.so" &&
\\test -f "{s}/libcef_dll_wrapper/libcef_dll_wrapper.a" || {{
\\ echo "missing CEF dependency for -Dweb-engine=chromium" >&2
\\ echo "Fix with: native cef install --dir {s}" >&2
\\ exit 1
\\}}
, .{ cef_dir, cef_dir, cef_dir, cef_dir }),
.windows => b.fmt(
\\test -f "{s}/include/cef_app.h" &&
\\test -f "{s}/Release/libcef.dll" &&
\\test -f "{s}/libcef_dll_wrapper/libcef_dll_wrapper.lib" || {{
\\ echo "missing CEF dependency for -Dweb-engine=chromium" >&2
\\ echo "Fix with: native cef install --dir {s}" >&2
\\ exit 1
\\}}
, .{ cef_dir, cef_dir, cef_dir, cef_dir }),
else => "echo unsupported CEF target >&2; exit 1",
};
return b.addSystemCommand(&.{ "sh", "-c", script });
}
const AppWebEngineConfig = struct {
web_engine: WebEngineOption = .system,
cef_dir: []const u8 = "third_party/cef/macos",
cef_auto_install: bool = false,
};
fn defaultCefDir(platform: PlatformOption, configured: []const u8) []const u8 {
if (!std.mem.eql(u8, configured, "third_party/cef/macos")) return configured;
return switch (platform) {
.linux => "third_party/cef/linux",
.windows => "third_party/cef/windows",
else => configured,
};
}
fn appWebEngineConfig() AppWebEngineConfig {
const source = @embedFile("app.zon");
var config: AppWebEngineConfig = .{};
if (stringField(source, ".web_engine")) |value| {
config.web_engine = parseWebEngine(value) orelse .system;
}
if (objectSection(source, ".cef")) |cef| {
if (stringField(cef, ".dir")) |value| config.cef_dir = value;
if (boolField(cef, ".auto_install")) |value| config.cef_auto_install = value;
}
return config;
}
fn parseWebEngine(value: []const u8) ?WebEngineOption {
if (std.mem.eql(u8, value, "system")) return .system;
if (std.mem.eql(u8, value, "chromium")) return .chromium;
return null;
}
fn stringField(source: []const u8, field: []const u8) ?[]const u8 {
const field_index = std.mem.indexOf(u8, source, field) orelse return null;
const equals = std.mem.indexOfScalarPos(u8, source, field_index, '=') orelse return null;
const start_quote = std.mem.indexOfScalarPos(u8, source, equals, '"') orelse return null;
const end_quote = std.mem.indexOfScalarPos(u8, source, start_quote + 1, '"') orelse return null;
return source[start_quote + 1 .. end_quote];
}
fn objectSection(source: []const u8, field: []const u8) ?[]const u8 {
const field_index = std.mem.indexOf(u8, source, field) orelse return null;
const open = std.mem.indexOfScalarPos(u8, source, field_index, '{') orelse return null;
var depth: usize = 0;
var index = open;
while (index < source.len) : (index += 1) {
switch (source[index]) {
'{' => depth += 1,
'}' => {
depth -= 1;
if (depth == 0) return source[open + 1 .. index];
},
else => {},
}
}
return null;
}
fn boolField(source: []const u8, field: []const u8) ?bool {
const field_index = std.mem.indexOf(u8, source, field) orelse return null;
const equals = std.mem.indexOfScalarPos(u8, source, field_index, '=') orelse return null;
var index = equals + 1;
while (index < source.len and std.ascii.isWhitespace(source[index])) : (index += 1) {}
if (std.mem.startsWith(u8, source[index..], "true")) return true;
if (std.mem.startsWith(u8, source[index..], "false")) return false;
return null;
}
+8
View File
@@ -0,0 +1,8 @@
.{
.name = .capabilities,
.fingerprint = 0x1d3b2dfd44d3e473,
.version = "0.1.0",
.minimum_zig_version = "0.16.0",
.dependencies = .{},
.paths = .{ "build.zig", "build.zig.zon", "src", "assets", "app.zon", "README.md" },
}
+233
View File
@@ -0,0 +1,233 @@
const std = @import("std");
const runner = @import("runner");
const native_sdk = @import("native_sdk");
const app_manifest = @import("app_manifest_zon");
pub const panic = std.debug.FullPanic(native_sdk.debug.capturePanic);
const manifest_file_associations = if (@hasField(@TypeOf(app_manifest), "file_associations")) app_manifest.file_associations else .{};
const manifest_url_schemes = if (@hasField(@TypeOf(app_manifest), "url_schemes")) app_manifest.url_schemes else .{};
const window_width: f32 = 900;
const window_height: f32 = 620;
const statusbar_height: f32 = 34;
const html =
\\<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
\\<meta http-equiv="Content-Security-Policy" content="default-src 'self';script-src 'self' 'unsafe-inline';style-src 'self' 'unsafe-inline'">
\\<style>:root{color-scheme:light dark}body{margin:0;padding:32px;font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text",Segoe UI,system-ui,sans-serif;background:#f8f9fb;color:#18181b}h1{margin:0 0 10px;font-size:30px;letter-spacing:0}.actions{display:grid;grid-template-columns:repeat(2,minmax(170px,1fr));gap:10px;max-width:740px;margin:20px 0}button{min-height:40px;border:1px solid #d8dde4;border-radius:7px;padding:9px 13px;font:inherit;font-weight:590;text-align:left;background:white}.primary{color:white;background:#18181b;border-color:#18181b}pre{max-width:760px;min-height:140px;padding:14px 16px;overflow:auto;border:1px solid #dde2e8;border-radius:7px;background:white;color:#374151;font-size:13px;line-height:1.45}@media(prefers-color-scheme:dark){body{background:#101214;color:#f4f4f5}button,pre{color:#f4f4f5;background:#171a20;border-color:#2b3038}.primary{color:#101214;background:#f4f4f5}}</style>
\\<h1>Capabilities</h1><p>Trusted WebView code can call native OS services only after explicit permissions and command policies.</p><div class="actions"><button class="primary" id="notify">Send Notification</button><button id="support">Check Support</button><button id="open">Open URL</button><button id="reveal">Reveal Path</button><button id="recent">Recent Documents</button><button id="clipboard">Clipboard Round Trip</button><button id="message">Show Message</button><button id="credentials">Credential Round Trip</button></div><pre id="output">Ready.</pre>
\\<script>const q=s=>document.querySelector(s),out=q("#output"),show=v=>out.textContent=JSON.stringify(v,null,2),fail=e=>out.textContent=(e.code||"error")+": "+e.message,inv=(c,p)=>window.zero.invoke(c,p),doc="/tmp/native-sdk-example.txt",recent="/tmp/recent-native-sdk-example.txt";q("#notify").onclick=async()=>{try{show(await inv("native-sdk.os.showNotification",{title:"Capabilities",subtitle:"native-sdk",body:"Notification bridge succeeded."}))}catch(e){fail(e)}};q("#support").onclick=async()=>{try{let r={},f=["open_url","reveal_path","recent_documents","notifications","dialogs","clipboard_text","clipboard_rich_data","credentials","file_drops","app_activation_events"];for(const x of f)r[x]=await inv("native-sdk.platform.supports",{feature:x});show(r)}catch(e){fail(e)}};q("#open").onclick=async()=>{try{show({opened:await inv("native-sdk.os.openUrl",{url:"https://example.com/docs/start"})})}catch(e){fail(e)}};q("#reveal").onclick=async()=>{try{show({revealed:await inv("native-sdk.os.revealPath",{path:doc})})}catch(e){fail(e)}};q("#recent").onclick=async()=>{try{await inv("native-sdk.os.addRecentDocument",{path:recent});show({cleared:await inv("native-sdk.os.clearRecentDocuments",{})})}catch(e){fail(e)}};q("#clipboard").onclick=async()=>{try{await inv("native-sdk.clipboard.writeText",{text:"Copied from Native SDK"});show({text:await inv("native-sdk.clipboard.readText",{})})}catch(e){fail(e)}};q("#message").onclick=async()=>{try{show(await inv("native-sdk.dialog.showMessage",{style:"info",title:"Capabilities",message:"Native dialog bridge succeeded.",primaryButton:"OK"}))}catch(e){fail(e)}};q("#credentials").onclick=async()=>{try{const key={service:"dev.native-sdk.capabilities",account:"demo"};await inv("native-sdk.credentials.set",{...key,secret:"demo-token"});show({token:await inv("native-sdk.credentials.get",key),deleted:await inv("native-sdk.credentials.delete",key)})}catch(e){fail(e)}};window.addEventListener("native-sdk:drop:files",e=>show(e.detail));if(window.zero&&window.zero.on){window.zero.on("app:activate",d=>show({event:"app:activate",detail:d}));window.zero.on("app:deactivate",d=>show({event:"app:deactivate",detail:d}))}</script>
;
const app_permissions = [_][]const u8{
native_sdk.security.permission_window,
native_sdk.security.permission_network,
native_sdk.security.permission_filesystem,
native_sdk.security.permission_notifications,
native_sdk.security.permission_dialog,
native_sdk.security.permission_clipboard,
native_sdk.security.permission_credentials,
};
const bridge_origins = [_][]const u8{ "zero://inline", "zero://app" };
const platform_permission = [_][]const u8{native_sdk.security.permission_window};
const network_permission = [_][]const u8{native_sdk.security.permission_network};
const filesystem_permission = [_][]const u8{native_sdk.security.permission_filesystem};
const notification_permission = [_][]const u8{native_sdk.security.permission_notifications};
const dialog_permission = [_][]const u8{native_sdk.security.permission_dialog};
const clipboard_permission = [_][]const u8{native_sdk.security.permission_clipboard};
const credential_permission = [_][]const u8{native_sdk.security.permission_credentials};
const builtin_policies = [_]native_sdk.BridgeCommandPolicy{
.{ .name = "native-sdk.platform.supports", .permissions = &platform_permission, .origins = &bridge_origins },
.{ .name = "native-sdk.os.openUrl", .permissions = &network_permission, .origins = &bridge_origins },
.{ .name = "native-sdk.os.showNotification", .permissions = &notification_permission, .origins = &bridge_origins },
.{ .name = "native-sdk.os.revealPath", .permissions = &filesystem_permission, .origins = &bridge_origins },
.{ .name = "native-sdk.os.addRecentDocument", .permissions = &filesystem_permission, .origins = &bridge_origins },
.{ .name = "native-sdk.os.clearRecentDocuments", .permissions = &filesystem_permission, .origins = &bridge_origins },
.{ .name = "native-sdk.dialog.showMessage", .permissions = &dialog_permission, .origins = &bridge_origins },
.{ .name = "native-sdk.clipboard.readText", .permissions = &clipboard_permission, .origins = &bridge_origins },
.{ .name = "native-sdk.clipboard.writeText", .permissions = &clipboard_permission, .origins = &bridge_origins },
.{ .name = "native-sdk.clipboard.read", .permissions = &clipboard_permission, .origins = &bridge_origins },
.{ .name = "native-sdk.clipboard.write", .permissions = &clipboard_permission, .origins = &bridge_origins },
.{ .name = "native-sdk.credentials.set", .permissions = &credential_permission, .origins = &bridge_origins },
.{ .name = "native-sdk.credentials.get", .permissions = &credential_permission, .origins = &bridge_origins },
.{ .name = "native-sdk.credentials.delete", .permissions = &credential_permission, .origins = &bridge_origins },
};
const shell_views = [_]native_sdk.ShellView{
.{ .label = "main", .kind = .webview, .url = "zero://inline", .fill = true },
.{ .label = "statusbar", .kind = .statusbar, .edge = .bottom, .height = statusbar_height, .layer = 20, .role = "Status" },
.{ .label = "status-label", .kind = .label, .parent = "statusbar", .x = 14, .y = 8, .width = 640, .height = 18, .layer = 21, .text = "Ready." },
};
const shell_windows = [_]native_sdk.ShellWindow{.{
.label = "main",
.title = "Native SDK Capabilities",
.width = window_width,
.height = window_height,
.views = &shell_views,
}};
const shell_scene: native_sdk.ShellConfig = .{ .windows = &shell_windows };
const CapabilitiesApp = struct {
drop_count: u32 = 0,
activation_count: u32 = 0,
deactivation_count: u32 = 0,
last_drop_paths: []const []const u8 = &.{},
fn app(self: *@This()) native_sdk.App {
return .{
.context = self,
.name = "capabilities",
.source = native_sdk.WebViewSource.html(html),
.scene_fn = scene,
.event_fn = event,
};
}
fn scene(context: *anyopaque) anyerror!native_sdk.ShellConfig {
_ = context;
return shell_scene;
}
fn event(context: *anyopaque, runtime: *native_sdk.Runtime, event_value: native_sdk.Event) anyerror!void {
const self: *@This() = @ptrCast(@alignCast(context));
switch (event_value) {
.files_dropped => |drop| {
self.drop_count += 1;
self.last_drop_paths = drop.paths;
var status_buffer: [160]u8 = undefined;
const first_path = if (drop.paths.len > 0) drop.paths[0] else "";
const status = try std.fmt.bufPrint(&status_buffer, "Received file drop {d}: {d} file(s): {s}", .{ self.drop_count, drop.paths.len, first_path });
_ = try runtime.updateView(drop.window_id, "status-label", .{ .text = status });
},
.lifecycle => |lifecycle| switch (lifecycle) {
.activate => {
self.activation_count += 1;
_ = try runtime.updateView(1, "status-label", .{ .text = "App activated." });
},
.deactivate => {
self.deactivation_count += 1;
_ = try runtime.updateView(1, "status-label", .{ .text = "App deactivated." });
},
else => {},
},
.appearance_changed, .command, .shortcut, .timer, .effects_wake, .audio, .gpu_surface_frame, .gpu_surface_resized, .gpu_surface_input, .canvas_widget_pointer, .canvas_widget_keyboard, .canvas_widget_scroll, .canvas_widget_file_drop, .canvas_widget_drag, .canvas_widget_context_menu, .canvas_widget_context_menu_request, .canvas_widget_dismiss, .canvas_widget_context_press, .canvas_widget_resize, .canvas_widget_change, .window_closed, .automation_provenance => {},
}
}
};
pub fn main(init: std.process.Init) !void {
var app = CapabilitiesApp{};
try runner.runWithOptions(app.app(), .{
.app_name = "capabilities",
.window_title = "Native SDK Capabilities",
.bundle_id = "dev.native_sdk.capabilities",
.default_frame = native_sdk.geometry.RectF.init(0, 0, window_width, window_height),
.builtin_bridge = .{ .enabled = true, .commands = &builtin_policies },
.security = .{
.permissions = &app_permissions,
.navigation = .{
.allowed_origins = &bridge_origins,
.external_links = .{
.action = .open_system_browser,
.allowed_urls = &.{"https://example.com/docs/*"},
},
},
},
}, init);
}
test "capabilities bridge gates native services and dispatches file drops" {
const harness = try native_sdk.TestHarness().create(std.testing.allocator, .{ .size = native_sdk.geometry.SizeF.init(window_width, window_height) });
defer harness.destroy(std.testing.allocator);
harness.runtime.options.builtin_bridge = .{ .enabled = true, .commands = &builtin_policies };
harness.runtime.options.security = .{
.permissions = &app_permissions,
.navigation = .{
.allowed_origins = &bridge_origins,
.external_links = .{
.action = .open_system_browser,
.allowed_urls = &.{"https://example.com/docs/*"},
},
},
};
var app_state = CapabilitiesApp{};
const app = app_state.app();
try harness.start(app);
try dispatchBridge(harness, app, "{\"id\":\"notify\",\"command\":\"native-sdk.os.showNotification\",\"payload\":{\"title\":\"Capabilities\",\"subtitle\":\"native-sdk\",\"body\":\"Done\"}}");
try std.testing.expect(std.mem.indexOf(u8, harness.null_platform.lastBridgeResponse(), "\"ok\":true") != null);
try std.testing.expectEqual(@as(usize, 1), harness.null_platform.notificationCount());
try std.testing.expectEqualStrings("Capabilities", harness.null_platform.lastNotificationTitle());
try dispatchBridge(harness, app, "{\"id\":\"support\",\"command\":\"native-sdk.platform.supports\",\"payload\":{\"feature\":\"notifications\"}}");
try std.testing.expect(std.mem.indexOf(u8, harness.null_platform.lastBridgeResponse(), "\"result\":true") != null);
try dispatchBridge(harness, app, "{\"id\":\"support-recent\",\"command\":\"native-sdk.platform.supports\",\"payload\":{\"feature\":\"recentDocuments\"}}");
try std.testing.expect(std.mem.indexOf(u8, harness.null_platform.lastBridgeResponse(), "\"result\":true") != null);
try dispatchBridge(harness, app, "{\"id\":\"open\",\"command\":\"native-sdk.os.openUrl\",\"payload\":{\"url\":\"https://example.com/docs/start\"}}");
try std.testing.expect(std.mem.indexOf(u8, harness.null_platform.lastBridgeResponse(), "\"ok\":true") != null);
try std.testing.expectEqualStrings("https://example.com/docs/start", harness.null_platform.lastExternalUrl());
try dispatchBridge(harness, app, "{\"id\":\"reveal\",\"command\":\"native-sdk.os.revealPath\",\"payload\":{\"path\":\"/tmp/native-sdk-example.txt\"}}");
try std.testing.expect(std.mem.indexOf(u8, harness.null_platform.lastBridgeResponse(), "\"ok\":true") != null);
try std.testing.expectEqualStrings("/tmp/native-sdk-example.txt", harness.null_platform.lastRevealedPath());
try dispatchBridge(harness, app, "{\"id\":\"recent\",\"command\":\"native-sdk.os.addRecentDocument\",\"payload\":{\"path\":\"/tmp/recent-native-sdk-example.txt\"}}");
try std.testing.expect(std.mem.indexOf(u8, harness.null_platform.lastBridgeResponse(), "\"ok\":true") != null);
try std.testing.expectEqualStrings("/tmp/recent-native-sdk-example.txt", harness.null_platform.lastRecentDocumentPath());
try dispatchBridge(harness, app, "{\"id\":\"clear-recent\",\"command\":\"native-sdk.os.clearRecentDocuments\",\"payload\":{}}");
try std.testing.expect(std.mem.indexOf(u8, harness.null_platform.lastBridgeResponse(), "\"ok\":true") != null);
try std.testing.expectEqual(@as(usize, 1), harness.null_platform.recentDocumentsClearedCount());
try dispatchBridge(harness, app, "{\"id\":\"write\",\"command\":\"native-sdk.clipboard.writeText\",\"payload\":{\"text\":\"plain text\"}}");
try std.testing.expect(std.mem.indexOf(u8, harness.null_platform.lastBridgeResponse(), "\"ok\":true") != null);
try std.testing.expectEqualStrings("plain text", harness.null_platform.lastClipboardData());
try dispatchBridge(harness, app, "{\"id\":\"read\",\"command\":\"native-sdk.clipboard.readText\",\"payload\":{}}");
try std.testing.expect(std.mem.indexOf(u8, harness.null_platform.lastBridgeResponse(), "\"result\":\"plain text\"") != null);
try dispatchBridge(harness, app, "{\"id\":\"set\",\"command\":\"native-sdk.credentials.set\",\"payload\":{\"service\":\"dev.native-sdk.capabilities\",\"account\":\"demo\",\"secret\":\"demo-token\"}}");
try std.testing.expectEqual(@as(usize, 1), harness.null_platform.credentialSetCount());
try dispatchBridge(harness, app, "{\"id\":\"get\",\"command\":\"native-sdk.credentials.get\",\"payload\":{\"service\":\"dev.native-sdk.capabilities\",\"account\":\"demo\"}}");
try std.testing.expect(std.mem.indexOf(u8, harness.null_platform.lastBridgeResponse(), "\"result\":\"demo-token\"") != null);
try dispatchBridge(harness, app, "{\"id\":\"delete\",\"command\":\"native-sdk.credentials.delete\",\"payload\":{\"service\":\"dev.native-sdk.capabilities\",\"account\":\"demo\"}}");
try std.testing.expect(std.mem.indexOf(u8, harness.null_platform.lastBridgeResponse(), "\"result\":true") != null);
const dropped_paths = [_][]const u8{ "/tmp/one\nname.txt", "/tmp/two.txt" };
try harness.runtime.dispatchPlatformEvent(app, .{ .files_dropped = .{
.window_id = 1,
.paths = &dropped_paths,
} });
try std.testing.expectEqual(@as(u32, 1), app_state.drop_count);
try std.testing.expectEqual(@as(usize, 2), app_state.last_drop_paths.len);
try std.testing.expectEqualStrings("/tmp/one\nname.txt", app_state.last_drop_paths[0]);
try std.testing.expectEqualStrings("/tmp/two.txt", app_state.last_drop_paths[1]);
try std.testing.expectEqualStrings("drop:files", harness.null_platform.lastWindowEventName());
try harness.runtime.dispatchPlatformEvent(app, .app_activated);
try std.testing.expectEqual(@as(u32, 1), app_state.activation_count);
try std.testing.expectEqualStrings("app:activate", harness.null_platform.lastWindowEventName());
try harness.runtime.dispatchPlatformEvent(app, .app_deactivated);
try std.testing.expectEqual(@as(u32, 1), app_state.deactivation_count);
try std.testing.expectEqualStrings("app:deactivate", harness.null_platform.lastWindowEventName());
}
test "capabilities manifest declares package integration metadata" {
try std.testing.expectEqual(@as(usize, 1), manifest_file_associations.len);
try std.testing.expectEqualStrings("Native SDK Capability Document", manifest_file_associations[0].name);
try std.testing.expectEqualStrings("viewer", manifest_file_associations[0].role);
try std.testing.expectEqualStrings("zncap", manifest_file_associations[0].extensions[0]);
try std.testing.expectEqualStrings("application/vnd.native-sdk.capability+json", manifest_file_associations[0].mime_types[0]);
try std.testing.expectEqual(@as(usize, 1), manifest_url_schemes.len);
try std.testing.expectEqualStrings("native-sdk-capabilities", manifest_url_schemes[0].scheme);
}
fn dispatchBridge(harness: *native_sdk.TestHarness(), app: native_sdk.App, bytes: []const u8) !void {
try harness.runtime.dispatchPlatformEvent(app, .{ .bridge_message = .{
.bytes = bytes,
.origin = "zero://inline",
.window_id = 1,
.webview_label = "main",
} });
}
+419
View File
@@ -0,0 +1,419 @@
const std = @import("std");
const build_options = @import("build_options");
const native_sdk = @import("native_sdk");
const app_manifest = @import("app_manifest_zon");
const manifest_shortcuts = if (@hasField(@TypeOf(app_manifest), "shortcuts")) app_manifest.shortcuts else .{};
const manifest_windows = if (@hasField(@TypeOf(app_manifest), "windows")) app_manifest.windows else .{};
pub const StdoutTraceSink = struct {
pub fn sink(self: *StdoutTraceSink) native_sdk.trace.Sink {
return .{ .context = self, .write_fn = write };
}
fn write(context: *anyopaque, record: native_sdk.trace.Record) native_sdk.trace.WriteError!void {
_ = context;
if (!shouldTrace(record)) return;
// Never fail on an oversized record: logging failures must
// degrade (truncated output), not fail dispatch upstream.
var buffer: [4096]u8 = undefined;
std.debug.print("{s}\n", .{native_sdk.trace.formatTextBounded(record, &buffer)});
}
};
pub const RunOptions = struct {
app_name: []const u8,
window_title: []const u8 = "",
bundle_id: []const u8,
icon_path: []const u8 = "assets/icon.png",
default_frame: native_sdk.geometry.RectF = native_sdk.geometry.RectF.init(0, 0, 1100, 760),
bridge: ?native_sdk.BridgeDispatcher = null,
builtin_bridge: native_sdk.BridgePolicy = .{},
js_window_api: bool = false,
security: native_sdk.SecurityPolicy = .{},
menus: []const native_sdk.Menu = &.{},
shortcuts: ?[]const native_sdk.Shortcut = null,
fn appInfo(self: RunOptions, buffers: *StateBuffers) native_sdk.AppInfo {
var info: native_sdk.AppInfo = .{
.app_name = self.app_name,
// The identity the OS shows (application menu, Dock, About
// panel) reads straight from app.zon at comptime, so dev
// runs carry the same display name and version a packaged
// bundle gets from its Info.plist.
.display_name = manifestStringField("display_name"),
.version = manifestStringField("version"),
.description = manifestStringField("description"),
.has_web_content = manifestHasWebContent(),
.window_title = self.window_title,
.bundle_id = self.bundle_id,
.icon_path = self.icon_path,
.main_window = .{
.id = 1,
.label = "main",
.title = self.window_title,
.default_frame = self.default_frame,
},
};
const windows = manifestWindowOptions(buffers);
if (windows.len > 0) {
info.main_window = windows[0];
info.windows = windows;
}
return info;
}
fn resolvedShortcuts(self: RunOptions, storage: *ShortcutStorage) []const native_sdk.Shortcut {
return self.shortcuts orelse storage.fromManifest();
}
};
const ShortcutStorage = struct {
shortcuts: [native_sdk.platform.max_shortcuts]native_sdk.Shortcut = undefined,
fn fromManifest(self: *ShortcutStorage) []const native_sdk.Shortcut {
comptime {
if (manifest_shortcuts.len > native_sdk.platform.max_shortcuts) {
@compileError("app.zon defines too many shortcuts");
}
}
inline for (manifest_shortcuts, 0..) |shortcut, index| {
self.shortcuts[index] = .{
.id = shortcut.id,
.key = shortcut.key,
.modifiers = shortcutModifiers(shortcut),
};
}
return self.shortcuts[0..manifest_shortcuts.len];
}
};
/// A top-level app.zon string field (`display_name`, `version`,
/// `description`), or "" when the manifest omits it — optional identity
/// stays optional all the way into `AppInfo`.
fn manifestStringField(comptime field: []const u8) []const u8 {
if (comptime !@hasField(@TypeOf(app_manifest), field)) return "";
const value = @field(app_manifest, field);
if (comptime @TypeOf(value) == @TypeOf(null)) return "";
return value;
}
/// Whether app.zon declares web content: the `webview` capability or a
/// `frontend` block. Hosts build honest default menus from this — web
/// items like Reload only exist when a webview can answer them, so
/// canvas-only apps never ship dead menu items.
fn manifestHasWebContent() bool {
if (comptime @hasField(@TypeOf(app_manifest), "frontend")) return true;
if (comptime !@hasField(@TypeOf(app_manifest), "capabilities")) return false;
inline for (app_manifest.capabilities) |capability| {
if (comptime std.mem.eql(u8, capability, "webview")) return true;
}
return false;
}
fn manifestWindowOptions(buffers: *StateBuffers) []const native_sdk.WindowOptions {
comptime {
if (manifest_windows.len > native_sdk.platform.max_windows) {
@compileError("app.zon defines too many windows");
}
}
inline for (manifest_windows, 0..) |window, index| {
buffers.restored_windows[index] = manifestWindow(window, index);
}
return buffers.restored_windows[0..manifest_windows.len];
}
fn manifestWindow(comptime window: anytype, comptime index: usize) native_sdk.WindowOptions {
return .{
.id = index + 1,
.label = windowLabel(window, index),
.title = windowTitle(window),
.default_frame = native_sdk.geometry.RectF.init(
windowFloat(window, "x", 0),
windowFloat(window, "y", 0),
windowFloat(window, "width", 720),
windowFloat(window, "height", 480),
),
.resizable = windowBool(window, "resizable", true),
.restore_state = windowBool(window, "restore_state", true),
.restore_policy = windowRestorePolicy(window),
};
}
fn windowLabel(comptime window: anytype, comptime index: usize) []const u8 {
if (comptime @hasField(@TypeOf(window), "label")) return window.label;
return if (index == 0) "main" else "window";
}
fn windowTitle(comptime window: anytype) []const u8 {
if (comptime !@hasField(@TypeOf(window), "title")) return "";
const title = window.title;
if (comptime @TypeOf(title) == @TypeOf(null)) return "";
return title;
}
fn windowFloat(comptime window: anytype, comptime field: []const u8, comptime default_value: f32) f32 {
if (comptime @hasField(@TypeOf(window), field)) return @field(window, field);
return default_value;
}
fn windowBool(comptime window: anytype, comptime field: []const u8, comptime default_value: bool) bool {
if (comptime @hasField(@TypeOf(window), field)) return @field(window, field);
return default_value;
}
fn windowRestorePolicy(comptime window: anytype) native_sdk.WindowRestorePolicy {
if (comptime !@hasField(@TypeOf(window), "restore_policy")) return .clamp_to_visible_screen;
const value = window.restore_policy;
if (comptime std.mem.eql(u8, value, "clamp_to_visible_screen")) return .clamp_to_visible_screen;
if (comptime std.mem.eql(u8, value, "center_on_primary")) return .center_on_primary;
@compileError("unknown app.zon window restore_policy");
}
fn shortcutModifiers(comptime shortcut: anytype) native_sdk.ShortcutModifiers {
const values = if (@hasField(@TypeOf(shortcut), "modifiers")) shortcut.modifiers else .{};
var modifiers: native_sdk.ShortcutModifiers = .{};
inline for (values) |value| {
const modifier: []const u8 = value;
if (comptime std.mem.eql(u8, modifier, "primary")) {
modifiers.primary = true;
} else if (comptime std.mem.eql(u8, modifier, "command")) {
modifiers.command = true;
} else if (comptime std.mem.eql(u8, modifier, "control")) {
modifiers.control = true;
} else if (comptime std.mem.eql(u8, modifier, "option") or std.mem.eql(u8, modifier, "alt")) {
modifiers.option = true;
} else if (comptime std.mem.eql(u8, modifier, "shift")) {
modifiers.shift = true;
} else {
@compileError("unknown app.zon shortcut modifier");
}
}
return modifiers;
}
pub fn runWithOptions(app: native_sdk.App, options: RunOptions, init: std.process.Init) !void {
if (build_options.debug_overlay) {
std.debug.print("debug-overlay=true backend={s} web-engine={s} trace={s}\n", .{ build_options.platform, build_options.web_engine, build_options.trace });
}
if (comptime std.mem.eql(u8, build_options.platform, "macos")) {
try runMacos(app, options, init);
} else if (comptime std.mem.eql(u8, build_options.platform, "linux")) {
try runLinux(app, options, init);
} else if (comptime std.mem.eql(u8, build_options.platform, "windows")) {
try runWindows(app, options, init);
} else {
try runNull(app, options, init);
}
}
fn runNull(app: native_sdk.App, options: RunOptions, init: std.process.Init) !void {
var buffers: StateBuffers = undefined;
var app_info = options.appInfo(&buffers);
const store = prepareStateStore(init.io, init.environ_map, &app_info, &buffers);
var null_platform = native_sdk.NullPlatform.initWithOptions(.{}, webEngine(), app_info);
var trace_sink = StdoutTraceSink{};
var log_buffers: native_sdk.debug.LogPathBuffers = .{};
const log_setup = native_sdk.debug.setupLogging(init.io, init.environ_map, app_info.bundle_id, &log_buffers) catch null;
if (log_setup) |setup| native_sdk.debug.installPanicCapture(init.io, setup.paths);
var file_trace_sink: native_sdk.debug.FileTraceSink = undefined;
var fanout_sinks: [2]native_sdk.trace.Sink = undefined;
var fanout_sink: native_sdk.debug.FanoutTraceSink = undefined;
var runtime_trace_sink = trace_sink.sink();
if (log_setup) |setup| {
file_trace_sink = native_sdk.debug.FileTraceSink.init(init.io, setup.paths.log_dir, setup.paths.log_file, setup.format);
fanout_sinks = .{ trace_sink.sink(), file_trace_sink.sink() };
fanout_sink = .{ .sinks = &fanout_sinks };
runtime_trace_sink = fanout_sink.sink();
}
var shortcut_storage: ShortcutStorage = .{};
const shortcuts = options.resolvedShortcuts(&shortcut_storage);
// The Runtime is tens of megabytes; construct on the heap (default
// main-thread stacks overflow on a stack instance).
const runtime = try std.heap.page_allocator.create(native_sdk.Runtime);
defer std.heap.page_allocator.destroy(runtime);
native_sdk.Runtime.initAt(runtime, .{
.platform = null_platform.platform(),
.trace_sink = runtime_trace_sink,
.log_path = if (log_setup) |setup| setup.paths.log_file else null,
.bridge = options.bridge,
.builtin_bridge = options.builtin_bridge,
.js_window_api = options.js_window_api,
.security = options.security,
.menus = options.menus,
.shortcuts = shortcuts,
.automation = if (build_options.automation) native_sdk.automation.Server.init(init.io, ".zig-cache/native-sdk-automation", app_info.resolvedWindowTitle()) else null,
.window_state_store = store,
});
try runtime.run(app);
}
fn runMacos(app: native_sdk.App, options: RunOptions, init: std.process.Init) !void {
var buffers: StateBuffers = undefined;
var app_info = options.appInfo(&buffers);
const store = prepareStateStore(init.io, init.environ_map, &app_info, &buffers);
var mac_platform = try native_sdk.platform.macos.MacPlatform.initWithOptions(native_sdk.geometry.SizeF.init(720, 480), webEngine(), app_info);
defer mac_platform.deinit();
var trace_sink = StdoutTraceSink{};
var log_buffers: native_sdk.debug.LogPathBuffers = .{};
const log_setup = native_sdk.debug.setupLogging(init.io, init.environ_map, app_info.bundle_id, &log_buffers) catch null;
if (log_setup) |setup| native_sdk.debug.installPanicCapture(init.io, setup.paths);
var file_trace_sink: native_sdk.debug.FileTraceSink = undefined;
var fanout_sinks: [2]native_sdk.trace.Sink = undefined;
var fanout_sink: native_sdk.debug.FanoutTraceSink = undefined;
var runtime_trace_sink = trace_sink.sink();
if (log_setup) |setup| {
file_trace_sink = native_sdk.debug.FileTraceSink.init(init.io, setup.paths.log_dir, setup.paths.log_file, setup.format);
fanout_sinks = .{ trace_sink.sink(), file_trace_sink.sink() };
fanout_sink = .{ .sinks = &fanout_sinks };
runtime_trace_sink = fanout_sink.sink();
}
var shortcut_storage: ShortcutStorage = .{};
const shortcuts = options.resolvedShortcuts(&shortcut_storage);
// The Runtime is tens of megabytes; construct on the heap (default
// main-thread stacks overflow on a stack instance).
const runtime = try std.heap.page_allocator.create(native_sdk.Runtime);
defer std.heap.page_allocator.destroy(runtime);
native_sdk.Runtime.initAt(runtime, .{
.platform = mac_platform.platform(),
.trace_sink = runtime_trace_sink,
.log_path = if (log_setup) |setup| setup.paths.log_file else null,
.bridge = options.bridge,
.builtin_bridge = options.builtin_bridge,
.js_window_api = options.js_window_api,
.security = options.security,
.menus = options.menus,
.shortcuts = shortcuts,
.automation = if (build_options.automation) native_sdk.automation.Server.init(init.io, ".zig-cache/native-sdk-automation", app_info.resolvedWindowTitle()) else null,
.window_state_store = store,
});
try runtime.run(app);
}
fn runLinux(app: native_sdk.App, options: RunOptions, init: std.process.Init) !void {
var buffers: StateBuffers = undefined;
var app_info = options.appInfo(&buffers);
const store = prepareStateStore(init.io, init.environ_map, &app_info, &buffers);
var linux_platform = try native_sdk.platform.linux.LinuxPlatform.initWithOptions(native_sdk.geometry.SizeF.init(720, 480), webEngine(), app_info);
defer linux_platform.deinit();
var trace_sink = StdoutTraceSink{};
var log_buffers: native_sdk.debug.LogPathBuffers = .{};
const log_setup = native_sdk.debug.setupLogging(init.io, init.environ_map, app_info.bundle_id, &log_buffers) catch null;
if (log_setup) |setup| native_sdk.debug.installPanicCapture(init.io, setup.paths);
var file_trace_sink: native_sdk.debug.FileTraceSink = undefined;
var fanout_sinks: [2]native_sdk.trace.Sink = undefined;
var fanout_sink: native_sdk.debug.FanoutTraceSink = undefined;
var runtime_trace_sink = trace_sink.sink();
if (log_setup) |setup| {
file_trace_sink = native_sdk.debug.FileTraceSink.init(init.io, setup.paths.log_dir, setup.paths.log_file, setup.format);
fanout_sinks = .{ trace_sink.sink(), file_trace_sink.sink() };
fanout_sink = .{ .sinks = &fanout_sinks };
runtime_trace_sink = fanout_sink.sink();
}
var shortcut_storage: ShortcutStorage = .{};
const shortcuts = options.resolvedShortcuts(&shortcut_storage);
// The Runtime is tens of megabytes; construct on the heap (default
// main-thread stacks overflow on a stack instance).
const runtime = try std.heap.page_allocator.create(native_sdk.Runtime);
defer std.heap.page_allocator.destroy(runtime);
native_sdk.Runtime.initAt(runtime, .{
.platform = linux_platform.platform(),
.trace_sink = runtime_trace_sink,
.log_path = if (log_setup) |setup| setup.paths.log_file else null,
.bridge = options.bridge,
.builtin_bridge = options.builtin_bridge,
.js_window_api = options.js_window_api,
.security = options.security,
.menus = options.menus,
.shortcuts = shortcuts,
.automation = if (build_options.automation) native_sdk.automation.Server.init(init.io, ".zig-cache/native-sdk-automation", app_info.resolvedWindowTitle()) else null,
.window_state_store = store,
});
try runtime.run(app);
}
fn runWindows(app: native_sdk.App, options: RunOptions, init: std.process.Init) !void {
var buffers: StateBuffers = undefined;
var app_info = options.appInfo(&buffers);
const store = prepareStateStore(init.io, init.environ_map, &app_info, &buffers);
var windows_platform = try native_sdk.platform.windows.WindowsPlatform.initWithOptions(native_sdk.geometry.SizeF.init(720, 480), webEngine(), app_info);
defer windows_platform.deinit();
var trace_sink = StdoutTraceSink{};
var log_buffers: native_sdk.debug.LogPathBuffers = .{};
const log_setup = native_sdk.debug.setupLogging(init.io, init.environ_map, app_info.bundle_id, &log_buffers) catch null;
if (log_setup) |setup| native_sdk.debug.installPanicCapture(init.io, setup.paths);
var file_trace_sink: native_sdk.debug.FileTraceSink = undefined;
var fanout_sinks: [2]native_sdk.trace.Sink = undefined;
var fanout_sink: native_sdk.debug.FanoutTraceSink = undefined;
var runtime_trace_sink = trace_sink.sink();
if (log_setup) |setup| {
file_trace_sink = native_sdk.debug.FileTraceSink.init(init.io, setup.paths.log_dir, setup.paths.log_file, setup.format);
fanout_sinks = .{ trace_sink.sink(), file_trace_sink.sink() };
fanout_sink = .{ .sinks = &fanout_sinks };
runtime_trace_sink = fanout_sink.sink();
}
var shortcut_storage: ShortcutStorage = .{};
const shortcuts = options.resolvedShortcuts(&shortcut_storage);
// The Runtime is tens of megabytes; construct on the heap (default
// main-thread stacks overflow on a stack instance).
const runtime = try std.heap.page_allocator.create(native_sdk.Runtime);
defer std.heap.page_allocator.destroy(runtime);
native_sdk.Runtime.initAt(runtime, .{
.platform = windows_platform.platform(),
.trace_sink = runtime_trace_sink,
.log_path = if (log_setup) |setup| setup.paths.log_file else null,
.bridge = options.bridge,
.builtin_bridge = options.builtin_bridge,
.js_window_api = options.js_window_api,
.security = options.security,
.menus = options.menus,
.shortcuts = shortcuts,
.automation = if (build_options.automation) native_sdk.automation.Server.init(init.io, ".zig-cache/native-sdk-automation", app_info.resolvedWindowTitle()) else null,
.window_state_store = store,
});
try runtime.run(app);
}
fn shouldTrace(record: native_sdk.trace.Record) bool {
if (comptime std.mem.eql(u8, build_options.trace, "off")) return false;
if (comptime std.mem.eql(u8, build_options.trace, "all")) return true;
if (comptime std.mem.eql(u8, build_options.trace, "events")) return true;
return std.mem.indexOf(u8, record.name, build_options.trace) != null;
}
fn webEngine() native_sdk.WebEngine {
if (comptime std.mem.eql(u8, build_options.web_engine, "chromium")) return .chromium;
return .system;
}
const StateBuffers = struct {
state_dir: [1024]u8 = undefined,
file_path: [1200]u8 = undefined,
read: [8192]u8 = undefined,
restored_windows: [native_sdk.platform.max_windows]native_sdk.WindowOptions = undefined,
};
fn prepareStateStore(io: std.Io, env_map: *std.process.Environ.Map, app_info: *native_sdk.AppInfo, buffers: *StateBuffers) ?native_sdk.window_state.Store {
const paths = native_sdk.window_state.defaultPaths(&buffers.state_dir, &buffers.file_path, app_info.bundle_id, native_sdk.debug.envFromMap(env_map)) catch return null;
const store = native_sdk.window_state.Store.init(io, paths.state_dir, paths.file_path);
if (app_info.windows.len > 0) {
const restored_windows = buffers.restored_windows[0..app_info.windows.len];
for (restored_windows, 0..) |*window, index| {
if (!window.restore_state) continue;
if (store.loadWindow(window.label, &buffers.read) catch null) |saved| {
window.default_frame = saved.frame;
if (index == 0) app_info.main_window.default_frame = saved.frame;
}
}
} else if (app_info.main_window.restore_state) {
if (store.loadWindow(app_info.main_window.label, &buffers.read) catch null) |saved| {
app_info.main_window.default_frame = saved.frame;
}
}
return store;
}
+22
View File
@@ -0,0 +1,22 @@
# Native SDK command-app example
This example shows one `app.sync` command handled from each user-facing entry point:
- Native toolbar button.
- Native menu item.
- Native tray item.
- App shortcut.
- WebView bridge call.
- Manifest command catalog listing from the WebView.
Run with the system backend:
```sh
zig build run -Dplatform=macos -Dweb-engine=system
```
Run the headless test path:
```sh
zig build test -Dplatform=null
```
+49
View File
@@ -0,0 +1,49 @@
.{
.id = "dev.native_sdk.command_app",
.name = "command-app",
.display_name = "Command App",
.version = "0.1.0",
.platforms = .{ "macos", "linux" },
.permissions = .{ "command" },
.capabilities = .{ "webview", "js_bridge", "native_views", "menus", "shortcuts", "tray" },
.commands = .{
.{ .id = "app.sync", .title = "Sync" },
},
.shortcuts = .{
.{ .id = "app.sync", .key = "s", .modifiers = .{ "primary" } },
},
.menus = .{
.{
.title = "View",
.items = .{
.{ .label = "Sync", .command = "app.sync", .key = "s", .modifiers = .{ "primary" } },
},
},
},
.shell = .{
.windows = .{
.{
.label = "main",
.title = "Native SDK Command App",
.width = 860,
.height = 560,
.restore_policy = "center_on_primary",
.views = .{
.{ .label = "toolbar", .kind = "toolbar", .edge = "top", .height = 48, .role = "Toolbar" },
.{ .label = "sync-button", .kind = "button", .parent = "toolbar", .x = 12, .y = 9, .width = 92, .height = 30, .accessibility_label = "Sync now", .text = "Sync", .command = "app.sync" },
.{ .label = "main", .kind = "webview", .url = "zero://inline", .fill = true },
.{ .label = "statusbar", .kind = "statusbar", .edge = "bottom", .height = 34, .role = "Status" },
.{ .label = "status-label", .kind = "label", .parent = "statusbar", .x = 14, .y = 8, .width = 520, .height = 18, .text = "Ready. Use the toolbar, menu, tray, shortcut, or WebView button." },
},
},
},
},
.security = .{
.navigation = .{
.allowed_origins = .{ "zero://app", "zero://inline" },
.external_links = .{ .action = "deny" },
},
},
.web_engine = "system",
.cef = .{ .dir = "third_party/cef/macos", .auto_install = false },
}
+450
View File
@@ -0,0 +1,450 @@
// This example owns its build: it hand-wires the framework modules, src/runner.zig, and the -Dtrace/-Djs-bridge/-Dweb-engine options that the generated app graph does not expose.
const std = @import("std");
const PlatformOption = enum {
auto,
null,
macos,
linux,
windows,
};
const TraceOption = enum {
off,
events,
runtime,
all,
};
const WebEngineOption = enum {
system,
chromium,
};
const default_native_sdk_path = "../../";
const app_exe_name = "command-app";
pub fn build(b: *std.Build) void {
const target = nativeSdkTarget(b);
const optimize = b.standardOptimizeOption(.{});
const platform_option = b.option(PlatformOption, "platform", "Desktop backend: auto, null, macos, linux, windows") orelse .auto;
const trace_option = b.option(TraceOption, "trace", "Trace output: off, events, runtime, all") orelse .events;
const debug_overlay = b.option(bool, "debug-overlay", "Enable debug overlay output") orelse false;
const automation_enabled = b.option(bool, "automation", "Enable Native SDK automation artifacts") orelse false;
const js_bridge_enabled = b.option(bool, "js-bridge", "Enable optional JavaScript bridge stubs") orelse false;
const web_engine_override = b.option(WebEngineOption, "web-engine", "Override app.zon web engine: system, chromium");
const cef_dir_override = b.option([]const u8, "cef-dir", "Override CEF root directory for Chromium builds");
const cef_auto_install_override = b.option(bool, "cef-auto-install", "Override app.zon CEF auto-install setting");
const native_sdk_path = b.option([]const u8, "native-sdk-path", "Path to the Native SDK framework checkout") orelse default_native_sdk_path;
const selected_platform: PlatformOption = switch (platform_option) {
.auto => if (target.result.os.tag == .macos) .macos else if (target.result.os.tag == .linux) .linux else if (target.result.os.tag == .windows) .windows else .null,
else => platform_option,
};
if (selected_platform == .macos and target.result.os.tag != .macos) {
@panic("-Dplatform=macos requires a macOS target");
}
if (selected_platform == .linux and target.result.os.tag != .linux) {
@panic("-Dplatform=linux requires a Linux target");
}
if (selected_platform == .windows and target.result.os.tag != .windows) {
@panic("-Dplatform=windows requires a Windows target");
}
const app_web_engine = appWebEngineConfig();
const web_engine = web_engine_override orelse app_web_engine.web_engine;
const cef_dir = cef_dir_override orelse defaultCefDir(selected_platform, app_web_engine.cef_dir);
const cef_auto_install = cef_auto_install_override orelse app_web_engine.cef_auto_install;
if (web_engine == .chromium and selected_platform != .macos) {
@panic("-Dweb-engine=chromium currently requires -Dplatform=macos");
}
const native_sdk_mod = nativeSdkModule(b, target, optimize, native_sdk_path);
const options = b.addOptions();
options.addOption([]const u8, "platform", switch (selected_platform) {
.auto => unreachable,
.null => "null",
.macos => "macos",
.linux => "linux",
.windows => "windows",
});
options.addOption([]const u8, "trace", @tagName(trace_option));
options.addOption([]const u8, "web_engine", @tagName(web_engine));
options.addOption(bool, "debug_overlay", debug_overlay);
options.addOption(bool, "automation", automation_enabled);
options.addOption(bool, "js_bridge", js_bridge_enabled);
const options_mod = options.createModule();
const runner_mod = localModule(b, target, optimize, "src/runner.zig");
runner_mod.addImport("native_sdk", native_sdk_mod);
runner_mod.addImport("build_options", options_mod);
runner_mod.addImport("app_manifest_zon", b.createModule(.{ .root_source_file = b.path("app.zon") }));
const app_mod = localModule(b, target, optimize, "src/main.zig");
app_mod.addImport("native_sdk", native_sdk_mod);
app_mod.addImport("runner", runner_mod);
const exe = b.addExecutable(.{
.name = app_exe_name,
.root_module = app_mod,
});
linkPlatform(b, target, app_mod, exe, selected_platform, web_engine, native_sdk_path, cef_dir, cef_auto_install);
b.installArtifact(exe);
const run = b.addRunArtifact(exe);
addCefRuntimeRunFiles(b, target, run, exe, web_engine, cef_dir);
addWebView2RuntimeRunFiles(b, target, run, web_engine, native_sdk_path);
const run_step = b.step("run", "Run the app");
run_step.dependOn(&run.step);
const tests = b.addTest(.{ .root_module = app_mod });
const test_step = b.step("test", "Run tests");
test_step.dependOn(&b.addRunArtifact(tests).step);
}
fn nativeSdkTarget(b: *std.Build) std.Build.ResolvedTarget {
const target = b.standardTargetOptions(.{});
if (target.result.os.tag != .macos) return target;
if (b.sysroot == null) {
b.sysroot = macosSdkPath(b) orelse b.sysroot;
}
var query = target.query;
query.os_tag = .macos;
query.os_version_min = .{ .semver = .{ .major = 11, .minor = 0, .patch = 0 } };
return b.resolveTargetQuery(query);
}
fn macosSdkPath(b: *std.Build) ?[]const u8 {
if (b.graph.environ_map.get("SDKROOT")) |sdkroot| {
if (sdkroot.len > 0) return sdkroot;
}
const result = std.process.run(b.allocator, b.graph.io, .{
.argv = &.{ "xcrun", "--sdk", "macosx", "--show-sdk-path" },
.stdout_limit = .limited(4096),
.stderr_limit = .limited(4096),
}) catch return null;
defer b.allocator.free(result.stderr);
if (result.term != .exited or result.term.exited != 0) {
b.allocator.free(result.stdout);
return null;
}
return std.mem.trimEnd(u8, result.stdout, "\r\n");
}
fn localModule(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, path: []const u8) *std.Build.Module {
return b.createModule(.{
.root_source_file = b.path(path),
.target = target,
.optimize = optimize,
});
}
fn nativeSdkPath(b: *std.Build, native_sdk_path: []const u8, sub_path: []const u8) std.Build.LazyPath {
return .{ .cwd_relative = b.pathJoin(&.{ native_sdk_path, sub_path }) };
}
fn nativeSdkModule(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, native_sdk_path: []const u8) *std.Build.Module {
const geometry_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/geometry/root.zig");
const assets_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/assets/root.zig");
const app_dirs_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/app_dirs/root.zig");
const trace_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/trace/root.zig");
const app_manifest_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/app_manifest/root.zig");
const diagnostics_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/diagnostics/root.zig");
const platform_info_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/platform_info/root.zig");
const json_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/json/root.zig");
const canvas_mod = externalModule(b, target, optimize, native_sdk_path, "src/primitives/canvas/root.zig");
canvas_mod.addImport("geometry", geometry_mod);
canvas_mod.addImport("json", json_mod);
const debug_mod = externalModule(b, target, optimize, native_sdk_path, "src/debug/root.zig");
debug_mod.addImport("app_dirs", app_dirs_mod);
debug_mod.addImport("trace", trace_mod);
const native_sdk_mod = externalModule(b, target, optimize, native_sdk_path, "src/root.zig");
native_sdk_mod.addImport("geometry", geometry_mod);
native_sdk_mod.addImport("assets", assets_mod);
native_sdk_mod.addImport("app_dirs", app_dirs_mod);
native_sdk_mod.addImport("trace", trace_mod);
native_sdk_mod.addImport("app_manifest", app_manifest_mod);
native_sdk_mod.addImport("diagnostics", diagnostics_mod);
native_sdk_mod.addImport("platform_info", platform_info_mod);
native_sdk_mod.addImport("json", json_mod);
native_sdk_mod.addImport("canvas", canvas_mod);
return native_sdk_mod;
}
fn externalModule(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, native_sdk_path: []const u8, path: []const u8) *std.Build.Module {
return b.createModule(.{
.root_source_file = nativeSdkPath(b, native_sdk_path, path),
.target = target,
.optimize = optimize,
});
}
fn linkPlatform(b: *std.Build, target: std.Build.ResolvedTarget, app_mod: *std.Build.Module, exe: *std.Build.Step.Compile, platform: PlatformOption, web_engine: WebEngineOption, native_sdk_path: []const u8, cef_dir: []const u8, cef_auto_install: bool) void {
if (platform == .macos) {
switch (web_engine) {
.system => {
const sdk_include = if (b.sysroot) |sysroot| b.fmt("-I{s}/usr/include", .{sysroot}) else "";
const flags: []const []const u8 = if (b.sysroot) |sysroot| &.{ "-fobjc-arc", "-fno-sanitize=builtin", "-ObjC", "-mmacosx-version-min=11.0", "-isysroot", sysroot, sdk_include } else &.{ "-fobjc-arc", "-fno-sanitize=builtin", "-ObjC", "-mmacosx-version-min=11.0" };
app_mod.addCSourceFile(.{ .file = nativeSdkPath(b, native_sdk_path, "src/platform/macos/appkit_host.m"), .flags = flags });
app_mod.linkFramework("WebKit", .{});
},
.chromium => {
const cef_check = addCefCheck(b, target, cef_dir);
if (cef_auto_install) {
const cef_auto = b.addSystemCommand(&.{ "native", "cef", "install", "--dir", cef_dir });
cef_check.step.dependOn(&cef_auto.step);
}
exe.step.dependOn(&cef_check.step);
const include_arg = b.fmt("-I{s}", .{cef_dir});
const define_arg = b.fmt("-DNATIVE_SDK_CEF_DIR=\"{s}\"", .{cef_dir});
// The SDK's usr/include must stay a system include dir (searched after zig's
// bundled libc++/libc headers). A plain -I shadows libc++'s <string.h>/<math.h>
// wrappers in ObjC++ and surfaces SDK nullability gaps as a diagnostic flood.
const sdk_include = if (b.sysroot) |sysroot| b.fmt("-isystem{s}/usr/include", .{sysroot}) else "";
const flags: []const []const u8 = if (b.sysroot) |sysroot| &.{ "-fobjc-arc", "-fno-sanitize=builtin", "-ObjC++", "-std=c++17", "-stdlib=libc++", "-mmacosx-version-min=11.0", "-isysroot", sysroot, sdk_include, include_arg, define_arg } else &.{ "-fobjc-arc", "-fno-sanitize=builtin", "-ObjC++", "-std=c++17", "-stdlib=libc++", "-mmacosx-version-min=11.0", include_arg, define_arg };
app_mod.addCSourceFile(.{ .file = nativeSdkPath(b, native_sdk_path, "src/platform/macos/cef_host.mm"), .flags = flags });
app_mod.addObjectFile(b.path(b.fmt("{s}/libcef_dll_wrapper/libcef_dll_wrapper.a", .{cef_dir})));
app_mod.addFrameworkPath(b.path(b.fmt("{s}/Release", .{cef_dir})));
app_mod.linkFramework("Chromium Embedded Framework", .{});
app_mod.addRPath(.{ .cwd_relative = "@executable_path/Frameworks" });
},
}
if (b.sysroot) |sysroot| {
app_mod.addFrameworkPath(.{ .cwd_relative = b.pathJoin(&.{ sysroot, "System/Library/Frameworks" }) });
}
app_mod.linkFramework("AppKit", .{});
// The audio playback service (the AppKit host's single AVPlayer).
app_mod.linkFramework("AVFoundation", .{});
// Spectrum analysis of the app's own playback: the MediaToolbox
// audio tap hands the player's PCM to the host, and Accelerate
// (vDSP) turns it into band magnitudes.
app_mod.linkFramework("MediaToolbox", .{});
app_mod.linkFramework("Accelerate", .{});
app_mod.linkFramework("Foundation", .{});
app_mod.linkFramework("CoreText", .{});
app_mod.linkFramework("UniformTypeIdentifiers", .{});
app_mod.linkFramework("Security", .{});
app_mod.linkFramework("Metal", .{});
app_mod.linkFramework("QuartzCore", .{});
app_mod.linkSystemLibrary("c", .{});
if (web_engine == .chromium) app_mod.linkSystemLibrary("c++", .{});
} else if (platform == .linux) {
switch (web_engine) {
.system => {
app_mod.addCSourceFile(.{ .file = nativeSdkPath(b, native_sdk_path, "src/platform/linux/gtk_host.c"), .flags = &.{} });
app_mod.linkSystemLibrary("gtk4", .{});
app_mod.linkSystemLibrary("webkitgtk-6.0", .{});
app_mod.linkSystemLibrary("dl", .{});
},
.chromium => {
const cef_check = addCefCheck(b, target, cef_dir);
if (cef_auto_install) {
const cef_auto = b.addSystemCommand(&.{ "native", "cef", "install", "--dir", cef_dir });
cef_check.step.dependOn(&cef_auto.step);
}
exe.step.dependOn(&cef_check.step);
const include_arg = b.fmt("-I{s}", .{cef_dir});
const define_arg = b.fmt("-DNATIVE_SDK_CEF_DIR=\"{s}\"", .{cef_dir});
app_mod.addCSourceFile(.{ .file = nativeSdkPath(b, native_sdk_path, "src/platform/linux/cef_host.cpp"), .flags = &.{ "-std=c++17", include_arg, define_arg } });
app_mod.addObjectFile(b.path(b.fmt("{s}/libcef_dll_wrapper/libcef_dll_wrapper.a", .{cef_dir})));
app_mod.addLibraryPath(b.path(b.fmt("{s}/Release", .{cef_dir})));
app_mod.linkSystemLibrary("cef", .{});
app_mod.addRPath(.{ .cwd_relative = "$ORIGIN" });
},
}
app_mod.linkSystemLibrary("c", .{});
if (web_engine == .chromium) app_mod.linkSystemLibrary("stdc++", .{});
} else if (platform == .windows) {
switch (web_engine) {
.system => {
// The vendored WebView2 SDK header (third_party/webview2)
// turns on the host's embedded-WebView layer; the host
// fails the compile by design if it cannot be found.
app_mod.addIncludePath(nativeSdkPath(b, native_sdk_path, "third_party/webview2/include"));
app_mod.addCSourceFile(.{ .file = nativeSdkPath(b, native_sdk_path, "src/platform/windows/webview2_host.cpp"), .flags = &.{"-std=c++17"} });
// WebView2Loader.dll rides next to the installed app
// executable: the host loads it at runtime to discover
// the machine's WebView2 runtime. Canvas apps never
// touch it.
const loader = b.addInstallBinFile(nativeSdkPath(b, native_sdk_path, webView2LoaderSubPath(target)), "WebView2Loader.dll");
b.getInstallStep().dependOn(&loader.step);
},
.chromium => {
const cef_check = addCefCheck(b, target, cef_dir);
if (cef_auto_install) {
const cef_auto = b.addSystemCommand(&.{ "native", "cef", "install", "--dir", cef_dir });
cef_check.step.dependOn(&cef_auto.step);
}
exe.step.dependOn(&cef_check.step);
const include_arg = b.fmt("-I{s}", .{cef_dir});
const define_arg = b.fmt("-DNATIVE_SDK_CEF_DIR=\"{s}\"", .{cef_dir});
app_mod.addCSourceFile(.{ .file = nativeSdkPath(b, native_sdk_path, "src/platform/windows/cef_host.cpp"), .flags = &.{ "-std=c++17", include_arg, define_arg } });
app_mod.addObjectFile(b.path(b.fmt("{s}/libcef_dll_wrapper/libcef_dll_wrapper.lib", .{cef_dir})));
app_mod.addLibraryPath(b.path(b.fmt("{s}/Release", .{cef_dir})));
},
}
app_mod.linkSystemLibrary("c", .{});
app_mod.linkSystemLibrary("c++", .{});
app_mod.linkSystemLibrary("user32", .{});
app_mod.linkSystemLibrary("gdi32", .{});
app_mod.linkSystemLibrary("imm32", .{});
app_mod.linkSystemLibrary("comctl32", .{});
app_mod.linkSystemLibrary("ole32", .{});
app_mod.linkSystemLibrary("oleacc", .{});
app_mod.linkSystemLibrary("shell32", .{});
// The audio backend: Media Foundation (session + source resolver
// + streaming audio renderer) and WinHTTP (the cache fill).
app_mod.linkSystemLibrary("mf", .{});
app_mod.linkSystemLibrary("mfplat", .{});
app_mod.linkSystemLibrary("winhttp", .{});
if (web_engine == .chromium) app_mod.linkSystemLibrary("libcef", .{});
}
}
/// The vendored WebView2Loader.dll for the target architecture, relative
/// to the framework root.
fn webView2LoaderSubPath(target: std.Build.ResolvedTarget) []const u8 {
return if (target.result.cpu.arch == .aarch64)
"third_party/webview2/arm64/WebView2Loader.dll"
else
"third_party/webview2/x64/WebView2Loader.dll";
}
/// `zig build run` executes the cached artifact, which has no installed
/// WebView2Loader.dll beside it; the vendored loader's directory goes on
/// the run step's PATH so the host's LoadLibrary resolves it in dev runs.
fn addWebView2RuntimeRunFiles(b: *std.Build, target: std.Build.ResolvedTarget, run: *std.Build.Step.Run, web_engine: WebEngineOption, native_sdk_path: []const u8) void {
if (web_engine != .system) return;
if (target.result.os.tag != .windows) return;
const loader_dir = std.fs.path.dirname(webView2LoaderSubPath(target)).?;
run.addPathDir(b.pathFromRoot(b.pathJoin(&.{ native_sdk_path, loader_dir })));
}
fn addCefRuntimeRunFiles(b: *std.Build, target: std.Build.ResolvedTarget, run: *std.Build.Step.Run, exe: *std.Build.Step.Compile, web_engine: WebEngineOption, cef_dir: []const u8) void {
if (web_engine != .chromium) return;
if (target.result.os.tag != .macos) return;
const copy = b.addSystemCommand(&.{
"sh", "-c",
b.fmt(
\\set -e
\\exe="$0"
\\exe_dir="$(dirname "$exe")"
\\rm -rf "zig-out/Frameworks/Chromium Embedded Framework.framework" "zig-out/bin/Frameworks/Chromium Embedded Framework.framework" ".zig-cache/o/Frameworks/Chromium Embedded Framework.framework" &&
\\mkdir -p "zig-out/Frameworks" "zig-out/bin/Frameworks" ".zig-cache/o/Frameworks" "$exe_dir" &&
\\cp -R "{s}/Release/Chromium Embedded Framework.framework" "zig-out/Frameworks/" &&
\\cp -R "{s}/Release/Chromium Embedded Framework.framework" "zig-out/bin/Frameworks/" &&
\\cp -R "{s}/Release/Chromium Embedded Framework.framework" ".zig-cache/o/Frameworks/" &&
\\cp "{s}/Release/Chromium Embedded Framework.framework/Libraries/libEGL.dylib" "$exe_dir/" &&
\\cp "{s}/Release/Chromium Embedded Framework.framework/Libraries/libGLESv2.dylib" "$exe_dir/" &&
\\cp "{s}/Release/Chromium Embedded Framework.framework/Libraries/libvk_swiftshader.dylib" "$exe_dir/" &&
\\cp "{s}/Release/Chromium Embedded Framework.framework/Libraries/vk_swiftshader_icd.json" "$exe_dir/"
, .{ cef_dir, cef_dir, cef_dir, cef_dir, cef_dir, cef_dir, cef_dir }),
});
copy.addFileArg(exe.getEmittedBin());
run.step.dependOn(&copy.step);
}
fn addCefCheck(b: *std.Build, target: std.Build.ResolvedTarget, cef_dir: []const u8) *std.Build.Step.Run {
const script = switch (target.result.os.tag) {
.macos => b.fmt(
\\test -f "{s}/include/cef_app.h" &&
\\test -d "{s}/Release/Chromium Embedded Framework.framework" &&
\\test -f "{s}/libcef_dll_wrapper/libcef_dll_wrapper.a" || {{
\\ echo "missing CEF dependency for -Dweb-engine=chromium" >&2
\\ echo "Fix with: native cef install --dir {s}" >&2
\\ exit 1
\\}}
, .{ cef_dir, cef_dir, cef_dir, cef_dir }),
.linux => b.fmt(
\\test -f "{s}/include/cef_app.h" &&
\\test -f "{s}/Release/libcef.so" &&
\\test -f "{s}/libcef_dll_wrapper/libcef_dll_wrapper.a" || {{
\\ echo "missing CEF dependency for -Dweb-engine=chromium" >&2
\\ echo "Fix with: native cef install --dir {s}" >&2
\\ exit 1
\\}}
, .{ cef_dir, cef_dir, cef_dir, cef_dir }),
.windows => b.fmt(
\\test -f "{s}/include/cef_app.h" &&
\\test -f "{s}/Release/libcef.dll" &&
\\test -f "{s}/libcef_dll_wrapper/libcef_dll_wrapper.lib" || {{
\\ echo "missing CEF dependency for -Dweb-engine=chromium" >&2
\\ echo "Fix with: native cef install --dir {s}" >&2
\\ exit 1
\\}}
, .{ cef_dir, cef_dir, cef_dir, cef_dir }),
else => "echo unsupported CEF target >&2; exit 1",
};
return b.addSystemCommand(&.{ "sh", "-c", script });
}
const AppWebEngineConfig = struct {
web_engine: WebEngineOption = .system,
cef_dir: []const u8 = "third_party/cef/macos",
cef_auto_install: bool = false,
};
fn defaultCefDir(platform: PlatformOption, configured: []const u8) []const u8 {
if (!std.mem.eql(u8, configured, "third_party/cef/macos")) return configured;
return switch (platform) {
.linux => "third_party/cef/linux",
.windows => "third_party/cef/windows",
else => configured,
};
}
fn appWebEngineConfig() AppWebEngineConfig {
const source = @embedFile("app.zon");
var config: AppWebEngineConfig = .{};
if (stringField(source, ".web_engine")) |value| {
config.web_engine = parseWebEngine(value) orelse .system;
}
if (objectSection(source, ".cef")) |cef| {
if (stringField(cef, ".dir")) |value| config.cef_dir = value;
if (boolField(cef, ".auto_install")) |value| config.cef_auto_install = value;
}
return config;
}
fn parseWebEngine(value: []const u8) ?WebEngineOption {
if (std.mem.eql(u8, value, "system")) return .system;
if (std.mem.eql(u8, value, "chromium")) return .chromium;
return null;
}
fn stringField(source: []const u8, field: []const u8) ?[]const u8 {
const field_index = std.mem.indexOf(u8, source, field) orelse return null;
const equals = std.mem.indexOfScalarPos(u8, source, field_index, '=') orelse return null;
const start_quote = std.mem.indexOfScalarPos(u8, source, equals, '"') orelse return null;
const end_quote = std.mem.indexOfScalarPos(u8, source, start_quote + 1, '"') orelse return null;
return source[start_quote + 1 .. end_quote];
}
fn objectSection(source: []const u8, field: []const u8) ?[]const u8 {
const field_index = std.mem.indexOf(u8, source, field) orelse return null;
const open = std.mem.indexOfScalarPos(u8, source, field_index, '{') orelse return null;
var depth: usize = 0;
var index = open;
while (index < source.len) : (index += 1) {
switch (source[index]) {
'{' => depth += 1,
'}' => {
depth -= 1;
if (depth == 0) return source[open + 1 .. index];
},
else => {},
}
}
return null;
}
fn boolField(source: []const u8, field: []const u8) ?bool {
const field_index = std.mem.indexOf(u8, source, field) orelse return null;
const equals = std.mem.indexOfScalarPos(u8, source, field_index, '=') orelse return null;
var index = equals + 1;
while (index < source.len and std.ascii.isWhitespace(source[index])) : (index += 1) {}
if (std.mem.startsWith(u8, source[index..], "true")) return true;
if (std.mem.startsWith(u8, source[index..], "false")) return false;
return null;
}
+8
View File
@@ -0,0 +1,8 @@
.{
.name = .command_app,
.fingerprint = 0xb253836e291d354d,
.version = "0.1.0",
.minimum_zig_version = "0.16.0",
.dependencies = .{},
.paths = .{ "build.zig", "build.zig.zon", "src", "assets", "app.zon", "README.md" },
}
+273
View File
@@ -0,0 +1,273 @@
const std = @import("std");
const runner = @import("runner");
const native_sdk = @import("native_sdk");
pub const panic = std.debug.FullPanic(native_sdk.debug.capturePanic);
const window_width: f32 = 860;
const window_height: f32 = 560;
const toolbar_height: f32 = 48;
const statusbar_height: f32 = 34;
const command_id = "app.sync";
const html =
\\<!doctype html>
\\<html>
\\<head>
\\ <meta charset="utf-8">
\\ <meta name="viewport" content="width=device-width, initial-scale=1">
\\ <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;">
\\ <style>
\\ :root { color-scheme: light dark; }
\\ * { box-sizing: border-box; }
\\ body {
\\ margin: 0;
\\ min-height: 100vh;
\\ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", Segoe UI, system-ui, sans-serif;
\\ background: #f7f8fa;
\\ color: #171717;
\\ }
\\ main {
\\ width: min(680px, calc(100vw - 48px));
\\ padding: 42px 0;
\\ margin: 0 auto;
\\ display: grid;
\\ gap: 18px;
\\ }
\\ h1 { margin: 0; font-size: 30px; line-height: 1.1; font-weight: 650; letter-spacing: 0; }
\\ p { margin: 0; color: #606975; line-height: 1.55; }
\\ .panel {
\\ display: grid;
\\ grid-template-columns: 1fr auto;
\\ gap: 18px;
\\ align-items: center;
\\ padding: 18px 0;
\\ border-top: 1px solid #e3e6ea;
\\ border-bottom: 1px solid #e3e6ea;
\\ }
\\ button {
\\ min-width: 116px;
\\ border: 1px solid #171717;
\\ border-radius: 7px;
\\ padding: 9px 13px;
\\ font: inherit;
\\ font-weight: 590;
\\ color: white;
\\ background: #171717;
\\ cursor: pointer;
\\ }
\\ pre {
\\ min-height: 92px;
\\ margin: 0;
\\ padding: 14px 16px;
\\ overflow: auto;
\\ border: 1px solid #dde1e6;
\\ border-radius: 7px;
\\ background: white;
\\ color: #374151;
\\ font-size: 13px;
\\ line-height: 1.45;
\\ }
\\ @media (prefers-color-scheme: dark) {
\\ body { background: #111316; color: #f4f4f5; }
\\ p { color: #a1a1aa; }
\\ .panel { border-color: #2b2f37; }
\\ button { color: #111316; background: #f4f4f5; border-color: #f4f4f5; }
\\ pre { color: #d4d4d8; background: #171a20; border-color: #2b2f37; }
\\ }
\\ </style>
\\</head>
\\<body>
\\ <main>
\\ <h1>One command, five entry points</h1>
\\ <p>The toolbar button, View menu, tray item, primary shortcut, and WebView button all dispatch app.sync into the same Zig command handler.</p>
\\ <div class="panel">
\\ <p>Dispatch from the WebView through the built-in command bridge.</p>
\\ <button id="sync" type="button">Sync</button>
\\ <button id="commands" type="button">List Commands</button>
\\ </div>
\\ <pre id="output">Ready.</pre>
\\ </main>
\\ <script>
\\ const output = document.querySelector("#output");
\\ const show = (value) => { output.textContent = JSON.stringify(value, null, 2); };
\\ const fail = (error) => { output.textContent = `${error.code || "error"}: ${error.message}`; };
\\ const invokeCommand = (name) => {
\\ if (window.zero && window.zero.commands && window.zero.commands.invoke) {
\\ return window.zero.commands.invoke(name);
\\ }
\\ return window.zero.invoke("native-sdk.command.invoke", { name });
\\ };
\\ const listCommands = () => {
\\ if (window.zero && window.zero.commands && window.zero.commands.list) {
\\ return window.zero.commands.list();
\\ }
\\ return window.zero.invoke("native-sdk.command.list", {});
\\ };
\\ document.querySelector("#sync").addEventListener("click", async () => {
\\ try { show(await invokeCommand("app.sync")); } catch (error) { fail(error); }
\\ });
\\ document.querySelector("#commands").addEventListener("click", async () => {
\\ try { show(await listCommands()); } catch (error) { fail(error); }
\\ });
\\ </script>
\\</body>
\\</html>
;
const app_permissions = [_][]const u8{native_sdk.security.permission_command};
const bridge_origins = [_][]const u8{ "zero://inline", "zero://app" };
const command_permission = [_][]const u8{native_sdk.security.permission_command};
const command_catalog = [_]native_sdk.Command{.{ .id = command_id, .title = "Sync" }};
const builtin_policies = [_]native_sdk.BridgeCommandPolicy{
.{ .name = "native-sdk.command.invoke", .permissions = &command_permission, .origins = &bridge_origins },
.{ .name = "native-sdk.command.list", .permissions = &command_permission, .origins = &bridge_origins },
};
const tray_items = [_]native_sdk.TrayMenuItem{
.{ .id = 1, .label = "Sync", .command = command_id },
};
const shell_views = [_]native_sdk.ShellView{
.{ .label = "toolbar", .kind = .toolbar, .edge = .top, .height = toolbar_height, .layer = 20, .role = "Toolbar" },
.{ .label = "sync-button", .kind = .button, .parent = "toolbar", .x = 12, .y = 9, .width = 92, .height = 30, .layer = 21, .accessibility_label = "Sync now", .text = "Sync", .command = command_id },
.{ .label = "main", .kind = .webview, .url = "zero://inline", .fill = true },
.{ .label = "statusbar", .kind = .statusbar, .edge = .bottom, .height = statusbar_height, .layer = 20, .role = "Status" },
.{ .label = "status-label", .kind = .label, .parent = "statusbar", .x = 14, .y = 8, .width = 620, .height = 18, .layer = 21, .text = "Ready. Use the toolbar, menu, tray, shortcut, or WebView button." },
};
const shell_windows = [_]native_sdk.ShellWindow{.{
.label = "main",
.title = "Native SDK Command App",
.width = window_width,
.height = window_height,
.views = &shell_views,
}};
const shell_scene: native_sdk.ShellConfig = .{ .windows = &shell_windows };
const CommandApp = struct {
command_count: u32 = 0,
sources: [8]native_sdk.CommandSource = [_]native_sdk.CommandSource{.runtime} ** 8,
last_command_name: []const u8 = "",
fn app(self: *@This()) native_sdk.App {
return .{
.context = self,
.name = "command-app",
.source = native_sdk.WebViewSource.html(html),
.scene_fn = scene,
.start_fn = start,
.event_fn = event,
};
}
fn scene(context: *anyopaque) anyerror!native_sdk.ShellConfig {
_ = context;
return shell_scene;
}
fn start(context: *anyopaque, runtime: *native_sdk.Runtime) anyerror!void {
_ = context;
try runtime.createTray(.{
.tooltip = "Native SDK Command App",
.items = &tray_items,
});
}
fn event(context: *anyopaque, runtime: *native_sdk.Runtime, event_value: native_sdk.Event) anyerror!void {
const self: *@This() = @ptrCast(@alignCast(context));
switch (event_value) {
.command => |command| {
if (std.mem.eql(u8, command.name, command_id)) {
try self.handleCommand(runtime, command);
}
},
.appearance_changed, .shortcut, .timer, .effects_wake, .audio, .files_dropped, .gpu_surface_frame, .gpu_surface_resized, .gpu_surface_input, .canvas_widget_pointer, .canvas_widget_keyboard, .canvas_widget_scroll, .canvas_widget_file_drop, .canvas_widget_drag, .canvas_widget_context_menu, .canvas_widget_context_menu_request, .canvas_widget_dismiss, .canvas_widget_context_press, .canvas_widget_resize, .canvas_widget_change, .window_closed, .automation_provenance, .lifecycle => {},
}
}
fn handleCommand(self: *@This(), runtime: *native_sdk.Runtime, command: native_sdk.CommandEvent) anyerror!void {
if (self.command_count < self.sources.len) {
self.sources[self.command_count] = command.source;
}
self.command_count += 1;
self.last_command_name = command.name;
var status_buffer: [128]u8 = undefined;
const status = try std.fmt.bufPrint(
&status_buffer,
"Handled {s} from {s}. Count {d}.",
.{ command.name, @tagName(command.source), self.command_count },
);
const status_window_id = if (command.window_id == 0) 1 else command.window_id;
_ = try runtime.updateView(status_window_id, "status-label", .{ .text = status });
}
};
pub fn main(init: std.process.Init) !void {
var app = CommandApp{};
try runner.runWithOptions(app.app(), .{
.app_name = "command-app",
.window_title = "Native SDK Command App",
.bundle_id = "dev.native_sdk.command_app",
.default_frame = native_sdk.geometry.RectF.init(0, 0, window_width, window_height),
.builtin_bridge = .{ .enabled = true, .commands = &builtin_policies },
.js_window_api = true,
.security = .{
.permissions = &app_permissions,
.navigation = .{ .allowed_origins = &bridge_origins },
},
}, init);
}
test "command app routes toolbar menu tray shortcut and bridge commands" {
const harness = try native_sdk.TestHarness().create(std.testing.allocator, .{ .size = native_sdk.geometry.SizeF.init(window_width, window_height) });
defer harness.destroy(std.testing.allocator);
harness.runtime.options.builtin_bridge = .{ .enabled = true, .commands = &builtin_policies };
harness.runtime.options.js_window_api = true;
harness.runtime.options.commands = &command_catalog;
harness.runtime.options.security = .{
.permissions = &app_permissions,
.navigation = .{ .allowed_origins = &bridge_origins },
};
var app = CommandApp{};
try harness.start(app.app());
try harness.runtime.dispatchPlatformEvent(app.app(), .{ .native_command = .{
.name = command_id,
.window_id = 1,
.view_label = "sync-button",
} });
try harness.runtime.dispatchPlatformEvent(app.app(), .{ .menu_command = .{
.name = command_id,
.window_id = 1,
} });
try std.testing.expectEqual(@as(usize, 1), harness.null_platform.trayCreateCount());
try harness.runtime.dispatchPlatformEvent(app.app(), .{ .tray_action = 1 });
try harness.runtime.dispatchPlatformEvent(app.app(), .{ .shortcut = .{
.id = command_id,
.key = "s",
.window_id = 1,
.modifiers = .{ .primary = true },
} });
try harness.runtime.dispatchPlatformEvent(app.app(), .{ .bridge_message = .{
.bytes = "{\"id\":\"1\",\"command\":\"native-sdk.command.invoke\",\"payload\":{\"name\":\"app.sync\"}}",
.origin = "zero://inline",
.window_id = 1,
.webview_label = "main",
} });
try harness.runtime.dispatchPlatformEvent(app.app(), .{ .bridge_message = .{
.bytes = "{\"id\":\"2\",\"command\":\"native-sdk.command.list\",\"payload\":{}}",
.origin = "zero://inline",
.window_id = 1,
.webview_label = "main",
} });
try std.testing.expectEqual(@as(u32, 5), app.command_count);
try std.testing.expectEqualStrings(command_id, app.last_command_name);
try std.testing.expectEqual(native_sdk.CommandSource.toolbar, app.sources[0]);
try std.testing.expectEqual(native_sdk.CommandSource.menu, app.sources[1]);
try std.testing.expectEqual(native_sdk.CommandSource.tray, app.sources[2]);
try std.testing.expectEqual(native_sdk.CommandSource.shortcut, app.sources[3]);
try std.testing.expectEqual(native_sdk.CommandSource.bridge, app.sources[4]);
try std.testing.expect(std.mem.indexOf(u8, harness.null_platform.lastBridgeResponse(), "\"ok\":true") != null);
try std.testing.expect(std.mem.indexOf(u8, harness.null_platform.lastBridgeResponse(), "\"id\":\"app.sync\"") != null);
}
+520
View File
@@ -0,0 +1,520 @@
const std = @import("std");
const build_options = @import("build_options");
const native_sdk = @import("native_sdk");
const app_manifest = @import("app_manifest_zon");
const manifest_commands = if (@hasField(@TypeOf(app_manifest), "commands")) app_manifest.commands else .{};
const manifest_shortcuts = if (@hasField(@TypeOf(app_manifest), "shortcuts")) app_manifest.shortcuts else .{};
const manifest_menus = if (@hasField(@TypeOf(app_manifest), "menus")) app_manifest.menus else .{};
const manifest_windows = if (@hasField(@TypeOf(app_manifest), "windows")) app_manifest.windows else .{};
pub const StdoutTraceSink = struct {
pub fn sink(self: *StdoutTraceSink) native_sdk.trace.Sink {
return .{ .context = self, .write_fn = write };
}
fn write(context: *anyopaque, record: native_sdk.trace.Record) native_sdk.trace.WriteError!void {
_ = context;
if (!shouldTrace(record)) return;
// Never fail on an oversized record: logging failures must
// degrade (truncated output), not fail dispatch upstream.
var buffer: [4096]u8 = undefined;
std.debug.print("{s}\n", .{native_sdk.trace.formatTextBounded(record, &buffer)});
}
};
pub const RunOptions = struct {
app_name: []const u8,
window_title: []const u8 = "",
bundle_id: []const u8,
icon_path: []const u8 = "assets/icon.png",
default_frame: native_sdk.geometry.RectF = native_sdk.geometry.RectF.init(0, 0, 1100, 760),
bridge: ?native_sdk.BridgeDispatcher = null,
builtin_bridge: native_sdk.BridgePolicy = .{},
js_window_api: bool = false,
security: native_sdk.SecurityPolicy = .{},
commands: ?[]const native_sdk.Command = null,
menus: ?[]const native_sdk.Menu = null,
shortcuts: ?[]const native_sdk.Shortcut = null,
fn appInfo(self: RunOptions, buffers: *StateBuffers) native_sdk.AppInfo {
var info: native_sdk.AppInfo = .{
.app_name = self.app_name,
// The identity the OS shows (application menu, Dock, About
// panel) reads straight from app.zon at comptime, so dev
// runs carry the same display name and version a packaged
// bundle gets from its Info.plist.
.display_name = manifestStringField("display_name"),
.version = manifestStringField("version"),
.description = manifestStringField("description"),
.has_web_content = manifestHasWebContent(),
.window_title = self.window_title,
.bundle_id = self.bundle_id,
.icon_path = self.icon_path,
.main_window = .{
.id = 1,
.label = "main",
.title = self.window_title,
.default_frame = self.default_frame,
},
};
const windows = manifestWindowOptions(buffers);
if (windows.len > 0) {
info.main_window = windows[0];
info.windows = windows;
}
return info;
}
fn resolvedShortcuts(self: RunOptions, storage: *ShortcutStorage) []const native_sdk.Shortcut {
return self.shortcuts orelse storage.fromManifest();
}
fn resolvedCommands(self: RunOptions, storage: *CommandStorage) []const native_sdk.Command {
return self.commands orelse storage.fromManifest();
}
fn resolvedMenus(self: RunOptions, storage: *MenuStorage) []const native_sdk.Menu {
return self.menus orelse storage.fromManifest();
}
};
const CommandStorage = struct {
commands: [native_sdk.app_manifest.max_commands]native_sdk.Command = undefined,
fn fromManifest(self: *CommandStorage) []const native_sdk.Command {
comptime {
if (manifest_commands.len > native_sdk.app_manifest.max_commands) {
@compileError("app.zon defines too many commands");
}
}
inline for (manifest_commands, 0..) |command, index| {
self.commands[index] = .{
.id = command.id,
.title = if (@hasField(@TypeOf(command), "title")) command.title else "",
.enabled = if (@hasField(@TypeOf(command), "enabled")) command.enabled else true,
.checked = if (@hasField(@TypeOf(command), "checked")) command.checked else false,
};
}
return self.commands[0..manifest_commands.len];
}
};
const MenuStorage = struct {
menus: [native_sdk.platform.max_menus]native_sdk.Menu = undefined,
items: [native_sdk.platform.max_menu_items]native_sdk.MenuItem = undefined,
fn fromManifest(self: *MenuStorage) []const native_sdk.Menu {
comptime {
if (manifest_menus.len > native_sdk.platform.max_menus) {
@compileError("app.zon defines too many menus");
}
var item_count: usize = 0;
for (manifest_menus) |menu| {
const items = if (@hasField(@TypeOf(menu), "items")) menu.items else .{};
item_count += items.len;
}
if (item_count > native_sdk.platform.max_menu_items) {
@compileError("app.zon defines too many menu items");
}
}
var item_index: usize = 0;
inline for (manifest_menus, 0..) |menu, menu_index| {
const items = if (@hasField(@TypeOf(menu), "items")) menu.items else .{};
const first_item = item_index;
inline for (items) |item| {
self.items[item_index] = menuItem(item);
item_index += 1;
}
self.menus[menu_index] = .{
.title = menu.title,
.items = self.items[first_item..item_index],
};
}
return self.menus[0..manifest_menus.len];
}
};
const ShortcutStorage = struct {
shortcuts: [native_sdk.platform.max_shortcuts]native_sdk.Shortcut = undefined,
fn fromManifest(self: *ShortcutStorage) []const native_sdk.Shortcut {
comptime {
if (manifest_shortcuts.len > native_sdk.platform.max_shortcuts) {
@compileError("app.zon defines too many shortcuts");
}
}
inline for (manifest_shortcuts, 0..) |shortcut, index| {
self.shortcuts[index] = .{
.id = shortcut.id,
.key = shortcut.key,
.modifiers = shortcutModifiers(shortcut),
};
}
return self.shortcuts[0..manifest_shortcuts.len];
}
};
/// A top-level app.zon string field (`display_name`, `version`,
/// `description`), or "" when the manifest omits it — optional identity
/// stays optional all the way into `AppInfo`.
fn manifestStringField(comptime field: []const u8) []const u8 {
if (comptime !@hasField(@TypeOf(app_manifest), field)) return "";
const value = @field(app_manifest, field);
if (comptime @TypeOf(value) == @TypeOf(null)) return "";
return value;
}
/// Whether app.zon declares web content: the `webview` capability or a
/// `frontend` block. Hosts build honest default menus from this — web
/// items like Reload only exist when a webview can answer them, so
/// canvas-only apps never ship dead menu items.
fn manifestHasWebContent() bool {
if (comptime @hasField(@TypeOf(app_manifest), "frontend")) return true;
if (comptime !@hasField(@TypeOf(app_manifest), "capabilities")) return false;
inline for (app_manifest.capabilities) |capability| {
if (comptime std.mem.eql(u8, capability, "webview")) return true;
}
return false;
}
fn manifestWindowOptions(buffers: *StateBuffers) []const native_sdk.WindowOptions {
comptime {
if (manifest_windows.len > native_sdk.platform.max_windows) {
@compileError("app.zon defines too many windows");
}
}
inline for (manifest_windows, 0..) |window, index| {
buffers.restored_windows[index] = manifestWindow(window, index);
}
return buffers.restored_windows[0..manifest_windows.len];
}
fn manifestWindow(comptime window: anytype, comptime index: usize) native_sdk.WindowOptions {
return .{
.id = index + 1,
.label = windowLabel(window, index),
.title = windowTitle(window),
.default_frame = native_sdk.geometry.RectF.init(
windowFloat(window, "x", 0),
windowFloat(window, "y", 0),
windowFloat(window, "width", 720),
windowFloat(window, "height", 480),
),
.resizable = windowBool(window, "resizable", true),
.restore_state = windowBool(window, "restore_state", true),
.restore_policy = windowRestorePolicy(window),
};
}
fn windowLabel(comptime window: anytype, comptime index: usize) []const u8 {
if (comptime @hasField(@TypeOf(window), "label")) return window.label;
return if (index == 0) "main" else "window";
}
fn windowTitle(comptime window: anytype) []const u8 {
if (comptime !@hasField(@TypeOf(window), "title")) return "";
const title = window.title;
if (comptime @TypeOf(title) == @TypeOf(null)) return "";
return title;
}
fn windowFloat(comptime window: anytype, comptime field: []const u8, comptime default_value: f32) f32 {
if (comptime @hasField(@TypeOf(window), field)) return @field(window, field);
return default_value;
}
fn windowBool(comptime window: anytype, comptime field: []const u8, comptime default_value: bool) bool {
if (comptime @hasField(@TypeOf(window), field)) return @field(window, field);
return default_value;
}
fn windowRestorePolicy(comptime window: anytype) native_sdk.WindowRestorePolicy {
if (comptime !@hasField(@TypeOf(window), "restore_policy")) return .clamp_to_visible_screen;
const value = window.restore_policy;
if (comptime std.mem.eql(u8, value, "clamp_to_visible_screen")) return .clamp_to_visible_screen;
if (comptime std.mem.eql(u8, value, "center_on_primary")) return .center_on_primary;
@compileError("unknown app.zon window restore_policy");
}
fn menuItem(comptime item: anytype) native_sdk.MenuItem {
return .{
.label = if (@hasField(@TypeOf(item), "label")) item.label else "",
.command = if (@hasField(@TypeOf(item), "command")) item.command else "",
.key = if (@hasField(@TypeOf(item), "key")) item.key else "",
.modifiers = shortcutModifiers(item),
.separator = if (@hasField(@TypeOf(item), "separator")) item.separator else false,
.enabled = if (@hasField(@TypeOf(item), "enabled")) item.enabled else true,
.checked = if (@hasField(@TypeOf(item), "checked")) item.checked else false,
};
}
fn shortcutModifiers(comptime shortcut: anytype) native_sdk.ShortcutModifiers {
const values = if (@hasField(@TypeOf(shortcut), "modifiers")) shortcut.modifiers else .{};
var modifiers: native_sdk.ShortcutModifiers = .{};
inline for (values) |value| {
const modifier: []const u8 = value;
if (comptime std.mem.eql(u8, modifier, "primary")) {
modifiers.primary = true;
} else if (comptime std.mem.eql(u8, modifier, "command")) {
modifiers.command = true;
} else if (comptime std.mem.eql(u8, modifier, "control")) {
modifiers.control = true;
} else if (comptime std.mem.eql(u8, modifier, "option") or std.mem.eql(u8, modifier, "alt")) {
modifiers.option = true;
} else if (comptime std.mem.eql(u8, modifier, "shift")) {
modifiers.shift = true;
} else {
@compileError("unknown app.zon shortcut modifier");
}
}
return modifiers;
}
pub fn runWithOptions(app: native_sdk.App, options: RunOptions, init: std.process.Init) !void {
if (build_options.debug_overlay) {
std.debug.print("debug-overlay=true backend={s} web-engine={s} trace={s}\n", .{ build_options.platform, build_options.web_engine, build_options.trace });
}
if (comptime std.mem.eql(u8, build_options.platform, "macos")) {
try runMacos(app, options, init);
} else if (comptime std.mem.eql(u8, build_options.platform, "linux")) {
try runLinux(app, options, init);
} else if (comptime std.mem.eql(u8, build_options.platform, "windows")) {
try runWindows(app, options, init);
} else {
try runNull(app, options, init);
}
}
fn runNull(app: native_sdk.App, options: RunOptions, init: std.process.Init) !void {
var buffers: StateBuffers = undefined;
var app_info = options.appInfo(&buffers);
const store = prepareStateStore(init.io, init.environ_map, &app_info, &buffers);
var null_platform = native_sdk.NullPlatform.initWithOptions(.{}, webEngine(), app_info);
var trace_sink = StdoutTraceSink{};
var log_buffers: native_sdk.debug.LogPathBuffers = .{};
const log_setup = native_sdk.debug.setupLogging(init.io, init.environ_map, app_info.bundle_id, &log_buffers) catch null;
if (log_setup) |setup| native_sdk.debug.installPanicCapture(init.io, setup.paths);
var file_trace_sink: native_sdk.debug.FileTraceSink = undefined;
var fanout_sinks: [2]native_sdk.trace.Sink = undefined;
var fanout_sink: native_sdk.debug.FanoutTraceSink = undefined;
var runtime_trace_sink = trace_sink.sink();
if (log_setup) |setup| {
file_trace_sink = native_sdk.debug.FileTraceSink.init(init.io, setup.paths.log_dir, setup.paths.log_file, setup.format);
fanout_sinks = .{ trace_sink.sink(), file_trace_sink.sink() };
fanout_sink = .{ .sinks = &fanout_sinks };
runtime_trace_sink = fanout_sink.sink();
}
var shortcut_storage: ShortcutStorage = .{};
const shortcuts = options.resolvedShortcuts(&shortcut_storage);
var menu_storage: MenuStorage = .{};
const menus = options.resolvedMenus(&menu_storage);
var command_storage: CommandStorage = .{};
const commands = options.resolvedCommands(&command_storage);
// The Runtime is tens of megabytes; construct on the heap (default
// main-thread stacks overflow on a stack instance).
const runtime = try std.heap.page_allocator.create(native_sdk.Runtime);
defer std.heap.page_allocator.destroy(runtime);
native_sdk.Runtime.initAt(runtime, .{
.platform = null_platform.platform(),
.trace_sink = runtime_trace_sink,
.log_path = if (log_setup) |setup| setup.paths.log_file else null,
.bridge = options.bridge,
.builtin_bridge = options.builtin_bridge,
.js_window_api = options.js_window_api,
.security = options.security,
.commands = commands,
.menus = menus,
.shortcuts = shortcuts,
.automation = if (build_options.automation) native_sdk.automation.Server.init(init.io, ".zig-cache/native-sdk-automation", app_info.resolvedWindowTitle()) else null,
.window_state_store = store,
});
try runtime.run(app);
}
fn runMacos(app: native_sdk.App, options: RunOptions, init: std.process.Init) !void {
var buffers: StateBuffers = undefined;
var app_info = options.appInfo(&buffers);
const store = prepareStateStore(init.io, init.environ_map, &app_info, &buffers);
var mac_platform = try native_sdk.platform.macos.MacPlatform.initWithOptions(native_sdk.geometry.SizeF.init(720, 480), webEngine(), app_info);
defer mac_platform.deinit();
var trace_sink = StdoutTraceSink{};
var log_buffers: native_sdk.debug.LogPathBuffers = .{};
const log_setup = native_sdk.debug.setupLogging(init.io, init.environ_map, app_info.bundle_id, &log_buffers) catch null;
if (log_setup) |setup| native_sdk.debug.installPanicCapture(init.io, setup.paths);
var file_trace_sink: native_sdk.debug.FileTraceSink = undefined;
var fanout_sinks: [2]native_sdk.trace.Sink = undefined;
var fanout_sink: native_sdk.debug.FanoutTraceSink = undefined;
var runtime_trace_sink = trace_sink.sink();
if (log_setup) |setup| {
file_trace_sink = native_sdk.debug.FileTraceSink.init(init.io, setup.paths.log_dir, setup.paths.log_file, setup.format);
fanout_sinks = .{ trace_sink.sink(), file_trace_sink.sink() };
fanout_sink = .{ .sinks = &fanout_sinks };
runtime_trace_sink = fanout_sink.sink();
}
var shortcut_storage: ShortcutStorage = .{};
const shortcuts = options.resolvedShortcuts(&shortcut_storage);
var menu_storage: MenuStorage = .{};
const menus = options.resolvedMenus(&menu_storage);
var command_storage: CommandStorage = .{};
const commands = options.resolvedCommands(&command_storage);
// The Runtime is tens of megabytes; construct on the heap (default
// main-thread stacks overflow on a stack instance).
const runtime = try std.heap.page_allocator.create(native_sdk.Runtime);
defer std.heap.page_allocator.destroy(runtime);
native_sdk.Runtime.initAt(runtime, .{
.platform = mac_platform.platform(),
.trace_sink = runtime_trace_sink,
.log_path = if (log_setup) |setup| setup.paths.log_file else null,
.bridge = options.bridge,
.builtin_bridge = options.builtin_bridge,
.js_window_api = options.js_window_api,
.security = options.security,
.commands = commands,
.menus = menus,
.shortcuts = shortcuts,
.automation = if (build_options.automation) native_sdk.automation.Server.init(init.io, ".zig-cache/native-sdk-automation", app_info.resolvedWindowTitle()) else null,
.window_state_store = store,
});
try runtime.run(app);
}
fn runLinux(app: native_sdk.App, options: RunOptions, init: std.process.Init) !void {
var buffers: StateBuffers = undefined;
var app_info = options.appInfo(&buffers);
const store = prepareStateStore(init.io, init.environ_map, &app_info, &buffers);
var linux_platform = try native_sdk.platform.linux.LinuxPlatform.initWithOptions(native_sdk.geometry.SizeF.init(720, 480), webEngine(), app_info);
defer linux_platform.deinit();
var trace_sink = StdoutTraceSink{};
var log_buffers: native_sdk.debug.LogPathBuffers = .{};
const log_setup = native_sdk.debug.setupLogging(init.io, init.environ_map, app_info.bundle_id, &log_buffers) catch null;
if (log_setup) |setup| native_sdk.debug.installPanicCapture(init.io, setup.paths);
var file_trace_sink: native_sdk.debug.FileTraceSink = undefined;
var fanout_sinks: [2]native_sdk.trace.Sink = undefined;
var fanout_sink: native_sdk.debug.FanoutTraceSink = undefined;
var runtime_trace_sink = trace_sink.sink();
if (log_setup) |setup| {
file_trace_sink = native_sdk.debug.FileTraceSink.init(init.io, setup.paths.log_dir, setup.paths.log_file, setup.format);
fanout_sinks = .{ trace_sink.sink(), file_trace_sink.sink() };
fanout_sink = .{ .sinks = &fanout_sinks };
runtime_trace_sink = fanout_sink.sink();
}
var shortcut_storage: ShortcutStorage = .{};
const shortcuts = options.resolvedShortcuts(&shortcut_storage);
var menu_storage: MenuStorage = .{};
const menus = options.resolvedMenus(&menu_storage);
var command_storage: CommandStorage = .{};
const commands = options.resolvedCommands(&command_storage);
// The Runtime is tens of megabytes; construct on the heap (default
// main-thread stacks overflow on a stack instance).
const runtime = try std.heap.page_allocator.create(native_sdk.Runtime);
defer std.heap.page_allocator.destroy(runtime);
native_sdk.Runtime.initAt(runtime, .{
.platform = linux_platform.platform(),
.trace_sink = runtime_trace_sink,
.log_path = if (log_setup) |setup| setup.paths.log_file else null,
.bridge = options.bridge,
.builtin_bridge = options.builtin_bridge,
.js_window_api = options.js_window_api,
.security = options.security,
.commands = commands,
.menus = menus,
.shortcuts = shortcuts,
.automation = if (build_options.automation) native_sdk.automation.Server.init(init.io, ".zig-cache/native-sdk-automation", app_info.resolvedWindowTitle()) else null,
.window_state_store = store,
});
try runtime.run(app);
}
fn runWindows(app: native_sdk.App, options: RunOptions, init: std.process.Init) !void {
var buffers: StateBuffers = undefined;
var app_info = options.appInfo(&buffers);
const store = prepareStateStore(init.io, init.environ_map, &app_info, &buffers);
var windows_platform = try native_sdk.platform.windows.WindowsPlatform.initWithOptions(native_sdk.geometry.SizeF.init(720, 480), webEngine(), app_info);
defer windows_platform.deinit();
var trace_sink = StdoutTraceSink{};
var log_buffers: native_sdk.debug.LogPathBuffers = .{};
const log_setup = native_sdk.debug.setupLogging(init.io, init.environ_map, app_info.bundle_id, &log_buffers) catch null;
if (log_setup) |setup| native_sdk.debug.installPanicCapture(init.io, setup.paths);
var file_trace_sink: native_sdk.debug.FileTraceSink = undefined;
var fanout_sinks: [2]native_sdk.trace.Sink = undefined;
var fanout_sink: native_sdk.debug.FanoutTraceSink = undefined;
var runtime_trace_sink = trace_sink.sink();
if (log_setup) |setup| {
file_trace_sink = native_sdk.debug.FileTraceSink.init(init.io, setup.paths.log_dir, setup.paths.log_file, setup.format);
fanout_sinks = .{ trace_sink.sink(), file_trace_sink.sink() };
fanout_sink = .{ .sinks = &fanout_sinks };
runtime_trace_sink = fanout_sink.sink();
}
var shortcut_storage: ShortcutStorage = .{};
const shortcuts = options.resolvedShortcuts(&shortcut_storage);
var menu_storage: MenuStorage = .{};
const menus = options.resolvedMenus(&menu_storage);
var command_storage: CommandStorage = .{};
const commands = options.resolvedCommands(&command_storage);
// The Runtime is tens of megabytes; construct on the heap (default
// main-thread stacks overflow on a stack instance).
const runtime = try std.heap.page_allocator.create(native_sdk.Runtime);
defer std.heap.page_allocator.destroy(runtime);
native_sdk.Runtime.initAt(runtime, .{
.platform = windows_platform.platform(),
.trace_sink = runtime_trace_sink,
.log_path = if (log_setup) |setup| setup.paths.log_file else null,
.bridge = options.bridge,
.builtin_bridge = options.builtin_bridge,
.js_window_api = options.js_window_api,
.security = options.security,
.commands = commands,
.menus = menus,
.shortcuts = shortcuts,
.automation = if (build_options.automation) native_sdk.automation.Server.init(init.io, ".zig-cache/native-sdk-automation", app_info.resolvedWindowTitle()) else null,
.window_state_store = store,
});
try runtime.run(app);
}
fn shouldTrace(record: native_sdk.trace.Record) bool {
if (comptime std.mem.eql(u8, build_options.trace, "off")) return false;
if (comptime std.mem.eql(u8, build_options.trace, "all")) return true;
if (comptime std.mem.eql(u8, build_options.trace, "events")) return true;
return std.mem.indexOf(u8, record.name, build_options.trace) != null;
}
fn webEngine() native_sdk.WebEngine {
if (comptime std.mem.eql(u8, build_options.web_engine, "chromium")) return .chromium;
return .system;
}
const StateBuffers = struct {
state_dir: [1024]u8 = undefined,
file_path: [1200]u8 = undefined,
read: [8192]u8 = undefined,
restored_windows: [native_sdk.platform.max_windows]native_sdk.WindowOptions = undefined,
};
fn prepareStateStore(io: std.Io, env_map: *std.process.Environ.Map, app_info: *native_sdk.AppInfo, buffers: *StateBuffers) ?native_sdk.window_state.Store {
const paths = native_sdk.window_state.defaultPaths(&buffers.state_dir, &buffers.file_path, app_info.bundle_id, native_sdk.debug.envFromMap(env_map)) catch return null;
const store = native_sdk.window_state.Store.init(io, paths.state_dir, paths.file_path);
if (app_info.windows.len > 0) {
const restored_windows = buffers.restored_windows[0..app_info.windows.len];
for (restored_windows, 0..) |*window, index| {
if (!window.restore_state) continue;
if (store.loadWindow(window.label, &buffers.read) catch null) |saved| {
window.default_frame = saved.frame;
if (index == 0) app_info.main_window.default_frame = saved.frame;
}
}
} else if (app_info.main_window.restore_state) {
if (store.loadWindow(app_info.main_window.label, &buffers.read) catch null) |saved| {
app_info.main_window.default_frame = saved.frame;
}
}
return store;
}
+101
View File
@@ -0,0 +1,101 @@
# Native SDK deck example
The radical sibling of `examples/soundboard`: the **same app** — the same committed music catalog with albums, tracks, transport, seek, and search — wearing a completely different product identity. Soundboard is the clean house-catalog look; deck is a piece of vintage rack-mount audio hardware. Same catalog, same audio files, same runtime, same widget engine and token system — a different product. That contrast is the demo: "Beautiful by default. Customizable by design."
Desktop only, by design: the deck is a fixed-size piece of rack hardware and ships no mobile entry.
## Design brief (v4: the vintage hi-fi rack unit)
**Identity.** A desktop audio deck styled as a vintage component hi-fi rack unit, in the classic two-window *shape*: a **fixed-size player** (512x264, `resizable = false`) that IS the device — warm enamel fascia — plus a **matching playlist unit** in its own window at the SAME width (512x440, the two units stack flush in the rack), racked in and out by the chunky `PL` key or `primary+L`. Both windows are **chromeless** (the explicit fully-skinned titlebar style: no OS titlebar band, no system buttons on any desktop), so each enamel cap band is the drag region AND carries the skin's own **close and minimize keys** — real controls wired to the runtime's window-action effects, with proper roles and labels; nothing decorative. No library in the player; no transport in the playlist. Everything on screen should read as *hardware* — enamel plates, recessed screws, smoked-glass bays, silkscreened lettering — not as a document.
**Palette: two materials.** Warm cream/putty **enamel** for the chassis (`#E7E1D1` faceplate family, `#2C2820` silkscreen ink, `#A9A18A` putty hairlines) around dark **smoked glass** display bays (`#0C100D`) that print in a single phosphor green at three registers: live `#3EE08A`, resting `#A8D8B4`, engraved `#608068`. Signal amber `#ECB24A` is reserved for the failure stamps — the one non-green hue on the glass. The token table allocates its slots by material (see `src/theme.zig`); this skin is **custom by design** and deliberately does not follow the toolkit's theme packs or the OS color scheme.
**Type: one pixel face.** The deck's primary text face is **Geist Pixel** (the Vercel pixel family, Square cut), committed at `src/fonts/` with its OFL license and registered at boot through the app-fonts seam (`UiApp.Options.fonts`); the theme points BOTH typography slots at it, so every span on the fascia prints in the pixel face. Sizes sit on the face's design grid (every outline coordinate is a multiple of 38/1000 em): body, readouts, and captions at the half grid (~13.2px), the marquee at the full grid (~26.3px — one font-pixel per device pixel at 1x), so the blocks render crisp, never as anti-aliased mush. The face is proportional; column alignment rides fixed widths and text alignment. Hierarchy comes from the phosphor registers, not size steps the grid cannot honor. High contrast abandons the pixel face with the rest of the skin — the toolkit's stock faces are the accessible register.
**The player (main window).** Fixed 512x264. Top to bottom: the enamel cap band (drag region; the skin's close/minimize keys at the leading edge, then the **DECK** stamp silkscreened directly on the band — lettering on the finish, no raised plate — and the unit's model designation), then ONE row of glass — the **display bay** (the deck's single LED section: the chrome-drawn seven-segment elapsed readout with the **spectrum chart** beside it, the rotating **title marquee** at the full pixel grid, the `TRK NN` + timecode line with the LIVE/HOLD lamp, and the honest bitrate/size readout with the `SPECTRUM//32` engraving) beside the **art bay** (the loaded record's real cover, square at the row's full height) — then the long-travel seek fader, and the transport row: five chunky beveled keys with dark glyphs (prev / play / pause / stop / next) in a recessed well, the **rotary volume knob** riding the open enamel (no well, no box — the ticks and the phosphor position dot are the whole dial), and the `PL` key at the right margin.
**One LED section, one band.** Everything phosphor lives on the display bay's glass. An earlier round ran a second animated band (a five-ladder "band monitor" beside the spectrum bay); two elements visualizing one signal failed the intentionality bar, so the spectrum chart — the visually richer, period-honest instrument — is the one that survived, and it moved into the display glass with the clock and the marquee.
**Control vs. readout, one affordance each.** The display carries the authoritative playback readout (segment digits + timecode); the long-travel fader below the glass is the seek *control* AND the position affordance — the display deliberately carries no second progress bar. That split is honest engine design, not decoration: slider positions are runtime-owned between rebuilds (the engine's reconcile rule — a scrubber that was also the model-driven readout would fight the user's hand), so the fader is re-keyed per track and snaps home on every load.
**The marquee is honest.** The scroller is a pure function of (track id, elapsed ms): the composed `TITLE /// ARTIST /// ALBUM` line rotates one character per half-second of *playback* time, so pausing freezes it exactly like the spectrum (the clock stops) and the same model state always yields the same window of text. The suite asserts the rotation, the freeze, and the wrap.
**The display moves at display rate.** The rendered playback clock advances every presented frame while audio moves (the soundboard's frame-clock idiom: a guarded `on_frame` hook emits a journaled `frame_clock` Msg per presented frame, and the player's coarse ~500ms position ticks are the correcting truth — forward corrections apply, sub-tick backward drift holds flat, a past-slack desync snaps). Pause, stop, buffering, or idle starve the frame channel on its own — zero frames while nothing moves, the idle law — so the spectrum dances and the marquee glides at 60fps without giving up determinism: the suite replays the same Msg sequence to the same glass.
**The playlist unit (second window).** A model-declared window (`UiApp.windows_fn` + `window_view`): PRESENCE in the declared set is visibility, so the `PL` key just flips `playlist_open` and the runtime reconciles — the cap strip's own close key clears the same flag (the declarative close IS the real close), and its minimize key rides the window-action effect. Inside: an enamel chassis around one big smoked-glass **playlist bay** — ONE flat song list over the whole library as numbered phosphor rows (pressable, per-row native context menu: Copy Title), durations right-aligned, ruled by **single hairline dividers between rows** (no per-row plates, no boxes; the first row carries no rule above and the last none below), the loaded row lifted on a phosphor-tinted wash, the whole ledger inset four grid units of clear glass from the bay's x edges (the dividers inset with the content) — plus the bottom deck strip (the loaded record's sleeve window and the ON DECK stamp naming it) and the markup status strip (glass search inset, match counter). No album rail, no sub-collections, and no queue: the rack is a list of songs, its flat order is the play order (track end auto-advances down the ledger, wrapping at the end), and search narrows what you see without changing what plays next.
**Chrome language: machined enamel over honest texture.** The `UiApp.chrome` display-list pass draws the sculpted hardware layer in two fixed-count halves — pure fills, hairlines, gradients, and paths; **no bitmap skin assets anywhere**. Behind the widgets: the enamel chassis fill and warm faceplate gradient, a sparse comb of near-invisible grain hairlines, the cap band, the window's outer bevel, a ridged grip band, four recessed corner screws bolted hard against the chassis edges (one grid unit of enamel between screw rim and window edge — rack ears bolt at the extremes), and ONE inset well behind the transport cluster. In front: inset bevel frames around the two glass bays and the seek fader, scanlines and a diagonal glare wash on the display glass, the seven-segment readout drawn as sheared hexagon paths (ghost segments always faintly lit, live segments doubled with a glow stroke), the volume knob face seated directly on the enamel (its slider cover re-plots the faceplate gradient, so the patch vanishes into the chassis), and raised bevels on the eight keys (the cap band's window keys plus the transport six).
**Skin-native window controls, honestly.** Chromeless is an EXPLICIT opt-in for fully-skinned apps (ordinary apps should use the hidden titlebar styles, which keep the real OS buttons); it is honest here because the keys the chassis draws really work: the player's close key performs the REAL window close through `fx.closeWindow` (the app exits by the host's existing last-window semantics), both minimize keys genie the window into the Dock through `fx.minimizeWindow`, and the playlist's close key racks the unit back in through the same declarative machinery the `PL` key rides. Drag still works everywhere the cap bands are.
**One finish, by the brief.** Hardware has exactly one enamel; the OS appearance changes nothing here. Accessibility still beats brand: a high-contrast request abandons the widget skin for the toolkit's high-contrast light palette, the stock faces, and strips the chrome pass to structure (grain, glare, and scanlines go transparent; bevels fall back to the border token; the readouts switch to the high-contrast text color) — same command counts, honest contrast. Reduce-motion zeroes the motion tokens.
## Same app, different identity
Deck deliberately shares soundboard's domain, and its *data*: the same committed catalog (`src/music_manifest.zon`, byte-identical to the soundboard's copy, generated by `tools/prepare-example-music.sh`), the same on-disk audio (the mp3s live once, in `examples/soundboard/assets/music/` — gitignored; the deck plays them by relative path), the same search/seek/transport model shapes, the same context-menu `pbcopy` effect. Set the two side by side and every difference you see is *skin plus window shape*: design tokens, custom-drawn chrome, a registered pixel face, and a model-declared second window — all through the sanctioned view APIs. No engine fork, no private renderer hooks.
## Real playback
Pressing play issues `fx.playAudio` on the runtime's audio effect channel — on macOS that is the platform's real player, so the deck plays actual audio. Every report arrives as a typed Msg through the ordinary update path: the `loaded` acknowledgment carries the player's own duration readout (an estimate for this catalog — the prepare script strips the seek header — so it never replaces the manifest's measured total; the timecode and the ledger always agree), `position` ticks correct the frame-advanced progress clock (~500ms cadence, only while playing), one `completed` fires at natural end (the next ledger row plays — the playlist's flat order is the play order, wrapping from the last row to the first), and `failed` reports a missing file or a platform without audio playback. The volume knob rides `fx.setAudioVolume`, the long-travel fader seeks through `fx.seekAudio`, PAUSE holds the platform player in place, and STOP pauses it AND seeks home — halt-and-rewind with the record still loaded, the classic stop-vs-pause distinction.
**Tracks stream on demand out of the box.** The audio files are gitignored, but the committed manifest's `.url_base` points at the hosted mirror of the prepared catalog, so a fresh clone plays with zero setup: a missing local file streams instead of failing — the display stamps `BUFFERING` while the stream waits for bytes, playback starts as soon as they arrive, and the same bytes fill a local cache (`~/Library/Caches/deck/audio/`, keyed by URL hash and size-verified against the manifest's per-track `.bytes`; delete the directory to clear it) so the next play is local. `NATIVE_SDK_MUSIC_URL_BASE` overrides the base at launch (set it empty to disable streaming); `tools/prepare-example-music.sh` is the path for offline local files and for regenerating or self-hosting the catalog. A dead stream — offline with a cold cache, a mid-flight drop — stamps `STREAM LOST` with `CHECK THE CONNECTION AND RETRY`: a network problem, a network remedy.
**The NO MEDIA state is honest.** With streaming explicitly disabled and no prepared local files, the first play lands one `failed` event and the deck clears to an unmistakable degraded state — the display marquee stamps `NO MEDIA` in signal amber and the channel line reads `RUN TOOLS/PREPARE-EXAMPLE-MUSIC.SH`: there is genuinely no way to play, and the remedy names the script that fixes it. Never a crash, never silence; browsing and search work without the mp3s because the catalog is committed, and the next play attempt is the retry.
## Album art
The manifest's eight covers are committed beside it (`src/art/*.jpg`, 512px) and they are the only images this app registers (cover image id = album id). `init_fx` registers them through the runtime image channel, the player's **art bay** shows the loaded record's cover in its own glass window, and the playlist unit's deck strip carries the same cover as the **sleeve window** beside the ON DECK stamp. The covers are JPEG: live macOS decodes them through the platform codec; the null platform's strict test decoder refuses them, so under `-Dplatform=null` every cover id stays 0 and both surfaces degrade to engraved vector plates — the suite pins the degrade, not the decode.
## Authoring split
- `src/statusbar.native` — the playlist unit's status strip (search field, match counter), compiled at comptime; the test suite also runs it through the runtime interpreter and asserts engine parity.
- `src/spectrum.native` — the spectrum chart fragment (one bar series binding a model fn — bars only), composed into the display bay's Zig glass view.
- `src/layout.zig` — the chassis layout table: every shared dimension (window, cap band and its window keys, the glass row, key plates, wells, the transport row's accumulated x-positions, the playlist unit's stack) on one 4px grid, with comptime asserts holding the sums (the transport row fits its container; the ledger viewport folds on a whole row). Both the widget views and the chrome pass machine against this one table, so the enamel work cannot drift from the controls it hugs.
- `src/music_manifest.zon` — the committed catalog, byte-identical to the soundboard's copy (generated by `tools/prepare-example-music.sh`; edit the script, never this file). The model imports it typed at comptime and derives its flat album/track tables from it — per-album track counts vary, and nothing in the app assumes a stride.
- `src/view.zig` — the Zig fascia for both windows: the player (cap band drag region with the skin's window keys and the silkscreened DECK stamp, the one-glass display bay with the marquee and readouts around the chrome-drawn clock and the markup spectrum chart, the art bay, seek fader, the five-key transport with the volume slider and `PL` key) and the playlist unit (enamel panel chassis, the divider-ruled glass playlist bay with per-row native context menus, the deck strip with the sleeve window and the ON DECK stamp).
- `src/model.zig` — the manifest import and the comptime-derived library tables, playback/search/playlist-window state, the audio-event handling (`loaded`/`position`/`spectrum`/`completed`/`failed`) with the frame-clock rendered-clock rules and the spectrum's analyzer ballistics, the ledger-order advance, the deterministic marquee function, the window-action Msgs, and `update`.
- `src/chrome.zig` — the sculpted hardware layer, drawn through the `UiApp.chrome` display-list pass in two exact-count halves (prefix behind the widgets, suffix in front) at absolute coordinates spelled from the layout table (the window is fixed-size). The counts are module constants; the suite rebuilds the chrome across model states (idle, playing at both volume extremes, the NO MEDIA degrade, high contrast) and holds them, because the runtime rejects a build that misses its declared count.
- `src/theme.zig` — the widget skin: a one-finish token set split by material (enamel vs glass; the palette rationale lives on the fields), the registered pixel face on both typography slots with grid-derived sizes, plus per-control visual tokens (`controls.*`) that restyle the keys, the PL toggle, the glass search inset, the faders, and the scrollbar without touching any widget code.
- `src/fonts/GeistPixel-Square.ttf` + `src/fonts/OFL.txt` — the committed primary face and its SIL Open Font License (the license travels with the font, per its terms).
- `src/icons/stop.svg`, `src/icons/minimize.svg` — the deck's registered glyphs (the built-in icon set carries no transport square and no minimize bar); `main.registerIcons` installs them and views reach them as `app:stop` / `app:minimize`.
- `src/main.zig` — app wiring: fixed-size chromeless shell scene, icon and font registration, the boot effect registering the album covers, `windows_fn`/`window_view` for the playlist unit, shortcut command map, the guarded `on_frame` frame clock, tokens fn, the slider sync hook.
## The spectrum is real
The analyzer draws a real FFT of the audio the deck is playing. Hosts that can reach the player's PCM (macOS: an `MTAudioProcessingTap` on the app's single AVPlayer feeding vDSP; Windows: process-scoped WASAPI loopback capture of *this app's* audio session only, with an in-box FFT; Linux: GStreamer's `spectrum` element as the playbin's audio-filter) deliver `.spectrum` audio events at ~25 Hz while audio is audibly playing: 32 band magnitudes, log-spaced 50 Hz16 kHz, each byte linear-in-dB from the 60 dBFS floor to full scale. The events are journaled at the effect boundary exactly like position ticks — real non-determinism recorded at the edge — so replay repaints identical bars. The model keeps the latest report as per-band targets and runs classic analyzer ballistics on the frame clock: a rising band attacks instantly, a falling band decays linearly (`band_decay_per_second`), so the glass stays smooth at display rate between reports. Pausing starves both inputs and the bars FREEZE on real data; stop rests the glass; an idle deck — or a host that reports `audio_spectrum` unsupported and therefore never sends bands — shows the fixed noise-floor comb: honest absence, never fake dancing. The bands render as one markup `<chart>` (`src/spectrum.native`) on the display bay's glass — the deck's one animated band.
## Keyboard
Registered app shortcuts (`app.zon`, delivered as command events): `primary+P` play/pause, `primary+←`/`primary+→` previous/next, `primary+L` toggle the playlist window, `Escape` clears the search. The seek fader takes arrow-key steps when focused (the widget keyboard path).
`Space` toggles play/pause from anywhere — the media-app convention, carried by the app-level key fallback (`on_key`) rather than a chrome shortcut (unmodified space cannot be one, by design). A focused widget always wins first: a focused transport key presses itself, and a focused search field keeps typing spaces (`primary+P` is the works-while-typing chord).
## Fixed capacities
- The committed manifest's albums and tracks (comptime-derived tables; per-album counts VARY — the model derives `track_start`/`track_count` per album and never assumes a stride).
- 48-byte search buffer.
- 32 spectrum bands (one `.chart` widget, well under the per-series point budget).
- 16-character marquee window (worst-case fit for the proportional pixel face at the full-grid scale), one step per 500ms of playback.
- 8 registered images of the 16-slot registry: the album covers alone (each within the 1MB per-slot pixel bound) — plus ONE registered font of the 8-slot font registry (the pixel face, well inside the 2MB per-font budget).
- One context-menu item per ledger row (Copy Title): the full ledger's rows stay well inside the 512-item per-view budget (the ledger lives alone in the playlist window's view).
- Player tree under 128 widget nodes; playlist tree under 960 of the 1024 per-view budget (full ledger, one divider column per row after the first).
## Run
```sh
native dev
```
Run the deterministic suite — hermetic by construction: playback runs through the audio channel's fake executor (the gitignored mp3s are never read), every content assertion derives from the committed manifest, and the strict null-platform decoder pins the JPEG-cover degrade. It covers the manifest table derivation, the five-key transport (including stop's halt-and-rewind and the ledger-order advance), search dispatch, the NO MEDIA failure state, the playlist window round-trip through real dispatch, the spectrum's band-report envelope (fed `.spectrum` events through the fake executor: instant attack, frame-clock decay, freeze on pause, resting comb for idle/stop/no-analysis), marquee determinism on the frame-clock rendered clock (advance while playing, freeze on pause/stop, position-tick correction), the skin-native window keys (chromeless style at the create seam, close/minimize through the window-action effects), the registered pixel face (both typography slots, grid sizes, ASCII coverage), the divider-ruled ledger, cover registration + codec-less fallback, engine parity, theming, chrome command counts, layout budgets, and automation click-through:
```sh
native test -Dplatform=null
```
Verify live through the automation harness:
```sh
native build -Dautomation=true
./zig-out/bin/deck &
native automate assert 'gpu_nonblank=true' 'role=button name="Play"' 'role=button name="Stop"' 'role=button name="Close window"' 'role=button name="Minimize window"'
```
+42
View File
@@ -0,0 +1,42 @@
.{
.id = "dev.native_sdk.deck",
.name = "deck",
.display_name = "Deck",
.description = "A compact deck controller driven by keyboard shortcuts.",
.version = "0.2.0",
.platforms = .{"macos"},
.permissions = .{ "view", "command" },
.capabilities = .{ "native_views", "gpu_surfaces", "shortcuts" },
.shortcuts = .{
.{ .id = "deck.play-pause", .key = "p", .modifiers = .{"primary"} },
.{ .id = "deck.next", .key = "arrowright", .modifiers = .{"primary"} },
.{ .id = "deck.prev", .key = "arrowleft", .modifiers = .{"primary"} },
.{ .id = "deck.playlist", .key = "l", .modifiers = .{"primary"} },
.{ .id = "deck.dismiss", .key = "escape" },
},
.shell = .{
.windows = .{
.{
.label = "main",
.title = "Native SDK Deck",
.width = 512,
.height = 264,
.resizable = false,
.titlebar = "chromeless",
.restore_state = false,
.restore_policy = "center_on_primary",
.views = .{
.{ .label = "deck-canvas", .kind = "gpu_surface", .fill = true, .role = "Deck canvas", .accessibility_label = "Deck music player", .gpu_backend = "metal", .gpu_pixel_format = "bgra8_unorm", .gpu_present_mode = "timer", .gpu_alpha_mode = "opaque", .gpu_color_space = "srgb", .gpu_vsync = true },
},
},
},
},
.security = .{
.navigation = .{
.allowed_origins = .{ "zero://app", "zero://inline" },
.external_links = .{ .action = "deny" },
},
},
.web_engine = "system",
.cef = .{ .dir = "third_party/cef/macos", .auto_install = false },
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

+602
View File
@@ -0,0 +1,602 @@
//! deck chrome: the sculpted hardware layer, drawn through the sanctioned
//! `ChromeOptions` display-list pass (`UiApp.chrome`) — pushed to the
//! vintage rack-unit extreme on a SMALL, FIXED window (512x264,
//! resizable = false), so every coordinate below is absolute machining:
//!
//! prefix (behind the widgets): the cream enamel chassis fill, the
//! warm faceplate gradient with a set of near-invisible grain
//! hairlines (honest texture: fills, lines, and gradients only — this
//! skin ships no bitmap assets), the enamel cap band (the DECK stamp
//! prints directly on it — no raised plate), outer bevels, a ridged
//! grip band, four recessed corner screws, and ONE inset control well
//! behind the transport cluster (the volume knob sits directly on the
//! chassis enamel, unenclosed);
//!
//! suffix (in front of the widgets): inset bevel frames around the
//! two glass bays (the display — the deck's ONE LED section — and the
//! art bay) and the seek fader, scanlines and a diagonal glare wash
//! over the glass, the seven-segment elapsed readout drawn as sheared
//! hexagon paths (ghost segments always visible — display ghosting —
//! lit segments doubled with a translucent glow stroke), the analog
//! volume knob face with its position dot over the volume slider
//! (seated directly on the enamel — its slider cover re-plots the
//! faceplate gradient so the patch vanishes into the chassis), and
//! raised bevel edges on the transport keys and the cap band's window
//! keys (the chromeless window's own close/minimize controls).
//!
//! The chrome contract requires an EXACT command count per build, so
//! every section emits a fixed number of commands regardless of model
//! state: state-dependent marks (lit segments, lit ladder cells) are
//! drawn offscreen when hidden instead of skipped. The counts are module
//! constants and the test suite rebuilds the chrome across model states
//! to hold them.
//!
//! Path elements and gradient stops are captured by reference until the
//! runtime deep-copies the display list at install, so runtime-computed
//! segment paths live in file-scope storage (single canvas, UI-thread
//! builds only) and gradient stops are comptime constants.
//!
//! High contrast keeps the layout of the pass (same counts) but drops
//! the decoration: grain, glare, and scanlines go transparent, bevels
//! fall back to the border token, the knob flattens to bordered
//! surface + text-colored dot, and the readouts use the high-contrast
//! text color.
const std = @import("std");
const native_sdk = @import("native_sdk");
const layout = @import("layout.zig");
const model_mod = @import("model.zig");
const theme = @import("theme.zig");
const canvas = native_sdk.canvas;
const geometry = native_sdk.geometry;
const Color = canvas.Color;
const Model = model_mod.Model;
// ------------------------------------------------------------- counts
/// Scanline comb over the display glass: the one LED section is the
/// full glass-row height now, so the comb carries more lines at the
/// same ~4px pitch the old split bays wore.
const glass_scanlines: usize = 36;
const screw_commands: usize = 3;
const ridge_pairs = 3; // comptime_int: used in both command counts and f32 machining
const grain_lines: usize = 14;
const knob_ticks: usize = 5;
// Re-derived for the unboxed round: the raised brand plate (5 — the
// DECK stamp prints directly on the cap band now) and the volume well
// (5 — the knob sits directly on the enamel) are GONE from the prefix;
// every remaining term matches one section of buildPrefix in order.
pub const prefix_commands: usize =
1 + // chassis fill
1 + // faceplate gradient
grain_lines + // enamel grain hairlines
3 + // cap band (fill + top catch-light + bottom shadow)
4 + // window outer bevel
ridge_pairs * 2 + // ridged grip band above the bottom edge
4 * screw_commands + // corner screws
5; // transport well (fill + inset bevel) — the ONE recessed pocket
pub const suffix_commands: usize =
3 * 4 + // display + art + seek inset bevels
glass_scanlines + // display scanlines (the art bay keeps clear glass)
2 + // glass glare washes (display, art)
segment_commands + // seven-segment elapsed readout
knob_commands + // the volume knob face
8 * 4; // raised bevels: close, minimize, prev, play, pause, stop, next, PL
const segment_commands: usize = 3 * 21 + 6; // 3 digits x (ghost+glow+lit) + colon
const knob_commands: usize = knob_ticks + 5; // ticks + slider cover + ring + face + dot glow + dot
// ---------------------------------------------------------- palette
// Decorative chrome colors live here (they are machining, not theme
// tokens); the phosphor family comes from the theme so the readouts and
// the widgets stay one hue.
const chassis = Color.rgb8(214, 207, 189);
const faceplate_top = Color.rgb8(240, 234, 220);
const faceplate_bottom = Color.rgb8(221, 214, 196);
const cap_top = Color.rgb8(247, 242, 230);
const cap_bottom = Color.rgb8(228, 221, 203);
const bevel_light = Color.rgba8(255, 253, 244, 210);
const bevel_shadow = Color.rgba8(74, 66, 48, 150);
const ridge_light = Color.rgba8(255, 253, 244, 130);
const ridge_dark = Color.rgba8(74, 66, 48, 70);
/// The enamel grain: alternating warm hairlines a few alpha steps above
/// invisible — the honest stand-in for sprayed-enamel texture.
const grain = Color.rgba8(120, 110, 85, 9);
const scanline = Color.rgba8(0, 0, 0, 46);
const glare = Color.rgba8(255, 255, 255, 9);
const steel = Color.rgb8(196, 189, 172);
const steel_dark = Color.rgb8(110, 103, 84);
const well = Color.rgb8(216, 208, 187);
const knob_rim = Color.rgb8(87, 80, 60);
const knob_top = Color.rgb8(246, 241, 229);
const knob_bottom = Color.rgb8(210, 202, 181);
const transparent = Color.rgba8(0, 0, 0, 0);
const seg_lit = theme.phosphor;
const seg_ghost = Color.rgba8(62, 224, 138, 24);
const seg_glow = Color.rgba8(62, 224, 138, 60);
const faceplate_stops = [_]canvas.GradientStop{
.{ .offset = 0, .color = faceplate_top },
.{ .offset = 1, .color = faceplate_bottom },
};
const cap_stops = [_]canvas.GradientStop{
.{ .offset = 0, .color = cap_top },
.{ .offset = 1, .color = cap_bottom },
};
const glare_stops = [_]canvas.GradientStop{
.{ .offset = 0, .color = glare },
.{ .offset = 1, .color = transparent },
};
const screw_stops = [_]canvas.GradientStop{
.{ .offset = 0, .color = steel },
.{ .offset = 1, .color = steel_dark },
};
const knob_stops = [_]canvas.GradientStop{
.{ .offset = 0, .color = knob_top },
.{ .offset = 1, .color = knob_bottom },
};
const hc_stops = [_]canvas.GradientStop{
.{ .offset = 0, .color = transparent },
.{ .offset = 1, .color = transparent },
};
// ------------------------------------------------------------ geometry
// Absolute machining on the fixed 512x264 chassis. Every rect below is
// spelled from the layout table (layout.zig) — the same constants the
// widget views flow — so the enamel work hugs the widgets by
// construction.
const W: f32 = layout.window_width;
const H: f32 = layout.window_height;
fn rect(x: f32, y: f32, w: f32, h: f32) geometry.RectF {
return geometry.RectF.init(x, y, w, h);
}
/// Offscreen displacement for fixed-count commands that are hidden in
/// the current model state.
const offscreen: f32 = 100_000;
const display_rect = rect(layout.pad, layout.row1_y, layout.display_width, layout.row1_height);
const art_rect = rect(layout.art_x, layout.row1_y, layout.art_size, layout.row1_height);
const seek_rect = rect(layout.pad, layout.seek_y, W - layout.pad * 2, layout.seek_height);
const close_key = rect(layout.cap_close_x, layout.cap_key_y, layout.cap_key_size, layout.cap_key_size);
const min_key = rect(layout.cap_min_x, layout.cap_key_y, layout.cap_key_size, layout.cap_key_size);
const prev_key = rect(layout.prev_x, layout.key_y, layout.btn_prev_width, layout.key_height);
const play_key = rect(layout.play_x, layout.key_y, layout.btn_play_width, layout.key_height);
const pause_key = rect(layout.pause_x, layout.key_y, layout.btn_pause_width, layout.key_height);
const stop_key = rect(layout.stop_x, layout.key_y, layout.btn_stop_width, layout.key_height);
const next_key = rect(layout.next_x, layout.key_y, layout.btn_next_width, layout.key_height);
const pl_key = rect(layout.pl_x, layout.key_y, layout.btn_pl_width, layout.key_height);
const transport_well = rect(layout.transport_well_x, layout.well_y, layout.transport_well_width, layout.well_height);
// Chrome-only decoration bands, snapped to the chassis grid: the bottom
// strip between the transport row and the window edge carries the four
// screws' lower pair and the ridged grip band, both centered in it.
const bottom_band_center: f32 = (layout.transport_y + layout.transport_height + H) / 2; // 258
/// Screw centers: hard against the chassis edges horizontally (one grid
/// unit of enamel between screw rim and window edge — real rack ears
/// bolt at the extremes, not at the glass line), centered in the top
/// strip (cap band to glass) and the bottom band vertically.
const screw_radius: f32 = 4;
const screw_left_x: f32 = layout.grid + screw_radius; // 8
const screw_right_x: f32 = W - layout.grid - screw_radius; // 504
const screw_top_y: f32 = (layout.cap_height + layout.row1_y) / 2; // 36
const screw_bottom_y: f32 = bottom_band_center; // 258
/// The ridge band runs between the screws with a clear grid gap.
const ridge_x0: f32 = screw_left_x + screw_radius + layout.grid * 2; // 20
const ridge_x1: f32 = screw_right_x - screw_radius - layout.grid * 2; // 492
const ridge_pitch: f32 = 3;
const ridge_y0: f32 = bottom_band_center - (ridge_pitch * (ridge_pairs - 1) + 1) / 2; // 254.5
/// The volume knob's center: the middle of the volume slider's frame
/// (layout.knob_*), which is also the middle of the volume well band.
const knob_cx: f32 = layout.knob_x + layout.knob_width / 2;
const knob_cy: f32 = layout.transport_y + layout.transport_height / 2;
const knob_radius: f32 = layout.knob_size / 2; // 17
// ------------------------------------------------------------- build
pub fn build(model: *const Model, builder: *canvas.Builder, size: geometry.SizeF, tokens: canvas.DesignTokens) anyerror!void {
_ = size; // fixed window: the machining is absolute geometry
const hc = model.appearance.high_contrast;
try buildPrefix(builder, tokens, hc);
try buildSuffix(model, builder, tokens, hc);
}
fn buildPrefix(builder: *canvas.Builder, tokens: canvas.DesignTokens, hc: bool) anyerror!void {
// Chassis fill, then the warm enamel faceplate gradient.
try builder.fillRect(.{ .rect = rect(0, 0, W, H), .fill = .{ .color = if (hc) tokens.colors.background else chassis } });
const faceplate = rect(0, layout.cap_height, W, H - layout.cap_height);
try builder.fillRect(.{ .rect = faceplate, .fill = if (hc) .{ .color = tokens.colors.surface } else .{ .linear_gradient = .{
.start = point(0, faceplate.y),
.end = point(0, H),
.stops = &faceplate_stops,
} } });
// The enamel grain: a sparse comb of near-invisible warm hairlines
// across the faceplate — texture by honest means (no bitmap skin
// assets anywhere in this app).
const grain_pitch = (H - layout.cap_height - 12) / @as(f32, @floatFromInt(grain_lines - 1));
for (0..grain_lines) |index| {
const y = layout.cap_height + 6 + @as(f32, @floatFromInt(index)) * grain_pitch;
try hline(builder, 2, W - 2, y, if (hc) transparent else grain, 1);
}
// The cap band: lighter enamel with a catch-light on its top edge
// and a hard shadow under it — the band reads as its own plate.
try builder.fillRect(.{ .rect = rect(0, 0, W, layout.cap_height), .fill = if (hc) .{ .color = tokens.colors.surface } else .{ .linear_gradient = .{
.start = point(0, 0),
.end = point(0, layout.cap_height),
.stops = &cap_stops,
} } });
try hline(builder, 0, W, 0.5, if (hc) tokens.colors.border else bevel_light, 1);
try hline(builder, 0, W, layout.cap_height + 0.5, if (hc) tokens.colors.border else bevel_shadow, 1);
// No brand plate: the D E C K stamp (a widget paragraph in the cap
// band's flow) prints directly on the band's enamel — lettering on
// the finish, nothing raised, nothing boxed.
// Window outer bevel: the whole device is one raised plate.
try bevelOut(builder, rect(0, 0, W, H), tokens, hc);
// The ridged grip band, centered in the bottom strip between the
// screws (it stops a clear grid gap short of each).
var ridge: f32 = ridge_y0;
for (0..ridge_pairs) |_| {
try hline(builder, ridge_x0, ridge_x1, ridge, if (hc) transparent else ridge_light, 1);
try hline(builder, ridge_x0, ridge_x1, ridge + 1, if (hc) transparent else ridge_dark, 1);
ridge += ridge_pitch;
}
// Corner screws: flush with the glass bays' outer edges, centered
// in the top strip and the bottom band — clear of the glass frames,
// the wells, and the ridge band.
try screw(builder, screw_left_x, screw_top_y, hc);
try screw(builder, screw_right_x, screw_top_y, hc);
try screw(builder, screw_left_x, screw_bottom_y, hc);
try screw(builder, screw_right_x, screw_bottom_y, hc);
// The ONE inset well: the five-key transport cluster sits in a
// recessed pocket, like keys machined into the panel. The volume
// block gets no pocket — the knob rides the open enamel.
try insetWell(builder, transport_well, tokens, hc);
}
fn buildSuffix(model: *const Model, builder: *canvas.Builder, tokens: canvas.DesignTokens, hc: bool) anyerror!void {
// Inset bevels: both glass bays and the seek fader are recessed
// into the enamel.
try bevelIn(builder, display_rect, tokens, hc);
try bevelIn(builder, art_rect, tokens, hc);
try bevelIn(builder, seek_rect, tokens, hc);
// Scanlines over the printing glass (the art bay keeps clear glass:
// it shows a photograph, not phosphor). Over the spectrum chart the
// lines double as the classic segmented-ladder look — real bars,
// honestly segmented by the glass in front of them.
try scanlines(builder, display_rect, glass_scanlines, hc);
// Diagonal glare wash: light falls across the glass from top-left.
try glareWash(builder, display_rect, hc);
try glareWash(builder, art_rect, hc);
// The seven-segment elapsed readout on the display's clear glass.
try segmentReadout(model, builder, tokens, hc);
// The analog volume knob over the volume slider.
try volumeKnob(model, builder, tokens, hc);
// Raised bevel edges on the sculpted keys: the cap band's window
// keys (the chromeless window's own close/minimize controls), the
// transport cluster, and the PL toggle.
try bevelOut(builder, close_key, tokens, hc);
try bevelOut(builder, min_key, tokens, hc);
try bevelOut(builder, prev_key, tokens, hc);
try bevelOut(builder, play_key, tokens, hc);
try bevelOut(builder, pause_key, tokens, hc);
try bevelOut(builder, stop_key, tokens, hc);
try bevelOut(builder, next_key, tokens, hc);
try bevelOut(builder, pl_key, tokens, hc);
}
// ------------------------------------------------------------ helpers
fn point(x: f32, y: f32) geometry.PointF {
return geometry.PointF.init(x, y);
}
fn hline(builder: *canvas.Builder, x0: f32, x1: f32, y: f32, color: Color, width: f32) anyerror!void {
try builder.drawLine(.{ .from = point(x0, y), .to = point(x1, y), .stroke = .{ .fill = .{ .color = color }, .width = width } });
}
/// Raised edge: light catches the top and left, shadow falls bottom and
/// right. 4 commands.
fn bevelOut(builder: *canvas.Builder, r: geometry.RectF, tokens: canvas.DesignTokens, hc: bool) anyerror!void {
const light = if (hc) tokens.colors.border else bevel_light;
const shadow = if (hc) tokens.colors.border else bevel_shadow;
const x1 = r.x + r.width;
const y1 = r.y + r.height;
try builder.drawLine(.{ .from = point(r.x, r.y + 0.5), .to = point(x1, r.y + 0.5), .stroke = .{ .fill = .{ .color = light }, .width = 1 } });
try builder.drawLine(.{ .from = point(r.x + 0.5, r.y), .to = point(r.x + 0.5, y1), .stroke = .{ .fill = .{ .color = light }, .width = 1 } });
try builder.drawLine(.{ .from = point(r.x, y1 - 0.5), .to = point(x1, y1 - 0.5), .stroke = .{ .fill = .{ .color = shadow }, .width = 1 } });
try builder.drawLine(.{ .from = point(x1 - 0.5, r.y), .to = point(x1 - 0.5, y1), .stroke = .{ .fill = .{ .color = shadow }, .width = 1 } });
}
/// Recessed edge: the inverse — shadow on top/left, light on the bottom
/// lip. 4 commands.
fn bevelIn(builder: *canvas.Builder, r: geometry.RectF, tokens: canvas.DesignTokens, hc: bool) anyerror!void {
const light = if (hc) tokens.colors.border else bevel_light;
const shadow = if (hc) tokens.colors.border else bevel_shadow;
const x1 = r.x + r.width;
const y1 = r.y + r.height;
try builder.drawLine(.{ .from = point(r.x, r.y + 0.5), .to = point(x1, r.y + 0.5), .stroke = .{ .fill = .{ .color = shadow }, .width = 1 } });
try builder.drawLine(.{ .from = point(r.x + 0.5, r.y), .to = point(r.x + 0.5, y1), .stroke = .{ .fill = .{ .color = shadow }, .width = 1 } });
try builder.drawLine(.{ .from = point(r.x, y1 - 0.5), .to = point(x1, y1 - 0.5), .stroke = .{ .fill = .{ .color = light }, .width = 1 } });
try builder.drawLine(.{ .from = point(x1 - 0.5, r.y), .to = point(x1 - 0.5, y1), .stroke = .{ .fill = .{ .color = light }, .width = 1 } });
}
/// Recessed pocket: darker enamel fill + inset bevel. 5 commands.
fn insetWell(builder: *canvas.Builder, r: geometry.RectF, tokens: canvas.DesignTokens, hc: bool) anyerror!void {
try builder.fillRect(.{ .rect = r, .fill = .{ .color = if (hc) tokens.colors.background else well } });
try bevelIn(builder, r, tokens, hc);
}
/// One recessed screw: steel disc, slot, and a catch-light. 3 commands.
fn screw(builder: *canvas.Builder, cx: f32, cy: f32, hc: bool) anyerror!void {
const r: f32 = screw_radius;
try builder.fillRoundedRect(.{ .rect = rect(cx - r, cy - r, r * 2, r * 2), .radius = canvas.Radius.all(r), .fill = if (hc) .{ .color = transparent } else .{ .linear_gradient = .{
.start = point(cx - r, cy - r),
.end = point(cx + r, cy + r),
.stops = &screw_stops,
} } });
try builder.drawLine(.{ .from = point(cx - 2.5, cy + 2.5), .to = point(cx + 2.5, cy - 2.5), .stroke = .{ .fill = .{ .color = if (hc) transparent else bevel_shadow }, .width = 1.3 } });
try builder.drawLine(.{ .from = point(cx - 2, cy - 3), .to = point(cx + 0.5, cy - 3.8), .stroke = .{ .fill = .{ .color = if (hc) transparent else bevel_light }, .width = 1 } });
}
fn scanlines(builder: *canvas.Builder, r: geometry.RectF, count: usize, hc: bool) anyerror!void {
const pitch = r.height / @as(f32, @floatFromInt(count));
for (0..count) |index| {
const y = r.y + (@as(f32, @floatFromInt(index)) + 0.5) * pitch;
try hline(builder, r.x + 1, r.x + r.width - 1, y, if (hc) transparent else scanline, 1);
}
}
fn glareWash(builder: *canvas.Builder, r: geometry.RectF, hc: bool) anyerror!void {
try builder.fillRect(.{ .rect = r, .fill = .{ .linear_gradient = .{
.start = point(r.x, r.y),
.end = point(r.x + r.width * 0.7, r.y + r.height),
.stops = if (hc) &hc_stops else &glare_stops,
} } });
}
// ------------------------------------------------------- volume knob
/// The analog volume knob: drawn OVER the volume slider widget (the
/// slider is the real control — drag, arrow keys, automation, focus
/// ring — and the knob is its analog face). The position dot's angle
/// derives from the same `volume_fraction` the slider syncs into the
/// model, sweeping 270 degrees from min (down-left) to max
/// (down-right) like every amplifier dial ever cast.
fn volumeKnob(model: *const Model, builder: *canvas.Builder, tokens: canvas.DesignTokens, hc: bool) anyerror!void {
const r = knob_radius;
// Position ticks at 0/25/50/75/100 percent, engraved on the chassis
// enamel around the knob (part of the dial, not an enclosure).
for (0..knob_ticks) |index| {
const fraction = @as(f32, @floatFromInt(index)) / @as(f32, @floatFromInt(knob_ticks - 1));
const theta = knobAngle(fraction);
try builder.drawLine(.{
.from = point(knob_cx + @cos(theta) * (r - 1), knob_cy + @sin(theta) * (r - 1)),
.to = point(knob_cx + @cos(theta) * (r + 2), knob_cy + @sin(theta) * (r + 2)),
.stroke = .{ .fill = .{ .color = if (hc) transparent else bevel_shadow }, .width = 1 },
});
}
// A cover over the slider's own track and thumb: the slider stays
// the live control underneath (hits, focus, keys), but the knob is
// the only thing the eye gets. The fill re-plots the faceplate
// gradient over the faceplate's own y-range (the rect clips it), so
// the patch is byte-identical to the enamel behind it — the knob
// sits directly on the chassis, no well, no box. High contrast
// keeps the cover transparent so the stock slider shows honestly.
try builder.fillRect(.{ .rect = rect(layout.knob_x, layout.key_y, layout.knob_width, layout.key_height), .fill = if (hc) .{ .color = transparent } else .{ .linear_gradient = .{
.start = point(0, layout.cap_height),
.end = point(0, H),
.stops = &faceplate_stops,
} } });
// The dark rim ring, then the enamel face lit from the top.
try builder.fillRoundedRect(.{ .rect = rect(knob_cx - r, knob_cy - r, r * 2, r * 2), .radius = canvas.Radius.all(r), .fill = .{ .color = if (hc) tokens.colors.border else knob_rim } });
const face = r - 2;
try builder.fillRoundedRect(.{ .rect = rect(knob_cx - face, knob_cy - face, face * 2, face * 2), .radius = canvas.Radius.all(face), .fill = if (hc) .{ .color = tokens.colors.surface } else .{ .linear_gradient = .{
.start = point(knob_cx, knob_cy - face),
.end = point(knob_cx, knob_cy + face),
.stops = &knob_stops,
} } });
// The position dot with a phosphor glow halo (transparent in high
// contrast; the dot itself flips to the text color).
const theta = knobAngle(std.math.clamp(model.volume_fraction, 0, 1));
const dot_r: f32 = 2.2;
const dot_x = knob_cx + @cos(theta) * (face - 4.5);
const dot_y = knob_cy + @sin(theta) * (face - 4.5);
try builder.fillRoundedRect(.{ .rect = rect(dot_x - dot_r - 1.5, dot_y - dot_r - 1.5, (dot_r + 1.5) * 2, (dot_r + 1.5) * 2), .radius = canvas.Radius.all(dot_r + 1.5), .fill = .{ .color = if (hc) transparent else seg_glow } });
try builder.fillRoundedRect(.{ .rect = rect(dot_x - dot_r, dot_y - dot_r, dot_r * 2, dot_r * 2), .radius = canvas.Radius.all(dot_r), .fill = .{ .color = if (hc) tokens.colors.text else seg_lit } });
}
/// The dial sweep in y-down screen radians: fraction 0 sits down-left
/// (135 degrees), fraction 1 down-right (405 == 45 degrees), turning
/// clockwise through the top.
fn knobAngle(fraction: f32) f32 {
const degrees = 135.0 + fraction * 270.0;
return degrees * std.math.pi / 180.0;
}
// ----------------------------------------------------- seven-segment
// Segment order: A top, B top-right, C bottom-right, D bottom, E
// bottom-left, F top-left, G middle. Classic sheared display, sized for
// the display bay's clear glass (`layout.segment_area_width`).
const digit_width: f32 = 18;
const digit_height: f32 = 28;
const seg_thickness: f32 = 3.8;
const digit_gap: f32 = 6;
const colon_width: f32 = 8;
const shear: f32 = 0.09;
pub const readout_width: f32 = digit_width * 3 + digit_gap * 3 + colon_width;
/// The shear leans the glyph box right of x0+readout_width by this much
/// at its top row; the centering math folds it in so the leaned readout
/// sits optically centered in the clear glass.
const shear_reach: f32 = digit_height * shear;
const segments_for_digit = [10][7]bool{
.{ true, true, true, true, true, true, false }, // 0
.{ false, true, true, false, false, false, false }, // 1
.{ true, true, false, true, true, false, true }, // 2
.{ true, true, true, true, false, false, true }, // 3
.{ false, true, true, false, false, true, true }, // 4
.{ true, false, true, true, false, true, true }, // 5
.{ true, false, true, true, true, true, true }, // 6
.{ true, true, true, false, false, false, false }, // 7
.{ true, true, true, true, true, true, true }, // 8
.{ true, true, true, true, false, true, true }, // 9
};
/// Path storage referenced by the display list until the runtime's
/// deep copy at install (single canvas, UI-thread builds only).
var ghost_paths: [3][7][7]canvas.PathElement = undefined;
var lit_paths: [3][7][7]canvas.PathElement = undefined;
fn segmentReadout(model: *const Model, builder: *canvas.Builder, tokens: canvas.DesignTokens, hc: bool) anyerror!void {
// Dead-centered in the clear glass the display reserves at its
// top-left: vertically in the display's top row (the spectrum chart
// rides beside it), horizontally in the segment area (accounting
// for the shear lean).
const x0 = display_rect.x + layout.glass_inset + (layout.segment_area_width - readout_width - shear_reach) / 2;
const y0 = display_rect.y + layout.glass_inset + (layout.display_top_row_height - digit_height) / 2;
// Digits: M : S S. Idle shows dashes (G segments), the classic
// no-signal readout.
const elapsed_s = model.elapsed_ms / 1000;
const idle = model.now == null;
const digits = [3]?u8{
if (idle) null else @intCast(@min(9, elapsed_s / 60)),
if (idle) null else @intCast((elapsed_s % 60) / 10),
if (idle) null else @intCast(elapsed_s % 10),
};
const digit_x = [3]f32{
x0,
x0 + digit_width + digit_gap + colon_width + digit_gap,
x0 + digit_width * 2 + digit_gap * 2 + colon_width + digit_gap,
};
const ghost = if (hc) transparent else seg_ghost;
const lit = if (hc) tokens.colors.text else seg_lit;
const glow = if (hc) transparent else seg_glow;
for (digits, digit_x, 0..) |digit, dx, slot| {
for (0..7) |seg| {
const on = if (digit) |d| segments_for_digit[d][seg] else seg == 6;
// Ghost pass: every segment, always on-screen.
segmentPath(&ghost_paths[slot][seg], dx, y0, @intCast(seg), 0);
try builder.fillPath(.{ .elements = &ghost_paths[slot][seg], .fill = .{ .color = ghost } });
// Glow + lit passes: offscreen when the segment is dark.
const shift: f32 = if (on) 0 else offscreen;
segmentPath(&lit_paths[slot][seg], dx, y0, @intCast(seg), shift);
try builder.strokePath(.{ .elements = &lit_paths[slot][seg], .stroke = .{ .fill = .{ .color = glow }, .width = 3.2 } });
try builder.fillPath(.{ .elements = &lit_paths[slot][seg], .fill = .{ .color = lit } });
}
}
// Colon: two square dots, ghost + glow + lit (lit hidden when idle).
const cx = x0 + digit_width + digit_gap + shearAt(y0, y0 + digit_height * 0.5);
const dot_shift: f32 = if (idle) offscreen else 0;
const dot_ys = [2]f32{ y0 + digit_height * 0.30, y0 + digit_height * 0.64 };
for (dot_ys) |dy| {
try builder.fillRect(.{ .rect = rect(cx, dy, 3.5, 3.5), .fill = .{ .color = ghost } });
try builder.strokeRect(.{ .rect = rect(cx + dot_shift, dy, 3.5, 3.5), .stroke = .{ .fill = .{ .color = glow }, .width = 2.6 } });
try builder.fillRect(.{ .rect = rect(cx + dot_shift, dy, 3.5, 3.5), .fill = .{ .color = lit } });
}
}
fn shearAt(y0: f32, y: f32) f32 {
// Positive shear leans the display to the right, like every
// segment display ever.
return (y0 + digit_height - y) * shear;
}
/// Writes one segment's sheared hexagon into `out` (7 elements:
/// move + 5 lines + close).
fn segmentPath(out: *[7]canvas.PathElement, dx: f32, dy: f32, segment: u3, shift: f32) void {
const t = seg_thickness;
const ht = t / 2;
const w = digit_width;
const h = digit_height;
// Segment center-lines in unsheared digit space.
var horizontal = true;
var cx: f32 = w / 2;
var cy: f32 = 0;
var half: f32 = w / 2 - ht - 0.6;
switch (segment) {
0 => cy = ht, // A
1 => {
horizontal = false;
cx = w - ht;
cy = h * 0.25 + ht * 0.5;
half = h * 0.25 - ht - 0.6;
}, // B
2 => {
horizontal = false;
cx = w - ht;
cy = h * 0.75 - ht * 0.5;
half = h * 0.25 - ht - 0.6;
}, // C
3 => cy = h - ht, // D
4 => {
horizontal = false;
cx = ht;
cy = h * 0.75 - ht * 0.5;
half = h * 0.25 - ht - 0.6;
}, // E
5 => {
horizontal = false;
cx = ht;
cy = h * 0.25 + ht * 0.5;
half = h * 0.25 - ht - 0.6;
}, // F
6 => cy = h / 2, // G
7 => unreachable,
}
var points: [6][2]f32 = undefined;
if (horizontal) {
points = .{
.{ cx - half, cy },
.{ cx - half + ht, cy - ht },
.{ cx + half - ht, cy - ht },
.{ cx + half, cy },
.{ cx + half - ht, cy + ht },
.{ cx - half + ht, cy + ht },
};
} else {
points = .{
.{ cx, cy - half },
.{ cx + ht, cy - half + ht },
.{ cx + ht, cy + half - ht },
.{ cx, cy + half },
.{ cx - ht, cy + half - ht },
.{ cx - ht, cy - half + ht },
};
}
for (points, 0..) |p, index| {
const sheared_x = dx + p[0] + (h - p[1]) * shear + shift;
const y = dy + p[1];
out[index] = .{
.verb = if (index == 0) .move_to else .line_to,
.points = .{ point(sheared_x, y), point(0, 0), point(0, 0) },
};
}
out[6] = .{ .verb = .close };
}
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/></svg>

After

Width:  |  Height:  |  Size: 208 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="6" width="12" height="12" rx="1"/></svg>

After

Width:  |  Height:  |  Size: 237 B

+213
View File
@@ -0,0 +1,213 @@
//! deck chassis layout: THE single dimensions table. Both the widget
//! views (`view.zig`) and the sculpted chrome pass (`chrome.zig`) machine
//! against these constants — nothing below is allowed to hand-copy a
//! coordinate, so the enamel work cannot drift from the controls it hugs.
//!
//! Everything snaps to one 4px chassis grid: window margins, glass bays,
//! glass insets, key plates, the transport well, gaps, and the transport
//! row's derived x-positions (accumulated here exactly the way the widget
//! row flows them). Comptime asserts at the bottom hold the sums: the
//! transport row must fit its container, and the vertical rhythm must
//! land exactly on the window edge.
/// The chassis grid: every plate edge, well lip, key, gap, and glass
/// inset below is a multiple of this.
pub const grid: f32 = 4;
// ------------------------------------------------------------- window
/// Main window (the rack unit): a fixed-size enamel faceplate — FIXED
/// SIZE (resizable = false), so all machining is absolute geometry.
pub const window_width: f32 = 512;
pub const window_height: f32 = 264;
/// The enamel cap band across the top: the drag region (`window_drag`)
/// with the silkscreened DECK stamp and — the window is chromeless, no
/// OS controls exist — the skin's own close and minimize keys.
pub const cap_height: f32 = 30;
/// Faceplate margin: glass bays, the seek fader, and the key row all
/// keep this distance from the window edges.
pub const pad: f32 = 12;
/// The cap band's row gap: the view flows the window keys, the DECK
/// stamp, and the trailing lamps with this gap; the chrome's key bevels
/// ride the same flow constants.
pub const cap_gap: f32 = 8;
/// The cap band's window keys (close, then minimize): square enamel
/// keys at the leading edge — the fully-skinned replacement for the
/// traffic lights, wired to the runtime's REAL window-action effects.
pub const cap_key_size: f32 = 20;
pub const cap_key_y: f32 = (cap_height - cap_key_size) / 2; // 5
pub const cap_close_x: f32 = pad; // 12
pub const cap_min_x: f32 = cap_close_x + cap_key_size + cap_gap; // 40
/// The DECK stamp's cased width on the cap band: the "D E C K"
/// lettering (letter-spaced like the window-key inset it follows)
/// prints DIRECTLY on the band's enamel — silkscreen, not embossing; no
/// plate, no box — centered in this slot where the cap band's row flow
/// places it after the window keys.
pub const brand_width: f32 = 64;
/// The rhythm gap between the stacked rows (glass / seek / transport)
/// and between bays on the same row.
pub const gap: f32 = 8;
/// Every glass bay pads its widgets by this inset; the chrome's bevel
/// frames sit exactly on the bay rects.
pub const glass_inset: f32 = 8;
// ------------------------------------------------------ vertical rhythm
pub const row1_y: f32 = cap_height + pad; // 42
/// ONE glass row: the display bay (the deck's single LED section —
/// segment clock, spectrum, marquee, channel/source lines) beside the
/// art bay.
pub const row1_height: f32 = 140;
pub const seek_y: f32 = row1_y + row1_height + gap; // 190
pub const seek_height: f32 = 18; // cases the fader's squared thumb
pub const transport_y: f32 = seek_y + seek_height + gap; // 216
pub const transport_height: f32 = 36;
// ------------------------------------------------------------ glass row
/// The art bay: the loaded record's cover in its own glass window,
/// square at the glass row's full height.
pub const art_size: f32 = row1_height; // 140
pub const art_x: f32 = window_width - pad - art_size; // 360
/// The main display bay fills the rest of the row — the ONE LED glass
/// section on the fascia.
pub const display_width: f32 = window_width - pad * 2 - art_size - gap; // 340
/// Clear glass reserved at the display's top-left for the chrome-drawn
/// seven-segment elapsed readout; the spectrum chart fills the rest of
/// the same row.
pub const segment_area_width: f32 = 96;
/// The display bay's top row (segment readout | spectrum chart); the
/// chrome centers the segment digits vertically in it.
pub const display_top_row_height: f32 = 40;
/// The display column's row gap (top row / marquee / channel / source):
/// tighter than the chassis `gap` so the four rows sit inside the glass
/// interior with honest slack for the pixel face's line boxes.
pub const display_row_gap: f32 = 2;
// ----------------------------------------------------------- transport
// The transport row flows left-to-right with `gap` between items; the
// x-positions accumulate here so the chrome's raised bevels and well
// land exactly on the widget plates. A growing spacer follows the
// volume block — blank enamel — so the PL key is right-aligned at
// `pl_x` by construction.
pub const key_height: f32 = 26;
pub const key_y: f32 = transport_y + (transport_height - key_height) / 2; // 221
pub const btn_prev_width: f32 = 32;
pub const btn_play_width: f32 = 44;
pub const btn_pause_width: f32 = 32;
pub const btn_stop_width: f32 = 32;
pub const btn_next_width: f32 = 32;
pub const btn_pl_width: f32 = 44;
/// Fixed spacer between the transport well's bevel and the volume
/// block — clear enamel between the recessed pocket and the open-air
/// knob (the volume block has no enclosure of its own).
pub const cluster_spacer: f32 = 8;
/// Cases "VOL" at the pixel face's readout pitch.
pub const vol_caption_width: f32 = 28;
/// The rotary volume control: a real slider widget of this square-ish
/// footprint; the chrome draws the knob face over it, seated directly
/// on the chassis enamel (see chrome.zig — no well, no box).
pub const knob_width: f32 = 40;
pub const knob_size: f32 = 34; // the drawn knob's diameter
pub const prev_x: f32 = pad; // 12
pub const play_x: f32 = prev_x + btn_prev_width + gap; // 52
pub const pause_x: f32 = play_x + btn_play_width + gap; // 104
pub const stop_x: f32 = pause_x + btn_pause_width + gap; // 144
pub const next_x: f32 = stop_x + btn_stop_width + gap; // 184
pub const vol_x: f32 = next_x + btn_next_width + gap + cluster_spacer + gap; // 240
pub const knob_x: f32 = vol_x + vol_caption_width + gap; // 276
pub const pl_x: f32 = window_width - pad - btn_pl_width; // 456
/// The recessed pocket behind the five-key transport cluster: one grid
/// unit of lip around the plates it cases. The volume block gets NO
/// pocket — the knob sits directly on the chassis enamel.
pub const well_lip: f32 = grid;
pub const transport_well_x: f32 = prev_x - well_lip; // 8
pub const transport_well_width: f32 = next_x + btn_next_width + well_lip - transport_well_x; // 212
pub const well_y: f32 = transport_y; // 216
pub const well_height: f32 = transport_height; // 36
// ------------------------------------------------------------- playlist
/// Playlist window (the matching rack unit below the deck): SAME width
/// as the player — the two units stack flush in the rack.
pub const playlist_width: f32 = window_width; // 512
pub const playlist_height: f32 = 440;
/// The rack's cap strip: same height as the player's enamel cap, and
/// the same skin-native close/minimize keys at its leading edge (the
/// window is chromeless too).
pub const playlist_header_height: f32 = 30;
/// Rack padding: the ledger's VERTICAL rhythm and the status strip
/// inset content by this much (the ledger's x axis insets deeper — see
/// `ledger_inset_x`).
pub const rack_pad: f32 = 8;
pub const statusbar_height: f32 = 39; // 38 strip + 1 separator
/// The bottom deck strip: the loaded record's sleeve window plus the ON
/// DECK stamp naming it. Tall enough to case the sleeve with the
/// strip's own padding as its lip.
pub const deck_strip_height: f32 = 50;
pub const deck_strip_pad: f32 = 4;
/// The sleeve window: the current album's committed cover (or its
/// engraved fallback plate) at the strip's left.
pub const sleeve_size: f32 = deck_strip_height - deck_strip_pad * 2; // 42
pub const ledger_caption_height: f32 = 16;
pub const ledger_row_height: f32 = 27;
/// The ledger's HORIZONTAL content inset: four grid units of clear
/// glass between the bay edges and the rows on the x axis (the caption
/// line, the rows, and the hairline rules between them all live in the
/// same padded column, so the dividers inset with the content). The
/// vertical rhythm stays on `rack_pad` — the viewport fold assert below
/// depends on it.
pub const ledger_inset_x: f32 = grid * 4; // 16
/// The hairline divider BETWEEN ledger rows (no per-row plates — the
/// bay is one glass table ruled by single hairlines; the first row has
/// no rule above it and the last none below).
pub const ledger_divider_height: f32 = 1;
/// Ledger columns (the rack is ONE flat song list): the track number
/// slot, the growing title, then artist and the right-aligned duration.
/// Number and duration slots case the pixel face's wider digits.
pub const ledger_number_width: f32 = 20;
pub const ledger_artist_width: f32 = 132;
pub const ledger_duration_width: f32 = 40;
/// Trailing in-row slot reserving the overlay scrollbar's lane (density
/// inset 3 + ~5.5 thickness): it keeps the duration digits clear of the
/// thumb while the rows keep their full width.
pub const ledger_scroll_lane: f32 = 2;
/// The ledger's scroll viewport, derived the way the playlist column
/// stacks: header, hairline, ledger (rack_pad + caption + gap + rows +
/// rack_pad), deck strip, status strip.
pub const ledger_viewport_height: f32 = playlist_height -
playlist_header_height - 1 - deck_strip_height - statusbar_height -
rack_pad * 2 - ledger_caption_height - gap; // 280 (ten whole rows)
// ------------------------------------------------------------- asserts
comptime {
// The vertical rhythm lands exactly on the window edge.
if (transport_y + transport_height + pad != window_height)
@compileError("player rows do not fill the fixed window height");
// The transport row fits its container: the volume block clears the
// right-pinned PL key with at least one gap of enamel between them
// (the growing spacer absorbs the rest as blank faceplate).
if (knob_x + knob_width + gap > pl_x)
@compileError("transport row overflows the fixed window width");
// The ledger viewport folds on a whole row: N rows plus their
// dividers fit with less than one row pitch of slack, so no row is
// ever cut mid-glyph at the fold.
const pitch = ledger_row_height + ledger_divider_height;
const rows = @floor(ledger_viewport_height / pitch);
if (ledger_viewport_height - rows * pitch > 2)
@compileError("ledger viewport cuts a row at the fold");
}
+334
View File
@@ -0,0 +1,334 @@
//! deck: the radically skinned sibling of `examples/soundboard` — the same
//! local music player (albums, tracks, transport, seek, search)
//! wearing vintage rack-unit hardware identity, in the true two-window
//! shape: a SMALL, FIXED player window (the window IS the device —
//! CHROMELESS, so the enamel cap band is the drag region and carries
//! the skin's own working close/minimize keys) and a matching playlist
//! unit declared through `windows_fn` while the model says it is open
//! (the PL key and `primary+L` flip the flag).
//! Everything visual comes from the deck theme's design tokens plus
//! Zig-drawn chrome (the `ui.chart` spectrum, pixel-face readouts, the
//! seven-segment elapsed readout, the volume knob face) — pure fills,
//! lines, gradients, and paths; this skin ships no bitmap texture
//! assets, and nothing forks the engine. Playback is REAL: the audio
//! effect channel drives the platform player over the shared committed
//! catalog (the mp3s live once, in the soundboard's gitignored assets),
//! and a failed load lands the honest NO MEDIA remedy on the display
//! instead of a crash or silence.
//!
//! Authoring split (markup where it fits): the playlist's status strip is
//! a `.native` view compiled at comptime; the faceplate and the playlist
//! rack are Zig views because they need what the closed markup grammar
//! deliberately excludes — the chart widget, scaled mono spans, per-row
//! native context menus, and the registered-image cover leaf.
const std = @import("std");
const runner = @import("runner");
const native_sdk = @import("native_sdk");
pub const panic = std.debug.FullPanic(native_sdk.debug.capturePanic);
const canvas = native_sdk.canvas;
const geometry = native_sdk.geometry;
const chrome = @import("chrome.zig");
const model_mod = @import("model.zig");
const theme = @import("theme.zig");
const view_mod = @import("view.zig");
pub const Model = model_mod.Model;
pub const Msg = model_mod.Msg;
pub const update = model_mod.update;
pub const rootView = view_mod.rootView;
pub const canvas_label = "deck-canvas";
pub const window_width: f32 = view_mod.window_width;
pub const window_height: f32 = view_mod.window_height;
pub const playlist_window_label = model_mod.playlist_window_label;
pub const playlist_canvas_label = "playlist-canvas";
const app_permissions = [_][]const u8{ native_sdk.security.permission_command, native_sdk.security.permission_view };
const shell_views = [_]native_sdk.ShellView{
.{ .label = canvas_label, .kind = .gpu_surface, .fill = true, .role = "Deck canvas", .accessibility_label = "Deck music player", .gpu_backend = .metal, .gpu_pixel_format = .bgra8_unorm, .gpu_present_mode = .timer, .gpu_alpha_mode = .@"opaque", .gpu_color_space = .srgb, .gpu_vsync = true },
};
const shell_windows = [_]native_sdk.ShellWindow{.{
.label = model_mod.main_window_label,
.title = "Native SDK Deck",
.width = window_width,
.height = window_height,
// The player is a piece of hardware: fixed size (the chrome pass
// machines absolute geometry) and NO OS chrome at all — the
// explicit `chromeless` opt-in, honest here because the enamel cap
// band is the drag region AND carries the skin's own working
// close/minimize keys (wired to the real window-action effects).
// Apps that do not draw their own controls should use the hidden
// styles instead, which keep the real OS buttons.
.resizable = false,
.titlebar = .chromeless,
.restore_state = false,
.views = &shell_views,
}};
pub const shell_scene: native_sdk.ShellConfig = .{ .windows = &shell_windows };
// -------------------------------------------------------------- app icons
/// The deck's own vector glyphs, parsed at comptime from the common
/// stroke-icon dialect (24x24, stroke-width 2, currentColor): the
/// transport STOP square (the built-in set carries every other
/// transport verb but no square, and this skin refuses a text-labeled
/// stop key) and the cap band's MINIMIZE bar (the chromeless windows
/// draw their own window controls; the built-in set has no minus).
const stop_icon = canvas.svg_icon.parseComptime(@embedFile("icons/stop.svg"));
const minimize_icon = canvas.svg_icon.parseComptime(@embedFile("icons/minimize.svg"));
/// The registered icon table: ONE declaration feeds boot-time
/// registration (`registerIcons`, called from main and the test
/// harness) AND the model contract's `app_icons` list, so `app:<name>`
/// references are verified by `native check` against exactly what the
/// app registers.
pub const app_icons = [_]canvas.icons.Entry{
.{ .name = "stop", .icon = &stop_icon },
.{ .name = "minimize", .icon = &minimize_icon },
};
/// Install the app icon table; once, before views build (main does it
/// first thing, and the tests' harness setup mirrors it).
pub fn registerIcons() void {
canvas.icons.registerAppIcons(&app_icons);
}
// ---------------------------------------------------------------- covers
/// Album cover image ids ARE the album ids (1-based): the covers are
/// the only registered images this app carries — 8 of the runtime's 16
/// slots. (An earlier round also registered two bitmap chassis
/// textures; the vintage-enamel skin draws its texture with fills,
/// lines, and gradients instead, so the image channel is covers-only.)
pub fn coverImageId(album_id: u8) canvas.ImageId {
return album_id;
}
/// Boot effect: decode and register every album's committed cover from
/// the manifest's art slots. Registration is synchronous on the effects
/// channel; ids reach the model only on success, so a failed decode
/// leaves that surface on its vector fallback (the art bay and sleeve
/// pane stay engraved plates) — a bad asset can never break
/// presentation. The covers are JPEG: live macOS decodes them through
/// the platform codec, while the null platform's strict test decoder
/// refuses them and the suite pins the degrade instead.
pub fn boot(model: *Model, fx: *model_mod.Effects) void {
inline for (model_mod.albums, 0..) |album, index| {
if (album.art) |art_path| {
const image_id = coverImageId(album.id);
if (fx.registerImageBytes(image_id, @embedFile(art_path))) |_| {
model.covers[index] = image_id;
} else |_| {}
}
}
}
// --------------------------------------------------------------- commands
// Shortcut command ids: registered in app.zon (`.shortcuts`), delivered as
// command events, mapped to Msgs here. One spelling, two homes: app.zon
// and this table (the README documents the bindings).
pub const cmd_play_pause = "deck.play-pause"; // primary+P
pub const cmd_next = "deck.next"; // primary+arrowright
pub const cmd_prev = "deck.prev"; // primary+arrowleft
pub const cmd_playlist = "deck.playlist"; // primary+L
pub const cmd_dismiss = "deck.dismiss"; // escape
pub fn command(name: []const u8) ?Msg {
if (std.mem.eql(u8, name, cmd_play_pause)) return .toggle_play;
if (std.mem.eql(u8, name, cmd_next)) return .next_track;
if (std.mem.eql(u8, name, cmd_prev)) return .prev_track;
if (std.mem.eql(u8, name, cmd_playlist)) return .toggle_playlist;
if (std.mem.eql(u8, name, cmd_dismiss)) return .clear_search;
return null;
}
/// The media-app space convention: SPACE toggles the transport from
/// anywhere — both windows, focused or not. Bare space cannot be a
/// chrome shortcut (unmodified character keys and space are rejected by
/// `validateShortcut` so registration can never steal typing), so it
/// rides the app-level key FALLBACK instead: the framework's precedence
/// rule runs first, meaning a focused ledger row consumes space to play
/// THAT row, a focused transport button activates itself, and a focused
/// editable field keeps typing spaces — the text-entry exception is
/// structural (by widget kind), so the playlist's search field blocks
/// the toggle without this function naming it. `primary+P` (the chrome
/// shortcut above) stays the works-even-while-typing chord.
pub fn onKey(keyboard: canvas.WidgetKeyboardEvent) ?Msg {
if (keyboard.modifiers.hasNavigationModifier() or keyboard.modifiers.shift) return null;
if (std.ascii.eqlIgnoreCase(keyboard.key, "space")) return .toggle_play;
return null;
}
// -------------------------------------------------------------------- app
pub const DeckApp = native_sdk.UiApp(Model, Msg);
// ------------------------------------------------------------------ fonts
/// The deck's primary text face: Geist Pixel (Square), committed at
/// src/fonts/ with its OFL license and registered at boot through the
/// app-fonts seam — the theme's typography tokens point BOTH face slots
/// at this id (see theme.zig), so every span on the fascia prints in
/// the pixel face. The face is TrueType-glyf and well inside the
/// registry's per-font byte budget.
pub const app_fonts = [_]DeckApp.FontRegistration{.{
.id = theme.primary_font_id,
.name = "GeistPixel-Square.ttf",
.ttf = @embedFile("fonts/GeistPixel-Square.ttf"),
}};
pub fn deckOptions() DeckApp.Options {
return .{
.name = "deck",
.scene = shell_scene,
.canvas_label = canvas_label,
.update_fx = update,
.init_fx = boot,
.view = rootView,
.fonts = &app_fonts,
.tokens_fn = tokensFromModel,
// The sculpted hardware layer: enamel chassis and cap band,
// bevels, wells, screws, scanlines, the seven-segment readout,
// the band ladders, and the volume knob face — a fixed-count
// display-list pass drawn behind (prefix) and in front of
// (suffix) the widgets. See chrome.zig.
.chrome = .{
.prefix_commands = chrome.prefix_commands,
.suffix_commands = chrome.suffix_commands,
.build = chrome.build,
},
// The playlist rack: presence in the declared set IS visibility.
.windows_fn = deckWindows,
.window_view = deckWindowView,
.on_appearance = onAppearance,
.on_command = command,
.on_key = onKey,
.on_frame = onFrame,
.sync = sync,
};
}
/// The declared window set derives from the model: the playlist window
/// exists exactly while `playlist_open` is set. A Msg opens it, a Msg
/// closes it, and the user's titlebar close dispatches
/// `.playlist_closed` so the model agrees.
fn deckWindows(model: *const Model, scratch: *DeckApp.WindowsScratch) []const DeckApp.WindowDescriptor {
var count: usize = 0;
if (model.playlist_open) {
scratch.windows[count] = .{
.label = playlist_window_label,
.canvas_label = playlist_canvas_label,
.title = "Deck Playlist",
.width = view_mod.playlist_width,
.height = view_mod.playlist_height,
// A matching rack unit: fixed size and chromeless like the
// player — its cap strip is the drag region and carries the
// skin's own working close/minimize keys.
.resizable = false,
.titlebar = .chromeless,
.on_close = .playlist_closed,
};
count += 1;
}
return scratch.windows[0..count];
}
fn deckWindowView(ui: *DeckApp.Ui, model: *const Model, window_label: []const u8) DeckApp.Ui.Node {
std.debug.assert(std.mem.eql(u8, window_label, playlist_window_label));
return view_mod.playlistView(ui, model);
}
/// One finish, by the brief: hardware has exactly one enamel, so the OS
/// color scheme never reaches the theme. The appearance still matters
/// for high contrast (which abandons the skin for the framework
/// palette) and reduce motion.
pub fn tokensFromModel(model: *const Model) canvas.DesignTokens {
return theme.tokens(model.appearance.high_contrast, model.appearance.reduce_motion);
}
/// Appearance changes land in the model so `tokens_fn` re-derives; only
/// contrast and motion are consumed (see `tokensFromModel`).
fn onAppearance(appearance: native_sdk.Appearance) ?Msg {
return Msg{ .set_appearance = appearance };
}
/// The frame clock, WHILE PLAYING ONLY (the soundboard idiom): each
/// presented frame's timestamp advances the rendered playback clock
/// (`advanceRenderedClock`), whose changed spectrum/marquee/timecode
/// present the next frame — the dispatch loop sustains itself exactly
/// as long as audio moves. Pause, stop, buffering, or idle return null,
/// the display list stops changing, and the frame channel starves on
/// its own: zero frames while nothing moves (the idle law), with no
/// arming flag to forget to clear. Journaled like every Msg, so replay
/// is deterministic.
pub fn onFrame(model: *const Model, frame: native_sdk.platform.GpuFrame) ?Msg {
if (model.playing and model.now != null and !model.buffering) {
return Msg{ .frame_clock = .{ .timestamp_ns = frame.timestamp_ns, .interval_ns = frame.frame_interval_ns } };
}
return null;
}
/// The runtime owns transient slider state (`.change` carries no value);
/// mirror both faders into the model before each update so the `.seeked`
/// and `.volume_changed` arms read the positions the user dragged to.
/// Main canvas only — the playlist window has no sliders by design.
fn sync(model: *Model, layout: canvas.WidgetLayoutTree) void {
for (layout.nodes) |node| {
if (node.widget.kind != .slider) continue;
if (std.mem.eql(u8, node.widget.semantics.label, "Seek")) {
model.seek_fraction = node.widget.value;
} else if (std.mem.eql(u8, node.widget.semantics.label, "Volume")) {
model.volume_fraction = node.widget.value;
}
}
}
// ------------------------------------------------------------------- main
pub fn main(init: std.process.Init) !void {
// The app icon table installs before any view builds.
registerIcons();
// Streaming configuration, resolved once at launch (same story as
// the soundboard): NATIVE_SDK_MUSIC_URL_BASE overrides the
// manifest's committed base, and the platform caches directory
// hosts the track cache in its audio/ child — delete it to clear.
// Launch-time only, so replay's deterministic-init contract holds:
// no env read ever happens inside update.
var model: Model = .{};
if (init.environ_map.get("NATIVE_SDK_MUSIC_URL_BASE")) |base| model.setUrlBase(base);
var cache_dir_buffer: [model_mod.max_cache_dir]u8 = undefined;
const cache_dir = native_sdk.app_dirs.resolveOne(
.{ .name = "deck" },
native_sdk.app_dirs.currentPlatform(),
native_sdk.debug.envFromMap(init.environ_map),
.cache,
&cache_dir_buffer,
) catch "";
model.setCacheDir(cache_dir);
const app_state = try std.heap.page_allocator.create(DeckApp);
defer std.heap.page_allocator.destroy(app_state);
app_state.* = DeckApp.init(std.heap.page_allocator, model, deckOptions());
defer app_state.deinit();
try runner.runWithOptions(app_state.app(), .{
.app_name = "deck",
.window_title = "Native SDK Deck",
.bundle_id = "dev.native_sdk.deck",
.default_frame = geometry.RectF.init(0, 0, window_width, window_height),
.restore_state = false,
.js_window_api = false,
.security = .{
.permissions = &app_permissions,
.navigation = .{ .allowed_origins = &.{ "zero://inline", "zero://app" } },
},
}, init);
}
test {
_ = @import("tests.zig");
}
+997
View File
@@ -0,0 +1,997 @@
//! deck model: the same committed music catalog as `examples/soundboard`
//! (one manifest, two skins — the "same app, different identity" contrast
//! is the point) plus the playback, search, and playlist-window state
//! the deck binds to.
//!
//! Playback is REAL: pressing play issues `fx.playAudio` against the
//! shared on-disk library and every report — the load acknowledgment,
//! coarse position ticks while playing, the one completion at natural
//! end, failures — arrives as an `.audio_event` Msg through the
//! ordinary update path. The progress clock (`elapsed_ms`) advances on
//! the presented-frame channel WHILE PLAYING (the soundboard's
//! frame-clock idiom: `on_frame` emits a journaled `frame_clock` Msg
//! per presented frame, so the spectrum and the marquee move at display
//! rate) and the player's coarse position ticks CORRECT it; pause,
//! stop, buffering, and idle starve the frame channel, so everything
//! derived from the clock freezes deterministically — same Msg
//! sequence, same glass. Positions are the player's real clock; the
//! player's DURATION reports are estimates for this catalog and never
//! replace the manifest total (the duration rule, documented on
//! `handleAudio`).
//!
//! The committed manifest ships a hosted URL base, so a missing local
//! file STREAMS on demand — a fresh clone plays with zero setup. Only
//! with streaming explicitly disabled (NATIVE_SDK_MUSIC_URL_BASE set
//! empty) does a failed load light the honest degraded state: the
//! display marquee stamps `NO MEDIA` and the channel line names the
//! remedy (`tools/prepare-example-music.sh`). With a base configured a
//! failure stamps `STREAM LOST` instead — a network problem, a network
//! remedy. Browsing and search never need the audio files — the catalog
//! is committed.
//!
//! Everything the views show that is computable — the filtered ledger,
//! timecode labels — is derived per rebuild into the build arena, never
//! stored. The 32-band spectrum is REAL: hosts that can analyze their
//! own playback deliver `.spectrum` audio events (32 band magnitudes,
//! journaled at the effect boundary like every position tick), the
//! model keeps the latest report as per-band targets, and the frame
//! clock runs analyzer ballistics over them (instant attack, linear
//! decay — see `band_decay_per_second`), so the glass stays smooth
//! between the host's ~25 Hz reports and replay repaints identical
//! bars. Pause starves both inputs and the bars FREEZE; stop and track
//! changes reset the glass; a host that cannot analyze never sends
//! bands and the display rests on its noise-floor comb — honest
//! absence, never fake dancing.
//!
//! Fixed capacities (loud by design, documented in the README):
//! - the committed manifest's albums and tracks (comptime-derived
//! tables; per-album track counts VARY, nothing assumes a stride)
//! - 48-byte search buffer
//! - 32 spectrum bands
const std = @import("std");
const native_sdk = @import("native_sdk");
const canvas = native_sdk.canvas;
pub const Effects = native_sdk.Effects(Msg);
// ---------------------------------------------------------------- manifest
/// The manifest's own shapes, matching `music_manifest.zon` (generated by
/// `tools/prepare-example-music.sh`; the deck's copy is byte-identical to
/// the soundboard's). Track `file` paths are relative to the
/// SOUNDBOARD's assets directory — the mp3s live once on disk and both
/// examples play the same files.
pub const ManifestTrack = struct { title: []const u8, file: []const u8, duration_ms: u32, bytes: u64 };
pub const ManifestAlbum = struct { artist: []const u8, title: []const u8, year: u16, art: ?[]const u8, tracks: []const ManifestTrack };
pub const Catalog = struct { version: u32, url_base: ?[]const u8, albums: []const ManifestAlbum };
/// The committed catalog: browsable without any gitignored audio on disk.
pub const catalog: Catalog = @import("music_manifest.zon");
/// Where the shared audio lives relative to the deck's working
/// directory: the soundboard's gitignored assets tree, prepared by
/// `tools/prepare-example-music.sh`.
pub const audio_root = "../soundboard/assets/";
pub const album_count = catalog.albums.len;
pub const track_count = blk: {
var total: usize = 0;
for (catalog.albums) |album| total += album.tracks.len;
break :blk total;
};
// ------------------------------------------------------------------ library
pub const Album = struct {
/// 1-based id; the registered cover image id derives from it
/// (`main.coverImageId`).
id: u8,
title: []const u8,
artist: []const u8,
year: u16,
/// Committed cover path relative to `src/` (null when the album's
/// source art was absent at prepare time).
art: ?[]const u8,
/// This album's window into the flat `tracks` table. Counts VARY per
/// album — never assume a stride.
track_start: u16,
track_count: u8,
};
pub const Track = struct {
/// 1-based id, unique across the library.
id: u8,
album: u8,
/// 1-based position within the album.
number: u8,
title: []const u8,
/// Playable path (deck-relative into the soundboard's assets).
path: []const u8,
/// The manifest's relative file path, doubling as the track's URL
/// path under the streaming base.
file: []const u8,
/// The manifest's duration, MEASURED from the intact source file by
/// the prepare script — the total every surface displays and the
/// seek math uses. The platform player's self-reported duration
/// never replaces it: see `handleAudio` for why that number is an
/// estimate for this catalog.
duration_ms: u32,
/// The prepared file's exact byte size — the cache integrity gate
/// for streamed plays.
bytes: u64,
};
/// Flat album table derived from the manifest at comptime: ids are
/// 1-based positions, and each album records its slice of the flat
/// track table (variable counts, no stride arithmetic anywhere).
pub const albums: [album_count]Album = blk: {
var out: [album_count]Album = undefined;
var start: usize = 0;
for (catalog.albums, 0..) |album, index| {
out[index] = .{
.id = index + 1,
.title = album.title,
.artist = album.artist,
.year = album.year,
.art = album.art,
.track_start = start,
.track_count = album.tracks.len,
};
start += album.tracks.len;
}
break :blk out;
};
/// Flat track table derived from the manifest at comptime, in album
/// order; the playable path is the comptime concat of the shared assets
/// root and the manifest's soundboard-relative file.
pub const tracks: [track_count]Track = blk: {
var out: [track_count]Track = undefined;
var index: usize = 0;
for (catalog.albums, 0..) |album, album_index| {
for (album.tracks, 0..) |track, track_index| {
out[index] = .{
.id = index + 1,
.album = album_index + 1,
.number = track_index + 1,
.title = track.title,
.path = audio_root ++ track.file,
.file = track.file,
.duration_ms = track.duration_ms,
.bytes = track.bytes,
};
index += 1;
}
}
break :blk out;
};
pub fn albumById(id: u8) *const Album {
return &albums[id - 1];
}
pub fn trackById(id: u8) *const Track {
return &tracks[id - 1];
}
pub fn albumTracks(album_id: u8) []const Track {
const album = albumById(album_id);
const start: usize = album.track_start;
return tracks[start .. start + album.track_count];
}
// -------------------------------------------------------------- capacities
pub const max_search = 48;
pub const spectrum_bands = 32;
/// Marquee geometry: visible window in characters, and how much
/// playback time advances the scroll by one character. 16 characters of
/// the pixel face at the full-grid marquee scale fit the display bay's
/// full interior width even when every glyph is the face's widest
/// (0.76 em) — the face is proportional, so the budget is worst-case
/// honest, not average-case hopeful.
pub const marquee_window = 16;
pub const marquee_step_ms: u32 = 500;
/// The honest degraded state the display wears after a failed load
/// with NO URL base configured: the marquee stamp, and the channel-line
/// remedy naming the script that prepares the shared gitignored audio.
/// The committed manifest ships a hosted base, so this is reachable
/// only with streaming explicitly disabled (NATIVE_SDK_MUSIC_URL_BASE
/// set empty).
pub const no_media_marquee = "NO MEDIA";
pub const no_media_remedy = "RUN TOOLS/PREPARE-EXAMPLE-MUSIC.SH";
/// The honest degraded state after a failed STREAM (a URL base is
/// configured, so the local-file miss streamed — and the network let
/// the deck down: offline with a cold cache, a dead host, a mid-flight
/// drop). Distinct stamp, distinct remedy.
pub const stream_failed_marquee = "STREAM LOST";
pub const stream_failed_remedy = "CHECK THE CONNECTION AND RETRY";
/// The marquee stamp while a stream is stalled waiting for bytes — the
/// honest state between playing and paused that only URL sources have.
pub const buffering_marquee = "BUFFERING";
/// Streaming configuration bounds (manifest `.url_base`, overridable
/// with NATIVE_SDK_MUSIC_URL_BASE at launch; the cache directory from
/// app_dirs). Over-long values are refused whole, never truncated.
pub const max_url_base = 256;
pub const max_cache_dir = 512;
/// The manifest's committed streaming base ("" when null): the model
/// default the env override replaces at launch.
pub const manifest_url_base: []const u8 = catalog.url_base orelse "";
/// Effect keys, model-owned identity (effect-key style). The audio
/// channel's key is the playing track's id — its own namespace, so it
/// never collides with the spawn key below.
pub const copy_key: u64 = 2;
/// The two windows' declared labels — the addresses the window-action
/// effects (`fx.closeWindow`/`fx.minimizeWindow`) resolve. One spelling:
/// main.zig's shell scene and windows_fn use these same constants.
pub const main_window_label = "main";
pub const playlist_window_label = "playlist";
/// Which window a skin-native window key addresses (the chromeless
/// chassis draws its own close/minimize controls in each cap band).
pub const WindowRef = enum { player, playlist };
/// One presented frame's clock reading, from the guarded `on_frame`
/// hook (main.zig): the timestamp advances the rendered playback clock
/// and the display's frame interval bounds the advance when frames were
/// irregular (occlusion, a resume after pause), so a stale gap can
/// never lurch the readouts.
pub const FrameClock = struct {
timestamp_ns: u64,
interval_ns: u64,
};
/// How far the rendered clock may run AHEAD of a player position tick
/// before the disagreement stops being interpolation drift and gets
/// snapped. Drift per 500 ms tick is a few milliseconds; anything past a
/// full tick means reality changed (an external seek, a stall the
/// buffering flag has not reported yet), and honesty beats smoothness.
const position_snap_slack_ms: u32 = 600;
/// The spectrum envelope: classic analyzer ballistics over the host's
/// band reports. A NEW report attacks instantly (a rising band jumps to
/// its target the moment the event lands — transients read as hits, not
/// fades), and every presented frame decays each displayed band toward
/// its target at this linear rate, so a falling band takes ~1/3 s from
/// full scale to floor — smooth at display rate between the host's
/// ~25 Hz reports. Both inputs (the `.spectrum` events and the
/// `frame_clock` Msgs) are journaled, so the envelope replays exactly.
pub const band_decay_per_second: f32 = 3.0;
// ------------------------------------------------------------------- model
pub const Msg = union(enum) {
/// The PL key (and `primary+L`): the playlist window's open flag
/// flips, and the windows_fn reconcile creates or closes the window.
toggle_playlist,
/// The USER closed the playlist window (through the skin's own
/// close key or any OS route that survives chromeless): the window
/// is already gone as an optimistic echo; the model agrees here.
playlist_closed,
/// A skin-native CLOSE key (the chromeless chassis draws its own
/// window controls): the player's key closes the app's window for
/// real through the window-action effect; the playlist's closes
/// declaratively (presence in the declared set IS visibility).
close_window: WindowRef,
/// A skin-native MINIMIZE key: the real OS verb through the
/// window-action effect — the window genies into the Dock.
minimize_window: WindowRef,
/// One presented frame while playing (guarded in main.onFrame): the
/// rendered clock advances at display rate between position ticks.
frame_clock: FrameClock,
set_appearance: native_sdk.Appearance,
search_edit: canvas.TextInputEvent,
clear_search,
play_track: u8,
toggle_play,
/// The dedicated PLAY key: starts from idle, resumes from pause,
/// and is a no-op while already playing — a latching hardware key,
/// not a toggle (`toggle_play` stays the space-bar/shortcut verb).
transport_play,
/// The dedicated PAUSE key: pauses only. Resume is PLAY's job.
transport_pause,
/// The STOP key: playback halts and the head returns to 0:00, but
/// the track stays loaded — the classic stop-vs-pause distinction.
stop,
next_track,
prev_track,
/// Seek slider changed; the reconciled value arrives through the
/// `sync` hook (`seek_fraction`) before this message is applied.
seeked,
/// Output volume slider changed; same sync-hook contract
/// (`volume_fraction`). Rides through to the platform player.
volume_changed,
/// Every playback report from the audio effect channel: loaded,
/// position ticks, the one completion, failures.
audio_event: native_sdk.EffectAudio,
/// Context menu: copy the track title to the clipboard via `pbcopy`
/// (soundboard's effect, unchanged: the effects channel has no
/// clipboard call today).
copy_title: u8,
copied: native_sdk.EffectExit,
};
pub const Model = struct {
// Source-of-truth state only; everything else is derived per rebuild.
/// The playlist window's visibility channel: `windows_fn` declares
/// the window exactly while this is set (presence IS visibility).
playlist_open: bool = false,
appearance: native_sdk.Appearance = .{},
now: ?u8 = null, // loaded track id
playing: bool = false,
/// The RENDERED playback clock — the one input every live display
/// derives from (marquee, spectrum, timecode, segment digits).
/// While playing it advances every presented frame (`frame_clock`,
/// bounded per-frame steps) and the player's coarse position ticks
/// correct it: forward corrections apply, small backward ones hold
/// flat (the readouts never visibly rewind mid-play), and only a
/// past-slack disagreement snaps. Paused, stopped, buffering, and
/// seeking snap exactly — interpolation exists only where there is
/// motion.
elapsed_ms: u32 = 0,
/// The last `frame_clock` timestamp, the delta base for the
/// rendered clock. Never reset: a stale gap (pause, occlusion,
/// boot) is bounded by the frame-interval clamp in
/// `advanceRenderedClock`.
frame_ns: u64 = 0,
/// The loaded track's TOTAL for the timecode and seek math: the
/// manifest duration, the same number the ledger renders — the two
/// surfaces must agree. The platform's self-reported duration never
/// replaces a nonzero manifest value (it is an estimate for this
/// catalog — see `handleAudio`); only a track the manifest carries
/// no duration for adopts the platform's report.
now_duration_ms: u32 = 0,
/// The platform player's own duration report, mirrored verbatim
/// from the audio events: observable state for tests and debugging,
/// and the fallback total above — never the displayed number while
/// the manifest has one.
platform_duration_ms: u32 = 0,
/// A load failed (missing mp3s, no URL base, or a platform without
/// audio): the display wears the NO MEDIA remedy until the next
/// successful load attempt.
media_failed: bool = false,
/// A STREAM failed (URL base configured, network gone): the display
/// wears the STREAM LOST remedy instead — different problem,
/// different fix.
stream_failed: bool = false,
/// The stream is stalled waiting for network bytes, mirrored from
/// the audio events' buffering flag; the marquee stamps BUFFERING
/// while it holds. Local files never set it.
buffering: bool = false,
/// The streaming URL base (manifest default, env override at
/// launch; empty = local-only) and the platform cache directory
/// resolved in main — same conventions as the soundboard, one
/// on-disk cache since both examples share the audio.
url_base_buffer: [max_url_base]u8 = blk: {
var buffer: [max_url_base]u8 = @splat(0);
@memcpy(buffer[0..manifest_url_base.len], manifest_url_base);
break :blk buffer;
},
url_base_len: usize = manifest_url_base.len,
cache_dir_buffer: [max_cache_dir]u8 = @splat(0),
cache_dir_len: usize = 0,
search_buffer: canvas.TextBuffer(max_search) = .{},
/// Seek slider value, mirrored from the runtime through `sync`.
seek_fraction: f32 = 0,
/// Output level, mirrored from the runtime through `sync` and
/// applied to the platform player via `fx.setAudioVolume`.
volume_fraction: f32 = 0.8,
/// Copy-to-clipboard bookkeeping: how many pbcopy spawns finished ok.
copies_done: u32 = 0,
copy_failed: bool = false,
/// Registered album cover ImageIds, index = album id - 1; 0 while
/// unregistered (the strict test decoder has no JPEG codec, so under
/// the null platform every slot stays 0 and the sleeve pane degrades
/// to its engraved plate — asserted in the suite).
covers: [album_count]canvas.ImageId = @splat(0),
/// The spectrum glass, driven by REAL analysis: `band_targets` is
/// the latest `.spectrum` report (each byte / 255), `band_levels`
/// is what the chart draws — attacked instantly on report arrival,
/// decayed toward the targets by the frame clock (see
/// `band_decay_per_second`). Both journaled inputs, so replay
/// repaints identical bars.
band_targets: [spectrum_bands]f32 = @splat(0),
band_levels: [spectrum_bands]f32 = @splat(0),
/// Whether the CURRENT playback has delivered any `.spectrum`
/// report. Until it does — idle deck, a host that cannot analyze,
/// the instant before the first report — the glass shows the
/// resting comb: honest absence, never fake dancing.
spectrum_live: bool = false,
// ------------------------------------------------------------- queries
pub fn search(model: *const Model) []const u8 {
return model.search_buffer.text();
}
pub fn searching(model: *const Model) bool {
return model.search().len > 0;
}
pub fn playlistShowing(model: *const Model) bool {
return model.playlist_open;
}
pub fn nowTrack(model: *const Model) ?*const Track {
const id = model.now orelse return null;
return trackById(id);
}
pub fn idle(model: *const Model) bool {
return model.now == null;
}
/// Any degraded playback state, for the display's warning tint (the
/// stamps themselves name which problem it is).
pub fn mediaFailed(model: *const Model) bool {
return model.media_failed or model.stream_failed;
}
/// The channel-line remedy matching the marquee's failure stamp.
pub fn remedyText(model: *const Model) []const u8 {
if (model.stream_failed) return stream_failed_remedy;
return no_media_remedy;
}
pub fn urlBase(model: *const Model) []const u8 {
return model.url_base_buffer[0..model.url_base_len];
}
pub fn cacheDir(model: *const Model) []const u8 {
return model.cache_dir_buffer[0..model.cache_dir_len];
}
pub fn streamingConfigured(model: *const Model) bool {
return model.url_base_len > 0;
}
/// Install the streaming base (launch-time). Trailing slash
/// trimmed; over-long values refused whole, never truncated.
pub fn setUrlBase(model: *Model, base: []const u8) void {
const trimmed = std.mem.trimEnd(u8, base, "/");
if (trimmed.len > max_url_base) return;
@memcpy(model.url_base_buffer[0..trimmed.len], trimmed);
model.url_base_len = trimmed.len;
}
/// Install the platform cache directory (launch-time). Over-long
/// values leave the cache disabled — streams still play.
pub fn setCacheDir(model: *Model, dir: []const u8) void {
if (dir.len > max_cache_dir) return;
@memcpy(model.cache_dir_buffer[0..dir.len], dir);
model.cache_dir_len = dir.len;
}
pub fn coverFor(model: *const Model, album_id: u8) canvas.ImageId {
return model.covers[album_id - 1];
}
/// The loaded track's registered cover, 0 when idle or unregistered
/// (the sleeve pane degrades to its engraved plate).
pub fn nowCover(model: *const Model) canvas.ImageId {
const track = model.nowTrack() orelse return 0;
return model.coverFor(track.album);
}
/// Status-strip counter: visible tracks over the library total.
pub fn statusLabel(model: *const Model, arena: std.mem.Allocator) []const u8 {
return std.fmt.allocPrint(arena, "{d}/{d} TRK", .{ model.visibleTracks(arena).len, tracks.len }) catch "";
}
/// Display channel readout: "TRK 07". Short by design — the album is
/// already in the marquee, and the timecode rides the same line
/// (round 1 caught the long form wrapping over the progress strip).
pub fn channelLabel(model: *const Model, arena: std.mem.Allocator) []const u8 {
const track = model.nowTrack() orelse return "TRK --";
return std.fmt.allocPrint(arena, "TRK {d:0>2}", .{track.id}) catch "";
}
/// The source readout under the channel line: average bitrate and
/// file size, BOTH computed from the committed manifest's real bytes
/// and duration — the deck states what it can actually know about
/// the stream and nothing more (no invented sample rates, no codec
/// badges). Idle prints the powered-on dashes.
pub fn sourceLabel(model: *const Model, arena: std.mem.Allocator) []const u8 {
const track = model.nowTrack() orelse return "--- KBPS --.- MB";
const duration = model.now_duration_ms;
if (duration == 0 or track.bytes == 0) return "--- KBPS --.- MB";
const kbps = (track.bytes * 8) / duration; // bytes*8 bits / ms = kbit/s
const mb_tenths = (track.bytes * 10) / (1024 * 1024);
return std.fmt.allocPrint(arena, "{d} KBPS {d}.{d} MB", .{
kbps, mb_tenths / 10, mb_tenths % 10,
}) catch "";
}
/// The display marquee: title, artist, and album on one rotating line —
/// the classic scroller, derived (never stored) as a pure function
/// of the loaded track and the progress clock. The composed line
/// rotates one step every `marquee_step_ms` of PLAYBACK time, so
/// pause freezes it exactly like the spectrum (the position events
/// stop, the clock stops) and the same model state always yields the
/// same window of text. After a failed load the marquee stamps the
/// NO MEDIA state instead.
pub fn marqueeText(model: *const Model, arena: std.mem.Allocator) []const u8 {
if (model.stream_failed) return stream_failed_marquee;
if (model.media_failed) return no_media_marquee;
if (model.buffering) return buffering_marquee;
const track = model.nowTrack() orelse return "NO SIGNAL";
const album = albumById(track.album);
// ASCII-only composition: the rotation slices bytes, so a
// multi-byte separator could split mid-codepoint at the seam.
// 192 bytes cover the catalog's longest title/artist/album line.
var compose_buffer: [192]u8 = undefined;
var upper_buffer: [192]u8 = undefined;
const line = std.fmt.bufPrint(&compose_buffer, "{s} /// {s} /// {s} ", .{
track.title, album.artist, album.title,
}) catch return track.title;
const full = upperTo(&upper_buffer, line);
if (full.len <= marquee_window) return arena.dupe(u8, std.mem.trimEnd(u8, full, " /")) catch "";
const offset = (model.elapsed_ms / marquee_step_ms) % full.len;
const out = arena.alloc(u8, marquee_window) catch return "";
for (out, 0..) |*byte, index| {
byte.* = full[(offset + index) % full.len];
}
return out;
}
pub fn progressFraction(model: *const Model) f32 {
if (model.now == null) return 0;
if (model.now_duration_ms == 0) return 0;
const fraction = @as(f32, @floatFromInt(model.elapsed_ms)) / @as(f32, @floatFromInt(model.now_duration_ms));
return std.math.clamp(fraction, 0, 1);
}
pub fn elapsedLabel(model: *const Model, arena: std.mem.Allocator) []const u8 {
if (model.now == null) return "--:--";
return formatMs(arena, model.elapsed_ms);
}
pub fn durationLabel(model: *const Model, arena: std.mem.Allocator) []const u8 {
if (model.now == null) return "--:--";
return formatMs(arena, model.now_duration_ms);
}
/// The resting comb: the powered-on noise floor the glass wears
/// whenever no real analysis is on it — idle deck, a host without
/// `audio_spectrum`, the instant before the first report. A fixed
/// pattern, so the display reads as live hardware rather than a
/// dead widget, and honestly STILL: bands that are not measured do
/// not dance.
pub fn restingLevel(band: usize) f32 {
return if (band % 4 == 0) 0.05 else 0.02;
}
/// One spectrum band's displayed level in [0, 1]: the envelope over
/// the journaled `.spectrum` reports while real analysis is live,
/// the resting comb otherwise. Model state only — pause starves the
/// inputs and every reader freezes at once; replaying the journal
/// paints the same glass.
pub fn bandLevel(model: *const Model, band: usize) f32 {
if (model.now == null or !model.spectrum_live) return restingLevel(band);
return std.math.clamp(model.band_levels[band], 0, 1);
}
/// The 32 spectrum band levels, derived into the build arena per
/// rebuild (the chart widget's one bar series binds this).
pub fn spectrumLevels(model: *const Model, arena: std.mem.Allocator) []const f32 {
const out = arena.alloc(f32, spectrum_bands) catch return &.{};
for (out, 0..) |*level, band| level.* = model.bandLevel(band);
return out;
}
/// Ledger rows: the whole library as ONE flat list, narrowed by the
/// search query, derived into the build arena.
pub fn visibleTracks(model: *const Model, arena: std.mem.Allocator) []const TrackRow {
const out = arena.alloc(TrackRow, tracks.len) catch return &.{};
var count: usize = 0;
for (&tracks) |*track| {
if (!model.trackMatches(track)) continue;
out[count] = model.trackRow(arena, track);
// The ledger rules BETWEEN rows only: the first visible row
// carries no hairline above it (and the last none below).
out[count].first = count == 0;
count += 1;
}
return out[0..count];
}
fn trackMatches(model: *const Model, track: *const Track) bool {
const query = model.search();
if (query.len == 0) return true;
const album = albumById(track.album);
return containsIgnoreCase(track.title, query) or
containsIgnoreCase(album.artist, query) or
containsIgnoreCase(album.title, query);
}
fn trackRow(model: *const Model, arena: std.mem.Allocator, track: *const Track) TrackRow {
const album = albumById(track.album);
return .{
.id = track.id,
.number = std.fmt.allocPrint(arena, "{d:0>2}", .{track.id}) catch "",
.title = track.title,
.artist = album.artist,
.duration = formatMs(arena, track.duration_ms),
.now = model.now == track.id,
.playing = model.now == track.id and model.playing,
};
}
};
pub const TrackRow = struct {
id: u8,
number: []const u8,
title: []const u8,
artist: []const u8,
duration: []const u8,
/// This track is loaded in the deck (playing or paused).
now: bool,
playing: bool,
/// The ledger's first VISIBLE row: no hairline divider above it
/// (rules run between rows only).
first: bool = false,
};
fn formatMs(arena: std.mem.Allocator, ms: u32) []const u8 {
const total_seconds = ms / 1000;
return std.fmt.allocPrint(arena, "{d}:{d:0>2}", .{ total_seconds / 60, total_seconds % 60 }) catch "";
}
fn containsIgnoreCase(haystack: []const u8, needle: []const u8) bool {
if (needle.len == 0) return true;
if (needle.len > haystack.len) return false;
var index: usize = 0;
while (index + needle.len <= haystack.len) : (index += 1) {
if (std.ascii.eqlIgnoreCase(haystack[index .. index + needle.len], needle)) return true;
}
return false;
}
/// ASCII-uppercase into a fixed buffer (library titles are ASCII); the
/// caller's arena copy happens in the formatting call.
fn upperTo(buffer: []u8, source: []const u8) []const u8 {
const len = @min(buffer.len, source.len);
for (source[0..len], 0..) |byte, index| {
buffer[index] = std.ascii.toUpper(byte);
}
return buffer[0..len];
}
// ------------------------------------------------------------------ update
pub fn update(model: *Model, msg: Msg, fx: *Effects) void {
switch (msg) {
.toggle_playlist => model.playlist_open = !model.playlist_open,
.playlist_closed => model.playlist_open = false,
.close_window => |ref| switch (ref) {
// The player IS the app: its close key performs the REAL
// window close through the window-action effect, and the
// app exits by the host's existing last-window semantics.
.player => fx.closeWindow(main_window_label),
// The playlist rack closes DECLARATIVELY: presence in the
// declared window set is visibility, so clearing the flag
// IS the close (the reconcile tears the window down) — the
// same machinery the PL key rides.
.playlist => model.playlist_open = false,
},
// Minimize is the real OS verb on either unit — nothing
// declarative models "in the Dock", so both ride the effect.
.minimize_window => |ref| fx.minimizeWindow(switch (ref) {
.player => main_window_label,
.playlist => playlist_window_label,
}),
.frame_clock => |frame| advanceRenderedClock(model, frame),
.set_appearance => |appearance| model.appearance = appearance,
.search_edit => |edit| model.search_buffer.apply(edit),
.clear_search => model.search_buffer.apply(.clear),
.play_track => |id| {
if (model.now == id) {
setPlaying(model, fx, !model.playing);
} else {
startTrack(model, fx, id);
}
},
.toggle_play => {
if (model.now == null) {
startTrack(model, fx, tracks[0].id);
} else {
setPlaying(model, fx, !model.playing);
}
},
.transport_play => {
if (model.now == null) {
startTrack(model, fx, tracks[0].id);
} else if (!model.playing) {
setPlaying(model, fx, true);
}
},
.transport_pause => {
if (model.now != null and model.playing) setPlaying(model, fx, false);
},
.stop => {
if (model.now != null) {
// Halt AND rewind, keeping the record loaded: pause the
// platform player, seek it home, and zero the progress
// clock so everything derived from it (marquee,
// timecode) returns to the top deterministically. The
// spectrum glass RESTS (stop clears the analyzer, the
// classic stop-vs-pause distinction: pause freezes the
// bars, stop puts the comb back).
model.playing = false;
model.elapsed_ms = 0;
resetSpectrum(model);
fx.pauseAudio();
fx.seekAudio(0);
}
},
.next_track => advance(model, fx),
.prev_track => previous(model, fx),
.seeked => {
if (model.now != null) {
const duration: f32 = @floatFromInt(model.now_duration_ms);
const target: u32 = @intFromFloat(std.math.clamp(model.seek_fraction, 0, 1) * duration);
// Optimistic clock move; the platform's next position
// tick reports from the sought point and re-confirms it.
model.elapsed_ms = target;
fx.seekAudio(target);
}
},
.volume_changed => {
model.volume_fraction = std.math.clamp(model.volume_fraction, 0, 1);
fx.setAudioVolume(model.volume_fraction);
},
.audio_event => |event| handleAudio(model, fx, event),
.copy_title => |id| fx.spawn(.{
.key = copy_key,
.argv = &.{"/usr/bin/pbcopy"},
.stdin = trackById(id).title,
.on_exit = Effects.exitMsg(.copied),
}),
.copied => |exit| {
if (exit.reason == .exited and exit.code == 0) {
model.copies_done += 1;
model.copy_failed = false;
} else {
model.copy_failed = true;
}
},
}
}
/// One playback report from the audio channel. Events carry the key the
/// channel resolved them against; a straggler for a replaced track (the
/// deck already started something else) is dropped rather than applied
/// to the wrong record.
///
/// THE DURATION RULE (shared with the soundboard — one catalog, one
/// rule). Events carry the platform player's own duration readout, and
/// for this catalog that number is an ESTIMATE, never the truth: the
/// prepare script strips the seek header from every prepared file
/// (nothing of the source encoder may survive the strip), so a decoder
/// can only extrapolate the length from the bitrate it has seen — a
/// progressive stream's early guess has measured minutes wrong, and
/// even a fully local file reads seconds long and wobbles between
/// ticks. The manifest duration was measured from the intact SOURCE
/// file and matches a full decode exactly, so `now_duration_ms` (the
/// timecode total, the progress denominator, the seek target's scale)
/// keeps the manifest value and the platform's report only lands in
/// `platform_duration_ms` — adopted as the total solely when the
/// manifest carries no duration at all. Positions are different:
/// position ticks are the player's REAL clock and stay authoritative.
fn handleAudio(model: *Model, fx: *Effects, event: native_sdk.EffectAudio) void {
const id = model.now orelse return;
if (event.key != @as(u64, id)) return;
switch (event.kind) {
.loaded => {
adoptPlatformDuration(model, event.duration_ms);
model.buffering = event.buffering;
},
.position => {
const position = clampMs(event.position_ms);
adoptPlatformDuration(model, event.duration_ms);
// The stream's honest stall flag rides every tick.
model.buffering = event.buffering;
// The coarse tick CORRECTS the frame-advanced rendered
// clock. In motion, forward corrections apply and small
// backward ones hold flat — the readouts must never visibly
// rewind because our frames ran a few ms ahead of the
// player — while a past-slack disagreement is a real desync
// (an external seek, an unreported stall) and snaps. With
// no motion (paused, buffering) the tick is simply the
// truth.
if (model.playing and !model.buffering) {
if (position > model.elapsed_ms or model.elapsed_ms - position > position_snap_slack_ms) {
model.elapsed_ms = position;
}
} else {
model.elapsed_ms = position;
}
},
// Real analysis of the playing audio: the report becomes the
// per-band targets, and a RISING band attacks instantly (the
// frame clock only ever decays — see `advanceRenderedClock`).
// The first report flips the glass from the resting comb to
// live bars; pause simply stops the reports, so the last-drawn
// bars hold — freeze-on-pause with real data.
.spectrum => {
model.spectrum_live = true;
for (event.bands, 0..) |band, index| {
const target = @as(f32, @floatFromInt(band)) / 255.0;
model.band_targets[index] = target;
if (target > model.band_levels[index]) model.band_levels[index] = target;
}
},
// Natural end: the next ledger row plays (see `advance`).
.completed => advance(model, fx),
// Playback could not run (`failed`), or the effects layer
// refused the request (`rejected` — impossible for these
// comptime paths, honest anyway). With a URL base configured
// the local-file miss would have streamed, so a failure means
// the NETWORK let the deck down — STREAM LOST; without one the
// shared assets are not prepared — NO MEDIA. Never a crash,
// never silence.
.failed, .rejected => {
model.now = null;
model.playing = false;
model.buffering = false;
model.elapsed_ms = 0;
model.now_duration_ms = 0;
model.platform_duration_ms = 0;
resetSpectrum(model);
if (model.streamingConfigured()) {
model.stream_failed = true;
} else {
model.media_failed = true;
}
},
}
}
fn clampMs(ms: u64) u32 {
return @intCast(@min(ms, std.math.maxInt(u32)));
}
/// Mirror the platform's duration report, and adopt it as the displayed
/// total ONLY when the manifest gave none (see the duration rule on
/// `handleAudio`).
fn adoptPlatformDuration(model: *Model, reported_ms: u64) void {
if (reported_ms == 0) return;
const clamped = clampMs(reported_ms);
model.platform_duration_ms = clamped;
if (model.now_duration_ms == 0) model.now_duration_ms = clamped;
}
/// One presented frame while playing: advance the rendered clock by the
/// real frame delta, clamped to a few display intervals so a stale gap
/// (resume after pause, an occluded window, boot) steps gently instead
/// of lurching, and capped at the duration. Motion-gated twice — the
/// `on_frame` hook only emits the Msg while playing, and this re-checks
/// so a replayed journal straddling a pause boundary stays exact.
fn advanceRenderedClock(model: *Model, frame: FrameClock) void {
const last = model.frame_ns;
model.frame_ns = frame.timestamp_ns;
if (!model.playing or model.now == null or model.buffering) return;
const interval: u64 = if (frame.interval_ns > 0) frame.interval_ns else std.time.ns_per_s / 60;
const delta_ns: u64 = if (last == 0 or frame.timestamp_ns <= last)
interval
else
@min(frame.timestamp_ns - last, interval * 4);
const advanced = @as(u64, model.elapsed_ms) + delta_ns / std.time.ns_per_ms;
const limit: u64 = if (model.now_duration_ms > 0) model.now_duration_ms else advanced;
model.elapsed_ms = @intCast(@min(advanced, limit));
// Spectrum ballistics, the decay half (attack lives in the
// `.spectrum` arm of `handleAudio`): each displayed band falls
// linearly toward its latest reported target, on the same bounded
// frame delta the clock advanced by — deterministic per journal,
// frozen with the clock when nothing moves.
if (model.spectrum_live) {
const fall = band_decay_per_second *
(@as(f32, @floatFromInt(delta_ns)) / @as(f32, std.time.ns_per_s));
for (&model.band_levels, model.band_targets) |*level, target| {
if (level.* > target) level.* = @max(target, level.* - fall);
}
}
}
/// Put the spectrum glass back on its resting comb: a new playback, a
/// stop, or a failure means the last-drawn bars no longer describe any
/// audio; the next `.spectrum` report flips it live again.
fn resetSpectrum(model: *Model) void {
model.spectrum_live = false;
model.band_targets = @splat(0);
model.band_levels = @splat(0);
}
fn startTrack(model: *Model, fx: *Effects, track_id: u8) void {
const track = trackById(track_id);
model.now = track_id;
model.elapsed_ms = 0;
resetSpectrum(model);
// The manifest's measured duration is the total for the whole
// playback — the same number the ledger renders, so the two
// surfaces agree by construction (the duration rule on
// `handleAudio`). The platform's estimate mirror restarts too.
model.now_duration_ms = track.duration_ms;
model.platform_duration_ms = 0;
// A fresh load attempt is the retry path out of the failure states.
model.media_failed = false;
model.stream_failed = false;
model.buffering = false;
model.playing = true;
// The source cascade rides ONE playAudio call: the shared local
// file first, then (URL base configured) the track's URL — verified
// cache entry or progressive stream that fills the cache, keyed by
// the URL's hash. `expected_bytes` is the manifest's prepared size,
// the gate that keeps partial cache entries from ever playing.
var url_buffer: [max_url_base + 512]u8 = undefined;
var url: []const u8 = "";
if (model.streamingConfigured()) {
url = std.fmt.bufPrint(&url_buffer, "{s}/{s}", .{ model.urlBase(), track.file }) catch "";
}
var cache_buffer: [max_cache_dir + 128]u8 = undefined;
var cache_path: []const u8 = "";
if (url.len > 0 and model.cacheDir().len > 0) {
cache_path = native_sdk.audioCachePath(&cache_buffer, model.cacheDir(), url) catch "";
}
// One player is the whole surface: a new playAudio replaces whatever
// played before, and the track id is the channel key echoed in every
// event for this playback.
fx.playAudio(.{
.key = track_id,
.path = track.path,
.url = url,
.cache_path = cache_path,
.expected_bytes = track.bytes,
.on_event = Effects.audioMsg(.audio_event),
});
// Keep the platform player honest with the VOL fader from the very
// first load (the channel default is full volume).
fx.setAudioVolume(std.math.clamp(model.volume_fraction, 0, 1));
}
/// Play/pause ride the audio channel's transport: pause holds the
/// player in place (position events stop, so the clock and everything
/// derived from it freeze), resume continues from the same point.
fn setPlaying(model: *Model, fx: *Effects, playing: bool) void {
model.playing = playing;
if (playing) {
fx.resumeAudio();
} else {
fx.pauseAudio();
}
}
/// Natural end and the NEXT key both step the LEDGER: the catalog is
/// ONE flat song list on this deck (the playlist window shows exactly
/// that order), so advance plays the next row in the flat track table —
/// crossing album boundaries without a seam — and wraps from the last
/// row back to the first. Track ids ARE the flat 1-based positions, so
/// the wrap is id arithmetic on the full table.
fn advance(model: *Model, fx: *Effects) void {
const track = model.nowTrack() orelse return;
const next_index = @as(usize, track.id) % tracks.len;
startTrack(model, fx, tracks[next_index].id);
}
/// Restart the current track when it is a few seconds in; otherwise the
/// previous ledger row, wrapping backwards from the first to the last —
/// the mirror of `advance`, on the same flat order.
fn previous(model: *Model, fx: *Effects) void {
const track = model.nowTrack() orelse return;
if (model.elapsed_ms > 3000) {
model.elapsed_ms = 0;
fx.seekAudio(0);
return;
}
const prev_index = (@as(usize, track.id) + tracks.len - 2) % tracks.len;
startTrack(model, fx, tracks[prev_index].id);
}
+149
View File
@@ -0,0 +1,149 @@
// Music catalog for the soundboard and deck examples. Generated by
// tools/prepare-example-music.sh — edit the script, not this file.
// Track files live in the gitignored assets/music/ directory next to
// this manifest; the catalog itself is committed so the examples can
// browse it even before the audio has been prepared locally.
// .art names the committed 512px cover beside this manifest (null when
// the album's source art was absent at prepare time; the examples fall
// back to initials / pure vector in that case, never a broken image).
// .url_base (overridable at runtime with NATIVE_SDK_MUSIC_URL_BASE)
// lets tracks stream on demand when the local files are absent:
// <url_base>/<track .file>, verified against the track's .bytes.
.{
.version = 1,
.url_base = "https://xksenynjs1imkkii.public.blob.vercel-storage.com",
.albums = .{
.{
.artist = "Harbor Sleep",
.title = "Exit Signs",
.year = 2025,
.art = "art/exit-signs.jpg",
.tracks = .{
.{ .title = "Mile Marker West", .file = "music/exit-signs/mile-marker-west.mp3", .duration_ms = 164832, .bytes = 3982923 },
.{ .title = "Harvest Lot", .file = "music/exit-signs/harvest-lot.mp3", .duration_ms = 46944, .bytes = 1136278 },
.{ .title = "Winter Birds", .file = "music/exit-signs/winter-birds.mp3", .duration_ms = 70824, .bytes = 1661879 },
.{ .title = "Tidepool Windows", .file = "music/exit-signs/tidepool-windows.mp3", .duration_ms = 86952, .bytes = 1958331 },
.{ .title = "Untitled", .file = "music/exit-signs/untitled.mp3", .duration_ms = 195000, .bytes = 4382227 },
.{ .title = "Cedar Ave", .file = "music/exit-signs/cedar-ave.mp3", .duration_ms = 89160, .bytes = 1911716 },
.{ .title = "Sunday Call", .file = "music/exit-signs/sunday-call.mp3", .duration_ms = 131760, .bytes = 2953006 },
.{ .title = "Verse Starts Raw", .file = "music/exit-signs/verse-starts-raw.mp3", .duration_ms = 116712, .bytes = 2556363 },
},
},
.{
.artist = "Harbor Sleep",
.title = "Blue Season",
.year = 2025,
.art = "art/blue-season.jpg",
.tracks = .{
.{ .title = "Summer Rental", .file = "music/blue-season/summer-rental.mp3", .duration_ms = 98544, .bytes = 2174137 },
.{ .title = "Cut The Harbor", .file = "music/blue-season/cut-the-harbor.mp3", .duration_ms = 58992, .bytes = 1362458 },
.{ .title = "Greenhouse Hands", .file = "music/blue-season/greenhouse-hands.mp3", .duration_ms = 103272, .bytes = 2331628 },
.{ .title = "Open Windows", .file = "music/blue-season/open-windows.mp3", .duration_ms = 99432, .bytes = 2340120 },
.{ .title = "Morning Ferry", .file = "music/blue-season/morning-ferry.mp3", .duration_ms = 75912, .bytes = 1821721 },
.{ .title = "Lucky Number", .file = "music/blue-season/lucky-number.mp3", .duration_ms = 79224, .bytes = 1810104 },
.{ .title = "Salt on the Dock", .file = "music/blue-season/salt-on-the-dock.mp3", .duration_ms = 83352, .bytes = 1885660 },
},
},
.{
.artist = "Casino Hearts",
.title = "Second Nature",
.year = 2026,
.art = "art/second-nature.jpg",
.tracks = .{
.{ .title = "Velvet Jackpot", .file = "music/second-nature/velvet-jackpot.mp3", .duration_ms = 130872, .bytes = 3112901 },
.{ .title = "Second Nature", .file = "music/second-nature/second-nature.mp3", .duration_ms = 79920, .bytes = 1986436 },
.{ .title = "Passenger Seat", .file = "music/second-nature/passenger-seat.mp3", .duration_ms = 87840, .bytes = 2084573 },
.{ .title = "Casino Glow", .file = "music/second-nature/casino-glow.mp3", .duration_ms = 80040, .bytes = 1941026 },
.{ .title = "Better Luck", .file = "music/second-nature/better-luck.mp3", .duration_ms = 66480, .bytes = 1546394 },
.{ .title = "Slow Motion", .file = "music/second-nature/slow-motion.mp3", .duration_ms = 63984, .bytes = 1513418 },
.{ .title = "Out of Reach", .file = "music/second-nature/out-of-reach.mp3", .duration_ms = 67200, .bytes = 1561803 },
.{ .title = "New York 2AM", .file = "music/second-nature/new-york-2am.mp3", .duration_ms = 104424, .bytes = 2518971 },
.{ .title = "Casino Hearts", .file = "music/second-nature/casino-hearts.mp3", .duration_ms = 68640, .bytes = 1618756 },
},
},
.{
.artist = "Worn Thin",
.title = "No Good Way Out",
.year = 2025,
.art = "art/no-good-way-out.jpg",
.tracks = .{
.{ .title = "Nothing Left", .file = "music/no-good-way-out/nothing-left.mp3", .duration_ms = 68544, .bytes = 1638313 },
.{ .title = "Cheap Seats", .file = "music/no-good-way-out/cheap-seats.mp3", .duration_ms = 94944, .bytes = 2355960 },
.{ .title = "No Good Way Out", .file = "music/no-good-way-out/no-good-way-out.mp3", .duration_ms = 23424, .bytes = 540820 },
.{ .title = "Worn Thin", .file = "music/no-good-way-out/worn-thin.mp3", .duration_ms = 57432, .bytes = 1299862 },
.{ .title = "White Flag Burn", .file = "music/no-good-way-out/white-flag-burn.mp3", .duration_ms = 432672, .bytes = 10590412 },
.{ .title = "Burn Slow", .file = "music/no-good-way-out/burn-slow.mp3", .duration_ms = 104952, .bytes = 2405998 },
.{ .title = "Back Again", .file = "music/no-good-way-out/back-again.mp3", .duration_ms = 51720, .bytes = 1273271 },
.{ .title = "Eastbound Cut", .file = "music/no-good-way-out/eastbound-cut.mp3", .duration_ms = 112152, .bytes = 2685482 },
.{ .title = "Glass in My Teeth", .file = "music/no-good-way-out/glass-in-my-teeth.mp3", .duration_ms = 42504, .bytes = 1021086 },
},
},
.{
.artist = "Violet District",
.title = "Glass Flowers",
.year = 2025,
.art = "art/glass-flowers.jpg",
.tracks = .{
.{ .title = "Paper Satellite", .file = "music/glass-flowers/paper-satellite.mp3", .duration_ms = 129792, .bytes = 3101360 },
.{ .title = "Mothbox Window", .file = "music/glass-flowers/mothbox-window.mp3", .duration_ms = 163272, .bytes = 3528103 },
.{ .title = "Room 214", .file = "music/glass-flowers/room-214.mp3", .duration_ms = 143424, .bytes = 3391513 },
.{ .title = "Sleepwalking Violet", .file = "music/glass-flowers/sleepwalking-violet.mp3", .duration_ms = 204672, .bytes = 4808268 },
.{ .title = "Parallel Lines", .file = "music/glass-flowers/parallel-lines.mp3", .duration_ms = 162024, .bytes = 3795271 },
.{ .title = "Pressed Petals", .file = "music/glass-flowers/pressed-petals.mp3", .duration_ms = 145272, .bytes = 3426559 },
.{ .title = "Salt On Glass II", .file = "music/glass-flowers/salt-on-glass-ii.mp3", .duration_ms = 152952, .bytes = 3525465 },
.{ .title = "Fade Away", .file = "music/glass-flowers/fade-away.mp3", .duration_ms = 139392, .bytes = 3178058 },
.{ .title = "Summer Static", .file = "music/glass-flowers/summer-static.mp3", .duration_ms = 63864, .bytes = 1434174 },
},
},
.{
.artist = "Violet District",
.title = "Night Bloom",
.year = 2025,
.art = "art/night-bloom.jpg",
.tracks = .{
.{ .title = "Night Bloom", .file = "music/night-bloom/night-bloom.mp3", .duration_ms = 123264, .bytes = 2863514 },
.{ .title = "Japanese Maple", .file = "music/night-bloom/japanese-maple.mp3", .duration_ms = 192264, .bytes = 4319501 },
.{ .title = "Last Light", .file = "music/night-bloom/last-light.mp3", .duration_ms = 142752, .bytes = 3339817 },
.{ .title = "Northbound Window", .file = "music/night-bloom/northbound-window.mp3", .duration_ms = 151800, .bytes = 3472424 },
.{ .title = "Glass Flowers", .file = "music/night-bloom/glass-flowers.mp3", .duration_ms = 142560, .bytes = 3116716 },
.{ .title = "Salt On Glass", .file = "music/night-bloom/salt-on-glass.mp3", .duration_ms = 159792, .bytes = 3645772 },
.{ .title = "Palette Shift", .file = "music/night-bloom/palette-shift.mp3", .duration_ms = 154824, .bytes = 3500500 },
.{ .title = "White Noise", .file = "music/night-bloom/white-noise.mp3", .duration_ms = 139992, .bytes = 3160898 },
},
},
.{
.artist = "St. Electric",
.title = "Motion Picture",
.year = 2025,
.art = "art/motion-picture.jpg",
.tracks = .{
.{ .title = "Silver Frame", .file = "music/motion-picture/silver-frame.mp3", .duration_ms = 142344, .bytes = 3240483 },
.{ .title = "Running Lights", .file = "music/motion-picture/running-lights.mp3", .duration_ms = 143784, .bytes = 3264053 },
.{ .title = "Slow Burn", .file = "music/motion-picture/slow-burn.mp3", .duration_ms = 40272, .bytes = 960072 },
.{ .title = "Open Road Sign", .file = "music/motion-picture/open-road-sign.mp3", .duration_ms = 54792, .bytes = 1265357 },
.{ .title = "Fire Escape", .file = "music/motion-picture/fire-escape.mp3", .duration_ms = 154392, .bytes = 3443978 },
.{ .title = "Saint Electric", .file = "music/motion-picture/saint-electric.mp3", .duration_ms = 86424, .bytes = 1917917 },
.{ .title = "Reactive Hearts", .file = "music/motion-picture/reactive-hearts.mp3", .duration_ms = 77640, .bytes = 1729998 },
.{ .title = "Call It Love", .file = "music/motion-picture/call-it-love.mp3", .duration_ms = 122760, .bytes = 2775291 },
.{ .title = "Glassline Horizon", .file = "music/motion-picture/glassline-horizon.mp3", .duration_ms = 118824, .bytes = 2749472 },
},
},
.{
.artist = "Color TV",
.title = "Channel Surfing",
.year = 2025,
.art = "art/channel-surfing.jpg",
.tracks = .{
.{ .title = "Side B", .file = "music/channel-surfing/side-b.mp3", .duration_ms = 73992, .bytes = 1548162 },
.{ .title = "Last Broadcast", .file = "music/channel-surfing/last-broadcast.mp3", .duration_ms = 71952, .bytes = 1708346 },
.{ .title = "VHS Summer", .file = "music/channel-surfing/vhs-summer.mp3", .duration_ms = 130992, .bytes = 3045958 },
.{ .title = "Static in My Heart", .file = "music/channel-surfing/static-in-my-heart.mp3", .duration_ms = 179904, .bytes = 4172094 },
.{ .title = "Late Checkout", .file = "music/channel-surfing/late-checkout.mp3", .duration_ms = 78000, .bytes = 1889857 },
.{ .title = "Apartment 4B", .file = "music/channel-surfing/apartment-4b.mp3", .duration_ms = 111744, .bytes = 2439480 },
.{ .title = "Blue Screen", .file = "music/channel-surfing/blue-screen.mp3", .duration_ms = 80904, .bytes = 1838327 },
.{ .title = "Friday Lot Glow", .file = "music/channel-surfing/friday-lot-glow.mp3", .duration_ms = 87384, .bytes = 1870587 },
.{ .title = "Static on Maple", .file = "music/channel-surfing/static-on-maple.mp3", .duration_ms = 135672, .bytes = 3265131 },
},
},
},
}
+12
View File
@@ -0,0 +1,12 @@
<!-- The spectrum analyzer: 32 phosphor bars pinned to the absolute 0..1
level domain — bars only, nothing riding their caps (the scanline
chrome in front supplies the segmented-ladder read). The series
binds a model fn fed by REAL analysis: the host's journaled
`.spectrum` band reports are the targets and the frame clock runs
the analyzer ballistics (instant attack, linear decay), so pausing
freezes the bars because both inputs stop. Idle — and any host that
cannot analyze its playback — shows the resting noise-floor comb
instead: honest absence, never fake dancing. -->
<chart grow="1" y-min="0" y-max="1" label="Spectrum analyzer">
<series kind="bar" values="{spectrumLevels}" color="accent" label="spectrum" />
</chart>
+18
View File
@@ -0,0 +1,18 @@
<!-- The playlist rack's bottom status strip: search and the match
counter, riding directly on the rack's enamel panel (rows paint no
fill of their own). Compiled at comptime
(canvas.CompiledMarkupView) and composed under the Zig playlist
view; the test suite also runs it through the runtime interpreter
and asserts engine parity. Styling comes entirely from the deck
theme's tokens — the glass search inset is a `controls.*` override. -->
<column>
<separator />
<row height="38" padding="8" gap="10" cross="center" label="Status strip">
<search-field text="{search}" placeholder="SEARCH LIBRARY" width="210" on-input="search_edit" label="Search library" />
<if test="{searching}">
<button size="sm" variant="ghost" icon="x" on-press="clear_search" label="Clear search"></button>
</if>
<spacer grow="1" />
<text size="sm" foreground="text_muted">{statusLabel}</text>
</row>
</column>
File diff suppressed because it is too large Load Diff
+227
View File
@@ -0,0 +1,227 @@
//! deck theme: the whole skin, expressed as design tokens — no widget
//! code knows it is dressed as hardware.
//!
//! THIS SKIN IS CUSTOM BY DESIGN. It deliberately does not follow the
//! framework's theme packs or the OS color scheme: the deck is a piece
//! of vintage rack hardware, and hardware has exactly one finish — warm
//! cream/putty enamel chassis panels around dark smoked-glass display
//! bays that print in phosphor green. Every value below is this
//! product's identity, not a restatement of the house register; apps
//! that want the house look should start from `DesignTokens.theme` and
//! stop there.
//!
//! The palette is split across two physical materials, and the token
//! table allocates its slots by MATERIAL rather than by the house
//! semantics:
//! - enamel (the chassis): `surface` is the enamel, `text` is the
//! silkscreened ink, `text_muted` the lighter engraving gray,
//! `border` the putty hairline between plates.
//! - glass (the display bays): `background` is the smoked glass —
//! every bay fills with it — `accent` is the LIVE phosphor,
//! `success` the pale phosphor a readout prints in at rest, and
//! `info` the dim phosphor of engraved-on-glass captions (this app
//! has no informational-violet surface, so the slot is spent on the
//! third phosphor register; a teaching trade, stated here).
//! Signal amber (`warning`) is reserved for the failure stamps (NO
//! MEDIA, STREAM LOST, and their remedy lines) — the one non-green hue
//! on the glass.
//!
//! Accessibility still beats brand: a high-contrast request abandons the
//! skin for the framework's high-contrast light palette and stock
//! control chrome, and reduce-motion zeroes the motion tokens.
const native_sdk = @import("native_sdk");
const canvas = native_sdk.canvas;
const Color = canvas.Color;
/// The deck's primary text face: Geist Pixel (the Vercel pixel family,
/// Square cut), registered at boot through `UiApp.Options.fonts` (see
/// main.zig; the committed TTF and its OFL license live in src/fonts/).
/// One registered face fills BOTH typography slots below — every span
/// on the fascia, mono-flagged or not, prints in the pixel face; only
/// the seven-segment clock stays custom-drawn chrome.
pub const primary_font_id: canvas.FontId = canvas.min_registered_font_id;
/// Geist Pixel's design grid: every outline coordinate in the face is a
/// multiple of 38/1000 em, so ONE font-pixel is exactly one device
/// pixel when the em size is 1000/38 px. All deck type sits on this
/// grid — the body/readout size at the HALF grid (font-pixels land on
/// half-pixel boundaries at 1x and exactly on device pixels at 2x) and
/// the marquee at the FULL grid (pixel-perfect everywhere) — so the
/// pixel face renders as blocks, never as anti-aliased mush.
pub const pixel_grid_em: f32 = 1000.0 / 38.0; // ~26.32
pub const pixel_grid_half_em: f32 = pixel_grid_em / 2.0; // ~13.16
/// Paragraph base size (the typography token below): the pixel face's
/// half-grid size. Public because the views derive their display scales
/// from it (marquee = 2.0 => the full grid).
pub const body_size: f32 = pixel_grid_half_em;
pub fn tokens(high_contrast: bool, reduce_motion: bool) canvas.DesignTokens {
var out = canvas.DesignTokens.theme(.{
// One finish: the OS scheme never reaches this call. The light
// base keeps the framework's light-scheme control washes under
// the cream enamel overrides below.
.color_scheme = .light,
.contrast = if (high_contrast) .high else .standard,
.density = .compact,
.reduce_motion = reduce_motion,
});
out.pixel_snap = .{ .geometry = true, .text = true, .scale = 1 };
if (high_contrast) return out;
out.colors = chassis_colors;
// Softly beveled hardware: chunkier than machined chamfers, still
// nothing close to a pill.
out.radius = .{ .sm = 2, .md = 3, .lg = 4, .xl = 5 };
// The pixel face is the PRIMARY type: both slots point at the
// registered Geist Pixel face, so mono-flagged readouts and plain
// text alike print in it (high contrast returned above with the
// toolkit's stock faces — accessibility beats brand). The face is
// proportional, so nothing below assumes a fixed pitch; alignment
// rides fixed column widths and text alignment instead.
out.typography.font_id = primary_font_id;
out.typography.mono_font_id = primary_font_id;
// Every size sits on the face's half-grid (see `pixel_grid_half_em`)
// so the blocks stay square; hierarchy comes from the phosphor
// registers and the marquee's full-grid scale, not from size steps
// the grid cannot honor.
out.typography.body_size = body_size;
out.typography.label_size = pixel_grid_half_em;
out.typography.title_size = pixel_grid_em;
out.typography.button_size = pixel_grid_half_em;
// The long-travel fader: a slim track with a squared cap, stated as
// metric tokens so both engines cut the same thumb.
out.metrics.slider_track_height = 4;
out.metrics.slider_thumb_width = 10;
out.metrics.slider_thumb_height = 16;
// ---- control plating -------------------------------------------
// Chunky enamel keys with dark glyphs; the chrome pass adds the 3D
// bevel edges on the transport plates, so the tokens only state the
// fills and inks.
out.controls.button_outline = .{
.background = key_face,
.hover_background = key_hover,
.active_background = key_pressed,
.foreground = ink,
.border = key_edge,
};
out.controls.button_ghost = .{
.hover_background = key_hover,
.active_background = key_pressed,
.foreground = ink,
};
// No filled-primary control exists on this faceplate; keep the slot
// on the enamel register so any stray primary reads as hardware.
out.controls.button_primary = .{
.background = key_face,
.hover_background = key_hover,
.active_background = key_pressed,
.foreground = ink,
.border = key_edge,
};
// The PL key: a latching hardware toggle — pressed-in enamel while
// the playlist rack is out.
out.controls.toggle_button = .{
.background = key_face,
.hover_background = key_hover,
.active_background = key_latched,
.foreground = ink,
.border = key_edge,
};
// The search field is a small glass inset in the rack's enamel
// status strip: smoked fill, phosphor print.
out.controls.search_field = .{
.background = glass,
.foreground = phosphor_pale,
.border = hairline,
};
// Faders: putty groove, phosphor filled range, enamel cap with a
// dark rim (the radius squares the cap into a hardware slider).
out.controls.slider = .{
.background = groove,
.active_background = phosphor,
.foreground = key_face,
.border = ink,
.radius = 1,
};
out.controls.scrollbar = .{
.background = Color.rgba8(0, 0, 0, 0),
.foreground = Color.rgba8(94, 125, 104, 110),
};
// Default panels paint NOTHING — no fill, no stroke: every visible
// surface in this app states its material explicitly
// (`style_tokens`), the glass bays are framed by the chrome pass's
// bevels (not widget borders), and the panel family that leans on
// the default — the playlist bay's ledger rows — is bare glass by
// design: single hairline dividers between rows, no per-row plates.
out.controls.panel = .{
.background = Color.rgba8(0, 0, 0, 0),
.border = Color.rgba8(0, 0, 0, 0),
};
return out;
}
// ---- palette -------------------------------------------------------
// The enamel family: warm cream/putty, stepped by machining depth.
const enamel = Color.rgb8(231, 225, 209);
const enamel_bright = Color.rgb8(243, 238, 224);
const key_face = Color.rgb8(238, 232, 217);
const key_hover = Color.rgb8(245, 240, 227);
const key_pressed = Color.rgb8(212, 204, 184);
const key_latched = Color.rgb8(205, 197, 176);
const key_edge = Color.rgb8(158, 150, 128);
const groove = Color.rgb8(186, 178, 155);
const ink = Color.rgb8(44, 40, 32);
const engraving = Color.rgb8(110, 102, 82);
const putty_line = Color.rgb8(169, 161, 138);
const disabled_wash = Color.rgb8(222, 215, 198);
// The glass family: smoked near-black with a green cast, and the one
// phosphor hue at three registers. Public because the chrome pass draws
// its segment readout and band ladders in the same phosphor.
pub const glass = Color.rgb8(12, 16, 13);
const glass_lifted = Color.rgb8(24, 40, 30);
pub const phosphor = Color.rgb8(62, 224, 138);
const phosphor_pale = Color.rgb8(168, 216, 180);
const phosphor_dim = Color.rgb8(96, 128, 106);
const hairline = Color.rgb8(56, 68, 58);
pub const chassis_colors = canvas.ColorTokens{
// Glass register: every display bay fills with `background`.
.background = glass,
.surface = enamel,
// The lifted-glass wash under the loaded ledger row (glass, not
// enamel: the playlist bay is a display on this machine).
.surface_subtle = glass_lifted,
.surface_pressed = key_pressed,
.text = ink,
.text_muted = engraving,
.border = putty_line,
.accent = phosphor,
.accent_text = Color.rgb8(7, 21, 13),
.destructive = Color.rgb8(196, 60, 46),
.destructive_text = Color.rgb8(250, 246, 236),
// Pale phosphor: what a readout prints in at rest.
.success = phosphor_pale,
.success_text = Color.rgb8(7, 21, 13),
// Signal amber: the failure stamps (the NO MEDIA / STREAM LOST
// marquee and their remedy lines), the one non-green hue on the
// glass. The token survives the queue's removal because a warning
// register the failure states already wear is not optional.
.warning = Color.rgb8(236, 178, 74),
.warning_text = Color.rgb8(43, 30, 7),
// Dim phosphor: engraved-on-glass captions (see the module doc for
// why the info slot carries it).
.info = phosphor_dim,
.info_text = Color.rgb8(7, 21, 13),
// A phosphor focus ring on cream enamel reads as the powered-on
// cursor of the machine.
.focus_ring = phosphor,
// Depth on this product is machined (chrome bevels), never cast.
.shadow = Color.rgba8(0, 0, 0, 0),
.disabled = disabled_wash,
};
+620
View File
@@ -0,0 +1,620 @@
//! deck views: the hardware fascia, split across two windows the way a
//! component hi-fi stack splits it — a fixed-size player (the main
//! window IS the rack unit) and a matching playlist unit declared
//! through `windows_fn` while the model says it is open.
//!
//! Both windows are CHROMELESS (no OS titlebar, no system buttons), so
//! each cap band carries the skin's OWN close and minimize keys — real
//! controls wired to the runtime's window-action effects, with proper
//! roles and labels; nothing decorative.
//!
//! Markup-first where markup fits (the playlist's status strip and the
//! spectrum analyzer chart are compiled `.native` views); everything else
//! is Zig because the fascia needs what the closed markup grammar
//! excludes — paragraph readouts at custom scales, per-row native
//! context menus, the registered-image cover leaf, and model-conditional
//! plate styling.
//!
//! Every color in this file is a design-token reference (`style_tokens`);
//! the widget skin lives in `theme.zig`, the sculpted hardware layer
//! (enamel, bevels, screws, scanlines, the segment readout, the volume
//! knob face) in `chrome.zig`, and EVERY shared dimension in
//! `layout.zig` — the one chassis table both this file and the chrome
//! pass machine against. The glass bays fill with the `background`
//! token (the smoked glass) so the chrome's inset bevels read as depth
//! into the enamel.
//!
//! Type: ONE face — Geist Pixel, the deck's registered primary — fills
//! both typography slots (theme.zig), so mono-flagged readouts and
//! plain text print in the same pixel face. Scales sit on the face's
//! design grid: readouts and captions at the half grid (scale 1.0), the
//! marquee at the full grid (scale 2.0, one font-pixel per device
//! pixel at 1x). The face is proportional; column alignment rides fixed
//! widths and text alignment, never an assumed pitch.
//!
//! Token registers, by material (see theme.zig): on GLASS, `accent` is
//! live phosphor, `success` the pale resting print, `info` the dim
//! engraving, `warning` the one amber. On ENAMEL, `text` is the
//! silkscreened ink and `text_muted` the lighter stamping.
const std = @import("std");
const native_sdk = @import("native_sdk");
const model_mod = @import("model.zig");
const theme = @import("theme.zig");
const canvas = native_sdk.canvas;
pub const Model = model_mod.Model;
pub const Msg = model_mod.Msg;
pub const Ui = canvas.Ui(Msg);
pub const statusbar_markup = @embedFile("statusbar.native");
pub const CompiledStatusBarView = canvas.CompiledMarkupView(Model, Msg, statusbar_markup);
/// The spectrum analyzer chart: a compiled `.native` fragment (one
/// `<chart>` with a single bar series binding a model fn — bars only,
/// no line riding their caps), built into the display bay's glass
/// beside the segment clock — the deck's ONE animated band.
pub const CompiledSpectrumView = canvas.CompiledMarkupView(Model, Msg, @embedFile("spectrum.native"));
/// The chassis layout table (see layout.zig): re-exported so app wiring
/// and the tests read the same constants the views are built from.
pub const layout = @import("layout.zig");
pub const window_width = layout.window_width;
pub const window_height = layout.window_height;
pub const playlist_width = layout.playlist_width;
pub const playlist_height = layout.playlist_height;
/// Scales on the pixel face's design grid (see theme.zig): 1.0 is the
/// half-grid body size — every caption and readout — and 2.0 is the
/// full grid, where one font-pixel is exactly one device pixel at 1x.
/// Nothing between: sizes off the grid render the pixel blocks as
/// anti-aliased mush, so hierarchy comes from the phosphor registers
/// and the one full-grid marquee.
const caption_scale: f32 = 1.0;
const readout_scale: f32 = 1.0;
const marquee_scale: f32 = 2.0;
// ------------------------------------------------------------ player root
pub fn rootView(ui: *Ui, model: *const Model) Ui.Node {
// No root fill: the chrome pass paints the enamel chassis (warm
// gradient, grain hairlines, machining) behind everything.
return ui.column(.{ .grow = 1 }, .{
capBand(ui, model),
ui.column(.{ .grow = 1, .padding = layout.pad, .gap = layout.gap }, .{
ui.row(.{ .gap = layout.gap, .height = layout.row1_height }, .{
displayBay(ui, model),
artBay(ui, model),
}),
seekRow(ui, model),
transportRow(ui, model),
}),
});
}
/// The enamel cap band: the window's drag region, the skin's OWN window
/// keys, and the DECK stamp in one — the window IS the device and it
/// is chromeless, so the close and minimize keys here are the real
/// controls (wired to the window-action effects), not decoration. The
/// chrome draws the band and the key bevels; this row is transparent
/// and the stamp prints directly on the band's enamel. All x-positions
/// are layout-table constants (no OS chrome inset exists to track).
fn capBand(ui: *Ui, model: *const Model) Ui.Node {
return ui.row(.{
.height = layout.cap_height,
.gap = layout.cap_gap,
.cross = .center,
.window_drag = true,
.semantics = .{ .label = "Cap band" },
}, .{
// Leading margin: with the row gap, the close key lands at
// layout.cap_close_x.
ui.el(.stack, .{ .width = layout.pad - layout.cap_gap }, .{}),
windowKey(ui, "x", Msg{ .close_window = .player }, "Close window"),
windowKey(ui, "app:minimize", Msg{ .minimize_window = .player }, "Minimize window"),
brandStamp(ui),
ui.spacer(1),
// The unit's model designation — hardware fascia lettering, not
// framework branding (apps never self-brand their own chrome).
ui.paragraph(.{}, &.{
.{ .text = "STEREO DECK // MK-48", .monospace = true, .color = .text_muted, .scale = caption_scale },
}),
ui.row(.{ .gap = 4, .cross = .center }, .{
ui.icon(.{
.width = 9,
.height = 9,
.style_tokens = .{ .foreground = if (model.playing) .accent else .text_muted },
}, "circle-dot"),
ui.paragraph(.{ .semantics = .{ .label = "Power state" } }, &.{
.{ .text = if (model.playing) "RUN" else "STBY", .monospace = true, .color = .text_muted, .scale = caption_scale },
}),
}),
ui.el(.stack, .{ .width = layout.pad - layout.cap_gap }, .{}),
});
}
/// One skin-native window key: a real button with a real verb behind it
/// (close or minimize through the runtime's window-action effects) —
/// the affordance-honesty bar for a chromeless window. Square enamel,
/// dark glyph; the chrome pass adds the raised bevel.
fn windowKey(ui: *Ui, icon: []const u8, msg: Msg, label: []const u8) Ui.Node {
return ui.button(.{
.variant = .outline,
.width = layout.cap_key_size,
.height = layout.cap_key_size,
.icon = icon,
.on_press = msg,
.semantics = .{ .label = label },
}, "");
}
/// The DECK stamp: silkscreened lettering directly on the cap band's
/// enamel — no plate, no box behind it (the letter-spaced tracking and
/// the bold ink are the whole brand). `layout.brand_width` cases it.
fn brandStamp(ui: *Ui) Ui.Node {
var node = ui.paragraph(.{ .width = layout.brand_width, .semantics = .{ .label = "Brand" } }, &.{
.{ .text = "D E C K", .weight = .bold, .monospace = true, .color = .text, .scale = caption_scale },
});
node.widget.text_alignment = .center;
return node;
}
/// The display bay: the deck's ONE glass LED section — everything
/// phosphor lives here. Top row: clear glass for the chrome-drawn
/// seven-segment elapsed readout, with the spectrum chart (the one
/// animated band) beside it. Below: the rotating title marquee at the
/// full pixel grid, then the channel + timecode line (with the LIVE /
/// HOLD lamp) and the honest bitrate/size readout (with the SPECTRUM//32
/// engraving). No progress strip: the long-travel seek fader below the
/// glass is both the seek control AND the position readout — one
/// affordance, not two.
fn displayBay(ui: *Ui, model: *const Model) Ui.Node {
return ui.panel(.{
.width = layout.display_width,
.padding = layout.glass_inset,
.style_tokens = .{ .background = .background, .radius = .sm },
.semantics = .{ .label = "Display bay" },
}, ui.column(.{ .gap = layout.display_row_gap, .grow = 1 }, .{
ui.row(.{ .gap = layout.gap, .height = layout.display_top_row_height }, .{
// Clear glass: the chrome pass draws the sheared segment
// digits here; the timecode line below is the AX-readable
// echo of the same clock.
ui.el(.stack, .{ .width = layout.segment_area_width, .semantics = .{ .label = "Segment readout" } }, .{}),
CompiledSpectrumView.build(ui, model),
}),
// Failure stamps ride the marquee in signal amber — the one
// attention hue — so a failed load is unmistakable on the
// glass; the channel line below names the remedy.
ui.paragraph(.{ .semantics = .{ .label = "Marquee" } }, &.{
.{ .text = model.marqueeText(ui.arena), .monospace = true, .weight = .bold, .scale = marquee_scale, .color = if (model.mediaFailed()) .warning else if (model.idle()) .info else .accent },
}),
ui.row(.{ .cross = .center, .gap = layout.gap }, .{
if (model.mediaFailed())
// The remedy on the channel line, full display width.
// Which remedy depends on which failure: prepare the
// assets, or check the network.
ui.paragraph(.{ .semantics = .{ .label = "Channel" } }, &.{
.{ .text = model.remedyText(), .monospace = true, .color = .warning, .scale = caption_scale },
})
else
ui.paragraph(.{ .semantics = .{ .label = "Channel" } }, &.{
.{ .text = ui.fmt("{s} {s} / {s}", .{
model.channelLabel(ui.arena),
model.elapsedLabel(ui.arena),
model.durationLabel(ui.arena),
}), .monospace = true, .color = .success, .scale = readout_scale },
}),
ui.spacer(1),
ui.paragraph(.{}, &.{
.{ .text = if (model.playing) "LIVE" else "HOLD", .monospace = true, .color = if (model.playing) .accent else .info, .scale = caption_scale },
}),
}),
ui.row(.{ .cross = .center, .gap = layout.gap }, .{
// The source line: bitrate + size computed from the
// manifest's real bytes — see model.sourceLabel for why
// nothing here is invented.
ui.paragraph(.{ .semantics = .{ .label = "Source" } }, &.{
.{ .text = model.sourceLabel(ui.arena), .monospace = true, .color = .info, .scale = caption_scale },
}),
ui.spacer(1),
glassCaption(ui, "SPECTRUM//32"),
}),
}));
}
/// The art bay: the loaded record's committed cover in its own glass
/// window — real album art where the hardware would show the disc,
/// square at the glass row's full height. The cover id is 0 while
/// unregistered (the strict test decoder has no JPEG codec; live macOS
/// decodes through the platform codec) or while the deck is idle, and
/// the bay degrades to an engraved plate — a missing image can never
/// break the fascia.
fn artBay(ui: *Ui, model: *const Model) Ui.Node {
const cover = model.nowCover();
if (cover != 0) {
var node = ui.image(.{
.width = layout.art_size,
.height = layout.row1_height,
.image = cover,
.semantics = .{ .label = "Art bay" },
});
node.widget.image_fit = .cover;
return node;
}
return ui.panel(.{
.width = layout.art_size,
.height = layout.row1_height,
.style_tokens = .{ .background = .background, .radius = .sm },
.semantics = .{ .label = "Art bay" },
}, ui.column(.{ .grow = 1, .main = .center, .cross = .center }, .{
glassCaption(ui, if (model.idle()) "--" else "NO ART"),
}));
}
/// The long-travel seek fader: the seek control AND the deck's position
/// affordance (the display carries the timecode; a second progress bar
/// would duplicate this fader's travel). Re-keyed per track so it takes
/// the source position once on every load (snaps home) — slider values
/// are runtime-owned between rebuilds, so an un-keyed fader would hold
/// its last drag across track changes. The explicit height matches the
/// chrome's glass frame, so the thumb rides inside the bevel.
fn seekRow(ui: *Ui, model: *const Model) Ui.Node {
return ui.row(.{ .height = layout.seek_height, .cross = .center, .semantics = .{ .label = "Seek row" } }, .{
ui.el(.slider, .{
.key = canvas.uiKey(@as(u32, model.now orelse 0)),
.grow = 1,
.height = layout.seek_height,
.value = model.progressFraction(),
.disabled = model.idle(),
.on_change = .seeked,
.semantics = .{ .label = "Seek" },
}, .{}),
});
}
/// The transport row: five chunky enamel keys with dark glyphs (prev /
/// play / pause / stop / next — the full hardware verbs, each mapping
/// to a real transport message), then the rotary volume block and the
/// labeled PL utility key. Widths and gaps come from the layout table;
/// the chrome pass accumulates the same numbers into its bevel and well
/// positions, and the table's comptime assert holds that the row fits
/// its container (a growing spacer — blank faceplate — pins the PL key
/// right-aligned at `layout.pl_x` by construction).
fn transportRow(ui: *Ui, model: *const Model) Ui.Node {
return ui.row(.{ .gap = layout.gap, .height = layout.transport_height, .cross = .center, .semantics = .{ .label = "Transport" } }, .{
ui.button(.{
.variant = .outline,
.width = layout.btn_prev_width,
.height = layout.key_height,
.icon = "skip-back",
.disabled = model.idle(),
.on_press = .prev_track,
.semantics = .{ .label = "Previous track" },
}, ""),
ui.button(.{
.variant = .outline,
.width = layout.btn_play_width,
.height = layout.key_height,
.icon = "play",
.on_press = .transport_play,
.semantics = .{ .label = "Play" },
}, ""),
ui.button(.{
.variant = .outline,
.width = layout.btn_pause_width,
.height = layout.key_height,
.icon = "pause",
.disabled = !model.playing,
.on_press = .transport_pause,
.semantics = .{ .label = "Pause" },
}, ""),
ui.button(.{
.variant = .outline,
.width = layout.btn_stop_width,
.height = layout.key_height,
// The square stop glyph is the deck's own registered icon
// (src/icons/stop.svg through the app: namespace) — the
// built-in set carries no transport square.
.icon = "app:stop",
.disabled = model.idle(),
.on_press = .stop,
.semantics = .{ .label = "Stop" },
}, ""),
ui.button(.{
.variant = .outline,
.width = layout.btn_next_width,
.height = layout.key_height,
.icon = "skip-forward",
.disabled = model.idle(),
.on_press = .next_track,
.semantics = .{ .label = "Next track" },
}, ""),
// Fixed spacer past the transport well's bevel: clear enamel
// before the open-air volume block (no pocket of its own).
ui.el(.stack, .{ .width = layout.cluster_spacer }, .{}),
monoCaption(ui, "VOL", layout.vol_caption_width, .start, .text_muted),
// The rotary volume knob: the CONTROL is this real slider (drag,
// arrow keys, automation, the focus ring all work); the chrome
// pass draws the analog knob face with its position dot over the
// same frame, angle derived from the same `volume_fraction` the
// slider syncs — one value, two honest presentations.
ui.el(.slider, .{
.width = layout.knob_width,
.height = layout.key_height,
.value = model.volume_fraction,
.on_change = .volume_changed,
.semantics = .{ .label = "Volume" },
}, .{}),
// Blank faceplate grows between the volume block and the PL
// key, pinning PL at the right margin.
ui.spacer(1),
ui.el(.toggle_button, .{
.width = layout.btn_pl_width,
.height = layout.key_height,
.text = "PL",
.selected = model.playlist_open,
.on_toggle = .toggle_playlist,
.semantics = .{ .label = "Playlist window" },
}, .{}),
});
}
fn monoCaption(ui: *Ui, text: []const u8, width: f32, alignment: canvas.TextAlign, color: canvas.TextSpanColor) Ui.Node {
var node = ui.paragraph(.{ .width = width }, &.{
.{ .text = text, .monospace = true, .color = color, .scale = readout_scale },
});
node.widget.text_alignment = alignment;
return node;
}
// ---------------------------------------------------------- playlist root
/// The playlist unit: a second model-declared window — enamel chassis
/// around one big smoked-glass playlist bay. ONE flat list of every
/// song — no album rail, no sub-collections; search narrows it and the
/// deck strip carries the loaded record's sleeve and the ON DECK stamp.
/// The ledger's flat order IS the play order (track end advances down
/// it). No chrome pass reaches secondary windows, so the enamel here is
/// widgets and tokens only: the root fills with the `surface` token and
/// the machining is panel plates and hairline separators.
pub fn playlistView(ui: *Ui, model: *const Model) Ui.Node {
// The enamel chassis is a PANEL fill: plain layout containers carry
// no chrome of their own (the renderer paints nothing for rows and
// columns), so the one honest way to a painted surface is a surface
// widget — the panel wraps the whole rack.
return ui.panel(.{
.grow = 1,
.style_tokens = .{ .background = .surface },
.semantics = .{ .label = "Rack chassis" },
}, ui.column(.{ .grow = 1 }, .{
playlistHeader(ui),
ui.el(.separator, .{ .height = 1 }, .{}),
ledgerView(ui, model),
deckStrip(ui, model),
CompiledStatusBarView.build(ui, model),
}));
}
/// The rack's own cap strip: drag region, the skin's own window keys
/// (chromeless window, like the player), and the engraved unit label.
/// The close key racks the unit back in DECLARATIVELY — clearing
/// `playlist_open` is the real close, through the same reconcile the PL
/// key rides — and the minimize key is the real OS verb through the
/// window-action effect.
fn playlistHeader(ui: *Ui) Ui.Node {
return ui.row(.{
.height = layout.playlist_header_height,
.gap = layout.cap_gap,
.cross = .center,
.window_drag = true,
.semantics = .{ .label = "Playlist cap" },
}, .{
// Same leading margin as the player's cap band: the two units'
// window keys align when the windows stack.
ui.el(.stack, .{ .width = layout.pad - layout.cap_gap }, .{}),
windowKey(ui, "x", Msg{ .close_window = .playlist }, "Close window"),
windowKey(ui, "app:minimize", Msg{ .minimize_window = .playlist }, "Minimize window"),
enamelCaption(ui, "PLAYLIST"),
ui.spacer(1),
enamelCaption(ui, "DECK MK-48 // 1U"),
ui.el(.stack, .{ .width = layout.rack_pad }, .{}),
});
}
/// The playlist bay: ONE flat list of every song on dark glass — a
/// dense phosphor table, no cards, no covers, and no per-row plates:
/// single hairline rules BETWEEN the rows (the first row carries none
/// above, the last none below). Each row is pressable (load/toggle) and
/// carries the native context menu. The caption row's fixed height
/// keeps the scroll viewport folding on a whole row (the layout table's
/// comptime assert holds it).
fn ledgerView(ui: *Ui, model: *const Model) Ui.Node {
// The ledger is glass — the playlist IS a display on this machine —
// so a panel (containers paint nothing) fills the bay with the
// smoked-glass token.
const rows = model.visibleTracks(ui.arena);
// The bay's content column: the vertical rhythm keeps `rack_pad`
// (the viewport fold assert in layout.zig depends on it) while the
// x axis insets deeper (`ledger_inset_x`) so the rows — and the
// hairline rules between them, children of this same column — keep
// clear glass to the bay edges. Per-side padding is a direct layout
// write; the element options carry only the uniform shorthand.
var content = ui.column(.{ .grow = 1, .gap = layout.gap }, .{
ui.row(.{ .height = layout.ledger_caption_height, .cross = .center, .gap = 8 }, .{
glassCaption(ui, "TRACKS // LIBRARY"),
ui.spacer(1),
glassCaption(ui, ui.fmt("{d} TRK", .{rows.len})),
}),
if (rows.len == 0) emptyLedger(ui, model) else ui.scroll(.{
.grow = 1,
.semantics = .{ .label = "Track ledger" },
}, ui.el(.list, .{
.semantics = .{ .role = .list, .label = "Tracks" },
}, ui.each(rows, trackKey, ledgerRow))),
});
content.widget.layout.padding = .{
.top = layout.rack_pad,
.bottom = layout.rack_pad,
.left = layout.ledger_inset_x,
.right = layout.ledger_inset_x,
};
return ui.panel(.{
.grow = 1,
.style_tokens = .{ .background = .background },
.semantics = .{ .label = "Playlist bay" },
}, content);
}
fn trackKey(row: *const model_mod.TrackRow) canvas.UiKey {
return canvas.uiKey(@as(u32, row.id));
}
/// One ledger row, with its rule: every row but the first stacks a 1px
/// hairline ABOVE its plate, so the bay reads as one ruled glass table
/// — dividers between rows, never a box around each row.
fn ledgerRow(ui: *Ui, row: *const model_mod.TrackRow) Ui.Node {
if (row.first) return ledgerRowPlate(ui, row);
return ui.column(.{}, .{
// The rule: the same phosphor-tinted lift the loaded row washes
// in, one pixel tall — a hairline in the glass, not a border.
ui.el(.panel, .{
.height = layout.ledger_divider_height,
.style_tokens = .{ .background = .surface_subtle },
}, .{}),
ledgerRowPlate(ui, row),
});
}
fn ledgerRowPlate(ui: *Ui, row: *const model_mod.TrackRow) Ui.Node {
return ui.panel(.{
.global_key = canvas.uiKey(@as(u32, row.id)),
.height = layout.ledger_row_height,
.padding = 5,
.on_press = Msg{ .play_track = row.id },
// One item per row on purpose: the full ledger mounts every
// catalog track, and one item per row keeps the mounted total
// well inside the per-view context-menu budget.
.context_menu = &.{
.{ .label = "Copy Title", .msg = Msg{ .copy_title = row.id } },
},
// Rows are bare glass (the theme's default panel paints
// nothing); the loaded row alone lifts on a phosphor-tinted
// wash — the "current row" highlight of the bay.
.style_tokens = if (row.now)
.{ .background = .surface_subtle, .radius = .sm }
else
.{},
.semantics = .{ .role = .listitem, .label = row.title },
}, ui.row(.{ .gap = 8, .cross = .center }, .{
ui.row(.{ .width = layout.ledger_number_width, .cross = .center }, .{
if (row.now and row.playing)
ui.icon(.{ .width = 11, .height = 11, .style_tokens = .{ .foreground = .accent } }, "play")
else if (row.now)
ui.icon(.{ .width = 11, .height = 11, .style_tokens = .{ .foreground = .info } }, "pause")
else
ui.paragraph(.{}, &.{
.{ .text = row.number, .monospace = true, .color = .info, .scale = readout_scale },
}),
}),
// One-line ledger columns: elide a long title/artist behind a
// trailing ellipsis at the column edge, never wrap onto the row
// below. Titles print pale phosphor; the loaded row goes live.
ui.text(.{ .grow = 1, .size = .sm, .wrap = false, .style_tokens = .{ .foreground = if (row.now) .accent else .success } }, row.title),
ui.text(.{ .width = layout.ledger_artist_width, .size = .sm, .wrap = false, .style_tokens = .{ .foreground = .info } }, row.artist),
monoCaption(ui, row.duration, layout.ledger_duration_width, .end, .info),
// The overlay scrollbar's lane: keeps the duration digits clear
// of the thumb (the row itself stays full width).
ui.el(.stack, .{ .width = layout.ledger_scroll_lane }, .{}),
}));
}
fn emptyLedger(ui: *Ui, model: *const Model) Ui.Node {
return ui.panel(.{
.padding = 16,
.style_tokens = .{ .background = .background, .radius = .md },
.semantics = .{ .label = "No tracks match" },
}, ui.column(.{ .gap = 4 }, .{
ui.paragraph(.{}, &.{
.{ .text = "NO SIGNAL", .monospace = true, .color = .success },
}),
ui.text(.{ .size = .sm, .style_tokens = .{ .foreground = .info } }, ui.fmt("no matches for \"{s}\"", .{model.search()})),
}));
}
/// The bottom deck strip: the loaded record's sleeve window at the
/// left, then the ON DECK stamp naming what is loaded — enamel
/// silkscreen, one line. The strip states only what the deck holds now:
/// the ledger above is the play order, so nothing else needs stating.
fn deckStrip(ui: *Ui, model: *const Model) Ui.Node {
const track = model.nowTrack();
return ui.row(.{ .gap = 8, .height = layout.deck_strip_height, .cross = .center, .padding = layout.deck_strip_pad, .semantics = .{ .label = "Deck strip" } }, .{
// Leading margin: the sleeve aligns with the ledger content's
// deeper x inset above (the strip's own padding is the rest).
ui.el(.stack, .{ .width = layout.ledger_inset_x - layout.deck_strip_pad }, .{}),
sleevePane(ui, model),
enamelCaption(ui, "ON DECK //"),
// The loaded title, stamped uppercase in the silkscreen ink
// (hardware voice); idle wears the powered-on dashes. Elides at
// the strip's edge, never wraps.
if (track) |loaded|
ui.text(.{ .grow = 1, .size = .sm, .wrap = false, .style_tokens = .{ .foreground = .text } }, upper(ui, loaded.title))
else
ui.text(.{ .grow = 1, .size = .sm, .wrap = false, .style_tokens = .{ .foreground = .text_muted } }, "--"),
ui.el(.stack, .{ .width = layout.ledger_inset_x - layout.deck_strip_pad }, .{}),
});
}
/// The sleeve window: the loaded record's committed cover in a small
/// glass pane — real album art where the hardware would show the disc.
/// Same degrade story as the player's art bay: a missing decode leaves
/// an engraved plate, never a hole. The pane is too small for lettering
/// at the pixel face's caption size, so the engraving is the powered-on
/// dashes in every fallback state; the player's full-size art bay
/// carries the NO ART stamp.
fn sleevePane(ui: *Ui, model: *const Model) Ui.Node {
const cover = model.nowCover();
if (cover != 0) {
var node = ui.image(.{
.width = layout.sleeve_size,
.height = layout.sleeve_size,
.image = cover,
.semantics = .{ .label = "Sleeve" },
});
node.widget.image_fit = .cover;
return node;
}
return ui.panel(.{
.width = layout.sleeve_size,
.height = layout.sleeve_size,
.style_tokens = .{ .background = .background, .radius = .sm },
.semantics = .{ .label = "Sleeve" },
}, ui.column(.{ .grow = 1, .main = .center, .cross = .center }, .{
glassCaption(ui, "--"),
}));
}
// ---------------------------------------------------------------- shared
/// Engraved caption on GLASS: uppercase at the caption scale in the dim
/// phosphor register, natural advance — the pixel face's own spacing is
/// the honest stamping.
fn glassCaption(ui: *Ui, text: []const u8) Ui.Node {
return ui.paragraph(.{}, &.{
.{ .text = text, .monospace = true, .color = .info, .scale = caption_scale },
});
}
/// Engraved caption on ENAMEL: the same stamp in the silkscreen gray.
fn enamelCaption(ui: *Ui, text: []const u8) Ui.Node {
return ui.paragraph(.{}, &.{
.{ .text = text, .monospace = true, .color = .text_muted, .scale = caption_scale },
});
}
/// ASCII-uppercase into the build arena (library strings are ASCII).
fn upper(ui: *Ui, source: []const u8) []const u8 {
const out = ui.arena.alloc(u8, source.len) catch return source;
for (source, 0..) |byte, index| out[index] = std.ascii.toUpper(byte);
return out;
}
+31
View File
@@ -0,0 +1,31 @@
# effects-probe
The minimal effects dogfood: a native-rendered app whose Start button spawns a long-running shell stream through `fx.spawn`, streams each stdout line into the list as a typed `Msg`, and whose Cancel button kills the process mid-stream through `fx.cancel`.
This is the standing proof for the effect system's live path: worker thread → bounded completion queue → `wake_fn` → loop-thread drain → `update` → rebuild.
The stream command is platform-conditional: `/bin/sh` paces one line every 200ms on POSIX; Windows builds use `cmd /c for /L` paced by `ping -n 2 127.0.0.1` (~1 line/s), which also works under Wine — `.github/scripts/windows-effects-smoke.sh` cross-compiles this app for `x86_64-windows-gnu` and proves the spawn/stream/wake/cancel path against the automation snapshot there.
## Run
```bash
native dev
```
## Verify through the automation harness
```bash
native build -Dautomation=true
./zig-out/bin/effects-probe &
native automate wait
# click Start (find the id in snapshot.txt), watch "stream line N" grow,
# click Cancel, verify the count stops and the status shows "cancelled".
```
## Test
```bash
native test -Dplatform=null
```
The tests drive the same `update` through the fake effect executor: spawn requests are asserted on (argv, key), synthetic lines and exits are fed back as dispatched Msgs, and cancel semantics are proven without running a process.
+31
View File
@@ -0,0 +1,31 @@
.{
.id = "dev.native_sdk.effects_probe",
.name = "effects-probe",
.display_name = "Effects Probe",
.version = "0.1.0",
.platforms = .{"macos"},
.capabilities = .{ "native_views", "gpu_surfaces" },
.shell = .{
.windows = .{
.{
.label = "main",
.title = "Native SDK Effects Probe",
.width = 560,
.height = 480,
.restore_state = false,
.restore_policy = "center_on_primary",
.views = .{
.{ .label = "probe-canvas", .kind = "gpu_surface", .fill = true, .role = "Effects probe canvas", .accessibility_label = "Effects probe", .gpu_backend = "metal", .gpu_pixel_format = "bgra8_unorm", .gpu_present_mode = "timer", .gpu_alpha_mode = "opaque", .gpu_color_space = "srgb", .gpu_vsync = true },
},
},
},
},
.security = .{
.navigation = .{
.allowed_origins = .{ "zero://app", "zero://inline" },
.external_links = .{ .action = "deny" },
},
},
.web_engine = "system",
.cef = .{ .dir = "third_party/cef/macos", .auto_install = false },
}
+219
View File
@@ -0,0 +1,219 @@
//! effects-probe: the minimal effects dogfood app.
//!
//! One Start button spawns a long-running shell stream through
//! `fx.spawn`; each stdout line arrives as a typed Msg and lands in the
//! list; Cancel kills the process mid-stream through `fx.cancel`; Copy
//! status puts the counters on the system clipboard through
//! `fx.writeClipboard` (no pbcopy spawn). The view never spawns
//! anything — effects are update-side only.
const std = @import("std");
const builtin = @import("builtin");
const runner = @import("runner");
const native_sdk = @import("native_sdk");
pub const panic = std.debug.FullPanic(native_sdk.debug.capturePanic);
const canvas = native_sdk.canvas;
const geometry = native_sdk.geometry;
const canvas_label = "probe-canvas";
const window_width: f32 = 560;
const window_height: f32 = 480;
const shell_views = [_]native_sdk.ShellView{
.{ .label = canvas_label, .kind = .gpu_surface, .fill = true, .role = "Effects probe canvas", .accessibility_label = "Effects probe", .gpu_backend = .metal, .gpu_pixel_format = .bgra8_unorm, .gpu_present_mode = .timer, .gpu_alpha_mode = .@"opaque", .gpu_color_space = .srgb, .gpu_vsync = true },
};
const shell_windows = [_]native_sdk.ShellWindow{.{
.label = "main",
.title = "Native SDK Effects Probe",
.width = window_width,
.height = window_height,
.restore_state = false,
.views = &shell_views,
}};
const shell_scene: native_sdk.ShellConfig = .{ .windows = &shell_windows };
// ------------------------------------------------------------------ model
pub const stream_key: u64 = 1;
pub const clipboard_key: u64 = 2;
const max_visible_lines = 24;
const max_line_bytes = 64;
/// A slow, minutes-long stream: Cancel is the only way it ends early.
/// POSIX emits one line every 200ms through /bin/sh; Windows has no sh,
/// so cmd's `for /L` paces with the classic `ping -n 2 127.0.0.1` ~1s
/// delay (`timeout /t` refuses to wait without a console, and Wine's
/// stub returns immediately, so ping is also what keeps the stream slow
/// under the Wine effects verification). Cmd parser quirks shape the
/// exact spelling: real cmd echoes each loop-body command with a prompt
/// line unless echo is off — `/q` turns it off portably, while an `@(...)`
/// group is silently dropped by the Wine parser (exit 0, no output) —
/// and a redirect filename directly before `)` swallows the paren,
/// creating a literal `nul)` file — so ping runs first and echo closes
/// the group.
pub const stream_argv = if (builtin.os.tag == .windows) [_][]const u8{
"cmd", "/q", "/c", "for /L %i in (1,1,500) do (ping -n 2 127.0.0.1 > nul & echo stream line %i)",
} else [_][]const u8{
"/bin/sh", "-c", "i=0; while [ $i -lt 500 ]; do i=$((i+1)); echo \"stream line $i\"; sleep 0.2; done",
};
pub const Model = struct {
line_storage: [max_visible_lines][max_line_bytes]u8 = undefined,
line_lens: [max_visible_lines]usize = [_]usize{0} ** max_visible_lines,
visible_count: usize = 0,
total_lines: u64 = 0,
dropped_lines: u32 = 0,
streaming: bool = false,
last_exit: ?native_sdk.EffectExit = null,
/// Terminal outcome of the last clipboard copy (`fx.writeClipboard`).
copied: ?native_sdk.EffectClipboardOutcome = null,
/// Copy the payload: the line slice is drain scratch and dies with
/// this update call.
fn recordLine(model: *Model, line: native_sdk.EffectLine) void {
model.total_lines += 1;
model.dropped_lines += line.dropped_before;
if (model.visible_count == max_visible_lines) {
std.mem.copyForwards([max_line_bytes]u8, model.line_storage[0 .. max_visible_lines - 1], model.line_storage[1..max_visible_lines]);
std.mem.copyForwards(usize, model.line_lens[0 .. max_visible_lines - 1], model.line_lens[1..max_visible_lines]);
model.visible_count -= 1;
}
const len = @min(line.line.len, max_line_bytes);
@memcpy(model.line_storage[model.visible_count][0..len], line.line[0..len]);
model.line_lens[model.visible_count] = len;
model.visible_count += 1;
}
pub fn lineAt(model: *const Model, index: usize) []const u8 {
return model.line_storage[index][0..model.line_lens[index]];
}
pub fn visible(model: *const Model, arena: std.mem.Allocator) []const []const u8 {
const out = arena.alloc([]const u8, model.visible_count) catch return &.{};
for (out, 0..) |*slot, index| slot.* = model.lineAt(index);
return out;
}
pub fn statusText(model: *const Model, arena: std.mem.Allocator) []const u8 {
if (model.streaming) {
return std.fmt.allocPrint(arena, "streaming: {d} lines", .{model.total_lines}) catch "streaming";
}
if (model.last_exit) |exit| {
return std.fmt.allocPrint(arena, "{s}: code {d} after {d} lines", .{ @tagName(exit.reason), exit.code, model.total_lines }) catch "done";
}
return "idle";
}
};
pub const Msg = union(enum) {
start,
cancel,
copy_status,
line: native_sdk.EffectLine,
exited: native_sdk.EffectExit,
copied: native_sdk.EffectClipboardResult,
};
const ProbeApp = native_sdk.UiApp(Model, Msg);
pub const Effects = ProbeApp.Effects;
pub fn update(model: *Model, msg: Msg, fx: *Effects) void {
switch (msg) {
.start => {
if (model.streaming) return;
model.streaming = true;
model.last_exit = null;
model.total_lines = 0;
model.visible_count = 0;
fx.spawn(.{
.key = stream_key,
.argv = &stream_argv,
.on_line = Effects.lineMsg(.line),
.on_exit = Effects.exitMsg(.exited),
});
},
.cancel => fx.cancel(stream_key),
// Copy the status line to the system clipboard through the
// effects channel — no pbcopy spawn: the
// pasteboard is a platform service, and the terminal outcome
// arrives as one `.copied` Msg.
.copy_status => {
var buffer: [96]u8 = undefined;
const text = std.fmt.bufPrint(&buffer, "effects-probe: {d} lines total, {d} dropped", .{ model.total_lines, model.dropped_lines }) catch "effects-probe";
fx.writeClipboard(.{
.key = clipboard_key,
.text = text,
.on_result = Effects.clipboardMsg(.copied),
});
},
.line => |line| model.recordLine(line),
.exited => |exit| {
model.streaming = false;
model.last_exit = exit;
},
.copied => |result| model.copied = result.outcome,
}
}
// ------------------------------------------------------------------- view
pub const ProbeUi = canvas.Ui(Msg);
pub fn view(ui: *ProbeUi, model: *const Model) ProbeUi.Node {
return ui.column(.{ .gap = 8, .padding = 12, .style_tokens = .{ .background = .background } }, .{
ui.row(.{ .gap = 8, .cross = .center }, .{
ui.button(.{ .variant = .primary, .on_press = .start, .disabled = model.streaming }, "Start stream"),
ui.button(.{ .variant = .destructive, .on_press = .cancel, .disabled = !model.streaming }, "Cancel"),
ui.button(.{ .on_press = .copy_status }, if (model.copied) |outcome| switch (outcome) {
.ok => "Copied",
else => "Copy failed",
} else "Copy status"),
ui.spacer(1),
ui.text(.{ .style_tokens = .{ .foreground = .text_muted } }, model.statusText(ui.arena)),
}),
ui.scroll(.{ .grow = 1, .style_tokens = .{ .background = .surface, .radius = .md } }, .{
ui.column(.{ .gap = 2, .padding = 8 }, ui.each(model.visible(ui.arena), lineKey, lineView)),
}),
ui.statusBar(.{}, ui.fmt("{d} lines total · {d} dropped", .{ model.total_lines, model.dropped_lines })),
});
}
fn lineKey(line: *const []const u8) canvas.UiKey {
return canvas.uiKey(line.*);
}
fn lineView(ui: *ProbeUi, line: *const []const u8) ProbeUi.Node {
return ui.text(.{}, line.*);
}
// -------------------------------------------------------------------- app
pub fn main(init: std.process.Init) !void {
const app_state = try std.heap.page_allocator.create(ProbeApp);
defer std.heap.page_allocator.destroy(app_state);
app_state.* = ProbeApp.init(std.heap.page_allocator, .{}, .{
.name = "effects-probe",
.scene = shell_scene,
.canvas_label = canvas_label,
.update_fx = update,
.view = view,
});
defer app_state.deinit();
try runner.runWithOptions(app_state.app(), .{
.app_name = "effects-probe",
.window_title = "Native SDK Effects Probe",
.bundle_id = "dev.native_sdk.effects_probe",
.default_frame = geometry.RectF.init(0, 0, window_width, window_height),
.restore_state = false,
.js_window_api = false,
.security = .{
.navigation = .{ .allowed_origins = &.{ "zero://inline", "zero://app" } },
},
}, init);
}
test {
_ = @import("tests.zig");
}
+172
View File
@@ -0,0 +1,172 @@
const std = @import("std");
const native_sdk = @import("native_sdk");
const main = @import("main.zig");
const canvas = native_sdk.canvas;
const geometry = native_sdk.geometry;
const testing = std.testing;
const Model = main.Model;
const Msg = main.Msg;
const ProbeApp = native_sdk.UiApp(Model, Msg);
const shell_views = [_]native_sdk.ShellView{
.{ .label = "probe-canvas", .kind = .gpu_surface, .fill = true, .gpu_backend = .metal },
};
const shell_windows = [_]native_sdk.ShellWindow{.{
.label = "main",
.title = "Effects Probe",
.width = 560,
.height = 480,
.views = &shell_views,
}};
const shell_scene: native_sdk.ShellConfig = .{ .windows = &shell_windows };
fn probeOptions() ProbeApp.Options {
return .{
.name = "effects-probe",
.scene = shell_scene,
.canvas_label = "probe-canvas",
.update_fx = main.update,
.view = main.view,
};
}
test "start captures the spawn request and streamed lines land in the list" {
const harness = try native_sdk.TestHarness().create(testing.allocator, .{ .size = geometry.SizeF.init(560, 480) });
defer harness.destroy(testing.allocator);
harness.null_platform.gpu_surfaces = true;
const app_state = try testing.allocator.create(ProbeApp);
defer testing.allocator.destroy(app_state);
app_state.* = ProbeApp.init(std.heap.page_allocator, .{}, probeOptions());
defer app_state.deinit();
app_state.effects.executor = .fake;
const app = app_state.app();
try harness.start(app);
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{
.label = "probe-canvas",
.size = geometry.SizeF.init(560, 480),
.scale_factor = 1,
.frame_index = 1,
.timestamp_ns = 1_000_000,
.nonblank = true,
} });
// Start records the request (the fake executor runs nothing).
try app_state.dispatch(&harness.runtime, 1, .start);
try testing.expect(app_state.model.streaming);
try testing.expectEqual(@as(usize, 1), app_state.effects.pendingSpawnCount());
const request = app_state.effects.pendingSpawnAt(0).?;
try testing.expectEqual(main.stream_key, request.key);
// The argv is platform-conditional (/bin/sh on POSIX, cmd /c on
// Windows); assert the request captured it verbatim either way.
try testing.expectEqual(main.stream_argv.len, request.argv.len);
try testing.expectEqualStrings(main.stream_argv[0], request.argv[0]);
try testing.expectEqualStrings(main.stream_argv[1], request.argv[1]);
try testing.expectEqualStrings(main.stream_argv[2], request.argv[2]);
// Synthetic lines drain into the model through the wake path.
try app_state.effects.feedLine(main.stream_key, "stream line 1");
try app_state.effects.feedLine(main.stream_key, "stream line 2");
try harness.runtime.dispatchPlatformEvent(app, .wake);
try testing.expectEqual(@as(u64, 2), app_state.model.total_lines);
try testing.expectEqualStrings("stream line 2", app_state.model.lineAt(1));
// The synthetic exit lands and stops the stream state.
try app_state.effects.feedExit(main.stream_key, 0);
try harness.runtime.dispatchPlatformEvent(app, .wake);
try testing.expect(!app_state.model.streaming);
try testing.expectEqual(native_sdk.EffectExitReason.exited, app_state.model.last_exit.?.reason);
}
test "cancel stops the stream: queued lines are discarded, exit reports cancelled" {
const harness = try native_sdk.TestHarness().create(testing.allocator, .{ .size = geometry.SizeF.init(560, 480) });
defer harness.destroy(testing.allocator);
harness.null_platform.gpu_surfaces = true;
const app_state = try testing.allocator.create(ProbeApp);
defer testing.allocator.destroy(app_state);
app_state.* = ProbeApp.init(std.heap.page_allocator, .{}, probeOptions());
defer app_state.deinit();
app_state.effects.executor = .fake;
const app = app_state.app();
try harness.start(app);
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{
.label = "probe-canvas",
.size = geometry.SizeF.init(560, 480),
.scale_factor = 1,
.frame_index = 1,
.timestamp_ns = 1_000_000,
.nonblank = true,
} });
try app_state.dispatch(&harness.runtime, 1, .start);
try app_state.effects.feedLine(main.stream_key, "queued but never shown");
try app_state.dispatch(&harness.runtime, 1, .cancel);
try harness.runtime.dispatchPlatformEvent(app, .wake);
try testing.expectEqual(@as(u64, 0), app_state.model.total_lines);
try testing.expect(!app_state.model.streaming);
try testing.expectEqual(native_sdk.EffectExitReason.cancelled, app_state.model.last_exit.?.reason);
try testing.expectEqual(@as(usize, 0), app_state.effects.pendingSpawnCount());
}
test "copy status writes the clipboard through the effects channel" {
const harness = try native_sdk.TestHarness().create(testing.allocator, .{ .size = geometry.SizeF.init(560, 480) });
defer harness.destroy(testing.allocator);
harness.null_platform.gpu_surfaces = true;
const app_state = try testing.allocator.create(ProbeApp);
defer testing.allocator.destroy(app_state);
app_state.* = ProbeApp.init(std.heap.page_allocator, .{}, probeOptions());
defer app_state.deinit();
const app = app_state.app();
try harness.start(app);
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{
.label = "probe-canvas",
.size = geometry.SizeF.init(560, 480),
.scale_factor = 1,
.frame_index = 1,
.timestamp_ns = 1_000_000,
.nonblank = true,
} });
// Real executor against the null platform's clipboard store: the
// text lands through the PlatformServices pasteboard seam (no
// pbcopy spawn) and one terminal ok Msg drains back.
try app_state.dispatch(&harness.runtime, 1, .copy_status);
try harness.runtime.dispatchPlatformEvent(app, .wake);
try testing.expectEqual(native_sdk.EffectClipboardOutcome.ok, app_state.model.copied.?);
try testing.expectEqualStrings("text/plain", harness.null_platform.lastClipboardMimeType());
try testing.expectEqualStrings("effects-probe: 0 lines total, 0 dropped", harness.null_platform.lastClipboardData());
try testing.expectEqual(@as(usize, 0), app_state.effects.pendingSpawnCount());
}
test "the probe view lays out through the canvas engine" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var model = Model{};
model.streaming = true;
var ui = main.ProbeUi.init(arena);
const tree = try ui.finalize(main.view(&ui, &model));
var nodes: [256]canvas.WidgetLayoutNode = undefined;
const layout = try canvas.layoutWidgetTree(tree.root, geometry.RectF.init(0, 0, 560, 480), &nodes);
try testing.expect(layout.nodes.len > 0);
const cancel = findByText(tree.root, .button, "Cancel").?;
try testing.expect(!cancel.state.disabled);
const start = findByText(tree.root, .button, "Start stream").?;
try testing.expect(start.state.disabled);
}
fn findByText(widget: canvas.Widget, kind: canvas.WidgetKind, text: []const u8) ?canvas.Widget {
if (widget.kind == kind and std.mem.eql(u8, widget.text, text)) return widget;
for (widget.children) |child| {
if (findByText(child, kind, text)) |found| return found;
}
return null;
}
+24
View File
@@ -0,0 +1,24 @@
# feed
The infinite-scroll timeline — the VARIABLE-extent windowed virtual list proof. A 100,000-post synthetic corpus of MIXED-HEIGHT posts (every post derives deterministically from its index; no network, no storage) scrolls through one `ui.virtualList`, and the view only ever builds the rows on screen.
```sh
native dev
```
## What it demonstrates
- **The variable-extent windowed virtual list** — rows size to their wrapped bodies (one-liners, multi-sentence takes, the occasional long-form wall). The view provides a cheap per-post extent ESTIMATE (`postExtentEstimate`: body byte count over an assumed line width — rough on purpose), asks `ui.virtualWindow` which item range is visible, builds ONLY those rows, and hands both to `ui.virtualList`. The engine measures the rows it mounts and corrects its offset table, anchored on the first visible row — the scrollbar converges toward measured truth as you ride, and visible content never jumps. Widget-node cost is the window plus overscan — a dozen-odd rows — never the dataset; the automation snapshot's `widget_nodes=` telemetry proves it at the full corpus.
- **The runtime owns the scroll** — no `on_scroll` binding anywhere: wheel, kinetic, and keyboard scrolling apply engine-side, the native scroll driver takes over on macOS (its content size tracking the converging extent), and each scroll observation re-derives the view so the window follows the offset. The scrollbar spans the full virtual extent — millions of points at 100k mixed-height posts — and tells the best truth it has.
- **Infinite fetch through `on_reach_end`** — approaching the end of the loaded posts dispatches one `load_more` Msg (hysteresis built in: fire within one viewport of the end, re-arm past one and a half — which the appended batch causes on its own by growing the extent), and `update` appends the next 500 posts toward the 100k cap. No timers, no polling, no fetch storms.
- **Identity outlives the window** — every row is keyed by its post index, so its structural id is the same whenever it windows in; per-post state (likes, boosts, the selected row) lives in the model keyed by that same index. Like a post, scroll a hundred rows away, scroll back: same id, same wash, count still bumped.
- **A deterministic corpus** — `postAt(index)` hashes the index into author/handle/body/counts, so tests assert on exact content at post 90,000 without fixtures, and every platform renders the same timeline.
- **House flat rows** — avatar initials, bold author line, a wrapped multi-line body sized by its content, muted action chips from the built-in icon set, stock design tokens re-derived from the OS appearance. No cards, no borders, no brand marks.
## Fixed capacities
The corpus caps at 100,000 posts (`max_posts`); the model boots with 500 (`initial_batch`) and appends 500 per reach-end fetch (`fetch_batch`). Rows are as tall as their wrapped bodies — most posts run one to three sentences, every 13th is a longer take, every 47th a long-form wall (`postBodySentences`) — with 4 rows of overscan on each side. The engine's measured-correction store is budgeted per list; posts beyond it drift back to their estimates until revisited. Per-post interaction state is two 100k bitsets (~25 KB of model), keyed by post index.
## Tests
`native test` (or root `zig build test-example-feed`) drives the real dispatch paths: deterministic post derivation and body pricing, batch appends against the corpus cap, window-only tree builds with stable row identity across shifts, wheel scrolling through the runtime with the view re-windowing (no scroll Msg bound), like-state surviving a scroll away and back under the same structural id, reach-end firing once per approach through real dispatch, a scroll-storm test proving measured corrections never move the rows on screen, and snapshot telemetry showing `widget_nodes` viewport-sized at 100k posts while the scroll semantics span the corpus.
+37
View File
@@ -0,0 +1,37 @@
.{
.id = "dev.native_sdk.feed",
.name = "feed",
.display_name = "Feed",
.description = "A single-column feed of cards in a slim native window.",
.version = "0.1.0",
.platforms = .{"macos"},
.capabilities = .{ "native_views", "gpu_surfaces" },
.shell = .{
.windows = .{
.{
.label = "main",
.title = "Native SDK Feed",
.width = 520,
.height = 760,
// The floor the layout audit sweep proves clean
// (header + fixed-extent timeline rows).
.min_width = 520,
.min_height = 480,
.restore_state = false,
.restore_policy = "center_on_primary",
.titlebar = "hidden_inset_tall",
.views = .{
.{ .label = "feed-canvas", .kind = "gpu_surface", .fill = true, .role = "Feed timeline canvas", .accessibility_label = "Feed", .gpu_backend = "metal", .gpu_pixel_format = "bgra8_unorm", .gpu_present_mode = "timer", .gpu_alpha_mode = "opaque", .gpu_color_space = "srgb", .gpu_vsync = true },
},
},
},
},
.security = .{
.navigation = .{
.allowed_origins = .{ "zero://app", "zero://inline" },
.external_links = .{ .action = "deny" },
},
},
.web_engine = "system",
.cef = .{ .dir = "third_party/cef/macos", .auto_install = false },
}
+481
View File
@@ -0,0 +1,481 @@
//! feed: the infinite-scroll timeline, proving the VARIABLE-extent
//! windowed virtual list.
//!
//! A 100k-post synthetic corpus (every post derives deterministically
//! from its index — no network, no storage) scrolls through ONE
//! `ui.virtualList` with MIXED-HEIGHT rows — the real timeline shape:
//! one-liners, multi-sentence posts, and the occasional long-form wall
//! of text, each row sized by its wrapped body. The view provides a
//! cheap per-post extent ESTIMATE (character count over an assumed
//! line width — rough on purpose); the engine measures the rows it
//! mounts and corrects the scroll geometry as you ride, anchored so
//! corrections never move what you are reading. The view asks
//! `ui.virtualWindow` which rows are visible, builds ONLY those, and
//! the runtime owns the scroll — engine wheel and kinetic physics
//! everywhere, the native scroll driver on macOS, with the scrollbar
//! spanning the full (converging) virtual extent. Approaching the end
//! dispatches `on_reach_end` (once per approach, hysteresis built in)
//! and `update` appends the next batch, so the timeline grows to the
//! whole corpus as you ride it.
//!
//! Per-post interaction state (likes, boosts, the selected row) lives in
//! the MODEL keyed by post index — rows scroll away, state does not.
const std = @import("std");
const builtin = @import("builtin");
const runner = @import("runner");
const native_sdk = @import("native_sdk");
pub const panic = std.debug.FullPanic(native_sdk.debug.capturePanic);
const canvas = native_sdk.canvas;
const geometry = native_sdk.geometry;
pub const canvas_label = "feed-canvas";
pub const window_width: f32 = 520;
pub const window_height: f32 = 760;
/// Content min-size floor the window enforces: the feed column is
/// designed at exactly the window width (fixed-extent rows, a one-line
/// status strip budgeted for long content), so the width floor is the
/// designed width itself; only the height gives — proven by the layout
/// audit sweep in tests.zig, which sweeps from exactly this floor.
pub const window_min_width: f32 = window_width;
pub const window_min_height: f32 = 480;
/// The header bar's natural height, and the floor `header_height` falls
/// back to when no titlebar band overlays the content (fullscreen,
/// standard chrome, tests).
pub const header_natural_height: f32 = 52;
const shell_views = [_]native_sdk.ShellView{
.{ .label = canvas_label, .kind = .gpu_surface, .fill = true, .role = "Feed timeline canvas", .accessibility_label = "Feed", .gpu_backend = .metal, .gpu_pixel_format = .bgra8_unorm, .gpu_present_mode = .timer, .gpu_alpha_mode = .@"opaque", .gpu_color_space = .srgb, .gpu_vsync = true },
};
const shell_windows = [_]native_sdk.ShellWindow{.{
.label = "main",
.title = "Native SDK Feed",
.width = window_width,
.height = window_height,
.min_width = window_min_width,
.min_height = window_min_height,
.restore_state = false,
// Tall hidden-inset titlebar (declared in app.zon too, which threads
// it through the STARTUP window create): the header bar IS the
// titlebar — it pads its leading edge past the traffic lights via
// `on_chrome` and is the window's drag surface.
.titlebar = .hidden_inset_tall,
.views = &shell_views,
}};
pub const shell_scene: native_sdk.ShellConfig = .{ .windows = &shell_windows };
// ----------------------------------------------------------------- corpus
/// The whole demo corpus: posts exist as arithmetic, never as storage.
pub const max_posts: usize = 100_000;
/// Posts visible in the model at boot.
pub const initial_batch: usize = 500;
/// Posts each reach-end fetch appends.
pub const fetch_batch: usize = 500;
/// Rows built beyond the visible range on each side.
pub const post_overscan: usize = 4;
const author_names = [_][]const u8{
"Ada Byron", "Alan Kay", "Annie Easley", "Barbara Liskov",
"Dennis Wilson", "Edith Clarke", "Grace Murray", "Hedy Keller",
"Ivan Marsh", "Katherine Ross", "Lin Chen", "Mary Allen",
"Niklaus Wirth", "Radia Perl", "Sofia Kovaleva", "Vera Cortez",
};
const author_handles = [_][]const u8{
"@ada", "@kay", "@easley", "@liskov",
"@dwilson", "@edith", "@gmurray", "@hedy",
"@ivanm", "@kross", "@linchen", "@mallen",
"@wirth", "@radia", "@sofia", "@vera",
};
const author_initials = [_][]const u8{
"AB", "AK", "AE", "BL",
"DW", "EC", "GM", "HK",
"IM", "KR", "LC", "MA",
"NW", "RP", "SK", "VC",
};
const openers = [_][]const u8{
"Shipping it:",
"Today I learned that",
"Hot take:",
"Field note —",
"Small win:",
"Debugging diary:",
"Reading group takeaway:",
"Draft thought:",
};
const subjects = [_][]const u8{
"the retained tree keeps row state by identity",
"fixed budgets make failure modes honest",
"a flat list reads faster than a wall of cards",
"the scrollbar should always tell the truth",
"uniform row heights turn layout into arithmetic",
"the model owns the data, the runtime owns the viewport",
"overscan is the difference between smooth and shimmer",
"one keyed node per visible row is all a feed needs",
"deterministic fixtures beat recorded network traffic",
"an approach-end signal wants hysteresis, not a timer",
"typed messages make dispatch a compiler problem",
"windowed builds keep the arena small and warm",
};
const closers = [_][]const u8{
"More tomorrow.",
"Notes in the repo.",
"Convince me otherwise.",
"It held up under 100k rows.",
"The gate agrees.",
"Still chewing on it.",
"Benchmarks pending.",
"Filed under obvious-in-hindsight.",
};
/// Everything a post row shows, derived from the post index alone.
pub const Post = struct {
author: []const u8,
handle: []const u8,
initials: []const u8,
opener: []const u8,
subject: []const u8,
closer: []const u8,
minutes_ago: u32,
likes: u32,
boosts: u32,
replies: u32,
};
/// Deterministic post derivation: the same index always yields the same
/// post, on every build and every platform — the corpus is a function,
/// not a table.
pub fn postAt(index: usize) Post {
const seed = std.hash.Wyhash.hash(0xfeed_0001, std.mem.asBytes(&@as(u64, index)));
return .{
.author = author_names[seed % author_names.len],
.handle = author_handles[seed % author_handles.len],
.initials = author_initials[seed % author_initials.len],
.opener = openers[(seed >> 8) % openers.len],
.subject = subjects[(seed >> 16) % subjects.len],
.closer = closers[(seed >> 24) % closers.len],
.minutes_ago = @intCast(index % (60 * 24)),
.likes = @intCast((seed >> 32) % 900),
.boosts = @intCast((seed >> 40) % 120),
.replies = @intCast((seed >> 48) % 40),
};
}
pub fn postTimeLabel(arena: std.mem.Allocator, minutes_ago: u32) []const u8 {
if (minutes_ago < 60) return std.fmt.allocPrint(arena, "{d}m", .{minutes_ago}) catch "now";
return std.fmt.allocPrint(arena, "{d}h", .{minutes_ago / 60}) catch "today";
}
/// How many sentences post `index` carries: most posts are short (one
/// to three), every 13th is a longer take, every 47th the long-form
/// wall of text — the real timeline's mixed-height shape, derived from
/// the index alone like everything else in the corpus.
pub fn postBodySentences(index: usize) usize {
const seed = std.hash.Wyhash.hash(0xfeed_0002, std.mem.asBytes(&@as(u64, index)));
if (index % 47 == 0) return 14 + seed % 6;
if (index % 13 == 0) return 6 + seed % 4;
return 1 + seed % 3;
}
/// The k-th extra sentence of post `index` (deterministic, like the
/// post itself).
pub fn postBodySentence(index: usize, k: usize) []const u8 {
const seed = std.hash.Wyhash.hash(0xfeed_0003 +% @as(u64, k), std.mem.asBytes(&@as(u64, index)));
return subjects[seed % subjects.len];
}
/// The post's full body: "{opener} {subject}. {extra sentences…} {closer}".
pub fn postBody(arena: std.mem.Allocator, index: usize) []const u8 {
const post = postAt(index);
var builder: std.ArrayListUnmanaged(u8) = .empty;
const extras = postBodySentences(index) - 1;
builder.ensureTotalCapacity(arena, postBodyLength(index)) catch return post.subject;
builder.appendSliceAssumeCapacity(post.opener);
builder.appendAssumeCapacity(' ');
builder.appendSliceAssumeCapacity(post.subject);
builder.appendAssumeCapacity('.');
for (0..extras) |k| {
builder.appendAssumeCapacity(' ');
builder.appendSliceAssumeCapacity(postBodySentence(index, k));
builder.appendAssumeCapacity('.');
}
builder.appendAssumeCapacity(' ');
builder.appendSliceAssumeCapacity(post.closer);
return builder.items;
}
/// Byte length of `postBody` WITHOUT building it — the model fact the
/// extent estimate reads (an estimate must never require the content
/// to be materialized, let alone laid out).
pub fn postBodyLength(index: usize) usize {
const post = postAt(index);
var len = post.opener.len + 1 + post.subject.len + 1;
for (0..postBodySentences(index) - 1) |k| {
len += 1 + postBodySentence(index, k).len + 1;
}
return len + 1 + post.closer.len;
}
/// Estimate knobs: assumed characters per wrapped body line at the
/// designed width, the body line height, and the fixed per-row chrome
/// (padding, author line, actions row, column gaps). Rough on purpose —
/// the engine measures mounted rows and corrects, and the anchored
/// correction contract means a rough estimate costs scrollbar drift,
/// never a visible jump.
pub const estimate_chars_per_line: f32 = 52;
pub const post_line_height: f32 = 20;
pub const post_chrome_extent: f32 = 78;
/// The per-post extent estimate handed to the virtual list.
pub fn postExtentEstimate(context: ?*const anyopaque, index: u64) f32 {
_ = context;
const chars: f32 = @floatFromInt(postBodyLength(@intCast(index)));
const lines = @max(1, @ceil(chars / estimate_chars_per_line));
return post_chrome_extent + lines * post_line_height;
}
// ------------------------------------------------------------------ model
const LikedSet = std.StaticBitSet(max_posts);
pub const Model = struct {
/// Posts currently loaded into the timeline; reach-end fetches grow
/// it toward `max_posts`.
loaded: usize = initial_batch,
/// Reach-end dispatches observed (including at the corpus end).
fetches: u32 = 0,
/// Per-post interaction state, keyed by post INDEX — the identity
/// that outlives every window shift.
liked: LikedSet = LikedSet.initEmpty(),
boosted: LikedSet = LikedSet.initEmpty(),
selected: ?usize = null,
/// Chrome overlay geometry from `on_chrome` (tall hidden-inset
/// titlebar): the header leads with a spacer this wide so its
/// controls clear the traffic lights, and matches its height to the
/// titlebar band. Both fall back to the natural header when no band
/// overlays the content (fullscreen, standard chrome, tests).
chrome_leading: f32 = 0,
header_height: f32 = header_natural_height,
pub fn likeCount(model: *const Model, index: usize) u32 {
return postAt(index).likes + @as(u32, @intFromBool(model.liked.isSet(index)));
}
pub fn boostCount(model: *const Model, index: usize) u32 {
return postAt(index).boosts + @as(u32, @intFromBool(model.boosted.isSet(index)));
}
pub fn atCorpusEnd(model: *const Model) bool {
return model.loaded >= max_posts;
}
};
pub const Msg = union(enum) {
/// The approach-end signal (`on_reach_end`): append the next batch.
load_more,
toggle_like: usize,
toggle_boost: usize,
select_post: usize,
/// Chrome overlay geometry (tall hidden-inset titlebar): the header
/// pads its leading edge past the traffic lights and matches its
/// height to the titlebar band. Delivered through `on_chrome`.
chrome_changed: native_sdk.WindowChrome,
};
pub fn update(model: *Model, msg: Msg) void {
switch (msg) {
.load_more => {
model.fetches += 1;
if (model.loaded < max_posts) {
model.loaded = @min(max_posts, model.loaded + fetch_batch);
}
},
.toggle_like => |index| if (index < max_posts) model.liked.toggle(index),
.toggle_boost => |index| if (index < max_posts) model.boosted.toggle(index),
.select_post => |index| model.selected = if (model.selected != null and model.selected.? == index) null else index,
.chrome_changed => |chrome| {
model.chrome_leading = chrome.insets.left;
// Match the header to the titlebar band so its centered
// controls share the traffic lights' centerline; the natural
// height is the floor when no band overlays the content.
model.header_height = @max(header_natural_height, chrome.insets.top);
},
}
}
/// Chrome overlay geometry flows into the model (tall hidden-inset
/// titlebar): delivered before the first view build and again when it
/// changes — entering fullscreen hides the traffic lights and this goes
/// to zero.
pub fn onChrome(chrome: native_sdk.WindowChrome) ?Msg {
return .{ .chrome_changed = chrome };
}
// ------------------------------------------------------------------ theme
//
// House bar: no tokens/tokens_fn — the stock theme follows the system
// appearance by default (light/dark flips re-theme the running app).
// ------------------------------------------------------------------- view
pub const FeedUi = canvas.Ui(Msg);
/// The one options value both `virtualWindow` and `virtualList` read:
/// the MODEL owns the data (`item_count` is what's loaded right now),
/// the runtime owns the viewport math behind the window request.
pub fn timelineOptions(model: *const Model) FeedUi.VirtualListOptions {
return .{
.id = "timeline",
.item_count = model.loaded,
// VARIABLE-extent mode: rows size to their wrapped bodies; the
// estimate prices unmounted rows and the engine's measured
// corrections converge the scrollbar as the user rides.
.item_extent = 0,
.extent_estimate = postExtentEstimate,
.overscan = post_overscan,
.grow = 1,
.viewport_fallback = window_height,
.semantics = .{ .label = "Timeline" },
.on_reach_end = .load_more,
};
}
pub fn view(ui: *FeedUi, model: *const Model) FeedUi.Node {
const options = timelineOptions(model);
// The data-window seam: the runtime resolves scroll offset +
// viewport into the visible index range; the view builds only that.
const window = ui.virtualWindow(options);
const rows = ui.arena.alloc(FeedUi.Node, window.itemCount()) catch {
ui.failed = true;
return ui.column(.{}, .{});
};
for (rows, 0..) |*row, offset| row.* = postRow(ui, model, window.start_index + offset);
return ui.column(.{ .style_tokens = .{ .background = .background } }, .{
// The header IS the titlebar (tall hidden-inset chrome): it is
// the window's drag surface, leads with a spacer sized to the
// traffic lights via on_chrome, and matches its height to the
// titlebar band so its controls and the lights share a
// centerline.
// Band contents: one informative element — the load progress
// against the full corpus (the status bar reports the visible
// window, not the total) holds the trailing corner, and the rest
// of the band stays bare drag surface.
ui.row(.{ .height = model.header_height, .padding = 12, .gap = 10, .cross = .center, .window_drag = true, .style_tokens = .{ .background = .surface }, .semantics = .{ .label = "Feed header" } }, .{
ui.el(.stack, .{ .width = model.chrome_leading }, .{}),
ui.spacer(1),
ui.text(.{ .size = .sm, .style_tokens = .{ .foreground = .text_muted } }, ui.fmt("{d} of {d} posts", .{ model.loaded, max_posts })),
}),
ui.separator(.{}),
ui.virtualList(options, window, .{rows}),
ui.statusBar(.{}, statusLine(ui, model, window)),
});
}
fn statusLine(ui: *FeedUi, model: *const Model, window: canvas.VirtualListRange) []const u8 {
const tail: []const u8 = if (model.atCorpusEnd()) "end of corpus" else "scroll for more";
// Status bars paint one line and never elide, so this label budgets
// for the window's min width with long-content headroom: the visible
// range, the loaded total, and the tail — mounted/fetch counters
// live in the model (and the tests), not the strip.
return ui.fmt("posts {d}\u{2013}{d} \u{00b7} {d} loaded \u{00b7} {s}", .{
window.first_visible_index,
window.last_visible_index,
model.loaded,
tail,
});
}
/// One timeline row: avatar, author line, wrapped multi-line body,
/// actions. NO fixed extent — the row is as tall as its wrapped body
/// (the variable-extent contract), a FLAT list row (the list_item
/// composite — no border, no card chrome; hover and the selection are
/// full-width washes).
fn postRow(ui: *FeedUi, model: *const Model, index: usize) FeedUi.Node {
const post = postAt(index);
var node = ui.el(.list_item, .{
.padding = 12,
.selected = model.selected != null and model.selected.? == index,
.on_press = Msg{ .select_post = index },
// The label carries the post identity, not just the author:
// authors repeat across the timeline, and a screen reader needs
// adjacent rows to announce differently (the a11y audit's
// duplicate-sibling-label rule holds this honest).
.semantics = .{ .role = .listitem, .label = ui.fmt("Post {d} by {s}", .{ index, post.author }), .focusable = true },
}, .{
ui.row(.{ .grow = 1, .gap = 10 }, .{
ui.avatar(.{ .width = 36, .height = 36 }, post.initials),
ui.column(.{ .grow = 1, .gap = 3 }, .{
ui.row(.{ .gap = 6, .cross = .center }, .{
ui.paragraph(.{}, &.{.{ .text = post.author, .weight = .bold }}),
ui.text(.{ .size = .sm, .style_tokens = .{ .foreground = .text_muted } }, post.handle),
ui.spacer(1),
ui.text(.{ .size = .sm, .style_tokens = .{ .foreground = .text_muted } }, postTimeLabel(ui.arena, post.minutes_ago)),
}),
ui.text(.{ .wrap = true }, postBody(ui.arena, index)),
ui.row(.{ .gap = 14, .cross = .center }, .{
actionChip(ui, "arrow-up", model.likeCount(index), model.liked.isSet(index), Msg{ .toggle_like = index }, ui.fmt("Like post {d}", .{index})),
actionChip(ui, "repeat", model.boostCount(index), model.boosted.isSet(index), Msg{ .toggle_boost = index }, ui.fmt("Boost post {d}", .{index})),
ui.row(.{ .gap = 4, .cross = .center }, .{
ui.icon(.{ .width = 13, .height = 13, .style_tokens = .{ .foreground = .text_muted } }, "send"),
ui.text(.{ .size = .sm, .style_tokens = .{ .foreground = .text_muted } }, ui.fmt("{d}", .{post.replies})),
}),
}),
}),
}),
});
// Per-item identity: the post index keys the row, so its structural
// id — and every piece of engine/model state hanging off it — is
// the same whenever this post window in.
node.key = .{ .int = @intCast(index) };
return node;
}
fn actionChip(ui: *FeedUi, comptime icon_name: []const u8, count: u32, active: bool, msg: Msg, label: []const u8) FeedUi.Node {
var chip = ui.el(.toggle_button, .{
.size = .sm,
.variant = if (active) canvas.WidgetVariant.secondary else .ghost,
.icon = icon_name,
.selected = active,
.on_toggle = msg,
.semantics = .{ .label = label },
}, .{});
chip.widget.text = ui.fmt("{d}", .{count});
return chip;
}
// -------------------------------------------------------------------- app
const FeedApp = native_sdk.UiApp(Model, Msg);
pub fn main(init: std.process.Init) !void {
const app_state = try std.heap.page_allocator.create(FeedApp);
defer std.heap.page_allocator.destroy(app_state);
app_state.* = FeedApp.init(std.heap.page_allocator, .{}, .{
.name = "feed",
.scene = shell_scene,
.canvas_label = canvas_label,
.update = update,
.on_chrome = onChrome,
.view = view,
});
defer app_state.deinit();
try runner.runWithOptions(app_state.app(), .{
.app_name = "feed",
.window_title = "Native SDK Feed",
.bundle_id = "dev.native_sdk.feed",
.default_frame = geometry.RectF.init(0, 0, window_width, window_height),
.restore_state = false,
.js_window_api = false,
.security = .{
.navigation = .{ .allowed_origins = &.{ "zero://inline", "zero://app" } },
},
}, init);
}
test {
_ = @import("tests.zig");
}
+558
View File
@@ -0,0 +1,558 @@
const std = @import("std");
const native_sdk = @import("native_sdk");
const main = @import("main.zig");
const canvas = native_sdk.canvas;
const geometry = native_sdk.geometry;
const testing = std.testing;
const Model = main.Model;
const Msg = main.Msg;
const FeedUi = main.FeedUi;
const FeedApp = native_sdk.UiApp(Model, Msg);
const timeline_id = canvas.globalWidgetId(.scroll_view, .{ .str = "timeline" });
fn feedOptions() FeedApp.Options {
return .{
.name = "feed",
.scene = main.shell_scene,
.canvas_label = main.canvas_label,
.update = main.update,
.view = main.view,
};
}
/// ESTIMATED leading edge of post `index` (prefix sum over the same
/// estimate fn the view hands the engine) — how tests aim the pinned
/// window source at a post's neighborhood.
fn estimatedOffsetAt(index: usize) f32 {
var offset: f32 = 0;
for (0..index) |i| offset += main.postExtentEstimate(null, @intCast(i));
return offset;
}
// ------------------------------------------------------------ tree helpers
fn findByLabel(widget: canvas.Widget, label: []const u8) ?canvas.Widget {
if (std.mem.eql(u8, widget.semantics.label, label)) return widget;
for (widget.children) |child| {
if (findByLabel(child, label)) |found| return found;
}
return null;
}
fn subtreeHasText(widget: canvas.Widget, text: []const u8) bool {
if (std.mem.indexOf(u8, widget.text, text) != null) return true;
for (widget.children) |child| {
if (subtreeHasText(child, text)) return true;
}
return false;
}
/// A pinned window source, standing in for the runtime's retained
/// scroll state in pure-tree tests.
const PinnedSource = struct {
state: canvas.VirtualWindowState,
fn resolve(context: ?*anyopaque, id: canvas.ObjectId) ?canvas.VirtualWindowState {
_ = id;
const self: *PinnedSource = @ptrCast(@alignCast(context.?));
return self.state;
}
};
fn buildTreeAt(arena: std.mem.Allocator, model: *const Model, offset: f32, viewport: f32) !FeedUi.Tree {
var source = PinnedSource{ .state = .{ .offset = offset, .viewport_extent = viewport, .mounted = true } };
var ui = FeedUi.init(arena);
ui.virtual_window_context = @ptrCast(&source);
ui.virtual_window_source = PinnedSource.resolve;
return ui.finalize(main.view(&ui, model));
}
// -------------------------------------------------------------- pure model
test "posts derive deterministically from their index" {
const a = main.postAt(41_777);
const b = main.postAt(41_777);
try testing.expectEqualStrings(a.author, b.author);
try testing.expectEqualStrings(a.subject, b.subject);
try testing.expectEqual(a.likes, b.likes);
try testing.expectEqual(a.minutes_ago, b.minutes_ago);
// Different indices land on different content somewhere in the row.
const c = main.postAt(41_778);
const same = std.mem.eql(u8, a.author, c.author) and
std.mem.eql(u8, a.subject, c.subject) and
a.likes == c.likes;
try testing.expect(!same);
}
test "bodies are mixed-height on purpose and the estimate is a pure model fact" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
// The corpus really is mixed: shorts, longer takes (every 13th),
// and long-form walls (every 47th).
try testing.expect(main.postBodySentences(1) <= 3);
try testing.expect(main.postBodySentences(13) >= 6);
try testing.expect(main.postBodySentences(47) >= 14);
// postBodyLength prices the body without building it.
for ([_]usize{ 0, 1, 13, 47, 99_999 }) |index| {
try testing.expectEqual(main.postBody(arena, index).len, main.postBodyLength(index));
}
// Estimates vary by a real factor across the corpus — this is the
// variable-extent showcase, not a uniform list in disguise.
var min_estimate: f32 = std.math.floatMax(f32);
var max_estimate: f32 = 0;
for (0..200) |i| {
const estimate = main.postExtentEstimate(null, @intCast(i));
min_estimate = @min(min_estimate, estimate);
max_estimate = @max(max_estimate, estimate);
}
try testing.expect(max_estimate > min_estimate * 2);
}
test "update appends batches to the corpus cap and keys interaction by post index" {
var model = Model{};
try testing.expectEqual(main.initial_batch, model.loaded);
main.update(&model, .load_more);
try testing.expectEqual(main.initial_batch + main.fetch_batch, model.loaded);
try testing.expectEqual(@as(u32, 1), model.fetches);
// The cap holds: fetch counting continues, loading stops.
model.loaded = main.max_posts;
main.update(&model, .load_more);
try testing.expectEqual(main.max_posts, model.loaded);
try testing.expectEqual(@as(u32, 2), model.fetches);
try testing.expect(model.atCorpusEnd());
// Interaction state is keyed by post INDEX, not by row position.
main.update(&model, .{ .toggle_like = 99_999 });
try testing.expect(model.liked.isSet(99_999));
try testing.expectEqual(main.postAt(99_999).likes + 1, model.likeCount(99_999));
main.update(&model, .{ .toggle_like = 99_999 });
try testing.expect(!model.liked.isSet(99_999));
main.update(&model, .{ .select_post = 7 });
try testing.expectEqual(@as(?usize, 7), model.selected);
main.update(&model, .{ .select_post = 7 });
try testing.expectEqual(@as(?usize, null), model.selected);
}
// ------------------------------------------------------------------- views
test "the view builds only the visible window, with stable row identity across shifts" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var model = Model{ .loaded = main.max_posts };
// Scrolled to post 500's estimated edge: the tree holds the window
// around it — never 100k rows.
const tree_a = try buildTreeAt(arena, &model, estimatedOffsetAt(500), 700);
const list_a = findByLabel(tree_a.root, "Timeline").?;
try testing.expectEqual(timeline_id, list_a.id);
try testing.expectEqual(@as(usize, main.max_posts), list_a.layout.virtual_item_count);
try testing.expect(list_a.children.len < 30);
try testing.expectEqual(@as(usize, 500 - main.post_overscan), list_a.layout.virtual_first_index);
// The variable contract is stamped: a declared total extent, no
// uniform stride.
try testing.expect(list_a.layout.virtual_total_extent > 0);
try testing.expectEqual(@as(f32, 0), list_a.layout.virtual_item_extent);
// Two rows down: the overlapping post keeps its structural id.
const tree_b = try buildTreeAt(arena, &model, estimatedOffsetAt(502), 700);
const row_a = findByLabel(tree_a.root, "Like post 503").?;
const row_b = findByLabel(tree_b.root, "Like post 503").?;
try testing.expectEqual(row_a.id, row_b.id);
// The status line tells the truth about the window.
try testing.expect(subtreeHasText(tree_a.root, "100000 loaded"));
}
// ----------------------------------------------------------------- harness
const Harness = struct {
harness: *native_sdk.TestHarness(),
app_state: *FeedApp,
app: native_sdk.App,
fn create(model: Model) !Harness {
const harness = try native_sdk.TestHarness().create(testing.allocator, .{ .size = geometry.SizeF.init(main.window_width, main.window_height) });
errdefer harness.destroy(testing.allocator);
harness.null_platform.gpu_surfaces = true;
const app_state = try testing.allocator.create(FeedApp);
errdefer testing.allocator.destroy(app_state);
app_state.* = FeedApp.init(std.heap.page_allocator, model, feedOptions());
const app = app_state.app();
try harness.start(app);
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{
.label = main.canvas_label,
.size = geometry.SizeF.init(main.window_width, main.window_height),
.scale_factor = 2,
.frame_index = 1,
.timestamp_ns = 1_000_000,
.nonblank = true,
} });
return .{ .harness = harness, .app_state = app_state, .app = app };
}
fn destroy(self: *Harness) void {
self.app_state.deinit();
testing.allocator.destroy(self.app_state);
self.harness.destroy(testing.allocator);
}
fn dispatch(self: *Harness, msg: Msg) !void {
try self.app_state.dispatch(&self.harness.runtime, 1, msg);
}
fn wheel(self: *Harness, delta: f32) !void {
var buffer: [96]u8 = undefined;
const command = try std.fmt.bufPrint(&buffer, "widget-wheel {s} {d} {d}", .{ main.canvas_label, timeline_id, delta });
try self.harness.runtime.dispatchAutomationCommand(self.app, command);
}
fn clickWidget(self: *Harness, id: u64) !void {
var buffer: [96]u8 = undefined;
const command = try std.fmt.bufPrint(&buffer, "widget-click {s} {d}", .{ main.canvas_label, id });
try self.harness.runtime.dispatchAutomationCommand(self.app, command);
}
fn scrollState(self: *Harness) canvas.ScrollState {
return self.harness.runtime.views[0].canvasWidgetScrollStateById(timeline_id).?;
}
fn root(self: *Harness) canvas.Widget {
return self.app_state.tree.?.root;
}
};
test "the timeline scrolls through the runtime, re-windows, and keeps liked rows by identity" {
var h = try Harness.create(.{});
defer h.destroy();
try testing.expect(h.app_state.installed);
// Install: the first window mounts from post 0.
const list = findByLabel(h.root(), "Timeline").?;
try testing.expectEqual(@as(usize, 0), list.layout.virtual_first_index);
try testing.expect(list.children.len < 30);
try testing.expect(findByLabel(h.root(), "Like post 0") != null);
// Like post 2, through real dispatch.
const like_id = findByLabel(h.root(), "Like post 2").?.id;
try h.clickWidget(like_id);
try testing.expect(h.app_state.model.liked.isSet(2));
// Scroll far enough that post 2 unmounts (no on_scroll binding —
// the scroll observation itself re-derives the view).
try h.wheel(estimatedOffsetAt(200));
try testing.expect(findByLabel(h.root(), "Like post 2") == null);
try testing.expect(h.scrollState().offset > 0);
// Scroll back to the very top: the row returns under the SAME
// structural id with its liked state intact — identity is the
// post, not the slot.
var guard: usize = 0;
while (h.scrollState().offset > 0 and guard < 200) : (guard += 1) {
try h.wheel(-main.window_height * 4);
}
const returned = findByLabel(h.root(), "Like post 2").?;
try testing.expectEqual(like_id, returned.id);
try testing.expect(returned.state.selected);
// The derived count grew by the like.
var count_buffer: [16]u8 = undefined;
const expected_count = try std.fmt.bufPrint(&count_buffer, "{d}", .{main.postAt(2).likes + 1});
try testing.expectEqualStrings(expected_count, returned.text);
}
test "reach-end fires once per approach and appends the next batch" {
var h = try Harness.create(.{});
defer h.destroy();
// Ride to the end of the initial 500 posts: the approach-end signal
// fires ONCE (hysteresis) and update appends a batch.
try testing.expectEqual(@as(u32, 0), h.app_state.model.fetches);
var guard: usize = 0;
while (h.app_state.model.fetches == 0 and guard < 2_000) : (guard += 1) {
try h.wheel(main.window_height);
}
try testing.expectEqual(@as(u32, 1), h.app_state.model.fetches);
try testing.expectEqual(main.initial_batch + main.fetch_batch, h.app_state.model.loaded);
// The appended batch grew the extent, pulling the offset out of the
// band: the next nudge re-arms instead of re-firing.
try h.wheel(24);
try testing.expectEqual(@as(u32, 1), h.app_state.model.fetches);
// The next approach fires again.
guard = 0;
while (h.app_state.model.fetches == 1 and guard < 2_000) : (guard += 1) {
try h.wheel(main.window_height);
}
try testing.expectEqual(@as(u32, 2), h.app_state.model.fetches);
try testing.expectEqual(main.initial_batch + 2 * main.fetch_batch, h.app_state.model.loaded);
}
test "scroll storm: measured corrections never move the rows the user is reading" {
var h = try Harness.create(.{ .loaded = 5_000 });
defer h.destroy();
// Mixed steps and deep jumps across a corpus whose estimates are
// rough on purpose: at every step, a row still visible after the
// wheel must land exactly wheel-delta from where it was. The
// engine's anchored corrections may reprice everything OFF screen
// (that is the honest scrollbar-drift contract) — visible content
// never jumps.
var seed = std.Random.DefaultPrng.init(0x5eed_feed);
const random = seed.random();
var checked: usize = 0;
var step: usize = 0;
while (step < 150) : (step += 1) {
const before_state = h.scrollState();
const delta: f32 = switch (random.uintLessThan(u8, 8)) {
0, 1, 2 => main.window_height / 2,
3, 4 => -main.window_height / 2,
5 => main.window_height * 6,
6 => -main.window_height * 6,
else => before_state.maxOffset() * 0.5 - before_state.offset,
};
// Probe: the first row overlapping mid-viewport, by frame.
const layout = try h.harness.runtime.canvasWidgetLayout(1, main.canvas_label);
var list_index: usize = 0;
for (layout.nodes, 0..) |node, index| {
if (node.widget.id == timeline_id) list_index = index;
}
const list_frame = layout.nodes[list_index].frame.normalized();
var probe_index: ?u32 = null;
var probe_y: f32 = 0;
for (layout.nodes) |node| {
const parent = node.parent_index orelse continue;
if (parent != list_index) continue;
const item = node.widget.semantics.list_item_index orelse continue;
const frame = node.frame.normalized();
if (frame.y <= list_frame.y + main.window_height / 2 and frame.maxY() >= list_frame.y + main.window_height / 2) {
probe_index = item;
probe_y = frame.y;
break;
}
}
try h.wheel(delta);
// The invariant protects what the user SEES: after a wheel
// landing comfortably inside the scroll range, a row still
// (partly) visible must sit exactly wheel-delta from where it
// was — the offset absorbs any measured correction; the pixels
// never move by anything but the wheel. Edge-clamped steps are
// skipped (the applied wheel is not the asked wheel there by
// design).
const interior = before_state.offset + delta >= main.window_height and
before_state.offset + delta <= before_state.maxOffset() - 2 * main.window_height and
before_state.offset >= main.window_height and
before_state.offset <= before_state.maxOffset() - 2 * main.window_height;
if (interior) {
if (probe_index) |item| {
const after_layout = try h.harness.runtime.canvasWidgetLayout(1, main.canvas_label);
var after_list: usize = 0;
for (after_layout.nodes, 0..) |node, index| {
if (node.widget.id == timeline_id) after_list = index;
}
for (after_layout.nodes) |node| {
const parent = node.parent_index orelse continue;
if (parent != after_list) continue;
const after_item = node.widget.semantics.list_item_index orelse continue;
if (after_item != item) continue;
const frame = node.frame.normalized();
if (frame.maxY() > list_frame.y and frame.y < list_frame.y + main.window_height) {
try testing.expectApproxEqAbs(probe_y - delta, frame.y, 1.0);
checked += 1;
}
break;
}
}
}
}
try testing.expect(checked > 40);
}
test "layout audit sweep: nothing clips, overlaps, or escapes" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
var model = Model{ .loaded = main.max_posts };
// The windowed virtual timeline builds per viewport, so each swept
// size audits a tree built at exactly that viewport (mid-corpus, so
// real rows — including a long-form wall — are in the window).
const sizes = [_]geometry.SizeF{
geometry.SizeF.init(main.window_min_width, main.window_min_height),
geometry.SizeF.init(main.window_width, main.window_height),
geometry.SizeF.init(main.window_width * 1.5, main.window_height * 1.5),
};
for (sizes) |size| {
// Deep in the corpus: six-digit post indexes put the widest
// realistic numbers in the rows and the status strip.
const tree = try buildTreeAt(arena_state.allocator(), &model, estimatedOffsetAt(99_900), size.height);
try canvas.expectLayoutAuditSweepClean(testing.allocator, tree.root, .{
.tokens = canvas.DesignTokens.theme(.{}),
.min_size = size,
.default_size = size,
.large_size = size,
});
_ = arena_state.reset(.retain_capacity);
}
}
test "a11y audit sweep: every interactive widget is named, reachable, and unambiguous" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
var model = Model{ .loaded = main.max_posts };
// Same windowed-virtual discipline as the layout sweep: each swept
// size audits a tree built at exactly that viewport, deep in the
// corpus so the labels are the real six-digit ones.
const sizes = [_]geometry.SizeF{
geometry.SizeF.init(main.window_min_width, main.window_min_height),
geometry.SizeF.init(main.window_width, main.window_height),
geometry.SizeF.init(main.window_width * 1.5, main.window_height * 1.5),
};
for (sizes) |size| {
const tree = try buildTreeAt(arena_state.allocator(), &model, estimatedOffsetAt(99_900), size.height);
try canvas.expectA11yAuditSweepClean(testing.allocator, tree.root, .{
.tokens = canvas.DesignTokens.theme(.{}),
.min_size = size,
.default_size = size,
.large_size = size,
});
_ = arena_state.reset(.retain_capacity);
}
}
test "widget_nodes stays viewport-sized at the full 100k corpus while the scrollbar spans it" {
var h = try Harness.create(.{ .loaded = main.max_posts });
defer h.destroy();
// Snapshot telemetry: the retained node count is the WINDOW, deep
// under the 1024 budget, while the scroll semantics report the
// estimate-priced full corpus (converging toward measured truth as
// rows are visited) — the scrollbar tells the truth it has.
const snapshot = h.harness.runtime.automationSnapshot("Feed");
var found_view = false;
for (snapshot.views) |view| {
if (!std.mem.eql(u8, view.label, main.canvas_label)) continue;
found_view = true;
try testing.expect(view.widget_node_count > 0);
try testing.expect(view.widget_node_count < 320);
}
try testing.expect(found_view);
var found_scroll = false;
for (snapshot.widgets) |widget| {
if (widget.id != timeline_id) continue;
found_scroll = true;
try testing.expect(widget.scroll.present);
// 100k mixed-height posts: even the roughest honest pricing
// puts the timeline in the millions of points.
try testing.expect(widget.scroll.content_extent > @as(f32, @floatFromInt(main.max_posts)) * main.post_chrome_extent);
}
try testing.expect(found_scroll);
// Jump deep into the corpus: still the same bounded window, still
// six-digit posts on screen.
try h.wheel(estimatedOffsetAt(90_000));
const layout = try h.harness.runtime.canvasWidgetLayout(1, main.canvas_label);
try testing.expect(layout.nodes.len < 320);
const list = findByLabel(h.root(), "Timeline").?;
try testing.expect(list.layout.virtual_first_index > 80_000);
}
test "chrome geometry pads the header and matches its height to the tall band" {
var model = main.Model{};
try testing.expectEqual(main.header_natural_height, model.header_height);
// The tall hidden-inset band arrives through on_chrome: the header
// pads past the traffic lights and matches the band's height so its
// centered controls share the lights' centerline.
const chrome: native_sdk.WindowChrome = .{
.insets = .{ .top = 52, .left = 78 },
.buttons = native_sdk.geometry.RectF.init(20, 19, 52, 14),
};
const msg = main.onChrome(chrome) orelse return error.TestUnexpectedResult;
main.update(&model, msg);
try testing.expectEqual(@as(f32, 78), model.chrome_leading);
try testing.expectEqual(@max(main.header_natural_height, 52), model.header_height);
// A band taller than the natural header grows the header with it.
const tall = main.onChrome(.{ .insets = .{ .top = 72, .left = 78 } }) orelse return error.TestUnexpectedResult;
main.update(&model, tall);
try testing.expectEqual(@as(f32, 72), model.header_height);
// Fullscreen zeroes the chrome: the pad collapses and the height
// falls back to the header's natural floor.
const cleared = main.onChrome(.{}) orelse return error.TestUnexpectedResult;
main.update(&model, cleared);
try testing.expectEqual(@as(f32, 0), model.chrome_leading);
try testing.expectEqual(main.header_natural_height, model.header_height);
// The scene declares the matching titlebar so the platform actually
// hides the OS bar this header replaces.
try testing.expectEqual(.hidden_inset_tall, main.shell_scene.windows[0].titlebar);
}
// Env-gated homepage screenshot renderer (skipped by default, never in
// CI): the docs-homepage showcase state — the timeline a few posts in,
// so variable-height rows fill the fold and the header's load counter
// shows — once per color scheme, same state in both. PNGs land in
// /tmp/homepage-shots/feed-{light,dark}-artifacts/. To use:
//
// HOMEPAGE_SHOTS=1 zig build test
test "render homepage screenshots (env-gated)" {
if (!envGateSet("HOMEPAGE_SHOTS")) return error.SkipZigTest;
const io = testing.io;
var h = try Harness.create(.{});
defer h.destroy();
// The docs site overlays CSS stoplights on the capture, inside the
// header's own chrome gap. Reserve that gap for real: the standard
// macOS tall hidden-inset geometry (the same numbers the
// chrome-geometry test pins) arrives through the app's chrome
// channel, so the header pads exactly where the site's dots land.
try h.dispatch(main.onChrome(.{
.insets = .{ .top = 52, .left = 78 },
.buttons = native_sdk.geometry.RectF.init(20, 19, 52, 14),
}).?);
// A few posts in: wrapped multi-line bodies above and below the
// fold. The extra nudge past the post boundary lands the band edge
// inside a body, not across an author line (eyeballed).
try h.wheel(estimatedOffsetAt(5) + 100);
// The app follows the system appearance: drive the platform event
// once per scheme, the same channel the OS uses. The dispatch
// re-emits the display list with the re-derived tokens, so no
// present is needed in between.
try h.harness.runtime.dispatchPlatformEvent(h.app, .{ .appearance_changed = .{ .color_scheme = .light } });
h.harness.runtime.options.automation = native_sdk.automation.Server.init(io, "/tmp/homepage-shots/feed-light-artifacts", "Feed");
try h.harness.runtime.dispatchAutomationCommand(h.app, "screenshot feed-canvas 2");
try h.harness.runtime.dispatchPlatformEvent(h.app, .{ .appearance_changed = .{ .color_scheme = .dark } });
h.harness.runtime.options.automation = native_sdk.automation.Server.init(io, "/tmp/homepage-shots/feed-dark-artifacts", "Feed");
try h.harness.runtime.dispatchAutomationCommand(h.app, "screenshot feed-canvas 2");
}
/// Env-gated dump switch. `std.c.getenv` needs libc, which this test
/// build only links on targets whose platform layer pulls it in; when
/// libc is absent the gate reads as unset and the gated test skips.
fn envGateSet(name: [*:0]const u8) bool {
if (comptime !@import("builtin").link_libc) return false;
return std.c.getenv(name) != null;
}
+21
View File
@@ -0,0 +1,21 @@
# Native SDK gpu-components example
This example is a retained GPU widget lab for trying the finished native-first component surface:
- Native toolbar shell view with native-sdk-rendered sidebar, status strip, and GPU component surface.
- Buttons, icon buttons, text, icons, fields, checkbox, toggle, slider, progress, segmented control, lists, scroll views, popovers, menus, tooltips, and data grids.
- Built-in component catalog in the house style: Accordion, Alert, Avatar, Badge, Breadcrumb, Bubble, Button, Button Group, Card, Checkbox, Combobox, Dialog, Drawer, Dropdown Menu, Input, Pagination, Progress, Radio Group, Resizable, Select, Separator, Sheet, Skeleton, Slider, Spinner, Switch, Table, Tabs, Textarea, Toggle, Toggle Group, and Tooltip.
- Retained widget semantics for focus, press, toggle, select, text editing, scrolling, and data-grid roles.
- Token-driven rounded corners, shadows, blur, typography, color, and scroll physics.
Run with the macOS system backend. The GPU component lab defaults to `ReleaseFast`; pass `-Doptimize=Debug` only when debugging renderer internals.
```sh
native dev
```
Run the headless canvas and scene tests:
```sh
native test -Dplatform=null
```
+32
View File
@@ -0,0 +1,32 @@
.{
.id = "dev.native_sdk.gpu_components",
.name = "gpu-components",
.display_name = "GPU Components",
.version = "0.1.0",
.platforms = .{"macos"},
.permissions = .{ "view", "command" },
.capabilities = .{ "native_views", "gpu_surfaces" },
.shell = .{
.windows = .{
.{
.label = "main",
.title = "Native SDK GPU Components",
.width = 1180,
.height = 760,
.restore_state = false,
.restore_policy = "center_on_primary",
.views = .{
.{ .label = "components-canvas", .kind = "gpu_surface", .fill = true, .min_width = 640, .role = "Native-rendered component canvas", .accessibility_label = "Native-rendered component gallery canvas", .gpu_backend = "metal", .gpu_pixel_format = "bgra8_unorm", .gpu_present_mode = "timer", .gpu_alpha_mode = "opaque", .gpu_color_space = "srgb", .gpu_vsync = true },
},
},
},
},
.security = .{
.navigation = .{
.allowed_origins = .{ "zero://app", "zero://inline" },
.external_links = .{ .action = "deny" },
},
},
.web_engine = "system",
.cef = .{ .dir = "third_party/cef/macos", .auto_install = false },
}
+790
View File
@@ -0,0 +1,790 @@
const std = @import("std");
const native_sdk = @import("native_sdk");
const model = @import("model.zig");
const component_scene = @import("scene.zig");
const canvas = native_sdk.canvas;
const geometry = native_sdk.geometry;
const window_width = model.window_width;
const window_height = model.window_height;
const canvas_sidebar_width = model.canvas_sidebar_width;
const default_canvas_size = model.default_canvas_size;
const max_component_pipelines = model.max_component_pipelines;
const max_component_commands = model.max_component_commands;
const max_component_glyphs = model.max_component_glyphs;
const max_component_widgets = model.max_component_widgets;
const component_chrome_prefix_commands = model.component_chrome_prefix_commands;
const component_chrome_suffix_commands = model.component_chrome_suffix_commands;
const refresh_command = model.refresh_command;
const themeModeFromCommand = model.themeModeFromCommand;
const environment_toggle_command = model.environment_toggle_command;
const surface_dialog_command = model.surface_dialog_command;
const surface_drawer_command = model.surface_drawer_command;
const surface_sheet_command = model.surface_sheet_command;
const surface_close_command = model.surface_close_command;
const canvas_label = model.canvas_label;
const environment_select_id = model.environment_select_id;
const content_scroll_id = model.content_scroll_id;
const canvas_toolbar_theme_id = model.canvas_toolbar_theme_id;
const canvas_toolbar_refresh_id = model.canvas_toolbar_refresh_id;
const canvas_sidebar_resize_handle_id = model.canvas_sidebar_resize_handle_id;
const surface_overlay_backdrop_id = model.surface_overlay_backdrop_id;
const surface_overlay_id = model.surface_overlay_id;
const surface_overlay_close_id = model.surface_overlay_close_id;
const surface_overlay_content_parts = model.surface_overlay_content_parts;
const max_surface_overlay_animations = model.max_surface_overlay_animations;
const preview_images = component_scene.preview_images;
const environment_options = model.environment_options;
const environment_menu_id = model.environment_menu_id;
const initial_component_status_text = model.initial_component_status_text;
const max_component_status_text = model.max_component_status_text;
const ComponentVirtualScroll = model.ComponentVirtualScroll;
const ComponentUiState = model.ComponentUiState;
const ComponentSurfaceOverlay = model.ComponentSurfaceOverlay;
const ComponentSection = model.ComponentSection;
const ComponentThemeMode = model.ComponentThemeMode;
const environmentLabel = model.environmentLabel;
const environmentOptionIndex = model.environmentOptionIndex;
const environmentCommandIndex = model.environmentCommandIndex;
const componentSectionLabel = model.componentSectionLabel;
const componentSectionFromCommand = model.componentSectionFromCommand;
const surfaceOverlayLabel = model.surfaceOverlayLabel;
const installComponentsCanvasModel = component_scene.installComponentsCanvasModel;
const componentSurfaceSize = component_scene.componentSurfaceSize;
const componentSidebarWidthForSize = component_scene.componentSidebarWidthForSize;
const componentVirtualScrollTarget = component_scene.componentVirtualScrollTarget;
const componentVirtualKeyboardScrollTarget = component_scene.componentVirtualKeyboardScrollTarget;
const componentVirtualKeyboardScrollDelta = component_scene.componentVirtualKeyboardScrollDelta;
const snapComponentVirtualScrollOffset = component_scene.snapComponentVirtualScrollOffset;
const componentScrollStatesEqual = component_scene.componentScrollStatesEqual;
const componentFrameIntervalMs = component_scene.componentFrameIntervalMs;
const componentSizesEqual = component_scene.componentSizesEqual;
const componentTokensForScaleMotionAndContrast = component_scene.componentTokensForScaleMotionAndContrast;
const componentThemeModeForAppearance = component_scene.componentThemeModeForAppearance;
const normalizedPixelSnapScale = component_scene.normalizedPixelSnapScale;
const buildComponentsWidgetLayoutWithStateSizeAndTokens = component_scene.buildComponentsWidgetLayoutWithStateSizeAndTokens;
const surfaceOverlayKind = component_scene.surfaceOverlayKind;
const surfaceOverlayFrameForSidebar = component_scene.surfaceOverlayFrameForSidebar;
const gpuFrameEvent = component_scene.gpuFrameEvent;
const componentFrameStatus = component_scene.componentFrameStatus;
pub const app_permissions = [_][]const u8{ native_sdk.security.permission_command, native_sdk.security.permission_view };
pub const shell_views = [_]native_sdk.ShellView{
.{ .label = canvas_label, .kind = .gpu_surface, .fill = true, .min_width = 640, .layer = 12, .role = "Native-rendered component canvas", .accessibility_label = "Native-rendered component gallery canvas", .gpu_backend = .metal, .gpu_pixel_format = .bgra8_unorm, .gpu_present_mode = .timer, .gpu_alpha_mode = .@"opaque", .gpu_color_space = .srgb, .gpu_vsync = true },
};
pub const shell_windows = [_]native_sdk.ShellWindow{.{
.label = "main",
.title = "Native SDK GPU Components",
.width = window_width,
.height = window_height,
.restore_state = false,
.views = &shell_views,
}};
pub const shell_scene: native_sdk.ShellConfig = .{ .windows = &shell_windows };
pub const GpuComponentsApp = struct {
refresh_count: u32 = 0,
theme_count: u32 = 0,
theme_mode: ComponentThemeMode = .light,
theme_overridden: bool = false,
reduce_motion: bool = false,
high_contrast: bool = false,
canvas_installed: bool = false,
reported_planned_frame: bool = false,
virtual_scroll: ComponentVirtualScroll = .{},
environment_select_open: bool = false,
environment_index: usize = 0,
surface_overlay: ComponentSurfaceOverlay = .none,
section: ComponentSection = .controls,
sidebar_width: f32 = canvas_sidebar_width,
canvas_size: geometry.SizeF = default_canvas_size,
pixel_snap_scale: f32 = 1,
status_text_storage: [max_component_status_text]u8 = [_]u8{0} ** max_component_status_text,
status_text_len: usize = 0,
pixels: ?[]u8 = null,
scratch: ?[]u8 = null,
gpu_commands: [max_component_commands]canvas.CanvasGpuCommand = undefined,
packet_json: [native_sdk.platform.max_gpu_surface_packet_json_bytes]u8 = undefined,
render_commands: [max_component_commands]canvas.RenderCommand = undefined,
render_batches: [max_component_commands]canvas.RenderBatch = undefined,
images: [max_component_commands]canvas.RenderImage = undefined,
image_cache_entries: [max_component_commands]canvas.RenderImageCacheEntry = undefined,
image_cache_actions: [max_component_commands * 2]canvas.RenderImageCacheAction = undefined,
pipeline_cache_entries: [max_component_pipelines]canvas.RenderPipelineCacheEntry = undefined,
pipeline_cache_actions: [max_component_pipelines * 2]canvas.RenderPipelineCacheAction = undefined,
layers: [max_component_commands]canvas.RenderLayer = undefined,
layer_cache_entries: [max_component_commands]canvas.RenderLayerCacheEntry = undefined,
layer_cache_actions: [max_component_commands * 2]canvas.RenderLayerCacheAction = undefined,
resources: [max_component_commands]canvas.RenderResource = undefined,
cache_entries: [max_component_commands]canvas.RenderResourceCacheEntry = undefined,
cache_actions: [max_component_commands * 2]canvas.RenderResourceCacheAction = undefined,
visual_effects: [max_component_commands]canvas.VisualEffect = undefined,
visual_effect_cache_entries: [max_component_commands]canvas.VisualEffectCacheEntry = undefined,
visual_effect_cache_actions: [max_component_commands * 2]canvas.VisualEffectCacheAction = undefined,
glyphs: [max_component_glyphs]canvas.GlyphAtlasEntry = undefined,
glyph_cache_entries: [max_component_glyphs]canvas.GlyphAtlasCacheEntry = undefined,
glyph_cache_actions: [max_component_glyphs * 2]canvas.GlyphAtlasCacheAction = undefined,
text_layout_plans: [max_component_commands]canvas.TextLayoutPlan = undefined,
text_layout_lines: [max_component_glyphs]canvas.TextLine = undefined,
text_layout_cache_entries: [max_component_commands]canvas.TextLayoutCacheEntry = undefined,
text_layout_cache_actions: [max_component_commands * 2]canvas.TextLayoutCacheAction = undefined,
changes: [max_component_commands * 2 + 1]canvas.DiffChange = undefined,
pub fn app(self: *@This()) native_sdk.App {
return .{
.context = self,
.name = "gpu-components",
.scene_fn = scene,
.event_fn = event,
.stop_fn = stop,
};
}
pub fn deinit(self: *@This()) void {
if (self.pixels) |pixels| std.heap.page_allocator.free(pixels);
if (self.scratch) |scratch| std.heap.page_allocator.free(scratch);
self.pixels = null;
self.scratch = null;
}
fn scene(context: *anyopaque) anyerror!native_sdk.ShellConfig {
_ = context;
return shell_scene;
}
fn event(context: *anyopaque, runtime: *native_sdk.Runtime, event_value: native_sdk.Event) anyerror!void {
const self: *@This() = @ptrCast(@alignCast(context));
switch (event_value) {
.command => |command| {
if (std.mem.eql(u8, command.name, environment_toggle_command)) {
try self.toggleEnvironmentSelect(runtime, command);
} else if (environmentCommandIndex(command.name)) |index| {
try self.selectEnvironment(runtime, command, index);
} else if (std.mem.eql(u8, command.name, surface_dialog_command)) {
try self.openSurfaceOverlay(runtime, command, .dialog);
} else if (std.mem.eql(u8, command.name, surface_drawer_command)) {
try self.openSurfaceOverlay(runtime, command, .drawer);
} else if (std.mem.eql(u8, command.name, surface_sheet_command)) {
try self.openSurfaceOverlay(runtime, command, .sheet);
} else if (std.mem.eql(u8, command.name, surface_close_command)) {
try self.closeSurfaceOverlay(runtime, command);
} else if (std.mem.eql(u8, command.name, refresh_command)) {
try self.refresh(runtime, command);
} else if (themeModeFromCommand(command.name)) |mode| {
try self.changeTheme(runtime, command, mode);
} else if (componentSectionFromCommand(command.name)) |section| {
try self.changeSection(runtime, command, section);
}
},
.gpu_surface_frame => |frame_event| try self.handleGpuFrame(runtime, frame_event),
.canvas_widget_pointer => |pointer_event| try self.handleWidgetPointer(runtime, pointer_event),
.canvas_widget_keyboard => |keyboard_event| try self.handleWidgetKeyboard(runtime, keyboard_event),
.canvas_widget_dismiss => |dismiss_event| try self.handleWidgetDismiss(runtime, dismiss_event),
.appearance_changed => |appearance| try self.applySystemAppearance(runtime, appearance),
.gpu_surface_resized, .gpu_surface_input, .shortcut, .timer, .effects_wake, .audio, .files_dropped, .canvas_widget_scroll, .canvas_widget_file_drop, .canvas_widget_drag, .canvas_widget_context_menu, .canvas_widget_context_menu_request, .canvas_widget_context_press, .canvas_widget_resize, .canvas_widget_change, .window_closed, .automation_provenance, .lifecycle => {},
}
}
fn stop(context: *anyopaque, runtime: *native_sdk.Runtime) anyerror!void {
_ = runtime;
const self: *@This() = @ptrCast(@alignCast(context));
self.deinit();
}
fn handleGpuFrame(self: *@This(), runtime: *native_sdk.Runtime, frame_event: native_sdk.GpuSurfaceFrameEvent) anyerror!void {
if (!std.mem.eql(u8, frame_event.label, canvas_label)) return;
const first_install = !self.canvas_installed;
const scale_changed = self.updatePixelSnapScale(frame_event.scale_factor);
const size_changed = self.updateCanvasSize(componentSurfaceSize(frame_event.size));
if (first_install or scale_changed or size_changed) {
if (first_install) self.setStatusText("Component lab display list presented on the GPU surface.");
try installComponentsCanvasModel(runtime, frame_event.window_id, self.virtual_scroll, self.componentUiState(), self.componentTokens(), self.canvas_size);
_ = try self.presentComponentsCanvas(runtime, frame_event, true);
self.canvas_installed = true;
return;
}
const scrolled = try self.stepComponentVirtualScrollForFrame(runtime, frame_event);
_ = try self.presentComponentsCanvas(runtime, frame_event, frame_event.canvas_frame_full_repaint or scrolled);
const current_frame = try runtime.gpuSurfaceFrame(frame_event.window_id, canvas_label);
try self.reportFrameStatus(runtime, gpuFrameEvent(current_frame));
}
fn handleWidgetPointer(self: *@This(), runtime: *native_sdk.Runtime, pointer_event: native_sdk.runtime.CanvasWidgetPointerEvent) anyerror!void {
if (!std.mem.eql(u8, pointer_event.view_label, canvas_label)) return;
const target = pointer_event.target orelse return;
switch (pointer_event.pointer.phase) {
.move => {
if (target.id == canvas_sidebar_resize_handle_id) {
try self.resizeSidebar(runtime, pointer_event);
return;
}
},
.up => {
if (target.id == canvas_sidebar_resize_handle_id) return;
if (target.id == surface_overlay_backdrop_id and self.surface_overlay != .none) {
self.surface_overlay = .none;
_ = runtime.clearCanvasRenderAnimations(pointer_event.window_id, canvas_label) catch {};
try self.updateComponentsCanvasModel(runtime, pointer_event.window_id);
try self.updateStatus(runtime, pointer_event.window_id, "Surface closed.");
return;
}
if (target.id == environment_select_id or
target.id == canvas_toolbar_theme_id or
target.id == model.themeModeTriggerId(.light) or
target.id == model.themeModeTriggerId(.dark) or
target.id == model.themeModeTriggerId(.high) or
target.id == canvas_toolbar_refresh_id or
environmentOptionIndex(target.id) != null or
target.id == 175 or
target.id == 176 or
target.id == 177 or
target.id == surface_overlay_close_id) return;
if (self.environment_select_open) {
self.environment_select_open = false;
try self.updateComponentsCanvasModel(runtime, pointer_event.window_id);
try self.updateStatus(runtime, pointer_event.window_id, "Environment menu closed.");
return;
}
try self.reportWidgetInteraction(runtime, pointer_event.window_id, "Clicked", target.id);
},
.wheel => {
_ = try self.scrollVirtualWidget(runtime, pointer_event);
},
else => {},
}
}
fn resizeSidebar(self: *@This(), runtime: *native_sdk.Runtime, pointer_event: native_sdk.runtime.CanvasWidgetPointerEvent) anyerror!void {
const next_width = componentSidebarWidthForSize(self.sidebar_width + pointer_event.pointer.delta.dx, self.canvas_size);
if (@abs(next_width - self.sidebar_width) < 0.001) return;
self.sidebar_width = next_width;
try installComponentsCanvasModel(runtime, pointer_event.window_id, self.virtual_scroll, self.componentUiState(), self.componentTokens(), self.canvas_size);
}
fn handleWidgetKeyboard(self: *@This(), runtime: *native_sdk.Runtime, keyboard_event: native_sdk.runtime.CanvasWidgetKeyboardEvent) anyerror!void {
if (!std.mem.eql(u8, keyboard_event.view_label, canvas_label)) return;
if (keyboard_event.keyboard.phase != .key_down) return;
const target = keyboard_event.target orelse return;
const scrolled_id = try self.scrollVirtualWidgetFromKeyboard(runtime, keyboard_event) orelse target.id;
try self.reportWidgetInteraction(runtime, keyboard_event.window_id, "Keyed", scrolled_id);
}
/// The engine's dismissal (Escape, outside click, automation) hands
/// the surface id back so the MODEL closes it — the app clears the
/// open flag and rebuilds, agreeing with the optimistic hide.
fn handleWidgetDismiss(self: *@This(), runtime: *native_sdk.Runtime, dismiss_event: native_sdk.runtime.CanvasWidgetDismissEvent) anyerror!void {
if (!std.mem.eql(u8, dismiss_event.view_label, canvas_label)) return;
if (dismiss_event.id != environment_menu_id) return;
if (!self.environment_select_open) return;
self.environment_select_open = false;
try self.updateComponentsCanvasModel(runtime, dismiss_event.window_id);
try self.updateStatus(runtime, dismiss_event.window_id, "Environment menu closed.");
}
fn reportWidgetInteraction(self: *@This(), runtime: *native_sdk.Runtime, window_id: native_sdk.WindowId, action: []const u8, id: canvas.ObjectId) anyerror!void {
const layout = try runtime.canvasWidgetLayout(window_id, canvas_label);
const node = layout.findById(id) orelse return;
const widget = node.widget;
var status_buffer: [192]u8 = undefined;
const status = switch (widget.kind) {
.checkbox, .radio, .switch_control, .toggle, .toggle_button => try std.fmt.bufPrint(
&status_buffer,
"{s} {s} #{d}: {s}.",
.{ action, @tagName(widget.kind), id, if (widget.state.selected or widget.value >= 0.5) "on" else "off" },
),
.slider, .progress => try std.fmt.bufPrint(
&status_buffer,
"{s} {s} #{d}: value {d:.2}.",
.{ action, @tagName(widget.kind), id, widget.value },
),
.scroll_view, .list, .data_grid, .table => try std.fmt.bufPrint(
&status_buffer,
"{s} {s} #{d}: offset {d}.",
.{ action, @tagName(widget.kind), id, widget.value },
),
.input, .text_field, .search_field, .combobox, .textarea => try std.fmt.bufPrint(
&status_buffer,
"{s} {s} #{d}: {d} bytes.",
.{ action, @tagName(widget.kind), id, widget.text.len },
),
else => try std.fmt.bufPrint(
&status_buffer,
"{s} {s} #{d}{s}.",
.{ action, @tagName(widget.kind), id, if (widget.state.selected) ": selected" else "" },
),
};
try self.updateStatus(runtime, window_id, status);
}
fn scrollVirtualWidget(self: *@This(), runtime: *native_sdk.Runtime, pointer_event: native_sdk.runtime.CanvasWidgetPointerEvent) anyerror!?canvas.ObjectId {
const id = componentVirtualScrollTarget(pointer_event.route) orelse return null;
const layout = try runtime.canvasWidgetLayout(pointer_event.window_id, canvas_label);
const node = layout.findById(id) orelse return null;
if (!node.widget.layout.virtualized) return null;
const viewport = node.frame.inset(node.widget.layout.padding).normalized();
if (viewport.isEmpty()) return null;
const max_offset = @max(0, canvas.virtualWidgetScrollContentExtent(node.widget, viewport.height) - viewport.height);
const current = self.componentVirtualScrollState(id, viewport.height, viewport.height + max_offset) orelse return null;
const next = current.applyWheel(pointer_event.pointer.delta.dy, self.componentTokens().scroll);
if (componentScrollStatesEqual(current, next)) return id;
try self.setComponentVirtualScrollState(id, next);
try self.updateComponentsCanvasModel(runtime, pointer_event.window_id);
return id;
}
fn scrollVirtualWidgetFromKeyboard(self: *@This(), runtime: *native_sdk.Runtime, keyboard_event: native_sdk.runtime.CanvasWidgetKeyboardEvent) anyerror!?canvas.ObjectId {
if (keyboard_event.keyboard.modifiers.hasNavigationModifier()) return null;
const target = keyboard_event.target orelse return null;
const id = componentVirtualScrollTarget(keyboard_event.route) orelse return null;
const layout = try runtime.canvasWidgetLayout(keyboard_event.window_id, canvas_label);
const node = layout.findById(id) orelse return null;
if (!node.widget.layout.virtualized) return null;
const viewport = node.frame.inset(node.widget.layout.padding).normalized();
if (viewport.isEmpty()) return null;
const direct_target = target.id == id;
const max_offset = @max(0, canvas.virtualWidgetScrollContentExtent(node.widget, viewport.height) - viewport.height);
const current = self.componentVirtualScrollValue(id) orelse return null;
const raw_next = if (componentVirtualKeyboardScrollTarget(keyboard_event.keyboard, direct_target)) |scroll_target| switch (scroll_target) {
.start => 0,
.end => max_offset,
} else if (componentVirtualKeyboardScrollDelta(viewport.height, keyboard_event.keyboard, direct_target)) |delta|
std.math.clamp(current + delta, 0, max_offset)
else
return null;
const next = snapComponentVirtualScrollOffset(node.widget, current, raw_next, max_offset);
if (next == current) return id;
try self.setComponentVirtualScrollState(id, .{
.offset = next,
.velocity = 0,
.viewport_extent = viewport.height,
.content_extent = viewport.height + max_offset,
});
try self.updateComponentsCanvasModel(runtime, keyboard_event.window_id);
return id;
}
fn stepComponentVirtualScrollForFrame(self: *@This(), runtime: *native_sdk.Runtime, frame_event: native_sdk.GpuSurfaceFrameEvent) anyerror!bool {
const layout = try runtime.canvasWidgetLayout(frame_event.window_id, canvas_label);
var changed = false;
const ids = [_]canvas.ObjectId{ 120, 130, 150 };
for (ids) |id| {
const node = layout.findById(id) orelse continue;
if (!node.widget.layout.virtualized) continue;
const viewport = node.frame.inset(node.widget.layout.padding).normalized();
if (viewport.isEmpty()) continue;
const content_extent = canvas.virtualWidgetScrollContentExtent(node.widget, viewport.height);
const current = self.componentVirtualScrollState(id, viewport.height, content_extent) orelse continue;
if (!current.needsKineticStep(self.componentTokens().scroll)) {
if (current.velocity != 0) {
var settled = current;
settled.velocity = 0;
try self.setComponentVirtualScrollState(id, settled);
}
continue;
}
const next = current.stepKinetic(componentFrameIntervalMs(frame_event.frame_interval_ns), self.componentTokens().scroll);
if (componentScrollStatesEqual(current, next)) continue;
try self.setComponentVirtualScrollState(id, next);
changed = true;
}
if (changed) try self.updateComponentsCanvasModel(runtime, frame_event.window_id);
return changed;
}
fn refresh(self: *@This(), runtime: *native_sdk.Runtime, command: native_sdk.CommandEvent) anyerror!void {
self.refresh_count += 1;
self.virtual_scroll = .{};
self.environment_select_open = false;
self.surface_overlay = .none;
self.section = .controls;
_ = runtime.clearCanvasRenderAnimations(command.window_id, canvas_label) catch {};
const gpu_frame = try runtime.gpuSurfaceFrame(command.window_id, canvas_label);
_ = self.updateCanvasSize(componentSurfaceSize(gpu_frame.size));
try installComponentsCanvasModel(runtime, command.window_id, self.virtual_scroll, self.componentUiState(), self.componentTokens(), self.canvas_size);
_ = try self.presentComponentsCanvas(runtime, gpuFrameEvent(gpu_frame), true);
var status_buffer: [160]u8 = undefined;
const status = try std.fmt.bufPrint(&status_buffer, "Component lab refreshed from {s}. Count {d}.", .{ @tagName(command.source), self.refresh_count });
try self.updateStatus(runtime, command.window_id, status);
}
fn changeSection(self: *@This(), runtime: *native_sdk.Runtime, command: native_sdk.CommandEvent, section: ComponentSection) anyerror!void {
self.section = section;
self.environment_select_open = false;
self.surface_overlay = .none;
self.virtual_scroll.page = 0;
_ = runtime.clearCanvasRenderAnimations(command.window_id, canvas_label) catch {};
try self.updateComponentsCanvasModel(runtime, command.window_id);
var status_buffer: [96]u8 = undefined;
const status = try std.fmt.bufPrint(&status_buffer, "Showing {s}.", .{componentSectionLabel(section)});
try self.updateStatus(runtime, command.window_id, status);
}
fn toggleEnvironmentSelect(self: *@This(), runtime: *native_sdk.Runtime, command: native_sdk.CommandEvent) anyerror!void {
self.environment_select_open = !self.environment_select_open;
try self.updateComponentsCanvasModel(runtime, command.window_id);
try self.updateStatus(runtime, command.window_id, if (self.environment_select_open) "Environment menu opened." else "Environment menu closed.");
}
fn selectEnvironment(self: *@This(), runtime: *native_sdk.Runtime, command: native_sdk.CommandEvent, index: usize) anyerror!void {
self.environment_index = @min(index, environment_options.len - 1);
self.environment_select_open = false;
try self.updateComponentsCanvasModel(runtime, command.window_id);
try self.updateEnvironmentSelectedStatus(runtime, command.window_id);
}
fn updateEnvironmentSelectedStatus(self: *@This(), runtime: *native_sdk.Runtime, window_id: native_sdk.WindowId) anyerror!void {
var status_buffer: [96]u8 = undefined;
const status = try std.fmt.bufPrint(&status_buffer, "Environment selected: {s}.", .{environmentLabel(self.environment_index)});
try self.updateStatus(runtime, window_id, status);
}
fn openSurfaceOverlay(self: *@This(), runtime: *native_sdk.Runtime, command: native_sdk.CommandEvent, overlay: ComponentSurfaceOverlay) anyerror!void {
self.environment_select_open = false;
self.surface_overlay = overlay;
try self.updateComponentsCanvasModel(runtime, command.window_id);
try self.scheduleSurfaceOverlayAnimation(runtime, command.window_id, overlay);
var status_buffer: [96]u8 = undefined;
const status = try std.fmt.bufPrint(&status_buffer, "{s} surface opened.", .{surfaceOverlayLabel(overlay)});
try self.updateStatus(runtime, command.window_id, status);
}
fn closeSurfaceOverlay(self: *@This(), runtime: *native_sdk.Runtime, command: native_sdk.CommandEvent) anyerror!void {
if (self.surface_overlay == .none) return;
self.surface_overlay = .none;
_ = runtime.clearCanvasRenderAnimations(command.window_id, canvas_label) catch {};
try self.updateComponentsCanvasModel(runtime, command.window_id);
try self.updateStatus(runtime, command.window_id, "Surface closed.");
}
fn scheduleSurfaceOverlayAnimation(self: *@This(), runtime: *native_sdk.Runtime, window_id: native_sdk.WindowId, overlay: ComponentSurfaceOverlay) anyerror!void {
const motion = self.componentTokens().motion;
const start_ns = runtime.canvasRenderAnimationStartNs(window_id, canvas_label) catch |err| switch (err) {
error.WindowNotFound, error.ViewNotFound, error.InvalidViewOptions => return,
else => return err,
};
var animations: [max_surface_overlay_animations]canvas.CanvasRenderAnimation = undefined;
var count: usize = 0;
try canvas.appendBuiltinSurfaceEnterAnimations(surfaceOverlayKind(overlay), .{
.surface_id = surface_overlay_id,
.frame = surfaceOverlayFrameForSidebar(self.canvas_size, overlay, self.sidebar_width),
.motion = motion,
.start_ns = start_ns,
.content = &surface_overlay_content_parts,
}, &animations, &count);
if (count == 0) return;
_ = try runtime.setCanvasRenderAnimations(window_id, canvas_label, animations[0..count]);
}
fn changeTheme(self: *@This(), runtime: *native_sdk.Runtime, command: native_sdk.CommandEvent, mode: ComponentThemeMode) anyerror!void {
self.theme_count += 1;
self.theme_overridden = true;
self.theme_mode = mode;
const gpu_frame = try runtime.gpuSurfaceFrame(command.window_id, canvas_label);
_ = self.updateCanvasSize(componentSurfaceSize(gpu_frame.size));
try installComponentsCanvasModel(runtime, command.window_id, self.virtual_scroll, self.componentUiState(), self.componentTokens(), self.canvas_size);
_ = try self.presentComponentsCanvas(runtime, gpuFrameEvent(gpu_frame), true);
var status_buffer: [160]u8 = undefined;
const status = try std.fmt.bufPrint(
&status_buffer,
"GPU component theme: {s} from {s}. Count {d}.",
.{ self.theme_mode.label(), @tagName(command.source), self.theme_count },
);
try self.updateStatus(runtime, command.window_id, status);
}
fn applySystemAppearance(self: *@This(), runtime: *native_sdk.Runtime, appearance: native_sdk.Appearance) anyerror!void {
const motion_changed = self.reduce_motion != appearance.reduce_motion;
const contrast_changed = self.high_contrast != appearance.high_contrast;
self.reduce_motion = appearance.reduce_motion;
self.high_contrast = appearance.high_contrast;
const next = componentThemeModeForAppearance(appearance);
const theme_changed = !self.theme_overridden and self.theme_mode != next;
if (theme_changed) self.theme_mode = next;
if (!theme_changed and !motion_changed and !contrast_changed) return;
if (!self.canvas_installed) return;
const gpu_frame = runtime.gpuSurfaceFrame(1, canvas_label) catch |err| switch (err) {
error.WindowNotFound, error.ViewNotFound, error.InvalidViewOptions => return,
else => return err,
};
_ = self.updateCanvasSize(componentSurfaceSize(gpu_frame.size));
try installComponentsCanvasModel(runtime, 1, self.virtual_scroll, self.componentUiState(), self.componentTokens(), self.canvas_size);
_ = try self.presentComponentsCanvas(runtime, gpuFrameEvent(gpu_frame), true);
var status_buffer: [160]u8 = undefined;
const status = try std.fmt.bufPrint(&status_buffer, "GPU component theme: {s} from system appearance.", .{self.theme_mode.label()});
try self.updateStatus(runtime, 1, status);
}
fn presentComponentsCanvas(self: *@This(), runtime: *native_sdk.Runtime, frame_event: native_sdk.GpuSurfaceFrameEvent, full_repaint: bool) anyerror!void {
const surface_size = componentSurfaceSize(frame_event.size);
const scale_factor = if (frame_event.scale_factor > 0) frame_event.scale_factor else 1;
const present_scale = referencePresentScale(scale_factor);
const packet = runtime.presentNextCanvasGpuPacketWithScale(
frame_event.window_id,
canvas_label,
.{
.frame_index = frame_event.frame_index,
.timestamp_ns = frame_event.timestamp_ns,
.surface_size = surface_size,
.scale = scale_factor,
.full_repaint = full_repaint,
.image_resources = &preview_images,
},
self.frameStorage(),
self.componentTokens().colors.background,
&self.gpu_commands,
&self.packet_json,
present_scale,
) catch |err| switch (err) {
error.UnsupportedService => {
try self.presentComponentsCanvasPixels(runtime, frame_event.window_id, surface_size, scale_factor, frame_event.frame_index, frame_event.timestamp_ns, full_repaint);
return;
},
else => return err,
};
if (!packet.fullyRepresentable()) return error.UnsupportedCommand;
}
fn presentComponentsCanvasPixels(
self: *@This(),
runtime: *native_sdk.Runtime,
window_id: native_sdk.WindowId,
surface_size: geometry.SizeF,
scale_factor: f32,
frame_index: u64,
timestamp_ns: u64,
full_repaint: bool,
) anyerror!void {
const present_scale = referencePresentScale(scale_factor);
try self.ensurePixelBuffers(surface_size, present_scale);
_ = try runtime.presentNextCanvasFrame(
window_id,
canvas_label,
.{
.frame_index = frame_index,
.timestamp_ns = timestamp_ns,
.surface_size = surface_size,
.scale = scale_factor,
.full_repaint = full_repaint,
.image_resources = &preview_images,
},
self.frameStorage(),
&self.gpu_commands,
&self.packet_json,
self.pixels.?,
self.scratch.?,
self.componentTokens().colors.background,
present_scale,
);
}
fn referencePresentScale(scale_factor: f32) f32 {
const normalized = if (scale_factor > 0) scale_factor else 1;
return normalized;
}
pub fn setStatusText(self: *@This(), text: []const u8) void {
const len = @min(text.len, self.status_text_storage.len);
@memcpy(self.status_text_storage[0..len], text[0..len]);
self.status_text_len = len;
}
fn statusText(self: *const @This()) []const u8 {
if (self.status_text_len == 0) return initial_component_status_text;
return self.status_text_storage[0..self.status_text_len];
}
fn updateStatus(self: *@This(), runtime: *native_sdk.Runtime, window_id: native_sdk.WindowId, text: []const u8) anyerror!void {
self.setStatusText(text);
if (self.canvas_installed) try self.updateComponentsCanvasModel(runtime, window_id);
}
fn reportFrameStatus(self: *@This(), runtime: *native_sdk.Runtime, frame_event: native_sdk.GpuSurfaceFrameEvent) anyerror!void {
if (self.reported_planned_frame or frame_event.canvas_command_count == 0) return;
self.reported_planned_frame = true;
var status_buffer: [160]u8 = undefined;
const status = try componentFrameStatus(&status_buffer, frame_event);
try self.updateStatus(runtime, frame_event.window_id, status);
}
fn frameStorage(self: *@This()) canvas.CanvasFrameStorage {
return .{
.render_commands = &self.render_commands,
.render_batches = &self.render_batches,
.images = &self.images,
.image_cache_entries = &self.image_cache_entries,
.image_cache_actions = &self.image_cache_actions,
.pipeline_cache_entries = &self.pipeline_cache_entries,
.pipeline_cache_actions = &self.pipeline_cache_actions,
.layers = &self.layers,
.layer_cache_entries = &self.layer_cache_entries,
.layer_cache_actions = &self.layer_cache_actions,
.resources = &self.resources,
.resource_cache_entries = &self.cache_entries,
.resource_cache_actions = &self.cache_actions,
.visual_effects = &self.visual_effects,
.visual_effect_cache_entries = &self.visual_effect_cache_entries,
.visual_effect_cache_actions = &self.visual_effect_cache_actions,
.glyph_atlas_entries = &self.glyphs,
.glyph_atlas_cache_entries = &self.glyph_cache_entries,
.glyph_atlas_cache_actions = &self.glyph_cache_actions,
.text_layout_plans = &self.text_layout_plans,
.text_layout_lines = &self.text_layout_lines,
.text_layout_cache_entries = &self.text_layout_cache_entries,
.text_layout_cache_actions = &self.text_layout_cache_actions,
.changes = &self.changes,
};
}
pub fn updateComponentsCanvasModel(self: *@This(), runtime: *native_sdk.Runtime, window_id: native_sdk.WindowId) anyerror!void {
var nodes: [max_component_widgets]canvas.WidgetLayoutNode = undefined;
// Layout under the SAME tokens the display list is emitted
// with: under geometry pixel snapping, label-hugging intrinsic
// widths ceil to the snap grid, and only a token-matched layout
// keeps the renderer's edge snapping from shaving those widths
// below their labels (eliding text that fits unsnapped).
const layout = try buildComponentsWidgetLayoutWithStateSizeAndTokens(&nodes, self.virtual_scroll, self.componentUiState(), self.canvas_size, self.componentTokens());
_ = try runtime.setCanvasWidgetLayout(window_id, canvas_label, layout);
_ = try runtime.emitCanvasWidgetDisplayListWithStoredTokensAndChrome(window_id, canvas_label, .{
.prefix_command_count = component_chrome_prefix_commands,
.suffix_command_count = component_chrome_suffix_commands,
});
}
pub fn componentUiState(self: *const @This()) ComponentUiState {
return .{
.theme_mode = self.theme_mode,
.environment_select_open = self.environment_select_open,
.environment_index = self.environment_index,
.surface_overlay = self.surface_overlay,
.section = self.section,
.sidebar_width = self.sidebar_width,
.status_text = self.statusText(),
};
}
fn componentTokens(self: *const @This()) canvas.DesignTokens {
return componentTokensForScaleMotionAndContrast(self.theme_mode, self.pixel_snap_scale, self.reduce_motion, self.high_contrast);
}
fn updatePixelSnapScale(self: *@This(), scale_factor: f32) bool {
const next = normalizedPixelSnapScale(scale_factor);
if (@abs(self.pixel_snap_scale - next) < 0.001) return false;
self.pixel_snap_scale = next;
return true;
}
fn updateCanvasSize(self: *@This(), size: geometry.SizeF) bool {
const next_sidebar_width = componentSidebarWidthForSize(self.sidebar_width, size);
const sidebar_changed = @abs(next_sidebar_width - self.sidebar_width) >= 0.001;
if (sidebar_changed) self.sidebar_width = next_sidebar_width;
if (componentSizesEqual(self.canvas_size, size)) return sidebar_changed;
self.canvas_size = size;
return true;
}
fn componentVirtualScrollValue(self: *@This(), id: canvas.ObjectId) ?f32 {
return switch (id) {
120 => self.virtual_scroll.nav,
130 => self.virtual_scroll.behavior,
150 => self.virtual_scroll.data,
content_scroll_id => self.virtual_scroll.page,
else => null,
};
}
fn componentVirtualScrollVelocity(self: *@This(), id: canvas.ObjectId) ?f32 {
return switch (id) {
120 => self.virtual_scroll.nav_velocity,
130 => self.virtual_scroll.behavior_velocity,
150 => self.virtual_scroll.data_velocity,
content_scroll_id => self.virtual_scroll.page_velocity,
else => null,
};
}
fn componentVirtualScrollState(self: *@This(), id: canvas.ObjectId, viewport_extent: f32, content_extent: f32) ?canvas.ScrollState {
const offset = self.componentVirtualScrollValue(id) orelse return null;
const velocity = self.componentVirtualScrollVelocity(id) orelse return null;
return .{
.offset = offset,
.velocity = velocity,
.viewport_extent = viewport_extent,
.content_extent = @max(viewport_extent, content_extent),
};
}
fn setComponentVirtualScrollValue(self: *@This(), id: canvas.ObjectId, value: f32) anyerror!void {
switch (id) {
120 => {
self.virtual_scroll.nav = value;
self.virtual_scroll.nav_velocity = 0;
},
130 => {
self.virtual_scroll.behavior = value;
self.virtual_scroll.behavior_velocity = 0;
},
150 => {
self.virtual_scroll.data = value;
self.virtual_scroll.data_velocity = 0;
},
content_scroll_id => {
self.virtual_scroll.page = value;
self.virtual_scroll.page_velocity = 0;
},
else => return error.InvalidCommand,
}
}
fn setComponentVirtualScrollState(self: *@This(), id: canvas.ObjectId, state: canvas.ScrollState) anyerror!void {
switch (id) {
120 => {
self.virtual_scroll.nav = state.offset;
self.virtual_scroll.nav_velocity = state.velocity;
},
130 => {
self.virtual_scroll.behavior = state.offset;
self.virtual_scroll.behavior_velocity = state.velocity;
},
150 => {
self.virtual_scroll.data = state.offset;
self.virtual_scroll.data_velocity = state.velocity;
},
content_scroll_id => {
self.virtual_scroll.page = state.offset;
self.virtual_scroll.page_velocity = state.velocity;
},
else => return error.InvalidCommand,
}
}
fn ensurePixelBuffers(self: *@This(), surface_size: geometry.SizeF, scale_factor: f32) anyerror!void {
const pixel_size = try native_sdk.runtime.canvasSurfacePixelSize(surface_size, scale_factor);
if (self.pixels == null or self.pixels.?.len < pixel_size.byte_len) {
if (self.pixels) |pixels| std.heap.page_allocator.free(pixels);
self.pixels = try std.heap.page_allocator.alloc(u8, pixel_size.byte_len);
}
if (self.scratch == null or self.scratch.?.len < pixel_size.byte_len) {
if (self.scratch) |scratch| std.heap.page_allocator.free(scratch);
self.scratch = try std.heap.page_allocator.alloc(u8, pixel_size.byte_len);
}
}
};
+31
View File
@@ -0,0 +1,31 @@
const std = @import("std");
const runner = @import("runner");
const native_sdk = @import("native_sdk");
pub const panic = std.debug.FullPanic(native_sdk.debug.capturePanic);
const geometry = native_sdk.geometry;
const model = @import("model.zig");
const component_app = @import("app.zig");
const GpuComponentsApp = component_app.GpuComponentsApp;
pub fn main(init: std.process.Init) !void {
var app = GpuComponentsApp{};
try runner.runWithOptions(app.app(), .{
.app_name = "gpu-components",
.window_title = "Native SDK GPU Components",
.bundle_id = "dev.native_sdk.gpu_components",
.default_frame = geometry.RectF.init(0, 0, model.window_width, model.window_height),
.restore_state = false,
.js_window_api = false,
.security = .{
.permissions = &component_app.app_permissions,
.navigation = .{ .allowed_origins = &.{ "zero://inline", "zero://app" } },
},
}, init);
}
test {
_ = @import("tests.zig");
}
+293
View File
@@ -0,0 +1,293 @@
const std = @import("std");
pub const native_sdk = @import("native_sdk");
pub const canvas = native_sdk.canvas;
pub const geometry = native_sdk.geometry;
pub const window_width: f32 = 1180;
pub const window_height: f32 = 760;
pub const toolbar_height: f32 = 52;
pub const canvas_sidebar_width: f32 = 208;
pub const canvas_sidebar_min_width: f32 = 168;
pub const canvas_sidebar_max_width: f32 = 360;
pub const canvas_sidebar_min_content_width: f32 = 420;
pub const canvas_sidebar_resize_handle_width: f32 = 14;
pub const canvas_sidebar_resize_line_width: f32 = 1;
pub const statusbar_height: f32 = 32;
pub const canvas_width: f32 = window_width;
pub const canvas_height: f32 = window_height;
pub const canvas_content_y: f32 = toolbar_height;
pub const canvas_content_height: f32 = canvas_height - toolbar_height - statusbar_height;
pub const default_canvas_size = geometry.SizeF.init(canvas_width, canvas_height);
pub const max_component_pipelines: usize = 8;
pub const max_component_commands: usize = native_sdk.runtime.max_canvas_commands_per_view;
pub const max_component_glyphs: usize = native_sdk.runtime.max_canvas_glyphs_per_view;
pub const max_component_widgets: usize = native_sdk.runtime.max_canvas_widget_nodes_per_view;
pub const component_chrome_prefix_commands: usize = 3;
pub const component_chrome_suffix_commands: usize = 0;
pub const catalog_grid_columns: usize = 3;
pub const catalog_card_width: f32 = 256;
pub const catalog_card_height: f32 = 132;
pub const catalog_card_gap_x: f32 = 22;
pub const catalog_card_gap_y: f32 = 18;
pub const catalog_preview_y: f32 = 40;
pub const catalog_preview_width: f32 = catalog_card_width - 32;
pub const refresh_command = "components.refresh";
pub const theme_mode_commands = [_][]const u8{
"components.theme.light",
"components.theme.dark",
"components.theme.high",
};
pub fn themeModeCommand(mode: ComponentThemeMode) []const u8 {
return theme_mode_commands[@intFromEnum(mode)];
}
pub fn themeModeFromCommand(name: []const u8) ?ComponentThemeMode {
for (theme_mode_commands, 0..) |command, index| {
if (std.mem.eql(u8, name, command)) return @enumFromInt(index);
}
return null;
}
pub const environment_toggle_command = "components.environment.toggle";
pub const surface_dialog_command = "components.surface.dialog";
pub const surface_drawer_command = "components.surface.drawer";
pub const surface_sheet_command = "components.surface.sheet";
pub const surface_close_command = "components.surface.close";
pub const environment_option_commands = [_][]const u8{
"components.environment.production",
"components.environment.preview",
"components.environment.staging",
};
pub const canvas_label = "components-canvas";
pub const primary_button_fill_id: canvas.ObjectId = 104 * 16 + 1;
pub const project_static_text_id: canvas.ObjectId = 111 * 16 + 3;
pub const project_text_id: canvas.ObjectId = 111 * 16 + 4;
pub const project_selection_id: canvas.ObjectId = 111 * 16 + 3;
pub const project_composition_id: canvas.ObjectId = 111 * 16 + 5;
pub const search_text_id: canvas.ObjectId = 112 * 16 + 9;
pub const search_selection_id: canvas.ObjectId = 112 * 16 + 8;
pub const search_composition_id: canvas.ObjectId = 112 * 16 + 10;
pub const message_text_id: canvas.ObjectId = 171 * 16 + 4;
pub const scroll_track_id: canvas.ObjectId = 130 * 16 + 2;
pub const scroll_thumb_id: canvas.ObjectId = 130 * 16 + 3;
pub const menu_item_text_id: canvas.ObjectId = 142 * 16 + 3;
pub const data_cell_text_id: canvas.ObjectId = 156 * 16 + 4;
pub const environment_select_id: canvas.ObjectId = 172;
pub const environment_select_text_id: canvas.ObjectId = environment_select_id * 16 + 3;
pub const environment_menu_id: canvas.ObjectId = 216;
pub const environment_stack_id: canvas.ObjectId = 217;
pub const environment_option_base_id: canvas.ObjectId = 21601;
pub const content_scroll_id: canvas.ObjectId = 90;
pub const canvas_sidebar_id: canvas.ObjectId = 92;
pub const canvas_sidebar_title_id: canvas.ObjectId = 93;
pub const section_nav_base_id: canvas.ObjectId = 94;
pub const canvas_background_id: canvas.ObjectId = 79;
pub const canvas_toolbar_id: canvas.ObjectId = 80;
pub const canvas_toolbar_title_id: canvas.ObjectId = 81;
pub const canvas_toolbar_theme_id: canvas.ObjectId = 82;
/// The three theme triggers inside the toolbar tab strip (85..87 =
/// light, dark, high).
pub fn themeModeTriggerId(mode: ComponentThemeMode) canvas.ObjectId {
return 85 + @as(canvas.ObjectId, @intFromEnum(mode));
}
pub const canvas_toolbar_refresh_id: canvas.ObjectId = 83;
pub const canvas_toolbar_separator_id: canvas.ObjectId = 84;
pub const canvas_sidebar_resize_line_id: canvas.ObjectId = 88;
pub const canvas_sidebar_resize_handle_id: canvas.ObjectId = 99;
pub const canvas_status_text_id: canvas.ObjectId = 261;
pub const canvas_status_separator_id: canvas.ObjectId = canvas_status_text_id * 16 + 2;
pub const surface_overlay_backdrop_id: canvas.ObjectId = 222;
pub const surface_overlay_id: canvas.ObjectId = 223;
pub const surface_overlay_title_id: canvas.ObjectId = 224;
pub const surface_overlay_body_id: canvas.ObjectId = 225;
pub const surface_overlay_close_id: canvas.ObjectId = 226;
pub const surface_overlay_content_parts = [_]canvas.WidgetCommandPart{
.{ .widget_id = surface_overlay_title_id, .slot = 1 },
.{ .widget_id = surface_overlay_body_id, .slot = 1 },
.{ .widget_id = surface_overlay_close_id, .slot = 1 },
.{ .widget_id = surface_overlay_close_id, .slot = 2 },
.{ .widget_id = surface_overlay_close_id, .slot = 4 },
};
pub const surface_backdrop_layer: i32 = 300;
pub const surface_overlay_layer: i32 = 301;
pub const max_surface_overlay_animations: usize = 12;
pub const popover_blur_id: canvas.ObjectId = 140 * 16 + 12;
pub const preview_image_id: canvas.ImageId = 42;
pub const environment_options = [_][]const u8{ "Production", "Preview", "Staging" };
pub const initial_component_status_text = "Component lab waiting for the first GPU frame.";
pub const max_component_status_text: usize = 192;
pub const section_nav_commands = [_][]const u8{
"components.section.controls",
"components.section.inputs",
"components.section.data",
"components.section.components",
"components.section.surfaces",
};
pub const ComponentVirtualScroll = struct {
page: f32 = 0,
page_velocity: f32 = 0,
nav: f32 = 0,
nav_velocity: f32 = 0,
behavior: f32 = 28,
behavior_velocity: f32 = 0,
data: f32 = 28,
data_velocity: f32 = 0,
};
pub const ComponentUiState = struct {
theme_mode: ComponentThemeMode = .light,
environment_select_open: bool = false,
environment_index: usize = 0,
surface_overlay: ComponentSurfaceOverlay = .none,
section: ComponentSection = .controls,
sidebar_width: f32 = canvas_sidebar_width,
status_text: []const u8 = initial_component_status_text,
};
pub const ComponentSurfaceOverlay = enum {
none,
dialog,
drawer,
sheet,
};
pub const ComponentSection = enum(u8) {
controls,
inputs,
data,
components,
surfaces,
};
pub const ComponentThemeMode = enum {
light,
dark,
high,
pub fn next(self: ComponentThemeMode) ComponentThemeMode {
return switch (self) {
.light => .dark,
.dark => .high,
.high => .light,
};
}
pub fn label(self: ComponentThemeMode) []const u8 {
return switch (self) {
.light => "Light",
.dark => "Dark",
.high => "High contrast",
};
}
};
pub fn environmentLabel(index: usize) []const u8 {
return environment_options[@min(index, environment_options.len - 1)];
}
pub fn environmentOptionId(index: usize) canvas.ObjectId {
return environment_option_base_id + @as(canvas.ObjectId, @intCast(index));
}
pub fn environmentOptionIndex(id: canvas.ObjectId) ?usize {
if (id < environment_option_base_id) return null;
const index = id - environment_option_base_id;
if (index >= environment_options.len) return null;
return @intCast(index);
}
pub fn environmentNextIndex(index: usize) usize {
return (@min(index, environment_options.len - 1) + 1) % environment_options.len;
}
pub fn environmentPreviousIndex(index: usize) usize {
const current = @min(index, environment_options.len - 1);
return if (current == 0) environment_options.len - 1 else current - 1;
}
pub fn environmentCommandIndex(command_name: []const u8) ?usize {
for (environment_option_commands, 0..) |option_command, index| {
if (std.mem.eql(u8, command_name, option_command)) return index;
}
return null;
}
pub fn componentSectionLabel(section: ComponentSection) []const u8 {
return switch (section) {
.controls => "Controls",
.inputs => "Inputs",
.data => "Data",
.components => "Components",
.surfaces => "Surfaces",
};
}
pub fn componentSectionCommand(section: ComponentSection) []const u8 {
return section_nav_commands[@intFromEnum(section)];
}
pub fn componentSectionFromCommand(command_name: []const u8) ?ComponentSection {
for (section_nav_commands, 0..) |section_command, index| {
if (std.mem.eql(u8, command_name, section_command)) return @enumFromInt(index);
}
return null;
}
pub fn componentSectionNavId(section: ComponentSection) canvas.ObjectId {
return section_nav_base_id + @as(canvas.ObjectId, @intFromEnum(section));
}
pub fn surfaceOverlayLabel(overlay: ComponentSurfaceOverlay) []const u8 {
return switch (overlay) {
.dialog => "Confirm deployment",
.drawer => "Project settings",
.sheet => "Command palette",
.none => "Surface",
};
}
pub fn surfaceOverlayBody(overlay: ComponentSurfaceOverlay) []const u8 {
return switch (overlay) {
.dialog => "Production rollout is ready for review.",
.drawer => "Team notifications are synced.",
.sheet => "Recent actions are ready.",
.none => "",
};
}
pub fn color(r: u8, g: u8, b: u8) canvas.Color {
return canvas.Color.rgb8(r, g, b);
}
pub fn rgba(r: u8, g: u8, b: u8, a: u8) canvas.Color {
return canvas.Color.rgba8(r, g, b, a);
}
pub fn rect(x: f32, y: f32, width: f32, height: f32) geometry.RectF {
return geometry.RectF.init(x, y, width, height);
}
pub fn contentRect(x: f32, y: f32, width: f32, height: f32) geometry.RectF {
return contentRectForSidebar(canvas_sidebar_width, x, y, width, height);
}
pub fn contentRectForSidebar(sidebar_width: f32, x: f32, y: f32, width: f32, height: f32) geometry.RectF {
return rect(sidebar_width + x, canvas_content_y + y, width, height);
}
pub fn sidebarResizeHandleFrame(sidebar_width: f32, surface_height: f32) geometry.RectF {
return rect(sidebar_width - canvas_sidebar_resize_handle_width * 0.5, canvas_content_y, canvas_sidebar_resize_handle_width, @max(1, surface_height));
}
pub fn sidebarResizeLineFrame(sidebar_width: f32, surface_height: f32) geometry.RectF {
return rect(sidebar_width - canvas_sidebar_resize_line_width * 0.5, canvas_content_y, canvas_sidebar_resize_line_width, @max(1, surface_height));
}
pub fn componentCommandPartId(id: canvas.ObjectId, slot: canvas.ObjectId) canvas.ObjectId {
return canvas.widgetCommandPartId(.{ .widget_id = id, .slot = slot });
}
pub fn pt(x: f32, y: f32) geometry.PointF {
return geometry.PointF.init(x, y);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,282 @@
const std = @import("std");
const native_sdk = @import("native_sdk");
const model = @import("model.zig");
const scene = @import("scene.zig");
const canvas = native_sdk.canvas;
const canvas_width = model.window_width;
const geometry = native_sdk.geometry;
const canvas_label = model.canvas_label;
const canvas_sidebar_width = model.canvas_sidebar_width;
const canvas_content_y = model.canvas_content_y;
const canvas_content_height = model.canvas_content_height;
const content_scroll_id = model.content_scroll_id;
const canvas_status_text_id = model.canvas_status_text_id;
const componentCommandPartId = model.componentCommandPartId;
const rect = model.rect;
const componentFrameStatus = scene.componentFrameStatus;
pub fn componentSnapshotWidget(snapshot: native_sdk.automation.snapshot.Input, id: u64) ?native_sdk.automation.snapshot.Widget {
for (snapshot.widgets) |widget| {
if (widget.id == id and std.mem.eql(u8, widget.view_label, canvas_label)) return widget;
}
return null;
}
pub fn componentStatusText(runtime: *const native_sdk.Runtime) ![]const u8 {
const layout = try runtime.canvasWidgetLayout(1, canvas_label);
const node = layout.findById(canvas_status_text_id) orelse return error.TestUnexpectedResult;
return node.widget.text;
}
pub fn expectComponentStatusContains(runtime: *const native_sdk.Runtime, text: []const u8) !void {
try std.testing.expect(std.mem.indexOf(u8, try componentStatusText(runtime), text) != null);
}
pub fn resetComponentDirty(runtime: *native_sdk.Runtime) void {
runtime.invalidated = false;
runtime.dirty_region_count = 0;
}
pub fn componentWidgetCenter(runtime: *const native_sdk.Runtime, id: canvas.ObjectId) !geometry.PointF {
const layout = try runtime.canvasWidgetLayout(1, canvas_label);
const node = layout.findById(id) orelse return error.TestUnexpectedResult;
return node.frame.center();
}
pub fn dispatchComponentPointerClick(runtime: *native_sdk.Runtime, app: native_sdk.App, id: canvas.ObjectId) !void {
try dispatchComponentPointerClickAtTimestamp(runtime, app, id, 0);
}
pub fn dispatchComponentPointerClickAtTimestamp(runtime: *native_sdk.Runtime, app: native_sdk.App, id: canvas.ObjectId, timestamp_ns: u64) !void {
const point = try componentWidgetCenter(runtime, id);
try runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{
.window_id = 1,
.label = canvas_label,
.kind = .pointer_down,
.timestamp_ns = timestamp_ns,
.x = point.x,
.y = point.y,
.button = 0,
} });
try runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{
.window_id = 1,
.label = canvas_label,
.kind = .pointer_up,
.timestamp_ns = timestamp_ns,
.x = point.x,
.y = point.y,
.button = 0,
} });
}
pub fn dispatchComponentPointerWheel(runtime: *native_sdk.Runtime, app: native_sdk.App, id: canvas.ObjectId, delta_y: f32) !void {
const point = try componentWidgetCenter(runtime, id);
try runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{
.window_id = 1,
.label = canvas_label,
.kind = .scroll,
.x = point.x,
.y = point.y,
.delta_y = delta_y,
} });
}
pub fn dispatchComponentPointerDrag(runtime: *native_sdk.Runtime, app: native_sdk.App, id: canvas.ObjectId, start_ratio: f32, end_ratio: f32) !void {
const layout = try runtime.canvasWidgetLayout(1, canvas_label);
const node = layout.findById(id) orelse return error.TestUnexpectedResult;
const start = geometry.PointF.init(node.frame.x + node.frame.width * start_ratio, node.frame.center().y);
const end = geometry.PointF.init(node.frame.x + node.frame.width * end_ratio, node.frame.center().y);
try dispatchComponentPointerDragPoints(runtime, app, start, end);
}
pub fn dispatchComponentPointerDragByDelta(runtime: *native_sdk.Runtime, app: native_sdk.App, id: canvas.ObjectId, delta_x: f32) !void {
const point = try componentWidgetCenter(runtime, id);
try dispatchComponentPointerDragPoints(runtime, app, point, geometry.PointF.init(point.x + delta_x, point.y));
}
pub fn dispatchComponentPointerDragPoints(runtime: *native_sdk.Runtime, app: native_sdk.App, start: geometry.PointF, end: geometry.PointF) !void {
try runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{
.window_id = 1,
.label = canvas_label,
.kind = .pointer_down,
.x = start.x,
.y = start.y,
.button = 0,
} });
try runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{
.window_id = 1,
.label = canvas_label,
.kind = .pointer_drag,
.x = end.x,
.y = end.y,
.delta_x = end.x - start.x,
.delta_y = end.y - start.y,
.button = 0,
} });
try runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{
.window_id = 1,
.label = canvas_label,
.kind = .pointer_up,
.x = end.x,
.y = end.y,
.button = 0,
} });
}
pub fn expectComponentTextCommand(display_list: canvas.DisplayList, id: canvas.ObjectId, text: []const u8) !void {
const command_ref = display_list.findCommandById(id) orelse return error.TestUnexpectedResult;
switch (command_ref.command) {
.draw_text => |draw| try std.testing.expectEqualStrings(text, draw.text),
else => return error.TestUnexpectedResult,
}
}
pub fn expectComponentFillRoundedRectColor(display_list: canvas.DisplayList, id: canvas.ObjectId, expected: canvas.Color) !void {
const command_ref = display_list.findCommandById(id) orelse return error.TestUnexpectedResult;
switch (command_ref.command) {
.fill_rounded_rect => |fill| switch (fill.fill) {
.color => |actual| try std.testing.expectEqualDeep(expected, actual),
else => return error.TestUnexpectedResult,
},
else => return error.TestUnexpectedResult,
}
}
pub fn expectSemanticRole(semantics: []const canvas.WidgetSemanticsNode, id: canvas.ObjectId, role: canvas.WidgetRole) !void {
const semantic = expectSemantic(semantics, id);
try std.testing.expectEqual(role, semantic.role);
}
pub fn expectComponentWidgetFrame(layout: canvas.WidgetLayoutTree, id: canvas.ObjectId, expected: geometry.RectF) !void {
const node = layout.findById(id) orelse return error.TestUnexpectedResult;
try expectComponentRect(node.frame, expected);
}
pub fn referenceSurfaceSignature(pixels: []const u8) u64 {
var hash: u64 = 14695981039346656037;
for (pixels) |byte| {
hash = (hash ^ byte) *% 1099511628211;
}
return hash;
}
pub fn expectSemantic(semantics: []const canvas.WidgetSemanticsNode, id: canvas.ObjectId) canvas.WidgetSemanticsNode {
for (semantics) |semantic| {
if (semantic.id == id) return semantic;
}
@panic("missing semantic node");
}
pub fn expectComponentRect(actual: geometry.RectF, expected: geometry.RectF) !void {
try std.testing.expectApproxEqAbs(expected.x, actual.x, 0.001);
try std.testing.expectApproxEqAbs(expected.y, actual.y, 0.001);
try std.testing.expectApproxEqAbs(expected.width, actual.width, 0.001);
try std.testing.expectApproxEqAbs(expected.height, actual.height, 0.001);
}
pub fn expectNoContentScrollContainerChrome(display_list: canvas.DisplayList) !void {
const clip_ref = display_list.findCommandById(componentCommandPartId(content_scroll_id, 1)) orelse return error.TestUnexpectedResult;
if (clip_ref.command != .push_clip) return error.TestUnexpectedResult;
try std.testing.expect(display_list.findCommandById(componentCommandPartId(content_scroll_id, 4)) == null);
const content_frame = rect(canvas_sidebar_width, canvas_content_y, canvas_width - canvas_sidebar_width, canvas_content_height);
for (display_list.commands) |command| {
switch (command) {
.fill_rounded_rect => |fill| if (rectLooksLikeMainContentContainer(fill.rect, content_frame)) return error.TestUnexpectedResult,
.stroke_rect => |stroke| if (rectLooksLikeMainContentContainer(stroke.rect, content_frame)) return error.TestUnexpectedResult,
.shadow => |shadow| if (rectLooksLikeMainContentContainer(shadow.rect, content_frame)) return error.TestUnexpectedResult,
else => {},
}
}
}
pub fn expectVisiblePixel(pixel: [4]u8) !void {
try std.testing.expect(pixel[3] > 0);
try std.testing.expect(pixel[0] != 0 or pixel[1] != 0 or pixel[2] != 0);
}
pub fn expectNoSurfaceAnimation(animations: []const canvas.CanvasRenderAnimation, id: canvas.ObjectId) !void {
for (animations) |animation| {
if (animation.id == id) return error.TestUnexpectedResult;
}
}
pub fn expectComponentWidgetIndex(layout: canvas.WidgetLayoutTree, id: canvas.ObjectId) !usize {
for (layout.nodes, 0..) |node, index| {
if (node.widget.id == id) return index;
}
return error.TestUnexpectedResult;
}
pub fn expectComponentRoundedRectFrame(display_list: canvas.DisplayList, id: canvas.ObjectId, expected: geometry.RectF) !void {
const command_ref = display_list.findCommandById(id) orelse return error.TestUnexpectedResult;
switch (command_ref.command) {
.fill_rounded_rect => |rounded| try expectComponentRect(rounded.rect, expected),
else => return error.TestUnexpectedResult,
}
}
pub fn expectComponentFillRectFrame(display_list: canvas.DisplayList, id: canvas.ObjectId, expected: geometry.RectF) !void {
const command_ref = display_list.findCommandById(id) orelse return error.TestUnexpectedResult;
switch (command_ref.command) {
.fill_rect => |fill| try expectComponentRect(fill.rect, expected),
else => return error.TestUnexpectedResult,
}
}
pub fn expectComponentWidgetsDoNotOverlap(layout: canvas.WidgetLayoutTree, a_id: canvas.ObjectId, b_id: canvas.ObjectId) !void {
const a = layout.findById(a_id) orelse return error.TestUnexpectedResult;
const b = layout.findById(b_id) orelse return error.TestUnexpectedResult;
try std.testing.expect(geometry.RectF.intersection(a.frame.normalized(), b.frame.normalized()).isEmpty());
}
pub fn rectLooksLikeMainContentContainer(actual: geometry.RectF, content_frame: geometry.RectF) bool {
const normalized = actual.normalized();
if (!content_frame.containsRect(normalized)) return false;
return normalized.width >= content_frame.width - 160 and normalized.height >= content_frame.height - 160;
}
pub fn expectNoSurfaceChrome(display_list: canvas.DisplayList, id: canvas.ObjectId) !void {
for (display_list.commands) |command| {
const command_id = command.objectId() orelse continue;
if (command_id < id * 16 or command_id >= (id + 1) * 16) continue;
switch (command) {
.fill_rect, .fill_rounded_rect, .stroke_rect, .shadow => return error.TestUnexpectedResult,
else => {},
}
}
}
pub fn expectSurfaceTransformAnimation(animations: []const canvas.CanvasRenderAnimation, id: canvas.ObjectId, tx: f32, ty: f32) !void {
for (animations) |animation| {
if (animation.id != id) continue;
try std.testing.expectEqualDeep(canvas.Affine.identity(), animation.to_transform.?);
try std.testing.expectApproxEqAbs(tx, animation.from_transform.?.tx, 0.001);
try std.testing.expectApproxEqAbs(ty, animation.from_transform.?.ty, 0.001);
return;
}
return error.TestUnexpectedResult;
}
pub fn expectSurfaceOpacityAnimation(animations: []const canvas.CanvasRenderAnimation, id: canvas.ObjectId) !void {
for (animations) |animation| {
if (animation.id != id) continue;
try std.testing.expectEqual(@as(f32, 0), animation.from_opacity.?);
try std.testing.expectEqual(@as(f32, 1), animation.to_opacity.?);
try std.testing.expect(animation.from_transform == null);
try std.testing.expect(animation.to_transform == null);
return;
}
return error.TestUnexpectedResult;
}
pub fn expectSurfaceAnimationStart(animations: []const canvas.CanvasRenderAnimation, id: canvas.ObjectId, start_ns: u64) !void {
for (animations) |animation| {
if (animation.id != id) continue;
try std.testing.expectEqual(start_ns, animation.start_ns);
return;
}
return error.TestUnexpectedResult;
}
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
# Native SDK gpu-dashboard example
This example combines native app chrome, a `gpu_surface`, and a retained canvas display list:
- Native toolbar, sidebar, and statusbar controls.
- A GPU surface registered with explicit presentation settings and a dashboard display list.
- Retained widget semantics for dashboard lists, forms, data grids, popovers, and scrolling.
- A glass popover rendered with retained widget backdrop blur.
- A WebView inspector sibling in the same split layout.
- Frame diagnostics for canvas profile risk, work units, commands, batches, and dirty regions.
Run with the macOS system backend. The GPU dashboard defaults to `ReleaseFast`; pass `-Doptimize=Debug` only when debugging renderer internals.
```sh
native dev
```
Run the headless canvas and scene tests:
```sh
native test -Dplatform=null
```
+37
View File
@@ -0,0 +1,37 @@
.{
.id = "dev.native_sdk.gpu_dashboard",
.name = "gpu-dashboard",
.display_name = "GPU Dashboard",
.version = "0.1.0",
.platforms = .{"macos"},
.permissions = .{ "view", "command" },
.capabilities = .{ "native_views", "gpu_surfaces" },
.shell = .{
.windows = .{
.{
.label = "main",
.title = "Native SDK GPU Dashboard",
.width = 1240,
.height = 780,
// The floor the layout audit sweep proves clean
// (toolbar + hero rail + tiles + forecast form).
.min_width = 1080,
.min_height = 640,
.restore_state = false,
.restore_policy = "center_on_primary",
.titlebar = "hidden_inset_tall",
.views = .{
.{ .label = "dashboard-canvas", .kind = "gpu_surface", .fill = true, .min_width = 720, .role = "Native-rendered dashboard canvas", .accessibility_label = "Native-rendered product dashboard canvas", .gpu_backend = "metal", .gpu_pixel_format = "bgra8_unorm", .gpu_present_mode = "timer", .gpu_alpha_mode = "opaque", .gpu_color_space = "srgb", .gpu_vsync = true },
},
},
},
},
.security = .{
.navigation = .{
.allowed_origins = .{ "zero://app", "zero://inline" },
.external_links = .{ .action = "deny" },
},
},
.web_engine = "system",
.cef = .{ .dir = "third_party/cef/macos", .auto_install = false },
}
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
# Native SDK gpu-surface example
This example shows a real GPU-backed child surface in the native view tree:
- A native toolbar and statusbar.
- A Metal-backed `gpu_surface` pane with explicit pixel format, presentation, alpha, color-space, and vsync settings.
- A WebView sibling pane in the same split layout.
- Native controls that dispatch commands back to Zig.
Run with the macOS system backend. The GPU surface example defaults to `ReleaseFast`; pass `-Doptimize=Debug` only when debugging renderer internals.
```sh
native dev
```
Run the headless declaration test:
```sh
native test -Dplatform=null
```
+39
View File
@@ -0,0 +1,39 @@
.{
.id = "dev.native_sdk.gpu_surface",
.name = "gpu-surface",
.display_name = "GPU Surface",
.version = "0.1.0",
.platforms = .{ "macos" },
.permissions = .{ "view", "command" },
.capabilities = .{ "webview", "js_bridge", "native_views", "gpu_surfaces" },
.shell = .{
.windows = .{
.{
.label = "main",
.title = "Native SDK GPU Surface",
.width = 1120,
.height = 720,
.restore_policy = "center_on_primary",
.views = .{
.{ .label = "toolbar", .kind = "toolbar", .edge = "top", .height = 52, .role = "Toolbar" },
.{ .label = "toolbar-title", .kind = "label", .parent = "toolbar", .x = 18, .y = 16, .width = 220, .height = 20, .text = "GPU Surface" },
.{ .label = "frame-mode", .kind = "segmented_control", .parent = "toolbar", .x = 252, .y = 11, .width = 178, .height = 30, .text = "Canvas|Hybrid", .command = "gpu.mode" },
.{ .label = "refresh", .kind = "button", .parent = "toolbar", .x = 448, .y = 11, .width = 86, .height = 30, .text = "Refresh", .command = "gpu.refresh" },
.{ .label = "body", .kind = "split", .fill = true, .axis = "row" },
.{ .label = "canvas", .kind = "gpu_surface", .parent = "body", .width = 680, .min_width = 480, .role = "Animated Metal surface", .accessibility_label = "Animated GPU surface", .gpu_backend = "metal", .gpu_pixel_format = "bgra8_unorm", .gpu_present_mode = "timer", .gpu_alpha_mode = "opaque", .gpu_color_space = "srgb", .gpu_vsync = true },
.{ .label = "inspector", .kind = "webview", .parent = "body", .url = "zero://inline", .fill = true },
.{ .label = "statusbar", .kind = "statusbar", .edge = "bottom", .height = 34, .role = "Status" },
.{ .label = "status-label", .kind = "label", .parent = "statusbar", .x = 14, .y = 8, .width = 720, .height = 18, .text = "Metal surface ready." },
},
},
},
},
.security = .{
.navigation = .{
.allowed_origins = .{ "zero://app", "zero://inline" },
.external_links = .{ .action = "deny" },
},
},
.web_engine = "system",
.cef = .{ .dir = "third_party/cef/macos", .auto_install = false },
}
+203
View File
@@ -0,0 +1,203 @@
const std = @import("std");
const runner = @import("runner");
const native_sdk = @import("native_sdk");
pub const panic = std.debug.FullPanic(native_sdk.debug.capturePanic);
const window_width: f32 = 1120;
const window_height: f32 = 720;
const toolbar_height: f32 = 52;
const canvas_width: f32 = 680;
const statusbar_height: f32 = 34;
const refresh_command = "gpu.refresh";
const mode_command = "gpu.mode";
const html =
\\<!doctype html>
\\<html>
\\<head>
\\ <meta charset="utf-8">
\\ <meta name="viewport" content="width=device-width, initial-scale=1">
\\ <meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline';">
\\ <style>
\\ :root { color-scheme: light dark; }
\\ * { box-sizing: border-box; }
\\ body {
\\ margin: 0;
\\ min-height: 100vh;
\\ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", Segoe UI, system-ui, sans-serif;
\\ background: #f7f8fb;
\\ color: #16181d;
\\ }
\\ main {
\\ min-height: 100vh;
\\ padding: 34px 34px;
\\ display: grid;
\\ align-content: start;
\\ gap: 18px;
\\ }
\\ h1 { margin: 0; font-size: 28px; line-height: 1.12; font-weight: 660; letter-spacing: 0; }
\\ p { margin: 0; color: #606876; line-height: 1.55; }
\\ .facts {
\\ display: grid;
\\ gap: 10px;
\\ max-width: 360px;
\\ }
\\ .fact {
\\ border: 1px solid #dde3ea;
\\ border-radius: 7px;
\\ padding: 12px 13px;
\\ background: white;
\\ }
\\ .fact strong {
\\ display: block;
\\ font-size: 13px;
\\ margin-bottom: 4px;
\\ }
\\ .fact span {
\\ display: block;
\\ color: #697386;
\\ font-size: 13px;
\\ line-height: 1.45;
\\ }
\\ @media (prefers-color-scheme: dark) {
\\ body { background: #101216; color: #f4f6f8; }
\\ p, .fact span { color: #a2aab7; }
\\ .fact { background: #181b21; border-color: #2a303a; }
\\ }
\\ </style>
\\</head>
\\<body>
\\ <main>
\\ <h1>GPU surface</h1>
\\ <p>The left pane is a native Metal-backed child view. This WebView remains a sibling surface in the same native shell.</p>
\\ <div class="facts">
\\ <div class="fact"><strong>Surface</strong><span><code>ViewKind.gpu_surface</code> now maps to a real AppKit Metal view.</span></div>
\\ <div class="fact"><strong>Composition</strong><span>Native controls, WebView content, and GPU output share the same window layout.</span></div>
\\ <div class="fact"><strong>Next layer</strong><span>The display-list renderer can sit above this surface without changing the app model.</span></div>
\\ </div>
\\ </main>
\\</body>
\\</html>
;
const app_permissions = [_][]const u8{ native_sdk.security.permission_command, native_sdk.security.permission_view };
const shell_views = [_]native_sdk.ShellView{
.{ .label = "toolbar", .kind = .toolbar, .edge = .top, .height = toolbar_height, .layer = 20, .role = "Toolbar" },
.{ .label = "toolbar-title", .kind = .label, .parent = "toolbar", .x = 18, .y = 16, .width = 220, .height = 20, .layer = 21, .text = "GPU Surface" },
.{ .label = "frame-mode", .kind = .segmented_control, .parent = "toolbar", .x = 252, .y = 11, .width = 178, .height = 30, .layer = 21, .text = "Canvas|Hybrid", .command = mode_command },
.{ .label = "refresh", .kind = .button, .parent = "toolbar", .x = 448, .y = 11, .width = 86, .height = 30, .layer = 21, .text = "Refresh", .command = refresh_command },
.{ .label = "body", .kind = .split, .fill = true, .axis = .row },
.{ .label = "canvas", .kind = .gpu_surface, .parent = "body", .width = canvas_width, .min_width = 480, .layer = 10, .role = "Animated Metal surface", .accessibility_label = "Animated GPU surface", .gpu_backend = .metal, .gpu_pixel_format = .bgra8_unorm, .gpu_present_mode = .timer, .gpu_alpha_mode = .@"opaque", .gpu_color_space = .srgb, .gpu_vsync = true },
.{ .label = "inspector", .kind = .webview, .parent = "body", .url = "zero://inline", .fill = true },
.{ .label = "statusbar", .kind = .statusbar, .edge = .bottom, .height = statusbar_height, .layer = 20, .role = "Status" },
.{ .label = "status-label", .kind = .label, .parent = "statusbar", .x = 14, .y = 8, .width = 720, .height = 18, .layer = 21, .text = "Metal surface ready." },
};
const shell_windows = [_]native_sdk.ShellWindow{.{
.label = "main",
.title = "Native SDK GPU Surface",
.width = window_width,
.height = window_height,
.views = &shell_views,
}};
const shell_scene: native_sdk.ShellConfig = .{ .windows = &shell_windows };
const GpuSurfaceApp = struct {
refresh_count: u32 = 0,
mode_count: u32 = 0,
gpu_frame_count: u64 = 0,
gpu_resize_count: u32 = 0,
gpu_input_count: u32 = 0,
fn app(self: *@This()) native_sdk.App {
return .{
.context = self,
.name = "gpu-surface",
.source = native_sdk.WebViewSource.html(html),
.scene_fn = scene,
.event_fn = event,
};
}
fn scene(context: *anyopaque) anyerror!native_sdk.ShellConfig {
_ = context;
return shell_scene;
}
fn event(context: *anyopaque, runtime: *native_sdk.Runtime, event_value: native_sdk.Event) anyerror!void {
const self: *@This() = @ptrCast(@alignCast(context));
switch (event_value) {
.command => |command| {
if (std.mem.eql(u8, command.name, refresh_command)) {
try self.refresh(runtime, command);
} else if (std.mem.eql(u8, command.name, mode_command)) {
try self.toggleMode(runtime, command);
}
},
.gpu_surface_frame => |frame_event| {
if (std.mem.eql(u8, frame_event.label, "canvas") and self.gpu_frame_count == 0) {
self.gpu_frame_count = frame_event.frame_index + 1;
try self.updateStatus(runtime, frame_event.window_id, "GPU frame 1 from canvas.");
}
},
.gpu_surface_resized => |resize_event| {
if (std.mem.eql(u8, resize_event.label, "canvas")) {
self.gpu_resize_count += 1;
}
},
.gpu_surface_input => |input_event| {
if (std.mem.eql(u8, input_event.label, "canvas")) {
self.gpu_input_count += 1;
}
},
.appearance_changed, .shortcut, .timer, .effects_wake, .audio, .files_dropped, .canvas_widget_pointer, .canvas_widget_keyboard, .canvas_widget_scroll, .canvas_widget_file_drop, .canvas_widget_drag, .canvas_widget_context_menu, .canvas_widget_context_menu_request, .canvas_widget_dismiss, .canvas_widget_context_press, .canvas_widget_resize, .canvas_widget_change, .window_closed, .automation_provenance, .lifecycle => {},
}
}
fn updateStatus(self: *@This(), runtime: *native_sdk.Runtime, window_id: native_sdk.WindowId, text: []const u8) anyerror!void {
_ = self;
_ = try runtime.updateView(window_id, "status-label", .{ .text = text });
}
fn refresh(self: *@This(), runtime: *native_sdk.Runtime, command: native_sdk.CommandEvent) anyerror!void {
self.refresh_count += 1;
var status_buffer: [160]u8 = undefined;
const status = try std.fmt.bufPrint(&status_buffer, "GPU surface refreshed from {s}. Count {d}.", .{ @tagName(command.source), self.refresh_count });
try self.updateStatus(runtime, command.window_id, status);
}
fn toggleMode(self: *@This(), runtime: *native_sdk.Runtime, command: native_sdk.CommandEvent) anyerror!void {
self.mode_count += 1;
var status_buffer: [160]u8 = undefined;
const status = try std.fmt.bufPrint(&status_buffer, "Mode control fired from {s}. Count {d}.", .{ @tagName(command.source), self.mode_count });
try self.updateStatus(runtime, command.window_id, status);
}
};
pub fn main(init: std.process.Init) !void {
var app = GpuSurfaceApp{};
try runner.runWithOptions(app.app(), .{
.app_name = "gpu-surface",
.window_title = "Native SDK GPU Surface",
.bundle_id = "dev.native_sdk.gpu_surface",
.default_frame = native_sdk.geometry.RectF.init(0, 0, window_width, window_height),
.js_window_api = false,
.security = .{
.permissions = &app_permissions,
.navigation = .{ .allowed_origins = &.{ "zero://inline", "zero://app" } },
},
}, init);
}
test "gpu surface scene declares gpu and web siblings" {
try std.testing.expect(shell_views[5].kind == .gpu_surface);
try std.testing.expect(shell_views[6].kind == .webview);
try std.testing.expectEqualStrings("body", shell_views[5].parent.?);
try std.testing.expectEqualStrings("body", shell_views[6].parent.?);
try std.testing.expect(shell_views[5].gpu_backend.? == .metal);
try std.testing.expect(shell_views[5].gpu_pixel_format.? == .bgra8_unorm);
try std.testing.expect(shell_views[5].gpu_present_mode.? == .timer);
try std.testing.expect(shell_views[5].gpu_alpha_mode.? == .@"opaque");
try std.testing.expect(shell_views[5].gpu_color_space.? == .srgb);
try std.testing.expect(shell_views[5].gpu_vsync.?);
}
+3
View File
@@ -0,0 +1,3 @@
{
"files.associations": { "*.native": "html" }
}
+34
View File
@@ -0,0 +1,34 @@
.{
.id = "dev.native_sdk.habits",
.name = "habits",
.display_name = "Habits",
.description = "A daily habit tracker with streaks and filters.",
.version = "0.1.0",
.platforms = .{"macos"},
.permissions = .{ "view", "command" },
.capabilities = .{ "native_views", "gpu_surfaces" },
.shell = .{
.windows = .{
.{
.label = "main",
.title = "Native SDK Habits",
.width = 720,
.height = 520,
.restore_state = false,
.restore_policy = "center_on_primary",
.titlebar = "hidden_inset_tall",
.views = .{
.{ .label = "habits-canvas", .kind = "gpu_surface", .fill = true, .role = "Habit tracker canvas", .accessibility_label = "Habit tracker", .gpu_backend = "metal", .gpu_pixel_format = "bgra8_unorm", .gpu_present_mode = "timer", .gpu_alpha_mode = "opaque", .gpu_color_space = "srgb", .gpu_vsync = true },
},
},
},
},
.security = .{
.navigation = .{
.allowed_origins = .{ "zero://app", "zero://inline" },
.external_links = .{ .action = "deny" },
},
},
.web_engine = "system",
.cef = .{ .dir = "third_party/cef/macos", .auto_install = false },
}
+45
View File
@@ -0,0 +1,45 @@
<!-- The habit tracker view. Embedded into the binary and hot-reloaded in
dev: edit this file while the app runs and the window updates without
losing streak state. -->
<column background="background">
<!-- The header IS the titlebar (tall hidden-inset chrome): it is the
window's drag surface, leads with a spacer sized to the traffic
lights via on_chrome, and matches its height to the titlebar band
so its controls and the lights share a centerline. -->
<!-- Band contents: the one primary action holds the trailing corner;
the rest of the band stays bare drag surface. -->
<row height="{header_height}" padding="12" gap="10" cross="center" background="surface" window-drag="true" label="Habits header">
<spacer width="{chrome_leading}" />
<spacer grow="1" />
<button variant="primary" on-press="add">New habit</button>
</row>
<separator />
<column grow="1" gap="12" padding="16">
<!-- Single selection is radio semantics: the group renders selection
from {f == filter} equalities, each radio's on-toggle sets the
model field, and the visible label rides text= (radio is not a
text-bearing element). -->
<radio-group gap="8" label="Filter">
<for each="filters" as="f">
<radio checked="{f == filter}" on-toggle="set_filter:{f}" text="{f}" />
</for>
</radio-group>
<if test="{habit_count}">
<scroll grow="1">
<column gap="2">
<for each="visible" key="id" as="h">
<row global-key="{h.id}" gap="8" padding="6" cross="center" role="listitem" label="{h.name}">
<text grow="1">{h.name}</text>
<text>{h.streak} days</text>
<button size="sm" on-press="done:{h.id}">Done today</button>
</row>
</for>
</column>
</scroll>
</if>
<else>
<alert text="No habits yet - add your first habit." grow="1" />
</else>
</column>
<status-bar>{summaryLine}</status-bar>
</column>

Some files were not shown because too many files have changed in this diff Show More