Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1aa2f156b | |||
| 54cdd21a5b | |||
| bbb8fbb687 | |||
| cb23556824 | |||
| 599901f62d | |||
| a4f6278b38 | |||
| c49978274a | |||
| 12d7e2f48e | |||
| b5227d2722 | |||
| 5832d9abef | |||
| 84c1b45da2 | |||
| 746e472361 | |||
| 88ec9bde03 | |||
| c9a68e64f2 | |||
| c96bc191b9 | |||
| cb317bea87 | |||
| 1c8d4a5ea9 | |||
| 0e4e7c513a |
@@ -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.
|
||||
@@ -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 <view-label> <widget-id> <delta-y></code></td>
|
||||
<td>Dispatch wheel input at a retained canvas widget</td>
|
||||
<td><code>widget-wheel <view-label> <widget-id> <delta-y> [<delta-x>]</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 <view-label> <key> [<text>]</code></td>
|
||||
|
||||
@@ -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 <view-label> <widget-id> <start-x-ratio> <end-x-ratio> [<start-y-ratio> <end-y-ratio>]</code></dt>
|
||||
<dd>Dispatch pointer down/drag/up across a retained canvas widget.</dd>
|
||||
<dt><code>automate widget-wheel <view-label> <widget-id> <delta-y></code></dt>
|
||||
<dd>Dispatch wheel input at a retained canvas widget.</dd>
|
||||
<dt><code>automate widget-wheel <view-label> <widget-id> <delta-y> [<delta-x>]</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 <view-label> <key> [<text>]</code></dt>
|
||||
<dd>Dispatch key input to the focused retained canvas widget.</dd>
|
||||
<dt><code>automate widget-pinch <view-label> <scale> [<x> <y>]</code></dt>
|
||||
|
||||
@@ -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"]} />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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{};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = .{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).?,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
} });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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." },
|
||||
|
||||
Reference in New Issue
Block a user