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
+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;
}