Compare commits

...

18 Commits

Author SHA1 Message Date
Chris Tate f1aa2f156b Carry the two-axis scroll state through corewire's ABI seam
- dispatch_scroll_state now carries the eight per-axis scalars in declaration order; the retired one-axis quartet rides the generic record entry
- pin the new routing and the quartet's retirement in emit tests, and match the stub core's export to the widened symbol
2026-07-24 00:55:01 -05:00
Chris Tate 54cdd21a5b Make native wheel routing engine-exact: one owner or the wire
- One owner takes the WHOLE event natively and clamps at the edge exactly like the engine's consume-and-drop rule; split owners, dead axes, and wire-scrolled owners ride the wire whole — the residual measurement, fractional carries, and partial-clamp forwarding are gone, and consumption uses the engine's exact bounds.
- Zero-delta phase events forward only the gesture bookkeeping to the last native recipient; nonzero no-owner deltas go wire-only, so an overlay opening mid-momentum can no longer keep moving the obscured region.
- Fix the occluder model on every reported edge: view-local modal rects, tooltip passthrough, scrim=false previews, hidden/concealed surfaces, render transforms, self- and paint-order exemptions for anchored scroll regions, and a fail-safe whole-view occluder when surfaces outnumber the budget; both-axes semantic steps read child-frame range so the stepped axis matches the exposed semantics.
2026-07-24 00:44:21 -05:00
Chris Tate bbb8fbb687 Honor overlays, phases, and revoked axes on the native wheel seam
- Push OCCLUDER rects (anchored floating surfaces at their frames, modal catchers as the whole view) with per-driver masks through the driver sync, so the host's geometric wheel routing declines exactly the points the engine's hit test would give to an overlay's branch; drivers inside the overlay stay exempt. Pinned through the null platform.
- Forward zero-delta phase events (begins, the terminal Ended/Cancelled) to the gesture's last native recipient so the scroller's overscroll bookkeeping always terminates, and scope residual carries to the hit region so fractional motion never leaks onto whatever chain the pointer wanders to next.
- Pin REVOKED axes home on native-driven regions too (the range clamp stays the OS scroller's), so an axis flip behaves identically on every host instead of resurrecting a stale echoed offset on re-grant.
2026-07-24 00:44:21 -05:00
Chris Tate cb23556824 Scope phase-less wheel gesture state to bursts, not events
- A quiet gap (250ms, well past the input queue's one-frame coalescing) is the gesture boundary for discrete streams: residual carries keep accumulating sub-half-point deltas and wire bindings outlive any in-flight relative hand-off, while a fresh burst still resets both so no binding lives forever.
2026-07-24 00:44:21 -05:00
Chris Tate 599901f62d Treat each discrete wheel as its own gesture
- Legacy (phase-less) events reset the wire bindings and residual carries per event, so a binding can never demote a region to the wire forever.
2026-07-24 00:44:21 -05:00
Chris Tate a4f6278b38 Close the native-seam races and honesty gaps from the cycle-2 review
- Restrict wheel-owner resolution to the hit region's ancestor chain (parent driver ids through the ABI), anchor wire hand-offs at the gesture point, carry sub-half-point residuals across events, and wire-bind any region the engine scrolled this gesture so a later absolute native report can never erase relative wire motion.
- Skip subtrees anchored directly to the region in every VERTICAL extent walker (a stationary surface counted against a moving offset grew the range without bound), decide the assistive step axis from the semantics metrics (concealed-disclosure and anchored exclusions included), and page exactly one axis on widget-level semantic steps.
- Flag content stranded before a horizontal region's origin in both audits (offsets clamp at zero), double the reach-latch capacity for the (id, axis) key space, and fire reach signals only when the latch stores.
2026-07-24 00:44:21 -05:00
Chris Tate c49978274a Size the collection shelf to actually overflow at desktop widths
- 168-point tiles keep seven sibling covers wider than the regular shell's content row, so the rail scrolls where it ships.
2026-07-24 00:44:21 -05:00
Chris Tate 12d7e2f48e Flush driver offsets before the no-owner wire hand-off too
- Every wire emission now rides the offsets-first clock, including wheel events no native region owns.
2026-07-24 00:44:21 -05:00
Chris Tate b5227d2722 Resolve wheel owners per axis with measured residuals and axis-keyed reach latches
- Replace the single-winner gesture lock with per-axis owner resolution at the gesture's anchor point, re-evaluated per event against live scroller offsets: saturation hands an axis to its ancestor (elastic-take never outranks a consumer), cross-owner diagonals whose native recipient would eat the other axis ride the wire whole, and the residual is MEASURED (delta minus what the scroller actually absorbed) so a nearly saturated region can never double-spend.
- Flush the coalesced driver report before any residual or cross-owner wire emission (offsets first, one clock), and route wheels over a visible overlay scroller through the same splitter instead of the knob.
- Key reach-end/reach-start hysteresis latches by (id, axis) so a region whose primary axis changes re-arms honestly.
2026-07-24 00:44:21 -05:00
Chris Tate 5832d9abef Match native wheel routing to the engine walk and step the live axis
- Route each gesture axis to the deepest native driver that can consume its delta right now (direction-aware, so a saturated inner region hands an outward swipe to its ancestor), falling back to the outermost elastic region — rubber-band regions bounce even with short content — and reconcile the driver array in layout pre-order so keyed reorders keep the walk honest.
- Forward a residual axis only when the locked driver can neither move nor bounce on it.
- Assistive increment/decrement on a both-axes region whose only live axis is horizontal now step sideways instead of paging a zero-range vertical axis; pinned through the accessibility action path.
2026-07-24 00:44:21 -05:00
Chris Tate 84c1b45da2 Pin the axis-aware focus-reachability audit
- A horizontal shelf's offscreen-right tile is reachable by design while a below-viewport button inside it stays a finding.
2026-07-24 00:44:21 -05:00
Chris Tate 746e472361 Route every macOS wheel through the axis splitter and split set_offset per axis
- Stop the driver view claiming wheel events at hit test: the surface's dominant-axis selection, gesture lock, and residual-axis split now own all wheel routing, so a diagonal gesture over a nested vertical list cannot swallow the horizontal component its ancestor owns.
- Make the driver set-offset flag per axis end to end, so a programmatic vertical write can never push a stale horizontal offset over native motion whose coalesced report is still in flight.
- Teach the a11y focus-reachability audit the axis grants (offscreen-right tiles on a horizontal shelf are reachable, below-viewport content there is not) and bump the model-contract format to 2 - a format-1 artifact classified the retired one-axis scroll record as a scroll_state payload, which would be a false pass today.
2026-07-24 00:44:21 -05:00
Chris Tate 88ec9bde03 Split residual wheel axes on macOS and arm driver chrome per grant
- Forward the axis a locked native driver cannot travel to the wire, so one diagonal gesture scrolls a vertical list natively while its delta_x reaches the horizontal ancestor through per-axis routing.
- Carry the axis grants on the driver spec: elasticity and scroller chrome arm only on granted axes, so a horizontal-only region can never bounce vertically or report an offset the runtime would fight.
- Accept the optional delta-x token in the automation CLI, and warn at the builder seam when a DYNAMIC value pairs a horizontal grant with virtualization or value_x with a vertical-only region (both engines share the seam, so the diagnostic stays engine-uniform where validation cannot see the resolved value).
2026-07-24 00:44:21 -05:00
Chris Tate c9a68e64f2 Harden two-axis scrolling per external review
- Keep the vertical scrollbar's thickness formula byte-identical (per-axis derivation), exclude anchored/clip-scope/concealed-disclosure subtrees from horizontal extents, and stop region-anchored surfaces riding scrolled content.
- Route macOS native wheel gestures by dominant-axis capability with an engine-wire fallback, and rewrite a queued driver report when a programmatic offset lands.
- Pick scroll semantics by live range on both-axes regions, page every granted axis on assistive steps, carry fling velocity per axis only while its offset survived, refine the axis/virtualized validation to the horizontal grant, and migrate bench_render.
2026-07-24 00:44:21 -05:00
Chris Tate c96bc191b9 Give the soundboard detail page a horizontal collection shelf
- Rail every other album under the track list on an axis="horizontal" scroll region: the grid's quiet-hover cover tiles, one press from record to record.
- Echo the shelf's offset_x into value_x (the controlled-scroll shape on the sideways axis) and reset the rail when an album opens.
- Teach the widget-wheel automation verb an optional delta-x token so drivers can scroll both axes like a real trackpad gesture.
2026-07-24 00:44:21 -05:00
Chris Tate cb317bea87 Pin two-axis scrolling with routing, driver, replay, and markup tests
- Cover the nested independent-axis routing case (dy to the vertical child, dx to the horizontal ancestor), the horizontal wheel/keymap/scrollbar paths, and per-axis driver sync with pinned ranges on ungranted axes.
- Ride a diagonal wheel through the reference record/replay session so a journaled two-axis scroll replays to identical per-axis offsets.
- Migrate the example apps to the two-axis ScrollState fields.
2026-07-24 00:44:20 -05:00
Chris Tate 1c8d4a5ea9 Carry both scroll axes through hosts, docs, and the TS SDK
- Widen the macOS native scroll driver ABI to offset_x with a horizontal overlay scroller; GTK and Windows wheel handlers already forward delta_x.
- Update the TS SDK ScrollState to the eight per-axis fields and regenerate the markup vocabulary and scroll reference for axis/value-x.
- State the ScrollState break and its one-sentence migration in the changelog fragment.
2026-07-24 00:43:00 -05:00
Chris Tate 0e4e7c513a Make canvas scroll state and routing two-axis
- Widen ScrollState to eight per-axis fields (ScrollAxisState carries the physics), route each wheel axis independently to the nearest ancestor scrollable on that axis, and step kinetic motion per axis.
- Add the scroll axis declaration (attr 86 axis, attr 87 value-x, Widget.scroll_axes/value_x) through schema, markup validation, both engines, layout, clamping, reconcile, semantics, and the two-bar scrollbar renderer.
- Break the one-axis on-scroll record consciously: the reflect vocabulary, contract class, and both engines now teach the per-axis migration by field name, and the scroll-driver ABI/journal carry offset_x.
2026-07-24 00:43:00 -05:00
78 changed files with 3360 additions and 534 deletions
+3
View File
@@ -0,0 +1,3 @@
feature: **Horizontal and two-axis canvas scrolling**: scroll views declare `axis="vertical|horizontal|both"` (builder `axis:`), horizontal offsets ride `value-x` with the same source-wins reconcile as `value`, the engine draws a bottom-edge scrollbar, keyboard scrolling gains Left/Right/Home/End on horizontal-capable regions, and macOS native scroll drivers carry both axes with OS momentum and rubber-band.
- **Independent per-axis wheel routing**: each axis of a wheel/trackpad gesture travels to the nearest ancestor scrollable on that axis, so a horizontal timeline holding a vertical list splits a diagonal gesture — `delta_y` scrolls the list, `delta_x` reaches the timeline.
- **BREAKING — `ScrollState` is two-axis now**: the one-axis `{offset, velocity, viewport_extent, content_extent}` record (TS: `offset`/`velocity`/`viewportExtent`/`contentExtent`) was replaced by per-axis fields `offset_x`/`offset_y`, `velocity_x`/`velocity_y`, `viewport_extent_x`/`viewport_extent_y`, `content_extent_x`/`content_extent_y` (TS: `offsetX``contentExtentY`); migrate a vertical region by reading the `_y` fields where it read the old ones — an `on-scroll` arm still declaring the old shape fails the build with a teaching that names the new fields.
+2 -2
View File
@@ -130,8 +130,8 @@ The runtime watches the command queue and processes these actions:
<td>Dispatch pointer down/drag/up across a retained canvas widget</td>
</tr>
<tr>
<td><code>widget-wheel &lt;view-label&gt; &lt;widget-id&gt; &lt;delta-y&gt;</code></td>
<td>Dispatch wheel input at a retained canvas widget</td>
<td><code>widget-wheel &lt;view-label&gt; &lt;widget-id&gt; &lt;delta-y&gt; [&lt;delta-x&gt;]</code></td>
<td>Dispatch wheel input at a retained canvas widget (the optional <code>delta-x</code> scrolls the horizontal axis)</td>
</tr>
<tr>
<td><code>widget-key &lt;view-label&gt; &lt;key&gt; [&lt;text&gt;]</code></td>
+2 -2
View File
@@ -229,8 +229,8 @@ Interact with the automation server of a running automation-enabled app. See [Au
<dd>Invoke a widget's declared context-menu item by index — the snapshot lists each widget's items in order.</dd>
<dt><code>automate widget-drag &lt;view-label&gt; &lt;widget-id&gt; &lt;start-x-ratio&gt; &lt;end-x-ratio&gt; [&lt;start-y-ratio&gt; &lt;end-y-ratio&gt;]</code></dt>
<dd>Dispatch pointer down/drag/up across a retained canvas widget.</dd>
<dt><code>automate widget-wheel &lt;view-label&gt; &lt;widget-id&gt; &lt;delta-y&gt;</code></dt>
<dd>Dispatch wheel input at a retained canvas widget.</dd>
<dt><code>automate widget-wheel &lt;view-label&gt; &lt;widget-id&gt; &lt;delta-y&gt; [&lt;delta-x&gt;]</code></dt>
<dd>Dispatch wheel input at a retained canvas widget; the optional <code>delta-x</code> scrolls the horizontal axis (each axis routes to the nearest region that scrolls it).</dd>
<dt><code>automate widget-key &lt;view-label&gt; &lt;key&gt; [&lt;text&gt;]</code></dt>
<dd>Dispatch key input to the focused retained canvas widget.</dd>
<dt><code>automate widget-pinch &lt;view-label&gt; &lt;scale&gt; [&lt;x&gt; &lt;y&gt;]</code></dt>
+19 -5
View File
@@ -4,7 +4,9 @@ import { CodeToggle } from "@/components/code-toggle";
# Scroll
A scroll view: wrap multiple children in a single column inside it. The engine owns wheel, kinetic, and keyboard scrolling and draws the scrollbar while a scroll is in flight; `on-scroll` names a Msg variant with a `canvas.ScrollState` payload — or, in a transpiled TypeScript core, a declared record of the same `offset`/`velocity`/`viewport_extent`/`content_extent` fields, matched by name — that delivers the post-scroll offset and viewport/content extents, so the model can observe position without owning it. Echo the offset into a model field bound as `value` and the model owns the position too: setting the field scrolls the region (the controlled-scroll shape). Scrolling pins at the content edges by default — no rubber-band bounce; kinetic motion stops cleanly at the boundary. `overscroll="rubber_band"` opts one region into bouncing past its edges (both the engine physics and the native macOS scroller honor it), and the `ScrollPhysics.overscroll` design token flips the app-wide default, which per-region values override. `on-reach-end` dispatches a plain Msg when a scroll comes within one viewport of the content end — the infinite-fetch signal, fired once per approach with hysteresis (appending a batch grows the extent and re-arms the next approach). A programmatic jump to the end fires once and never re-arms while the offset stays near the end — re-arming needs a post-scroll observation at least 1.5 viewports from it. Pair with [list](/components/list) for layout-culled rows, or the builder's [virtual list](/components/virtual-list) for dataset-scale windows.
A scroll view: wrap multiple children in a single column inside it. The engine owns wheel, kinetic, and keyboard scrolling and draws the scrollbar while a scroll is in flight; `on-scroll` names a Msg variant with a `canvas.ScrollState` payload — or, in a transpiled TypeScript core, a declared record of the same two-axis fields (`offset_x`/`offset_y`, `velocity_x`/`velocity_y`, `viewport_extent_x`/`viewport_extent_y`, `content_extent_x`/`content_extent_y`), matched by name — that delivers the post-scroll offsets and viewport/content extents on both axes, so the model can observe position without owning it. Echo `offset_y` into a model field bound as `value` and the model owns the position too: setting the field scrolls the region (the controlled-scroll shape).
`axis` declares which axes the region scrolls: `vertical` (the default), `horizontal`, or `both`. A horizontal grant opts the region into wheel/trackpad `delta_x`, a bottom-edge scrollbar, and the horizontal keymap — Left/Right step in lines, and a horizontal-only region takes Home/End/PageUp/PageDown on its one axis too. The horizontal offset rides `value-x`, the sideways counterpart of `value` with the same source-wins reconcile. Nested regions route each wheel axis independently: every axis of a scroll gesture travels to the nearest ancestor that scrolls on that axis, so inside a horizontal timeline holding a vertical list, `delta_y` scrolls the list while `delta_x` reaches the timeline — one diagonal gesture, two regions, no fighting. Virtualized scrolls stay vertical (windowed virtualization prices rows, not columns). Scrolling pins at the content edges by default — no rubber-band bounce; kinetic motion stops cleanly at the boundary. `overscroll="rubber_band"` opts one region into bouncing past its edges (both the engine physics and the native macOS scroller honor it), and the `ScrollPhysics.overscroll` design token flips the app-wide default, which per-region values override. `on-reach-end` dispatches a plain Msg when a scroll comes within one viewport of the content end — the infinite-fetch signal, fired once per approach with hysteresis (appending a batch grows the extent and re-arms the next approach). A programmatic jump to the end fires once and never re-arms while the offset stays near the end — re-arming needs a post-scroll observation at least 1.5 viewports from it. Pair with [list](/components/list) for layout-culled rows, or the builder's [virtual list](/components/virtual-list) for dataset-scale windows.
<ComponentPreview name="scroll" alt="A scroll region rendered by the engine" caption="a fixed-height scroll region; the engine draws the scrollbar during scrolling" />
@@ -24,7 +26,7 @@ A scroll view: wrap multiple children in a single column inside it. The engine o
</scroll>
```
The arm receives the scroll state and stores what the model wants to remember — echo `offset` into a field bound as the scroll's `value` and the model owns the position too:
The arm receives the scroll state and stores what the model wants to remember — echo `offsetY` into a field bound as the scroll's `value` (and `offsetX` into `value-x` on horizontal-capable regions) and the model owns the position too:
<CodeToggle>
@@ -34,16 +36,28 @@ import { type ScrollState } from "@native-sdk/core/events";
export type Msg = /* ... */ | { readonly kind: "log_scrolled"; readonly scroll: ScrollState };
// in update:
case "log_scrolled":
return { ...model, changelog_offset: msg.scroll.offset };
return { ...model, changelog_offset: msg.scroll.offsetY };
```
```zig
// Msg arm: log_scrolled: canvas.ScrollState
.log_scrolled => |scroll| model.changelog_offset = scroll.offset,
.log_scrolled => |scroll| model.changelog_offset = scroll.offset_y,
```
</CodeToggle>
A horizontal shelf declares its axis and reads the `X` fields:
```html
<scroll axis="horizontal" height="180" on-scroll="shelf_scrolled">
<row gap="8">
<panel width="140"><text>Cover 1</text></panel>
<panel width="140"><text>Cover 2</text></panel>
<panel width="140"><text>Cover 3</text></panel>
</row>
</scroll>
```
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically. `on_scroll` pairs with `Ui.scrollMsg(.tag)`; the delivered offset is the value the runtime already applied, so echoing it back never fights the scroll reconcile.
@@ -62,4 +76,4 @@ ui.scroll(.{ .height = 240, .padding = 8, .on_scroll = Ui.scrollMsg(.log_scrolle
## Attributes
<AttrTable attrs={["on-scroll", "on-reach-end", "overscroll", "height", "width", "padding"]} />
<AttrTable attrs={["on-scroll", "on-reach-end", "axis", "value-x", "overscroll", "height", "width", "padding"]} />
+9 -1
View File
@@ -432,6 +432,14 @@
"name": "overscroll",
"doc": "scroll only: edge behavior of the region. none pins scrolling at the content edges (the shipped default via the ScrollPhysics.overscroll token), rubber_band lets this region bounce past them, default follows the token. Honored by the engine's scroll physics and the native OS scroller alike."
},
{
"name": "axis",
"doc": "scroll only: which axes the region scrolls - vertical (the default), horizontal, or both. Horizontal grants opt the region into wheel/trackpad delta-x, the bottom-edge scrollbar, and the horizontal keymap; in a nested tree each wheel axis routes independently to the nearest ancestor scrolling that axis. Virtualized scrolls stay vertical (a horizontal grant there is a teaching error)."
},
{
"name": "value-x",
"doc": "scroll only, beside axis=\"horizontal\" or axis=\"both\": the horizontal scroll offset - the sideways counterpart of value, following the same source-wins reconcile rule (echo on-scroll's offset_x back to keep user scrolling; move it model-side to scroll programmatically). Without a horizontal axis grant it is a teaching error (it would be silently inert)."
},
{
"name": "resize-duration",
"doc": "split only: layout-tween duration in milliseconds (a plain number or one {binding}). Nonzero makes the bound value a TARGET - a rebuild that moves it lays both panes out at the target ONCE, then the runtime slides the rendered boundary there one presented frame at a time under the panes' clips (content never re-wraps mid-flight), dispatching ONE on-resize echo at settle with the applied fraction. 0 (and absent) snaps, today's behavior. A divider DRAG keeps live per-step reflow and echoes. Reduced-motion appearances snap automatically - apps declare nothing extra."
@@ -504,7 +512,7 @@
},
{
"name": "on-scroll",
"doc": "scroll element only: names a Msg variant with canvas.ScrollState payload; delivers the post-scroll offset/viewport/content extents after wheel, kinetic, keyboard, and accessibility scrolls."
"doc": "scroll element only: names a Msg variant with canvas.ScrollState payload; delivers the post-scroll two-axis state (offset_x/offset_y, velocity_x/velocity_y, viewport_extent_x/viewport_extent_y, content_extent_x/content_extent_y) after wheel, kinetic, keyboard, and accessibility scrolls."
},
{
"name": "on-dismiss",
+1 -1
View File
@@ -390,7 +390,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
// The controlled-scroll echo: the applied offset lands in the
// model, so the next rebuild's `value` binding never fights the
// runtime.
return { ...model, chatScrollTop: msg.scroll.offset };
return { ...model, chatScrollTop: msg.scroll.offsetY };
case "endpoint_set":
return { ...model, endpoint: msg.value };
case "model_set":
+4 -2
View File
@@ -226,8 +226,10 @@ const Harness = struct {
try self.harness.runtime.dispatchAutomationCommand(self.app, command);
}
fn scrollState(self: *Harness) canvas.ScrollState {
return self.harness.runtime.views[0].canvasWidgetScrollStateById(timeline_id).?;
/// The timeline's VERTICAL axis state — the feed is a vertical
/// virtual list, so every assertion here reads that axis.
fn scrollState(self: *Harness) canvas.ScrollAxisState {
return self.harness.runtime.views[0].canvasWidgetScrollStateById(timeline_id).?.axis(.vertical);
}
fn root(self: *Harness) canvas.Widget {
+2 -2
View File
@@ -721,7 +721,7 @@ pub const GpuComponentsApp = struct {
};
}
fn componentVirtualScrollState(self: *@This(), id: canvas.ObjectId, viewport_extent: f32, content_extent: f32) ?canvas.ScrollState {
fn componentVirtualScrollState(self: *@This(), id: canvas.ObjectId, viewport_extent: f32, content_extent: f32) ?canvas.ScrollAxisState {
const offset = self.componentVirtualScrollValue(id) orelse return null;
const velocity = self.componentVirtualScrollVelocity(id) orelse return null;
return .{
@@ -754,7 +754,7 @@ pub const GpuComponentsApp = struct {
}
}
fn setComponentVirtualScrollState(self: *@This(), id: canvas.ObjectId, state: canvas.ScrollState) anyerror!void {
fn setComponentVirtualScrollState(self: *@This(), id: canvas.ObjectId, state: canvas.ScrollAxisState) anyerror!void {
switch (id) {
120 => {
self.virtual_scroll.nav = state.offset;
+1 -1
View File
@@ -455,7 +455,7 @@ pub fn clampComponentVirtualScrollOffset(raw_next: f32, max_offset: f32, fallbac
return std.math.clamp(@max(0, raw_next), 0, @max(0, max_offset));
}
pub fn componentScrollStatesEqual(a: canvas.ScrollState, b: canvas.ScrollState) bool {
pub fn componentScrollStatesEqual(a: canvas.ScrollAxisState, b: canvas.ScrollAxisState) bool {
return a.offset == b.offset and
a.velocity == b.velocity and
a.viewport_extent == b.viewport_extent and
+1 -1
View File
@@ -278,7 +278,7 @@ pub fn update(model: *Model, msg: Msg) void {
// runtime already applied, so echoing it back through
// `.value = model.activity_scroll` on the next rebuild never
// fights the scroll reconcile rule.
.activity_scrolled => |scroll_state| model.activity_scroll = scroll_state.offset,
.activity_scrolled => |scroll_state| model.activity_scroll = scroll_state.offset_y,
.submit_forecast => model.setStatus("Forecast amount submitted."),
.submit_search => model.setStatus("Segment search submitted."),
.chrome_changed => |chrome| model.chrome_leading = chrome.insets.left,
+1 -1
View File
@@ -477,7 +477,7 @@ pub fn update(model: *Model, msg: Msg, fx: *Effects) void {
// Echo the applied scroll offset back through the model: the next
// rebuild lays the preview at exactly this value, so scrolling
// never fights the reconcile.
.doc_scrolled => |state| model.doc_scroll = state.offset,
.doc_scrolled => |state| model.doc_scroll = state.offset_y,
.chrome_changed => |chrome| {
model.chrome_leading = chrome.insets.left;
model.chrome_trailing = chrome.insets.right;
+1 -1
View File
@@ -454,7 +454,7 @@ test "the preview scroll offset round-trips through the model" {
var model = main.initialModel();
// The runtime delivers the applied offset; the model stores it…
main.update(&model, .{ .doc_scrolled = .{ .offset = 120, .viewport_extent = 600, .content_extent = 2400 } }, &fx);
main.update(&model, .{ .doc_scrolled = .{ .offset_y = 120, .viewport_extent_y = 600, .content_extent_y = 2400 } }, &fx);
try testing.expectEqual(@as(f32, 120), model.doc_scroll);
// …and the rebuilt tree echoes it back through the scroll's value,
+1 -1
View File
@@ -938,7 +938,7 @@ pub fn update(model: *Model, msg: Msg, fx: *Effects) void {
.list_resized => |fraction| model.list_split = fraction,
// Same controlled pattern for the note-list scroll: store the
// applied offset, echo it back through the scroll's value.
.note_list_scrolled => |state| model.note_list_scroll = state.offset,
.note_list_scrolled => |state| model.note_list_scroll = state.offset_y,
.chrome_changed => |chrome| {
model.chrome_leading = chrome.insets.left;
// Match the header to the titlebar band so its centered
+1 -1
View File
@@ -1376,7 +1376,7 @@ test "the note list scroll offset round-trips through the model" {
var model = model_mod.initialModel(testClock(&clock));
// The runtime delivers the applied offset; the model stores it…
main.update(&model, .{ .note_list_scrolled = .{ .offset = 42, .viewport_extent = 500, .content_extent = 900 } }, &fx);
main.update(&model, .{ .note_list_scrolled = .{ .offset_y = 42, .viewport_extent_y = 500, .content_extent_y = 900 } }, &fx);
try testing.expectEqual(@as(f32, 42), model.note_list_scroll);
// …and the rebuilt tree echoes it back through the scroll's value,
+1 -1
View File
@@ -899,7 +899,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
// The controlled-scroll echo: the applied offset lands in the
// model, so the next rebuild's `value` binding never fights the
// runtime (and page changes reset it to 0 above).
return { ...model, libraryScrollTop: msg.scroll.offset };
return { ...model, libraryScrollTop: msg.scroll.offsetY };
case "canvas_resized":
return { ...model, canvasWidth: msg.width };
case "url_base_set":
+58 -27
View File
@@ -335,6 +335,11 @@ pub const Msg = union(enum) {
grid_scrolled: canvas.ScrollState,
detail_scrolled: canvas.ScrollState,
songs_scrolled: canvas.ScrollState,
/// The detail page's "From the collection" shelf — a HORIZONTAL
/// scroll region, so its controlled echo rides the state's
/// `offset_x` into `value_x` (the sideways twin of the offsets
/// above).
shelf_scrolled: canvas.ScrollState,
/// Context menu: queue a track to play after the current one.
queue_track: u8,
/// Context menu: copy the track title to the clipboard via `pbcopy`
@@ -348,13 +353,13 @@ pub const Msg = union(enum) {
/// process exits) send these — never a markup on-* event — so the
/// dead-state lint must not ask for one.
pub const view_unbound = .{
"set_appearance", "chrome_changed", "canvas_resized",
"open_album", "close_album", "play_album",
"play_track", "select_track", "select_next",
"select_previous", "play_selected", "frame_clock",
"audio_event", "grid_scrolled", "detail_scrolled",
"songs_scrolled", "queue_track", "copy_title",
"copied",
"set_appearance", "chrome_changed", "canvas_resized",
"open_album", "close_album", "play_album",
"play_track", "select_track", "select_next",
"select_previous", "play_selected", "frame_clock",
"audio_event", "grid_scrolled", "detail_scrolled",
"songs_scrolled", "shelf_scrolled", "queue_track",
"copy_title", "copied",
};
};
@@ -367,6 +372,9 @@ pub const Model = struct {
/// the model observes the applied offset and echoes it back.
grid_scroll: f32 = 0,
detail_scroll: f32 = 0,
/// The detail page's album shelf, HORIZONTAL: the echoed `offset_x`
/// the view binds back as `value_x`.
shelf_scroll_x: f32 = 0,
/// Chrome overlay geometry from `on_chrome` (tall hidden-inset
/// titlebar): the header leads with a spacer this wide so its
/// controls clear the traffic lights, and matches its height to the
@@ -490,22 +498,21 @@ pub const Model = struct {
/// the derived fns (`nowPlayingTitle`, `playPauseIcon`,
/// `progressFraction`, ...) instead of these backing stores.
pub const view_unbound = .{
"tab", "open_album", "grid_scroll",
"detail_scroll", "canvas_width", "songs_scroll",
"now", "selected", "playing",
"elapsed_ms", "frame_ns", "now_duration_ms",
"platform_duration_ms",
"assets_missing", "stream_failed", "buffering",
"url_base_len", "cache_dir_len", "queue_dropped",
"seek_fraction", "copies_done", "copy_failed",
"appearance", "search_buffer", "url_base_buffer",
"cache_dir_buffer", "queue", "covers",
"chrome_top", "chrome_bottom", "host_form_factor",
"native_tabs", "urlBase",
"cacheDir", "streamingConfigured", "searching",
"colorScheme", "hasNowPlaying", "playingAlbum",
"visibleAlbums", "visibleTracks", "miniBarVisible",
"formFactor",
"tab", "open_album", "grid_scroll",
"detail_scroll", "shelf_scroll_x", "canvas_width",
"songs_scroll", "now", "selected",
"playing", "elapsed_ms", "frame_ns",
"now_duration_ms", "platform_duration_ms", "assets_missing",
"stream_failed", "buffering", "url_base_len",
"cache_dir_len", "queue_dropped", "seek_fraction",
"copies_done", "copy_failed", "appearance",
"search_buffer", "url_base_buffer", "cache_dir_buffer",
"queue", "covers", "chrome_top",
"chrome_bottom", "host_form_factor", "native_tabs",
"urlBase", "cacheDir", "streamingConfigured",
"searching", "colorScheme", "hasNowPlaying",
"playingAlbum", "visibleAlbums", "visibleTracks",
"miniBarVisible", "formFactor", "otherAlbums",
};
// ------------------------------------------------------------- queries
@@ -679,6 +686,27 @@ pub const Model = struct {
}
/// Albums matching the search query, derived into the build arena.
/// Every album EXCEPT `exclude`, in catalog order and unfiltered —
/// the detail page's "From the collection" shelf browses the whole
/// catalog sideways, whatever the search field holds.
pub fn otherAlbums(model: *const Model, arena: std.mem.Allocator, exclude: u8) []const AlbumCell {
const out = arena.alloc(AlbumCell, albums.len) catch return &.{};
var count: usize = 0;
for (&albums) |*album| {
if (album.id == exclude) continue;
out[count] = .{
.id = album.id,
.title = album.title,
.artist = album.artist,
.initials = album.initials,
.cover = model.coverFor(album.id),
.playing = model.playingAlbum() == album.id,
};
count += 1;
}
return out[0..count];
}
pub fn visibleAlbums(model: *const Model, arena: std.mem.Allocator) []const AlbumCell {
const out = arena.alloc(AlbumCell, albums.len) catch return &.{};
var count: usize = 0;
@@ -867,14 +895,17 @@ pub fn update(model: *Model, msg: Msg, fx: *Effects) void {
},
.canvas_resized => |width| model.canvas_width = width,
.search_edit => |edit| model.search_buffer.apply(edit),
.grid_scrolled => |state| model.grid_scroll = state.offset,
.detail_scrolled => |state| model.detail_scroll = state.offset,
.songs_scrolled => |state| model.songs_scroll = state.offset,
.grid_scrolled => |state| model.grid_scroll = state.offset_y,
.detail_scrolled => |state| model.detail_scroll = state.offset_y,
.songs_scrolled => |state| model.songs_scroll = state.offset_y,
.shelf_scrolled => |state| model.shelf_scroll_x = state.offset_x,
.open_album => |id| {
model.open_album = id;
model.tab = .albums;
// A fresh record opens at its top.
// A fresh record opens at its top, with its shelf at the
// leading edge.
model.detail_scroll = 0;
model.shelf_scroll_x = 0;
},
.close_album => model.open_album = null,
.play_album => |id| startTrack(model, fx, albumTracks(id)[0].id),
+23 -3
View File
@@ -979,7 +979,27 @@ test "a full session: open an album, play it, and use the context menus" {
tree = try buildTree(arena, &model);
try testing.expect(findByLabel(tree.root, "Album detail") != null);
try testing.expectEqual(@as(usize, album.track_count), countListItems(tree.root));
// Track rows count inside the track LIST: the page also carries the
// "From the collection" shelf, whose album tiles are listitems too.
try testing.expectEqual(@as(usize, album.track_count), countListItems(findByLabel(tree.root, "Album tracks").?));
// The collection shelf: every OTHER album on a horizontal rail —
// wheel delta_x and Left/Right scroll it while the page keeps
// vertical scrolling (each axis routes to the nearest region that
// scrolls it).
const shelf = findByLabel(tree.root, "More albums").?;
try testing.expectEqual(canvas.WidgetKind.scroll_view, shelf.kind);
try testing.expectEqual(canvas.ScrollAxes.horizontal, shelf.scroll_axes);
try testing.expectEqual(model.shelf_scroll_x, shelf.value_x);
try testing.expectEqual(@as(usize, model_mod.albums.len - 1), countListItems(shelf));
// The shelf echo is the controlled-scroll shape on the HORIZONTAL
// axis: the applied offset_x lands in the model and binds back as
// value_x, and opening an album resets the rail.
apply(&model, tree.msgForScroll(shelf.id, .{ .offset_x = 96, .viewport_extent_x = 480, .content_extent_x = 960 }).?);
try testing.expectEqual(@as(f32, 96), model.shelf_scroll_x);
tree = try buildTree(arena, &model);
try testing.expectEqual(@as(f32, 96), findByLabel(tree.root, "More albums").?.value_x);
// Play album starts the record's first track. The button carries its
// play icon inline (widget.icon) beside the label: one widget, one
@@ -2231,7 +2251,7 @@ test "the navigation projection follows the visible page stack" {
// identical depth readouts at every step — the projection is a pure
// derivation, so a journal replayed without a host is identical.
const journal = [_]Msg{
.{ .open_album = 2 }, .show_songs, .show_albums, .close_album,
.{ .open_album = 2 }, .show_songs, .show_albums, .close_album,
.{ .open_album = 5 }, .close_album,
};
var first = Model{};
@@ -2506,7 +2526,7 @@ test "one Msg journal drives both shells deterministically" {
const expected_form = [journal.len]model_mod.FormFactor{
.compact, .compact, .compact, .compact,
.regular, .regular, .regular, .compact,
.regular, .compact, .compact,
.regular, .compact, .compact,
};
var first = Model{};
+45
View File
@@ -70,6 +70,14 @@ const tile_text_height: f32 = 36;
const detail_cover_size: f32 = 184;
const content_padding: f32 = 24;
/// The detail page's album rail: fixed-width tiles on a horizontal
/// scroll region exactly tall enough for one tile. 168 keeps seven
/// sibling covers wider than the desktop shell's content row at every
/// regular window width up to ~1300 points, so the rail actually
/// scrolls where it ships (a rail that fits simply rests).
const shelf_tile_width: f32 = 168;
const shelf_height: f32 = tile_padding * 2 + (shelf_tile_width - tile_padding * 2) + cover_text_gap + tile_text_height;
// ---------------------------------------------------- compact constants
/// The compact shell's content padding: tighter than the desktop's 24 —
@@ -294,9 +302,46 @@ fn albumDetailView(ui: *Ui, model: *const Model, album_id: u8) Ui.Node {
detailHeading(ui, model, album, rows.len, .regular),
}),
trackList(ui, rows, "Album tracks", .regular),
collectionShelf(ui, model, album_id),
}));
}
/// The "From the collection" shelf under the track list: every OTHER
/// album as a sideways rail of the same bare cover tiles the grid uses —
/// the music-app carousel, so the next record is one press away without
/// leaving this one. The rail is a HORIZONTAL scroll region: the wheel's
/// `delta_x` (and Left/Right on the focused rail) scrolls it while
/// `delta_y` keeps scrolling the page — each axis routes to the nearest
/// region that scrolls it, so one diagonal trackpad gesture reads as
/// completely unsurprising. Controlled like every scroll in this app:
/// the applied `offset_x` echoes into `value_x`, and opening an album
/// resets the shelf to its leading edge.
fn collectionShelf(ui: *Ui, model: *const Model, album_id: u8) Ui.Node {
const cells = model.otherAlbums(ui.arena, album_id);
if (cells.len == 0) return ui.el(.stack, .{}, .{});
const fit = GridFit{ .columns = 1, .tile_width = shelf_tile_width };
const rail_width = @as(f32, @floatFromInt(cells.len)) * (shelf_tile_width + grid_gap) - grid_gap;
return ui.column(.{ .gap = 12 }, .{
sectionHeading(ui, "From the collection", ui.fmt("{d} more", .{cells.len})),
ui.scroll(.{
.axis = .horizontal,
.height = shelf_height,
.value_x = model.shelf_scroll_x,
.on_scroll = Ui.scrollMsg(.shelf_scrolled),
.semantics = .{ .label = "More albums" },
}, ui.row(.{ .width = rail_width, .gap = grid_gap }, ui.eachCtx(TileContext{ .fit = fit, .form = .regular }, cells, albumKey, shelfTile))),
});
}
/// One shelf tile: the grid's bare album tile at the rail's fixed width
/// (the tile draws its own height from it).
fn shelfTile(ui: *Ui, context: TileContext, cell: *const model_mod.AlbumCell) Ui.Node {
var tile = albumTile(ui, context, cell);
tile.widget.layout.min_size.width = shelf_tile_width;
tile.widget.layout.max_size.width = shelf_tile_width;
return tile;
}
/// The album detail cover, shared by both shells: the rounded square at
/// whatever size the shell's composition affords.
fn detailCover(ui: *Ui, model: *const Model, album: *const model_mod.Album, size: f32) Ui.Node {
+1 -1
View File
@@ -809,7 +809,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
case "search_edit":
return { ...model, search: searchApply(model.search, msg.edit) };
case "table_scrolled":
return { ...model, tableScroll: msg.scroll.offset };
return { ...model, tableScroll: msg.scroll.offsetY };
case "sort_cpu":
return sortedBy(model, "cpu");
case "sort_mem":
+1 -1
View File
@@ -584,7 +584,7 @@ pub fn update(model: *Model, msg: Msg, fx: *Effects) void {
},
.toggle_sampling => setSampling(model, fx, model.paused),
.search_edit => |edit| model.search_buffer.apply(edit),
.table_scrolled => |state| model.table_scroll = state.offset,
.table_scrolled => |state| model.table_scroll = state.offset_y,
.set_sort => |key| {
if (model.sort_key == key) {
model.sort_descending = !model.sort_descending;
+1 -1
View File
@@ -534,7 +534,7 @@ test "the process rows are table rows and the table scroll is controlled" {
// The scroll echoes the model-owned offset: the applied offset lands
// in the model and the next build carries it.
apply(&model, .{ .table_scrolled = .{ .offset = 66 } });
apply(&model, .{ .table_scrolled = .{ .offset_y = 66 } });
try testing.expectEqual(@as(f32, 66), model.table_scroll);
tree = try buildTree(arena, &model);
const scroll = findByKind(tree.root, .scroll_view).?;
+17 -8
View File
@@ -40,15 +40,24 @@
export type { TextCaretDirection, TextCaretMove, TextSelection, TextInputEvent } from "./text.ts";
/// The scroll-state mirror markup's `on-scroll` matches structurally: a
/// record of exactly these four numeric fields. Offsets and extents are
/// canvas points; `velocity` is points per second while a fling decays.
/// Echo `offset` into the model field bound as the scroll's `value` to
/// keep the region model-driven (setting that field in update scrolls it).
/// record of exactly these eight numeric fields — the TWO-AXIS shape, one
/// offset/velocity/viewport/content quartet per axis. Offsets and extents
/// are canvas points; velocities are points per second while a fling
/// decays. A vertical list carries live `...Y` fields and quiet `...X`
/// ones (offset 0, content pinned to the viewport width); a horizontal
/// shelf the reverse. Echo `offsetY` into the model field bound as the
/// scroll's `value` (and `offsetX` into `value-x` on horizontal-capable
/// regions) to keep the region model-driven — setting those fields in
/// update scrolls it.
export interface ScrollState {
readonly offset: number;
readonly velocity: number;
readonly viewportExtent: number;
readonly contentExtent: number;
readonly offsetX: number;
readonly offsetY: number;
readonly velocityX: number;
readonly velocityY: number;
readonly viewportExtentX: number;
readonly viewportExtentY: number;
readonly contentExtentX: number;
readonly contentExtentY: number;
}
/// The presented-frame channel's record (`frameMsg(model, frame)`): the
+30 -6
View File
@@ -297,9 +297,11 @@ typedef struct {
int high_contrast;
uint64_t timer_id;
/* GPU_SURFACE_SCROLL_DRIVER / CONTEXT_MENU_ACTION payloads: widget_id
* carries the driver id / menu token; scroll_driver_offset_y the new
* content offset (canvas points, y-down, overscroll passes through);
* menu_item_id the selected context-menu item (0 = dismissed). */
* carries the driver id / menu token; scroll_driver_offset_x/_y the
* new content offsets (canvas points, x-rightward, y-down,
* overscroll passes through); menu_item_id the selected
* context-menu item (0 = dismissed). */
double scroll_driver_offset_x;
double scroll_driver_offset_y;
uint32_t menu_item_id;
/* GPU_SURFACE_FRAME payloads: host-stamped durations of the most
@@ -676,19 +678,41 @@ typedef void (*native_sdk_appkit_tray_callback_t)(void *context, uint32_t item_i
* set_gpu_surface_scroll_drivers_fn). Frame coordinates are view-local
* canvas points (top-left origin, y-down); the host flips to AppKit
* coordinates itself. */
/* A surface that hit-blocks scroll regions beneath it (view-local
* canvas points, top-left origin): wheel routing declines a driver
* whose occluder mask includes an occluder containing the point. */
typedef struct {
double x;
double y;
double width;
double height;
} native_sdk_appkit_scroll_occluder_t;
typedef struct {
uint64_t driver_id;
/* The nearest ancestor driver's id (0 = none): wheel-owner
* resolution is restricted to the hit region and its ancestors. */
uint64_t parent_driver_id;
/* Bit i set = occluder i (of the sync call's occluder array) blocks
* this region at points it contains. */
uint32_t occluder_mask;
double x;
double y;
double width;
double height;
double content_width;
double content_height;
double offset_x;
double offset_y;
int set_offset;
int set_offset_x;
int set_offset_y;
/* Edge behavior: 0 pins scrolling at the content edges, nonzero lets
* the scroller bounce past them (vertical elasticity). */
* the scroller bounce past them (armed per axis via the grants). */
int rubber_band;
/* Which axes the region grants: elasticity and scroller chrome arm
* only on granted axes; an ungranted axis never moves or bounces. */
int scrolls_x;
int scrolls_y;
} native_sdk_appkit_scroll_driver_t;
/* One native context-menu entry. */
@@ -705,7 +729,7 @@ typedef struct {
* extents / (when set_offset) offsets, remove drivers absent from the
* list. Idempotent; called every layout install and every presented
* frame. Returns 1 on success, 0 when the view does not exist. */
int native_sdk_appkit_set_gpu_surface_scroll_drivers(native_sdk_appkit_host_t *host, uint64_t window_id, const char *label, size_t label_len, const native_sdk_appkit_scroll_driver_t *drivers, size_t count);
int native_sdk_appkit_set_gpu_surface_scroll_drivers(native_sdk_appkit_host_t *host, uint64_t window_id, const char *label, size_t label_len, const native_sdk_appkit_scroll_driver_t *drivers, size_t count, const native_sdk_appkit_scroll_occluder_t *occluders, size_t occluder_count);
/* Present a native context menu (NSMenu popUpMenuPositioningItem) at the
* view-local point on the next main-loop turn. The selection (or
+309 -55
View File
@@ -342,6 +342,16 @@ static NSMutableDictionary *NativeSdkCredentialQuery(NSString *service, NSString
* overlay knob stays grabbable). */
@interface NativeSdkScrollDriverView : NSScrollView
@property(nonatomic, assign) uint64_t driverId;
@property(nonatomic, assign) uint64_t parentDriverId;
/* Bit i set = occluder i of the surface's occluder array hit-blocks
* this region at points it contains. */
@property(nonatomic, assign) uint32_t occluderMask;
/* The region's axis grants (the runtime's widgetScrollsAxis), pushed on
* every reconcile: the wheel winner-resolution's fallback rule needs
* grants, not range a granted axis with short content still owns a
* bounce, an ungranted one owns nothing. */
@property(nonatomic, assign) BOOL grantsX;
@property(nonatomic, assign) BOOL grantsY;
@end
/* Captures the selected item id of a context-menu popUp; NSMenuItem
@@ -575,10 +585,28 @@ static NSMutableDictionary *NativeSdkCredentialQuery(NSString *service, NSString
@property(nonatomic, assign) BOOL interpretedKeyEventEmittedInput;
@property(nonatomic, strong) NSArray<NSAccessibilityElement *> *widgetAccessibilityElements;
@property(nonatomic, strong) NSMutableArray<NativeSdkScrollDriverView *> *scrollDrivers;
@property(nonatomic, weak) NativeSdkScrollDriverView *activeWheelDriver;
@property(nonatomic, assign) NSPoint wheelGesturePoint;
@property(nonatomic, assign) BOOL wheelGestureActive;
/* The last phase-less wheel's timestamp: discrete streams have no
* gesture phases, so a quiet gap IS the gesture boundary. */
@property(nonatomic, assign) uint64_t lastLegacyWheelTimestampNs;
/* The gesture's most recent native recipient: zero-delta phase events
* (begins, the terminal Ended/Cancelled) resolve no owner but still
* carry the bookkeeping the scroller's overscroll recovery keys off,
* so they forward here. */
@property(nonatomic, weak) NativeSdkScrollDriverView *lastNativeWheelDriver;
/* Driver ids the WIRE has scrolled during this gesture: once the engine
* applies relative deltas to a region, later ABSOLUTE native reports
* from the same region would erase them, so a wire-scrolled region
* never becomes the native recipient again until the gesture ends. */
@property(nonatomic, strong) NSMutableSet *wireBoundDriverIds;
/* Occluder rects (view space): floating surfaces and modal catchers
* that hit-block scroll regions beneath them. */
@property(nonatomic, strong) NSArray *scrollOccluderRects;
@property(nonatomic, assign) BOOL applyingScrollDriverOffset;
@property(nonatomic, assign) BOOL scrollDriverEventPending;
@property(nonatomic, assign) uint64_t pendingScrollDriverId;
@property(nonatomic, assign) double pendingScrollDriverOffsetX;
@property(nonatomic, assign) double pendingScrollDriverOffsetY;
@property(nonatomic, assign) uint64_t scrollDriverEventLastEmitNs;
@property(nonatomic, assign) BOOL controlClickActive;
@@ -625,7 +653,7 @@ static NSMutableDictionary *NativeSdkCredentialQuery(NSString *service, NSString
- (void)emitInputEventWithKind:(NSInteger)kind event:(NSEvent *)event button:(NSInteger)button deltaX:(double)deltaX deltaY:(double)deltaY;
- (void)queuePointerMotionInputEvent:(NSEvent *)event kind:(NSInteger)kind button:(NSInteger)button;
- (void)emitQueuedPointerMotionInputEvent;
- (void)queueScrollInputEvent:(NSEvent *)event deltaX:(double)deltaX deltaY:(double)deltaY;
- (void)queueScrollInputEvent:(NSEvent *)event atPoint:(NSPoint)point deltaX:(double)deltaX deltaY:(double)deltaY;
- (void)emitQueuedScrollInputEvent;
- (void)emitPinchInputEventWithKind:(NSInteger)kind event:(NSEvent *)event magnification:(double)magnification;
- (void)emitPinchChangeForEvent:(NSEvent *)event;
@@ -638,7 +666,7 @@ static NSMutableDictionary *NativeSdkCredentialQuery(NSString *service, NSString
- (BOOL)emitWidgetAccessibilityActionWithId:(uint64_t)widgetId action:(NSInteger)action;
- (BOOL)emitWidgetAccessibilityActionWithId:(uint64_t)widgetId action:(NSInteger)action text:(NSString *)text selectedRange:(NSRange)selectedRange hasSelectedRange:(BOOL)hasSelectedRange;
- (void)setSurfaceCursor:(NSCursor *)cursor;
- (void)setScrollDrivers:(const native_sdk_appkit_scroll_driver_t *)drivers count:(NSUInteger)count;
- (void)setScrollDrivers:(const native_sdk_appkit_scroll_driver_t *)drivers count:(NSUInteger)count occluders:(const native_sdk_appkit_scroll_occluder_t *)occluders occluderCount:(NSUInteger)occluderCount;
@end
@interface NativeSdkAssetSchemeHandler : NSObject <WKURLSchemeHandler>
@@ -905,7 +933,7 @@ static NSMutableDictionary *NativeSdkCredentialQuery(NSString *service, NSString
- (NSInteger)presentGpuSurfacePacketBinaryInWindow:(uint64_t)windowId label:(NSString *)label surfaceWidth:(CGFloat)surfaceWidth height:(CGFloat)surfaceHeight scale:(CGFloat)scale clearR:(uint8_t)clearR clearG:(uint8_t)clearG clearB:(uint8_t)clearB clearA:(uint8_t)clearA requiresRender:(BOOL)requiresRender commandCount:(NSUInteger)commandCount unsupportedCommandCount:(NSUInteger)unsupportedCommandCount representable:(BOOL)representable packet:(const uint8_t *)packet byteLength:(NSUInteger)byteLength;
- (BOOL)requestGpuSurfaceFrameInWindow:(uint64_t)windowId label:(NSString *)label;
- (BOOL)noteGpuSurfaceInputInWindow:(uint64_t)windowId label:(NSString *)label;
- (BOOL)setGpuSurfaceScrollDriversInWindow:(uint64_t)windowId label:(NSString *)label drivers:(const native_sdk_appkit_scroll_driver_t *)drivers count:(NSUInteger)count;
- (BOOL)setGpuSurfaceScrollDriversInWindow:(uint64_t)windowId label:(NSString *)label drivers:(const native_sdk_appkit_scroll_driver_t *)drivers count:(NSUInteger)count occluders:(const native_sdk_appkit_scroll_occluder_t *)occluders occluderCount:(NSUInteger)occluderCount;
- (BOOL)showContextMenuInWindow:(uint64_t)windowId label:(NSString *)label x:(double)x y:(double)y token:(uint64_t)token items:(const native_sdk_appkit_context_menu_item_t *)items count:(NSUInteger)count;
- (BOOL)uploadGpuSurfaceImageWithId:(uint64_t)imageId width:(NSUInteger)width height:(NSUInteger)height rgba8:(const uint8_t *)rgba8 byteLength:(NSUInteger)byteLength;
- (BOOL)removeGpuSurfaceImageWithId:(uint64_t)imageId;
@@ -3433,15 +3461,28 @@ static NSDictionary *NativeSdkPacketDictionaryFromBinary(const uint8_t *bytes, N
@implementation NativeSdkScrollDriverView
- (NSView *)hitTest:(NSPoint)point {
// Scroll-wheel events route to the driver through the ordinary hit
// test so AppKit's own (responsive) scrolling machinery handles them
// a programmatically forwarded scrollWheel: is ignored by that
// path. Everything else passes through to the canvas beneath, except
// the overlay scrollers themselves (the knob stays grabbable).
// Wheel events deliberately do NOT hit the driver: they fall
// through to the surface, whose scrollWheel: resolves the gesture's
// dominant axis against every driver under the pointer, locks the
// gesture, forwards it to the winner, and splits any residual axis
// to the engine wire per-axis routing that direct NSScrollView
// delivery cannot perform (NSScrollView consumes whole events, so a
// diagonal gesture over a nested vertical list would swallow the
// horizontal component its ancestor owns). The forwarded event
// skips AppKit's concurrent responsive-scrolling fast path, but the
// scroller still owns feel momentum, rubber-band, and the overlay
// scroller all run from forwarded events (the responder-chain
// fallback always relied on exactly that). Everything else passes
// through to the canvas beneath, except the overlay scrollers
// themselves (the knob stays grabbable).
NSView *hit = [super hitTest:point];
if (!hit) return nil;
// Wheel events never hit-test into the driver not even over a
// visible overlay scroller: a wheel over the knob still needs the
// splitter, or its cross-axis component would be swallowed. Only
// pointer interaction keeps the knob grabbable.
NSEvent *current = NSApp.currentEvent;
if (current && current.type == NSEventTypeScrollWheel) return hit;
if (current && current.type == NSEventTypeScrollWheel) return nil;
NSView *candidate = hit;
while (candidate && candidate != self) {
if ([candidate isKindOfClass:[NSScroller class]]) return hit;
@@ -5939,15 +5980,96 @@ static BOOL NativeSdkCompositeBlurWriteRegion(NSDictionary *command, CGFloat sca
}
- (void)scrollWheel:(NSEvent *)event {
NativeSdkScrollDriverView *driver = [self scrollDriverForWheelEvent:event];
if (driver) {
// The OS scroller owns input + physics for this region (momentum,
// rubber-band, overlay scroller); the resulting contentOffset
// flows back through the clip-view bounds-change notification.
[driver scrollWheel:event];
if (self.scrollDrivers.count == 0) {
[self queueScrollInputEvent:event atPoint:[self convertPoint:event.locationInWindow fromView:nil] deltaX:-event.scrollingDeltaX deltaY:-event.scrollingDeltaY];
return;
}
[self queueScrollInputEvent:event deltaX:-event.scrollingDeltaX deltaY:-event.scrollingDeltaY];
// Gesture anchoring: owners resolve at the point where the GESTURE
// began (so momentum and wire hand-offs keep working the same
// regions after the pointer wanders), re-evaluated EVERY event
// against the drivers' live offsets which is what hands an axis
// to the ancestor the moment the inner region saturates, mirroring
// the engine's per-event walk. Legacy wheels (no phases) resolve at
// the live pointer; a quiet gap is their gesture boundary.
const BOOL legacy = event.phase == NSEventPhaseNone && event.momentumPhase == NSEventPhaseNone;
NSPoint point;
if (legacy) {
point = [self convertPoint:event.locationInWindow fromView:nil];
const uint64_t now = NativeSdkTimestampNanoseconds();
const BOOL freshBurst = self.lastLegacyWheelTimestampNs == 0 ||
now - self.lastLegacyWheelTimestampNs > 250000000ull;
self.lastLegacyWheelTimestampNs = now;
if (freshBurst) {
[self.wireBoundDriverIds removeAllObjects];
self.lastNativeWheelDriver = nil;
}
} else {
if (event.phase == NSEventPhaseBegan || event.phase == NSEventPhaseMayBegin) {
self.wheelGesturePoint = [self convertPoint:event.locationInWindow fromView:nil];
self.wheelGestureActive = YES;
[self.wireBoundDriverIds removeAllObjects];
self.lastLegacyWheelTimestampNs = 0;
self.lastNativeWheelDriver = nil;
}
point = self.wheelGestureActive ? self.wheelGesturePoint : [self convertPoint:event.locationInWindow fromView:nil];
if (event.momentumPhase == NSEventPhaseEnded || event.momentumPhase == NSEventPhaseCancelled) {
self.wheelGestureActive = NO;
}
}
if (!self.wireBoundDriverIds) self.wireBoundDriverIds = [[NSMutableSet alloc] init];
const double canvasDx = -event.scrollingDeltaX;
const double canvasDy = -event.scrollingDeltaY;
// Zero-delta phase events (begins, the terminal Ended/Cancelled)
// resolve no owner but still carry the bookkeeping the scroller's
// overscroll recovery keys off they forward to the gesture's last
// native recipient and nowhere else.
if (canvasDx == 0 && canvasDy == 0) {
if (!legacy && self.lastNativeWheelDriver) {
[self.lastNativeWheelDriver scrollWheel:event];
}
return;
}
NSArray *chain = [self wheelCandidateChainAtPoint:point];
NativeSdkScrollDriverView *ownerX = nil;
NativeSdkScrollDriverView *ownerY = nil;
[self resolveWheelOwnersInChain:chain canvasDx:canvasDx canvasDy:canvasDy ownerX:&ownerX ownerY:&ownerY];
// ONE owner takes the whole event natively; everything ambiguous
// rides the wire, where the engine's real routing decides:
// - no owner at all (dead axes, an overlay over the anchor);
// - the two axes owned by DIFFERENT regions (one NSScrollView
// cannot keep the axes apart the engine applies each axis to
// its own region and the set-offset flags sync the scrollers);
// - an owner the wire already scrolled this gesture (the engine
// applied relative deltas its next ABSOLUTE report would
// erase).
// A partially consumable delta stays NATIVE and clamps at the edge
// exactly the engine's rule (the region that can move consumes
// the whole event; the remainder dies), so native and engine-only
// execution agree.
const BOOL dominantVertical = fabs(canvasDy) >= fabs(canvasDx);
NativeSdkScrollDriverView *native = dominantVertical ? (ownerY ?: ownerX) : (ownerX ?: ownerY);
const BOOL splitOwners = ownerX && ownerY && ownerX != ownerY;
const BOOL nativeWireBound = native && [self.wireBoundDriverIds containsObject:@(native.driverId)];
if (!native || splitOwners || nativeWireBound) {
if (ownerX) [self.wireBoundDriverIds addObject:@(ownerX.driverId)];
if (ownerY) [self.wireBoundDriverIds addObject:@(ownerY.driverId)];
// Offsets first (one clock): the wire routing consults the
// runtime's offsets, so any coalesced driver report must land
// before the deltas that depend on it.
[self emitQueuedScrollDriverEvent];
[self queueScrollInputEvent:event atPoint:point deltaX:canvasDx deltaY:canvasDy];
return;
}
// The OS scroller owns input + physics for this event (momentum,
// rubber-band, overlay scroller); the resulting contentOffset flows
// back through the clip-view bounds-change notification.
self.lastNativeWheelDriver = native;
[native scrollWheel:event];
}
- (void)magnifyWithEvent:(NSEvent *)event {
@@ -6154,10 +6276,13 @@ static BOOL NativeSdkCompositeBlurWriteRegion(NSDictionary *command, CGFloat sca
deltaY:0];
}
- (void)queueScrollInputEvent:(NSEvent *)event deltaX:(double)deltaX deltaY:(double)deltaY {
// The POINT is caller-supplied: driver hand-offs pass the gesture's
// anchor so residual and saturated-momentum deltas route to the same
// regions the native side resolved, however far the pointer wandered.
- (void)queueScrollInputEvent:(NSEvent *)event atPoint:(NSPoint)point deltaX:(double)deltaX deltaY:(double)deltaY {
if (!self.host || self.surfaceLabel.length == 0 || !event) return;
if (deltaX == 0 && deltaY == 0) return;
self.pendingScrollPoint = [self convertPoint:event.locationInWindow fromView:nil];
self.pendingScrollPoint = point;
self.pendingScrollDeltaX += deltaX;
self.pendingScrollDeltaY += deltaY;
self.pendingScrollModifiers = NativeSdkModifierFlagsForEvent(event);
@@ -6266,8 +6391,18 @@ static double NativeSdkClampedPinchMagnification(double magnification) {
// IS the canvas scroll offset, reported back per frame interval through
// GPU_SURFACE_SCROLL_DRIVER events.
- (void)setScrollDrivers:(const native_sdk_appkit_scroll_driver_t *)drivers count:(NSUInteger)count {
- (void)setScrollDrivers:(const native_sdk_appkit_scroll_driver_t *)drivers count:(NSUInteger)count occluders:(const native_sdk_appkit_scroll_occluder_t *)occluders occluderCount:(NSUInteger)occluderCount {
if (!self.scrollDrivers) self.scrollDrivers = [[NSMutableArray alloc] init];
// Occluder rects arrive in canvas coordinates (top-left origin,
// y-down); store them flipped into this view's space, like the
// driver frames.
NSMutableArray *occluderRects = [[NSMutableArray alloc] initWithCapacity:occluderCount];
for (NSUInteger index = 0; index < occluderCount; index += 1) {
const native_sdk_appkit_scroll_occluder_t occluder = occluders[index];
const NSRect rect = NSMakeRect(occluder.x, self.bounds.size.height - occluder.y - occluder.height, occluder.width, occluder.height);
[occluderRects addObject:[NSValue valueWithRect:rect]];
}
self.scrollOccluderRects = occluderRects;
for (NSInteger index = (NSInteger)self.scrollDrivers.count - 1; index >= 0; index -= 1) {
NativeSdkScrollDriverView *driver = self.scrollDrivers[(NSUInteger)index];
BOOL present = NO;
@@ -6282,6 +6417,11 @@ static double NativeSdkClampedPinchMagnification(double magnification) {
[driver removeFromSuperview];
[self.scrollDrivers removeObjectAtIndex:(NSUInteger)index];
}
// Rebuilt in SPEC order every push: the specs ride layout pre-order
// (outermost first, deepest last), and the wheel selection walk
// depends on that order a keyed reorder that keeps ids must not
// leave a stale creation order deciding which region is "deepest".
NSMutableArray *ordered = [[NSMutableArray alloc] initWithCapacity:count];
for (NSUInteger spec = 0; spec < count; spec += 1) {
const native_sdk_appkit_scroll_driver_t desired = drivers[spec];
NativeSdkScrollDriverView *driver = nil;
@@ -6298,10 +6438,14 @@ static double NativeSdkClampedPinchMagnification(double magnification) {
driver.driverId = desired.driver_id;
driver.drawsBackground = NO;
driver.hasVerticalScroller = YES;
driver.hasHorizontalScroller = NO;
// The horizontal scroller engages only when the driver's
// content is wider than its frame the runtime pins
// content_width to the frame width on regions without a
// horizontal axis grant, so vertical-only regions never
// grow one.
driver.hasHorizontalScroller = YES;
driver.scrollerStyle = NSScrollerStyleOverlay;
driver.autohidesScrollers = YES;
driver.horizontalScrollElasticity = NSScrollElasticityNone;
driver.automaticallyAdjustsContentInsets = NO;
NativeSdkScrollDriverDocumentView *document = [[NativeSdkScrollDriverDocumentView alloc] initWithFrame:NSMakeRect(0, 0, MAX(desired.content_width, 1), MAX(desired.content_height, 1))];
driver.documentView = document;
@@ -6314,52 +6458,159 @@ static double NativeSdkClampedPinchMagnification(double magnification) {
// against anything but the actual frame races with relayout.
// Elasticity rides the same reconcile: a region's edge behavior
// (pin at the edges vs bounce past them) is per-region state the
// runtime owns.
NSScrollElasticity elasticity = desired.rubber_band ? NSScrollElasticityAllowed : NSScrollElasticityNone;
if (driver.verticalScrollElasticity != elasticity) driver.verticalScrollElasticity = elasticity;
// runtime owns, armed ONLY on axes the region grants an
// ungranted axis must never bounce or report a native offset the
// runtime would ignore and fight back.
NSScrollElasticity verticalElasticity = (desired.rubber_band && desired.scrolls_y) ? NSScrollElasticityAllowed : NSScrollElasticityNone;
if (driver.verticalScrollElasticity != verticalElasticity) driver.verticalScrollElasticity = verticalElasticity;
NSScrollElasticity horizontalElasticity = (desired.rubber_band && desired.scrolls_x) ? NSScrollElasticityAllowed : NSScrollElasticityNone;
if (driver.horizontalScrollElasticity != horizontalElasticity) driver.horizontalScrollElasticity = horizontalElasticity;
// Scroller chrome follows the grants the same way, and the
// grants themselves ride the view for the wheel winner walk.
if (driver.hasVerticalScroller != (desired.scrolls_y != 0)) driver.hasVerticalScroller = desired.scrolls_y != 0;
if (driver.hasHorizontalScroller != (desired.scrolls_x != 0)) driver.hasHorizontalScroller = desired.scrolls_x != 0;
driver.grantsX = desired.scrolls_x != 0;
driver.grantsY = desired.scrolls_y != 0;
driver.parentDriverId = desired.parent_driver_id;
driver.occluderMask = desired.occluder_mask;
NSRect target = NSMakeRect(desired.x, self.bounds.size.height - desired.y - desired.height, desired.width, desired.height);
if (!NSEqualRects(driver.frame, target)) driver.frame = target;
NSSize contentSize = NSMakeSize(MAX(desired.content_width, 1), MAX(desired.content_height, 1));
if (driver.documentView && !NSEqualSizes(driver.documentView.frame.size, contentSize)) {
[driver.documentView setFrameSize:contentSize];
}
if (created || desired.set_offset) [self applyScrollDriverOffset:driver offsetY:desired.offset_y];
if (created || desired.set_offset_x || desired.set_offset_y) {
[self applyScrollDriverOffset:driver
offsetX:desired.offset_x
setX:(created || desired.set_offset_x)
offsetY:desired.offset_y
setY:(created || desired.set_offset_y)];
}
[ordered addObject:driver];
}
self.scrollDrivers = ordered;
}
- (void)applyScrollDriverOffset:(NativeSdkScrollDriverView *)driver offsetY:(double)offsetY {
// Per-axis programmatic offset write: an axis the runtime did not move
// keeps the native scroller's CURRENT origin, so a vertical write can
// never drag a stale horizontal offset over native motion whose
// coalesced report is still in flight (and vice versa).
- (void)applyScrollDriverOffset:(NativeSdkScrollDriverView *)driver offsetX:(double)offsetX setX:(BOOL)setX offsetY:(double)offsetY setY:(BOOL)setY {
const NSPoint current = driver.contentView.bounds.origin;
self.applyingScrollDriverOffset = YES;
[driver.contentView setBoundsOrigin:NSMakePoint(0, offsetY)];
[driver.contentView setBoundsOrigin:NSMakePoint(setX ? offsetX : current.x, setY ? offsetY : current.y)];
[driver reflectScrolledClipView:driver.contentView];
self.applyingScrollDriverOffset = NO;
// A queued (frame-coalesced) report for THIS driver predates the
// programmatic write: rewrite it to the offsets the clip view
// actually settled on, so the stale pair can never re-land and
// yank the region back after the runtime moved it.
if (self.scrollDriverEventPending && self.pendingScrollDriverId == driver.driverId) {
self.pendingScrollDriverOffsetX = driver.contentView.bounds.origin.x;
self.pendingScrollDriverOffsetY = driver.contentView.bounds.origin.y;
}
}
- (NativeSdkScrollDriverView *)scrollDriverForPoint:(NSPoint)viewPoint {
// Driver specs arrive in layout pre-order, so the LAST hit is the
// deepest scroll region under the pointer.
NativeSdkScrollDriverView *result = nil;
// Direction-aware consumption, the engine's nested-handoff predicate
// (`canvasWidgetScrollCanConsumeAxis`) restated against the native
// scroller with the engine's EXACT bounds (no edge tolerance the
// engine compares exactly, and a half-point slack would hand an
// almost-home inner region's delta to its ancestor where the engine
// still moves the inner one): a driver consumes a delta on an axis
// only while its offset can still move in that direction, so a
// saturated inner region hands an outward swipe to its ancestor
// exactly like the engine walk does.
static BOOL NativeSdkScrollDriverCanConsumeVertically(NativeSdkScrollDriverView *driver, double canvasDelta) {
if (canvasDelta == 0) return NO;
const double offset = driver.contentView.bounds.origin.y;
const double maxOffset = driver.documentView.frame.size.height - driver.contentView.bounds.size.height;
return canvasDelta > 0 ? offset < maxOffset : offset > 0;
}
static BOOL NativeSdkScrollDriverCanConsumeHorizontally(NativeSdkScrollDriverView *driver, double canvasDelta) {
if (canvasDelta == 0) return NO;
const double offset = driver.contentView.bounds.origin.x;
const double maxOffset = driver.documentView.frame.size.width - driver.contentView.bounds.size.width;
return canvasDelta > 0 ? offset < maxOffset : offset > 0;
}
// The wheel CANDIDATE CHAIN at a point: the deepest driver under the
// point (specs ride layout pre-order, so the last containing entry is
// the hit region) plus its ancestors by parent id the widget-tree
// containment chain the engine's route walks. Restricting owner
// resolution to this chain keeps a geometrically overlapping but
// UNRELATED region (a background scroller behind a floating surface)
// from stealing an axis the engine would never give it. Ordered
// outermost first, like the spec array.
- (NSArray *)wheelCandidateChainAtPoint:(NSPoint)viewPoint {
// Occluders first: an overlay containing the point hit-blocks every
// region beneath it (bit set in the region's mask), exactly where
// the engine's hit test would give the point to the overlay's
// branch. Blocked drivers decline outright the wheel then rides
// the wire, where real hit-testing decides.
uint32_t blockedBits = 0;
NSUInteger occluderIndex = 0;
for (NSValue *value in self.scrollOccluderRects) {
if (NSPointInRect(viewPoint, value.rectValue)) blockedBits |= (uint32_t)1 << occluderIndex;
occluderIndex += 1;
}
NativeSdkScrollDriverView *hit = nil;
for (NativeSdkScrollDriverView *driver in self.scrollDrivers) {
if (NSPointInRect(viewPoint, driver.frame)) result = driver;
if ((driver.occluderMask & blockedBits) != 0) continue;
if (NSPointInRect(viewPoint, driver.frame)) hit = driver;
}
return result;
if (!hit) return @[];
NSMutableArray *chain = [[NSMutableArray alloc] init];
NativeSdkScrollDriverView *current = hit;
while (current) {
if ((current.occluderMask & blockedBits) == 0) [chain insertObject:current atIndex:0];
uint64_t parentId = current.parentDriverId;
NativeSdkScrollDriverView *parent = nil;
if (parentId != 0) {
for (NativeSdkScrollDriverView *candidate in self.scrollDrivers) {
if (candidate.driverId == parentId) {
parent = candidate;
break;
}
}
}
current = parent;
}
return chain;
}
- (NativeSdkScrollDriverView *)scrollDriverForWheelEvent:(NSEvent *)event {
if (self.scrollDrivers.count == 0) return nil;
const BOOL legacy = event.phase == NSEventPhaseNone && event.momentumPhase == NSEventPhaseNone;
if (legacy) {
return [self scrollDriverForPoint:[self convertPoint:event.locationInWindow fromView:nil]];
// Resolve each axis's OWNER independently over the candidate chain
// the engine's per-axis walk restated over the native drivers: an axis
// routes to the DEEPEST chain region that can consume its delta right
// now (direction-aware, so a saturated inner region hands an outward
// swipe to its ancestor), falling back to the OUTERMOST region granted
// that axis which bounces if elastic (rubber-band needs no range,
// short content included) and clamps into a harmless no-op if not,
// exactly the engine's outermost-applies rule. Nil means no native
// region owns the axis. Elastic-take deliberately never outranks a
// consumer: an elastic inner list that saturates while its parent still
// has range hands the axis over instead of bouncing forever.
- (void)resolveWheelOwnersInChain:(NSArray *)chain
canvasDx:(double)canvasDx
canvasDy:(double)canvasDy
ownerX:(NativeSdkScrollDriverView **)ownerX
ownerY:(NativeSdkScrollDriverView **)ownerY {
NativeSdkScrollDriverView *consumerX = nil;
NativeSdkScrollDriverView *fallbackX = nil;
NativeSdkScrollDriverView *consumerY = nil;
NativeSdkScrollDriverView *fallbackY = nil;
for (NativeSdkScrollDriverView *driver in chain) {
if (canvasDx != 0 && driver.grantsX) {
if (NativeSdkScrollDriverCanConsumeHorizontally(driver, canvasDx)) consumerX = driver;
if (!fallbackX) fallbackX = driver;
}
if (canvasDy != 0 && driver.grantsY) {
if (NativeSdkScrollDriverCanConsumeVertically(driver, canvasDy)) consumerY = driver;
if (!fallbackY) fallbackY = driver;
}
}
if (event.phase == NSEventPhaseBegan || event.phase == NSEventPhaseMayBegin) {
// Lock the gesture to the region under the pointer so momentum
// keeps scrolling it after the pointer wanders.
self.activeWheelDriver = [self scrollDriverForPoint:[self convertPoint:event.locationInWindow fromView:nil]];
}
NativeSdkScrollDriverView *driver = self.activeWheelDriver;
if (event.momentumPhase == NSEventPhaseEnded || event.momentumPhase == NSEventPhaseCancelled) {
self.activeWheelDriver = nil;
}
return driver;
*ownerX = consumerX ?: fallbackX;
*ownerY = consumerY ?: fallbackY;
}
- (void)scrollDriverBoundsDidChange:(NSNotification *)note {
@@ -6367,17 +6618,18 @@ static double NativeSdkClampedPinchMagnification(double magnification) {
NSClipView *clipView = note.object;
for (NativeSdkScrollDriverView *driver in self.scrollDrivers) {
if (driver.contentView != clipView) continue;
[self queueScrollDriverEventWithId:driver.driverId offsetY:clipView.bounds.origin.y];
[self queueScrollDriverEventWithId:driver.driverId offsetX:clipView.bounds.origin.x offsetY:clipView.bounds.origin.y];
return;
}
}
- (void)queueScrollDriverEventWithId:(uint64_t)driverId offsetY:(double)offsetY {
- (void)queueScrollDriverEventWithId:(uint64_t)driverId offsetX:(double)offsetX offsetY:(double)offsetY {
if (!self.host || self.surfaceLabel.length == 0) return;
if (self.scrollDriverEventPending && self.pendingScrollDriverId != driverId) {
[self emitQueuedScrollDriverEvent];
}
self.pendingScrollDriverId = driverId;
self.pendingScrollDriverOffsetX = offsetX;
self.pendingScrollDriverOffsetY = offsetY;
if (self.scrollDriverEventPending) return;
self.scrollDriverEventPending = YES;
@@ -6399,6 +6651,7 @@ static double NativeSdkClampedPinchMagnification(double magnification) {
- (void)emitQueuedScrollDriverEvent {
if (!self.scrollDriverEventPending) return;
const uint64_t driverId = self.pendingScrollDriverId;
const double offsetX = self.pendingScrollDriverOffsetX;
const double offsetY = self.pendingScrollDriverOffsetY;
self.scrollDriverEventPending = NO;
if (!self.host || self.surfaceLabel.length == 0) return;
@@ -6411,6 +6664,7 @@ static double NativeSdkClampedPinchMagnification(double magnification) {
.view_label_len = [self.surfaceLabel lengthOfBytesUsingEncoding:NSUTF8StringEncoding],
.timestamp_ns = NativeSdkTimestampNanoseconds(),
.widget_id = driverId,
.scroll_driver_offset_x = offsetX,
.scroll_driver_offset_y = offsetY,
}];
}
@@ -7683,11 +7937,11 @@ static double NativeSdkClampedPinchMagnification(double magnification) {
return YES;
}
- (BOOL)setGpuSurfaceScrollDriversInWindow:(uint64_t)windowId label:(NSString *)label drivers:(const native_sdk_appkit_scroll_driver_t *)drivers count:(NSUInteger)count {
- (BOOL)setGpuSurfaceScrollDriversInWindow:(uint64_t)windowId label:(NSString *)label drivers:(const native_sdk_appkit_scroll_driver_t *)drivers count:(NSUInteger)count occluders:(const native_sdk_appkit_scroll_occluder_t *)occluders occluderCount:(NSUInteger)occluderCount {
NSString *key = [self nativeViewKeyForWindow:windowId label:label];
NSView *view = self.nativeViews[key];
if (![view isKindOfClass:[NativeSdkMetalSurfaceView class]]) return NO;
[(NativeSdkMetalSurfaceView *)view setScrollDrivers:drivers count:count];
[(NativeSdkMetalSurfaceView *)view setScrollDrivers:drivers count:count occluders:occluders occluderCount:occluderCount];
return YES;
}
@@ -11448,10 +11702,10 @@ int native_sdk_appkit_note_gpu_surface_input(native_sdk_appkit_host_t *host, uin
return [object noteGpuSurfaceInputInWindow:window_id label:labelString ?: @""] ? 1 : 0;
}
int native_sdk_appkit_set_gpu_surface_scroll_drivers(native_sdk_appkit_host_t *host, uint64_t window_id, const char *label, size_t label_len, const native_sdk_appkit_scroll_driver_t *drivers, size_t count) {
int native_sdk_appkit_set_gpu_surface_scroll_drivers(native_sdk_appkit_host_t *host, uint64_t window_id, const char *label, size_t label_len, const native_sdk_appkit_scroll_driver_t *drivers, size_t count, const native_sdk_appkit_scroll_occluder_t *occluders, size_t occluder_count) {
NativeSdkAppKitHost *object = (__bridge NativeSdkAppKitHost *)host;
NSString *labelString = label ? [[NSString alloc] initWithBytes:label length:label_len encoding:NSUTF8StringEncoding] : @"";
return [object setGpuSurfaceScrollDriversInWindow:window_id label:labelString ?: @"" drivers:drivers count:count] ? 1 : 0;
return [object setGpuSurfaceScrollDriversInWindow:window_id label:labelString ?: @"" drivers:drivers count:count occluders:occluders occluderCount:occluder_count] ? 1 : 0;
}
int native_sdk_appkit_show_context_menu(native_sdk_appkit_host_t *host, uint64_t window_id, const char *label, size_t label_len, double x, double y, uint64_t token, const native_sdk_appkit_context_menu_item_t *items, size_t count) {
+36 -5
View File
@@ -94,6 +94,7 @@ const AppKitEvent = extern struct {
reduce_motion: c_int,
high_contrast: c_int,
timer_id: u64,
scroll_driver_offset_x: f64,
scroll_driver_offset_y: f64,
menu_item_id: u32,
/// Host-stamped packet decode/draw durations riding the frame
@@ -191,7 +192,7 @@ extern fn native_sdk_appkit_adopt_view_surface(host: *AppKitHost, window_id: u64
extern fn native_sdk_appkit_release_view_surface(host: *AppKitHost, window_id: u64, label: [*]const u8, label_len: usize) c_int;
extern fn native_sdk_appkit_request_gpu_surface_frame(host: *AppKitHost, window_id: u64, label: [*]const u8, label_len: usize) c_int;
extern fn native_sdk_appkit_note_gpu_surface_input(host: *AppKitHost, window_id: u64, label: [*]const u8, label_len: usize) c_int;
extern fn native_sdk_appkit_set_gpu_surface_scroll_drivers(host: *AppKitHost, window_id: u64, label: [*]const u8, label_len: usize, drivers: [*]const AppKitScrollDriver, count: usize) c_int;
extern fn native_sdk_appkit_set_gpu_surface_scroll_drivers(host: *AppKitHost, window_id: u64, label: [*]const u8, label_len: usize, drivers: [*]const AppKitScrollDriver, count: usize, occluders: [*]const AppKitScrollOccluder, occluder_count: usize) c_int;
extern fn native_sdk_appkit_show_context_menu(host: *AppKitHost, window_id: u64, label: [*]const u8, label_len: usize, x: f64, y: f64, token: u64, items: [*]const AppKitContextMenuItem, count: usize) c_int;
extern fn native_sdk_appkit_start_timer(host: *AppKitHost, timer_id: u64, interval_ns: u64, repeats: c_int) void;
extern fn native_sdk_appkit_cancel_timer(host: *AppKitHost, timer_id: u64) void;
@@ -243,17 +244,30 @@ extern fn native_sdk_appkit_set_credential(host: *AppKitHost, service: [*]const
extern fn native_sdk_appkit_get_credential(host: *AppKitHost, service: [*]const u8, service_len: usize, account: [*]const u8, account_len: usize, buffer: [*]u8, buffer_len: usize) usize;
extern fn native_sdk_appkit_delete_credential(host: *AppKitHost, service: [*]const u8, service_len: usize, account: [*]const u8, account_len: usize) c_int;
const AppKitScrollOccluder = extern struct {
x: f64,
y: f64,
width: f64,
height: f64,
};
const AppKitScrollDriver = extern struct {
driver_id: u64,
parent_driver_id: u64,
occluder_mask: u32,
x: f64,
y: f64,
width: f64,
height: f64,
content_width: f64,
content_height: f64,
offset_x: f64,
offset_y: f64,
set_offset: c_int,
set_offset_x: c_int,
set_offset_y: c_int,
rubber_band: c_int,
scrolls_x: c_int,
scrolls_y: c_int,
};
const AppKitContextMenuItem = extern struct {
@@ -956,6 +970,7 @@ fn appkitCallback(context: ?*anyopaque, event: *const AppKitEvent) callconv(.c)
.window_id = event.window_id,
.label = event.view_label[0..event.view_label_len],
.driver_id = event.widget_id,
.offset_x = @floatCast(event.scroll_driver_offset_x),
.offset_y = @floatCast(event.scroll_driver_offset_y),
.timestamp_ns = event.timestamp_ns,
} }),
@@ -1614,7 +1629,7 @@ fn noteGpuSurfaceInput(context: ?*anyopaque, window_id: platform_mod.WindowId, l
_ = native_sdk_appkit_note_gpu_surface_input(self.host, window_id, label.ptr, label.len);
}
fn setGpuSurfaceScrollDrivers(context: ?*anyopaque, window_id: platform_mod.WindowId, label: []const u8, drivers: []const platform_mod.GpuSurfaceScrollDriver) anyerror!void {
fn setGpuSurfaceScrollDrivers(context: ?*anyopaque, window_id: platform_mod.WindowId, label: []const u8, drivers: []const platform_mod.GpuSurfaceScrollDriver, occluders: []const platform_mod.GpuSurfaceScrollOccluder) anyerror!void {
const self: *MacPlatform = @ptrCast(@alignCast(context.?));
if (self.web_engine != .system) return error.UnsupportedService;
var specs: [platform_mod.max_gpu_surface_scroll_drivers]AppKitScrollDriver = undefined;
@@ -1622,18 +1637,34 @@ fn setGpuSurfaceScrollDrivers(context: ?*anyopaque, window_id: platform_mod.Wind
for (drivers[0..count], 0..) |driver, index| {
specs[index] = .{
.driver_id = driver.id,
.parent_driver_id = driver.parent_id,
.occluder_mask = driver.occluder_mask,
.x = driver.frame.x,
.y = driver.frame.y,
.width = driver.frame.width,
.height = driver.frame.height,
.content_width = driver.content_size.width,
.content_height = driver.content_size.height,
.offset_x = driver.offset_x,
.offset_y = driver.offset_y,
.set_offset = if (driver.set_offset) 1 else 0,
.set_offset_x = if (driver.set_offset_x) 1 else 0,
.set_offset_y = if (driver.set_offset_y) 1 else 0,
.rubber_band = if (driver.rubber_band) 1 else 0,
.scrolls_x = if (driver.scrolls_x) 1 else 0,
.scrolls_y = if (driver.scrolls_y) 1 else 0,
};
}
if (native_sdk_appkit_set_gpu_surface_scroll_drivers(self.host, window_id, label.ptr, label.len, &specs, count) == 0) return error.ViewNotFound;
var occluder_specs: [platform_mod.max_gpu_surface_scroll_occluders]AppKitScrollOccluder = undefined;
const occluder_count = @min(occluders.len, occluder_specs.len);
for (occluders[0..occluder_count], 0..) |occluder, index| {
occluder_specs[index] = .{
.x = occluder.frame.x,
.y = occluder.frame.y,
.width = occluder.frame.width,
.height = occluder.frame.height,
};
}
if (native_sdk_appkit_set_gpu_surface_scroll_drivers(self.host, window_id, label.ptr, label.len, &specs, count, &occluder_specs, occluder_count) == 0) return error.ViewNotFound;
}
fn showContextMenu(context: ?*anyopaque, request: platform_mod.ContextMenuRequest) anyerror!void {
+10 -4
View File
@@ -688,9 +688,12 @@ pub const NullPlatform = struct {
scroll_driver_label_len: usize = 0,
scroll_drivers: [max_gpu_surface_scroll_drivers]GpuSurfaceScrollDriver = undefined,
scroll_driver_count: usize = 0,
scroll_occluders: [types.max_gpu_surface_scroll_occluders]types.GpuSurfaceScrollOccluder = undefined,
scroll_occluder_count: usize = 0,
scroll_driver_set_count: usize = 0,
/// Lifetime count of driver entries pushed with `set_offset = true`
/// (the runtime forcing its offset into the native scroller).
/// Lifetime count of driver entries pushed with a set-offset flag
/// on EITHER axis (the runtime forcing an offset into the native
/// scroller).
scroll_driver_set_offset_count: usize = 0,
/// Whether this modeled host presents native context menus. On by
/// default (the recorder below stands in for the OS menu); tests
@@ -2794,7 +2797,7 @@ pub const NullPlatform = struct {
return self.menus[0..self.menu_count];
}
fn setGpuSurfaceScrollDrivers(context: ?*anyopaque, window_id: WindowId, label: []const u8, drivers: []const GpuSurfaceScrollDriver) anyerror!void {
fn setGpuSurfaceScrollDrivers(context: ?*anyopaque, window_id: WindowId, label: []const u8, drivers: []const GpuSurfaceScrollDriver, occluders: []const types.GpuSurfaceScrollOccluder) anyerror!void {
const self: *NullPlatform = @ptrCast(@alignCast(context.?));
if (!self.gpu_surface_scroll_drivers) return error.UnsupportedService;
self.scroll_driver_set_count += 1;
@@ -2804,8 +2807,11 @@ pub const NullPlatform = struct {
const count = @min(drivers.len, self.scroll_drivers.len);
@memcpy(self.scroll_drivers[0..count], drivers[0..count]);
self.scroll_driver_count = count;
const occluder_count = @min(occluders.len, self.scroll_occluders.len);
@memcpy(self.scroll_occluders[0..occluder_count], occluders[0..occluder_count]);
self.scroll_occluder_count = occluder_count;
for (drivers) |driver| {
if (driver.set_offset) self.scroll_driver_set_offset_count += 1;
if (driver.set_offset_x or driver.set_offset_y) self.scroll_driver_set_offset_count += 1;
}
}
+2
View File
@@ -149,7 +149,9 @@ pub const touch_pointer_id_bit = types.touch_pointer_id_bit;
pub const PinchPhase = types.PinchPhase;
pub const PinchEvent = types.PinchEvent;
pub const max_gpu_surface_scroll_drivers = types.max_gpu_surface_scroll_drivers;
pub const max_gpu_surface_scroll_occluders = types.max_gpu_surface_scroll_occluders;
pub const GpuSurfaceScrollDriver = types.GpuSurfaceScrollDriver;
pub const GpuSurfaceScrollOccluder = types.GpuSurfaceScrollOccluder;
pub const GpuSurfaceScrollDriverEvent = types.GpuSurfaceScrollDriverEvent;
pub const max_context_menu_items = types.max_context_menu_items;
pub const ContextMenuItem = types.ContextMenuItem;
+60 -13
View File
@@ -1824,6 +1824,22 @@ pub const PinchEvent = struct {
/// scrollable canvas region).
pub const max_gpu_surface_scroll_drivers: usize = 16;
/// Upper bound on scroll OCCLUDERS per gpu-surface view (floating
/// surfaces and modal catchers that hit-block regions beneath them).
pub const max_gpu_surface_scroll_occluders: usize = 8;
/// A surface that hit-blocks scroll regions beneath it: anchored
/// floating surfaces (popovers, dropdowns, tooltips) at their frames,
/// and modal surfaces (dialog/drawer/sheet input catchers) as the whole
/// view. The host must not route a wheel to a driver whose
/// `occluder_mask` includes an occluder containing the point — the
/// engine's hit test would give that point to the overlay's branch, so
/// the native fast path has to decline it the same way (the wheel then
/// rides the wire, where real hit-testing decides).
pub const GpuSurfaceScrollOccluder = struct {
frame: geometry.RectF,
};
/// One native scroll driver's desired state, pushed by the runtime on
/// every widget-layout install and every presented frame (self-healing
/// against host-side relayouts). Coordinates are view-local canvas points
@@ -1835,20 +1851,50 @@ pub const GpuSurfaceScrollDriver = struct {
/// The scroll region's layout frame, view-local.
frame: geometry.RectF,
/// Total scrollable content size for the region. The vertical max
/// scroll offset is `content_size.height - frame.height`.
/// scroll offset is `content_size.height - frame.height`; the
/// horizontal one is `content_size.width - frame.width` (a region
/// that does not scroll horizontally reports `width ==
/// frame.width`, so the horizontal max is 0 and the native
/// scroller never travels sideways).
content_size: geometry.SizeF,
/// The runtime's current scroll offset (canvas points, y-down).
/// The runtime's current scroll offsets (canvas points, y-down,
/// x-rightward).
offset_x: f32 = 0,
offset_y: f32 = 0,
/// True when the runtime changed the offset from a non-driver source
/// (keyboard scroll, programmatic scroll, rebuild clamp): the host
/// must write `offset_y` into the native scroller. False leaves the
/// native scroller alone — the driver owns the offset.
set_offset: bool = false,
/// True when the runtime changed that axis's offset from a
/// non-driver source (keyboard scroll, programmatic scroll, rebuild
/// clamp): the host must write that offset into the native
/// scroller. False leaves the axis alone — the driver owns it. Per
/// axis, so a programmatic vertical write can never push a stale
/// horizontal offset over native motion whose coalesced report is
/// still in flight (and vice versa).
set_offset_x: bool = false,
set_offset_y: bool = false,
/// Edge behavior for this region's native scroller: false (the
/// default) pins scrolling at the content edges, true lets the OS
/// scroller bounce past them (vertical elasticity). Reconciled on
/// every push like the frame and content size.
/// scroller bounce past them. Reconciled on every push like the
/// frame and content size, and armed per axis through the grants
/// below.
rubber_band: bool = false,
/// The nearest ANCESTOR driver's id (0 = none): the widget-tree
/// containment chain, pushed so the host can restrict wheel-owner
/// resolution to the hit region and its ancestors — geometric
/// overlap alone would let an unrelated background region behind a
/// floating surface steal an axis the engine's route would never
/// give it.
parent_id: u64 = 0,
/// Which occluders (by bit index into the sync call's occluder
/// array) hit-block THIS region: every pushed occluder that is not
/// an ancestor of the region — a scroll region inside an open
/// popover is not blocked by its own surface.
occluder_mask: u32 = 0,
/// Which axes the region GRANTS (`canvas.widgetScrollsAxis`): the
/// host arms elasticity and scroller chrome only on granted axes.
/// Distinct from having range right now — a granted axis with short
/// content keeps its scroller parked but may still bounce, while an
/// ungranted axis must never move, bounce, or grow a scroller.
scrolls_x: bool = false,
scrolls_y: bool = true,
};
/// A native scroll driver reported a new content offset (the user
@@ -1859,6 +1905,7 @@ pub const GpuSurfaceScrollDriverEvent = struct {
window_id: WindowId = 1,
label: []const u8,
driver_id: u64,
offset_x: f32 = 0,
offset_y: f32 = 0,
timestamp_ns: u64 = 0,
};
@@ -2514,12 +2561,12 @@ pub const PlatformServices = struct {
update_widget_accessibility_fn: ?*const fn (context: ?*anyopaque, snapshot: WidgetAccessibilitySnapshot) anyerror!void = null,
/// Reconcile the native scroll drivers for a gpu-surface view against
/// the full desired set: create missing drivers, update frames /
/// content extents / (when `set_offset`) offsets, remove drivers whose
/// content extents / (per set-offset flag) offsets, remove drivers whose
/// id is absent. Idempotent — the runtime calls this on every layout
/// install and every presented frame. Null on platforms without
/// native scroll drivers (GTK / Win32 / null default), which keeps
/// scrolling on the engine's wheel physics.
set_gpu_surface_scroll_drivers_fn: ?*const fn (context: ?*anyopaque, window_id: WindowId, label: []const u8, drivers: []const GpuSurfaceScrollDriver) anyerror!void = null,
set_gpu_surface_scroll_drivers_fn: ?*const fn (context: ?*anyopaque, window_id: WindowId, label: []const u8, drivers: []const GpuSurfaceScrollDriver, occluders: []const GpuSurfaceScrollOccluder) anyerror!void = null,
/// Present a native context menu at the request's pointer location.
/// Asynchronous: the selection (or dismissal) arrives later as a
/// `context_menu_action` event echoing `request.token`. Null on
@@ -2751,9 +2798,9 @@ pub const PlatformServices = struct {
return close_fn(self.context, window_id, label);
}
pub fn setGpuSurfaceScrollDrivers(self: PlatformServices, window_id: WindowId, label: []const u8, drivers: []const GpuSurfaceScrollDriver) anyerror!void {
pub fn setGpuSurfaceScrollDrivers(self: PlatformServices, window_id: WindowId, label: []const u8, drivers: []const GpuSurfaceScrollDriver, occluders: []const GpuSurfaceScrollOccluder) anyerror!void {
const set_fn = self.set_gpu_surface_scroll_drivers_fn orelse return error.UnsupportedService;
return set_fn(self.context, window_id, label, drivers);
return set_fn(self.context, window_id, label, drivers, occluders);
}
pub fn showContextMenu(self: PlatformServices, request: ContextMenuRequest) anyerror!void {
+33 -11
View File
@@ -207,10 +207,17 @@ fn widgetClipsForAudit(widget: Widget) bool {
return widget_tree.widgetClipsContent(widget) or widget.layout.virtualized;
}
/// Scroll scopes scroll vertically by design: content below the fold is
/// reachable after a scroll, so only a horizontal full-clip counts.
/// Whether a scroll scope carries content into view along each axis:
/// content past a granted axis's fold is reachable after a scroll, so a
/// full-clip on that axis never counts. A horizontal-only shelf grants
/// x and NOT y — an offscreen-right tile is reachable, a below-viewport
/// one is genuinely stranded.
fn scopeScrollsVertically(widget: Widget) bool {
return widget.kind == .scroll_view or widget.layout.virtualized;
return (widget.kind == .scroll_view and widget.scroll_axes.scrollsVertically()) or widget.layout.virtualized;
}
fn scopeScrollsHorizontally(widget: Widget) bool {
return widget.kind == .scroll_view and widget.scroll_axes.scrollsHorizontally() and !widget.layout.virtualized;
}
fn auditFocusReachable(layout: WidgetLayoutTree, node_index: usize, sink: *FindingSink) void {
@@ -221,14 +228,17 @@ fn auditFocusReachable(layout: WidgetLayoutTree, node_index: usize, sink: *Findi
const frame = node.frame.normalized();
var current = node_index;
// Once the walk crosses a vertically scrolling scope, the widget's
// layout-space y is scroll content, not screen geometry: scrolling
// carries it into every OUTER scope's band, so only horizontal
// full-clips count from there up (below-the-fold rows in a long
// scroll region — windowed virtual lists included — are reachable
// by design; a pane column clipping the scroll's frame must not
// re-flag them).
// Once the walk crosses a scrolling scope, the widget's layout-space
// position ALONG THAT AXIS is scroll content, not screen geometry:
// scrolling carries it into every OUTER scope's band, so full-clips
// on that axis stop counting from there up (below-the-fold rows in
// a long scroll region — windowed virtual lists included — and
// offscreen-right tiles on a horizontal shelf are reachable by
// design; a pane clipping the scroll's frame must not re-flag
// them). Each axis tracks independently: a horizontal shelf forgives
// x overhang while a below-viewport button inside it stays flagged.
var scrolls_vertically = false;
var scrolls_horizontally = false;
while (true) {
// Anchored floating widgets escape every ancestor clip (the
// routing layer keeps focus targets inside open overlays live).
@@ -239,14 +249,26 @@ fn auditFocusReachable(layout: WidgetLayoutTree, node_index: usize, sink: *Findi
const scope = parent.frame.normalized();
const outside_x = frame.maxX() <= scope.x or frame.x >= scope.maxX();
const outside_y = frame.maxY() <= scope.y or frame.y >= scope.maxY();
const parent_scrolls_horizontally = scopeScrollsHorizontally(parent.widget);
const vertical_scroll_scope = scrolls_vertically or scopeScrollsVertically(parent.widget);
const unreachable_here = if (vertical_scroll_scope) outside_x else (outside_x or outside_y);
const horizontal_scroll_scope = scrolls_horizontally or parent_scrolls_horizontally;
// A horizontal scroll forgives only what scrolling can
// REVEAL. Offsets clamp at zero, so content wholly BEFORE
// the origin in content space (layout x plus the region's
// own offset, checkable exactly at the immediate scope) is
// stranded on the leading side forever.
const stranded_leading_x = parent_scrolls_horizontally and !scrolls_horizontally and
frame.maxX() + parent.widget.value_x <= scope.x;
const unreachable_here = (outside_x and !horizontal_scroll_scope) or
(outside_y and !vertical_scroll_scope) or
stranded_leading_x;
if (unreachable_here) {
sink.append(.{ .rule = .focus_unreachable, .node_index = node_index, .other_index = parent_index });
return;
}
}
if (scopeScrollsVertically(parent.widget)) scrolls_vertically = true;
if (scopeScrollsHorizontally(parent.widget)) scrolls_horizontally = true;
current = parent_index;
}
}
@@ -190,6 +190,26 @@ test "below-the-fold scroll content stays reachable through OUTER clip scopes" {
try std.testing.expectEqual(a11y_audit.A11yAuditRuleKind.focus_unreachable, issues.findings[0].rule);
}
test "a horizontal shelf forgives offscreen-right tiles and still flags below-viewport content" {
var nodes: [32]canvas.WidgetLayoutNode = undefined;
var storage: [8]a11y_audit.A11yAuditFinding = undefined;
// A horizontal-only scroll region: a tile past its RIGHT edge is
// scroll content (reachable by design), while a button fully below
// the viewport is genuinely stranded — nothing scrolls vertically
// to reveal it.
const root = Widget{ .kind = .column, .children = &.{
.{ .kind = .scroll_view, .id = 2, .scroll_axes = .horizontal, .frame = geometry.RectF.init(0, 0, 400, 80), .children = &.{
.{ .kind = .button, .id = 3, .text = "Visible", .frame = geometry.RectF.init(0, 0, 140, 60) },
.{ .kind = .button, .id = 4, .text = "Offscreen right", .frame = geometry.RectF.init(500, 0, 140, 60) },
.{ .kind = .button, .id = 5, .text = "Below the shelf", .frame = geometry.RectF.init(0, 120, 140, 30) },
} },
} };
const issues = try auditTree(root, window, &nodes, &storage);
try std.testing.expectEqual(@as(usize, 1), issues.total);
try std.testing.expectEqual(a11y_audit.A11yAuditRuleKind.focus_unreachable, issues.findings[0].rule);
}
test "the formatter names the path, the role, and the fix" {
var nodes: [16]canvas.WidgetLayoutNode = undefined;
var storage: [8]a11y_audit.A11yAuditFinding = undefined;
+91 -15
View File
@@ -261,7 +261,12 @@ pub const WidgetControlIntent = struct {
kind: WidgetControlIntentKind,
actions: WidgetActions = .{},
value: ?f32 = null,
delta: f32 = 0,
/// Scroll step for `scroll_by` intents, in canvas points on each
/// axis. Keyboard and semantic scroll steps set exactly one axis;
/// which one follows the widget's scroll-axes grant (vertical
/// keymap on vertical-capable regions, horizontal on
/// horizontal-only ones).
delta: geometry.OffsetF = .{},
};
pub const WidgetSemanticAction = enum {
@@ -664,29 +669,68 @@ pub fn widgetScrollKeyboardIntent(widget: Widget, keyboard: WidgetKeyboardEvent)
if (std.ascii.eqlIgnoreCase(keyboard.key, "home")) return .{ .kind = .scroll_to_start, .actions = .{ .decrement = true } };
if (std.ascii.eqlIgnoreCase(keyboard.key, "end")) return .{ .kind = .scroll_to_end, .actions = .{ .increment = true } };
const delta = widgetScrollKeyboardDelta(widget, keyboard) orelse return null;
const step = if (delta.dy != 0) delta.dy else delta.dx;
return .{
.kind = .scroll_by,
.actions = .{
.increment = delta > 0,
.decrement = delta < 0,
.increment = step > 0,
.decrement = step < 0,
},
.delta = delta,
};
}
pub fn widgetScrollKeyboardDelta(widget: Widget, keyboard: WidgetKeyboardEvent) ?f32 {
/// True when a scroll-intent widget takes the HORIZONTAL keymap: a
/// horizontal-only `.scroll_view`. Vertical-capable regions (including
/// `both`, whose left/right arrows step sideways below) and every other
/// scrollable kind keep the vertical keymap they always had.
fn widgetScrollKeymapHorizontalOnly(widget: Widget) bool {
return widget.kind == .scroll_view and !widget.layout.virtualized and widget.scroll_axes == .horizontal;
}
/// The keyboard scroll step, axis-aware:
/// - vertical regions keep the exact legacy map (both arrow pairs step
/// the vertical axis — Left/Up a line up, Right/Down a line down —
/// and PageUp/PageDown page it);
/// - a horizontal-only region mirrors that whole map onto its one axis,
/// with line/page steps measured from the viewport WIDTH;
/// - a `both` region keeps the vertical map and gives Left/Right to the
/// horizontal axis — the two-axis convention native scroll views use.
pub fn widgetScrollKeyboardDelta(widget: Widget, keyboard: WidgetKeyboardEvent) ?geometry.OffsetF {
if (keyboard.phase != .key_down or keyboard.modifiers.hasNavigationModifier()) return null;
const viewport = widget.frame.inset(widget.layout.padding).normalized();
if (widgetScrollKeymapHorizontalOnly(widget)) {
const line_step = @max(24, viewport.width * 0.35);
const page_step = @max(line_step, viewport.width * 0.85);
if (std.ascii.eqlIgnoreCase(keyboard.key, "arrowleft") or std.ascii.eqlIgnoreCase(keyboard.key, "arrowup")) {
return geometry.OffsetF.init(-line_step, 0);
}
if (std.ascii.eqlIgnoreCase(keyboard.key, "arrowright") or std.ascii.eqlIgnoreCase(keyboard.key, "arrowdown")) {
return geometry.OffsetF.init(line_step, 0);
}
if (std.ascii.eqlIgnoreCase(keyboard.key, "pageup")) return geometry.OffsetF.init(-page_step, 0);
if (std.ascii.eqlIgnoreCase(keyboard.key, "pagedown")) return geometry.OffsetF.init(page_step, 0);
return null;
}
const dual = widget.kind == .scroll_view and !widget.layout.virtualized and widget.scroll_axes == .both;
const line_step = @max(24, viewport.height * 0.35);
const page_step = @max(line_step, viewport.height * 0.85);
if (std.ascii.eqlIgnoreCase(keyboard.key, "arrowleft") or std.ascii.eqlIgnoreCase(keyboard.key, "arrowup")) {
return -line_step;
if (dual) {
const line_step_x = @max(24, viewport.width * 0.35);
if (std.ascii.eqlIgnoreCase(keyboard.key, "arrowleft")) return geometry.OffsetF.init(-line_step_x, 0);
if (std.ascii.eqlIgnoreCase(keyboard.key, "arrowright")) return geometry.OffsetF.init(line_step_x, 0);
if (std.ascii.eqlIgnoreCase(keyboard.key, "arrowup")) return geometry.OffsetF.init(0, -line_step);
if (std.ascii.eqlIgnoreCase(keyboard.key, "arrowdown")) return geometry.OffsetF.init(0, line_step);
} else {
if (std.ascii.eqlIgnoreCase(keyboard.key, "arrowleft") or std.ascii.eqlIgnoreCase(keyboard.key, "arrowup")) {
return geometry.OffsetF.init(0, -line_step);
}
if (std.ascii.eqlIgnoreCase(keyboard.key, "arrowright") or std.ascii.eqlIgnoreCase(keyboard.key, "arrowdown")) {
return geometry.OffsetF.init(0, line_step);
}
}
if (std.ascii.eqlIgnoreCase(keyboard.key, "arrowright") or std.ascii.eqlIgnoreCase(keyboard.key, "arrowdown")) {
return line_step;
}
if (std.ascii.eqlIgnoreCase(keyboard.key, "pageup")) return -page_step;
if (std.ascii.eqlIgnoreCase(keyboard.key, "pagedown")) return page_step;
if (std.ascii.eqlIgnoreCase(keyboard.key, "pageup")) return geometry.OffsetF.init(0, -page_step);
if (std.ascii.eqlIgnoreCase(keyboard.key, "pagedown")) return geometry.OffsetF.init(0, page_step);
return null;
}
@@ -719,11 +763,43 @@ fn widgetSemanticStepControlIntent(widget: Widget, direction: WidgetSemanticStep
};
}
fn widgetSemanticScrollDelta(widget: Widget, direction: WidgetSemanticStepDirection) f32 {
/// Assistive scroll steps page ONE axis — the region's primary, by the
/// same range-aware rule its scroll semantics report through: vertical
/// wherever the vertical axis is granted and can move, the horizontal
/// axis on horizontal-only regions and on `both` regions whose content
/// only overflows sideways. A diagonal step would move the viewport on
/// an axis the assistive node never exposed. Range for the `both` case
/// reads the widget's own children (widget-walk trees carry them);
/// retained layout nodes drop children, and their caller — the
/// runtime's accessibility-action path — resolves the axis with live
/// extents instead (`canvasWidgetStepKey`).
fn widgetSemanticScrollDelta(widget: Widget, direction: WidgetSemanticStepDirection) geometry.OffsetF {
const viewport = widget.frame.inset(widget.layout.padding).normalized();
const line_step = @max(24, viewport.height * 0.35);
const page_step = @max(line_step, viewport.height * 0.85);
return if (direction == .increment) page_step else -page_step;
const sign: f32 = if (direction == .increment) 1 else -1;
const horizontal_primary = widgetScrollKeymapHorizontalOnly(widget) or
(widget.kind == .scroll_view and !widget.layout.virtualized and widget.scroll_axes == .both and
widgetChildrenScrollHorizontalOnly(widget, viewport));
if (horizontal_primary) {
const page_step_x = @max(@max(24, viewport.width * 0.35), viewport.width * 0.85);
return geometry.OffsetF.init(sign * page_step_x, 0);
}
const page_step_y = @max(@max(24, viewport.height * 0.35), viewport.height * 0.85);
return geometry.OffsetF.init(0, sign * page_step_y);
}
/// Whether a `both` region's mounted children overflow ONLY sideways:
/// no vertical range (nothing reaches past the fold) while something
/// reaches past the right edge. False on childless nodes — retained
/// trees drop children, and their callers resolve range elsewhere.
fn widgetChildrenScrollHorizontalOnly(widget: Widget, viewport: geometry.RectF) bool {
if (widget.children.len == 0) return false;
var right = viewport.maxX();
var bottom = viewport.maxY();
for (widget.children) |child| {
right = @max(right, child.frame.maxX() + widget.value_x);
bottom = @max(bottom, child.frame.maxY() + widget.value);
}
return bottom <= viewport.maxY() and right > viewport.maxX();
}
pub fn semanticActions(widget: Widget) WidgetActions {
+21 -4
View File
@@ -395,9 +395,17 @@ fn widgetClipsForAudit(widget: Widget) bool {
}
/// Whether the clip scope scrolls vertically by design, making vertical
/// overhang the normal operating mode rather than damage.
/// overhang the normal operating mode rather than damage. A
/// horizontal-only scroll view does NOT scroll vertically: content
/// escaping it downward is damage nothing can reveal.
fn scopeScrollsVertically(widget: Widget) bool {
return widget.kind == .scroll_view or widget.layout.virtualized;
return (widget.kind == .scroll_view and widget.scroll_axes.scrollsVertically()) or widget.layout.virtualized;
}
/// The horizontal twin: a scroll viewport granting the horizontal axis
/// makes horizontal overhang the operating mode, not damage.
fn scopeScrollsHorizontally(widget: Widget) bool {
return widget.kind == .scroll_view and widget.scroll_axes.scrollsHorizontally() and !widget.layout.virtualized;
}
/// Nearest ancestor whose clip bounds this node: the first clipping
@@ -428,6 +436,7 @@ fn auditNodeContainerEscape(
const scope_index = clipScopeIndex(layout, node_index);
const scope = if (scope_index) |index| layout.nodes[index].frame.normalized() else window.normalized();
const vertical_checked = if (scope_index) |index| !scopeScrollsVertically(layout.nodes[index].widget) else true;
const horizontal_checked = if (scope_index) |index| !scopeScrollsHorizontally(layout.nodes[index].widget) else true;
// Attribute the escape to the outermost offender: if any ancestor
// inside the same scope is already reported, this node's overhang is
@@ -441,8 +450,16 @@ fn auditNodeContainerEscape(
const frame = node.frame.normalized();
var overrun_x: f32 = 0;
overrun_x = @max(overrun_x, overrunPast(frame.maxX(), scope.maxX()));
overrun_x = @max(overrun_x, overrunPast(scope.x, frame.x));
if (horizontal_checked) {
overrun_x = @max(overrun_x, overrunPast(frame.maxX(), scope.maxX()));
overrun_x = @max(overrun_x, overrunPast(scope.x, frame.x));
} else if (scope_index) |index| {
// A horizontal scroll scope forgives trailing overhang (the
// scroll reveals it) but not content stranded BEFORE the
// origin: offsets clamp at zero, so anything the region's own
// offset cannot explain on the leading side is damage.
overrun_x = @max(overrun_x, overrunPast(scope.x, frame.x + layout.nodes[index].widget.value_x));
}
var overrun_y: f32 = 0;
if (vertical_checked) {
overrun_y = @max(overrun_y, overrunPast(frame.maxY(), scope.maxY()));
+5
View File
@@ -371,6 +371,9 @@ pub const BlurTokenRef = token_model.BlurTokenRef;
pub const ScrollPhysics = token_model.ScrollPhysics;
pub const ScrollOverscroll = token_model.ScrollOverscroll;
pub const ScrollState = token_model.ScrollState;
pub const ScrollAxisState = token_model.ScrollAxisState;
pub const ScrollAxis = token_model.ScrollAxis;
pub const ScrollAxes = token_model.ScrollAxes;
pub const VirtualListOptions = token_model.VirtualListOptions;
pub const VirtualListRange = token_model.VirtualListRange;
pub const virtualListRange = token_model.virtualListRange;
@@ -730,6 +733,8 @@ pub const widgetIsAnchored = @import("widget_tree.zig").widgetIsAnchored;
/// and whose children are the built window, not the full item set.
pub const widgetVirtualRuntimeScrolled = @import("widget_tree.zig").widgetVirtualRuntimeScrolled;
pub const widgetScrollPhysics = @import("widget_tree.zig").widgetScrollPhysics;
pub const widgetScrollsAxis = @import("widget_tree.zig").widgetScrollsAxis;
pub const widgetScrollAxisMetrics = @import("widget_semantics.zig").widgetScrollAxisMetrics;
pub const isWidgetHiddenInAncestors = @import("widget_tree.zig").isWidgetHiddenInAncestors;
/// The disclosure family (widget_tree.zig): collapsible widgets whose
/// content lays out at full size and REVEALS, plus the settled/concealed
+1
View File
@@ -199,6 +199,7 @@ pub const SpringToken = canvas.SpringToken;
pub const BlurTokenRef = canvas.BlurTokenRef;
pub const ScrollPhysics = canvas.ScrollPhysics;
pub const ScrollState = canvas.ScrollState;
pub const ScrollAxisState = canvas.ScrollAxisState;
pub const VirtualListOptions = canvas.VirtualListOptions;
pub const VirtualListRange = canvas.VirtualListRange;
pub const virtualListRange = canvas.virtualListRange;
+39 -4
View File
@@ -501,15 +501,41 @@ test "widget keyboard control intents map slider and scroll keys" {
const line_down = widgetKeyboardControlIntent(scroll, .{ .phase = .key_down, .key = "arrowdown" }).?;
try std.testing.expectEqual(WidgetControlIntentKind.scroll_by, line_down.kind);
try std.testing.expect(line_down.actions.increment);
try std.testing.expectApproxEqAbs(@as(f32, 35), line_down.delta, 0.001);
try std.testing.expectApproxEqAbs(@as(f32, 35), line_down.delta.dy, 0.001);
try std.testing.expectEqual(@as(f32, 0), line_down.delta.dx);
const page_up = widgetKeyboardControlIntent(scroll, .{ .phase = .key_down, .key = "pageup" }).?;
try std.testing.expectEqual(WidgetControlIntentKind.scroll_by, page_up.kind);
try std.testing.expect(page_up.actions.decrement);
try std.testing.expectApproxEqAbs(@as(f32, -85), page_up.delta, 0.001);
try std.testing.expectApproxEqAbs(@as(f32, -85), page_up.delta.dy, 0.001);
try std.testing.expectEqual(WidgetControlIntentKind.scroll_to_start, widgetKeyboardControlIntent(scroll, .{ .phase = .key_down, .key = "home" }).?.kind);
try std.testing.expectEqual(WidgetControlIntentKind.scroll_to_end, widgetKeyboardControlIntent(scroll, .{ .phase = .key_down, .key = "end" }).?.kind);
// A vertical-only region maps Left/Right to the vertical axis (the
// legacy keymap, byte-identical); a horizontal-only region mirrors
// the whole map onto its one axis, steps measured from the WIDTH;
// a both-axes region gives Left/Right to the horizontal axis.
const vertical_left = widgetKeyboardControlIntent(scroll, .{ .phase = .key_down, .key = "arrowleft" }).?;
try std.testing.expectApproxEqAbs(@as(f32, -35), vertical_left.delta.dy, 0.001);
try std.testing.expectEqual(@as(f32, 0), vertical_left.delta.dx);
const shelf = Widget{ .kind = .scroll_view, .scroll_axes = .horizontal, .frame = geometry.RectF.init(0, 0, 120, 100) };
const shelf_right = widgetKeyboardControlIntent(shelf, .{ .phase = .key_down, .key = "arrowright" }).?;
try std.testing.expectEqual(WidgetControlIntentKind.scroll_by, shelf_right.kind);
try std.testing.expect(shelf_right.actions.increment);
try std.testing.expectApproxEqAbs(@as(f32, 42), shelf_right.delta.dx, 0.001);
try std.testing.expectEqual(@as(f32, 0), shelf_right.delta.dy);
const shelf_page = widgetKeyboardControlIntent(shelf, .{ .phase = .key_down, .key = "pagedown" }).?;
try std.testing.expectApproxEqAbs(@as(f32, 102), shelf_page.delta.dx, 0.001);
const dual = Widget{ .kind = .scroll_view, .scroll_axes = .both, .frame = geometry.RectF.init(0, 0, 120, 100) };
const dual_left = widgetKeyboardControlIntent(dual, .{ .phase = .key_down, .key = "arrowleft" }).?;
try std.testing.expectApproxEqAbs(@as(f32, -42), dual_left.delta.dx, 0.001);
try std.testing.expectEqual(@as(f32, 0), dual_left.delta.dy);
const dual_down = widgetKeyboardControlIntent(dual, .{ .phase = .key_down, .key = "arrowdown" }).?;
try std.testing.expectApproxEqAbs(@as(f32, 35), dual_down.delta.dy, 0.001);
try std.testing.expectEqual(@as(f32, 0), dual_down.delta.dx);
}
test "widget semantic control intents map built-in actions" {
@@ -577,12 +603,21 @@ test "widget semantic control intents map slider and scroll actions" {
const page_down = widgetSemanticControlIntentWithActions(scroll, .increment, scroll_actions).?;
try std.testing.expectEqual(WidgetControlIntentKind.scroll_by, page_down.kind);
try std.testing.expect(page_down.actions.increment);
try std.testing.expectApproxEqAbs(@as(f32, 85), page_down.delta, 0.001);
try std.testing.expectApproxEqAbs(@as(f32, 85), page_down.delta.dy, 0.001);
try std.testing.expectEqual(@as(f32, 0), page_down.delta.dx);
const page_up = widgetSemanticControlIntentWithActions(scroll, .decrement, scroll_actions).?;
try std.testing.expectEqual(WidgetControlIntentKind.scroll_by, page_up.kind);
try std.testing.expect(page_up.actions.decrement);
try std.testing.expectApproxEqAbs(@as(f32, -85), page_up.delta, 0.001);
try std.testing.expectApproxEqAbs(@as(f32, -85), page_up.delta.dy, 0.001);
// A horizontal-only region's assistive steps page its one axis,
// measured from the viewport WIDTH.
const shelf = Widget{ .kind = .scroll_view, .scroll_axes = .horizontal, .frame = geometry.RectF.init(0, 0, 120, 100) };
const shelf_page = widgetSemanticControlIntentWithActions(shelf, .increment, scroll_actions).?;
try std.testing.expectEqual(WidgetControlIntentKind.scroll_by, shelf_page.kind);
try std.testing.expectApproxEqAbs(@as(f32, 102), shelf_page.delta.dx, 0.001);
try std.testing.expectEqual(@as(f32, 0), shelf_page.delta.dy);
}
test "widget keyboard events map to text edit events" {
+115 -12
View File
@@ -715,17 +715,48 @@ pub const ScrollPhysics = struct {
rubberband_snap_distance: f32 = 0.5,
};
pub const ScrollState = struct {
/// Which axes a scroll container scrolls. Vertical is the default —
/// every scroll region before axis declarations existed scrolled
/// vertically, and that stays byte-identical. Horizontal opts a region
/// into wheel/trackpad `delta_x`, the horizontal scrollbar, and the
/// horizontal keymap; `both` scrolls freely on the two axes at once.
pub const ScrollAxes = enum {
vertical,
horizontal,
both,
pub fn scrollsVertically(self: ScrollAxes) bool {
return self != .horizontal;
}
pub fn scrollsHorizontally(self: ScrollAxes) bool {
return self != .vertical;
}
};
/// One named scroll axis. `ScrollAxisState` carries the physics for a
/// single axis; `ScrollState` composes the two.
pub const ScrollAxis = enum {
horizontal,
vertical,
};
/// The retained scroll physics of ONE axis: offset, wheel/kinetic
/// velocity, and the extents that bound them. The two-axis `ScrollState`
/// is a pair of these; every physics rule (clamping, rubber-band,
/// kinetic decay) is per-axis, which is how independent-axis routing
/// stays independent physics.
pub const ScrollAxisState = struct {
offset: f32 = 0,
velocity: f32 = 0,
viewport_extent: f32 = 0,
content_extent: f32 = 0,
pub fn maxOffset(self: ScrollState) f32 {
pub fn maxOffset(self: ScrollAxisState) f32 {
return @max(0, nonNegative(self.content_extent) - nonNegative(self.viewport_extent));
}
pub fn clamped(self: ScrollState) ScrollState {
pub fn clamped(self: ScrollAxisState) ScrollAxisState {
var next = self;
const clamped_offset = std.math.clamp(nonNegative(next.offset), 0, next.maxOffset());
if (clamped_offset != next.offset) next.velocity = 0;
@@ -733,27 +764,27 @@ pub const ScrollState = struct {
return next;
}
pub fn applyWheel(self: ScrollState, delta: f32, physics: ScrollPhysics) ScrollState {
pub fn applyWheel(self: ScrollAxisState, delta: f32, physics: ScrollPhysics) ScrollAxisState {
return self.applyWheelWithRubberband(delta, physics, physics.overscroll == .rubber_band);
}
pub fn applyWheelClamped(self: ScrollState, delta: f32, physics: ScrollPhysics) ScrollState {
pub fn applyWheelClamped(self: ScrollAxisState, delta: f32, physics: ScrollPhysics) ScrollAxisState {
return self.applyWheelWithRubberband(delta, physics, false);
}
pub fn visualOffset(self: ScrollState) f32 {
pub fn visualOffset(self: ScrollAxisState) f32 {
return std.math.clamp(self.offset, 0, self.maxOffset());
}
pub fn overscroll(self: ScrollState) f32 {
pub fn overscroll(self: ScrollAxisState) f32 {
return self.offset - self.visualOffset();
}
pub fn needsKineticStep(self: ScrollState, physics: ScrollPhysics) bool {
pub fn needsKineticStep(self: ScrollAxisState, physics: ScrollPhysics) bool {
return @abs(self.velocity) > nonNegative(physics.stop_velocity) or @abs(self.overscroll()) > @max(0.01, nonNegative(physics.rubberband_snap_distance));
}
fn applyWheelWithRubberband(self: ScrollState, delta: f32, physics: ScrollPhysics, rubberband: bool) ScrollState {
fn applyWheelWithRubberband(self: ScrollAxisState, delta: f32, physics: ScrollPhysics, rubberband: bool) ScrollAxisState {
var next = self;
const scaled_delta = delta * physics.wheel_multiplier;
var effective_delta = scaled_delta;
@@ -771,7 +802,7 @@ pub const ScrollState = struct {
return if (rubberband) next.rubberbanded(physics) else next.clamped();
}
pub fn stepKinetic(self: ScrollState, dt_ms: f32, physics: ScrollPhysics) ScrollState {
pub fn stepKinetic(self: ScrollAxisState, dt_ms: f32, physics: ScrollPhysics) ScrollAxisState {
var next = self;
const dt_seconds = nonNegative(dt_ms) / 1000.0;
// With overscroll off there is never an excursion to recover
@@ -805,7 +836,7 @@ pub const ScrollState = struct {
return next.rubberbanded(physics);
}
fn rubberbanded(self: ScrollState, physics: ScrollPhysics) ScrollState {
fn rubberbanded(self: ScrollAxisState, physics: ScrollPhysics) ScrollAxisState {
if (physics.overscroll == .none) return self.clamped();
const extent = self.rubberbandExtent(physics);
if (extent <= 0) return self.clamped();
@@ -816,7 +847,7 @@ pub const ScrollState = struct {
return next;
}
fn rubberbandExtent(self: ScrollState, physics: ScrollPhysics) f32 {
fn rubberbandExtent(self: ScrollAxisState, physics: ScrollPhysics) f32 {
const viewport_extent = nonNegative(self.viewport_extent);
if (viewport_extent <= 0) return 0;
const ratio_extent = viewport_extent * nonNegative(physics.rubberband_extent_ratio);
@@ -826,6 +857,78 @@ pub const ScrollState = struct {
}
};
/// The TWO-AXIS scroll state of a scroll container — the record scroll
/// observation events (`on_scroll`) deliver and retained scroll physics
/// store. Flat per-axis fields rather than nested axis records so the
/// declared-record mirror a transpiled TypeScript core emits
/// (`{offsetX, offsetY, velocityX, velocityY, viewportExtentX,
/// viewportExtentY, contentExtentX, contentExtentY}`) maps onto this
/// field-by-field — the same structural matching the one-axis record
/// used. Vertical-only regions carry live `_y` fields and quiet `_x`
/// ones (offset 0, content pinned to the viewport width), and the
/// reverse for horizontal-only regions.
pub const ScrollState = struct {
offset_x: f32 = 0,
offset_y: f32 = 0,
velocity_x: f32 = 0,
velocity_y: f32 = 0,
viewport_extent_x: f32 = 0,
viewport_extent_y: f32 = 0,
content_extent_x: f32 = 0,
content_extent_y: f32 = 0,
pub fn fromAxes(x: ScrollAxisState, y: ScrollAxisState) ScrollState {
return .{
.offset_x = x.offset,
.offset_y = y.offset,
.velocity_x = x.velocity,
.velocity_y = y.velocity,
.viewport_extent_x = x.viewport_extent,
.viewport_extent_y = y.viewport_extent,
.content_extent_x = x.content_extent,
.content_extent_y = y.content_extent,
};
}
/// The named axis as a standalone physics state.
pub fn axis(self: ScrollState, comptime which: ScrollAxis) ScrollAxisState {
return switch (which) {
.horizontal => .{
.offset = self.offset_x,
.velocity = self.velocity_x,
.viewport_extent = self.viewport_extent_x,
.content_extent = self.content_extent_x,
},
.vertical => .{
.offset = self.offset_y,
.velocity = self.velocity_y,
.viewport_extent = self.viewport_extent_y,
.content_extent = self.content_extent_y,
},
};
}
/// This state with the named axis replaced.
pub fn withAxis(self: ScrollState, comptime which: ScrollAxis, state: ScrollAxisState) ScrollState {
var next = self;
switch (which) {
.horizontal => {
next.offset_x = state.offset;
next.velocity_x = state.velocity;
next.viewport_extent_x = state.viewport_extent;
next.content_extent_x = state.content_extent;
},
.vertical => {
next.offset_y = state.offset;
next.velocity_y = state.velocity;
next.viewport_extent_y = state.viewport_extent;
next.content_extent_y = state.content_extent;
},
}
return next;
}
};
pub const VirtualListOptions = struct {
item_count: usize = 0,
item_extent: f32 = 0,
+54
View File
@@ -52,6 +52,38 @@ fn warnStackContainerGap(kind: WidgetKind, gap: f32) void {
);
}
/// Debug-build diagnostics for scroll-axis combinations that only a
/// DYNAMIC value can produce (markup validation already rejects the
/// literal spellings): a horizontal grant on a virtualized region is
/// ignored — windowed virtualization prices rows, not columns — and a
/// `value_x` without a horizontal grant is silently inert. Warn and
/// keep building; the runtime behavior (vertical scrolling, offset
/// ignored) is well-defined either way.
fn warnInertScrollAxis(kind: WidgetKind, options: ElementOptionsShape) void {
if (builtin.mode != .Debug) return;
if (kind != .scroll_view) return;
if (options.virtualized and options.axis != .vertical) {
ui_log.warn(
"axis={s} is ignored on a virtualized scroll: windowed virtualization prices rows, not columns, so the region scrolls vertically",
.{@tagName(options.axis)},
);
}
if (options.value_x != 0 and (options.virtualized or !options.axis.scrollsHorizontally())) {
ui_log.warn(
"value_x does nothing without a horizontal axis grant: declare axis horizontal (or both) on the scroll region, or drop the offset",
.{},
);
}
}
/// The slice of `ElementOptions` the scroll-axis diagnostic reads —
/// declared outside the generic `Ui` so the warning helper can too.
const ElementOptionsShape = struct {
virtualized: bool,
axis: canvas.ScrollAxes,
value_x: f32,
};
/// Debug-build diagnostic for a set `wrap` on anything but a plain text
/// leaf. `ElementOptions.wrap` is text-leaf line policy only — rows and
/// columns never flow-wrap their children (that is the layout system's
@@ -479,6 +511,25 @@ pub fn Ui(comptime Msg: type) type {
text: []const u8 = "",
placeholder: []const u8 = "",
value: f32 = 0,
/// HORIZONTAL scroll offset for a horizontal-capable
/// `scroll` container (markup `value-x`) — the sideways
/// counterpart of `value`. Follows the same source-wins
/// reconcile rule: echo the `on_scroll` state's `offset_x`
/// back here and the runtime-owned offset survives
/// rebuilds; change it model-side to scroll
/// programmatically. Meaningless on every other element.
value_x: f32 = 0,
/// Which axes a `scroll` container scrolls (markup `axis`):
/// `.vertical` (the default — every scroll region before
/// axis declarations existed), `.horizontal`, or `.both`.
/// Horizontal grants opt the region into wheel/trackpad
/// `delta_x`, the bottom-edge scrollbar, and the horizontal
/// keymap (Left/Right lines, and on horizontal-only regions
/// Home/End/PageUp/PageDown too). Virtualized containers
/// ignore a horizontal grant — windowed virtualization
/// prices rows, not columns. Meaningless on non-scroll
/// elements.
axis: canvas.ScrollAxes = .vertical,
checked: bool = false,
selected: bool = false,
/// Disclosure state for tree rows (`role = .treeitem`): null
@@ -3001,6 +3052,7 @@ pub fn Ui(comptime Msg: type) type {
fn widgetFromOptions(kind: WidgetKind, options: ElementOptions) Widget {
warnStackContainerGap(kind, options.gap);
warnUnknownIconName(options.icon);
warnInertScrollAxis(kind, .{ .virtualized = options.virtualized, .axis = options.axis, .value_x = options.value_x });
var widget: Widget = .{
.kind = kind,
.frame = options.frame,
@@ -3015,6 +3067,8 @@ pub fn Ui(comptime Msg: type) type {
.autofocus = options.autofocus,
.image_id = options.image,
.value = options.value,
.value_x = options.value_x,
.scroll_axes = options.axis,
.variant = options.variant,
.size = options.size,
.state = .{
+74 -1
View File
@@ -1117,7 +1117,8 @@ pub const known_events = schema.event_names;
pub const on_scroll_element_message = "on-scroll is only supported on scroll - the runtime emits scroll offsets for scroll containers, so the handler belongs on the scroll element itself";
pub const on_reach_end_element_message = "on-reach-end is only supported on scroll - the runtime emits the approach-end signal for scroll containers, so the handler belongs on the scroll element itself";
pub const on_scroll_payload_message = "on-scroll takes a bare Msg tag whose payload is the post-scroll state (a canvas.ScrollState variant, like activity_scrolled: canvas.ScrollState, or a declared record of its offset/velocity/viewport_extent/content_extent fields for transpiled cores)";
pub const on_scroll_payload_message = "on-scroll takes a bare Msg tag whose payload is the post-scroll state (a canvas.ScrollState variant, like activity_scrolled: canvas.ScrollState, or a declared record of its offset_x/offset_y/velocity_x/velocity_y/viewport_extent_x/viewport_extent_y/content_extent_x/content_extent_y fields for transpiled cores)";
pub const on_scroll_legacy_payload_message = "on-scroll payloads are two-axis now: the one-axis {offset, velocity, viewport_extent, content_extent} record was replaced by per-axis fields - declare offset_x/offset_y, velocity_x/velocity_y, viewport_extent_x/viewport_extent_y, content_extent_x/content_extent_y (TS cores: offsetX/offsetY, velocityX/velocityY, viewportExtentX/viewportExtentY, contentExtentX/contentExtentY); a vertical list reads the _y fields where it read the old ones";
pub const on_resize_element_message = "on-resize is only supported on split - the runtime emits fraction changes for split dividers, so the handler belongs on the split element itself";
pub const on_resize_payload_message = "on-resize takes a bare Msg tag whose payload is the new first-pane fraction (an f32 variant, like sidebar_resized: f32; transpiled cores declare a one-number float arm)";
@@ -1224,6 +1225,22 @@ pub const overscroll_value_names = [_][]const u8{ "default", "none", "rubber_ban
pub const overscroll_value_message = "unknown overscroll value - scroll takes default (follow the ScrollPhysics.overscroll token, off unless a theme flips it), none (pin at the content edges), or rubber_band (bounce past them)";
pub const axis_element_message = "axis is only supported on scroll - it declares which axes the region scrolls (vertical, horizontal, or both); anywhere else it would be silently inert";
/// The `axis` attribute's closed value vocabulary: the member names of
/// `canvas.ScrollAxes`, mirrored as data here (this layer stays
/// std-only) with a lockstep test in ui_markup_view_tests.zig holding
/// the mirror equal to the live enum.
pub const axis_value_names = [_][]const u8{ "vertical", "horizontal", "both" };
pub const axis_value_message = "unknown axis value - scroll takes vertical (the default), horizontal, or both";
pub const axis_virtualized_message = "a horizontal axis grant is not supported on a virtualized scroll - windowed virtualization prices rows, not columns, so a virtual list always scrolls vertically (axis=\"vertical\" stays legal beside virtualized)";
pub const value_x_element_message = "value-x is only supported on scroll - it is the horizontal scroll offset (the sideways counterpart of value); anywhere else it would be silently inert";
pub const value_x_dependent_attr_message = "value-x needs axis=\"horizontal\" or axis=\"both\" on the same scroll - a vertical-only region never applies a horizontal offset, so without the axis grant it is silently inert";
pub const resize_duration_element_message = "resize-duration is only supported on split - it declares the split's layout tween (milliseconds; 0 snaps, the default): a rebuild that moves the bound value eases the rendered fraction there instead of snapping; anywhere else it would be silently inert";
pub const resize_easing_element_message = "resize-easing is only supported on split - it names the easing curve of the split's layout tween (linear, standard, emphasized, spring); anywhere else it would be silently inert";
@@ -3250,6 +3267,62 @@ fn validateNode(document: MarkupDocument, node: MarkupNode, parent_element: ?[]c
}
}
}
if (std.mem.eql(u8, attribute.name, "axis")) {
// Axes exist only where the runtime scrolls: anywhere
// but a scroll container the option is silently inert
// (same policy as overscroll off scroll).
if (!std.mem.eql(u8, node.name, "scroll")) {
return attrError(node, attribute, axis_element_message);
}
// The closed value vocabulary, checked on literals
// here so the teaching error lands at validation
// (bindings resolve at build, where the engines
// enforce the same set).
if (parseAttrExpression(attribute.value)) |expression| {
if (expression == .literal and !nameInList(expression.literal, &axis_value_names)) {
return attrError(node, attribute, axis_value_message);
}
// Windowed virtualization is vertical machinery:
// a horizontal GRANT on a virtualized scroll
// would be silently ignored, so exactly that
// pairing is a teaching error. A literal vertical
// axis stays legal beside virtualized, as does a
// grant beside a literal virtualized="false";
// binding-valued sides resolve at build.
if (expression == .literal and !std.mem.eql(u8, expression.literal, "vertical")) {
if (node.attr("virtualized")) |virtualized_raw| {
const virtualized_off = if (parseAttrExpression(virtualized_raw)) |virtualized_expression|
virtualized_expression != .literal or std.mem.eql(u8, virtualized_expression.literal, "false")
else
false;
if (!virtualized_off) {
return attrError(node, attribute, axis_virtualized_message);
}
}
}
}
}
if (std.mem.eql(u8, attribute.name, "value-x")) {
// The horizontal offset exists only where the runtime
// scrolls sideways: anywhere but a scroll container
// it is silently inert.
if (!std.mem.eql(u8, node.name, "scroll")) {
return attrError(node, attribute, value_x_element_message);
}
// An offset on an axis the region never grants is
// silently inert, so it is a teaching error (the
// resize-easing-needs-duration policy). A
// binding-valued axis cannot exist (axis takes the
// closed literal set), so the literal check is total.
const axis_raw = node.attr("axis") orelse {
return attrError(node, attribute, value_x_dependent_attr_message);
};
if (parseAttrExpression(axis_raw)) |axis_expression| {
if (axis_expression == .literal and std.mem.eql(u8, axis_expression.literal, "vertical")) {
return attrError(node, attribute, value_x_dependent_attr_message);
}
}
}
if (std.mem.eql(u8, attribute.name, "quiet-hover")) {
// The hover wash exists only on hit-tested elements:
// anywhere else the knob is silently inert (same
+20 -1
View File
@@ -1934,7 +1934,11 @@ fn CompiledMarkupEngine(comptime ModelT: type, comptime MsgT: type, comptime res
comptime {
if (!std.mem.eql(u8, node.name, "scroll")) fail(node, markup.on_scroll_element_message);
}
options.on_scroll = comptime (scrollConstructor(expression.tag) orelse fail(node, markup.on_scroll_payload_message));
options.on_scroll = comptime (scrollConstructor(expression.tag) orelse
// The retired one-axis record gets the migration
// teaching (it names the new per-axis fields) instead
// of the generic payload rejection.
fail(node, if (legacyScrollTag(expression.tag)) markup.on_scroll_legacy_payload_message else markup.on_scroll_payload_message));
return;
}
if (comptime std.mem.eql(u8, event, "resize")) {
@@ -2032,6 +2036,21 @@ fn CompiledMarkupEngine(comptime ModelT: type, comptime MsgT: type, comptime res
}
}
/// Whether `tag` names an arm carrying the RETIRED one-axis
/// scroll record — recognized only so the on-scroll rejection
/// can teach the two-axis migration by field name.
fn legacyScrollTag(comptime tag: []const u8) bool {
comptime {
@setEvalBranchQuota(10_000);
for (@typeInfo(MsgT).@"union".fields) |field| {
if (interpreter.declaredLegacyScrollStateRecord(field.type) and std.mem.eql(u8, field.name, tag)) {
return true;
}
}
return false;
}
}
fn resizeConstructor(comptime tag: []const u8) ?Ui.ValueMsgFn {
comptime {
@setEvalBranchQuota(10_000);
@@ -391,11 +391,11 @@ test "compiled on-scroll binds the ScrollState constructor identically to the in
// Both engines dispatch the same typed scroll Msg for the container.
const feed = fixture.findByKind(compiled.root, .scroll_view).?;
const state = canvas.ScrollState{ .offset = 40, .viewport_extent = 80, .content_extent = 200 };
try testing.expectEqual(@as(f32, 40), compiled.msgForScroll(feed.id, state).?.feed_scrolled.offset);
const state = canvas.ScrollState{ .offset_y = 40, .viewport_extent_y = 80, .content_extent_y = 200 };
try testing.expectEqual(@as(f32, 40), compiled.msgForScroll(feed.id, state).?.feed_scrolled.offset_y);
try testing.expectEqual(
interpreted.msgForScroll(feed.id, state).?.feed_scrolled.offset,
compiled.msgForScroll(feed.id, state).?.feed_scrolled.offset,
interpreted.msgForScroll(feed.id, state).?.feed_scrolled.offset_y,
compiled.msgForScroll(feed.id, state).?.feed_scrolled.offset_y,
);
// And the same approach-end Msg (`on-reach-end`, the infinite-scroll
@@ -2173,11 +2173,13 @@ test "compiled on-scroll binds a declared scroll-state mirror identically to the
// Both engines translate the runtime state into the DECLARED record:
// float fields widen exactly, integer-classed fields round.
const region = fixture.findByKind(compiled.root, .scroll_view).?;
const state = canvas.ScrollState{ .offset = 41.5, .velocity = -3.25, .viewport_extent = 480.4, .content_extent = 2000.6 };
const state = canvas.ScrollState{ .offset_x = 12.5, .offset_y = 41.5, .velocity_y = -3.25, .viewport_extent_x = 320.2, .viewport_extent_y = 480.4, .content_extent_x = 960.7, .content_extent_y = 2000.6 };
const compiled_msg = compiled.msgForScroll(region.id, state).?;
try testing.expectEqual(@as(f64, 41.5), compiled_msg.library_scrolled.offset);
try testing.expectEqual(@as(i64, 480), compiled_msg.library_scrolled.viewportExtent);
try testing.expectEqual(@as(i64, 2001), compiled_msg.library_scrolled.contentExtent);
try testing.expectEqual(@as(f64, 41.5), compiled_msg.library_scrolled.offsetY);
try testing.expectEqual(@as(f64, 12.5), compiled_msg.library_scrolled.offsetX);
try testing.expectEqual(@as(i64, 480), compiled_msg.library_scrolled.viewportExtentY);
try testing.expectEqual(@as(i64, 2001), compiled_msg.library_scrolled.contentExtentY);
try testing.expectEqual(@as(i64, 961), compiled_msg.library_scrolled.contentExtentX);
try testing.expectEqual(
interpreted.msgForScroll(region.id, state).?,
compiled.msgForScroll(region.id, state).?,
+18 -5
View File
@@ -46,8 +46,11 @@ pub const ValueKind = expr.ValueKind;
/// Bumped when the artifact layout or its checking semantics change; a
/// reader refuses versions it does not know (loudly, degrading to
/// structural checks — never a false pass).
pub const format_version: u32 = 1;
/// structural checks — never a false pass). Version 2: the scroll-state
/// payload class became TWO-AXIS — a format-1 artifact classified the
/// retired four-field record as `.scroll_state`, which both engines now
/// reject, so accepting the stale artifact would be a false pass.
pub const format_version: u32 = 2;
/// Where the app's build step writes the artifact, relative to the app
/// directory (a build product lives under zig-out, not in durable state).
@@ -129,8 +132,10 @@ pub const Iterable = struct {
/// Payload classes a markup dispatch can (or cannot) construct. The
/// special classes match the engines exactly: text_input/scroll_state
/// tags bind through on-input/on-scroll only, and `unsupported` payloads
/// cannot be built from markup at all.
pub const PayloadClass = enum { none, string, integer, float, boolean, enum_tag, text_input, scroll_state, unsupported };
/// cannot be built from markup at all. `legacy_scroll_state` is the
/// RETIRED one-axis scroll record, recognized only so `on-scroll` can
/// teach the two-axis migration by field name.
pub const PayloadClass = enum { none, string, integer, float, boolean, enum_tag, text_input, scroll_state, legacy_scroll_state, unsupported };
pub const MsgTag = struct {
name: []const u8,
@@ -329,6 +334,10 @@ fn payloadClassOf(comptime T: type, comptime specials: Specials) PayloadClass {
// through on-scroll exactly like the canvas type — same resolution as
// both engines' scrollConstructor.
if (reflect.declaredScrollStateRecord(T)) return .scroll_state;
// The retired ONE-AXIS record classifies separately so on-scroll can
// teach the two-axis migration by field name instead of rejecting
// the payload generically.
if (reflect.declaredLegacyScrollStateRecord(T)) return .legacy_scroll_state;
return switch (@typeInfo(T)) {
.int => .integer,
.float => .float,
@@ -856,6 +865,10 @@ const Checker = struct {
}
if (std.mem.eql(u8, event, "scroll")) {
const found = tag orelse return self.failAttr(node, attribute, markup.on_scroll_payload_message);
// The retired one-axis record gets the migration teaching —
// it names the new per-axis fields — instead of the generic
// payload rejection.
if (found.payload == .legacy_scroll_state) return self.failAttr(node, attribute, markup.on_scroll_legacy_payload_message);
if (found.payload != .scroll_state) return self.failAttr(node, attribute, markup.on_scroll_payload_message);
return;
}
@@ -898,7 +911,7 @@ const Checker = struct {
.boolean => {},
// These payloads cannot be constructed from a markup binding
// (input/scroll payloads bind through their own events).
.text_input, .scroll_state, .unsupported => return self.failPayloadType(node, attribute, resolved, found),
.text_input, .scroll_state, .legacy_scroll_state, .unsupported => return self.failPayloadType(node, attribute, resolved, found),
.none => unreachable,
}
}
@@ -49,6 +49,19 @@ const Profile = struct {
/// The transpiled-core mirror of `canvas.ScrollState` (field names pinned
/// by the reflect drift test below; classes per field).
const MirrorScroll = struct {
offset_x: f64,
offset_y: f64,
velocity_x: f64,
velocity_y: f64,
viewport_extent_x: f64,
viewport_extent_y: f64,
content_extent_x: f64,
content_extent_y: f64,
};
/// The RETIRED one-axis mirror: classifies as `legacy_scroll_state` so
/// the on-scroll checker teaches the per-axis migration by name.
const LegacyMirrorScroll = struct {
offset: f64,
velocity: f64,
viewport_extent: f64,
@@ -64,6 +77,7 @@ const Msg = union(enum) {
draft: canvas.TextInputEvent,
scrolled: canvas.ScrollState,
mirror_scrolled: MirrorScroll,
legacy_scrolled: LegacyMirrorScroll,
pane: f32,
pane_wide: f64,
tick,
@@ -399,6 +413,17 @@ const fixtures = [_]Fixture{
,
.expect = markup.on_scroll_payload_message,
},
.{
// The RETIRED one-axis record gets the migration teaching that
// names the new per-axis fields, not the generic rejection.
.name = "an on-scroll tag carrying the retired one-axis record teaches the two-axis migration",
.source =
\\<scroll on-scroll="legacy_scrolled">
\\ <column><text>body</text></column>
\\</scroll>
,
.expect = markup.on_scroll_legacy_payload_message,
},
.{
// The transpiled one-number float arm carries the split fraction.
.name = "a split on-resize f64 arm accepts",
@@ -944,9 +969,15 @@ test "a contract round-trips through the ZON artifact" {
try testing.expectEqualStrings("wave", parsed.app_icons[0]);
try testing.expectEqualStrings("wave-pulse", parsed.app_icons[1]);
// Artifacts from before the app_icons field parse with the default
// (no registered icons) - the additive-with-default contract.
// (no registered icons) - the additive-with-default contract. The
// FORMAT VERSION is a separate gate: parsing tolerates missing
// fields, and the reader (tools/native-sdk markup check) refuses
// any format it does not know — format 1 classified the retired
// one-axis scroll record as a scroll_state payload, which would be
// a false pass today.
const legacy = try contract.parseArtifact(arena, ".{ .format = 1 }");
try testing.expectEqual(@as(usize, 0), legacy.app_icons.len);
try testing.expect(legacy.format != contract.format_version);
try testing.expectEqualStrings(model_contract.model_type, parsed.model_type);
try testing.expectEqual(model_contract.model.scalars.len, parsed.model.scalars.len);
try testing.expectEqual(model_contract.iterables.len, parsed.iterables.len);
@@ -1224,12 +1255,17 @@ test "the reflect field vocabulary never drifts from canvas.ScrollState" {
test "a declared scroll-state mirror classifies as a scroll_state payload" {
var saw_mirror = false;
var saw_legacy = false;
var saw_wide_pane = false;
for (model_contract.msgs) |tag| {
if (std.mem.eql(u8, tag.name, "mirror_scrolled")) {
saw_mirror = true;
try testing.expectEqual(contract.PayloadClass.scroll_state, tag.payload);
}
if (std.mem.eql(u8, tag.name, "legacy_scrolled")) {
saw_legacy = true;
try testing.expectEqual(contract.PayloadClass.legacy_scroll_state, tag.payload);
}
if (std.mem.eql(u8, tag.name, "pane_wide")) {
saw_wide_pane = true;
try testing.expectEqual(contract.PayloadClass.float, tag.payload);
@@ -1237,6 +1273,7 @@ test "a declared scroll-state mirror classifies as a scroll_state payload" {
}
}
try testing.expect(saw_mirror);
try testing.expect(saw_legacy);
try testing.expect(saw_wide_pane);
}
+57 -24
View File
@@ -106,9 +106,9 @@ pub fn isZeroArgFn(comptime T: type, comptime DeclType: type) bool {
/// declared-shape predicate below stays std-only. A drift test in
/// `ui_markup_contract_tests.zig` holds this list equal to the real union.
pub const text_input_event_tags = [_][]const u8{
"insert_text", "delete_backward", "delete_forward", "delete_word_backward",
"delete_word_forward", "clear", "move_caret", "set_selection",
"set_composition", "commit_composition", "cancel_composition",
"insert_text", "delete_backward", "delete_forward", "delete_word_backward",
"delete_word_forward", "clear", "move_caret", "set_selection",
"set_composition", "commit_composition", "cancel_composition",
};
/// The caret-direction member vocabulary (`canvas.TextCaretDirection`).
@@ -156,12 +156,16 @@ pub fn declaredTextInputUnion(comptime T: type) bool {
return true;
}
/// The canvas `ScrollState` field vocabulary, pinned here so the
/// declared-shape predicate below stays std-only. A drift test in
/// `ui_markup_contract_tests.zig` holds this list equal to the real
/// struct's fields (names and f32 types alike).
/// The canvas `ScrollState` field vocabulary — the TWO-AXIS record,
/// eight per-axis fields — pinned here so the declared-shape predicate
/// below stays std-only. A drift test in `ui_markup_contract_tests.zig`
/// holds this list equal to the real struct's fields (names and f32
/// types alike).
pub const scroll_state_field_names = [_][]const u8{
"offset", "velocity", "viewport_extent", "content_extent",
"offset_x", "offset_y",
"velocity_x", "velocity_y",
"viewport_extent_x", "viewport_extent_y",
"content_extent_x", "content_extent_y",
};
/// The same vocabulary in the TS SDK's spelling (`@native-sdk/core/events`
@@ -169,35 +173,39 @@ pub const scroll_state_field_names = [_][]const u8{
/// field names the TS source wrote — your names are your names — so the
/// structural match accepts either spelling, never a mix.
pub const scroll_state_field_names_ts = [_][]const u8{
"offsetX", "offsetY",
"velocityX", "velocityY",
"viewportExtentX", "viewportExtentY",
"contentExtentX", "contentExtentY",
};
/// The RETIRED one-axis scroll-state vocabulary, kept only to recognize
/// a pre-two-axis mirror and teach the migration by name (see
/// `declaredLegacyScrollStateRecord`). Nothing dispatches through these
/// fields anymore.
pub const legacy_scroll_state_field_names = [_][]const u8{
"offset", "velocity", "viewport_extent", "content_extent",
};
/// The retired vocabulary in the TS SDK's spelling.
pub const legacy_scroll_state_field_names_ts = [_][]const u8{
"offset", "velocity", "viewportExtent", "contentExtent",
};
/// A Msg arm payload record DECLARING the scroll-state shape rather than
/// being `canvas.ScrollState` by identity — the transpiled-core case,
/// where the emitted module declares its own mirror record (type identity
/// cannot cross the emission boundary). Matched structurally, the
/// `declaredTextInputUnion` contract applied to `on-scroll`: a struct of
/// exactly the four field names in either the canvas spelling
/// (`viewport_extent`, Zig-declared mirrors and `canvas.ScrollState`
/// itself) or the TS SDK spelling (`viewportExtent`, the emitted-core
/// mirror — transpiled fields keep their TS names), each numeric. Integer
/// or float per field (the transpiler's number model classes each slot);
/// the dispatch translation widens floats exactly and rounds
/// integer-classed fields to the nearest whole number.
pub fn declaredScrollStateRecord(comptime T: type) bool {
fn declaredRecordMatchesVocabulary(comptime T: type, comptime canvas_names: []const []const u8, comptime ts_names: []const []const u8) bool {
const info = switch (@typeInfo(T)) {
.@"struct" => |s| s,
else => return false,
};
if (info.fields.len != scroll_state_field_names.len) return false;
if (info.fields.len != canvas_names.len) return false;
const canvas_spelling = comptime blk: {
for (scroll_state_field_names) |name| {
for (canvas_names) |name| {
if (!@hasField(T, name)) break :blk false;
}
break :blk true;
};
const ts_spelling = comptime blk: {
for (scroll_state_field_names_ts) |name| {
for (ts_names) |name| {
if (!@hasField(T, name)) break :blk false;
}
break :blk true;
@@ -209,6 +217,31 @@ pub fn declaredScrollStateRecord(comptime T: type) bool {
return true;
}
/// A Msg arm payload record DECLARING the scroll-state shape rather than
/// being `canvas.ScrollState` by identity — the transpiled-core case,
/// where the emitted module declares its own mirror record (type identity
/// cannot cross the emission boundary). Matched structurally, the
/// `declaredTextInputUnion` contract applied to `on-scroll`: a struct of
/// exactly the eight per-axis field names in either the canvas spelling
/// (`viewport_extent_y`, Zig-declared mirrors and `canvas.ScrollState`
/// itself) or the TS SDK spelling (`viewportExtentY`, the emitted-core
/// mirror — transpiled fields keep their TS names), each numeric. Integer
/// or float per field (the transpiler's number model classes each slot);
/// the dispatch translation widens floats exactly and rounds
/// integer-classed fields to the nearest whole number.
pub fn declaredScrollStateRecord(comptime T: type) bool {
return declaredRecordMatchesVocabulary(T, &scroll_state_field_names, &scroll_state_field_names_ts);
}
/// A mirror of the RETIRED one-axis scroll state — `{offset, velocity,
/// viewport_extent, content_extent}` in either spelling. Recognized only
/// to fail with a teaching that names the new per-axis fields, so an app
/// carrying the pre-two-axis record shape hears exactly what to declare
/// instead of a generic payload rejection.
pub fn declaredLegacyScrollStateRecord(comptime T: type) bool {
return declaredRecordMatchesVocabulary(T, &legacy_scroll_state_field_names, &legacy_scroll_state_field_names_ts);
}
/// The machine classes a value-carrying Msg arm may declare for the
/// markup value events (slider `on-change`, split `on-resize`): `f32` is
/// the canvas-native payload Zig cores declare; `f64` is the one-number
+66
View File
@@ -883,6 +883,72 @@ test "overscroll validates as scroll-scoped with a closed value vocabulary" {
}
}
test "axis and value-x validate as scroll-scoped with the axis grant dependency" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
// Valid: every vocabulary value on scroll, and value-x beside a
// horizontal-capable axis (literal or a binding resolved at build).
const valid_sources = [_][]const u8{
"<column>\n <scroll axis=\"vertical\">\n <column><text>a</text></column>\n </scroll>\n</column>",
"<column>\n <scroll axis=\"horizontal\">\n <row><text>a</text></row>\n </scroll>\n</column>",
"<column>\n <scroll axis=\"both\">\n <column><text>a</text></column>\n </scroll>\n</column>",
"<column>\n <scroll axis=\"horizontal\" value-x=\"120\">\n <row><text>a</text></row>\n </scroll>\n</column>",
"<column>\n <scroll axis=\"both\" value-x=\"{shelf_x}\">\n <column><text>a</text></column>\n </scroll>\n</column>",
// A literal VERTICAL axis is legal beside virtualization (it
// grants nothing virtualization ignores), and a horizontal
// grant is legal beside a literal virtualized="false".
"<column>\n <scroll axis=\"vertical\" virtualized=\"true\" virtual-item-extent=\"24\">\n <column><text>a</text></column>\n </scroll>\n</column>",
"<column>\n <scroll axis=\"horizontal\" virtualized=\"false\">\n <row><text>a</text></row>\n </scroll>\n</column>",
};
for (valid_sources) |source| {
var parser = markup.Parser.init(arena, source);
try testing.expectEqual(@as(?markup.MarkupErrorInfo, null), markup.validate(try parser.parse()));
}
const cases = [_]struct { source: []const u8, message: []const u8 }{
// Axes exist only where the runtime scrolls.
.{
.source = "<column>\n <row axis=\"horizontal\">\n <text>a</text>\n </row>\n</column>",
.message = markup.axis_element_message,
},
// Literal values outside the closed vocabulary teach the set.
.{
.source = "<column>\n <scroll axis=\"sideways\">\n <column><text>a</text></column>\n </scroll>\n</column>",
.message = markup.axis_value_message,
},
// Windowed virtualization is vertical machinery: a horizontal
// grant there would be silently ignored.
.{
.source = "<column>\n <scroll axis=\"horizontal\" virtualized=\"true\" virtual-item-extent=\"24\">\n <column><text>a</text></column>\n </scroll>\n</column>",
.message = markup.axis_virtualized_message,
},
// The horizontal offset exists only where the runtime scrolls
// sideways.
.{
.source = "<column>\n <row value-x=\"20\">\n <text>a</text>\n </row>\n</column>",
.message = markup.value_x_element_message,
},
// An offset on an axis the region never grants is silently
// inert: no axis at all, and the explicit vertical default.
.{
.source = "<column>\n <scroll value-x=\"20\">\n <column><text>a</text></column>\n </scroll>\n</column>",
.message = markup.value_x_dependent_attr_message,
},
.{
.source = "<column>\n <scroll axis=\"vertical\" value-x=\"20\">\n <column><text>a</text></column>\n </scroll>\n</column>",
.message = markup.value_x_dependent_attr_message,
},
};
for (cases) |case| {
var parser = markup.Parser.init(arena, case.source);
const info = markup.validate(try parser.parse()) orelse return error.TestUnexpectedResult;
try testing.expectEqualStrings(case.message, info.message);
try testing.expect(info.line > 0);
}
}
test "tooltip-delay validates as tooltip-scoped beside anchor, and anchor accepts tooltip" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
+20
View File
@@ -1862,6 +1862,12 @@ pub fn MarkupView(comptime ModelT: type, comptime MsgT: type) type {
return self.failVoid(node, markup.on_scroll_element_message);
}
options.on_scroll = scrollConstructor(expression.tag) orelse {
// The retired one-axis record gets the migration
// teaching (it names the new per-axis fields) instead
// of the generic payload rejection.
if (legacyScrollTag(expression.tag)) {
return self.failVoid(node, markup.on_scroll_legacy_payload_message);
}
return self.failVoid(node, markup.on_scroll_payload_message);
};
return;
@@ -2022,6 +2028,19 @@ pub fn MarkupView(comptime ModelT: type, comptime MsgT: type) type {
return null;
}
/// Whether `tag` names an arm carrying the RETIRED one-axis
/// scroll record — recognized only so the on-scroll rejection
/// can teach the two-axis migration by field name.
fn legacyScrollTag(tag: []const u8) bool {
@setEvalBranchQuota(scan_quota);
inline for (@typeInfo(MsgT).@"union".fields) |field| {
if (comptime reflect.declaredLegacyScrollStateRecord(field.type)) {
if (std.mem.eql(u8, field.name, tag)) return true;
}
}
return false;
}
fn resizeConstructor(tag: []const u8) ?Ui.ValueMsgFn {
@setEvalBranchQuota(scan_quota);
inline for (@typeInfo(MsgT).@"union".fields) |field| {
@@ -2369,6 +2388,7 @@ pub const typeScanQuota = reflect.typeScanQuota;
pub const Pointee = reflect.Pointee;
pub const declaredTextInputUnion = reflect.declaredTextInputUnion;
pub const declaredScrollStateRecord = reflect.declaredScrollStateRecord;
pub const declaredLegacyScrollStateRecord = reflect.declaredLegacyScrollStateRecord;
pub const valueArmClass = reflect.valueArmClass;
pub const sliceElement = reflect.sliceElement;
pub const isItemFn = reflect.isItemFn;
+76 -19
View File
@@ -675,6 +675,42 @@ test "overscroll on scroll stamps the region's edge behavior" {
try testing.expectEqual(canvas.ScrollOverscroll.none, canvas.widgetScrollPhysics(tree.root.children[1], physics).overscroll);
}
test "axis and value-x stamp the region's scroll axes and horizontal offset" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const model = Model{};
var view = try InboxMarkup.init(arena, "<column>\n <scroll axis=\"horizontal\" value-x=\"120\">\n <row><text>a</text></row>\n </scroll>\n <scroll axis=\"both\">\n <column><text>b</text></column>\n </scroll>\n <scroll>\n <column><text>c</text></column>\n </scroll>\n</column>");
var ui = InboxUi.init(arena);
const tree = try ui.finalize(try view.build(&ui, &model));
const shelf = tree.root.children[0];
try testing.expectEqual(canvas.WidgetKind.scroll_view, shelf.kind);
try testing.expectEqual(canvas.ScrollAxes.horizontal, shelf.scroll_axes);
try testing.expectEqual(@as(f32, 120), shelf.value_x);
try testing.expect(canvas.widgetScrollsAxis(shelf, .horizontal));
try testing.expect(!canvas.widgetScrollsAxis(shelf, .vertical));
const freeform = tree.root.children[1];
try testing.expectEqual(canvas.ScrollAxes.both, freeform.scroll_axes);
try testing.expect(canvas.widgetScrollsAxis(freeform, .horizontal));
try testing.expect(canvas.widgetScrollsAxis(freeform, .vertical));
// The undeclared region keeps the pre-axis default: vertical only.
const classic = tree.root.children[2];
try testing.expectEqual(canvas.ScrollAxes.vertical, classic.scroll_axes);
try testing.expect(!canvas.widgetScrollsAxis(classic, .horizontal));
try testing.expect(canvas.widgetScrollsAxis(classic, .vertical));
}
test "axis value vocabulary mirrors the live ScrollAxes enum" {
// The validator's std-only mirror of the enum's member names; a new
// member cannot ship without its markup spelling.
const fields = @typeInfo(canvas.ScrollAxes).@"enum".fields;
try testing.expectEqual(fields.len, canvas.ui_markup.axis_value_names.len);
inline for (fields, 0..) |field, index| {
try testing.expectEqualStrings(field.name, canvas.ui_markup.axis_value_names[index]);
}
}
test "overscroll value vocabulary mirrors the live WidgetOverscroll enum" {
// The validator's std-only mirror of the enum's member names; a new
// member cannot ship without its markup spelling.
@@ -4349,10 +4385,14 @@ test "declaredTextInputUnion accepts the emitted mirror shape and rejects near-m
/// independently (the number tier): float fields widen exactly, integer
/// fields round.
pub const MirrorScrollState = struct {
offset: f64,
velocity: f64,
viewportExtent: i64,
contentExtent: i64,
offsetX: f64,
offsetY: f64,
velocityX: f64,
velocityY: f64,
viewportExtentX: i64,
viewportExtentY: i64,
contentExtentX: i64,
contentExtentY: i64,
};
pub const MirrorControlsModel = struct {
@@ -4371,18 +4411,27 @@ pub const MirrorControlsMsg = union(enum) {
test "declaredScrollStateRecord accepts the emitted mirror shape and rejects near-misses" {
try testing.expect(markup_view.declaredScrollStateRecord(MirrorScrollState));
// The canvas struct itself matches structurally too (four f32 fields).
// The canvas struct itself matches structurally too (eight f32 fields).
try testing.expect(markup_view.declaredScrollStateRecord(canvas.ScrollState));
// A Zig-declared mirror in the canvas spelling stays accepted.
try testing.expect(markup_view.declaredScrollStateRecord(struct { offset: f64, velocity: f64, viewport_extent: f64, content_extent: f64 }));
try testing.expect(markup_view.declaredScrollStateRecord(struct { offset_x: f64, offset_y: f64, velocity_x: f64, velocity_y: f64, viewport_extent_x: f64, viewport_extent_y: f64, content_extent_x: f64, content_extent_y: f64 }));
// Near-misses stay out: a missing field, a renamed field, a non-numeric
// field, an extra field, a spelling mix.
try testing.expect(!markup_view.declaredScrollStateRecord(struct { offset: f64, velocity: f64, viewport_extent: f64 }));
try testing.expect(!markup_view.declaredScrollStateRecord(struct { offset: f64, velocity: f64, viewport_extent: f64, content_size: f64 }));
try testing.expect(!markup_view.declaredScrollStateRecord(struct { offset: []const u8, velocity: f64, viewport_extent: f64, content_extent: f64 }));
try testing.expect(!markup_view.declaredScrollStateRecord(struct { offset: f64, velocity: f64, viewport_extent: f64, content_extent: f64, extra: f64 }));
try testing.expect(!markup_view.declaredScrollStateRecord(struct { offset: f64, velocity: f64, viewport_extent: f64, contentExtent: f64 }));
try testing.expect(!markup_view.declaredScrollStateRecord(struct { offset_x: f64, offset_y: f64, velocity_x: f64, velocity_y: f64, viewport_extent_x: f64, viewport_extent_y: f64, content_extent_x: f64 }));
try testing.expect(!markup_view.declaredScrollStateRecord(struct { offset_x: f64, offset_y: f64, velocity_x: f64, velocity_y: f64, viewport_extent_x: f64, viewport_extent_y: f64, content_extent_x: f64, content_size_y: f64 }));
try testing.expect(!markup_view.declaredScrollStateRecord(struct { offset_x: []const u8, offset_y: f64, velocity_x: f64, velocity_y: f64, viewport_extent_x: f64, viewport_extent_y: f64, content_extent_x: f64, content_extent_y: f64 }));
try testing.expect(!markup_view.declaredScrollStateRecord(struct { offset_x: f64, offset_y: f64, velocity_x: f64, velocity_y: f64, viewport_extent_x: f64, viewport_extent_y: f64, content_extent_x: f64, content_extent_y: f64, extra: f64 }));
try testing.expect(!markup_view.declaredScrollStateRecord(struct { offset_x: f64, offset_y: f64, velocity_x: f64, velocity_y: f64, viewport_extent_x: f64, viewport_extent_y: f64, content_extent_x: f64, contentExtentY: f64 }));
try testing.expect(!markup_view.declaredScrollStateRecord(i64));
// The RETIRED one-axis record is not a scroll-state record anymore -
// it classifies as the legacy shape so on-scroll teaches the
// per-axis migration by name (either spelling, never a mix).
try testing.expect(!markup_view.declaredScrollStateRecord(struct { offset: f64, velocity: f64, viewport_extent: f64, content_extent: f64 }));
try testing.expect(markup_view.declaredLegacyScrollStateRecord(struct { offset: f64, velocity: f64, viewport_extent: f64, content_extent: f64 }));
try testing.expect(markup_view.declaredLegacyScrollStateRecord(struct { offset: f64, velocity: f64, viewportExtent: i64, contentExtent: i64 }));
try testing.expect(!markup_view.declaredLegacyScrollStateRecord(MirrorScrollState));
try testing.expect(!markup_view.declaredLegacyScrollStateRecord(canvas.ScrollState));
try testing.expect(!markup_view.declaredLegacyScrollStateRecord(struct { offset: f64, velocity: f64, viewport_extent: f64, contentExtent: f64 }));
}
test "valueArmClass classifies exactly the value-carrying arm shapes" {
@@ -4411,17 +4460,25 @@ test "the interpreter binds on-scroll to a declared mirror record and translates
const tree = try ui.finalize(try view.build(&ui, &model));
const region = findByKind(tree.root, .scroll_view).?;
const msg = tree.msgForScroll(region.id, .{
.offset = 41.5,
.velocity = -3.25,
.viewport_extent = 480.4,
.content_extent = 2000.6,
.offset_x = 12.5,
.offset_y = 41.5,
.velocity_x = 1.5,
.velocity_y = -3.25,
.viewport_extent_x = 320.2,
.viewport_extent_y = 480.4,
.content_extent_x = 960.7,
.content_extent_y = 2000.6,
}).?;
// Float fields widen exactly; integer-classed fields round to the
// nearest whole number.
try testing.expectEqual(@as(f64, 41.5), msg.library_scrolled.offset);
try testing.expectEqual(@as(f64, -3.25), msg.library_scrolled.velocity);
try testing.expectEqual(@as(i64, 480), msg.library_scrolled.viewportExtent);
try testing.expectEqual(@as(i64, 2001), msg.library_scrolled.contentExtent);
try testing.expectEqual(@as(f64, 12.5), msg.library_scrolled.offsetX);
try testing.expectEqual(@as(f64, 41.5), msg.library_scrolled.offsetY);
try testing.expectEqual(@as(f64, 1.5), msg.library_scrolled.velocityX);
try testing.expectEqual(@as(f64, -3.25), msg.library_scrolled.velocityY);
try testing.expectEqual(@as(i64, 320), msg.library_scrolled.viewportExtentX);
try testing.expectEqual(@as(i64, 480), msg.library_scrolled.viewportExtentY);
try testing.expectEqual(@as(i64, 961), msg.library_scrolled.contentExtentX);
try testing.expectEqual(@as(i64, 2001), msg.library_scrolled.contentExtentY);
}
test "the interpreter binds slider on-change value arms and keeps the void static form" {
+11
View File
@@ -518,6 +518,17 @@ pub const attrs = [_]AttrInfo{
.{ .code = 83, .name = "autoplay", .class = .flag, .group = .composite },
.{ .code = 84, .name = "loop", .class = .flag, .group = .composite },
.{ .code = 85, .name = "muted", .class = .flag, .group = .composite },
// Scroll axis declaration (scroll only; the validator scopes it):
// vertical (the default — every pre-axis document keeps its exact
// behavior), horizontal, or both. A closed vocabulary mirroring
// `canvas.ScrollAxes`, the overscroll pattern.
.{ .code = 86, .name = "axis", .class = .option, .group = .option, .field = "axis" },
// The horizontal scroll offset (scroll only, and only beside a
// horizontal-capable axis; the validator scopes both): the sideways
// counterpart of `value`, following the same source-wins reconcile
// rule — echo `on-scroll`'s offset_x back here and user scrolling
// survives rebuilds; move it model-side to scroll programmatically.
.{ .code = 87, .name = "value-x", .class = .number, .group = .option, .field = "value_x" },
};
// ----------------------------------------------------------------- events
+5 -4
View File
@@ -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, 68), schema.elements.len);
try testing.expectEqual(@as(usize, 85), schema.attrs.len);
try testing.expectEqual(@as(usize, 87), schema.attrs.len);
try testing.expectEqual(@as(usize, 12), schema.events.len);
// The element table runs through the span composite (64), the
// bubble-reactions composite (65), the media surface (66), the
@@ -38,10 +38,11 @@ test "registry codes are stable: assigned at birth, never renumbered or renamed"
// split enter-from attribute resize-origin (78), the quiet-surface
// hover knob quiet-hover (79), the anchored-tooltip hover-intent
// delay tooltip-delay (80), the media-surface producer rendezvous
// surface (81), and the video element attributes controls (82),
// autoplay (83), loop (84), and muted (85).
// surface (81), the video element attributes controls (82),
// autoplay (83), loop (84), and muted (85), and the scroll-axis
// attributes axis (86) and value-x (87).
try testing.expectEqual(
@as(u64, 0xa9dbe926a4488dc6),
@as(u64, 0xba6077bbdaac1438),
tableFingerprint(schema.AttrInfo, &schema.attrs),
);
// The event table runs through the pointer-hover containment pair
+7 -7
View File
@@ -476,12 +476,12 @@ test "payload-carrying handlers build messages from edits and values" {
};
const feed = findByKind(scroll_tree.root, .scroll_view).?;
const scrolled = scroll_tree.msgForScroll(feed.id, .{
.offset = 64,
.viewport_extent = 72,
.content_extent = 200,
.offset_y = 64,
.viewport_extent_y = 72,
.content_extent_y = 200,
}).?.feed_scrolled;
try testing.expectEqual(@as(f32, 64), scrolled.offset);
try testing.expectEqual(@as(f32, 128), scrolled.maxOffset());
try testing.expectEqual(@as(f32, 64), scrolled.offset_y);
try testing.expectEqual(@as(f32, 128), scrolled.axis(.vertical).maxOffset());
try testing.expectEqual(@as(?Msg, null), tree.msgForScroll(slider.id, .{}));
// Widgets without payload handlers dispatch nothing for edits.
@@ -1441,8 +1441,8 @@ test "virtualWindow resolves the runtime state and virtualList builds only the w
// Typed dispatch: the scroll observation and the approach-end signal
// both resolve through the container's handlers.
const state = canvas.ScrollState{ .offset = 25_000, .viewport_extent = 90, .content_extent = 2_499_975 };
try testing.expectEqual(@as(f32, 25_000), tree.msgForScroll(tree.root.id, state).?.feed_scrolled.offset);
const state = canvas.ScrollState{ .offset_y = 25_000, .viewport_extent_y = 90, .content_extent_y = 2_499_975 };
try testing.expectEqual(@as(f32, 25_000), tree.msgForScroll(tree.root.id, state).?.feed_scrolled.offset_y);
try testing.expectEqual(Msg.load_more, tree.msgForReachEnd(tree.root.id).?);
}
@@ -260,6 +260,8 @@ fn widgetChange(previous: WidgetLayoutNode, next: WidgetLayoutNode, previous_ind
!std.mem.eql(u8, previous.widget.placeholder, next.widget.placeholder) or
!std.mem.eql(u8, previous.widget.icon, next.widget.icon) or
previous.widget.value != next.widget.value or
previous.widget.value_x != next.widget.value_x or
previous.widget.scroll_axes != next.widget.scroll_axes or
previous.widget.image_id != next.widget.image_id or
!optionalRectsEqual(previous.widget.image_src, next.widget.image_src) or
previous.widget.image_fit != next.widget.image_fit or
+34 -6
View File
@@ -103,7 +103,7 @@ pub fn layoutWidgetDepth(
.scroll_view => if (widget.layout.virtualized)
try layoutVirtualVerticalChildren(widget.children, content, index, depth, output, len, widget.value, widget.layout, tokens)
else
try layoutScrollChildren(widget.children, content, index, depth, output, len, widget.value, tokens),
try layoutScrollChildren(widget.children, content, index, depth, output, len, scrollLayoutOffset(widget), tokens),
.list => if (widget.layout.virtualized)
try layoutVirtualVerticalChildren(widget.children, content, index, depth, output, len, widget.value, widget.layout, tokens)
else
@@ -531,7 +531,22 @@ fn widgetInsideVerticalScrollScope(output: []const WidgetLayoutNode, parent_inde
var current: ?usize = parent_index;
while (current) |index| {
const widget = output[index].widget;
if (widget.kind == .scroll_view or widget.layout.virtualized) return true;
if ((widget.kind == .scroll_view and widget.scroll_axes.scrollsVertically()) or widget.layout.virtualized) return true;
if (widget.layout.anchor != null) return false;
current = output[index].parent_index;
}
return false;
}
/// The horizontal twin of `widgetInsideVerticalScrollScope`: inside a
/// HORIZONTALLY scrolling scope (a `.scroll_view` ancestor granting the
/// horizontal axis), content wider than the viewport is the operating
/// mode, so the horizontal overflow diagnostic stays quiet there.
fn widgetInsideHorizontalScrollScope(output: []const WidgetLayoutNode, parent_index: usize) bool {
var current: ?usize = parent_index;
while (current) |index| {
const widget = output[index].widget;
if (widget.kind == .scroll_view and widget.scroll_axes.scrollsHorizontally() and !widget.layout.virtualized) return true;
if (widget.layout.anchor != null) return false;
current = output[index].parent_index;
}
@@ -549,13 +564,15 @@ fn widgetInsideVerticalScrollScope(output: []const WidgetLayoutNode, parent_inde
/// virtualized scroll's content wrapper is sized to the viewport and
/// its children legitimately extend past it on every rebuild, which
/// used to repeat this line hundreds of times for a perfectly correct
/// layout. Horizontal overflow still warns there — nothing scrolls
/// sideways to reveal it.
/// layout. Horizontal overflow warns there UNLESS a horizontally
/// scrolling scope encloses it — a `horizontal`/`both` scroll view
/// exists precisely to reveal sideways content.
/// - The line names the concrete widget (root-first kind path, label
/// snippet, id — the same identity the layout audit prints), because
/// a bare kind like "column" is unactionable in any real tree.
fn logAxisChildrenOverflow(output: []const WidgetLayoutNode, parent_index: usize, axis: LayoutAxis, available_extent: f32, used_extent: f32, overflow: f32) void {
if (axis == .vertical and widgetInsideVerticalScrollScope(output, parent_index)) return;
if (axis == .horizontal and widgetInsideHorizontalScrollScope(output, parent_index)) return;
if (builtin.is_test) test_axis_overflow_diagnostics += 1;
if (builtin.mode != .Debug) return;
var path_buffer: [256]u8 = undefined;
@@ -1014,6 +1031,17 @@ fn preferredGridRowExtent(children: []const Widget, columns: usize, tokens: Desi
return max_height;
}
/// The layout-time scroll displacement of a non-virtualized scroll
/// view: each offset applies only on an axis the region grants, so a
/// stale `value_x` on a vertical-only region can never shear its
/// content sideways — and a stale `value` on a horizontal-only one can
/// never leave it displaced upward.
fn scrollLayoutOffset(widget: Widget) geometry.OffsetF {
const scroll_x = if (widget.scroll_axes.scrollsHorizontally()) widget.value_x else 0;
const scroll_y = if (widget.scroll_axes.scrollsVertically()) widget.value else 0;
return geometry.OffsetF.init(scroll_x, scroll_y);
}
fn layoutScrollChildren(
children: []const Widget,
content: geometry.RectF,
@@ -1021,10 +1049,10 @@ fn layoutScrollChildren(
depth: usize,
output: []WidgetLayoutNode,
len: *usize,
scroll_y: f32,
scroll_offset: geometry.OffsetF,
tokens: DesignTokens,
) Error!void {
const scrolled_content = content.translate(geometry.OffsetF.init(0, -scroll_y));
const scrolled_content = content.translate(geometry.OffsetF.init(-scroll_offset.dx, -scroll_offset.dy));
for (children) |child| {
if (child.layout.anchor != null) continue;
_ = try layoutWidgetDepth(child, stackChildFrame(scrolled_content, child), parent_index, depth + 1, output, len, tokens);
+125 -4
View File
@@ -196,6 +196,7 @@ const SpringToken = support.SpringToken;
const BlurTokenRef = support.BlurTokenRef;
const ScrollPhysics = support.ScrollPhysics;
const ScrollState = support.ScrollState;
const ScrollAxisState = support.ScrollAxisState;
const VirtualListOptions = support.VirtualListOptions;
const VirtualListRange = support.VirtualListRange;
const virtualListRange = support.virtualListRange;
@@ -1412,6 +1413,126 @@ test "widget scroll view scrollbars use control visual tokens" {
}
}
test "horizontal scroll views draw the bottom-edge scrollbar and two-axis regions reserve the corner" {
const tokens: DesignTokens = .{};
// A horizontal shelf: content reaches x = 460 in a 120-wide
// viewport. Only the horizontal bar (part slots 4/5) exists.
const tiles = [_]Widget{
.{ .id = 2, .kind = .panel, .frame = geometry.RectF.init(0, 0, 140, 40) },
.{ .id = 3, .kind = .panel, .frame = geometry.RectF.init(320, 0, 140, 40) },
};
const shelf = Widget{
.id = 1,
.kind = .scroll_view,
.scroll_axes = .horizontal,
.value_x = 20,
.children = &tiles,
};
var shelf_nodes: [4]WidgetLayoutNode = undefined;
const shelf_layout = try layoutWidgetTree(shelf, geometry.RectF.init(0, 0, 120, 60), &shelf_nodes);
// The horizontal offset displaces children leftward at layout time,
// exactly as the vertical offset displaces them upward.
try expectLayoutFrame(shelf_layout, 2, geometry.RectF.init(-20, 0, 140, 40));
try expectLayoutFrame(shelf_layout, 3, geometry.RectF.init(300, 0, 140, 40));
var commands: [16]CanvasCommand = undefined;
var builder = Builder.init(&commands);
try shelf_layout.emitDisplayList(&builder, tokens);
const display_list = builder.displayList();
try std.testing.expect(display_list.findCommandById(widgetPartId(1, 2)) == null);
try std.testing.expect(display_list.findCommandById(widgetPartId(1, 3)) == null);
switch (display_list.findCommandById(widgetPartId(1, 4)).?.command) {
.fill_rounded_rect => |track| {
// Bottom edge: inset 3, thickness 3, full width minus insets.
try expectRect(geometry.RectF.init(3, 54, 114, 3), track.rect);
},
else => return error.TestUnexpectedResult,
}
switch (display_list.findCommandById(widgetPartId(1, 5)).?.command) {
.fill_rounded_rect => |thumb| {
try std.testing.expectEqual(@as(f32, 54), thumb.rect.y);
try std.testing.expect(thumb.rect.width < 114);
try std.testing.expect(thumb.rect.x > 3);
},
else => return error.TestUnexpectedResult,
}
// A both-axes region with overflow on both axes draws BOTH bars,
// each track ending short of the shared corner.
const sheet = Widget{
.id = 1,
.kind = .scroll_view,
.scroll_axes = .both,
.children = &[_]Widget{.{ .id = 2, .kind = .panel, .frame = geometry.RectF.init(0, 0, 400, 300) }},
};
var sheet_nodes: [3]WidgetLayoutNode = undefined;
const sheet_layout = try layoutWidgetTree(sheet, geometry.RectF.init(0, 0, 120, 60), &sheet_nodes);
var sheet_commands: [16]CanvasCommand = undefined;
var sheet_builder = Builder.init(&sheet_commands);
try sheet_layout.emitDisplayList(&sheet_builder, tokens);
const sheet_list = sheet_builder.displayList();
switch (sheet_list.findCommandById(widgetPartId(1, 2)).?.command) {
.fill_rounded_rect => |track| {
// The vertical track gives up thickness + inset (6) at the
// bottom corner: 60 - 2*3 - 6 = 48.
try expectRect(geometry.RectF.init(114, 3, 3, 48), track.rect);
},
else => return error.TestUnexpectedResult,
}
switch (sheet_list.findCommandById(widgetPartId(1, 4)).?.command) {
.fill_rounded_rect => |track| {
try expectRect(geometry.RectF.init(3, 54, 108, 3), track.rect);
},
else => return error.TestUnexpectedResult,
}
try std.testing.expect(sheet_list.findCommandById(widgetPartId(1, 3)) != null);
try std.testing.expect(sheet_list.findCommandById(widgetPartId(1, 5)) != null);
}
test "a both-axes region whose content only overflows sideways reports horizontal scroll semantics" {
// 500-wide content in a 200 x 100 both-axes viewport: no vertical
// range, real horizontal range. The assistive node must read the
// LIVE axis — scrollable, with the horizontal offset/extents — not
// a permanently unscrollable vertical axis.
const sheet = Widget{
.id = 1,
.kind = .scroll_view,
.scroll_axes = .both,
.value_x = 60,
.children = &[_]Widget{.{ .id = 2, .kind = .panel, .frame = geometry.RectF.init(0, 0, 500, 100) }},
};
var nodes: [3]WidgetLayoutNode = undefined;
const layout = try layoutWidgetTree(sheet, geometry.RectF.init(0, 0, 200, 100), &nodes);
var semantics_buffer: [3]WidgetSemanticsNode = undefined;
const semantics = try layout.collectSemantics(&semantics_buffer);
try std.testing.expect(semantics[0].scroll.present);
try std.testing.expectEqual(@as(f32, 60), semantics[0].scroll.offset);
try std.testing.expectEqual(@as(f32, 200), semantics[0].scroll.viewport_extent);
try std.testing.expectEqual(@as(f32, 500), semantics[0].scroll.content_extent);
try std.testing.expectApproxEqAbs(@as(f32, 60.0 / 300.0), semantics[0].value.?, 0.001);
try std.testing.expect(semantics[0].actions.increment);
try std.testing.expect(semantics[0].actions.decrement);
// With VERTICAL range present the vertical axis stays primary — the
// pre-axis behavior for every region that scrolls down.
const tall = Widget{
.id = 1,
.kind = .scroll_view,
.scroll_axes = .both,
.value = 30,
.children = &[_]Widget{.{ .id = 2, .kind = .panel, .frame = geometry.RectF.init(0, 0, 500, 400) }},
};
var tall_nodes: [3]WidgetLayoutNode = undefined;
const tall_layout = try layoutWidgetTree(tall, geometry.RectF.init(0, 0, 200, 100), &tall_nodes);
var tall_buffer: [3]WidgetSemanticsNode = undefined;
const tall_semantics = try tall_layout.collectSemantics(&tall_buffer);
try std.testing.expectEqual(@as(f32, 30), tall_semantics[0].scroll.offset);
try std.testing.expectEqual(@as(f32, 100), tall_semantics[0].scroll.viewport_extent);
try std.testing.expectEqual(@as(f32, 400), tall_semantics[0].scroll.content_extent);
}
test "widget focus traversal skips scroll clipped children" {
const children = [_]Widget{
.{ .id = 2, .kind = .button, .frame = geometry.RectF.init(0, 0, 0, 32), .text = "One" },
@@ -1446,7 +1567,7 @@ test "scroll state applies wheel deltas kinetic decay and bounds" {
.deceleration_per_second = 0.5,
.stop_velocity = 1,
};
const start = ScrollState{
const start = ScrollAxisState{
.offset = 10,
.viewport_extent = 100,
.content_extent = 360,
@@ -1471,7 +1592,7 @@ test "scroll state applies wheel deltas kinetic decay and bounds" {
}
test "scroll overscroll gates rubber-band: none pins at the edges, rubber_band excursions recover" {
const start = ScrollState{
const start = ScrollAxisState{
.offset = 250,
.viewport_extent = 100,
.content_extent = 360,
@@ -1485,7 +1606,7 @@ test "scroll overscroll gates rubber-band: none pins at the edges, rubber_band e
const pinned = start.applyWheel(1000, pinned_physics);
try std.testing.expectEqual(@as(f32, 260), pinned.offset);
try std.testing.expectEqual(@as(f32, 0), pinned.velocity);
var rolling = ScrollState{
var rolling = ScrollAxisState{
.offset = 250,
.velocity = 400,
.viewport_extent = 100,
@@ -1512,7 +1633,7 @@ test "scroll overscroll gates rubber-band: none pins at the edges, rubber_band e
// A stale out-of-range offset on a pinned region self-heals in one
// kinetic step instead of animating a return.
const stale = ScrollState{
const stale = ScrollAxisState{
.offset = 300,
.viewport_extent = 100,
.content_extent = 360,
+37 -4
View File
@@ -510,7 +510,14 @@ fn emitWidgetLayoutNodeContent(
try builder.popClip();
// Native scroll drivers own the (OS overlay) scrollbar.
if (!paint_widget.native_scroll) {
try widget_render_scroll.emitScrollViewScrollbar(builder, paint_widget.frame, widgetScrollSemantics(layout, node_index).metrics, tokens, paint_widget.id);
try widget_render_scroll.emitScrollViewScrollbars(
builder,
paint_widget.frame,
widgetScrollAxisMetrics(layout, node_index, .vertical),
widgetScrollAxisMetrics(layout, node_index, .horizontal),
tokens,
paint_widget.id,
);
}
return;
},
@@ -629,9 +636,18 @@ fn emitWidgetLayoutScrollableChildren(
try builder.pushClip(clip);
try emitWidgetLayoutChildren(builder, layout, parent_index, tokens, state);
try builder.popClip();
// Native scroll drivers own the (OS overlay) scrollbar.
// Native scroll drivers own the (OS overlay) scrollbar. These are
// the virtualized containers — vertical machinery, so only the
// vertical bar can exist.
if (!widget.native_scroll) {
try widget_render_scroll.emitScrollViewScrollbar(builder, widget.frame, widgetScrollSemantics(layout, parent_index).metrics, tokens, widget.id);
try widget_render_scroll.emitScrollViewScrollbars(
builder,
widget.frame,
widgetScrollAxisMetrics(layout, parent_index, .vertical),
.{},
tokens,
widget.id,
);
}
}
@@ -864,7 +880,14 @@ fn emitScrollViewWidget(builder: *Builder, widget: Widget, tokens: DesignTokens,
try builder.popClip();
// Native scroll drivers own the (OS overlay) scrollbar.
if (!widget.native_scroll) {
try widget_render_scroll.emitScrollViewScrollbar(builder, widget.frame, widget_render_scroll.widgetScrollMetricsForWidget(widget, tokens), tokens, widget.id);
try widget_render_scroll.emitScrollViewScrollbars(
builder,
widget.frame,
widget_render_scroll.widgetScrollAxisMetricsForWidget(widget, tokens, .vertical),
widget_render_scroll.widgetScrollAxisMetricsForWidget(widget, tokens, .horizontal),
tokens,
widget.id,
);
}
}
@@ -878,6 +901,16 @@ fn widgetScrollSemantics(layout: anytype, node_index: usize) widget_semantics.Wi
return widget_semantics.widgetScrollSemantics(layout, node_index, widget_layout.virtualWidgetScrollContentExtent);
}
/// Per-axis scrollbar metrics for a layout-walk scroll region (present
/// = false on an ungranted axis or an empty viewport).
fn widgetScrollAxisMetrics(layout: anytype, node_index: usize, comptime axis: canvas.ScrollAxis) event_model.WidgetScrollMetrics {
if (node_index >= layout.nodes.len) return .{};
const node = layout.nodes[node_index];
const viewport = node.frame.inset(node.widget.layout.padding).normalized();
if (viewport.isEmpty()) return .{};
return widget_semantics.widgetScrollAxisMetrics(layout, node_index, widget_layout.virtualWidgetScrollContentExtent, axis, viewport);
}
fn emitWidgetLayoutClippedChildren(
builder: *Builder,
layout: anytype,
+107 -26
View File
@@ -26,22 +26,60 @@ pub const ScrollbarGeometry = struct {
thumb: geometry.RectF,
};
pub fn emitScrollViewScrollbar(builder: *Builder, frame: geometry.RectF, metrics: WidgetScrollMetrics, tokens: DesignTokens, id: ObjectId) Error!void {
const scrollbar = scrollViewScrollbarGeometry(frame, metrics, tokens) orelse return;
/// Emit the engine scrollbars for one scroll region: the vertical bar
/// on the right edge (part slots 2/3, exactly as before) and the
/// horizontal bar along the bottom edge (part slots 4/5), each drawn
/// only when its axis has scrollable range. When BOTH are visible each
/// track ends short of the shared corner by the other bar's thickness
/// plus the inset — the standard scroller corner gap, so the thumbs
/// never overlap.
pub fn emitScrollViewScrollbars(builder: *Builder, frame: geometry.RectF, vertical: WidgetScrollMetrics, horizontal: WidgetScrollMetrics, tokens: DesignTokens, id: ObjectId) Error!void {
try emitScrollViewScrollbarAxis(builder, frame, vertical, tokens, id, .vertical, scrollbarCornerReserve(frame, vertical, horizontal, tokens, .vertical), 2);
try emitScrollViewScrollbarAxis(builder, frame, horizontal, tokens, id, .horizontal, scrollbarCornerReserve(frame, vertical, horizontal, tokens, .horizontal), 4);
}
/// The corner gap each track reserves when both bars are visible: the
/// OTHER bar's thickness plus the edge inset, 0 when either bar is
/// absent.
fn scrollbarCornerReserve(frame: geometry.RectF, vertical: WidgetScrollMetrics, horizontal: WidgetScrollMetrics, tokens: DesignTokens, axis: token_model.ScrollAxis) f32 {
if (!scrollbarAxisVisible(vertical) or !scrollbarAxisVisible(horizontal)) return 0;
const inset = densityValue(tokens, 3);
const other: token_model.ScrollAxis = if (axis == .vertical) .horizontal else .vertical;
return scrollbarThickness(frame, tokens, other) + inset;
}
fn scrollbarAxisVisible(metrics: WidgetScrollMetrics) bool {
const viewport = nonNegative(metrics.viewport_extent);
const content = nonNegative(metrics.content_extent);
return metrics.present and viewport > 0 and content > viewport;
}
/// Each bar's thickness derives from the dimension it spans ACROSS —
/// the vertical bar from the region's width (exactly the pre-axis
/// formula, so every existing vertical scrollbar renders byte-identical
/// pixels), the horizontal bar from its height (the mirror).
fn scrollbarThickness(frame: geometry.RectF, tokens: DesignTokens, axis: token_model.ScrollAxis) f32 {
const reference = if (axis == .vertical) frame.width else frame.height;
return @min(@max(densityValue(tokens, 3), reference * 0.0125), densityValue(tokens, 6));
}
fn emitScrollViewScrollbarAxis(builder: *Builder, frame: geometry.RectF, metrics: WidgetScrollMetrics, tokens: DesignTokens, id: ObjectId, axis: token_model.ScrollAxis, reserved_end: f32, track_slot: ObjectId) Error!void {
const scrollbar = scrollViewScrollbarGeometryForAxis(frame, metrics, tokens, axis, reserved_end) orelse return;
const track = pixelSnapGeometryRect(tokens, scrollbar.track);
const thumb = pixelSnapGeometryRect(tokens, scrollbar.thumb);
const visual = tokens.controls.scrollbar;
const radius = Radius.all(if (visual.radius) |value| nonNegative(value) else track.width * 0.5);
const bar_thickness = if (axis == .vertical) track.width else track.height;
const radius = Radius.all(if (visual.radius) |value| nonNegative(value) else bar_thickness * 0.5);
const track_fill = visual.background orelse colorWithAlpha(tokens.colors.border, @min(tokens.colors.border.a, 0.22));
const thumb_fill = visual.foreground orelse visual.active_background orelse colorWithAlpha(tokens.colors.text_muted, 0.55);
try builder.fillRoundedRect(.{
.id = widgetPartId(id, 2),
.id = widgetPartId(id, track_slot),
.rect = track,
.radius = radius,
.fill = colorFill(track_fill),
});
try builder.fillRoundedRect(.{
.id = widgetPartId(id, 3),
.id = widgetPartId(id, track_slot + 1),
.rect = thumb,
.radius = radius,
.fill = colorFill(thumb_fill),
@@ -49,6 +87,10 @@ pub fn emitScrollViewScrollbar(builder: *Builder, frame: geometry.RectF, metrics
}
pub fn scrollViewScrollbarGeometry(frame: geometry.RectF, metrics: WidgetScrollMetrics, tokens: DesignTokens) ?ScrollbarGeometry {
return scrollViewScrollbarGeometryForAxis(frame, metrics, tokens, .vertical, 0);
}
pub fn scrollViewScrollbarGeometryForAxis(frame: geometry.RectF, metrics: WidgetScrollMetrics, tokens: DesignTokens, axis: token_model.ScrollAxis, reserved_end: f32) ?ScrollbarGeometry {
if (!metrics.present) return null;
const viewport = nonNegative(metrics.viewport_extent);
const content = nonNegative(metrics.content_extent);
@@ -56,41 +98,80 @@ pub fn scrollViewScrollbarGeometry(frame: geometry.RectF, metrics: WidgetScrollM
if (frame.isEmpty() or viewport <= 0 or content <= viewport or max_offset <= 0) return null;
const inset = densityValue(tokens, 3);
const thickness = @min(@max(densityValue(tokens, 3), frame.width * 0.0125), densityValue(tokens, 6));
const track_height = @max(0, frame.height - inset * 2);
if (track_height <= 0 or thickness <= 0) return null;
const thickness = scrollbarThickness(frame, tokens, axis);
const track_extent = @max(0, (if (axis == .vertical) frame.height else frame.width) - inset * 2 - nonNegative(reserved_end));
if (track_extent <= 0 or thickness <= 0) return null;
const track = geometry.RectF.init(
frame.x + frame.width - inset - thickness,
frame.y + inset,
thickness,
track_height,
);
const track = switch (axis) {
.vertical => geometry.RectF.init(
frame.x + frame.width - inset - thickness,
frame.y + inset,
thickness,
track_extent,
),
.horizontal => geometry.RectF.init(
frame.x + inset,
frame.y + frame.height - inset - thickness,
track_extent,
thickness,
),
};
const thumb_ratio = std.math.clamp(viewport / content, 0, 1);
const min_thumb = @min(track_height, densityValue(tokens, 18));
const thumb_height = @min(track_height, @max(min_thumb, track_height * thumb_ratio));
const travel = @max(0, track_height - thumb_height);
const min_thumb = @min(track_extent, densityValue(tokens, 18));
const thumb_extent = @min(track_extent, @max(min_thumb, track_extent * thumb_ratio));
const travel = @max(0, track_extent - thumb_extent);
const offset_ratio = std.math.clamp(nonNegative(metrics.offset) / max_offset, 0, 1);
return .{
.track = track,
.thumb = geometry.RectF.init(track.x, track.y + travel * offset_ratio, track.width, thumb_height),
.thumb = switch (axis) {
.vertical => geometry.RectF.init(track.x, track.y + travel * offset_ratio, track.width, thumb_extent),
.horizontal => geometry.RectF.init(track.x + travel * offset_ratio, track.y, thumb_extent, track.height),
},
};
}
pub fn widgetScrollMetricsForWidget(widget: Widget, tokens: DesignTokens) WidgetScrollMetrics {
return widgetScrollAxisMetricsForWidget(widget, tokens, .vertical);
}
/// Per-axis metrics for a WIDGET-walk scroll view (static trees, docs
/// scenes — children carry their own frames). `present = false` on an
/// axis the region does not grant, mirroring the layout-walk metrics.
pub fn widgetScrollAxisMetricsForWidget(widget: Widget, tokens: DesignTokens, axis: token_model.ScrollAxis) WidgetScrollMetrics {
if (widget.kind != .scroll_view) return .{};
const viewport = widget.frame.inset(widget.layout.padding).normalized();
if (viewport.isEmpty()) return .{};
const content_extent = widgetScrollContentExtentForWidget(widget, viewport, tokens);
const max_offset = @max(0, content_extent - viewport.height);
return .{
.present = true,
.offset = std.math.clamp(nonNegative(widget.value), 0, max_offset),
.viewport_extent = viewport.height,
.content_extent = content_extent,
};
switch (axis) {
.vertical => {
if (!widget.layout.virtualized and !widget.scroll_axes.scrollsVertically()) return .{};
const content_extent = widgetScrollContentExtentForWidget(widget, viewport, tokens);
const max_offset = @max(0, content_extent - viewport.height);
return .{
.present = true,
.offset = std.math.clamp(nonNegative(widget.value), 0, max_offset),
.viewport_extent = viewport.height,
.content_extent = content_extent,
};
},
.horizontal => {
if (widget.layout.virtualized or !widget.scroll_axes.scrollsHorizontally()) return .{};
const offset = widget.value_x;
var right = viewport.maxX();
for (widget.children) |child| {
right = @max(right, child.frame.maxX() + offset);
}
const content_extent = @max(0, right - viewport.x);
const max_offset = @max(0, content_extent - viewport.width);
return .{
.present = true,
.offset = std.math.clamp(nonNegative(widget.value_x), 0, max_offset),
.viewport_extent = viewport.width,
.content_extent = content_extent,
};
},
}
}
fn widgetScrollContentExtentForWidget(widget: Widget, viewport: geometry.RectF, tokens: DesignTokens) f32 {
+109 -10
View File
@@ -16,6 +16,7 @@ const WidgetLayoutNode = event_model.WidgetLayoutNode;
const WidgetSemanticsNode = event_model.WidgetSemanticsNode;
const WidgetListMetrics = event_model.WidgetListMetrics;
const WidgetScrollMetrics = event_model.WidgetScrollMetrics;
const ScrollAxis = token_model.ScrollAxis;
const VirtualListRange = token_model.VirtualListRange;
const virtualListRange = token_model.virtualListRange;
const semanticActions = event_model.semanticActions;
@@ -418,6 +419,50 @@ pub const WidgetScrollSemantics = struct {
scrollable: bool = false,
};
/// One axis of a scroll region as the one-axis metrics record, or
/// `present = false` when the region does not grant that axis (a
/// horizontal-only shelf has no vertical metrics, a vertical list no
/// horizontal ones). The engine scrollbar renders each axis from this;
/// `widgetScrollSemantics` picks the primary axis for the assistive
/// node.
pub fn widgetScrollAxisMetrics(layout: anytype, node_index: usize, virtual_content_extent_fn: anytype, comptime axis: ScrollAxis, viewport: geometry.RectF) WidgetScrollMetrics {
const node = layout.nodes[node_index];
switch (axis) {
.vertical => {
if (node.widget.kind == .scroll_view and !node.widget.layout.virtualized and !node.widget.scroll_axes.scrollsVertically()) return .{};
const content_extent = widgetScrollContentExtent(layout, node_index, viewport, virtual_content_extent_fn);
const max_offset = @max(0, content_extent - viewport.height);
return .{
.present = true,
.offset = std.math.clamp(nonNegative(node.widget.value), 0, max_offset),
.viewport_extent = viewport.height,
.content_extent = content_extent,
};
},
.horizontal => {
if (node.widget.kind != .scroll_view or node.widget.layout.virtualized or !node.widget.scroll_axes.scrollsHorizontally()) return .{};
const content_extent = widgetScrollContentExtentX(layout, node_index, viewport);
const max_offset = @max(0, content_extent - viewport.width);
return .{
.present = true,
.offset = std.math.clamp(nonNegative(node.widget.value_x), 0, max_offset),
.viewport_extent = viewport.width,
.content_extent = content_extent,
};
},
}
}
/// Scroll semantics report the region's PRIMARY axis: the vertical one
/// wherever it is granted AND has scrollable range (the pre-axis
/// behavior for every vertical-only region, byte-identical), otherwise
/// the horizontal one — so an assistive query on a sideways shelf, or
/// on a `both` region whose content only overflows sideways, reads a
/// live position instead of a permanently unscrollable axis. The
/// one-axis metrics shape is deliberate: it rides the embed ABI and
/// automation snapshots, and the assistive scroll ACTIONS resolve
/// their axis through the same primary-axis rule
/// (`widgetSemanticScrollDelta`).
pub fn widgetScrollSemantics(layout: anytype, node_index: usize, virtual_content_extent_fn: anytype) WidgetScrollSemantics {
if (node_index >= layout.nodes.len) return .{};
const node = layout.nodes[node_index];
@@ -426,17 +471,21 @@ pub fn widgetScrollSemantics(layout: anytype, node_index: usize, virtual_content
const viewport = node.frame.inset(node.widget.layout.padding).normalized();
if (viewport.isEmpty()) return .{};
const content_extent = widgetScrollContentExtent(layout, node_index, viewport, virtual_content_extent_fn);
const max_offset = @max(0, content_extent - viewport.height);
const offset = std.math.clamp(nonNegative(node.widget.value), 0, max_offset);
const vertical = widgetScrollAxisMetrics(layout, node_index, virtual_content_extent_fn, .vertical, viewport);
const horizontal = widgetScrollAxisMetrics(layout, node_index, virtual_content_extent_fn, .horizontal, viewport);
const vertical_range = vertical.present and vertical.content_extent > vertical.viewport_extent;
const horizontal_range = horizontal.present and horizontal.content_extent > horizontal.viewport_extent;
const metrics = if (vertical_range or (vertical.present and !horizontal_range))
vertical
else if (horizontal.present)
horizontal
else
vertical;
if (!metrics.present) return .{};
const max_offset = @max(0, metrics.content_extent - metrics.viewport_extent);
return .{
.metrics = .{
.present = true,
.offset = offset,
.viewport_extent = viewport.height,
.content_extent = content_extent,
},
.value = if (max_offset > 0) offset / max_offset else 0,
.metrics = metrics,
.value = if (max_offset > 0) metrics.offset / max_offset else 0,
.scrollable = max_offset > 0,
};
}
@@ -461,6 +510,16 @@ fn widgetScrollContentExtent(layout: anytype, scroll_index: usize, viewport: geo
var index = scroll_index + 1;
while (index < layout.nodes.len and layout.nodes[index].depth > scroll_depth) {
const node = layout.nodes[index];
// A subtree anchored DIRECTLY to the scroll region stays
// stationary under scrolling (its anchor base never moves), so
// `frame + offset` is not a content-space position for it —
// counting it would inflate the range on every scroll. Deeper
// anchored subtrees ride their in-content anchors and keep
// their historical contribution.
if (node.widget.layout.anchor != null and node.parent_index == scroll_index) {
index = skipSubtree(layout, index);
continue;
}
bottom = @max(bottom, node.frame.maxY() + offset);
// A disclosure widget's own frame is authoritative for how far
// its content currently reaches: concealed content lays out at
@@ -480,6 +539,46 @@ fn widgetScrollContentExtent(layout: anytype, scroll_index: usize, viewport: geo
return @max(0, bottom - viewport.y);
}
/// The horizontal content reach for a horizontal scroll view's
/// semantics — the sideways mirror of `widgetScrollContentExtent`, with
/// the honest-range exclusions the engine's clamp/driver walker applies
/// (`canvasWidgetLayoutScrollContentExtentX`): anchored floating
/// subtrees are out of flow, a nested clip scope bounds its own
/// children, and disclosure content counts only while settled open.
fn widgetScrollContentExtentX(layout: anytype, scroll_index: usize, viewport: geometry.RectF) f32 {
const scroll_node = layout.nodes[scroll_index];
const scroll_depth = scroll_node.depth;
const offset = scroll_node.widget.value_x;
var right = viewport.maxX();
var index = scroll_index + 1;
while (index < layout.nodes.len and layout.nodes[index].depth > scroll_depth) {
const node = layout.nodes[index];
if (node.widget.layout.anchor != null) {
index = skipSubtree(layout, index);
continue;
}
right = @max(right, node.frame.maxX() + offset);
if (widget_tree.widgetClipsContent(node.widget) or node.widget.layout.virtualized) {
index = skipSubtree(layout, index);
continue;
}
if (widget_tree.widgetKindDisclosureAnimated(node.widget.kind) and !widget_tree.disclosureSettledOpen(layout, index)) {
index = skipSubtree(layout, index);
continue;
}
index += 1;
}
return @max(0, right - viewport.x);
}
/// The index just past `index`'s whole subtree.
fn skipSubtree(layout: anytype, index: usize) usize {
const subtree_depth = layout.nodes[index].depth;
var next = index + 1;
while (next < layout.nodes.len and layout.nodes[next].depth > subtree_depth) : (next += 1) {}
return next;
}
fn nonNegative(value: f32) f32 {
return if (value < 0) 0 else value;
}
+17
View File
@@ -114,6 +114,23 @@ pub fn widgetVirtualRuntimeScrolled(widget: Widget) bool {
return widget.kind == .scroll_view and widget.layout.virtualized and widget.layout.virtual_item_count > 0;
}
/// Whether one widget scrolls on the named axis. This is THE axis
/// capability predicate: wheel/trackpad routing resolves each axis
/// against it independently, keyboard and semantic scroll intents pick
/// their axis through it, and the scrollbar/driver emit only the axes
/// it grants. Vertical: every scrollable kind (scroll views unless
/// declared `horizontal`, textareas, the virtualized containers).
/// Horizontal: only a non-virtualized `.scroll_view` declared
/// `horizontal` or `both` — virtualization is vertical machinery
/// (windowed ranges price rows, not columns), so a virtualized region
/// never grants the horizontal axis.
pub fn widgetScrollsAxis(widget: Widget, axis: token_model.ScrollAxis) bool {
return switch (axis) {
.vertical => widget.kind != .scroll_view or widget.scroll_axes.scrollsVertically() or widget.layout.virtualized,
.horizontal => widget.kind == .scroll_view and widget.scroll_axes.scrollsHorizontally() and !widget.layout.virtualized,
};
}
/// The effective scroll physics for one scroll region: the shared
/// `ScrollPhysics` token with the region's `Widget.overscroll` override
/// resolved onto `physics.overscroll` (`.default` keeps the token's
+17
View File
@@ -20,6 +20,7 @@ const TextRange = text_model.TextRange;
const TextSelection = text_model.TextSelection;
const CanvasRenderAnimation = canvas.CanvasRenderAnimation;
const BlurTokenRef = token_model.BlurTokenRef;
const ScrollAxes = token_model.ScrollAxes;
const Easing = token_model.Easing;
const MotionDuration = token_model.MotionDuration;
const MotionTokens = token_model.MotionTokens;
@@ -847,6 +848,22 @@ pub const Widget = struct {
text_selection: ?TextSelection = null,
text_composition: ?TextRange = null,
value: f32 = 0,
/// The HORIZONTAL scroll offset of a horizontal-capable
/// `.scroll_view` (`value_x:` in the builder, `value-x=` in markup)
/// — the horizontal counterpart of the retained offset that rides
/// `value`. Follows the same source-wins reconcile rule: the
/// runtime-owned offset (user scrolling) survives rebuilds while
/// the source offset is unchanged; a source-side change
/// (programmatic scroll) wins. Meaningless on every other kind.
value_x: f32 = 0,
/// Which axes a `.scroll_view` scrolls (`axis:` in the builder,
/// `axis=` in markup). Vertical — the pre-axis behavior — is the
/// default; `horizontal` and `both` opt the region into wheel
/// `delta_x`, the horizontal scrollbar, and the horizontal keymap.
/// Virtualized regions ignore the horizontal grant (windowed
/// virtualization prices rows, not columns). Meaningless on every
/// other kind.
scroll_axes: ScrollAxes = .vertical,
layer: ?i32 = null,
/// Modal surfaces (dialog/drawer/sheet) paint a token-driven scrim
/// (dim + backdrop blur) across the whole tree behind them. False
+20 -1
View File
@@ -40,6 +40,9 @@ pub const AutomationWidgetTarget = struct {
pub const AutomationWidgetWheel = struct {
target: AutomationWidgetTarget,
delta_y: f32,
/// Horizontal wheel delta (the optional fourth token): 0 — the
/// classic three-token form — scrolls vertically exactly as before.
delta_x: f32 = 0,
};
/// `widget-context-menu <view-label> <id> <item-index>`: invoke one of
@@ -238,18 +241,27 @@ pub fn parseAutomationProvenanceTarget(value: []const u8) !AutomationProvenanceT
return .{ .view_label = view.token, .id = id };
}
/// `widget-wheel <view-label> <id> <delta-y> [delta-x]`: the optional
/// fourth token scrolls the horizontal axis (per-axis routing applies,
/// exactly like a real trackpad gesture with both deltas).
pub fn parseAutomationWidgetWheel(value: []const u8) !AutomationWidgetWheel {
const view = takeAutomationToken(value) orelse return error.InvalidCommand;
const id_part = takeAutomationToken(view.rest) orelse return error.InvalidCommand;
const delta_part = takeAutomationToken(id_part.rest) orelse return error.InvalidCommand;
if (takeAutomationToken(delta_part.rest) != null) return error.InvalidCommand;
const id = std.fmt.parseInt(canvas.ObjectId, id_part.token, 10) catch return error.InvalidCommand;
if (id == 0) return error.InvalidCommand;
const delta_y = std.fmt.parseFloat(f32, delta_part.token) catch return error.InvalidCommand;
if (!std.math.isFinite(delta_y)) return error.InvalidCommand;
var delta_x: f32 = 0;
if (takeAutomationToken(delta_part.rest)) |delta_x_part| {
if (takeAutomationToken(delta_x_part.rest) != null) return error.InvalidCommand;
delta_x = std.fmt.parseFloat(f32, delta_x_part.token) catch return error.InvalidCommand;
if (!std.math.isFinite(delta_x)) return error.InvalidCommand;
}
return .{
.target = .{ .view_label = view.token, .id = id },
.delta_y = delta_y,
.delta_x = delta_x,
};
}
@@ -627,6 +639,12 @@ test "runtime parses automation widget wheel targets" {
try std.testing.expectEqualStrings("canvas", wheel.target.view_label);
try std.testing.expectEqual(@as(canvas.ObjectId, 42), wheel.target.id);
try std.testing.expectEqual(@as(f32, 18.5), wheel.delta_y);
try std.testing.expectEqual(@as(f32, 0), wheel.delta_x);
// The optional fourth token is the horizontal delta.
const diagonal = try parseAutomationWidgetWheel("canvas 42 18.5 -6");
try std.testing.expectEqual(@as(f32, 18.5), diagonal.delta_y);
try std.testing.expectEqual(@as(f32, -6), diagonal.delta_x);
try std.testing.expectError(error.InvalidCommand, parseAutomationWidgetWheel(""));
try std.testing.expectError(error.InvalidCommand, parseAutomationWidgetWheel("canvas"));
@@ -635,6 +653,7 @@ test "runtime parses automation widget wheel targets" {
try std.testing.expectError(error.InvalidCommand, parseAutomationWidgetWheel("canvas 42 nope"));
try std.testing.expectError(error.InvalidCommand, parseAutomationWidgetWheel("canvas 42 nan"));
try std.testing.expectError(error.InvalidCommand, parseAutomationWidgetWheel("canvas 42 18 extra"));
try std.testing.expectError(error.InvalidCommand, parseAutomationWidgetWheel("canvas 42 18 6 extra"));
}
test "runtime parses automation widget context-menu items" {
@@ -321,6 +321,7 @@ pub fn RuntimeAutomationWidgetDispatch(comptime Runtime: type) type {
.timestamp_ns = timestamp_ns,
.x = point.x,
.y = point.y,
.delta_x = wheel.delta_x,
.delta_y = wheel.delta_y,
} });
}
+1 -1
View File
@@ -1863,7 +1863,7 @@ pub fn RuntimeCanvasWidgetEvents(comptime Runtime: type) type {
const index = runtimeFindViewIndex(self, pointer_event.window_id, pointer_event.view_label) orelse return;
if (self.views[index].kind != .gpu_surface) return;
const dirty = try self.views[index].applyCanvasWidgetScrollRoute(pointer_event.route, pointer_event.pointer.delta.dy, .wheel) orelse return;
const dirty = try self.views[index].applyCanvasWidgetScrollRoute(pointer_event.route, pointer_event.pointer.delta, .wheel) orelse return;
const previous_cursor = self.views[index].canvas_widget_cursor;
try reconcileCanvasWidgetRenderStateAfterScrollWithTooltipIntent(self, index, pointer_event.pointer.point);
if (previous_cursor != self.views[index].canvas_widget_cursor) try syncCanvasWidgetCursorForView(self, index);
+189 -29
View File
@@ -159,6 +159,9 @@ pub const CanvasWidgetTextReconcileEntry = struct {
pub const CanvasWidgetSourceScrollEntry = struct {
id: canvas.ObjectId = 0,
value: f32 = 0,
/// The horizontal scroll offset channel (`Widget.value_x`) — 0 for
/// splits, which share this entry shape but carry one fraction.
value_x: f32 = 0,
};
pub fn canvasWidgetSourceScrollById(entries: []const CanvasWidgetSourceScrollEntry, id: canvas.ObjectId) ?f32 {
@@ -168,6 +171,13 @@ pub fn canvasWidgetSourceScrollById(entries: []const CanvasWidgetSourceScrollEnt
return null;
}
pub fn canvasWidgetSourceScrollEntryById(entries: []const CanvasWidgetSourceScrollEntry, id: canvas.ObjectId) ?CanvasWidgetSourceScrollEntry {
for (entries) |entry| {
if (entry.id == id) return entry;
}
return null;
}
/// Scroll offsets and split fractions share the entry shape (id +
/// value) and the source-wins reconcile rule, so one collector serves
/// both.
@@ -183,7 +193,7 @@ pub fn collectCanvasWidgetScrollOffsetEntries(
for (nodes) |node| {
if (!canvasWidgetSourceValueKind(node.widget.kind) or node.widget.id == 0) continue;
if (len >= output.len) break;
output[len] = .{ .id = node.widget.id, .value = node.widget.value };
output[len] = .{ .id = node.widget.id, .value = node.widget.value, .value_x = node.widget.value_x };
len += 1;
}
return output[0..len];
@@ -204,13 +214,30 @@ pub fn restoreCanvasWidgetLayoutScrollOffsets(
) void {
for (nodes, 0..) |node, index| {
if (node.widget.kind != .scroll_view or node.widget.id == 0) continue;
const previous_runtime = canvasWidgetSourceScrollById(previous_runtime_offsets, node.widget.id) orelse continue;
const previous_source = canvasWidgetSourceScrollById(previous_source_offsets, node.widget.id) orelse continue;
if (node.widget.value != previous_source) continue;
if (node.widget.value == previous_runtime) continue;
const laid_out = node.widget.value;
nodes[index].widget.value = previous_runtime;
translateCanvasWidgetLayoutScrollDescendants(nodes, index, -(previous_runtime - laid_out));
const previous_runtime = canvasWidgetSourceScrollEntryById(previous_runtime_offsets, node.widget.id) orelse continue;
const previous_source = canvasWidgetSourceScrollEntryById(previous_source_offsets, node.widget.id) orelse continue;
// Each axis reconciles on its own: a programmatic vertical
// scroll (source-side `value` change) must not snap a
// user-scrolled horizontal offset back, and vice versa. Only
// granted axes restore — the layout pass never displaced
// children along an ungranted axis, so restoring its offset
// would translate content that never moved (the clamp pass
// pins the stale value home instead).
var restore = geometry.OffsetF{};
if (canvas.widgetScrollsAxis(node.widget, .vertical) and
node.widget.value == previous_source.value and node.widget.value != previous_runtime.value)
{
restore.dy = previous_runtime.value - node.widget.value;
nodes[index].widget.value = previous_runtime.value;
}
if (canvas.widgetScrollsAxis(node.widget, .horizontal) and
node.widget.value_x == previous_source.value_x and node.widget.value_x != previous_runtime.value_x)
{
restore.dx = previous_runtime.value_x - node.widget.value_x;
nodes[index].widget.value_x = previous_runtime.value_x;
}
if (restore.dx == 0 and restore.dy == 0) continue;
translateCanvasWidgetLayoutScrollDescendants(nodes, index, .{ .dx = -restore.dx, .dy = -restore.dy });
}
}
@@ -517,13 +544,24 @@ pub fn canvasWidgetScrollStateForLayoutNode(
node: canvas.WidgetLayoutNode,
previous: []const CanvasWidgetScrollReconcileEntry,
) canvas.ScrollState {
var state = canvas.ScrollState{ .offset = node.widget.value };
var state = canvas.ScrollState{ .offset_y = node.widget.value, .offset_x = node.widget.value_x };
if (node.widget.kind != .scroll_view or node.widget.id == 0) return state;
for (previous) |entry| {
if (entry.id == node.widget.id) {
state.velocity = entry.state.velocity;
return state;
if (entry.id != node.widget.id) continue;
// In-flight fling velocity survives a rebuild PER AXIS, and only
// while that axis's offset of record survived too: a
// source-side (programmatic) jump means the model took the
// wheel — resuming the old fling would immediately drag the
// region away from where it was just placed. An axis the region
// no longer grants carries no velocity either, so a revoked and
// later restored grant can never resume an obsolete fling.
if (canvas.widgetScrollsAxis(node.widget, .vertical) and node.widget.value == entry.state.offset_y) {
state.velocity_y = entry.state.velocity_y;
}
if (canvas.widgetScrollsAxis(node.widget, .horizontal) and node.widget.value_x == entry.state.offset_x) {
state.velocity_x = entry.state.velocity_x;
}
return state;
}
return state;
}
@@ -912,11 +950,35 @@ pub fn clampCanvasWidgetLayoutScrollOffsets(nodes: []canvas.WidgetLayoutNode, st
// Runtime-scrolled virtual lists (declared item count) clamp
// like plain scroll views, against the VIRTUAL content extent.
if (node.widget.layout.virtualized and !canvas.widgetVirtualRuntimeScrolled(node.widget)) continue;
// Native scroll drivers own clamping: the OS scroller constrains
// its own contentOffset (including mid-rubber-band rebuilds, which
// an engine clamp here would fight) and reports the settled offset
// back through the driver event.
if (node.widget.native_scroll) continue;
// Native scroll drivers own RANGE clamping: the OS scroller
// constrains its own contentOffset (including mid-rubber-band
// rebuilds, which an engine clamp here would fight) and reports
// the settled offset back through the driver event. A REVOKED
// axis is different — it has no scroller range at all (content
// pins to the frame), so its stale offset pins home here
// exactly like the engine-scrolled path below: an axis flip
// must behave the same on every host, or a source still
// echoing the old offset would resurrect it on re-grant only
// where drivers run.
if (node.widget.native_scroll) {
const pin_y = !canvas.widgetScrollsAxis(node.widget, .vertical) and node.widget.value != 0;
const pin_x = !canvas.widgetScrollsAxis(node.widget, .horizontal) and node.widget.value_x != 0;
if (pin_y) nodes[index].widget.value = 0;
if (pin_x) nodes[index].widget.value_x = 0;
if ((pin_y or pin_x) and states != null) {
if (index < states.?.len) {
if (pin_y) {
states.?[index].offset_y = 0;
states.?[index].velocity_y = 0;
}
if (pin_x) {
states.?[index].offset_x = 0;
states.?[index].velocity_x = 0;
}
}
}
continue;
}
const viewport = node.frame.inset(node.widget.layout.padding).normalized();
if (viewport.isEmpty()) continue;
@@ -924,18 +986,42 @@ pub fn clampCanvasWidgetLayoutScrollOffsets(nodes: []canvas.WidgetLayoutNode, st
const content_extent = canvasWidgetLayoutScrollContentExtent(nodes, index, viewport);
const max_offset = @max(0, content_extent - viewport.height);
const current_offset = node.widget.value;
const next_offset = std.math.clamp(@max(0, current_offset), 0, max_offset);
if (next_offset == current_offset) continue;
// An offset on an axis the region does not grant pins home: a
// rebuild that flips the axis (or virtualizes the region) must
// not leave content displaced along the revoked axis.
const next_offset = if (canvas.widgetScrollsAxis(node.widget, .vertical))
std.math.clamp(@max(0, current_offset), 0, max_offset)
else
0;
const content_extent_x = canvasWidgetLayoutScrollContentExtentX(nodes, index, viewport);
const max_offset_x = @max(0, content_extent_x - viewport.width);
const current_offset_x = node.widget.value_x;
const next_offset_x = if (canvas.widgetScrollsAxis(node.widget, .horizontal))
std.math.clamp(@max(0, current_offset_x), 0, max_offset_x)
else
0;
if (next_offset == current_offset and next_offset_x == current_offset_x) continue;
const offset_delta = next_offset - current_offset;
nodes[index].widget.value = next_offset;
translateCanvasWidgetLayoutScrollDescendants(nodes, index, -offset_delta);
nodes[index].widget.value_x = next_offset_x;
// Only granted axes translate: the layout pass never displaced
// children along an ungranted axis (`scrollLayoutOffset` gates
// it), so pinning that value home is bookkeeping, not motion.
translateCanvasWidgetLayoutScrollDescendants(nodes, index, .{
.dx = if (canvas.widgetScrollsAxis(node.widget, .horizontal)) -(next_offset_x - current_offset_x) else 0,
.dy = if (canvas.widgetScrollsAxis(node.widget, .vertical)) -(next_offset - current_offset) else 0,
});
if (states) |scroll_states| {
if (index < scroll_states.len) {
scroll_states[index].offset = next_offset;
scroll_states[index].velocity = 0;
scroll_states[index].viewport_extent = viewport.height;
scroll_states[index].content_extent = content_extent;
scroll_states[index].offset_y = next_offset;
scroll_states[index].velocity_y = 0;
scroll_states[index].viewport_extent_y = viewport.height;
scroll_states[index].content_extent_y = content_extent;
scroll_states[index].offset_x = next_offset_x;
scroll_states[index].velocity_x = 0;
scroll_states[index].viewport_extent_x = viewport.width;
scroll_states[index].content_extent_x = content_extent_x;
}
}
}
@@ -972,19 +1058,93 @@ pub fn canvasWidgetLayoutScrollContentExtent(nodes: []const canvas.WidgetLayoutN
const offset = scroll_node.widget.value;
var bottom = viewport.maxY();
var index = scroll_index + 1;
while (index < nodes.len and nodes[index].depth > scroll_depth) : (index += 1) {
while (index < nodes.len and nodes[index].depth > scroll_depth) {
// A subtree anchored DIRECTLY to the scroll region stays
// stationary while content scrolls (its anchor base never
// moves), so `frame + offset` is not a content-space position
// for it — counting it would grow the scroll range on every
// scroll, an unbounded feedback loop into blank space. Deeper
// anchored subtrees translate with their in-content anchors and
// keep their historical (constant) contribution.
if (nodes[index].widget.layout.anchor != null and nodes[index].parent_index == scroll_index) {
index = skipCanvasWidgetSubtree(nodes, index);
continue;
}
bottom = @max(bottom, nodes[index].frame.maxY() + offset);
index += 1;
}
return @max(0, bottom - viewport.y);
}
pub fn translateCanvasWidgetLayoutScrollDescendants(nodes: []canvas.WidgetLayoutNode, scroll_index: usize, dy: f32) void {
/// The horizontal counterpart of `canvasWidgetLayoutScrollContentExtent`:
/// how far the region's descendants reach rightward, rebased to offset 0.
/// Virtualized regions never scroll horizontally, so their horizontal
/// content pins to the viewport width. Three subtree exclusions keep the
/// range honest — each names blank space the user could otherwise scroll
/// to (or live content they otherwise could not reach):
/// - ANCHORED floating subtrees are out of flow and window-clipped;
/// an open dropdown to the right of the viewport is not content;
/// - a NESTED CLIP SCOPE (scroll view, `clip_content` surface,
/// virtualized container) bounds its own children — its frame is
/// how far it reaches, whatever overflows inside it;
/// - a disclosure subtree counts only while SETTLED OPEN: concealed
/// content lays out at full size but cannot be revealed sideways,
/// while an open item's wide child is live and reachable.
pub fn canvasWidgetLayoutScrollContentExtentX(nodes: []const canvas.WidgetLayoutNode, scroll_index: usize, viewport: geometry.RectF) f32 {
if (scroll_index >= nodes.len) return 0;
const scroll_node = nodes[scroll_index];
if (scroll_node.widget.layout.virtualized) return viewport.width;
const layout = canvas.WidgetLayoutTree{ .nodes = nodes };
const scroll_depth = scroll_node.depth;
const offset = scroll_node.widget.value_x;
var right = viewport.maxX();
var index = scroll_index + 1;
while (index < nodes.len and nodes[index].depth > scroll_depth) {
const node = nodes[index];
if (node.widget.layout.anchor != null) {
index = skipCanvasWidgetSubtree(nodes, index);
continue;
}
right = @max(right, node.frame.maxX() + offset);
if (canvasWidgetClipsContent(node.widget) or node.widget.layout.virtualized) {
index = skipCanvasWidgetSubtree(nodes, index);
continue;
}
if (canvas.widgetKindDisclosureAnimated(node.widget.kind) and !canvas.disclosureSettledOpen(layout, index)) {
index = skipCanvasWidgetSubtree(nodes, index);
continue;
}
index += 1;
}
return @max(0, right - viewport.x);
}
/// The index just past `index`'s whole subtree.
fn skipCanvasWidgetSubtree(nodes: []const canvas.WidgetLayoutNode, index: usize) usize {
const subtree_depth = nodes[index].depth;
var next = index + 1;
while (next < nodes.len and nodes[next].depth > subtree_depth) : (next += 1) {}
return next;
}
/// Scrolled content carries its descendants — including floating
/// surfaces anchored to widgets INSIDE it — but a surface anchored to
/// the SCROLL REGION ITSELF stays put: its anchor base is the region's
/// own frame, which never moves when the content under it does (the
/// live-scroll translate applies the same rule).
pub fn translateCanvasWidgetLayoutScrollDescendants(nodes: []canvas.WidgetLayoutNode, scroll_index: usize, offset: geometry.OffsetF) void {
if (scroll_index >= nodes.len) return;
const scroll_depth = nodes[scroll_index].depth;
var index = scroll_index + 1;
while (index < nodes.len and nodes[index].depth > scroll_depth) : (index += 1) {
nodes[index].frame = nodes[index].frame.translate(geometry.OffsetF.init(0, dy));
while (index < nodes.len and nodes[index].depth > scroll_depth) {
const node = nodes[index];
if (node.widget.layout.anchor != null and node.parent_index == scroll_index) {
index = skipCanvasWidgetSubtree(nodes, index);
continue;
}
nodes[index].frame = nodes[index].frame.translate(offset);
nodes[index].widget.frame = nodes[index].frame;
index += 1;
}
}
@@ -58,7 +58,8 @@ test "layout install publishes native scroll drivers and suppresses engine scrol
// The install pushed one driver: region frame, rebased content extent
// (viewport 72, content 120 -> max offset 48 -> content height 120),
// the source offset, and set_offset (a fresh driver adopts it).
// the source offset, and the set-offset flags (a fresh driver
// adopts both axes).
try std.testing.expect(harness.null_platform.scroll_driver_set_count >= 1);
try std.testing.expectEqualStrings("canvas", harness.null_platform.scrollDriverLabel());
const drivers = harness.null_platform.scrollDrivers();
@@ -68,7 +69,7 @@ test "layout install publishes native scroll drivers and suppresses engine scrol
try std.testing.expectEqual(@as(f32, 180), drivers[0].content_size.width);
try std.testing.expectEqual(@as(f32, 120), drivers[0].content_size.height);
try std.testing.expectEqual(@as(f32, 24), drivers[0].offset_y);
try std.testing.expect(drivers[0].set_offset);
try std.testing.expect(drivers[0].set_offset_y);
// Edge behavior defaults off: the native scroller pins at the
// content edges unless the region (or the scroll-physics token)
// opts into rubber-band.
@@ -169,7 +170,7 @@ test "driver offsets scroll retained scroll views and pass through overscroll" {
try std.testing.expect(harness.runtime.invalidated);
try std.testing.expect(harness.runtime.pendingDirtyRegions().len >= 1);
// The driver owns physics: no engine velocity was introduced.
try std.testing.expectEqual(@as(f32, 0), harness.runtime.views[0].widget_scroll_states[0].velocity);
try std.testing.expectEqual(@as(f32, 0), harness.runtime.views[0].widget_scroll_states[0].velocity_y);
// Rubber-band overscroll passes through so the bounce is visible;
// the engine performs no kinetic recovery of its own.
@@ -251,9 +252,9 @@ test "driver offsets deliver canvas_widget_scroll observation events" {
} });
try std.testing.expectEqual(@as(u32, 1), app_state.scroll_event_count);
try std.testing.expectEqual(@as(canvas.ObjectId, 1), app_state.last_id);
try std.testing.expectEqual(@as(f32, 24), app_state.last_scroll.offset);
try std.testing.expectEqual(@as(f32, 72), app_state.last_scroll.viewport_extent);
try std.testing.expectEqual(@as(f32, 120), app_state.last_scroll.content_extent);
try std.testing.expectEqual(@as(f32, 24), app_state.last_scroll.offset_y);
try std.testing.expectEqual(@as(f32, 72), app_state.last_scroll.viewport_extent_y);
try std.testing.expectEqual(@as(f32, 120), app_state.last_scroll.content_extent_y);
// An echo of the applied offset changes nothing and observes nothing.
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_scroll_driver = .{
@@ -371,7 +372,7 @@ test "driver-scrolled offsets survive rebuilds until the source offset changes"
try std.testing.expectEqual(@as(f32, 40), retained.nodes[0].widget.value);
try std.testing.expect(harness.null_platform.scroll_driver_set_offset_count > pushes_before);
try std.testing.expectEqual(@as(f32, 40), harness.null_platform.scrollDrivers()[0].offset_y);
try std.testing.expect(harness.null_platform.scrollDrivers()[0].set_offset);
try std.testing.expect(harness.null_platform.scrollDrivers()[0].set_offset_y);
}
test "scroll drivers stay unpublished without platform support" {
@@ -580,3 +581,165 @@ test "a rebuild restores the retained scroll offset with translated descendants"
try std.testing.expectEqualDeep(geometry.RectF.init(0, 20, 180, 32), retained.findById(3).?.frame);
try std.testing.expectEqualDeep(geometry.RectF.init(0, 64, 180, 32), retained.findById(4).?.frame);
}
test "a two-axis region's driver carries both content dimensions and follows offset_x reports" {
const harness = try TestHarness().create(std.testing.allocator, .{});
defer harness.destroy(std.testing.allocator);
harness.null_platform.gpu_surfaces = true;
harness.null_platform.gpu_surface_scroll_drivers = true;
var app_state: PassiveApp = .{};
const app = app_state.app();
try harness.start(app);
_ = try harness.runtime.createView(.{
.window_id = 1,
.label = "canvas",
.kind = .gpu_surface,
.frame = geometry.RectF.init(10, 20, 180, 72),
});
// A BOTH-axes region: content reaches x = 460 and y = 120 inside a
// 180 x 72 viewport, so the driver's content size must exceed the
// frame on both dimensions.
const tiles = [_]canvas.Widget{
.{ .id = 2, .kind = .panel, .frame = geometry.RectF.init(0, 0, 140, 120) },
.{ .id = 3, .kind = .panel, .frame = geometry.RectF.init(320, 0, 140, 60) },
};
const region = canvas.Widget{
.id = 1,
.kind = .scroll_view,
.scroll_axes = .both,
.value = 12,
.value_x = 24,
.children = &tiles,
};
var nodes: [4]canvas.WidgetLayoutNode = undefined;
const layout = try canvas.layoutWidgetTree(region, geometry.RectF.init(0, 0, 180, 72), &nodes);
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout);
const drivers = harness.null_platform.scrollDrivers();
try std.testing.expectEqual(@as(usize, 1), drivers.len);
// Rebased: frame + (content - viewport) on each axis.
try std.testing.expectEqual(@as(f32, 460), drivers[0].content_size.width);
try std.testing.expectEqual(@as(f32, 120), drivers[0].content_size.height);
try std.testing.expectEqual(@as(f32, 24), drivers[0].offset_x);
try std.testing.expectEqual(@as(f32, 12), drivers[0].offset_y);
try std.testing.expect(drivers[0].set_offset_x);
try std.testing.expect(drivers[0].set_offset_y);
try std.testing.expect(drivers[0].scrolls_x);
try std.testing.expect(drivers[0].scrolls_y);
// A driver report with both offsets lands on both axes: the widget
// adopts the offsets and the descendants translate to match.
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_scroll_driver = .{
.window_id = 1,
.label = "canvas",
.driver_id = 1,
.offset_x = 60,
.offset_y = 30,
.timestamp_ns = 1_000_000_000,
} });
const retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
try std.testing.expectEqual(@as(f32, 60), retained.findById(1).?.widget.value_x);
try std.testing.expectEqual(@as(f32, 30), retained.findById(1).?.widget.value);
try std.testing.expectEqualDeep(geometry.RectF.init(-60, -30, 140, 120), retained.findById(2).?.frame);
}
test "a horizontal-only region's driver pins its vertical range" {
const harness = try TestHarness().create(std.testing.allocator, .{});
defer harness.destroy(std.testing.allocator);
harness.null_platform.gpu_surfaces = true;
harness.null_platform.gpu_surface_scroll_drivers = true;
var app_state: PassiveApp = .{};
const app = app_state.app();
try harness.start(app);
_ = try harness.runtime.createView(.{
.window_id = 1,
.label = "canvas",
.kind = .gpu_surface,
.frame = geometry.RectF.init(10, 20, 180, 72),
});
// Content is both WIDE and TALL, but the region grants only the
// horizontal axis: the driver's content height pins to the frame so
// the OS scroller can never travel vertically, and a stray vertical
// report must not displace content.
const tiles = [_]canvas.Widget{
.{ .id = 2, .kind = .panel, .frame = geometry.RectF.init(0, 0, 140, 200) },
.{ .id = 3, .kind = .panel, .frame = geometry.RectF.init(320, 0, 140, 60) },
};
const shelf = canvas.Widget{
.id = 1,
.kind = .scroll_view,
.scroll_axes = .horizontal,
.children = &tiles,
};
var nodes: [4]canvas.WidgetLayoutNode = undefined;
const layout = try canvas.layoutWidgetTree(shelf, geometry.RectF.init(0, 0, 180, 72), &nodes);
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout);
const drivers = harness.null_platform.scrollDrivers();
try std.testing.expectEqual(@as(usize, 1), drivers.len);
try std.testing.expectEqual(@as(f32, 460), drivers[0].content_size.width);
try std.testing.expectEqual(@as(f32, 72), drivers[0].content_size.height);
try std.testing.expect(drivers[0].scrolls_x);
try std.testing.expect(!drivers[0].scrolls_y);
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_scroll_driver = .{
.window_id = 1,
.label = "canvas",
.driver_id = 1,
.offset_x = 40,
.offset_y = 50,
.timestamp_ns = 1_000_000_000,
} });
const retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
try std.testing.expectEqual(@as(f32, 40), retained.findById(1).?.widget.value_x);
try std.testing.expectEqual(@as(f32, 0), retained.findById(1).?.widget.value);
try std.testing.expectEqualDeep(geometry.RectF.init(-40, 0, 140, 200), retained.findById(2).?.frame);
}
test "floating surfaces push occluders that block underlying drivers but not their own" {
const harness = try TestHarness().create(std.testing.allocator, .{});
defer harness.destroy(std.testing.allocator);
harness.null_platform.gpu_surfaces = true;
harness.null_platform.gpu_surface_scroll_drivers = true;
var app_state: PassiveApp = .{};
try harness.start(app_state.app());
_ = try harness.runtime.createView(.{
.window_id = 1,
.label = "canvas",
.kind = .gpu_surface,
.frame = geometry.RectF.init(10, 20, 180, 72),
});
// A horizontal shelf (id 1) with an ANCHORED popover above part of
// it (id 4); the popover holds its own scroll region (id 5). The
// popover occludes the shelf where they overlap, but never its own
// scroll region.
const nodes = [_]canvas.WidgetLayoutNode{
.{ .widget = .{ .id = 1, .kind = .scroll_view, .scroll_axes = .horizontal, .frame = geometry.RectF.init(0, 0, 180, 72) }, .frame = geometry.RectF.init(0, 0, 180, 72), .depth = 0 },
.{ .widget = .{ .id = 2, .kind = .panel, .frame = geometry.RectF.init(0, 0, 400, 60) }, .frame = geometry.RectF.init(0, 0, 400, 60), .depth = 1, .parent_index = 0 },
.{ .widget = .{ .id = 4, .kind = .popover, .frame = geometry.RectF.init(40, 10, 100, 50), .layout = .{ .anchor = .{ .placement = .below } } }, .frame = geometry.RectF.init(40, 10, 100, 50), .depth = 1, .parent_index = 0 },
.{ .widget = .{ .id = 5, .kind = .scroll_view, .frame = geometry.RectF.init(44, 14, 92, 42) }, .frame = geometry.RectF.init(44, 14, 92, 42), .depth = 2, .parent_index = 2 },
.{ .widget = .{ .id = 6, .kind = .panel, .frame = geometry.RectF.init(44, 14, 92, 200) }, .frame = geometry.RectF.init(44, 14, 92, 200), .depth = 3, .parent_index = 3 },
};
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", .{ .nodes = &nodes });
try std.testing.expectEqual(@as(usize, 1), harness.null_platform.scroll_occluder_count);
try std.testing.expectEqualDeep(geometry.RectF.init(40, 10, 100, 50), harness.null_platform.scroll_occluders[0].frame);
const drivers = harness.null_platform.scrollDrivers();
try std.testing.expectEqual(@as(usize, 2), drivers.len);
// The shelf is blocked by the popover (bit 0)...
try std.testing.expectEqual(@as(u64, 1), drivers[0].id);
try std.testing.expectEqual(@as(u32, 1), drivers[0].occluder_mask);
// ...while the popover's own scroll region is exempt.
try std.testing.expectEqual(@as(u64, 5), drivers[1].id);
try std.testing.expectEqual(@as(u32, 0), drivers[1].occluder_mask);
// And the nested chain rides the parent ids.
try std.testing.expectEqual(@as(u64, 0), drivers[0].parent_id);
try std.testing.expectEqual(@as(u64, 1), drivers[1].parent_id);
}
+151 -17
View File
@@ -17,10 +17,12 @@
//! record and the existing "runtime offset wins until the source
//! changes" rebuild reconciliation keeps working unchanged.
//!
//! `set_offset` is only forced when the runtime's offset diverged from
//! the last driver-reported offset (keyboard scroll, automation wheel,
//! source-side programmatic scroll, clamp after content shrink): pushing
//! unconditionally would snap the OS scroller back mid-gesture.
//! `set_offset_x`/`set_offset_y` are only forced when that axis's
//! runtime offset diverged from the last driver-reported offset
//! (keyboard scroll, automation wheel, source-side programmatic scroll,
//! clamp after content shrink): pushing unconditionally — or pushing
//! the OTHER axis along for the ride — would snap the OS scroller back
//! mid-gesture.
const std = @import("std");
const geometry = @import("geometry");
@@ -66,8 +68,53 @@ pub fn RuntimeCanvasWidgetScrollDrivers(comptime Runtime: type) type {
var drivers: [platform.max_gpu_surface_scroll_drivers]platform.GpuSurfaceScrollDriver = undefined;
var ids: [platform.max_gpu_surface_scroll_drivers]u64 = undefined;
var offsets: [platform.max_gpu_surface_scroll_drivers]f32 = undefined;
var offsets: [platform.max_gpu_surface_scroll_drivers]geometry.OffsetF = undefined;
var count: usize = 0;
// Occluders: floating surfaces and modal catchers that
// hit-block regions beneath them, so the host's geometric
// wheel routing declines exactly the points the engine's
// hit test would give to an overlay's branch. Anchored
// TOOLTIPS are excluded (they pass hit-testing through),
// as are surfaces hidden by an ancestor or concealed by a
// closed disclosure — the same visibility rules the hit
// test applies. Frames honor the widget's render transform
// (a sliding popover blocks where it is SEEN), and a modal
// catcher spans the whole VIEW-LOCAL canvas only when it is
// actually modal (`scrim` — the false case is the inline
// preview, which floats over nothing). More surfaces than
// the budget fails SAFE: one whole-view occluder blocking
// every driver, so every wheel rides the wire and the
// engine's real hit test decides.
var occluders: [platform.max_gpu_surface_scroll_occluders]platform.GpuSurfaceScrollOccluder = undefined;
var occluder_nodes: [platform.max_gpu_surface_scroll_occluders]usize = undefined;
var occluder_count: usize = 0;
var occluder_overflow = false;
const layout_tree = view.widgetLayoutTree();
const view_local = geometry.RectF.init(0, 0, view.frame.normalized().width, view.frame.normalized().height);
for (view.widget_layout_nodes[0..view.widget_layout_node_count], 0..) |*node, node_index| {
const anchored_surface = node.widget.layout.anchor != null and node.widget.kind != .tooltip;
const modal_surface = switch (node.widget.kind) {
.dialog, .drawer, .sheet => node.widget.scrim,
else => false,
};
if (!anchored_surface and !modal_surface) continue;
if (node.widget.semantics.hidden) continue;
if (canvas.isWidgetHiddenInAncestors(layout_tree, node_index)) continue;
if (canvas.isWidgetConcealedByDisclosure(layout_tree, node_index)) continue;
const frame = if (modal_surface) view_local else transformedOccluderFrame(node.*);
if (frame.isEmpty()) continue;
if (occluder_count >= occluders.len) {
occluder_overflow = true;
break;
}
occluders[occluder_count] = .{ .frame = frame };
occluder_nodes[occluder_count] = node_index;
occluder_count += 1;
}
if (occluder_overflow) {
occluders[0] = .{ .frame = view_local };
occluder_count = 1;
}
for (view.widget_layout_nodes[0..view.widget_layout_node_count], 0..) |*node, node_index| {
if (!canvasWidgetScrollDriverEligible(node.*)) continue;
node.widget.native_scroll = true;
@@ -80,30 +127,52 @@ pub fn RuntimeCanvasWidgetScrollDrivers(comptime Runtime: type) type {
// Content extent is viewport-relative; rebase it onto the
// full region frame so the native max offset
// (content_height - frame.height) matches the engine's
// (content_extent - viewport.height).
const content_height = frame.height + @max(0, content_extent - viewport.height);
const offset = node.widget.value;
// (content_extent - viewport.height). Each dimension
// exceeds the frame only on an axis the region grants;
// everywhere else it pins to the frame so the OS
// scroller cannot travel along a revoked axis (a
// horizontal-only shelf with tall content must not
// accept vertical wheel motion natively).
const content_height = if (canvas.widgetScrollsAxis(node.widget, .vertical))
frame.height + @max(0, content_extent - viewport.height)
else
frame.height;
const content_width = if (canvas.widgetScrollsAxis(node.widget, .horizontal))
frame.width + @max(0, view.canvasWidgetScrollContentExtentX(node_index, viewport) - viewport.width)
else
frame.width;
const offset = geometry.OffsetF.init(
if (canvas.widgetScrollsAxis(node.widget, .horizontal)) node.widget.value_x else 0,
if (canvas.widgetScrollsAxis(node.widget, .vertical)) node.widget.value else 0,
);
const tracked = trackedScrollDriverOffset(view, node.widget.id);
const push = tracked == null or @abs(tracked.? - offset) > scroll_driver_offset_epsilon;
const push_x = tracked == null or @abs(tracked.?.dx - offset.dx) > scroll_driver_offset_epsilon;
const push_y = tracked == null or @abs(tracked.?.dy - offset.dy) > scroll_driver_offset_epsilon;
drivers[count] = .{
.id = node.widget.id,
.parent_id = nearestAncestorDriverId(view, node_index),
.occluder_mask = if (occluder_overflow) 1 else driverOccluderMask(view, node_index, occluder_nodes[0..occluder_count]),
.frame = frame,
.content_size = .{ .width = frame.width, .height = content_height },
.offset_y = offset,
.set_offset = push,
.content_size = .{ .width = content_width, .height = content_height },
.offset_x = offset.dx,
.offset_y = offset.dy,
.set_offset_x = push_x,
.set_offset_y = push_y,
// Per-region edge behavior, resolved the same way the
// engine physics resolve it (region override onto the
// scroll-physics token): off pins the OS scroller at
// the content edges, on lets it bounce.
.rubber_band = canvas.widgetScrollPhysics(node.widget, view.widget_tokens.scroll).overscroll == .rubber_band,
.scrolls_x = canvas.widgetScrollsAxis(node.widget, .horizontal),
.scrolls_y = canvas.widgetScrollsAxis(node.widget, .vertical),
};
ids[count] = node.widget.id;
offsets[count] = offset;
count += 1;
}
self.options.platform.services.setGpuSurfaceScrollDrivers(view.window_id, view.label, drivers[0..count]) catch |err| {
self.options.platform.services.setGpuSurfaceScrollDrivers(view.window_id, view.label, drivers[0..count], occluders[0..occluder_count]) catch |err| {
if (err != error.UnsupportedService) {
scroll_driver_log.warn("scroll driver sync failed for view '{s}': {s}", .{ view.label, @errorName(err) });
}
@@ -122,10 +191,10 @@ pub fn RuntimeCanvasWidgetScrollDrivers(comptime Runtime: type) type {
const index = runtimeFindViewIndex(self, event.window_id, event.label) orelse return;
if (self.views[index].kind != .gpu_surface) return;
self.views[index].recordGpuSurfaceInputTimestamp(event.timestamp_ns);
recordScrollDriverOffset(&self.views[index], event.driver_id, event.offset_y);
recordScrollDriverOffset(&self.views[index], event.driver_id, geometry.OffsetF.init(event.offset_x, event.offset_y));
const node_index = self.views[index].canvasWidgetNodeIndexById(event.driver_id) orelse return;
const dirty = try self.views[index].applyCanvasWidgetScrollDriverOffset(node_index, event.offset_y) orelse return;
const dirty = try self.views[index].applyCanvasWidgetScrollDriverOffset(node_index, event.offset_x, event.offset_y) orelse return;
const previous_cursor = self.views[index].canvas_widget_cursor;
try CanvasWidgetEventMethods().reconcileCanvasWidgetRenderStateAfterScrollWithTooltipIntent(self, index, null);
@@ -165,14 +234,79 @@ pub fn canvasWidgetScrollDriverEligible(node: canvas.WidgetLayoutNode) bool {
return true;
}
fn trackedScrollDriverOffset(view: anytype, driver_id: u64) ?f32 {
/// Which occluders block this driver. An occluder never blocks:
/// - ITSELF (a directly anchored scroll region is its own surface);
/// - its own subtree (a scroll region inside an open popover or
/// modal is above the surface, not beneath it);
/// - a driver whose anchored ROOT paints LATER in the floating pass
/// (anchored surfaces paint in tree order above all in-flow
/// content, so a scroll region inside the topmost of two
/// overlapping popovers sits above the lower one).
fn driverOccluderMask(view: anytype, driver_node: usize, occluder_nodes: []const usize) u32 {
const driver_anchor_root = anchoredRootIndex(view, driver_node);
var mask: u32 = 0;
for (occluder_nodes, 0..) |occluder_node, bit| {
if (occluder_node == driver_node) continue;
if (driver_anchor_root) |root| {
if (occluder_node == root) continue;
if (root > occluder_node) continue;
}
if (nodeIsAncestor(view, occluder_node, driver_node)) continue;
mask |= @as(u32, 1) << @intCast(bit);
}
return mask;
}
/// The render transform honored: the occluder blocks where the surface
/// is SEEN (the transformed frame's axis-aligned bounds), not where it
/// was laid out.
fn transformedOccluderFrame(node: canvas.WidgetLayoutNode) geometry.RectF {
const frame = node.frame.normalized();
if (frame.isEmpty()) return frame;
const transform = node.widget.transform;
if (std.meta.eql(transform, canvas.Affine.identity())) return frame;
return transform.transformRect(frame).normalized();
}
/// The nearest self-or-ancestor node that is an anchored floating root,
/// or null for in-flow content.
fn anchoredRootIndex(view: anytype, node_index: usize) ?usize {
var current: ?usize = node_index;
while (current) |index| {
if (view.widget_layout_nodes[index].widget.layout.anchor != null) return index;
current = view.widget_layout_nodes[index].parent_index;
}
return null;
}
fn nodeIsAncestor(view: anytype, ancestor: usize, node: usize) bool {
var current = view.widget_layout_nodes[node].parent_index;
while (current) |index| {
if (index == ancestor) return true;
current = view.widget_layout_nodes[index].parent_index;
}
return false;
}
/// The widget id of the nearest ancestor node that is itself
/// driver-eligible, or 0 at the top of the scrollable chain.
fn nearestAncestorDriverId(view: anytype, node_index: usize) u64 {
var current = view.widget_layout_nodes[node_index].parent_index;
while (current) |index| {
if (canvasWidgetScrollDriverEligible(view.widget_layout_nodes[index])) return view.widget_layout_nodes[index].widget.id;
current = view.widget_layout_nodes[index].parent_index;
}
return 0;
}
fn trackedScrollDriverOffset(view: anytype, driver_id: u64) ?geometry.OffsetF {
for (view.scroll_driver_ids[0..view.scroll_driver_count], 0..) |id, index| {
if (id == driver_id) return view.scroll_driver_offsets[index];
}
return null;
}
fn recordScrollDriverOffset(view: anytype, driver_id: u64, offset: f32) void {
fn recordScrollDriverOffset(view: anytype, driver_id: u64, offset: geometry.OffsetF) void {
for (view.scroll_driver_ids[0..view.scroll_driver_count], 0..) |id, index| {
if (id != driver_id) continue;
view.scroll_driver_offsets[index] = offset;
+435 -23
View File
@@ -243,15 +243,15 @@ test "runtime dispatches canvas widget scroll events for wheel and kinetic scrol
try std.testing.expectEqual(@as(u32, 1), app_state.scroll_event_count);
try std.testing.expectEqual(@as(canvas.ObjectId, 1), app_state.last_id);
try std.testing.expectEqual(@as(f32, 24), app_state.last_scroll.offset);
try std.testing.expectEqual(@as(f32, 72), app_state.last_scroll.viewport_extent);
try std.testing.expectEqual(@as(f32, 120), app_state.last_scroll.content_extent);
try std.testing.expectEqual(@as(f32, 48), app_state.last_scroll.maxOffset());
try std.testing.expectEqual(@as(f32, 24), app_state.last_scroll.offset_y);
try std.testing.expectEqual(@as(f32, 72), app_state.last_scroll.viewport_extent_y);
try std.testing.expectEqual(@as(f32, 120), app_state.last_scroll.content_extent_y);
try std.testing.expectEqual(@as(f32, 48), app_state.last_scroll.axis(.vertical).maxOffset());
// The wheel left momentum; the first frame after input skips the
// kinetic step (pending-input frame), the second one steps it and
// delivers a fresh event with the advanced offset.
try std.testing.expect(harness.runtime.views[0].widget_scroll_states[0].velocity > 0);
try std.testing.expect(harness.runtime.views[0].widget_scroll_states[0].velocity_y > 0);
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{
.window_id = 1,
.label = "canvas",
@@ -273,10 +273,10 @@ test "runtime dispatches canvas widget scroll events for wheel and kinetic scrol
} });
try std.testing.expectEqual(@as(u32, 2), app_state.scroll_event_count);
try std.testing.expectEqual(@as(canvas.ObjectId, 1), app_state.last_id);
try std.testing.expect(app_state.last_scroll.offset > 24);
try std.testing.expect(app_state.last_scroll.offset_y > 24);
try std.testing.expectEqual(
harness.runtime.views[0].widget_layout_nodes[0].widget.value,
app_state.last_scroll.offset,
app_state.last_scroll.offset_y,
);
}
@@ -398,7 +398,7 @@ test "runtime wheel input scrolls retained canvas scroll views" {
}
try std.testing.expect(saw_scrolled_button);
try std.testing.expect(harness.runtime.views[0].widget_scroll_states[0].velocity > 0);
try std.testing.expect(harness.runtime.views[0].widget_scroll_states[0].velocity_y > 0);
harness.runtime.invalidated = false;
harness.runtime.dirty_region_count = 0;
harness.null_platform.gpu_surface_frame_request_count = 0;
@@ -439,7 +439,7 @@ test "runtime wheel input scrolls retained canvas scroll views" {
try std.testing.expectApproxEqAbs(@as(f32, -47.04), kinetic_layout.nodes[1].frame.y, 0.01);
try std.testing.expectApproxEqAbs(@as(f32, -3.04), kinetic_layout.nodes[2].frame.y, 0.01);
try std.testing.expectApproxEqAbs(@as(f32, 40.96), kinetic_layout.nodes[3].frame.y, 0.01);
try std.testing.expect(harness.runtime.views[0].widget_scroll_states[0].velocity > 0);
try std.testing.expect(harness.runtime.views[0].widget_scroll_states[0].velocity_y > 0);
const kinetic_display_list = try harness.runtime.canvasDisplayList(1, "canvas");
var saw_kinetic_scrolled_button = false;
@@ -469,7 +469,7 @@ test "runtime wheel input scrolls retained canvas scroll views" {
try std.testing.expectApproxEqAbs(@as(f32, -48), kinetic_layout.nodes[1].frame.y, 0.01);
try std.testing.expectApproxEqAbs(@as(f32, -4), kinetic_layout.nodes[2].frame.y, 0.01);
try std.testing.expectApproxEqAbs(@as(f32, 40), kinetic_layout.nodes[3].frame.y, 0.01);
try std.testing.expectEqual(@as(f32, 0), harness.runtime.views[0].widget_scroll_states[0].velocity);
try std.testing.expectEqual(@as(f32, 0), harness.runtime.views[0].widget_scroll_states[0].velocity_y);
var settle_frame: usize = 0;
while (settle_frame < 48) : (settle_frame += 1) {
@@ -477,7 +477,7 @@ test "runtime wheel input scrolls retained canvas scroll views" {
harness.runtime.dirty_region_count = 0;
_ = try harness.runtime.stepCanvasWidgetKineticScroll(1, "canvas", 16);
kinetic_layout = try harness.runtime.canvasWidgetLayout(1, "canvas");
if (@abs(kinetic_layout.nodes[0].widget.value - 48) <= 0.01 and harness.runtime.views[0].widget_scroll_states[0].velocity == 0) break;
if (@abs(kinetic_layout.nodes[0].widget.value - 48) <= 0.01 and harness.runtime.views[0].widget_scroll_states[0].velocity_y == 0) break;
}
try std.testing.expect(settle_frame < 48);
@@ -485,7 +485,7 @@ test "runtime wheel input scrolls retained canvas scroll views" {
try std.testing.expectApproxEqAbs(@as(f32, -48), kinetic_layout.nodes[1].frame.y, 0.01);
try std.testing.expectApproxEqAbs(@as(f32, -4), kinetic_layout.nodes[2].frame.y, 0.01);
try std.testing.expectApproxEqAbs(@as(f32, 40), kinetic_layout.nodes[3].frame.y, 0.01);
try std.testing.expectEqual(@as(f32, 0), harness.runtime.views[0].widget_scroll_states[0].velocity);
try std.testing.expectEqual(@as(f32, 0), harness.runtime.views[0].widget_scroll_states[0].velocity_y);
const settled_revision = harness.runtime.views[0].widget_revision;
harness.runtime.invalidated = false;
@@ -745,13 +745,13 @@ test "runtime applies stored design token scroll physics" {
var retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
try std.testing.expectEqual(@as(f32, 20), retained.nodes[0].widget.value);
try std.testing.expectEqual(@as(f32, -20), retained.nodes[1].frame.y);
try std.testing.expectEqual(@as(f32, 80), harness.runtime.views[0].widget_scroll_states[0].velocity);
try std.testing.expectEqual(@as(f32, 80), harness.runtime.views[0].widget_scroll_states[0].velocity_y);
_ = try harness.runtime.stepCanvasWidgetKineticScroll(1, "canvas", 16);
retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
try std.testing.expectApproxEqAbs(@as(f32, 21.28), retained.nodes[0].widget.value, 0.001);
try std.testing.expectApproxEqAbs(@as(f32, -21.28), retained.nodes[1].frame.y, 0.001);
try std.testing.expectEqual(@as(f32, 80), harness.runtime.views[0].widget_scroll_states[0].velocity);
try std.testing.expectEqual(@as(f32, 80), harness.runtime.views[0].widget_scroll_states[0].velocity_y);
}
test "runtime refreshes hovered canvas widget after scroll clipping" {
@@ -959,7 +959,7 @@ test "runtime clears focused canvas widget after kinetic scroll clipping" {
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout);
try harness.runtime.focusView(1, "canvas");
harness.runtime.views[0].canvas_widget_focused_id = 2;
harness.runtime.views[0].widget_scroll_states[0].velocity = 2500;
harness.runtime.views[0].widget_scroll_states[0].velocity_y = 2500;
_ = try harness.runtime.emitCanvasWidgetDisplayList(1, "canvas", .{});
harness.runtime.invalidated = false;
@@ -1129,7 +1129,7 @@ test "runtime reconciles canvas widget scroll momentum across layout replacement
.y = 20,
.delta_y = 24,
} });
try std.testing.expect(harness.runtime.views[0].widget_scroll_states[0].velocity > 0);
try std.testing.expect(harness.runtime.views[0].widget_scroll_states[0].velocity_y > 0);
const scrolled = try harness.runtime.canvasWidgetLayout(1, "canvas");
const current_offset = scrolled.findById(1).?.widget.value;
@@ -1152,7 +1152,7 @@ test "runtime reconciles canvas widget scroll momentum across layout replacement
const refreshed = try harness.runtime.canvasWidgetLayout(1, "canvas");
try std.testing.expectEqual(@as(f32, 24), refreshed.findById(1).?.widget.value);
try std.testing.expect(harness.runtime.views[0].widget_scroll_states[2].velocity > 0);
try std.testing.expect(harness.runtime.views[0].widget_scroll_states[2].velocity_y > 0);
harness.runtime.invalidated = false;
harness.runtime.dirty_region_count = 0;
@@ -1225,8 +1225,8 @@ test "runtime clamps canvas scroll offset after layout replacement shrinks conte
retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
try std.testing.expectEqual(@as(f32, 0), retained.findById(1).?.widget.value);
try std.testing.expectEqual(@as(f32, 0), retained.findById(2).?.frame.y);
try std.testing.expectEqual(@as(f32, 0), harness.runtime.views[0].widget_scroll_states[0].offset);
try std.testing.expectEqual(@as(f32, 0), harness.runtime.views[0].widget_scroll_states[0].velocity);
try std.testing.expectEqual(@as(f32, 0), harness.runtime.views[0].widget_scroll_states[0].offset_y);
try std.testing.expectEqual(@as(f32, 0), harness.runtime.views[0].widget_scroll_states[0].velocity_y);
const snapshot = harness.runtime.automationSnapshot("Widgets");
try std.testing.expectEqual(@as(usize, 2), snapshot.widgets.len);
@@ -1324,8 +1324,8 @@ test "runtime chains wheel input from saturated nested canvas scroll views" {
try std.testing.expectEqualDeep(geometry.RectF.init(0, -60, 180, 32), retained.nodes[2].frame);
try std.testing.expectEqualDeep(geometry.RectF.init(0, -16, 180, 32), retained.nodes[3].frame);
try std.testing.expectEqualDeep(geometry.RectF.init(0, 96, 180, 32), retained.nodes[4].frame);
try std.testing.expect(harness.runtime.views[0].widget_scroll_states[0].velocity > 0);
try std.testing.expectEqual(@as(f32, 0), harness.runtime.views[0].widget_scroll_states[1].velocity);
try std.testing.expect(harness.runtime.views[0].widget_scroll_states[0].velocity_y > 0);
try std.testing.expectEqual(@as(f32, 0), harness.runtime.views[0].widget_scroll_states[1].velocity_y);
}
test "runtime leaves virtualized canvas scroll views app driven" {
@@ -1534,8 +1534,8 @@ test "engine wheel scrolls a windowed virtual list against its declared extent"
// The scroll state reports the DECLARED virtual extent (1000 x 20),
// not the four mounted rows.
const state = harness.runtime.views[0].canvasWidgetScrollStateById(1).?;
try std.testing.expectEqual(@as(f32, 20_000), state.content_extent);
try std.testing.expectEqual(@as(f32, 64), state.viewport_extent);
try std.testing.expectEqual(@as(f32, 20_000), state.content_extent_y);
try std.testing.expectEqual(@as(f32, 64), state.viewport_extent_y);
// A rebuild whose source offset overshoots the end clamps against
// the virtual extent (max offset 20_000 - 64).
@@ -1547,3 +1547,415 @@ test "engine wheel scrolls a windowed virtual list against its declared extent"
retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
try std.testing.expectEqual(@as(f32, 19_936), retained.findById(1).?.widget.value);
}
test "runtime wheel delta_x scrolls a horizontal scroll view and quiets its vertical axis" {
const TestApp = struct {
scroll_event_count: u32 = 0,
last_scroll: canvas.ScrollState = .{},
fn app(self: *@This()) App {
return .{ .context = self, .name = "gpu-widget-horizontal-scroll", .source = platform.WebViewSource.html("<h1>Hello</h1>"), .event_fn = event };
}
fn event(context: *anyopaque, runtime: *Runtime, event_value: Event) anyerror!void {
_ = runtime;
const self: *@This() = @ptrCast(@alignCast(context));
switch (event_value) {
.canvas_widget_scroll => |scroll_event| {
self.scroll_event_count += 1;
self.last_scroll = scroll_event.scroll;
},
else => {},
}
}
};
const harness = try TestHarness().create(std.testing.allocator, .{});
defer harness.destroy(std.testing.allocator);
harness.null_platform.gpu_surfaces = true;
var app_state: TestApp = .{};
const app = app_state.app();
try harness.start(app);
_ = try harness.runtime.createView(.{
.window_id = 1,
.label = "canvas",
.kind = .gpu_surface,
.frame = geometry.RectF.init(10, 20, 180, 72),
});
// A shelf: three fixed-width tiles in a row, reaching x = 460 inside
// a 180-wide viewport (max horizontal offset 280).
const tiles = [_]canvas.Widget{
.{ .id = 2, .kind = .panel, .frame = geometry.RectF.init(0, 0, 140, 60) },
.{ .id = 3, .kind = .panel, .frame = geometry.RectF.init(160, 0, 140, 60) },
.{ .id = 4, .kind = .panel, .frame = geometry.RectF.init(320, 0, 140, 60) },
};
const shelf = canvas.Widget{
.id = 1,
.kind = .scroll_view,
.scroll_axes = .horizontal,
.children = &tiles,
};
var nodes: [5]canvas.WidgetLayoutNode = undefined;
const layout = try canvas.layoutWidgetTree(shelf, geometry.RectF.init(0, 0, 180, 72), &nodes);
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout);
// A diagonal wheel: delta_x scrolls the shelf, delta_y dies (the
// region grants no vertical axis).
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{
.window_id = 1,
.label = "canvas",
.timestamp_ns = 1_000_000_000,
.kind = .scroll,
.x = 20,
.y = 20,
.delta_x = 24,
.delta_y = 16,
} });
const retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
try std.testing.expectEqual(@as(f32, 24), retained.findById(1).?.widget.value_x);
try std.testing.expectEqual(@as(f32, 0), retained.findById(1).?.widget.value);
try std.testing.expectEqualDeep(geometry.RectF.init(-24, 0, 140, 60), retained.findById(2).?.frame);
try std.testing.expectEqualDeep(geometry.RectF.init(136, 0, 140, 60), retained.findById(3).?.frame);
// The observation delivers the two-axis state: a live horizontal
// axis and a QUIET vertical one (content pinned to the viewport).
try std.testing.expectEqual(@as(u32, 1), app_state.scroll_event_count);
try std.testing.expectEqual(@as(f32, 24), app_state.last_scroll.offset_x);
try std.testing.expectEqual(@as(f32, 180), app_state.last_scroll.viewport_extent_x);
try std.testing.expectEqual(@as(f32, 460), app_state.last_scroll.content_extent_x);
try std.testing.expectEqual(@as(f32, 0), app_state.last_scroll.offset_y);
try std.testing.expectEqual(@as(f32, 72), app_state.last_scroll.content_extent_y);
try std.testing.expectEqual(@as(f32, 0), app_state.last_scroll.axis(.vertical).maxOffset());
// The wheel left horizontal momentum for the kinetic stepper.
try std.testing.expect(harness.runtime.views[0].widget_scroll_states[0].velocity_x > 0);
try std.testing.expectEqual(@as(f32, 0), harness.runtime.views[0].widget_scroll_states[0].velocity_y);
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{
.window_id = 1,
.label = "canvas",
.size = geometry.SizeF.init(180, 72),
.scale_factor = 1,
.frame_index = 1,
.timestamp_ns = 1_016_000_000,
.frame_interval_ns = 16_000_000,
} });
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{
.window_id = 1,
.label = "canvas",
.size = geometry.SizeF.init(180, 72),
.scale_factor = 1,
.frame_index = 2,
.timestamp_ns = 1_032_000_000,
.frame_interval_ns = 16_000_000,
} });
const stepped = try harness.runtime.canvasWidgetLayout(1, "canvas");
try std.testing.expect(stepped.findById(1).?.widget.value_x > 24);
try std.testing.expectEqual(@as(f32, 0), stepped.findById(1).?.widget.value);
}
test "nested regions route each wheel axis to the nearest ancestor scrolling that axis" {
const TestApp = struct {
fn app(self: *@This()) App {
return .{ .context = self, .name = "gpu-widget-nested-axis-routing", .source = platform.WebViewSource.html("<h1>Hello</h1>") };
}
};
const harness = try TestHarness().create(std.testing.allocator, .{});
defer harness.destroy(std.testing.allocator);
harness.null_platform.gpu_surfaces = true;
var app_state: TestApp = .{};
const app = app_state.app();
try harness.start(app);
_ = try harness.runtime.createView(.{
.window_id = 1,
.label = "canvas",
.kind = .gpu_surface,
.frame = geometry.RectF.init(10, 20, 180, 72),
});
// The reported nested shape: a HORIZONTAL timeline (id 1) holding a
// VERTICAL list (id 2) whose rows overflow its height. The wheel
// lands over the list.
const rows = [_]canvas.Widget{
.{ .id = 3, .kind = .button, .frame = geometry.RectF.init(0, 0, 120, 32), .text = "One" },
.{ .id = 4, .kind = .button, .frame = geometry.RectF.init(0, 44, 120, 32), .text = "Two" },
.{ .id = 5, .kind = .button, .frame = geometry.RectF.init(0, 88, 120, 32), .text = "Three" },
};
const inner = canvas.Widget{
.id = 2,
.kind = .scroll_view,
.frame = geometry.RectF.init(0, 0, 120, 72),
.children = &rows,
};
const spacer = canvas.Widget{ .id = 6, .kind = .panel, .frame = geometry.RectF.init(130, 0, 300, 60) };
const timeline = canvas.Widget{
.id = 1,
.kind = .scroll_view,
.scroll_axes = .horizontal,
.children = &.{ inner, spacer },
};
var nodes: [8]canvas.WidgetLayoutNode = undefined;
const layout = try canvas.layoutWidgetTree(timeline, geometry.RectF.init(0, 0, 180, 72), &nodes);
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout);
// One diagonal gesture over the inner list: delta_y scrolls the
// LIST (the nearest vertical scrollable), delta_x passes through it
// to the TIMELINE (the nearest horizontal scrollable).
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{
.window_id = 1,
.label = "canvas",
.timestamp_ns = 1_000_000_000,
.kind = .scroll,
.x = 20,
.y = 20,
.delta_x = 30,
.delta_y = 24,
} });
var retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
try std.testing.expectEqual(@as(f32, 24), retained.findById(2).?.widget.value);
try std.testing.expectEqual(@as(f32, 0), retained.findById(2).?.widget.value_x);
try std.testing.expectEqual(@as(f32, 30), retained.findById(1).?.widget.value_x);
try std.testing.expectEqual(@as(f32, 0), retained.findById(1).?.widget.value);
// The list's rows moved UP by the consumed delta_y AND left by the
// timeline's consumed delta_x (the list itself rides the timeline).
try std.testing.expectEqualDeep(geometry.RectF.init(-30, -24, 120, 32), retained.findById(3).?.frame);
// A purely horizontal follow-up over the same point keeps the list
// still and moves only the timeline.
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{
.window_id = 1,
.label = "canvas",
.timestamp_ns = 1_032_000_000,
.kind = .scroll,
.x = 20,
.y = 20,
.delta_x = 10,
} });
retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
try std.testing.expectEqual(@as(f32, 24), retained.findById(2).?.widget.value);
try std.testing.expectEqual(@as(f32, 40), retained.findById(1).?.widget.value_x);
}
test "horizontal-only scroll views take the whole keymap on their one axis" {
const TestApp = struct {
fn app(self: *@This()) App {
return .{ .context = self, .name = "gpu-widget-horizontal-keymap", .source = platform.WebViewSource.html("<h1>Hello</h1>") };
}
};
const harness = try TestHarness().create(std.testing.allocator, .{});
defer harness.destroy(std.testing.allocator);
harness.null_platform.gpu_surfaces = true;
var app_state: TestApp = .{};
const app = app_state.app();
try harness.start(app);
_ = try harness.runtime.createView(.{
.window_id = 1,
.label = "canvas",
.kind = .gpu_surface,
.frame = geometry.RectF.init(10, 20, 180, 72),
});
const tiles = [_]canvas.Widget{
.{ .id = 2, .kind = .panel, .frame = geometry.RectF.init(0, 0, 140, 60) },
.{ .id = 3, .kind = .panel, .frame = geometry.RectF.init(160, 0, 140, 60) },
.{ .id = 4, .kind = .panel, .frame = geometry.RectF.init(320, 0, 140, 60) },
};
const shelf = canvas.Widget{
.id = 1,
.kind = .scroll_view,
.scroll_axes = .horizontal,
.children = &tiles,
};
var nodes: [5]canvas.WidgetLayoutNode = undefined;
const layout = try canvas.layoutWidgetTree(shelf, geometry.RectF.init(0, 0, 180, 72), &nodes);
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout);
try harness.runtime.focusView(1, "canvas");
harness.runtime.views[0].canvas_widget_focused_id = 1;
// ArrowRight steps a line on the ONE axis the region grants: the
// viewport-width-derived step (max(24, 180 * 0.35) = 63).
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{
.window_id = 1,
.label = "canvas",
.kind = .key_down,
.key = "arrowright",
} });
var retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
try std.testing.expectEqual(@as(f32, 63), retained.findById(1).?.widget.value_x);
try std.testing.expectEqual(@as(f32, 0), retained.findById(1).?.widget.value);
// End jumps to the horizontal terminus (content 460 - viewport 180).
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{
.window_id = 1,
.label = "canvas",
.kind = .key_down,
.key = "end",
} });
retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
try std.testing.expectEqual(@as(f32, 280), retained.findById(1).?.widget.value_x);
try std.testing.expectEqualDeep(geometry.RectF.init(-280, 0, 140, 60), retained.findById(2).?.frame);
// Home returns to the origin.
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{
.window_id = 1,
.label = "canvas",
.kind = .key_down,
.key = "home",
} });
retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
try std.testing.expectEqual(@as(f32, 0), retained.findById(1).?.widget.value_x);
try std.testing.expectEqualDeep(geometry.RectF.init(0, 0, 140, 60), retained.findById(2).?.frame);
}
test "horizontal content extent counts only honestly reachable descendants" {
const TestApp = struct {
fn app(self: *@This()) App {
return .{ .context = self, .name = "gpu-widget-horizontal-extent", .source = platform.WebViewSource.html("<h1>Hello</h1>") };
}
};
const harness = try TestHarness().create(std.testing.allocator, .{});
defer harness.destroy(std.testing.allocator);
harness.null_platform.gpu_surfaces = true;
var app_state: TestApp = .{};
try harness.start(app_state.app());
_ = try harness.runtime.createView(.{
.window_id = 1,
.label = "canvas",
.kind = .gpu_surface,
.frame = geometry.RectF.init(10, 20, 180, 72),
});
// Hand-built pre-order layout under a horizontal region (id 1):
// - a NESTED CLIP SCOPE (scroll view, id 2) whose clipped child
// (id 3) reaches x = 500: the scope's own 120-wide frame is how
// far it reaches;
// - an ANCHORED floating surface (id 4) at x = 500..800: out of
// flow, window-clipped, never scrollable range;
// - a CLOSED accordion (id 5) whose concealed child (id 6)
// reaches x = 600: unreachable sideways;
// - an OPEN (settled) accordion (id 7) whose live child (id 8)
// reaches x = 460: the honest rightmost reach.
const nodes = [_]canvas.WidgetLayoutNode{
.{ .widget = .{ .id = 1, .kind = .scroll_view, .scroll_axes = .horizontal, .frame = geometry.RectF.init(0, 0, 180, 72) }, .frame = geometry.RectF.init(0, 0, 180, 72), .depth = 0 },
.{ .widget = .{ .id = 2, .kind = .scroll_view, .frame = geometry.RectF.init(0, 0, 120, 60) }, .frame = geometry.RectF.init(0, 0, 120, 60), .depth = 1, .parent_index = 0 },
.{ .widget = .{ .id = 3, .kind = .panel, .frame = geometry.RectF.init(0, 0, 500, 40) }, .frame = geometry.RectF.init(0, 0, 500, 40), .depth = 2, .parent_index = 1 },
.{ .widget = .{ .id = 4, .kind = .panel, .frame = geometry.RectF.init(500, 0, 300, 40), .layout = .{ .anchor = .{ .placement = .below } } }, .frame = geometry.RectF.init(500, 0, 300, 40), .depth = 1, .parent_index = 0 },
.{ .widget = .{ .id = 5, .kind = .accordion, .frame = geometry.RectF.init(0, 0, 160, 40), .state = .{ .selected = false } }, .frame = geometry.RectF.init(0, 0, 160, 40), .depth = 1, .parent_index = 0 },
.{ .widget = .{ .id = 6, .kind = .panel, .frame = geometry.RectF.init(8, 44, 600, 20) }, .frame = geometry.RectF.init(8, 44, 600, 20), .depth = 2, .parent_index = 4 },
.{ .widget = .{ .id = 7, .kind = .accordion, .frame = geometry.RectF.init(0, 0, 200, 72), .state = .{ .selected = true } }, .frame = geometry.RectF.init(0, 0, 200, 72), .depth = 1, .parent_index = 0 },
.{ .widget = .{ .id = 8, .kind = .panel, .frame = geometry.RectF.init(8, 40, 452, 20) }, .frame = geometry.RectF.init(8, 40, 452, 20), .depth = 2, .parent_index = 6 },
};
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", .{ .nodes = &nodes });
const state = harness.runtime.views[0].canvasWidgetScrollStateById(1).?;
try std.testing.expectEqual(@as(f32, 460), state.content_extent_x);
try std.testing.expectEqual(@as(f32, 180), state.viewport_extent_x);
}
test "a surface anchored to the scroll region itself never rides its content" {
const TestApp = struct {
fn app(self: *@This()) App {
return .{ .context = self, .name = "gpu-widget-anchored-scroll", .source = platform.WebViewSource.html("<h1>Hello</h1>") };
}
};
const harness = try TestHarness().create(std.testing.allocator, .{});
defer harness.destroy(std.testing.allocator);
harness.null_platform.gpu_surfaces = true;
var app_state: TestApp = .{};
const app = app_state.app();
try harness.start(app);
_ = try harness.runtime.createView(.{
.window_id = 1,
.label = "canvas",
.kind = .gpu_surface,
.frame = geometry.RectF.init(10, 20, 180, 72),
});
// A vertical region with tall content (id 2), a surface anchored to
// the REGION ITSELF (id 3 — its anchor base never moves when the
// content does), and a surface anchored to a widget INSIDE the
// content (id 4 — its anchor base scrolls, so it rides along).
const nodes = [_]canvas.WidgetLayoutNode{
.{ .widget = .{ .id = 1, .kind = .scroll_view, .frame = geometry.RectF.init(0, 0, 180, 72) }, .frame = geometry.RectF.init(0, 0, 180, 72), .depth = 0 },
.{ .widget = .{ .id = 2, .kind = .panel, .frame = geometry.RectF.init(0, 0, 160, 200) }, .frame = geometry.RectF.init(0, 0, 160, 200), .depth = 1, .parent_index = 0 },
.{ .widget = .{ .id = 4, .kind = .popover, .frame = geometry.RectF.init(20, 40, 100, 30), .layout = .{ .anchor = .{ .placement = .below } } }, .frame = geometry.RectF.init(20, 40, 100, 30), .depth = 2, .parent_index = 1 },
.{ .widget = .{ .id = 3, .kind = .popover, .frame = geometry.RectF.init(10, 72, 100, 30), .layout = .{ .anchor = .{ .placement = .below } } }, .frame = geometry.RectF.init(10, 72, 100, 30), .depth = 1, .parent_index = 0 },
};
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", .{ .nodes = &nodes });
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{
.window_id = 1,
.label = "canvas",
.timestamp_ns = 1_000_000_000,
.kind = .scroll,
.x = 20,
.y = 20,
.delta_y = 24,
} });
const retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
try std.testing.expectEqual(@as(f32, 24), retained.findById(1).?.widget.value);
// Content and the content-anchored surface moved up together...
try std.testing.expectEqualDeep(geometry.RectF.init(0, -24, 160, 200), retained.findById(2).?.frame);
try std.testing.expectEqualDeep(geometry.RectF.init(20, 16, 100, 30), retained.findById(4).?.frame);
// ...while the region-anchored surface stayed put.
try std.testing.expectEqualDeep(geometry.RectF.init(10, 72, 100, 30), retained.findById(3).?.frame);
}
test "assistive steps on a both-axes region page its live axis" {
const TestApp = struct {
fn app(self: *@This()) App {
return .{ .context = self, .name = "gpu-widget-both-axes-step", .source = platform.WebViewSource.html("<h1>Hello</h1>") };
}
};
const harness = try TestHarness().create(std.testing.allocator, .{});
defer harness.destroy(std.testing.allocator);
harness.null_platform.gpu_surfaces = true;
var app_state: TestApp = .{};
const app = app_state.app();
try harness.start(app);
_ = try harness.runtime.createView(.{
.window_id = 1,
.label = "canvas",
.kind = .gpu_surface,
.frame = geometry.RectF.init(10, 20, 180, 72),
});
// A BOTH-axes region whose content only overflows SIDEWAYS: the
// assistive increment must move the live (horizontal) axis instead
// of paging the zero-range vertical one and reporting success.
const sheet = canvas.Widget{
.id = 1,
.kind = .scroll_view,
.scroll_axes = .both,
.children = &[_]canvas.Widget{.{ .id = 2, .kind = .panel, .frame = geometry.RectF.init(0, 0, 500, 60) }},
};
var nodes: [3]canvas.WidgetLayoutNode = undefined;
const layout = try canvas.layoutWidgetTree(sheet, geometry.RectF.init(0, 0, 180, 72), &nodes);
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout);
try harness.runtime.focusView(1, "canvas");
harness.runtime.views[0].canvas_widget_focused_id = 1;
try dispatchAutomationWidgetAction(&harness.runtime, app, .{ .view_label = "canvas", .id = 1, .action = .increment });
var retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
// The both-keymap's horizontal line step: max(24, 180 * 0.35) = 63.
try std.testing.expectEqual(@as(f32, 63), retained.findById(1).?.widget.value_x);
try std.testing.expectEqual(@as(f32, 0), retained.findById(1).?.widget.value);
try dispatchAutomationWidgetAction(&harness.runtime, app, .{ .view_label = "canvas", .id = 1, .action = .decrement });
retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
try std.testing.expectEqual(@as(f32, 0), retained.findById(1).?.widget.value_x);
}
+5
View File
@@ -691,6 +691,7 @@ pub fn encodeEvent(event: platform.Event, buffer: []u8) JournalError![]const u8
try cursor.writeInt(u64, driver.window_id);
try cursor.writeStr(driver.label);
try cursor.writeInt(u64, driver.driver_id);
try cursor.writeF32(driver.offset_x);
try cursor.writeF32(driver.offset_y);
try cursor.writeInt(u64, driver.timestamp_ns);
},
@@ -937,11 +938,13 @@ pub fn decodeEvent(bytes: []const u8, storage: *EventDecodeStorage) JournalError
const window_id = try cursor.readInt(u64);
const label = try cursor.readStr();
const driver_id = try cursor.readInt(u64);
const offset_x = try cursor.readF32();
const offset_y = try cursor.readF32();
break :blk .{ .gpu_surface_scroll_driver = .{
.window_id = window_id,
.label = label,
.driver_id = driver_id,
.offset_x = offset_x,
.offset_y = offset_y,
.timestamp_ns = try cursor.readInt(u64),
} };
@@ -1528,10 +1531,12 @@ test "event codec round-trips every payload variant" {
const decoded = try roundTripEvent(.{ .gpu_surface_scroll_driver = .{
.label = "canvas",
.driver_id = 88,
.offset_x = 7.25,
.offset_y = -12.5,
.timestamp_ns = 4,
} });
try testing.expectEqual(@as(f32, -12.5), decoded.gpu_surface_scroll_driver.offset_y);
try testing.expectEqual(@as(f32, 7.25), decoded.gpu_surface_scroll_driver.offset_x);
}
{
const decoded = try roundTripEvent(.{ .context_menu_action = .{ .view_label = "canvas", .token = 5, .item_id = 2 } });
+43
View File
@@ -64,6 +64,13 @@ const SessionModel = struct {
/// re-derive the same enter/leave containment edges.
hover_enters: u32 = 0,
hover_leaves: u32 = 0,
/// The two-axis shelf's `on_scroll` mirror: a recorded diagonal
/// wheel must replay to the identical per-axis offsets — the
/// journal records only the raw scroll input event (both deltas),
/// and replay re-derives the same per-axis routing and physics.
shelf_offset_x: f32 = 0,
shelf_offset_y: f32 = 0,
shelf_scrolls: u32 = 0,
fn bodyText(self: *const SessionModel) []const u8 {
return self.body[0..self.body_len];
@@ -95,6 +102,7 @@ const SessionMsg = union(enum) {
exited: effects_mod.EffectExit,
tick: effects_mod.EffectTimer,
audio_event: effects_mod.EffectAudio,
shelf_scrolled: canvas.ScrollState,
};
const SessionApp = ui_app_mod.UiApp(SessionModel, SessionMsg);
@@ -122,6 +130,11 @@ fn sessionUpdate(model: *SessionModel, msg: SessionMsg, fx: *SessionApp.Effects)
.on_fire = SessionApp.Effects.timerMsg(.tick),
});
},
.shelf_scrolled => |scroll_state| {
model.shelf_offset_x = scroll_state.offset_x;
model.shelf_offset_y = scroll_state.offset_y;
model.shelf_scrolls += 1;
},
.start_audio => fx.playAudio(.{
.key = 9,
.path = "assets/session-track.mp3",
@@ -195,6 +208,10 @@ fn sessionView(ui: *SessionApp.Ui, model: *const SessionModel) SessionApp.Ui.Nod
ui.text(.{}, ui.fmt("Query {s} ({d}) Name {s} ({d})", .{ model.queryText(), model.query_edits, model.nameText(), model.name_edits })),
ui.text(.{}, ui.fmt("Zoom {d:.4} ({d}/{d})", .{ model.zoom, model.pinch_begins, model.pinch_ends })),
ui.text(.{}, ui.fmt("Hover {d}/{d}", .{ model.hover_enters, model.hover_leaves })),
// A BOTH-axes scroll region wider and taller than its viewport:
// the recorded diagonal wheel below scrolls it on both axes.
ui.scroll(.{ .height = 60, .axis = .both, .on_scroll = SessionApp.Ui.scrollMsg(.shelf_scrolled) }, ui.el(.panel, .{ .width = 600, .height = 200 }, .{})),
ui.text(.{}, ui.fmt("Shelf {d:.1},{d:.1} ({d})", .{ model.shelf_offset_x, model.shelf_offset_y, model.shelf_scrolls })),
ui.button(.{ .on_press = .increment, .on_hover_enter = .hover_enter, .on_hover_leave = .hover_leave }, "Increment"),
});
}
@@ -490,6 +507,28 @@ fn recordReferenceSession(gpa: std.mem.Allocator, buffer: *JournalBuffer, web_la
.y = 2,
} });
try harness.runtime.dispatchPlatformEvent(app, .frame_requested);
// A DIAGONAL wheel over the both-axes shelf: one recorded scroll
// input event carrying both deltas. Per-axis routing applies both
// (the shelf grants both axes), and the on_scroll observation
// mirrors the applied offsets into the model — replay must land on
// the identical pair from the raw journaled event alone.
var shelf_frame: ?geometry.RectF = null;
for ((try harness.runtime.canvasWidgetLayout(1, canvas_label)).nodes) |node| {
if (node.widget.kind == .scroll_view) shelf_frame = node.frame;
}
const shelf = shelf_frame.?;
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{
.window_id = 1,
.label = canvas_label,
.kind = .scroll,
.x = shelf.x + shelf.width * 0.5,
.y = shelf.y + shelf.height * 0.5,
.delta_x = 30,
.delta_y = 24,
} });
try harness.runtime.dispatchPlatformEvent(app, .frame_requested);
try std.testing.expectEqual(@as(f32, 30), app_state.model.shelf_offset_x);
try std.testing.expectEqual(@as(f32, 24), app_state.model.shelf_offset_y);
recorder.finish();
try std.testing.expect(!recorder.failed);
@@ -556,6 +595,10 @@ test "a recorded session replays to identical model state and fingerprints" {
// The hover moves dispatched exactly one containment pair.
try std.testing.expectEqual(@as(u32, 1), recorded.model.hover_enters);
try std.testing.expectEqual(@as(u32, 1), recorded.model.hover_leaves);
// The diagonal wheel reached both axes of the shelf.
try std.testing.expectEqual(@as(f32, 30), recorded.model.shelf_offset_x);
try std.testing.expectEqual(@as(f32, 24), recorded.model.shelf_offset_y);
try std.testing.expect(recorded.model.shelf_scrolls > 0);
const replayed = try replayIntoFreshApp(gpa, buffer.journalBytes(), true);
try std.testing.expect(replayed.report.ok());
+83 -42
View File
@@ -1001,10 +1001,13 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
virtual_windows: [canvas.max_virtual_windows]canvas.VirtualWindowRecord = [_]canvas.VirtualWindowRecord{.{}} ** canvas.max_virtual_windows,
virtual_window_count: usize = 0,
/// Scroll regions whose `on_reach_end` fired and has not re-armed
/// (the approach-end hysteresis state, keyed by widget id).
reach_end_fired_ids: [canvas.max_virtual_windows]canvas.ObjectId = [_]canvas.ObjectId{0} ** canvas.max_virtual_windows,
/// (the approach-end hysteresis state, keyed by widget id AND the
/// axis the reach was measured on: a region whose primary axis
/// changes — content growing sideways after a vertical fire —
/// must not have the stale axis's latch suppress the fresh one).
reach_end_fired: [max_reach_latches]ReachLatch = [_]ReachLatch{.{}} ** max_reach_latches,
/// The approach-START mirror (`on_reach_start` hysteresis).
reach_start_fired_ids: [canvas.max_virtual_windows]canvas.ObjectId = [_]canvas.ObjectId{0} ** canvas.max_virtual_windows,
reach_start_fired: [max_reach_latches]ReachLatch = [_]ReachLatch{.{}} ** max_reach_latches,
/// Retained offset tables for VARIABLE-extent virtual lists,
/// claimed per list identity during builds (`Ui.virtualWindow`
/// through the extent source) and patched by the post-layout
@@ -2157,6 +2160,36 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
return false;
}
/// One (id, axis) hysteresis latch: the axis rides along so a
/// region whose primary axis changes re-arms honestly. Two
/// slots per declarable window — the (id, axis) key space is
/// twice the id space.
const max_reach_latches = canvas.max_virtual_windows * 2;
const ReachLatch = struct {
id: canvas.ObjectId = 0,
axis: canvas.ScrollAxis = .vertical,
};
const ReachAxis = struct {
state: canvas.ScrollAxisState,
axis: canvas.ScrollAxis,
};
/// The axis reach-end/reach-start measure: the vertical axis
/// wherever it has scrollable range (every pre-axis region, so
/// existing apps see identical behavior), otherwise the
/// horizontal one — a horizontal timeline's `on-reach-end` is
/// its right edge. One rule for both signals so "the end" and
/// "the start" always name the same axis.
fn reachAxisState(scroll_state: canvas.ScrollState) ReachAxis {
const vertical = scroll_state.axis(.vertical);
if (vertical.maxOffset() > 0) return .{ .state = vertical, .axis = .vertical };
const horizontal = scroll_state.axis(.horizontal);
if (horizontal.maxOffset() > 0) return .{ .state = horizontal, .axis = .horizontal };
return .{ .state = vertical, .axis = .vertical };
}
/// Approach-end hysteresis (`on_reach_end`): fire when a scroll
/// lands within `reach_end_fire_ratio` viewports of the content
/// end and the region is armed; re-arm once the offset sits more
@@ -2165,37 +2198,43 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
/// under the unchanged offset. One Msg per approach, never a
/// fetch storm from a user riding the end of the list.
fn reachEndShouldFire(self: *Self, id: canvas.ObjectId, scroll_state: canvas.ScrollState) bool {
if (id == 0 or scroll_state.viewport_extent <= 0) return false;
const remaining = scroll_state.content_extent - scroll_state.viewport_extent - scroll_state.offset;
if (remaining > scroll_state.viewport_extent * reach_end_rearm_ratio) {
self.clearReachEndFired(id);
const reach = reachAxisState(scroll_state);
const axis_state = reach.state;
if (id == 0 or axis_state.viewport_extent <= 0) return false;
const remaining = axis_state.content_extent - axis_state.viewport_extent - axis_state.offset;
if (remaining > axis_state.viewport_extent * reach_end_rearm_ratio) {
self.clearReachEndFired(id, reach.axis);
return false;
}
if (remaining > scroll_state.viewport_extent * reach_end_fire_ratio) return false;
if (self.reachEndFired(id)) return false;
self.markReachEndFired(id);
return true;
if (remaining > axis_state.viewport_extent * reach_end_fire_ratio) return false;
if (self.reachEndFired(id, reach.axis)) return false;
// Fire only when the latch STORES: an unstorable latch
// (table full — a degenerate tree) would otherwise fire on
// every observation, the exact storm the hysteresis exists
// to prevent. Silence is the safer failure.
return self.markReachEndFired(id, reach.axis);
}
fn reachEndFired(self: *const Self, id: canvas.ObjectId) bool {
for (self.reach_end_fired_ids) |fired| {
if (fired == id) return true;
fn reachEndFired(self: *const Self, id: canvas.ObjectId, axis: canvas.ScrollAxis) bool {
for (self.reach_end_fired) |fired| {
if (fired.id == id and fired.axis == axis) return true;
}
return false;
}
fn markReachEndFired(self: *Self, id: canvas.ObjectId) void {
for (&self.reach_end_fired_ids) |*slot| {
if (slot.* == 0 or slot.* == id) {
slot.* = id;
return;
fn markReachEndFired(self: *Self, id: canvas.ObjectId, axis: canvas.ScrollAxis) bool {
for (&self.reach_end_fired) |*slot| {
if (slot.id == 0 or (slot.id == id and slot.axis == axis)) {
slot.* = .{ .id = id, .axis = axis };
return true;
}
}
return false;
}
fn clearReachEndFired(self: *Self, id: canvas.ObjectId) void {
for (&self.reach_end_fired_ids) |*slot| {
if (slot.* == id) slot.* = 0;
fn clearReachEndFired(self: *Self, id: canvas.ObjectId, axis: canvas.ScrollAxis) void {
for (&self.reach_end_fired) |*slot| {
if (slot.id == id and slot.axis == axis) slot.* = .{};
}
}
@@ -2210,37 +2249,40 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
/// only moves on scroll OBSERVATIONS, so a programmatic jump out
/// of the band re-arms on the next user scroll, not instantly.
fn reachStartShouldFire(self: *Self, id: canvas.ObjectId, scroll_state: canvas.ScrollState) bool {
if (id == 0 or scroll_state.viewport_extent <= 0) return false;
const remaining = scroll_state.offset;
if (remaining > scroll_state.viewport_extent * reach_start_rearm_ratio) {
self.clearReachStartFired(id);
const reach = reachAxisState(scroll_state);
const axis_state = reach.state;
if (id == 0 or axis_state.viewport_extent <= 0) return false;
const remaining = axis_state.offset;
if (remaining > axis_state.viewport_extent * reach_start_rearm_ratio) {
self.clearReachStartFired(id, reach.axis);
return false;
}
if (remaining > scroll_state.viewport_extent * reach_start_fire_ratio) return false;
if (self.reachStartFired(id)) return false;
self.markReachStartFired(id);
return true;
if (remaining > axis_state.viewport_extent * reach_start_fire_ratio) return false;
if (self.reachStartFired(id, reach.axis)) return false;
// Fire only when the latch stores (the reach-end rule).
return self.markReachStartFired(id, reach.axis);
}
fn reachStartFired(self: *const Self, id: canvas.ObjectId) bool {
for (self.reach_start_fired_ids) |fired| {
if (fired == id) return true;
fn reachStartFired(self: *const Self, id: canvas.ObjectId, axis: canvas.ScrollAxis) bool {
for (self.reach_start_fired) |fired| {
if (fired.id == id and fired.axis == axis) return true;
}
return false;
}
fn markReachStartFired(self: *Self, id: canvas.ObjectId) void {
for (&self.reach_start_fired_ids) |*slot| {
if (slot.* == 0 or slot.* == id) {
slot.* = id;
return;
fn markReachStartFired(self: *Self, id: canvas.ObjectId, axis: canvas.ScrollAxis) bool {
for (&self.reach_start_fired) |*slot| {
if (slot.id == 0 or (slot.id == id and slot.axis == axis)) {
slot.* = .{ .id = id, .axis = axis };
return true;
}
}
return false;
}
fn clearReachStartFired(self: *Self, id: canvas.ObjectId) void {
for (&self.reach_start_fired_ids) |*slot| {
if (slot.* == id) slot.* = 0;
fn clearReachStartFired(self: *Self, id: canvas.ObjectId, axis: canvas.ScrollAxis) void {
for (&self.reach_start_fired) |*slot| {
if (slot.id == id and slot.axis == axis) slot.* = .{};
}
}
@@ -5422,4 +5464,3 @@ const ContextMenuPin = struct {
window_id: ?platform.WindowId,
arena_index: usize,
};
+4 -4
View File
@@ -345,9 +345,9 @@ const FeedApp = ui_app_model.UiApp(FeedModel, FeedMsg);
fn feedUpdate(model: *FeedModel, msg: FeedMsg) void {
switch (msg) {
.feed_scrolled => |scroll_state| {
model.offset = scroll_state.offset;
model.viewport_extent = scroll_state.viewport_extent;
model.content_extent = scroll_state.content_extent;
model.offset = scroll_state.offset_y;
model.viewport_extent = scroll_state.viewport_extent_y;
model.content_extent = scroll_state.content_extent_y;
model.scroll_events += 1;
},
}
@@ -5226,7 +5226,7 @@ test "windowed virtual list scrolls, re-windows, budgets to the viewport, and fi
const final_layout = try harness.runtime.canvasWidgetLayout(1, canvas_label);
try std.testing.expect(final_layout.nodes.len < 40);
const scroll_state = harness.runtime.views[0].canvasWidgetScrollStateById(list_id).?;
try std.testing.expectEqual(@as(f32, 600 * virtual_row_extent), scroll_state.content_extent);
try std.testing.expectEqual(@as(f32, 600 * virtual_row_extent), scroll_state.content_extent_y);
// A window-growing resize converges within ONE rebuild: the first
// build pass reads the stale 300pt viewport, the coverage check sees
@@ -181,8 +181,10 @@ const Harness = struct {
return layout.findById(transcript_id).?;
}
fn scrollState(self: *Harness) canvas.ScrollState {
return self.harness.runtime.views[0].canvasWidgetScrollStateById(transcript_id).?;
/// The transcript's VERTICAL axis state — virtual lists are
/// vertical machinery, so every assertion here reads that axis.
fn scrollState(self: *Harness) canvas.ScrollAxisState {
return self.harness.runtime.views[0].canvasWidgetScrollStateById(transcript_id).?.axis(.vertical);
}
/// Screen-space frame of a row by logical index, from the RETAINED
+5 -2
View File
@@ -421,7 +421,7 @@ pub const RuntimeView = struct {
/// the last offset it reported (or was pushed), so the sync only
/// forces `set_offset` when a non-driver source moved the offset.
scroll_driver_ids: [platform.max_gpu_surface_scroll_drivers]u64 = undefined,
scroll_driver_offsets: [platform.max_gpu_surface_scroll_drivers]f32 = undefined,
scroll_driver_offsets: [platform.max_gpu_surface_scroll_drivers]geometry.OffsetF = undefined,
scroll_driver_count: usize = 0,
canvas_widget_focused_id: canvas.ObjectId = 0,
canvas_widget_focus_visible_id: canvas.ObjectId = 0,
@@ -632,16 +632,19 @@ pub const RuntimeView = struct {
pub const canvasWidgetKineticScrollActive = CanvasWidgetScrollMethods.canvasWidgetKineticScrollActive;
pub const applyCanvasWidgetScrollRoute = CanvasWidgetScrollMethods.applyCanvasWidgetScrollRoute;
pub const deepestCanvasWidgetScrollIndex = CanvasWidgetScrollMethods.deepestCanvasWidgetScrollIndex;
pub const deepestCanvasWidgetScrollIndexForAxis = CanvasWidgetScrollMethods.deepestCanvasWidgetScrollIndexForAxis;
pub const canvasWidgetScrollState = CanvasWidgetScrollMethods.canvasWidgetScrollState;
pub const canvasWidgetScrollStateById = CanvasWidgetScrollMethods.canvasWidgetScrollStateById;
pub const noteCanvasWidgetScrollEvent = CanvasWidgetScrollMethods.noteCanvasWidgetScrollEvent;
pub const canvasWidgetScrollCanConsume = CanvasWidgetScrollMethods.canvasWidgetScrollCanConsume;
pub const canvasWidgetScrollCanConsumeAxis = CanvasWidgetScrollMethods.canvasWidgetScrollCanConsumeAxis;
pub const applyCanvasWidgetScroll = CanvasWidgetScrollMethods.applyCanvasWidgetScroll;
pub const applyCanvasWidgetScrollAxis = CanvasWidgetScrollMethods.applyCanvasWidgetScrollAxis;
pub const applyCanvasWidgetTextareaScroll = CanvasWidgetScrollMethods.applyCanvasWidgetTextareaScroll;
pub const applyCanvasWidgetScrollDriverOffset = CanvasWidgetScrollMethods.applyCanvasWidgetScrollDriverOffset;
pub const applyCanvasWidgetScrollKeyboardTarget = CanvasWidgetScrollMethods.applyCanvasWidgetScrollKeyboardTarget;
pub const stepCanvasWidgetKineticScroll = CanvasWidgetScrollMethods.stepCanvasWidgetKineticScroll;
pub const canvasWidgetScrollContentExtent = CanvasWidgetScrollMethods.canvasWidgetScrollContentExtent;
pub const canvasWidgetScrollContentExtentX = CanvasWidgetScrollMethods.canvasWidgetScrollContentExtentX;
pub const translateCanvasWidgetScrollDescendants = CanvasWidgetScrollMethods.translateCanvasWidgetScrollDescendants;
pub const scrollCanvasTextInputCaretIntoView = CanvasWidgetScrollMethods.scrollCanvasTextInputCaretIntoView;
+219 -69
View File
@@ -23,6 +23,12 @@ fn canvasWidgetModelDrivenVirtual(widget: canvas.Widget) bool {
return widget.layout.virtualized and !canvas.widgetVirtualRuntimeScrolled(widget);
}
fn unionOptionalRects(a: ?geometry.RectF, b: ?geometry.RectF) ?geometry.RectF {
const first = a orelse return b;
const second = b orelse return first;
return unionRects(first, second);
}
pub fn RuntimeViewCanvasWidgetScroll(comptime RuntimeView: type) type {
return struct {
pub fn canvasWidgetKineticScrollActive(self: *const RuntimeView) bool {
@@ -33,31 +39,59 @@ pub fn RuntimeViewCanvasWidgetScroll(comptime RuntimeView: type) type {
const viewport = node.frame.inset(node.widget.layout.padding).normalized();
if (viewport.isEmpty()) continue;
const physics = canvas.widgetScrollPhysics(node.widget, self.widget_tokens.scroll);
if (self.canvasWidgetScrollState(index, node, viewport).needsKineticStep(physics)) return true;
const state = self.canvasWidgetScrollState(index, node, viewport);
if (canvas.widgetScrollsAxis(node.widget, .vertical) and state.axis(.vertical).needsKineticStep(physics)) return true;
if (canvas.widgetScrollsAxis(node.widget, .horizontal) and state.axis(.horizontal).needsKineticStep(physics)) return true;
}
return false;
}
pub fn applyCanvasWidgetScrollRoute(self: *RuntimeView, route: []const canvas.WidgetEventRouteEntry, delta_y: f32, source: CanvasWidgetScrollSource) anyerror!?geometry.RectF {
/// Route a wheel/trackpad scroll: EACH AXIS resolves
/// independently to the nearest ancestor scrollable on that
/// axis. A horizontal timeline holding a vertical list splits a
/// diagonal gesture — `dy` scrolls the list, `dx` reaches the
/// timeline — and a vertical-only tree behaves byte-identically
/// to the one-axis routing this generalizes (every scrollable
/// there grants the vertical axis and nothing grants the
/// horizontal one). Both axes route even at delta 0: a wheel
/// event has always overwritten the landing region's velocity,
/// so a purely horizontal gesture stills a vertical flick the
/// same way a zero-delta vertical wheel did.
pub fn applyCanvasWidgetScrollRoute(self: *RuntimeView, route: []const canvas.WidgetEventRouteEntry, delta: geometry.OffsetF, source: CanvasWidgetScrollSource) anyerror!?geometry.RectF {
const vertical = try applyCanvasWidgetScrollAxisRoute(self, route, .vertical, delta.dy, source);
const horizontal = try applyCanvasWidgetScrollAxisRoute(self, route, .horizontal, delta.dx, source);
return unionOptionalRects(vertical, horizontal);
}
fn applyCanvasWidgetScrollAxisRoute(self: *RuntimeView, route: []const canvas.WidgetEventRouteEntry, comptime axis: canvas.ScrollAxis, delta: f32, source: CanvasWidgetScrollSource) anyerror!?geometry.RectF {
var depth_limit: ?usize = null;
while (self.deepestCanvasWidgetScrollIndex(route, depth_limit)) |scroll_index| {
while (self.deepestCanvasWidgetScrollIndexForAxis(route, axis, depth_limit)) |scroll_index| {
if (canvasWidgetModelDrivenVirtual(self.widget_layout_nodes[scroll_index].widget)) return null;
const has_scroll_parent = self.deepestCanvasWidgetScrollIndex(route, self.widget_layout_nodes[scroll_index].depth) != null;
if (has_scroll_parent and !self.canvasWidgetScrollCanConsume(scroll_index, delta_y)) {
const has_scroll_parent = self.deepestCanvasWidgetScrollIndexForAxis(route, axis, self.widget_layout_nodes[scroll_index].depth) != null;
if (has_scroll_parent and !self.canvasWidgetScrollCanConsumeAxis(scroll_index, axis, delta)) {
depth_limit = self.widget_layout_nodes[scroll_index].depth;
continue;
}
if (try self.applyCanvasWidgetScroll(scroll_index, delta_y, source, !has_scroll_parent)) |dirty| return dirty;
if (try self.applyCanvasWidgetScrollAxis(scroll_index, axis, delta, source, !has_scroll_parent)) |dirty| return dirty;
depth_limit = self.widget_layout_nodes[scroll_index].depth;
}
return null;
}
pub fn deepestCanvasWidgetScrollIndex(self: *const RuntimeView, route: []const canvas.WidgetEventRouteEntry, depth_limit: ?usize) ?usize {
return self.deepestCanvasWidgetScrollIndexForAxis(route, .vertical, depth_limit);
}
/// The deepest routed widget that scrolls on `axis`. The axis
/// filter is what makes per-axis routing independent: a
/// vertical-only list is invisible to the horizontal walk, so
/// `dx` passes through it to the horizontal ancestor.
pub fn deepestCanvasWidgetScrollIndexForAxis(self: *const RuntimeView, route: []const canvas.WidgetEventRouteEntry, comptime axis: canvas.ScrollAxis, depth_limit: ?usize) ?usize {
var result: ?usize = null;
var result_depth: usize = 0;
for (route) |entry| {
if (!canvasWidgetScrollableKind(entry.kind) or entry.node_index >= self.widget_layout_node_count) continue;
if (!canvas.widgetScrollsAxis(self.widget_layout_nodes[entry.node_index].widget, axis)) continue;
const depth = self.widget_layout_nodes[entry.node_index].depth;
if (depth_limit) |limit| {
if (depth >= limit) continue;
@@ -100,50 +134,74 @@ pub fn RuntimeViewCanvasWidgetScroll(comptime RuntimeView: type) type {
return null;
}
/// The region's two-axis scroll state. An axis the region does
/// not grant is QUIET: offset and velocity 0 and the content
/// extent pinned to the viewport, so `on_scroll` consumers and
/// the routing/consume checks read `maxOffset() == 0` — never a
/// falsely scrollable inactive axis (a vertical list whose rows
/// happen to overhang sideways stays horizontally inert).
pub fn canvasWidgetScrollState(self: *const RuntimeView, scroll_index: usize, scroll_node: canvas.WidgetLayoutNode, viewport: geometry.RectF) canvas.ScrollState {
const retained = self.widget_scroll_states[scroll_index];
const vertical = canvas.widgetScrollsAxis(scroll_node.widget, .vertical);
const horizontal = canvas.widgetScrollsAxis(scroll_node.widget, .horizontal);
return .{
.offset = scroll_node.widget.value,
.velocity = retained.velocity,
.viewport_extent = viewport.height,
.content_extent = self.canvasWidgetScrollContentExtent(scroll_index, viewport),
.offset_y = if (vertical) scroll_node.widget.value else 0,
.offset_x = if (horizontal) scroll_node.widget.value_x else 0,
.velocity_y = if (vertical) retained.velocity_y else 0,
.velocity_x = if (horizontal) retained.velocity_x else 0,
.viewport_extent_y = viewport.height,
.viewport_extent_x = viewport.width,
.content_extent_y = if (vertical) self.canvasWidgetScrollContentExtent(scroll_index, viewport) else viewport.height,
.content_extent_x = if (horizontal) self.canvasWidgetScrollContentExtentX(scroll_index, viewport) else viewport.width,
};
}
pub fn canvasWidgetScrollCanConsume(self: *const RuntimeView, scroll_index: usize, delta_y: f32) bool {
if (scroll_index >= self.widget_layout_node_count or delta_y == 0) return false;
pub fn canvasWidgetScrollCanConsumeAxis(self: *const RuntimeView, scroll_index: usize, comptime axis: canvas.ScrollAxis, delta: f32) bool {
if (scroll_index >= self.widget_layout_node_count or delta == 0) return false;
const scroll_node = self.widget_layout_nodes[scroll_index];
if (!canvasWidgetScrollableKind(scroll_node.widget.kind)) return false;
if (canvasWidgetModelDrivenVirtual(scroll_node.widget)) return false;
if (!canvas.widgetScrollsAxis(scroll_node.widget, axis)) return false;
if (scroll_node.widget.kind == .textarea) {
const max_offset = canvas.textInputMaxScrollOffsetForWidget(scroll_node.widget, self.widget_tokens);
if (max_offset <= 0) return false;
const current_offset = std.math.clamp(scroll_node.widget.value, 0, max_offset);
return if (delta_y > 0) current_offset < max_offset else current_offset > 0;
return if (delta > 0) current_offset < max_offset else current_offset > 0;
}
const viewport = scroll_node.frame.inset(scroll_node.widget.layout.padding).normalized();
if (viewport.isEmpty()) return false;
const current = self.canvasWidgetScrollState(scroll_index, scroll_node, viewport);
const current = self.canvasWidgetScrollState(scroll_index, scroll_node, viewport).axis(axis);
const max_offset = current.maxOffset();
if (current.offset < 0) return delta_y > 0;
if (current.offset > max_offset) return delta_y < 0;
return if (delta_y > 0) current.offset < max_offset else current.offset > 0;
if (current.offset < 0) return delta > 0;
if (current.offset > max_offset) return delta < 0;
return if (delta > 0) current.offset < max_offset else current.offset > 0;
}
pub fn applyCanvasWidgetScroll(self: *RuntimeView, scroll_index: usize, delta_y: f32, source: CanvasWidgetScrollSource, allow_rubberband: bool) anyerror!?geometry.RectF {
pub fn applyCanvasWidgetScroll(self: *RuntimeView, scroll_index: usize, delta: geometry.OffsetF, source: CanvasWidgetScrollSource, allow_rubberband: bool) anyerror!?geometry.RectF {
const vertical = try self.applyCanvasWidgetScrollAxis(scroll_index, .vertical, delta.dy, source, allow_rubberband);
const horizontal = try self.applyCanvasWidgetScrollAxis(scroll_index, .horizontal, delta.dx, source, allow_rubberband);
return unionOptionalRects(vertical, horizontal);
}
pub fn applyCanvasWidgetScrollAxis(self: *RuntimeView, scroll_index: usize, comptime axis: canvas.ScrollAxis, delta: f32, source: CanvasWidgetScrollSource, allow_rubberband: bool) anyerror!?geometry.RectF {
if (scroll_index >= self.widget_layout_node_count) return null;
const scroll_node = self.widget_layout_nodes[scroll_index];
if (!canvasWidgetScrollableKind(scroll_node.widget.kind)) return null;
if (scroll_node.widget.kind == .textarea) return self.applyCanvasWidgetTextareaScroll(scroll_index, delta_y, source);
if (scroll_node.widget.kind == .textarea) {
if (axis != .vertical) return null;
return self.applyCanvasWidgetTextareaScroll(scroll_index, delta, source);
}
if (canvasWidgetModelDrivenVirtual(scroll_node.widget)) return null;
if (!canvas.widgetScrollsAxis(scroll_node.widget, axis)) return null;
const viewport = scroll_node.frame.inset(scroll_node.widget.layout.padding).normalized();
if (viewport.isEmpty()) return null;
const current = self.canvasWidgetScrollState(scroll_index, scroll_node, viewport);
const state = self.canvasWidgetScrollState(scroll_index, scroll_node, viewport);
const current = state.axis(axis);
// Per-region edge behavior: the region's overscroll override
// resolved onto the scroll-physics token (off by default —
// `applyWheel` clamps unless the effective mode is
@@ -156,22 +214,30 @@ pub fn RuntimeViewCanvasWidgetScroll(comptime RuntimeView: type) type {
const rubberband = allow_rubberband and !scroll_node.widget.native_scroll;
const next = switch (source) {
.wheel => if (rubberband)
current.applyWheel(delta_y, physics)
current.applyWheel(delta, physics)
else
current.applyWheelClamped(delta_y, physics),
current.applyWheelClamped(delta, physics),
.discrete => discrete: {
var state = current;
state.offset += delta_y;
state.velocity = 0;
break :discrete state.clamped();
var axis_state = current;
axis_state.offset += delta;
axis_state.velocity = 0;
break :discrete axis_state.clamped();
},
};
self.widget_scroll_states[scroll_index] = next;
self.widget_scroll_states[scroll_index] = state.withAxis(axis, next);
if (next.offset == current.offset) return null;
const offset_delta = next.offset - current.offset;
self.widget_layout_nodes[scroll_index].widget.value = next.offset;
self.translateCanvasWidgetScrollDescendants(scroll_index, -offset_delta);
switch (axis) {
.vertical => {
self.widget_layout_nodes[scroll_index].widget.value = next.offset;
self.translateCanvasWidgetScrollDescendants(scroll_index, .{ .dy = -offset_delta });
},
.horizontal => {
self.widget_layout_nodes[scroll_index].widget.value_x = next.offset;
self.translateCanvasWidgetScrollDescendants(scroll_index, .{ .dx = -offset_delta });
},
}
self.noteCanvasWidgetScrollEvent(scroll_node.widget.id);
try self.refreshCanvasWidgetSemantics();
@@ -185,7 +251,7 @@ pub fn RuntimeViewCanvasWidgetScroll(comptime RuntimeView: type) type {
if (widget.kind != .textarea) return null;
const viewport = canvas.textInputViewportForWidget(widget, self.widget_tokens) orelse return null;
const current = canvas.ScrollState{
const current = canvas.ScrollAxisState{
.offset = canvas.clampedTextInputScrollOffsetForWidget(widget, self.widget_tokens, widget.value),
.viewport_extent = viewport.height,
.content_extent = canvas.textInputContentExtentForWidget(widget, self.widget_tokens),
@@ -208,10 +274,10 @@ pub fn RuntimeViewCanvasWidgetScroll(comptime RuntimeView: type) type {
}
/// Absolute offset write from a native scroll driver: the OS
/// scroller computed the offset (momentum, rubber-band — overscroll
/// scroller computed the offsets (momentum, rubber-band — overscroll
/// values pass through so the bounce is visible), the engine just
/// follows. Engine velocity is zeroed; the driver owns physics.
pub fn applyCanvasWidgetScrollDriverOffset(self: *RuntimeView, scroll_index: usize, offset: f32) anyerror!?geometry.RectF {
pub fn applyCanvasWidgetScrollDriverOffset(self: *RuntimeView, scroll_index: usize, offset_x: f32, offset_y: f32) anyerror!?geometry.RectF {
if (scroll_index >= self.widget_layout_node_count) return null;
const scroll_node = self.widget_layout_nodes[scroll_index];
if (scroll_node.widget.kind != .scroll_view or canvasWidgetModelDrivenVirtual(scroll_node.widget)) return null;
@@ -221,14 +287,24 @@ pub fn RuntimeViewCanvasWidgetScroll(comptime RuntimeView: type) type {
const current = self.canvasWidgetScrollState(scroll_index, scroll_node, viewport);
var next = current;
next.offset = offset;
next.velocity = 0;
// Driver offsets land only on axes the region grants: the
// sync pins the native scroller's range on ungranted axes,
// and a stray report there must not displace content.
if (canvas.widgetScrollsAxis(scroll_node.widget, .vertical)) {
next.offset_y = offset_y;
next.velocity_y = 0;
}
if (canvas.widgetScrollsAxis(scroll_node.widget, .horizontal)) {
next.offset_x = offset_x;
next.velocity_x = 0;
}
self.widget_scroll_states[scroll_index] = next;
if (next.offset == current.offset) return null;
if (next.offset_y == current.offset_y and next.offset_x == current.offset_x) return null;
const offset_delta = next.offset - current.offset;
self.widget_layout_nodes[scroll_index].widget.value = next.offset;
self.translateCanvasWidgetScrollDescendants(scroll_index, -offset_delta);
const offset_delta = geometry.OffsetF.init(next.offset_x - current.offset_x, next.offset_y - current.offset_y);
self.widget_layout_nodes[scroll_index].widget.value = next.offset_y;
self.widget_layout_nodes[scroll_index].widget.value_x = next.offset_x;
self.translateCanvasWidgetScrollDescendants(scroll_index, .{ .dx = -offset_delta.dx, .dy = -offset_delta.dy });
self.noteCanvasWidgetScrollEvent(scroll_node.widget.id);
try self.refreshCanvasWidgetSemantics();
@@ -244,20 +320,40 @@ pub fn RuntimeViewCanvasWidgetScroll(comptime RuntimeView: type) type {
const viewport = scroll_node.frame.inset(scroll_node.widget.layout.padding).normalized();
if (viewport.isEmpty()) return null;
// Home/End land at the content origin/terminus on EVERY axis
// the region grants: a vertical list jumps top/bottom exactly
// as before, a horizontal shelf jumps to its left/right edge,
// and a freely scrolling region jumps to the corner (the
// NSScrollView document begin/end convention).
const current = self.canvasWidgetScrollState(scroll_index, scroll_node, viewport);
var next = current;
next.offset = switch (target) {
.start => 0,
.end => current.maxOffset(),
};
next.velocity = 0;
next = next.clamped();
if (canvas.widgetScrollsAxis(scroll_node.widget, .vertical)) {
var axis_state = current.axis(.vertical);
axis_state.offset = switch (target) {
.start => 0,
.end => axis_state.maxOffset(),
};
axis_state.velocity = 0;
next = next.withAxis(.vertical, axis_state.clamped());
}
if (canvas.widgetScrollsAxis(scroll_node.widget, .horizontal)) {
var axis_state = current.axis(.horizontal);
axis_state.offset = switch (target) {
.start => 0,
.end => axis_state.maxOffset(),
};
axis_state.velocity = 0;
next = next.withAxis(.horizontal, axis_state.clamped());
}
self.widget_scroll_states[scroll_index] = next;
if (next.offset == current.offset) return null;
if (next.offset_y == current.offset_y and next.offset_x == current.offset_x) return null;
const offset_delta = next.offset - current.offset;
self.widget_layout_nodes[scroll_index].widget.value = next.offset;
self.translateCanvasWidgetScrollDescendants(scroll_index, -offset_delta);
self.widget_layout_nodes[scroll_index].widget.value = next.offset_y;
self.widget_layout_nodes[scroll_index].widget.value_x = next.offset_x;
self.translateCanvasWidgetScrollDescendants(scroll_index, .{
.dx = -(next.offset_x - current.offset_x),
.dy = -(next.offset_y - current.offset_y),
});
self.noteCanvasWidgetScrollEvent(scroll_node.widget.id);
try self.refreshCanvasWidgetSemantics();
@@ -276,24 +372,48 @@ pub fn RuntimeViewCanvasWidgetScroll(comptime RuntimeView: type) type {
const viewport = scroll_node.frame.inset(scroll_node.widget.layout.padding).normalized();
if (viewport.isEmpty()) {
self.widget_scroll_states[scroll_index].velocity = 0;
self.widget_scroll_states[scroll_index].velocity_y = 0;
self.widget_scroll_states[scroll_index].velocity_x = 0;
continue;
}
const physics = canvas.widgetScrollPhysics(scroll_node.widget, self.widget_tokens.scroll);
const current = self.canvasWidgetScrollState(scroll_index, scroll_node, viewport);
if (!current.needsKineticStep(physics)) {
self.widget_scroll_states[scroll_index].velocity = 0;
continue;
var next = current;
var moved = geometry.OffsetF{};
if (canvas.widgetScrollsAxis(scroll_node.widget, .vertical)) {
const current_y = current.axis(.vertical);
if (current_y.needsKineticStep(physics)) {
const next_y = current_y.stepKinetic(dt_ms, physics);
next = next.withAxis(.vertical, next_y);
moved.dy = next_y.offset - current_y.offset;
} else {
next.velocity_y = 0;
}
} else {
next.velocity_y = 0;
}
const next = current.stepKinetic(dt_ms, physics);
self.widget_scroll_states[scroll_index] = next;
if (next.offset == current.offset) continue;
if (canvas.widgetScrollsAxis(scroll_node.widget, .horizontal)) {
const current_x = current.axis(.horizontal);
if (current_x.needsKineticStep(physics)) {
const next_x = current_x.stepKinetic(dt_ms, physics);
next = next.withAxis(.horizontal, next_x);
moved.dx = next_x.offset - current_x.offset;
} else {
next.velocity_x = 0;
}
} else {
next.velocity_x = 0;
}
const offset_delta = next.offset - current.offset;
self.widget_layout_nodes[scroll_index].widget.value = next.offset;
self.translateCanvasWidgetScrollDescendants(scroll_index, -offset_delta);
self.widget_scroll_states[scroll_index] = next;
if (moved.dx == 0 and moved.dy == 0) continue;
self.widget_layout_nodes[scroll_index].widget.value = next.offset_y;
self.widget_layout_nodes[scroll_index].widget.value_x = next.offset_x;
self.translateCanvasWidgetScrollDescendants(scroll_index, .{ .dx = -moved.dx, .dy = -moved.dy });
self.noteCanvasWidgetScrollEvent(scroll_node.widget.id);
dirty = unionRects(dirty, self.canvasWidgetDirtyBounds(scroll_index, scroll_node.frame));
changed = true;
@@ -316,23 +436,53 @@ pub fn RuntimeViewCanvasWidgetScroll(comptime RuntimeView: type) type {
if (scroll_index < self.widget_layout_node_count and self.widget_layout_nodes[scroll_index].widget.layout.virtualized) {
return @max(viewport.height, canvas.virtualWidgetScrollContentExtentWithTokens(self.widget_layout_nodes[scroll_index].widget, viewport.height, self.widget_tokens));
}
const scroll_depth = self.widget_layout_nodes[scroll_index].depth;
const offset = self.widget_layout_nodes[scroll_index].widget.value;
var bottom = viewport.maxY();
var index = scroll_index + 1;
while (index < self.widget_layout_node_count and self.widget_layout_nodes[index].depth > scroll_depth) : (index += 1) {
bottom = @max(bottom, self.widget_layout_nodes[index].frame.maxY() + offset);
}
return @max(0, bottom - viewport.y);
return canvas_widget_runtime.canvasWidgetLayoutScrollContentExtent(
self.widget_layout_nodes[0..self.widget_layout_node_count],
scroll_index,
viewport,
);
}
pub fn translateCanvasWidgetScrollDescendants(self: *RuntimeView, scroll_index: usize, dy: f32) void {
/// The horizontal content extent: how far the region's mounted
/// descendants reach rightward, rebased to offset 0. Textareas
/// and virtualized containers never scroll horizontally, so
/// their horizontal content pins to the viewport width. Closed
/// disclosure subtrees are skipped — concealed content lays out
/// at full size and must not inflate the scrollable range on
/// either axis (the semantics walker applies the same rule).
pub fn canvasWidgetScrollContentExtentX(self: *const RuntimeView, scroll_index: usize, viewport: geometry.RectF) f32 {
if (scroll_index < self.widget_layout_node_count and
(self.widget_layout_nodes[scroll_index].widget.kind == .textarea or self.widget_layout_nodes[scroll_index].widget.layout.virtualized))
{
return viewport.width;
}
return canvas_widget_runtime.canvasWidgetLayoutScrollContentExtentX(
self.widget_layout_nodes[0..self.widget_layout_node_count],
scroll_index,
viewport,
);
}
/// Scrolled content carries its descendants — INCLUDING floating
/// surfaces anchored to widgets inside it (their anchor bases
/// moved) — but a surface anchored to the SCROLL REGION ITSELF
/// stays put: its anchor base is the region's own frame, which
/// never moves when the content under it does.
pub fn translateCanvasWidgetScrollDescendants(self: *RuntimeView, scroll_index: usize, offset: geometry.OffsetF) void {
const scroll_depth = self.widget_layout_nodes[scroll_index].depth;
var index = scroll_index + 1;
while (index < self.widget_layout_node_count and self.widget_layout_nodes[index].depth > scroll_depth) : (index += 1) {
const translated = self.widget_layout_nodes[index].frame.translate(.{ .dx = 0, .dy = dy });
while (index < self.widget_layout_node_count and self.widget_layout_nodes[index].depth > scroll_depth) {
const node = self.widget_layout_nodes[index];
if (node.widget.layout.anchor != null and node.parent_index == scroll_index) {
const subtree_depth = node.depth;
index += 1;
while (index < self.widget_layout_node_count and self.widget_layout_nodes[index].depth > subtree_depth) : (index += 1) {}
continue;
}
const translated = node.frame.translate(offset);
self.widget_layout_nodes[index].frame = translated;
self.widget_layout_nodes[index].widget.frame = translated;
index += 1;
}
}
+32 -2
View File
@@ -1060,8 +1060,15 @@ pub fn RuntimeViewCanvasWidgetTree(comptime RuntimeView: type) type {
};
return switch (self.widget_layout_nodes[index].widget.kind) {
.grid, .scroll_view, .list, .data_grid, .table => switch (direction) {
.increment => "pagedown",
.decrement => "pageup",
// Page keys step the vertical axis on every keymap
// except the horizontal-only one (which mirrors the
// whole map sideways) — so a BOTH-axes region whose
// live axis is horizontal needs the both-keymap's
// horizontal keys instead, or the assistive step
// would page a zero-range vertical axis and report
// success without moving.
.increment => if (canvasWidgetStepAxisHorizontal(self, index)) "arrowright" else "pagedown",
.decrement => if (canvasWidgetStepAxisHorizontal(self, index)) "arrowleft" else "pageup",
},
else => switch (direction) {
.increment => "arrowright",
@@ -1070,6 +1077,29 @@ pub fn RuntimeViewCanvasWidgetTree(comptime RuntimeView: type) type {
};
}
/// Whether a BOTH-axes scroll region's assistive step must take
/// the horizontal keys: the vertical axis has no range while
/// the horizontal one does. The extents come from the SEMANTICS
/// metrics — the same derivation the assistive node reports its
/// primary axis through, concealed-disclosure and anchored
/// exclusions included — so the key an increment synthesizes
/// can never disagree with the axis the node advertised.
/// Vertical-capable regions with vertical range — and
/// horizontal-only regions, whose keymap already maps the page
/// keys sideways — keep the page keys.
fn canvasWidgetStepAxisHorizontal(self: *const RuntimeView, index: usize) bool {
const node = self.widget_layout_nodes[index];
if (node.widget.kind != .scroll_view or node.widget.scroll_axes != .both) return false;
const viewport = node.frame.inset(node.widget.layout.padding).normalized();
if (viewport.isEmpty()) return false;
const layout = self.widgetLayoutTree();
const vertical = canvas.widgetScrollAxisMetrics(layout, index, canvas.virtualWidgetScrollContentExtent, .vertical, viewport);
const horizontal = canvas.widgetScrollAxisMetrics(layout, index, canvas.virtualWidgetScrollContentExtent, .horizontal, viewport);
const vertical_range = vertical.present and vertical.content_extent > vertical.viewport_extent;
const horizontal_range = horizontal.present and horizontal.content_extent > horizontal.viewport_extent;
return !vertical_range and horizontal_range;
}
pub fn refreshCanvasWidgetSemantics(self: *RuntimeView) anyerror!void {
const semantics = try self.widgetLayoutTree().collectSemantics(&self.widget_semantics_nodes);
self.widget_semantics_node_count = semantics.len;
+9 -5
View File
@@ -112,12 +112,16 @@ export fn nsc_core_dispatch_text_input(tag: u8, event: [*]const u8, event_len: u
noCore();
}
export fn nsc_core_dispatch_scroll_state(tag: u8, offset: f64, velocity: f64, viewport_extent: f64, content_extent: f64, cmd: *[*]const u8, cmd_len: *usize) void {
export fn nsc_core_dispatch_scroll_state(tag: u8, offset_x: f64, offset_y: f64, velocity_x: f64, velocity_y: f64, viewport_extent_x: f64, viewport_extent_y: f64, content_extent_x: f64, content_extent_y: f64, cmd: *[*]const u8, cmd_len: *usize) void {
_ = tag;
_ = offset;
_ = velocity;
_ = viewport_extent;
_ = content_extent;
_ = offset_x;
_ = offset_y;
_ = velocity_x;
_ = velocity_y;
_ = viewport_extent_x;
_ = viewport_extent_y;
_ = content_extent_x;
_ = content_extent_y;
_ = cmd;
_ = cmd_len;
noCore();
+2 -2
View File
@@ -336,7 +336,7 @@ const TranscriptModel = struct {
fn transcriptUpdate(model: *TranscriptModel, msg: TranscriptMsg) void {
switch (msg) {
.scrolled => |scroll| model.offset = scroll.offset,
.scrolled => |scroll| model.offset = scroll.offset_y,
}
}
@@ -484,7 +484,7 @@ fn measuredChatUpdate(model: *MeasuredChatModel, msg: MeasuredChatMsg) void {
model.draft[model.draft_len % measured_chat_draft_capacity] = 'a' + @as(u8, @intCast(model.typed % 26));
model.draft_len = (model.draft_len % measured_chat_draft_capacity) + 1;
},
.scrolled => |scroll| model.offset = scroll.offset,
.scrolled => |scroll| model.offset = scroll.offset_y,
}
}
+3 -2
View File
@@ -125,8 +125,9 @@ pub fn Bindings(comptime prefix: []const u8) type {
/// in the canonical value encoding.
pub const dispatch_text_input = Symbol(fn (tag: u8, event: [*]const u8, event_len: usize, cmd: *[*]const u8, cmd_len: *usize) callconv(.c) void, "dispatch_text_input");
/// The declared scroll-state mirror record as direct scalars
/// (the hottest markup dispatch: per-frame during scrolls).
pub const dispatch_scroll_state = Symbol(fn (tag: u8, offset: f64, velocity: f64, viewport_extent: f64, content_extent: f64, cmd: *[*]const u8, cmd_len: *usize) callconv(.c) void, "dispatch_scroll_state");
/// (the hottest markup dispatch: per-frame during scrolls)
/// the TWO-AXIS record's eight fields in declaration order.
pub const dispatch_scroll_state = Symbol(fn (tag: u8, offset_x: f64, offset_y: f64, velocity_x: f64, velocity_y: f64, viewport_extent_x: f64, viewport_extent_y: f64, content_extent_x: f64, content_extent_y: f64, cmd: *[*]const u8, cmd_len: *usize) callconv(.c) void, "dispatch_scroll_state");
// -------------------------------------------------- post-cycle
pub const subscriptions = Symbol(fn (subs: *[*]const u8, subs_len: *usize) callconv(.c) void, "subscriptions");
+105 -10
View File
@@ -48,10 +48,27 @@ const text_input_event_tags = [_][]const u8{
"set_composition", "commit_composition", "cancel_composition",
};
/// The scroll-state field vocabulary, TS spelling (the emitted-core
/// mirror keeps the author's names) and the canvas spelling.
const scroll_state_fields_ts = [_][]const u8{ "offset", "velocity", "viewportExtent", "contentExtent" };
const scroll_state_fields_canvas = [_][]const u8{ "offset", "velocity", "viewport_extent", "content_extent" };
/// The scroll-state field vocabulary — the TWO-AXIS record, eight
/// per-axis fields — in the TS spelling (the emitted-core mirror keeps
/// the author's names) and the canvas spelling. The order is
/// `canvas.ScrollState`'s declaration order, which is also the ABI
/// entry's parameter order. The retired one-axis quartet
/// (`{offset, velocity, viewportExtent, contentExtent}`) is NOT scroll
/// state anymore: a record carrying it rides the generic record entry,
/// and the markup engines refuse to bind `on-scroll` to it with a
/// teaching that names these fields.
const scroll_state_fields_ts = [_][]const u8{
"offsetX", "offsetY",
"velocityX", "velocityY",
"viewportExtentX", "viewportExtentY",
"contentExtentX", "contentExtentY",
};
const scroll_state_fields_canvas = [_][]const u8{
"offset_x", "offset_y",
"velocity_x", "velocity_y",
"viewport_extent_x", "viewport_extent_y",
"content_extent_x", "content_extent_y",
};
pub const Error = error{ Refused, OutOfMemory };
@@ -932,14 +949,15 @@ const Emitter = struct {
}
/// Declaration-order field indexes of a scroll-state record, in the
/// ABI entry's parameter order (offset, velocity, viewport extent,
/// content extent) — or null when the record is not that shape.
fn scrollStateFields(self: *Emitter, type_name: []const u8) ?[4]usize {
/// ABI entry's parameter order (the eight per-axis scalars,
/// offset_x through content_extent_y) — or null when the record is
/// not that shape.
fn scrollStateFields(self: *Emitter, type_name: []const u8) ?[8]usize {
const entry = sidecar_mod.findStruct(self.sidecar.types, type_name) orelse return null;
if (entry.fields.len != 4) return null;
const spellings = [_][4][]const u8{ scroll_state_fields_ts, scroll_state_fields_canvas };
if (entry.fields.len != 8) return null;
const spellings = [_][8][]const u8{ scroll_state_fields_ts, scroll_state_fields_canvas };
for (spellings) |names| {
var indexes: [4]usize = undefined;
var indexes: [8]usize = undefined;
var all_found = true;
for (names, 0..) |field_name, position| {
indexes[position] = for (entry.fields, 0..) |field, index| {
@@ -1672,6 +1690,83 @@ test "a text-input-named union without the payload shapes rides the record entry
try testing.expect(std.mem.indexOf(u8, generated, "abi.dispatch_text_input(0,") == null);
}
test "the two-axis scroll-state record dispatches as direct scalars" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
// The eight per-axis fields in the TS spelling (the emitted-core
// mirror keeps the author's names), declaration order: the shape
// the markup predicate binds as `on-scroll`, so dispatch must ride
// the dedicated scalar entry, never the encoded record entry.
const scroll_struct =
\\ {"name": "Scroll", "fields": [
\\ {"name": "offsetX", "type": {"kind": "f64"}},
\\ {"name": "offsetY", "type": {"kind": "f64"}},
\\ {"name": "velocityX", "type": {"kind": "f64"}},
\\ {"name": "velocityY", "type": {"kind": "f64"}},
\\ {"name": "viewportExtentX", "type": {"kind": "f64"}},
\\ {"name": "viewportExtentY", "type": {"kind": "f64"}},
\\ {"name": "contentExtentX", "type": {"kind": "f64"}},
\\ {"name": "contentExtentY", "type": {"kind": "f64"}}
\\ ]},
;
var source = try std.mem.replaceOwned(
u8,
arena,
sidecar_mod.minimal_valid_json,
"\"structs\": [\n",
try std.fmt.allocPrint(arena, "\"structs\": [\n{s}\n", .{scroll_struct}),
);
source = try std.mem.replaceOwned(
u8,
arena,
source,
"{\"name\": \"bump\", \"payload\": {\"kind\": \"void\"}}",
"{\"name\": \"bump\", \"payload\": {\"kind\": \"void\"}},\n {\"name\": \"scrolled\", \"payload\": {\"kind\": \"record\", \"name\": \"Scroll\"}}",
);
const generated = try emitFromJson(arena, source);
try testing.expect(std.mem.indexOf(
u8,
generated,
"abi.dispatch_scroll_state(1, payload.offsetX, payload.offsetY, payload.velocityX, payload.velocityY, payload.viewportExtentX, payload.viewportExtentY, payload.contentExtentX, payload.contentExtentY, &cmd_ptr, &cmd_len)",
) != null);
}
test "the retired one-axis scroll quartet rides the record entry" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
// The pre-two-axis shape `{offset, velocity, viewportExtent,
// contentExtent}` is an ordinary record now — the markup engines
// refuse to bind `on-scroll` to it (with a teaching that names the
// per-axis fields), so dispatch must not claim the scalar entry.
const legacy_struct =
\\ {"name": "Scroll", "fields": [
\\ {"name": "offset", "type": {"kind": "f64"}},
\\ {"name": "velocity", "type": {"kind": "f64"}},
\\ {"name": "viewportExtent", "type": {"kind": "f64"}},
\\ {"name": "contentExtent", "type": {"kind": "f64"}}
\\ ]},
;
var source = try std.mem.replaceOwned(
u8,
arena,
sidecar_mod.minimal_valid_json,
"\"structs\": [\n",
try std.fmt.allocPrint(arena, "\"structs\": [\n{s}\n", .{legacy_struct}),
);
source = try std.mem.replaceOwned(
u8,
arena,
source,
"{\"name\": \"bump\", \"payload\": {\"kind\": \"void\"}}",
"{\"name\": \"bump\", \"payload\": {\"kind\": \"void\"}},\n {\"name\": \"scrolled\", \"payload\": {\"kind\": \"record\", \"name\": \"Scroll\"}}",
);
const generated = try emitFromJson(arena, source);
try testing.expect(std.mem.indexOf(u8, generated, "abi.dispatch_record(1,") != null);
try testing.expect(std.mem.indexOf(u8, generated, "abi.dispatch_scroll_state(1,") == null);
}
test "boot references every attested export so the link proves the set" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
+5 -3
View File
@@ -90,8 +90,10 @@ pub fn run(allocator: std.mem.Allocator, io: std.Io, environ_map: *std.process.E
defer allocator.free(value);
try sendCommand(allocator, io, "widget-drag", value);
} else if (std.mem.eql(u8, command, "widget-wheel")) {
if (args.len != 4) return usage();
const value = try std.fmt.allocPrint(allocator, "{s} {s} {s}", .{ args[1], args[2], args[3] });
// The optional fourth token is the horizontal delta (per-axis
// routing applies, like a real trackpad gesture with both).
if (args.len != 4 and args.len != 5) return usage();
const value = try std.mem.join(allocator, " ", args[1..]);
defer allocator.free(value);
try sendCommand(allocator, io, "widget-wheel", value);
} else if (std.mem.eql(u8, command, "widget-key")) {
@@ -450,7 +452,7 @@ fn printUsage() void {
\\ widget-context-press <view-label> <widget-id> (right-click: context menu, or on_hold when the route has none)
\\ widget-context-menu <view-label> <widget-id> <item-index> (invoke a declared context-menu item; snapshots list them as context_menu=[...])
\\ widget-drag <view-label> <widget-id> <start-x-ratio> <end-x-ratio> [start-y-ratio end-y-ratio]
\\ widget-wheel <view-label> <widget-id> <delta-y>
\\ widget-wheel <view-label> <widget-id> <delta-y> [delta-x]
\\ widget-key <view-label> <key> [text]
\\ widget-pinch <view-label> <scale> [x y] (trackpad pinch: <scale> is the gesture's final multiplicative zoom, e.g. 1.5 zooms in; anchor defaults to the view center)
\\ shortcut <id>
+3 -1
View File
@@ -131,6 +131,8 @@ pub const attribute_docs = [_]Doc{
.{ .name = "icon-placement", .doc = "Icon slot side on label-bearing buttons/toggle-buttons: leading (default) draws the icon before the label, trailing after it — the next-page chevron. Icon-only buttons center the glyph regardless." },
.{ .name = "window-drag", .doc = "Marks the element as a window-drag surface (the hidden-titlebar pattern): pressing its background - or plain text/icons inside - moves the window; double-click zooms per the OS convention. Buttons and other press-claiming children inside stay clickable. macOS-only; elsewhere the press is dead space." },
.{ .name = "overscroll", .doc = "scroll only: edge behavior of the region. none pins scrolling at the content edges (the shipped default via the ScrollPhysics.overscroll token), rubber_band lets this region bounce past them, default follows the token. Honored by the engine's scroll physics and the native OS scroller alike." },
.{ .name = "axis", .doc = "scroll only: which axes the region scrolls - vertical (the default), horizontal, or both. Horizontal grants opt the region into wheel/trackpad delta-x, the bottom-edge scrollbar, and the horizontal keymap; in a nested tree each wheel axis routes independently to the nearest ancestor scrolling that axis. Virtualized scrolls stay vertical (a horizontal grant there is a teaching error)." },
.{ .name = "value-x", .doc = "scroll only, beside axis=\"horizontal\" or axis=\"both\": the horizontal scroll offset - the sideways counterpart of value, following the same source-wins reconcile rule (echo on-scroll's offset_x back to keep user scrolling; move it model-side to scroll programmatically). Without a horizontal axis grant it is a teaching error (it would be silently inert)." },
.{ .name = "resize-duration", .doc = "split only: layout-tween duration in milliseconds (a plain number or one {binding}). Nonzero makes the bound value a TARGET - a rebuild that moves it lays both panes out at the target ONCE, then the runtime slides the rendered boundary there one presented frame at a time under the panes' clips (content never re-wraps mid-flight), dispatching ONE on-resize echo at settle with the applied fraction. 0 (and absent) snaps, today's behavior. A divider DRAG keeps live per-step reflow and echoes. Reduced-motion appearances snap automatically - apps declare nothing extra." },
.{ .name = "resize-easing", .doc = "split only, beside a nonzero resize-duration: easing curve of the layout tween - linear, standard (the default), emphasized, or spring. Easing without a duration is a teaching error (it would be silently inert)." },
.{ .name = "resize-origin", .doc = "split only, beside a nonzero resize-duration: the fraction a freshly MOUNTED split's pane boundary slides in from toward its declared value (children keep the value's layout; the pane clips reveal them) - a pane expanding out of an unmounted collapsed state slides in instead of popping. An origin without a duration is a teaching error (it would be silently inert)." },
@@ -288,7 +290,7 @@ pub const event_docs = [_]Doc{
.{ .name = "on-change", .doc = "Dispatch a Msg on change: tag or tag:{payload}. Hit-target elements only (slider, ...)." },
.{ .name = "on-submit", .doc = "Dispatch a Msg on submit: tag or tag:{payload}. Enter in a text field, primary+enter in a textarea; on a list-item, plain Enter dispatches it as the row's primary action while Space keeps the row's select (on-press)." },
.{ .name = "on-input", .doc = "Names a Msg variant with canvas.TextInputEvent payload; delivers each text edit." },
.{ .name = "on-scroll", .doc = "scroll element only: names a Msg variant with canvas.ScrollState payload; delivers the post-scroll offset/viewport/content extents after wheel, kinetic, keyboard, and accessibility scrolls." },
.{ .name = "on-scroll", .doc = "scroll element only: names a Msg variant with canvas.ScrollState payload; delivers the post-scroll two-axis state (offset_x/offset_y, velocity_x/velocity_y, viewport_extent_x/viewport_extent_y, content_extent_x/content_extent_y) after wheel, kinetic, keyboard, and accessibility scrolls." },
.{ .name = "on-dismiss", .doc = "Dismissible surfaces only (dialog, drawer, sheet, dropdown-menu): Msg dispatched when Escape or a click outside dismisses the surface, so the MODEL owns the close (clear the open flag in update). The engine hides the surface immediately as an optimistic echo; the source tree wins on the next rebuild." },
.{ .name = "on-hold", .doc = "Press-and-hold Msg: a pointer held ~350 ms dispatches it and the release then presses nothing; a quick click dispatches on-press as usual. A right/ctrl-click with no context menu on the route dispatches it immediately. Like on-press, binding it makes any element pressable." },
.{ .name = "on-resize", .doc = "split element only: names a Msg variant with f32 payload; delivers the applied first-pane fraction after every divider drag, keyboard adjustment, and assistive increment/decrement. Echo it back into value - the delivered fraction never fights the reconcile." },