Skip to content

v0.5.4

Choose a tag to compare

@github-actions github-actions released this 21 Jul 00:32
349618a

New Features

  • Cmd.imageLoad — dynamic images, the first full media pipeline: apps load images at runtime from disk or the network by a model-owned ImageId, the effect executor resolves the audio cascade's source order (local path first, then a verified content-addressed cache entry under <caches>/images/, then the network with an atomic cache install behind it), decodes through the platform codec into the existing registered-image storage, and exactly ONE result Msg comes back — loaded with the decoded width/height, or one honest failure class from the same vocabulary the direct registration API raises (decode_failed, too_large, registry_full, unsupported, alloc_failed — the host refused the memory the registration needed, resource exhaustion rather than corrupt bytes — the fetch taxonomy, http_status with the status carried through).
  • TS tier first-class: Cmd.imageLoad(id, { path?, url?, cachePath?, expectedBytes? }, { event }) with a five-field result arm matched by name (id/state/width/height/statusid echoes the requested ImageId so concurrent loads sharing one arm stay distinguishable; the fifteen-member ImageState union checked at build time), id expressions welcome (ids are model data), Cmd.imageCancel(id) ending a live load loudly (the event arm's "cancelled", freeing the id for a same-id retry; an id with no live load no-ops), Cmd.imageUnregister(id) releasing a loaded image's registry slot (the gallery eviction move past the 16-slot registry — synchronous registry surgery like registration itself, no result Msg, misses no-op; a load in flight still registers at its terminal, so cancel first to keep the slot free), opcodes 0x12/0x13/0x14 additive within cmd_format_version 3, and TsUiApp's image_cache_dir deriving the content-addressed cache path from the URL so update never builds filesystem paths.
  • Markup <image> — the runtime-image leaf (element code 67): image="{binding}" binds the model-owned u64 ImageId in avatar's grammar (binding-only, required on the leaf, negative model values fail the build with a teaching, never a trap), wired through the validator, both engines, native check's model contract, LSP hover docs, and the docs vocabulary; the image attribute's scope broadened from avatar-only to avatar+image.
  • Recorded sessions replay byte-identical, offline: an image load's ENCODED source bytes are the effect result, journaled at effect-result time into a content-addressed blob store beside the journal (blobs/<sha256[..16]> in the session directory — identical bytes twice store one blob), with the journal record carrying hash + length and the dedup probe verifying an existing blob's bytes before trusting its name (a damaged blob repairs in place from the bytes in hand — recording self-heals the store instead of sealing a journal replay must refuse); replay reads the blob, verifies it against its address, re-runs decode + registration, and delivers the recorded result with no file, network, or cache touched, refusing loudly when the blob store is missing or damaged.
  • Journal format, stated plainly: the image records bump the session-journal format to v7 (the .image effect-record kind plus the blob-address fields appended to every effect record); v6 and older journals are refused at the preamble with the standard re-record teaching — a v6 reader would have misparsed the longer records as corruption.
  • Zig tier: fx.loadImage(.{ .id, .path, .url, .cache_path, .expected_bytes, .on_result }) with Effects.imageMsg(...) routing, a fake-executor seam (pendingImageLoad*, feedImageBytes running the REAL decode+register path, feedImageResult), and imageCachePath deriving the cache convention; the encoded source is bounded at 1.25 MiB from every source alike and over-bound sources fail whole with too_large — a cut image never decodes, so there is no truncated delivery.
  • The menu-bar app lifecycle: windows can declare close_policy = "hide" in app.zon (the default "quit" keeps today's behavior for every existing app) — the red close button hides the window instead of quitting, the app keeps running behind its status item, and the macOS Dock reopen re-shows it.
  • New window verbs on the effects channel: fx.showWindow(label) un-hides and activates a window (the tray "Open" consequence; also restores a minimized window), and fx.quitApp() quits gracefully through the same shutdown path a last-window close takes — both mirrored in the TypeScript tier as Cmd.showWindow(label) and Cmd.quitApp().
  • Hidden state is honest, journaled window state: WindowState.hidden rides the frame channel, records into session journals, and replays.
  • Implemented on macOS (windowShouldClose + orderOut, Dock reopen) and Windows (WM_CLOSE hides via SW_HIDE; the tray re-shows); Linux GTK has no status item to bring a hidden window back, so "hide" is refused loudly at build/create time with a teaching instead of stranding a window.

Improvements

  • The tofu guard teaches font registration: the font-coverage teachings — the native markup check error, the Debug view-build diagnostic, and the CLI usage text — now name registering a covering face (UiApp.Options.fonts) behind a model binding as the first remedy for text beyond bundled coverage, alongside vector icons and plain words.
  • A fonts page in the docs: /fonts documents registering faces for scripts beyond bundled coverage — the Options.fonts scaffold shape, every registration-time validation error by name, ownership and lifecycle, how text resolves faces through the typography tokens, and per-platform truth including the unverified mobile seam.
  • The Chinese receipt runs natively on Windows in CI: a new zig build test-canvas-fonts step runs the font-registry suite on the Windows runner, including the receipt test that registers a committed subsetted Noto Sans SC (OFL, license alongside) through the app-fonts seam and proves the rendered string is real ideograph outlines — compared against both the bundled face's rendering and the same registered face's own uncovered-string fallback, so tofu from any face fails the receipt.
  • Registered-image memory is on-demand: each registered canvas image slot buffer is one lazy 1 MiB allocation from Runtime.Options.allocator at the slot's first registration (freed by Runtime.deinit; unregister/register churn reuses buffers, so the footprint stays bounded by the high-water slot count), so a runtime that never registers an image no longer carries the former 16 MiB embedded pixel pool.
  • New error on register: registerCanvasImage / registerCanvasImageBytes (and the fx.registerImage / fx.registerImageBytes bindings) now surface error.OutOfMemory when a slot's pixel buffer cannot be allocated — the refusal happens before any registry mutation, so the registry is unchanged and the same registration can be retried once memory recovers.

Bug Fixes

  • Large markup documents compile in the compiled engine: CompiledMarkupView / CompiledMarkupImports no longer fail with "evaluation exceeded 1000 backwards branches" on .native documents past ~10KB — documents now carry their source size from parse/resolve time, so the comptime canonicalize pass sizes its branch quota in O(1) instead of re-measuring the tree inside the quota argument (which ran under the caller's default budget).
  • Linux native dev no longer crashes at startup: Debug builds on x86_64 Linux segfaulted creating the first shell view (a general protection fault in the GTK host's native_sdk_gtk_create_view) because Zig 0.16.0's self-hosted x86_64 backend — the Debug default — mis-places stack-passed arguments in the host call's long mixed signature; the app executable now forces the LLVM backend on x86_64 like every other artifact in the build graph, and the ejected template's build does the same.
  • GTK host string caps: the Linux host now refuses view create/update calls whose string lengths exceed the platform caps with a teaching warning instead of copying from a corrupted pointer, so a broken C-ABI boundary fails loudly at the seam.
  • Linux Debug scaffold smoke in CI: a new linux-dev-smoke job scaffolds the default template, builds it -Doptimize=Debug (the native dev mode Release-shaped lanes never exercise), and drives it under Xvfb to the first presented frame.
  • Late-registered fonts re-measure open surfaces: registering a face after views are installed (runtime.registerCanvasFont on a live runtime) now rebuilds every installed UiApp surface — the main canvas and declared windows — on the next presented frame, so text laid out before the face joined re-measures with the registered face instead of keeping its pre-registration widths under a repaint.
  • macOS host font state ends with its runtime: Runtime.deinit now returns each registered id's host-side registration — the CoreText descriptor and its measurement caches, including the measured-width cache the host previously retained until memory pressure — so embedders that cycle runtimes no longer accumulate per-process font state; removal is ownership-token guarded, so an older runtime's teardown never removes a newer runtime's live face under a shared id.
  • Breaking: PlatformServices.registerGpuSurfaceFont now returns the host's ownership token for the registration (u64; 0 from hosts that retain no per-id state), and the new optional unregisterGpuSurfaceFont(id, token) service returns that state at teardown — a deliberate break while the toolkit is pre-1.0, so host font lifetime has an owner. Embedders implementing a custom platform change register_gpu_surface_font_fn to return a u64 token (0 is fine for a stateless accept) and may supply unregister_gpu_surface_font_fn to release per-id host state when the registering runtime deinits.
  • Ternaries with spread-literal arms compile from TypeScript cores: parsed === null ? q : { ...q, state: "ok", price: parsed } — and the nested, !==, both-arms, argument, object-field, and x === null ? { ...fallback } : x spellings — no longer emit Zig that reads the null-narrowing capture before it binds (use of undeclared identifier) or evaluates both arms unconditionally; arms that build values statement-by-statement now lower into per-branch blocks feeding a typed temp, so exactly the taken arm runs, and pure-arm ternaries keep their tight if/orelse expression forms.
  • Optional switch payloads keep their optional through the capture: reading a number | null payload (directly or via a const local) inside its case no longer mistypes the value as non-null, which routed msg.parsed === null ? ... : ... around the narrowing lowering and emitted ?f64 into f64 slots.
  • Early-exit guards narrow like early returns: if (r === null) break; in a parse loop (and the continue, labeled, multi-statement, throw-exit, and if (x !== null) { ... } else { break/return } spellings) now narrows the optional for the rest of the loop body the way tsc's flow analysis does, instead of emitting Zig field access on the still-optional value; if (msg.kind !== "num") break; narrows the union payload the same way.
  • Guard narrowing ends with its block: a guard's captures no longer leak past the loop body or branch they narrow — reads after the construct see the unnarrowed value again (matching tsc, whose exit path may bypass the guard) instead of referencing an out-of-scope Zig capture.
  • Early switch-clause breaks stop the build: an unlabeled break that exits a switch from inside a clause body now teaches at transpile time — Zig's break binds loops, so the old emission jumped past the enclosing loop instead of resuming after the switch.
  • A redundant kind guard no longer un-optionals a switch payload: scoped kind-narrowing now restores the still-optional markers alongside the substitutions it snapshots, so const marker = msg.kind === "got" ? 1 : 2; inside case "got": no longer leaves a number | null payload typed non-null for the rest of the clause (which emitted if (parsed != null) parsed + marker else 0 — invalid Zig operands on the ?f64).
  • Inferred locals from narrowed ternaries value non-optional: const picked = q === null ? { ...fallback, price: 0 } : q; (either polarity, no : Quote annotation) now types the local by the arm the condition narrows — Quote, exactly as tsc infers — instead of the raw optional, which declared a ?Quote temporary that failed Zig compilation at its first non-optional use (expected type 'Quote', found '?Quote').
  • A redundant nested switch on the same subject hands back the outer capture: the arm cleanup now repopulates its narrowing maps from the snapshot instead of only deleting the arm's additions, so an inner arm's capture that OVERWROTE the outer arm's entry no longer leaks into the continuation after the inner switch (which emitted the inner capture name after its Zig block had closed — use of undeclared identifier).
  • Else-if chains keep post-if narrowing: if (x === null) return -1; else if (flag) { n = 2; } return x.v + n; — and the else-if-else, chained else-if-else-if, and !==-polarity spellings — now narrow x after the statement like the plain-else form does; the else-if emission path returned before applying the post-if narrowing, so the fall-through read landed on the still-optional value (optional type does not support field access).
  • Reassigned let bindings never fuse into a const: let p = next(i); if (p === null) continue; p = { ...p, v: 10 }; no longer fuses the declaration and guard into const p = next(i) orelse continue; (Zig: cannot assign to constant); the binding stays a var, the guard keeps its plain null test, and later reads unwrap the live variable — which the assignment path keeps narrowed across provably non-null writes.
  • A branch that widens a narrowed optional stays widened past the merge: if (p === null) return -1; if (flag) { p = null; } if (p === null) return 0; — the branch-exit restore that keeps narrowing CONTAINED (additions inside a branch die at its exit) no longer also resurrects a narrow the branch killed by assigning null (or a fresh optional-returning call); kills now re-apply after every branch, switch-arm, and kind-guard exit and propagate through nested blocks, so the post-merge re-check tests the live value instead of emitting p.? == null (Zig: comparison of 'f64' with null). The merge is conservative — a kill on any path that can reach the merge drops the narrow, and the re-check tsc demands anyway always compiles.
  • Compound-guard branches keep those kills dead too: if (r !== null && r > 0) { p = null; } — where the branch emits under the chain's .? substitutions — no longer resurrects p's killed narrow when that substitution scope restores its snapshot (its restore ran after the branch re-applied the kill); the scope now rides the same kill-frame protocol as branch and switch-arm exits, and so does the chain-condition emitter, so every full-map narrowing restore in the emitter re-deletes killed entries on exit.
  • A kill on an always-exiting branch stays off the surviving flow: if (p.v < 0) { p = null; return -1; } return p.v; inside a null guard — tsc keeps p narrowed at the second return because the killing branch left the function, and the emitter now agrees: a branch that always returns (or throws uncaught) drops its kills at the merge instead of deleting the narrow the surviving read depends on (which emitted field access straight onto the ?P). Kills on paths that resume inside the function — fall-through arms, break/continue guards in loops, throws caught by an enclosing try — still merge outward and drive the post-merge re-check.
  • A guard in a lifted callback covers the trailing return it precedes: xs.map((p) => { if (p === null) throw bad; return p.v; }) lifts the callback as its statement prefix plus the trailing return's expression, and the prefix's narrowing scope closed before that expression emitted — the read landed on the raw ?P (optional type does not support field access); prefix and trailing expression now share one flow scope, and the scope still closes before the callback's siblings in the emitted loop body.
  • A do-while body guard covers the trailing test it flows into: do { if (p === null) return -1; n += p.v; } while (p.v > 0); — tsc evaluates the condition after the body, under the body's flow state, but the body's narrowing scope closed before the lowered if (!(cond)) break; emitted, so the test read the raw ?P (optional type does not support field access); the body and the trailing test now share one narrowing scope, restored at the loop boundary. A guard read only by the test binds its capture too, continue-carried kills still widen the hoisted first-pass test onto the live optional, and break-carried kills still land only on the post-loop state.
  • Canvas-app hosts compile silently without a WebView SDK: the informational #pragma message in the GTK host's WebKitGTK stub path (and its Windows WebView2 twin) is gone — zig renders every clang diagnostic of a failing C compile as error:, so on machines where a real, unrelated compile error occurred (for example a too-old GTK), the note itself surfaced as the first build-killing error and masked the actual cause; the stub is the expected state of every canvas app and now compiles with zero diagnostics, while a genuinely misconfigured web build still fails loudly via #error.
  • GTK host compiles against GLib 2.72: the host now spells "no application flags" in a way that compiles on GLib older than 2.74, so distros that backport GTK 4.10 onto a GLib 2.72 base (Ubuntu 22.04-derived) build canvas apps out of the box.

Contributors