feat(canvas): expose registered image source crops (#390)
* feat(canvas): expose registered image source crops - Add image_src to ElementOptions for texture-atlas rendering. - Expose atomic source rectangle attributes through Native markup. - Cover schema, compiler parity, validation, and authoring docs. * fix(markup): complete image crop suggestions * fix(canvas): prevent texture atlas sampling bleed
This commit is contained in:
@@ -31,6 +31,13 @@ With a registered image the engine clips it to the avatar circle (`cover` fit);
|
||||
ui.avatar(.{ .image = image_id }, "ZN")
|
||||
```
|
||||
|
||||
An avatar can select one region from a registered texture atlas by declaring all four source coordinates in decoded-image pixels. The same crop is `ElementOptions.image_src` in a Zig view:
|
||||
|
||||
```html
|
||||
<avatar image="{atlas_image}" source-x="64" source-y="0"
|
||||
source-width="32" source-height="32" label="Profile picture">ZN</avatar>
|
||||
```
|
||||
|
||||
## Attributes
|
||||
|
||||
<AttrTable element="avatar" attrs={["text", "image", "label"]} />
|
||||
<AttrTable element="avatar" attrs={["text", "image", "source-x", "source-y", "source-width", "source-height", "label"]} />
|
||||
|
||||
@@ -104,6 +104,8 @@ A loaded image occupies one of the registry's **16 slots** until you release it,
|
||||
|
||||
Unlike a load, unregister is **synchronous registry surgery, not an effect**: no result Msg follows (registration by `imageLoad` has a terminal because I/O and decode can fail; releasing a slot cannot), and aimed at an id with no registration it no-ops — `imageCancel`'s idle rule. It frees only the **current** registration: a load in flight under the id is untouched, and its terminal still registers the pixels, re-occupying the id. To evict an id whose load is still running, `Cmd.imageCancel(id)` first, then unregister.
|
||||
|
||||
When many small visuals ship together, one slot can hold a texture atlas instead: `<image>` and `<avatar>` accept `source-x`, `source-y`, `source-width`, and `source-height` together, in decoded-image pixel coordinates. Zig views set the equivalent `ElementOptions.image_src` rectangle. Cropped widgets use nearest sampling so adjacent regions cannot bleed into the tile. Every atlas region references the same registered ImageId, so it consumes one registry slot; use the dimensions reported by the load result because decode-to-fit may change the atlas geometry.
|
||||
|
||||
## Limits, honestly
|
||||
|
||||
The default is **16 slots** with a **1 MiB decoded-pixel target per slot**. That target is not a refusal: platform codecs decode photo-scale sources down, preserving aspect, until `width × height × 4` fits. A wide 1024×256 image already fits and stays that size; a 640×480 image registers at a smaller geometry, and the result's `width`/`height` report exactly what views draw. The encoded source has its own flat **8 MiB** bound; an over-bound source fails whole with `too_large`, never as truncated bytes.
|
||||
|
||||
@@ -573,6 +573,24 @@ ui.image(.{ .image = model.chart_image, .width = 120, .height = 80, .semantics =
|
||||
<avatar image="{avatar_image}" label="Octocat">OC</avatar>
|
||||
```
|
||||
|
||||
One registered image can be a texture atlas. `ElementOptions.image_src` is an optional `geometry.RectF` in decoded-image pixel coordinates; Native markup spells the same rectangle with all four source attributes. The widget frame stays the destination, a source rectangle crossing the registered image bounds is clipped, and cropped widgets use nearest sampling so adjacent atlas regions cannot bleed into the tile:
|
||||
|
||||
```zig
|
||||
ui.image(.{
|
||||
.image = model.atlas_image,
|
||||
.image_src = geometry.RectF.init(64, 32, 24, 24),
|
||||
.width = 48,
|
||||
.height = 48,
|
||||
.semantics = .{ .label = "Warning badge" },
|
||||
})
|
||||
```
|
||||
|
||||
```html
|
||||
<image image="{atlas_image}" source-x="64" source-y="32"
|
||||
source-width="24" source-height="24" width="48" height="48"
|
||||
label="Warning badge" />
|
||||
```
|
||||
|
||||
`fx.registerImage(id, width, height, rgba8)` registers already-decoded pixels (the runtime copies them; your buffer is free on return), `fx.registerImageBytes(id, bytes)` decodes through the platform codec first, and `fx.unregisterImage(id)` frees the slot. Re-registering an id replaces its pixels and every view repaints — GPU caches re-upload off the changed content fingerprint, no invalidation calls. For caches, mint fresh ids (effect-key style, monotonically increasing) and unregister the evictee — never re-key different content onto a live id. Outside `UiApp`, the same registry is `Runtime.registerCanvasImage`/`registerCanvasImageBytes`/`unregisterCanvasImage`.
|
||||
|
||||
Capacities are fixed and loud (`canvas_limits`): 16 slots with a 1 MiB decoded-pixel target by default. Encoded photos decode aspect-preservingly to fit, while raw `fx.registerImage` pixels remain strict. Image-centric apps may raise the startup-frozen target through app.zon `.images.max_image_pixel_bytes`, up to 8 MiB; storage is lazy per used slot, but filling all 16 ceiling-sized slots is a declared 128 MiB high-water. Every encoded entry point, including direct `fx.registerImageBytes`, shares the flat 8 MiB source bound. `error.ImageRegistryFull`, `error.ImageTooLarge` (encoded source, raw pixels, or a codec-contract violation), `error.ImageDecodeFailed`, and `error.UnsupportedService` are never silent. Registered images render everywhere the canvas does: live presentation, screenshots, and automation. A missing id simply draws its fallback. In tests, the null platform's deterministic strict-PNG decoder also pins exact decode-to-fit dimensions and pixels.
|
||||
|
||||
@@ -746,6 +746,22 @@
|
||||
{
|
||||
"name": "image",
|
||||
"doc": "avatar and image: one {binding} to a u64 ImageId the app registered at runtime (Cmd.imageLoad, fx.loadImage, fx.registerImageBytes); 0 draws nothing (an avatar falls back to its initials). Required on the image leaf."
|
||||
},
|
||||
{
|
||||
"name": "source-x",
|
||||
"doc": "avatar and image: left edge of an optional source crop, in decoded-image pixels. Declare all four source-* attributes together beside image."
|
||||
},
|
||||
{
|
||||
"name": "source-y",
|
||||
"doc": "avatar and image: top edge of an optional source crop, in decoded-image pixels. Declare all four source-* attributes together beside image."
|
||||
},
|
||||
{
|
||||
"name": "source-width",
|
||||
"doc": "avatar and image: width of an optional source crop, in decoded-image pixels. Declare all four source-* attributes together beside image."
|
||||
},
|
||||
{
|
||||
"name": "source-height",
|
||||
"doc": "avatar and image: height of an optional source crop, in decoded-image pixels. Declare all four source-* attributes together beside image."
|
||||
}
|
||||
],
|
||||
"media-surface": [
|
||||
|
||||
@@ -215,7 +215,7 @@ Automation drives the native path honestly: snapshots list every widget's declar
|
||||
| `skeleton`, `spinner` | loading leaves | size `skeleton` with `width`/`height` |
|
||||
| `icon` | vector icon leaf | `name` picks the icon: a bare literal is a curated built-in stroke icon (compile-checked; 49 names: search, plus, x, x-circle, check, check-circle, chevron-up/down/left/right, arrow-up/down/right, menu, panel-left, panel-right, settings, terminal, wrench, trash, edit, copy, external-link, play, pause, skip-back/forward, shuffle, repeat, music, volume, info, alert, download, save, folder, folder-open, file-text, sun, moon, eye, clock, git-pull-request, git-merge, git-branch, circle-dot, archive, refresh-cw, send); `app:<name>` reaches an icon the app registered at boot with `canvas.icons.registerAppIcons` (declare the table as `pub const app_icons` on the app root so `native check` verifies the name against the model contract), and one `{binding}` defers the choice to model data - an unknown resolved name draws the missing-icon fallback (a slashed circle) with a Debug warning naming the value, never a silent gap; tint with `foreground`, size with `width`/`height` |
|
||||
| `media-surface` | media surface leaf | composites a texture produced OUTSIDE the widget tree (video decoder, camera, an external renderer like mpv) into the layout like any widget — clipped, z-ordered, rounded. `surface="{binding}"` (required) binds the model-owned u64 surface id a Zig-tier producer targets (`runtime.acquireMediaSurfaceProducer` pushes RGBA8 frames, latest-wins, paced by the presented-frame clock; 0 = unbound, draws nothing; usable ids are nonzero values below the reserved bit 63). No intrinsic size — give it `width`/`height` or `grow`; display-only (presses fall through); `label` it (pictorial content). Texture contents are presentation chrome: goldens, reference screenshots, and session replay show the deterministic id-derived placeholder, never producer frames |
|
||||
| `image` | runtime image leaf | draws a RUNTIME-REGISTERED image by its model-owned u64 ImageId — the id `Cmd.imageLoad` (TS) or `fx.loadImage`/`fx.registerImageBytes` (Zig) registered pixels under. `image="{binding}"` (required) binds a model field/fn; ids are model data, never markup literals, and 0 draws nothing (store the id only when the load reports loaded — see the Images section). No intrinsic size — give it `width`/`height` or `grow`; display-only (presses fall through); `label` it (pictorial content) |
|
||||
| `image` | runtime image leaf | draws a RUNTIME-REGISTERED image by its model-owned u64 ImageId — the id `Cmd.imageLoad` (TS) or `fx.loadImage`/`fx.registerImageBytes` (Zig) registered pixels under. `image="{binding}"` (required) binds a model field/fn; ids are model data, never markup literals, and 0 draws nothing (store the id only when the load reports loaded — see the Images section). `source-x`/`source-y`/`source-width`/`source-height` select one atlas region in decoded-image pixel coordinates (declare all four). No intrinsic size — give it `width`/`height` or `grow`; display-only (presses fall through); `label` it (pictorial content) |
|
||||
| `code` | bare highlighted source/editor | `source="{binding}"` (required) provides source text and `language="tsx"` selects a literal lexer name; the component supplies no background, border, radius, shadow, or padding, so wrap it in a panel/card when chrome is wanted. It is read-only by default; `editable on-input="edit"` opts into multiline editing while retaining highlighting. It wraps by default, `line-numbers` opts into logical line numbers, `added-lines="5"` / `removed-lines="2-4"` add Geist-style diff rows without changing copied source, and `wrap="false"` preserves lines inside one horizontal scroll region. HTML-family highlighting distinguishes HTML/XML/SVG and JSX/TSX tags, attributes, strings, comments, and embedded expressions. Zig builder: `ui.code(CodeOptions, source)` |
|
||||
| `markdown` | rendered markdown subtree | leaf; `source` is one `{binding}` — see "Markdown in markup" |
|
||||
| `stepper` > `step` | composite stage track | `active="{index}"` (required) derives each step's completed/active/pending state; steps are text leaves (no attributes) joined by connectors; stepper also takes `key`, `global-key`, `label` |
|
||||
@@ -224,7 +224,7 @@ Automation drives the native path honestly: snapshots list every widget's declar
|
||||
| `context-menu` | consumed by its parent | right-click menu on its DIRECT parent (a hit-target kind or an element with `on-press`/`on-double-press`/`on-toggle`/`on-hold`/`on-drag`); metadata, never a flow child. Children: `menu-item`s (`on-press` required, `disabled` optional, no `icon`) and bare `separator`s, with `if`/`else`/`for` around them. Attribute-less; presents natively where the host has a menu presenter, as an anchored surface elsewhere — see "Context menus" |
|
||||
| `input-group` > `textarea` + `input-group-actions` | composite grouped input | the composer shape: ONE bordered field wrapping exactly one `textarea` (first — document order is focus order) plus an optional `input-group-actions` row of controls inside the same border. The group wears the focus ring for its focused descendant and the textarea's own chrome dissolves automatically, so the whole group reads as one field; the textarea keeps its full behavior (`text`, `placeholder`, `on-input`, `on-submit`, `autofocus`, and optional `submit-on-enter`). Group takes `label`, `width`, `height`, `min-width`, `grow`, `key`, `global-key`; the actions row takes `gap` and holds ordinary elements (`if`/`else`/`for` work — swap send for stop while streaming) — put a `<spacer grow="1"/>` between leading and trailing controls (`Ui.inputGroup`/`Ui.inputGroupActions` are the Zig-view equivalents) |
|
||||
|
||||
Not markup-expressible (deliberately — write these as Zig view functions with `canvas.Ui`): `icon_button` (`<button icon="...">` with empty content is the declarative icon button), `data_grid` (per-column cell templates), `popover`/`menu_surface` (anchored to runtime geometry), `segmented_control` (use `tabs`/`toggle-group`: `<button>` children of `<tabs>` lower to segmented triggers automatically, so the active tab lifts per the house treatment). Charts ARE expressible: `<chart>` with `<series values="{binding}">` children binding model f32 iterables — see the Charts section (`.band` series and dynamic series composition stay with `ui.chart`). Built-in vector icons ARE expressible: `<icon name="search"/>` (closed, compile-checked name set; `Ui.icon` is the Zig-view equivalent). App-authored icons: `canvas.svg_icon.parseComptime(@embedFile("icons/logo.svg"))` parses any SVG in the common 24x24 stroke-icon dialect at comptime; register the parsed table once at boot with `canvas.icons.registerAppIcons(&table)` and draw by name via `ui.appIcon(.{...}, "logo")` or `ElementOptions.icon` — registered names render exactly like built-ins on every draw path. Markup `<icon>`/`<button icon>` stay built-in-only (the compiled engine validates names at comptime, where runtime registrations cannot exist — engine parity). Runtime images ARE expressible: `<image image="{cover}" width="120" height="80" label="Cover art"/>` and `<avatar image="{user_image}">CT</avatar>` bind a `u64` ImageId model field/fn (the id is just model data; 0 draws nothing / keeps the initials fallback) — see the Images section; the `image` binding is required on the leaf (an unbound `<image>` is dead markup) and stays avatar+image scoped.
|
||||
Not markup-expressible (deliberately — write these as Zig view functions with `canvas.Ui`): `icon_button` (`<button icon="...">` with empty content is the declarative icon button), `data_grid` (per-column cell templates), `popover`/`menu_surface` (anchored to runtime geometry), `segmented_control` (use `tabs`/`toggle-group`: `<button>` children of `<tabs>` lower to segmented triggers automatically, so the active tab lifts per the house treatment). Charts ARE expressible: `<chart>` with `<series values="{binding}">` children binding model f32 iterables — see the Charts section (`.band` series and dynamic series composition stay with `ui.chart`). Built-in vector icons ARE expressible: `<icon name="search"/>` (closed, compile-checked name set; `Ui.icon` is the Zig-view equivalent). App-authored icons: `canvas.svg_icon.parseComptime(@embedFile("icons/logo.svg"))` parses any SVG in the common 24x24 stroke-icon dialect at comptime; register the parsed table once at boot with `canvas.icons.registerAppIcons(&table)` and draw by name via `ui.appIcon(.{...}, "logo")` or `ElementOptions.icon` — registered names render exactly like built-ins on every draw path. Markup `<icon>`/`<button icon>` stay built-in-only (the compiled engine validates names at comptime, where runtime registrations cannot exist — engine parity). Runtime images ARE expressible: `<image image="{cover}" width="120" height="80" label="Cover art"/>` and `<avatar image="{user_image}">CT</avatar>` bind a `u64` ImageId model field/fn (the id is just model data; 0 draws nothing / keeps the initials fallback) — see the Images section; the `image` binding is required on the leaf (an unbound `<image>` is dead markup) and stays avatar+image scoped. Add all four `source-x`/`source-y`/`source-width`/`source-height` attributes to draw one decoded-pixel sub-rectangle from a shared atlas.
|
||||
|
||||
## Attributes
|
||||
|
||||
@@ -979,6 +979,27 @@ The view binds that model-owned id in either tier; `0` is the no-image sentinel
|
||||
<avatar image="{avatar}" label="Octocat">OC</avatar>
|
||||
```
|
||||
|
||||
One registered image can be a texture atlas. The source rectangle uses
|
||||
decoded-image pixel coordinates; declare all four markup attributes, or set
|
||||
the equivalent `ElementOptions.image_src` `geometry.RectF` in a Zig view.
|
||||
Cropped widgets use nearest sampling so adjacent atlas regions cannot bleed:
|
||||
|
||||
```html
|
||||
<image image="{atlas}" source-x="64" source-y="32"
|
||||
source-width="24" source-height="24" width="48" height="48"
|
||||
label="Warning badge" />
|
||||
```
|
||||
|
||||
```zig
|
||||
ui.image(.{
|
||||
.image = model.atlas_image,
|
||||
.image_src = geometry.RectF.init(64, 32, 24, 24),
|
||||
.width = 48,
|
||||
.height = 48,
|
||||
.semantics = .{ .label = "Warning badge" },
|
||||
})
|
||||
```
|
||||
|
||||
### Zig cores and extensions: direct registration
|
||||
|
||||
Image pixels are runtime-registered resources keyed by a caller-chosen `ImageId` (`u64` in the model, effect-key style; 0 = no image). The framework bundles NO codecs — encoded bytes decode through the platform (CGImageSource / gdk-pixbuf / WIC) via `PlatformServices.decode_image_fn`. Registration lives on the effects channel (synchronous calls, not effects — no Msg follows):
|
||||
@@ -995,7 +1016,7 @@ Image pixels are runtime-registered resources keyed by a caller-chosen `ImageId`
|
||||
```
|
||||
|
||||
```zig
|
||||
// Zig views (image and icon content is markup-excluded):
|
||||
// Zig views:
|
||||
ui.avatar(.{ .image = model.avatar_image, .semantics = .{ .label = "Octocat" } }, "OC"),
|
||||
ui.image(.{ .image = model.chart_image, .width = 120, .height = 80, .semantics = .{ .label = "Chart" } }),
|
||||
```
|
||||
|
||||
@@ -486,6 +486,7 @@ pub const ReferenceRenderSurface = struct {
|
||||
} else return error.ReferenceRenderUnsupportedCommand;
|
||||
|
||||
const src_rect = referenceImageSourceRect(image, value.src) orelse return;
|
||||
const sample_bounds = referenceImageSampleBounds(image, src_rect);
|
||||
const local_dst = referenceImageDestinationRect(value.dst, src_rect, value.fit) orelse return;
|
||||
const dst_rect = command.transform.transformRect(local_dst).normalized();
|
||||
// The rounded mask applies over the REQUESTED destination (the
|
||||
@@ -510,7 +511,7 @@ pub const ReferenceRenderSurface = struct {
|
||||
// once per (image content, size, phase) and every later repaint
|
||||
// — the cover-loading cascade, a re-opened view, a whole-pixel
|
||||
// move — blends from the panel.
|
||||
if (self.imageScalePanel(image, value, src_rect, dst_rect, pixel_rect)) |panel| {
|
||||
if (self.imageScalePanel(image, value, src_rect, sample_bounds, dst_rect, pixel_rect)) |panel| {
|
||||
const dst_x0: i64 = @intFromFloat(@floor(dst_rect.x));
|
||||
const dst_y0: i64 = @intFromFloat(@floor(dst_rect.y));
|
||||
var y = pixel_rect.y;
|
||||
@@ -549,7 +550,7 @@ pub const ReferenceRenderSurface = struct {
|
||||
if (has_mask and !referencePointInRoundedRect(point, mask_rect, mask_radius)) continue;
|
||||
const u = std.math.clamp((point.x - dst_rect.x) / dst_rect.width, 0, 1);
|
||||
const v = std.math.clamp((point.y - dst_rect.y) / dst_rect.height, 0, 1);
|
||||
const sample = referenceSampleImage(image, src_rect, u, v, value.sampling);
|
||||
const sample = referenceSampleImage(image, src_rect, sample_bounds, u, v, value.sampling);
|
||||
const index = (y * self.width + x) * 4;
|
||||
const dst = [4]u8{
|
||||
self.pixels[index + 0],
|
||||
@@ -575,7 +576,7 @@ pub const ReferenceRenderSurface = struct {
|
||||
width: usize,
|
||||
};
|
||||
|
||||
fn imageScalePanel(self: ReferenceRenderSurface, image: ReferenceImage, value: DrawImage, src_rect: geometry.RectF, dst_rect: geometry.RectF, pixel_rect: ReferencePixelRect) ?ImageScalePanel {
|
||||
fn imageScalePanel(self: ReferenceRenderSurface, image: ReferenceImage, value: DrawImage, src_rect: geometry.RectF, sample_bounds: ReferenceImageSampleBounds, dst_rect: geometry.RectF, pixel_rect: ReferencePixelRect) ?ImageScalePanel {
|
||||
const memo = self.render_memo orelse return null;
|
||||
// Exact-arithmetic bounds: pixel offsets and phases must stay in
|
||||
// f32's exact-integer range for the phase-relative identity
|
||||
@@ -630,7 +631,7 @@ pub const ReferenceRenderSurface = struct {
|
||||
var column: usize = 0;
|
||||
while (column < panel_width) : (column += 1) {
|
||||
const u = std.math.clamp(((@as(f32, @floatFromInt(column)) + 0.5) - phase_x) / dst_rect.width, 0, 1);
|
||||
const sample = referenceSampleImage(image, src_rect, u, v, value.sampling);
|
||||
const sample = referenceSampleImage(image, src_rect, sample_bounds, u, v, value.sampling);
|
||||
const offset = (row * panel_width + column) * 4;
|
||||
buffer[offset] = sample[0];
|
||||
buffer[offset + 1] = sample[1];
|
||||
@@ -1372,22 +1373,43 @@ const ReferencePremultipliedLinearColor = struct {
|
||||
a: f32 = 0,
|
||||
};
|
||||
|
||||
fn referenceSampleImage(image: ReferenceImage, src: geometry.RectF, u: f32, v: f32, sampling: ImageSampling) [4]u8 {
|
||||
fn referenceSampleImage(image: ReferenceImage, src: geometry.RectF, bounds: ReferenceImageSampleBounds, u: f32, v: f32, sampling: ImageSampling) [4]u8 {
|
||||
return switch (sampling) {
|
||||
.nearest => referenceSampleImageNearest(image, src, u, v),
|
||||
.linear => referenceSampleImageLinear(image, src, u, v),
|
||||
.nearest => referenceSampleImageNearest(image, src, bounds, u, v),
|
||||
.linear => referenceSampleImageLinear(image, src, bounds, u, v),
|
||||
};
|
||||
}
|
||||
|
||||
fn referenceSampleImageNearest(image: ReferenceImage, src: geometry.RectF, u: f32, v: f32) [4]u8 {
|
||||
const ReferenceImageSampleBounds = struct {
|
||||
min_x: i32,
|
||||
min_y: i32,
|
||||
max_x: i32,
|
||||
max_y: i32,
|
||||
};
|
||||
|
||||
/// Inclusive texel bounds touched by a clipped source rectangle. Native
|
||||
/// image APIs constrain filtering to their source portion; mirror that
|
||||
/// here so scaling a texture-atlas tile never samples an adjacent tile.
|
||||
fn referenceImageSampleBounds(image: ReferenceImage, src: geometry.RectF) ReferenceImageSampleBounds {
|
||||
const image_max_x: i32 = @intCast(image.width - 1);
|
||||
const image_max_y: i32 = @intCast(image.height - 1);
|
||||
return .{
|
||||
.min_x = clampI32(referenceFloor(src.minX()), 0, image_max_x),
|
||||
.min_y = clampI32(referenceFloor(src.minY()), 0, image_max_y),
|
||||
.max_x = clampI32(referenceCeil(src.maxX()) - 1, 0, image_max_x),
|
||||
.max_y = clampI32(referenceCeil(src.maxY()) - 1, 0, image_max_y),
|
||||
};
|
||||
}
|
||||
|
||||
fn referenceSampleImageNearest(image: ReferenceImage, src: geometry.RectF, bounds: ReferenceImageSampleBounds, u: f32, v: f32) [4]u8 {
|
||||
const sample_x_f = src.x + std.math.clamp(u, 0, 1) * src.width;
|
||||
const sample_y_f = src.y + std.math.clamp(v, 0, 1) * src.height;
|
||||
const x = clampI32(referenceFloor(sample_x_f), 0, @intCast(image.width - 1));
|
||||
const y = clampI32(referenceFloor(sample_y_f), 0, @intCast(image.height - 1));
|
||||
const x = clampI32(referenceFloor(sample_x_f), bounds.min_x, bounds.max_x);
|
||||
const y = clampI32(referenceFloor(sample_y_f), bounds.min_y, bounds.max_y);
|
||||
return referenceImagePixel(image, x, y);
|
||||
}
|
||||
|
||||
fn referenceSampleImageLinear(image: ReferenceImage, src: geometry.RectF, u: f32, v: f32) [4]u8 {
|
||||
fn referenceSampleImageLinear(image: ReferenceImage, src: geometry.RectF, bounds: ReferenceImageSampleBounds, u: f32, v: f32) [4]u8 {
|
||||
// Belt over the renderPass-level fill: direct sampler callers (unit
|
||||
// tests, future paths) stay correct. One predictable branch per
|
||||
// output pixel — noise next to the twelve pows the table replaces.
|
||||
@@ -1396,10 +1418,10 @@ fn referenceSampleImageLinear(image: ReferenceImage, src: geometry.RectF, u: f32
|
||||
const sample_y_f = src.y + std.math.clamp(v, 0, 1) * src.height - 0.5;
|
||||
const x_floor = referenceFloor(sample_x_f);
|
||||
const y_floor = referenceFloor(sample_y_f);
|
||||
const x0 = clampI32(x_floor, 0, @intCast(image.width - 1));
|
||||
const y0 = clampI32(y_floor, 0, @intCast(image.height - 1));
|
||||
const x1 = clampI32(x_floor + 1, 0, @intCast(image.width - 1));
|
||||
const y1 = clampI32(y_floor + 1, 0, @intCast(image.height - 1));
|
||||
const x0 = clampI32(x_floor, bounds.min_x, bounds.max_x);
|
||||
const y0 = clampI32(y_floor, bounds.min_y, bounds.max_y);
|
||||
const x1 = clampI32(x_floor + 1, bounds.min_x, bounds.max_x);
|
||||
const y1 = clampI32(y_floor + 1, bounds.min_y, bounds.max_y);
|
||||
const tx = std.math.clamp(sample_x_f - @as(f32, @floatFromInt(x_floor)), 0, 1);
|
||||
const ty = std.math.clamp(sample_y_f - @as(f32, @floatFromInt(y_floor)), 0, 1);
|
||||
|
||||
|
||||
@@ -1731,6 +1731,69 @@ test "reference renderer bilinear-filters scaled images" {
|
||||
try expectPixelRgba8(.{ 255, 255, 255, 255 }, surface, 3, 3);
|
||||
}
|
||||
|
||||
test "reference renderer keeps linear atlas crops inside their source rectangle" {
|
||||
const commands = [_]CanvasCommand{.{ .draw_image = .{
|
||||
.id = 1,
|
||||
.image_id = 42,
|
||||
.src = geometry.RectF.init(1, 1, 2, 2),
|
||||
.dst = geometry.RectF.init(0, 0, 4, 4),
|
||||
} }};
|
||||
|
||||
// A green 2x2 tile surrounded by red atlas neighbors. Scaling the
|
||||
// crop 2x must stay green through its edge pixels; full-image clamp
|
||||
// would blend red into every side through the bilinear taps.
|
||||
var image_pixels: [4 * 4 * 4]u8 = undefined;
|
||||
for (0..16) |index| {
|
||||
image_pixels[index * 4 + 0] = 255;
|
||||
image_pixels[index * 4 + 1] = 0;
|
||||
image_pixels[index * 4 + 2] = 0;
|
||||
image_pixels[index * 4 + 3] = 255;
|
||||
}
|
||||
for (1..3) |y| {
|
||||
for (1..3) |x| {
|
||||
const index = (y * 4 + x) * 4;
|
||||
image_pixels[index + 0] = 0;
|
||||
image_pixels[index + 1] = 255;
|
||||
image_pixels[index + 2] = 0;
|
||||
}
|
||||
}
|
||||
const images = [_]ReferenceImage{.{
|
||||
.id = 42,
|
||||
.width = 4,
|
||||
.height = 4,
|
||||
.pixels = &image_pixels,
|
||||
}};
|
||||
|
||||
var render_commands: [1]RenderCommand = undefined;
|
||||
var render_batches: [1]RenderBatch = undefined;
|
||||
var resources: [1]RenderResource = undefined;
|
||||
var resource_cache_entries: [1]RenderResourceCacheEntry = undefined;
|
||||
var resource_cache_actions: [1]RenderResourceCacheAction = undefined;
|
||||
var glyphs: [0]GlyphAtlasEntry = .{};
|
||||
var changes: [0]DiffChange = .{};
|
||||
const frame = try (DisplayList{ .commands = &commands }).framePlan(null, .{
|
||||
.surface_size = geometry.SizeF.init(4, 4),
|
||||
}, .{
|
||||
.render_commands = &render_commands,
|
||||
.render_batches = &render_batches,
|
||||
.resources = &resources,
|
||||
.resource_cache_entries = &resource_cache_entries,
|
||||
.resource_cache_actions = &resource_cache_actions,
|
||||
.glyph_atlas_entries = &glyphs,
|
||||
.changes = &changes,
|
||||
});
|
||||
|
||||
var pixels: [4 * 4 * 4]u8 = undefined;
|
||||
const surface = (try ReferenceRenderSurface.init(4, 4, &pixels)).withImages(&images);
|
||||
try surface.renderPass(frame.renderPass(), Color.rgb8(0, 0, 0));
|
||||
|
||||
for (0..4) |y| {
|
||||
for (0..4) |x| {
|
||||
try expectPixelRgba8(.{ 0, 255, 0, 255 }, surface, x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "reference renderer nearest-filters scaled images" {
|
||||
const commands = [_]CanvasCommand{.{ .draw_image = .{
|
||||
.id = 1,
|
||||
|
||||
@@ -579,6 +579,14 @@ pub fn Ui(comptime Msg: type) type {
|
||||
/// shape, one bit of the id space apart (see
|
||||
/// `canvas.media_surface_image_id_bit`).
|
||||
image: canvas.ImageId = 0,
|
||||
/// Optional source rectangle in image pixel coordinates for
|
||||
/// image-bearing widgets. Null draws the whole registered
|
||||
/// image; a rectangle draws only that sub-region, clipped to
|
||||
/// the registered image bounds — the texture-atlas path.
|
||||
/// Crops use nearest sampling so filtering cannot bleed an
|
||||
/// adjacent atlas region. The destination remains the
|
||||
/// widget's resolved frame.
|
||||
image_src: ?geometry.RectF = null,
|
||||
/// Vector icon name drawn inside icon-bearing controls
|
||||
/// (`button`, `toggle_button`, `icon_button`, `list_item`,
|
||||
/// `menu_item`): a built-in registry name
|
||||
@@ -3759,6 +3767,7 @@ pub fn Ui(comptime Msg: type) type {
|
||||
.autofocus = options.autofocus,
|
||||
.submit_on_enter = options.submit_on_enter,
|
||||
.image_id = options.image,
|
||||
.image_src = options.image_src,
|
||||
.value = options.value,
|
||||
.value_x = options.value_x,
|
||||
.tree_level = options.tree_level,
|
||||
|
||||
@@ -1269,6 +1269,14 @@ pub const tooltip_delay_dependent_attr_message = "tooltip-delay needs anchor on
|
||||
|
||||
pub const image_binding_message = "image takes one {binding} to a u64 ImageId the app registered at runtime (Cmd.imageLoad, fx.loadImage, fx.registerImageBytes) - runtime image ids are model data, not markup literals; 0 renders nothing (an avatar falls back to its initials)";
|
||||
pub const image_binding_element_message = "image is only supported on avatar and image - the remaining image-bearing widget (icon-button) stays a Zig view (ElementOptions.image)";
|
||||
pub const image_source_element_message = "source-x, source-y, source-width, and source-height are only supported on avatar and image - they crop a runtime-registered image in decoded-image pixel coordinates";
|
||||
pub const image_source_binding_message = "source-x, source-y, source-width, and source-height require the element's image binding - without a registered image the source rectangle is inert";
|
||||
pub const image_source_complete_message = "source-x, source-y, source-width, and source-height must be declared together - they form one source rectangle in decoded-image pixel coordinates";
|
||||
pub const image_source_attr_names = [_][]const u8{ "source-x", "source-y", "source-width", "source-height" };
|
||||
|
||||
pub fn imageSourceAttrName(name: []const u8) bool {
|
||||
return nameInList(name, &image_source_attr_names);
|
||||
}
|
||||
pub const image_missing_image_message = "image requires image={binding} naming the u64 ImageId the app registered at runtime - without one the leaf can never draw anything (dead markup, same policy as icon without name)";
|
||||
pub const image_children_message = "image is a leaf - it takes no children";
|
||||
|
||||
@@ -3353,6 +3361,27 @@ fn validateNode(document: MarkupDocument, node: MarkupNode, parent_element: ?[]c
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (imageSourceAttrName(attribute.name)) {
|
||||
// A registered-image crop is one atomic rectangle in
|
||||
// decoded-image pixel coordinates. Partial declarations
|
||||
// and crops without an image would otherwise become
|
||||
// silently inert data.
|
||||
if (!std.mem.eql(u8, node.name, "avatar") and !std.mem.eql(u8, node.name, "image")) {
|
||||
return attrError(node, attribute, image_source_element_message);
|
||||
}
|
||||
if (node.attr("image") == null) {
|
||||
return attrError(node, attribute, image_source_binding_message);
|
||||
}
|
||||
for (image_source_attr_names) |name| {
|
||||
if (node.attr(name) == null) {
|
||||
return attrError(node, attribute, image_source_complete_message);
|
||||
}
|
||||
}
|
||||
if (attrExpressionError(attribute.value, invalid_expression_message)) |message| {
|
||||
return attrError(node, attribute, message);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (std.mem.eql(u8, attribute.name, "surface")) {
|
||||
// The media-surface producer rendezvous, media-surface
|
||||
// scoped: surface ids are model data a producer
|
||||
|
||||
@@ -1820,6 +1820,7 @@ fn CompiledMarkupEngine(comptime ModelT: type, comptime MsgT: type, comptime res
|
||||
// ---------------------------------------------------- attributes
|
||||
|
||||
fn applyAttrs(comptime node: markup.MarkupNode, comptime entries: []const ScopeEntry, ui: *Ui, model: *const ModelT, scope: anytype, options: *Ui.ElementOptions) void {
|
||||
applyImageSourceAttrs(node, entries, ui, model, scope, options);
|
||||
inline for (0..node.attrs.len) |attr_index| {
|
||||
const attribute = comptime node.attrs[attr_index];
|
||||
if (comptime std.mem.eql(u8, attribute.name, "kind")) {
|
||||
@@ -1840,6 +1841,8 @@ fn CompiledMarkupEngine(comptime ModelT: type, comptime MsgT: type, comptime res
|
||||
};
|
||||
} else if (comptime std.mem.eql(u8, attribute.name, "image")) {
|
||||
applyImageAttr(node, attribute.value, entries, ui, model, scope, options);
|
||||
} else if (comptime markup.imageSourceAttrName(attribute.name)) {
|
||||
// Consumed atomically by applyImageSourceAttrs above.
|
||||
} else if (comptime std.mem.eql(u8, attribute.name, "surface")) {
|
||||
applySurfaceAttr(node, attribute.value, entries, ui, model, scope, options);
|
||||
} else if (comptime std.mem.eql(u8, attribute.name, "pty")) {
|
||||
@@ -1943,6 +1946,35 @@ fn CompiledMarkupEngine(comptime ModelT: type, comptime MsgT: type, comptime res
|
||||
};
|
||||
}
|
||||
|
||||
/// Comptime mirror of the interpreter's atomic source-rectangle
|
||||
/// construction. Source-known shape mistakes are compile errors;
|
||||
/// bound numeric values evaluate through the shared expression
|
||||
/// path at runtime.
|
||||
fn applyImageSourceAttrs(comptime node: markup.MarkupNode, comptime entries: []const ScopeEntry, ui: *Ui, model: *const ModelT, scope: anytype, options: *Ui.ElementOptions) void {
|
||||
const has_any = comptime blk: {
|
||||
for (markup.image_source_attr_names) |name| {
|
||||
if (node.attr(name) != null) break :blk true;
|
||||
}
|
||||
break :blk false;
|
||||
};
|
||||
if (comptime !has_any) return;
|
||||
comptime {
|
||||
if (!std.mem.eql(u8, node.name, "avatar") and !std.mem.eql(u8, node.name, "image")) {
|
||||
fail(node, markup.image_source_element_message);
|
||||
}
|
||||
if (node.attr("image") == null) fail(node, markup.image_source_binding_message);
|
||||
for (markup.image_source_attr_names) |name| {
|
||||
if (node.attr(name) == null) fail(node, markup.image_source_complete_message);
|
||||
}
|
||||
}
|
||||
options.image_src = .{
|
||||
.x = floatAttr(node, entries, comptime node.attr("source-x").?, ui, model, scope),
|
||||
.y = floatAttr(node, entries, comptime node.attr("source-y").?, ui, model, scope),
|
||||
.width = floatAttr(node, entries, comptime node.attr("source-width").?, ui, model, scope),
|
||||
.height = floatAttr(node, entries, comptime node.attr("source-height").?, ui, model, scope),
|
||||
};
|
||||
}
|
||||
|
||||
/// Comptime mirror of the interpreter's `applySurfaceAttr`:
|
||||
/// `surface="{binding}"` on media-surface resolves to the
|
||||
/// model-owned u64 surface id a producer targets —
|
||||
|
||||
@@ -1406,12 +1406,14 @@ test "compiled image leaf binding matches the interpreter and the hand-written v
|
||||
try expectSameTree(fixture.ImageLeafMsg, hand, interpreted);
|
||||
try expectSameTree(fixture.ImageLeafMsg, hand, compiled);
|
||||
|
||||
// The field binding and the fn binding both resolve to the
|
||||
// widget's image id at comptime-unrolled access.
|
||||
// The field binding, source-coordinate bindings, and id fn all
|
||||
// resolve through comptime-unrolled access.
|
||||
const cover = compiled.root.children[0];
|
||||
try testing.expectEqual(canvas.WidgetKind.image, cover.kind);
|
||||
try testing.expectEqual(@as(canvas.ImageId, 42), cover.image_id);
|
||||
try testing.expectEqualDeep(@as(?geometry.RectF, geometry.RectF.init(4, 8, 32, 24)), cover.image_src);
|
||||
try testing.expectEqual(@as(canvas.ImageId, 43), compiled.root.children[1].image_id);
|
||||
try testing.expectEqual(@as(?geometry.RectF, null), compiled.root.children[1].image_src);
|
||||
|
||||
// 0 draws nothing in both engines — the not-loaded-yet state.
|
||||
const empty_model = fixture.ImageLeafModel{};
|
||||
|
||||
@@ -1248,6 +1248,13 @@ const Checker = struct {
|
||||
try self.requireAttrKind(node, attribute, resolved.kind, &.{.integer}, markup.image_binding_message);
|
||||
continue;
|
||||
}
|
||||
if (markup.imageSourceAttrName(attribute.name)) {
|
||||
// Structural validation owns the atomic/scoped rectangle
|
||||
// rules; the model-aware pass verifies every coordinate
|
||||
// binding is numeric and marks it used.
|
||||
try self.checkClassAttr(node, attribute, .number);
|
||||
continue;
|
||||
}
|
||||
if (std.mem.eql(u8, attribute.name, "surface")) {
|
||||
// Media-surface ids are model integers (engine parity —
|
||||
// the runtime-image-id shape exactly).
|
||||
|
||||
@@ -795,6 +795,27 @@ test "the image leaf's image binding checks as a model integer" {
|
||||
try testing.expectEqualStrings(markup.image_binding_message, message);
|
||||
}
|
||||
|
||||
test "registered-image source rectangle bindings check as numbers" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
const good = try parseFixture(arena,
|
||||
\\<column>
|
||||
\\ <image image="{count}" source-x="{ratio}" source-y="{count}" source-width="32" source-height="16" label="Atlas tile" />
|
||||
\\</column>
|
||||
);
|
||||
try testing.expectEqual(null, try contractMessage(arena, good, null));
|
||||
|
||||
const wrong = try parseFixture(arena,
|
||||
\\<column>
|
||||
\\ <image image="{count}" source-x="{name}" source-y="0" source-width="32" source-height="16" label="Atlas tile" />
|
||||
\\</column>
|
||||
);
|
||||
const message = (try contractMessage(arena, wrong, null)).?;
|
||||
try testing.expect(std.mem.startsWith(u8, message, "expected a number"));
|
||||
}
|
||||
|
||||
test "app: icon references check against the contract's registered icon list" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
@@ -696,6 +696,7 @@ test "the image attribute validates as one binding on avatar and image" {
|
||||
const valid = [_][]const u8{
|
||||
"<row>\n <avatar image=\"{user_image}\">CT</avatar>\n</row>",
|
||||
"<row>\n <image image=\"{cover}\" width=\"120\" height=\"80\" label=\"Cover art\" />\n</row>",
|
||||
"<row>\n <image image=\"{atlas}\" source-x=\"0\" source-y=\"32\" source-width=\"16\" source-height=\"16\" label=\"Atlas tile\" />\n</row>",
|
||||
};
|
||||
for (valid) |source| {
|
||||
var parser = markup.Parser.init(arena, source);
|
||||
@@ -711,6 +712,11 @@ test "the image attribute validates as one binding on avatar and image" {
|
||||
// attribute would be silently inert.
|
||||
.{ .source = "<row>\n <badge image=\"{user_image}\">3</badge>\n</row>", .message = markup.image_binding_element_message },
|
||||
.{ .source = "<column>\n <panel image=\"{user_image}\" />\n</column>", .message = markup.image_binding_element_message },
|
||||
// Source rectangles are image/avatar-only, require an image, and
|
||||
// are atomic so a missing coordinate never defaults silently.
|
||||
.{ .source = "<row>\n <badge source-x=\"0\" source-y=\"0\" source-width=\"16\" source-height=\"16\">3</badge>\n</row>", .message = markup.image_source_element_message },
|
||||
.{ .source = "<row>\n <avatar source-x=\"0\" source-y=\"0\" source-width=\"16\" source-height=\"16\">CT</avatar>\n</row>", .message = markup.image_source_binding_message },
|
||||
.{ .source = "<row>\n <image image=\"{atlas}\" source-x=\"0\" source-y=\"0\" source-width=\"16\" label=\"Tile\" />\n</row>", .message = markup.image_source_complete_message },
|
||||
// ...and required on the leaf: an unbound image is statically
|
||||
// dead markup (avatar keeps its initials fallback instead).
|
||||
.{ .source = "<row>\n <image label=\"Art\" />\n</row>", .message = markup.image_missing_image_message },
|
||||
|
||||
@@ -1714,6 +1714,7 @@ pub fn MarkupView(comptime ModelT: type, comptime MsgT: type) type {
|
||||
// ---------------------------------------------------- attributes
|
||||
|
||||
fn applyAttrs(self: *Self, scope: *Scope, node: markup.MarkupNode, options: *Ui.ElementOptions) BuildError!void {
|
||||
try self.applyImageSourceAttrs(scope, node, options);
|
||||
for (node.attrs) |attribute| {
|
||||
if (std.mem.eql(u8, attribute.name, "kind")) continue;
|
||||
if (std.mem.startsWith(u8, attribute.name, "on-")) {
|
||||
@@ -1751,6 +1752,7 @@ pub fn MarkupView(comptime ModelT: type, comptime MsgT: type) type {
|
||||
try self.applyImageAttr(scope, node, options, attribute);
|
||||
continue;
|
||||
}
|
||||
if (markup.imageSourceAttrName(attribute.name)) continue;
|
||||
if (std.mem.eql(u8, attribute.name, "surface")) {
|
||||
try self.applySurfaceAttr(scope, node, options, attribute);
|
||||
continue;
|
||||
@@ -1868,6 +1870,32 @@ pub fn MarkupView(comptime ModelT: type, comptime MsgT: type) type {
|
||||
};
|
||||
}
|
||||
|
||||
/// The four `source-*` attributes form one optional source
|
||||
/// rectangle in decoded-image pixel coordinates. Build it before
|
||||
/// the scalar attribute walk so declaration order carries no
|
||||
/// meaning; repeat the validator's shape checks for hot-reloaded
|
||||
/// documents that reached the interpreter without validation.
|
||||
fn applyImageSourceAttrs(self: *Self, scope: *Scope, node: markup.MarkupNode, options: *Ui.ElementOptions) BuildError!void {
|
||||
var any = false;
|
||||
for (markup.image_source_attr_names) |name| {
|
||||
if (node.attr(name) != null) any = true;
|
||||
}
|
||||
if (!any) return;
|
||||
if (!std.mem.eql(u8, node.name, "avatar") and !std.mem.eql(u8, node.name, "image")) {
|
||||
return self.failVoid(node, markup.image_source_element_message);
|
||||
}
|
||||
if (node.attr("image") == null) return self.failVoid(node, markup.image_source_binding_message);
|
||||
for (markup.image_source_attr_names) |name| {
|
||||
if (node.attr(name) == null) return self.failVoid(node, markup.image_source_complete_message);
|
||||
}
|
||||
options.image_src = .{
|
||||
.x = try self.floatAttr(scope, node, node.attrEntry("source-x").?),
|
||||
.y = try self.floatAttr(scope, node, node.attrEntry("source-y").?),
|
||||
.width = try self.floatAttr(scope, node, node.attrEntry("source-width").?),
|
||||
.height = try self.floatAttr(scope, node, node.attrEntry("source-height").?),
|
||||
};
|
||||
}
|
||||
|
||||
/// `surface="{binding}"` on media-surface: the model-owned u64
|
||||
/// surface id a producer targets — media-surface-only,
|
||||
/// binding-only, integer-valued (the runtime-image-id grammar
|
||||
|
||||
@@ -2846,6 +2846,9 @@ pub const ImageLeafModel = struct {
|
||||
/// A signed id field: bindings evaluate as i64, so a negative must
|
||||
/// reach the image seam as a value, never trap in the u64 cast.
|
||||
stale_image: i64 = -2,
|
||||
source_x: f32 = 4,
|
||||
source_width: f32 = 32,
|
||||
invalid_source: []const u8 = "wide",
|
||||
|
||||
/// A pub fn producing an ImageId binds like a field.
|
||||
pub fn thumbnail(model: *const ImageLeafModel) canvas.ImageId {
|
||||
@@ -2855,7 +2858,7 @@ pub const ImageLeafModel = struct {
|
||||
|
||||
pub const image_markup_source =
|
||||
\\<row gap="8">
|
||||
\\ <image image="{cover}" width="120" height="80" label="Cover art" />
|
||||
\\ <image image="{cover}" source-x="{source_x}" source-y="8" source-width="{source_width}" source-height="24" width="120" height="80" label="Cover art" />
|
||||
\\ <image image="{thumbnail}" width="48" height="48" label="" />
|
||||
\\</row>
|
||||
;
|
||||
@@ -2875,7 +2878,7 @@ pub const ImageLeafUi = canvas.Ui(ImageLeafMsg);
|
||||
/// shares.
|
||||
pub fn handImageLeafView(ui: *ImageLeafUi, model: *const ImageLeafModel) ImageLeafUi.Node {
|
||||
return ui.row(.{ .gap = 8 }, .{
|
||||
ui.image(.{ .image = model.cover, .width = 120, .height = 80, .semantics = .{ .label = "Cover art" } }),
|
||||
ui.image(.{ .image = model.cover, .image_src = geometry.RectF.init(model.source_x, 8, model.source_width, 24), .width = 120, .height = 80, .semantics = .{ .label = "Cover art" } }),
|
||||
ui.image(.{ .image = model.thumbnail(), .width = 48, .height = 48, .semantics = .{ .label = "" } }),
|
||||
});
|
||||
}
|
||||
@@ -2902,13 +2905,16 @@ test "the image leaf binds a dynamic ImageId from model fields and fns" {
|
||||
try collectIds(hand_tree.root, &hand_ids, testing.allocator);
|
||||
try testing.expectEqualSlices(canvas.ObjectId, hand_ids.items, markup_ids.items);
|
||||
|
||||
// The field binding and the fn binding both land in image_id.
|
||||
// The id and source-coordinate bindings land on the widget. The
|
||||
// second image omits a crop and draws the whole registered image.
|
||||
const cover = markup_tree.root.children[0];
|
||||
try testing.expectEqual(canvas.WidgetKind.image, cover.kind);
|
||||
try testing.expectEqual(@as(canvas.ImageId, 42), cover.image_id);
|
||||
try testing.expectEqualDeep(@as(?geometry.RectF, geometry.RectF.init(4, 8, 32, 24)), cover.image_src);
|
||||
try testing.expectEqualStrings("Cover art", cover.semantics.label);
|
||||
const thumbnail = markup_tree.root.children[1];
|
||||
try testing.expectEqual(@as(canvas.ImageId, 43), thumbnail.image_id);
|
||||
try testing.expectEqual(@as(?geometry.RectF, null), thumbnail.image_src);
|
||||
|
||||
// 0 is the "no image" sentinel: the leaf renders nothing (the
|
||||
// model simply has not loaded the image yet).
|
||||
@@ -2945,6 +2951,29 @@ test "image leaf misuse fails the build with the teaching messages" {
|
||||
.source = "<row>\n <image image=\"{cover}\" label=\"Art\"><text>Caption</text></image>\n</row>",
|
||||
.message = canvas.ui_markup.image_children_message,
|
||||
},
|
||||
.{
|
||||
// A source rectangle is atomic: omitting one coordinate is
|
||||
// a typo, not an implicit zero/default.
|
||||
.source = "<row>\n <image image=\"{cover}\" source-x=\"0\" source-y=\"0\" source-width=\"16\" label=\"Art\" />\n</row>",
|
||||
.message = canvas.ui_markup.image_source_complete_message,
|
||||
},
|
||||
.{
|
||||
// Cropping without an image would be inert (avatar may omit
|
||||
// image normally for its initials fallback).
|
||||
.source = "<row>\n <avatar source-x=\"0\" source-y=\"0\" source-width=\"16\" source-height=\"16\">CT</avatar>\n</row>",
|
||||
.message = canvas.ui_markup.image_source_binding_message,
|
||||
},
|
||||
.{
|
||||
// Only registered-image widgets consume source rectangles.
|
||||
.source = "<row>\n <badge source-x=\"0\" source-y=\"0\" source-width=\"16\" source-height=\"16\">3</badge>\n</row>",
|
||||
.message = canvas.ui_markup.image_source_element_message,
|
||||
},
|
||||
.{
|
||||
// Runtime model values still pass the numeric conversion
|
||||
// gate; a string cannot become a source coordinate.
|
||||
.source = "<row>\n <image image=\"{cover}\" source-x=\"{invalid_source}\" source-y=\"0\" source-width=\"16\" source-height=\"16\" label=\"Art\" />\n</row>",
|
||||
.message = "expected a number",
|
||||
},
|
||||
.{
|
||||
// Markup that skipped validation (hot reload) can reach the
|
||||
// engine without the binding: the leaf could only ever
|
||||
|
||||
@@ -591,6 +591,15 @@ pub const attrs = [_]AttrInfo{
|
||||
// minimum unconstrained so a capped element still shrinks with a
|
||||
// narrow parent.
|
||||
.{ .code = 98, .name = "max-width", .class = .number, .group = .option, .field = "max_width" },
|
||||
// Registered-image source rectangle, in decoded-image pixel
|
||||
// coordinates. The four values are one atomic declaration: the
|
||||
// validator scopes them to avatar/image and requires all four beside
|
||||
// the image binding. They lower together into ElementOptions.image_src,
|
||||
// so no individual attribute names a flat field.
|
||||
.{ .code = 99, .name = "source-x", .class = .number, .group = .element },
|
||||
.{ .code = 100, .name = "source-y", .class = .number, .group = .element },
|
||||
.{ .code = 101, .name = "source-width", .class = .number, .group = .element },
|
||||
.{ .code = 102, .name = "source-height", .class = .number, .group = .element },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------- events
|
||||
|
||||
@@ -22,7 +22,7 @@ test "registry codes are stable: assigned at birth, never renumbered or renamed"
|
||||
// the new fingerprint ONLY for additions; renames/renumbers are
|
||||
// schema-version-bump events, not silent edits.
|
||||
try testing.expectEqual(@as(usize, 70), schema.elements.len);
|
||||
try testing.expectEqual(@as(usize, 98), schema.attrs.len);
|
||||
try testing.expectEqual(@as(usize, 102), schema.attrs.len);
|
||||
try testing.expectEqual(@as(usize, 15), schema.events.len);
|
||||
// The element table runs through the span composite (64), the
|
||||
// bubble-reactions composite (65), the media surface (66), the
|
||||
@@ -48,9 +48,11 @@ test "registry codes are stable: assigned at birth, never renumbered or renamed"
|
||||
// code-diff line sets added-lines (94) and removed-lines (95), and
|
||||
// Markdown's registered-image iterable binding images (96), and the
|
||||
// textarea Enter policy submit-on-enter (97), and the responsive
|
||||
// layout ceiling max-width (98).
|
||||
// layout ceiling max-width (98), and the registered-image source
|
||||
// rectangle source-x (99), source-y (100), source-width (101), and
|
||||
// source-height (102).
|
||||
try testing.expectEqual(
|
||||
@as(u64, 0xb8991dcb86471877),
|
||||
@as(u64, 0x3614b1510b48dcf4),
|
||||
tableFingerprint(schema.AttrInfo, &schema.attrs),
|
||||
);
|
||||
// The event table runs through the pointer-hover containment pair
|
||||
|
||||
@@ -562,9 +562,9 @@ test "avatar and image sugar carry registered image ids" {
|
||||
|
||||
var ui = InboxUi.init(arena_state.allocator());
|
||||
const tree = try ui.finalize(ui.column(.{}, .{
|
||||
ui.avatar(.{ .image = 77, .semantics = .{ .label = "Native SDK" } }, "NS"),
|
||||
ui.avatar(.{ .image = 77, .image_src = geometry.RectF.init(32, 0, 32, 32), .semantics = .{ .label = "Native SDK" } }, "NS"),
|
||||
ui.avatar(.{}, "NS"),
|
||||
ui.image(.{ .image = 42, .semantics = .{ .label = "Chart" } }),
|
||||
ui.image(.{ .image = 42, .image_src = geometry.RectF.init(4, 8, 24, 16), .semantics = .{ .label = "Chart" } }),
|
||||
}));
|
||||
|
||||
// With an image id the avatar clips it to the circle (cover fit);
|
||||
@@ -572,6 +572,7 @@ test "avatar and image sugar carry registered image ids" {
|
||||
const with_image = tree.root.children[0];
|
||||
try testing.expectEqual(canvas.WidgetKind.avatar, with_image.kind);
|
||||
try testing.expectEqual(@as(canvas.ImageId, 77), with_image.image_id);
|
||||
try testing.expectEqualDeep(@as(?geometry.RectF, geometry.RectF.init(32, 0, 32, 32)), with_image.image_src);
|
||||
try testing.expectEqual(canvas.ImageFit.cover, with_image.image_fit);
|
||||
try testing.expectEqualStrings("NS", with_image.text);
|
||||
|
||||
@@ -582,6 +583,7 @@ test "avatar and image sugar carry registered image ids" {
|
||||
const image_leaf = tree.root.children[2];
|
||||
try testing.expectEqual(canvas.WidgetKind.image, image_leaf.kind);
|
||||
try testing.expectEqual(@as(canvas.ImageId, 42), image_leaf.image_id);
|
||||
try testing.expectEqualDeep(@as(?geometry.RectF, geometry.RectF.init(4, 8, 24, 16)), image_leaf.image_src);
|
||||
}
|
||||
|
||||
test "payload-carrying handlers build messages from edits and values" {
|
||||
|
||||
@@ -2697,7 +2697,10 @@ fn emitImageWidget(builder: *Builder, widget: Widget) Error!void {
|
||||
.dst = widget.frame,
|
||||
.opacity = widget.image_opacity,
|
||||
.fit = widget.image_fit,
|
||||
.sampling = widget.image_sampling,
|
||||
// Packet hosts expose filtering but no per-draw sampler-address
|
||||
// mode. Nearest sampling keeps an atlas crop from filtering
|
||||
// across its source boundary; whole-image draws stay linear.
|
||||
.sampling = if (widget.image_src != null) .nearest else widget.image_sampling,
|
||||
});
|
||||
if (clips_image) try builder.popClip();
|
||||
}
|
||||
@@ -2987,7 +2990,9 @@ fn emitAvatarWidget(builder: *Builder, widget: Widget, tokens: DesignTokens) Err
|
||||
.dst = widget.frame,
|
||||
.opacity = widget.image_opacity,
|
||||
.fit = widget.image_fit,
|
||||
.sampling = widget.image_sampling,
|
||||
// See emitImageWidget: a cropped avatar is an atlas draw and
|
||||
// must not sample neighboring regions on packet hosts.
|
||||
.sampling = if (widget.image_src != null) .nearest else widget.image_sampling,
|
||||
// The render plan flattens the clip stack to rects, so the
|
||||
// pill clip above only crops the bounds; the draw's own
|
||||
// radius mask is what actually rounds the image.
|
||||
|
||||
@@ -829,7 +829,7 @@ test "widget image emits draw image and exposes image semantics" {
|
||||
.image_id = 42,
|
||||
.image_src = geometry.RectF.init(0, 0, 320, 192),
|
||||
.image_fit = .cover,
|
||||
.image_sampling = .nearest,
|
||||
.image_sampling = .linear,
|
||||
.image_opacity = 0.75,
|
||||
.semantics = .{ .label = "Deployment preview" },
|
||||
};
|
||||
@@ -864,6 +864,8 @@ test "widget image emits draw image and exposes image semantics" {
|
||||
try expectRect(geometry.RectF.init(0, 0, 320, 192), draw.src);
|
||||
try expectRect(geometry.RectF.init(12, 14, 80, 48), draw.dst);
|
||||
try std.testing.expectEqual(ImageFit.cover, draw.fit);
|
||||
// Atlas crops force nearest sampling at the widget seam so
|
||||
// packet hosts cannot filter outside the source rectangle.
|
||||
try std.testing.expectEqual(ImageSampling.nearest, draw.sampling);
|
||||
try std.testing.expectEqual(@as(f32, 0.75), draw.opacity);
|
||||
},
|
||||
|
||||
@@ -85,7 +85,7 @@ pub const element_docs = [_]Doc{
|
||||
.{ .name = "span", .doc = "Inline styled run inside a <text> paragraph: mixed-weight, mono, italic, scaled, underlined, and token-colored runs word-wrap as ONE paragraph and announce as one text run. Takes weight (regular|medium|bold), mono, italic, scale (a positive multiplier on the paragraph's base size), underline, foreground; content is one run of text ({bindings} work). Whitespace between runs collapses to a single space; runs written with no whitespace between them abut. Spans do not nest; layout, events, and identity stay on the enclosing text." },
|
||||
.{ .name = "reactions", .doc = "The bubble's reaction pill (only inside bubble, at most one): a small muted capsule straddling the bubble's bottom edge, holding one run of text ({bindings} work). Takes text-alignment naming the dock — start, center, or end (the default trailing dock). Consumes no layout space (it overlaps like the reference); give the next turn breathing room with the thread's own spacing. Draws on the page plane, so a primary bubble's knockout ink never applies." },
|
||||
.{ .name = "media-surface", .doc = "The media surface leaf: composites a texture produced OUTSIDE the widget tree (a video decoder, a camera pipeline, an external renderer) into the layout like any widget — clipped, z-ordered, transformed. surface is one {binding} to the model-owned u64 surface id a Zig-tier producer targets (runtime.acquireMediaSurfaceProducer pushes RGBA8 frames, latest-wins, paced by the presented-frame clock). Until the first frame arrives it shows a deterministic id-derived placeholder — which is also all that goldens, screenshots, and session replay ever show: texture contents are presentation chrome. Display-only (presses fall through); size it like an image (width/height or grow); label it for screen readers." },
|
||||
.{ .name = "image", .doc = "The image leaf: draws a RUNTIME-REGISTERED image by its model-owned u64 ImageId — the id Cmd.imageLoad (TS) or fx.loadImage/fx.registerImageBytes (Zig) registered pixels under. Photo-size encoded sources decode aspect-preservingly to the app's fixed pixel budget (1 MiB default; app.zon images may raise it through 8 MiB), and load results report the registered dimensions. image is one required {binding}; ids are model data, never markup literals, and 0 draws nothing (store the id in the model only when the load reports loaded). Display-only; size it with width/height or grow; label it." },
|
||||
.{ .name = "image", .doc = "The image leaf: draws a RUNTIME-REGISTERED image by its model-owned u64 ImageId — the id Cmd.imageLoad (TS) or fx.loadImage/fx.registerImageBytes (Zig) registered pixels under. Photo-size encoded sources decode aspect-preservingly to the app's fixed pixel budget (1 MiB default; app.zon images may raise it through 8 MiB), and load results report the registered dimensions. image is one required {binding}; ids are model data, never markup literals, and 0 draws nothing (store the id in the model only when the load reports loaded). source-x/source-y/source-width/source-height optionally select one atlas region in decoded-image pixel coordinates. Display-only; size it with width/height or grow; label it." },
|
||||
.{ .name = "video", .doc = "The video leaf: plays the app's single platform-decoded video into the framework-owned media-surface (macOS decodes with AVFoundation; hosts without a decoder deliver one explicit failed event). src declares the source — an app-assets path or an http(s) URL, resolved local-first exactly like audio; autoplay (default true), loop, and muted shape the fresh playback; controls composes the house transport chrome (play/pause, scrub bar, time readout) under the picture, and without it the element is the surface alone — compose your own controls from the video command vocabulary. Until a decoded frame arrives (and in every golden, screenshot, and replay) the surface shows its deterministic placeholder: pixels are presentation chrome, transport is the journaled truth. Size it with width/height or grow (no intrinsic size); label it for screen readers." },
|
||||
.{ .name = "terminal", .doc = "The terminal leaf: renders the framework-owned emulator session behind a model-owned pty effect key — the grid as real text with geometric box drawing, a theme-derived ANSI palette, selection, cursor, and scrollback — and routes keys, IME text, and wheel scrollback to it when focused. pty is one {binding} to the u64 key the app's ptySpawn named (required; keys are model data, never markup literals; 0 renders the empty surface); scrollback echoes the app-visible offset back under the scroll value source-wins rule, and on-terminal delivers the post-change view state. The grid derives its cols/rows from the frame the layout resolves (the runtime pushes them to the pty), so size it like a leaf: grow or a definite width/height. An interactive control: give it a label." },
|
||||
};
|
||||
@@ -227,6 +227,10 @@ pub const timeline_item_attr_docs = [_]Doc{
|
||||
|
||||
pub const avatar_attr_docs = [_]Doc{
|
||||
.{ .name = "image", .doc = "avatar and image: one {binding} to a u64 ImageId the app registered at runtime (Cmd.imageLoad, fx.loadImage, fx.registerImageBytes); encoded photos decode-to-fit the app's pixel budget and results carry the registered dimensions. 0 draws nothing (an avatar falls back to its initials). Required on the image leaf." },
|
||||
.{ .name = "source-x", .doc = "avatar and image: left edge of an optional source crop, in decoded-image pixels. Declare all four source-* attributes together beside image." },
|
||||
.{ .name = "source-y", .doc = "avatar and image: top edge of an optional source crop, in decoded-image pixels. Declare all four source-* attributes together beside image." },
|
||||
.{ .name = "source-width", .doc = "avatar and image: width of an optional source crop, in decoded-image pixels. Declare all four source-* attributes together beside image." },
|
||||
.{ .name = "source-height", .doc = "avatar and image: height of an optional source crop, in decoded-image pixels. Declare all four source-* attributes together beside image." },
|
||||
};
|
||||
|
||||
pub const media_surface_attr_docs = [_]Doc{
|
||||
|
||||
@@ -340,8 +340,8 @@ pub const Server = struct {
|
||||
for (span_attr_docs) |doc| try writeCompletionItem(&js, doc.name, .property, "span attribute", doc.doc);
|
||||
} else if (std.mem.eql(u8, element_name, "reactions")) {
|
||||
for (reactions_attr_docs) |doc| try writeCompletionItem(&js, doc.name, .property, "reactions attribute", doc.doc);
|
||||
} else if (std.mem.eql(u8, element_name, "avatar")) {
|
||||
for (avatar_attr_docs) |doc| try writeCompletionItem(&js, doc.name, .property, "avatar attribute", doc.doc);
|
||||
} else if (std.mem.eql(u8, element_name, "avatar") or std.mem.eql(u8, element_name, "image")) {
|
||||
for (avatar_attr_docs) |doc| try writeCompletionItem(&js, doc.name, .property, "avatar/image attribute", doc.doc);
|
||||
for (attribute_docs) |doc| try writeCompletionItem(&js, doc.name, .property, "markup attribute", doc.doc);
|
||||
for (event_docs) |doc| try writeCompletionItem(&js, doc.name, .event, "markup event", doc.doc);
|
||||
} else if (std.mem.eql(u8, element_name, "dropdown-menu")) {
|
||||
@@ -718,6 +718,10 @@ test "serve: initialize, didOpen with broken markup, publishDiagnostics round tr
|
||||
"{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/didChange\",\"params\":{\"textDocument\":{" ++
|
||||
"\"uri\":\"file:///tmp/app.native\",\"version\":3},\"contentChanges\":[{" ++
|
||||
"\"text\":\"<avatar >CT</avatar>\"}]}}";
|
||||
const image_doc =
|
||||
"{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/didChange\",\"params\":{\"textDocument\":{" ++
|
||||
"\"uri\":\"file:///tmp/app.native\",\"version\":4},\"contentChanges\":[{" ++
|
||||
"\"text\":\"<image image=\\\"{cover}\\\" />\"}]}}";
|
||||
|
||||
var input: std.Io.Writer.Allocating = .init(arena);
|
||||
for ([_][]const u8{
|
||||
@@ -729,7 +733,9 @@ test "serve: initialize, didOpen with broken markup, publishDiagnostics round tr
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"textDocument/hover\",\"params\":{\"textDocument\":{\"uri\":\"file:///tmp/app.native\"},\"position\":{\"line\":0,\"character\":2}}}",
|
||||
avatar_doc,
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"textDocument/completion\",\"params\":{\"textDocument\":{\"uri\":\"file:///tmp/app.native\"},\"position\":{\"line\":0,\"character\":8}}}",
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"shutdown\"}",
|
||||
image_doc,
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"textDocument/completion\",\"params\":{\"textDocument\":{\"uri\":\"file:///tmp/app.native\"},\"position\":{\"line\":0,\"character\":23}}}",
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":6,\"method\":\"shutdown\"}",
|
||||
"{\"jsonrpc\":\"2.0\",\"method\":\"exit\"}",
|
||||
}) |body| {
|
||||
const framed = try frame(arena, body);
|
||||
@@ -754,8 +760,8 @@ test "serve: initialize, didOpen with broken markup, publishDiagnostics round tr
|
||||
}
|
||||
// initialize response, diagnostics (broken), diagnostics (clean),
|
||||
// completion, hover, diagnostics (avatar), avatar completion,
|
||||
// shutdown.
|
||||
try testing.expectEqual(@as(usize, 8), bodies.items.len);
|
||||
// diagnostics (image), image completion, shutdown.
|
||||
try testing.expectEqual(@as(usize, 10), bodies.items.len);
|
||||
|
||||
try testing.expect(std.mem.indexOf(u8, bodies.items[0], "\"id\":1") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, bodies.items[0], "\"capabilities\"") != null);
|
||||
@@ -785,15 +791,20 @@ test "serve: initialize, didOpen with broken markup, publishDiagnostics round tr
|
||||
// Hover over `row` returns the element doc.
|
||||
try testing.expect(std.mem.indexOf(u8, bodies.items[4], "Flex container") != null);
|
||||
|
||||
// Completion inside `<avatar ` offers the image binding alongside the
|
||||
// generic attributes and events.
|
||||
// Both registered-image elements receive their shared attributes.
|
||||
try testing.expect(std.mem.indexOf(u8, bodies.items[6], "\"label\":\"image\"") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, bodies.items[6], "\"label\":\"source-x\"") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, bodies.items[6], "\"label\":\"label\"") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, bodies.items[6], "\"label\":\"on-press\"") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, bodies.items[8], "\"label\":\"image\"") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, bodies.items[8], "\"label\":\"source-x\"") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, bodies.items[8], "\"label\":\"source-height\"") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, bodies.items[8], "\"label\":\"label\"") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, bodies.items[8], "\"label\":\"on-press\"") != null);
|
||||
|
||||
// Shutdown response.
|
||||
try testing.expect(std.mem.indexOf(u8, bodies.items[7], "\"id\":5") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, bodies.items[7], "\"result\":null") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, bodies.items[9], "\"id\":6") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, bodies.items[9], "\"result\":null") != null);
|
||||
}
|
||||
|
||||
test "analyze reports parser and validation findings with positions" {
|
||||
|
||||
Reference in New Issue
Block a user