Skip to content

Commit e5925ea

Browse files
authored
Move scroll mutation coordination to TypeScript (#702)
1 parent f7e55fb commit e5925ea

15 files changed

Lines changed: 790 additions & 0 deletions

‎build.zig‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1565,6 +1565,7 @@ pub fn build(b: *std.Build) void {
15651565
b.step("test-ts-context-menus", "Compare compiled context menus with complete native request and action races").dependOn(&b.addRunArtifact(filteredTestArtifact(b, ts_core_artifacts.surface_layout.root_module, "ts-context-menu-tests", &.{ "compiled context menus", "compiled automation interactions preserve context-menu" })).step);
15661566
b.step("test-ts-web-panes", "Compare complete compiled embedded WebView pane coordination with native behavior").dependOn(&b.addRunArtifact(filteredTestArtifact(b, ts_core_artifacts.surface_layout.root_module, "ts-web-pane-tests", &.{"compiled web panes"})).step);
15671567
b.step("test-ts-extent-program", "Compare compiled retained-table programs with complete native state and callback order").dependOn(&b.addRunArtifact(filteredTestArtifact(b, ts_core_artifacts.surface_layout.root_module, "ts-extent-program-tests", &.{ "compiled extent", "compiled estimate", "compiled variable" })).step);
1568+
b.step("test-ts-scroll-mutation", "Compare complete compiled scroll mutation programs with native state and effect ordering").dependOn(&b.addRunArtifact(filteredTestArtifact(b, ts_core_artifacts.surface_layout.root_module, "ts-scroll-mutation-tests", &.{ "compiled scroll mutation", "TypeScript app adapter supplies placement" })).step);
15681569
b.step("test-ts-virtual-windows", "Compare complete compiled virtual window coordination and fixed-only views").dependOn(&b.addRunArtifact(filteredTestArtifact(b, ts_core_artifacts.surface_layout.root_module, "ts-virtual-window-tests", &.{"compiled virtual window"})).step);
15691570
b.step("test-ts-staged-rebuild", "Compare complete compiled staged split and anchor rebuild programs").dependOn(&b.addRunArtifact(filteredTestArtifact(b, ts_core_artifacts.surface_layout.root_module, "ts-staged-rebuild-tests", &.{"compiled staged rebuild"})).step);
15701571
b.step("test-ts-widget-continuity", "Compare complete compiled rebuild semantics and fling continuity").dependOn(&b.addRunArtifact(filteredTestArtifact(b, ts_core_artifacts.surface_layout.root_module, "ts-widget-continuity-tests", &.{"compiled widget continuity"})).step);

‎packages/core/src/portable_sources.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
"src/context_menus.ts",
6161
"src/web_panes.ts",
6262
"src/extent_program.ts",
63+
"src/scroll_mutation.ts",
6364
"src/widget_paint.ts",
6465
"src/widget_presentation.ts",
6566
"src/widget_paint_walk.ts",

‎packages/core/src/runtime_policy.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ export function native_window_policy(request: Uint8Array): Uint8Array {
212212
if (request[0] === 98) return nscvContextMenus(request);
213213
if (request[0] === 99) return nscvWebPanes(request);
214214
if (request[0] === 100) return nscvExtentProgram(request);
215+
if (request[0] === 101) return nscvScrollMutation(request);
215216
if (request[0] === 89) return nscvBuiltinCoordination(request);
216217
if (request[0] === 81) return nscvWindowLifecycle(request);
217218
if (request[0] === 80) return nscvViewCoordination(request);
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/** Scroll mutation capability programs (101). Native retains the complete
2+
* states, f32 physics/geometry and callbacks. This owner sequences admission,
3+
* numeric capabilities, retained writes, events, accessibility and damage.
4+
* Facts are refreshed after each stage; no pointer or identity crosses here.
5+
* Modes: axis, textarea, driver, keyboard, kinetic region, kinetic finish,
6+
* and a batched kinetic-region selection over at most 1024 copied node facts.
7+
* Facts: valid=1, scroll=2, textarea=4, model-owned=8, y=16, x=32,
8+
* native=64, rubberband=128, viewport=256, moved=512, needs-step=1024.
9+
* Capabilities (see native Kind): 0–4 observations/delegation, 5–10 axis
10+
* arithmetic/store/compare, 11–16 geometry/event/semantics/revision/damage,
11+
* 17–23 driver/target/state/both-axis writes, 24–31 kinetic probes/steps/damage.
12+
*/
13+
function nscvScrollMutation(request: Uint8Array): Uint8Array {
14+
if (request[1] === 6) {
15+
if (request.length < 16 || request[0] !== 101 || request[2] !== 0 || request[3] !== 0 ||
16+
request[4] !== 0 || request[5] !== 0 || request[6] !== 0 || request[7] !== 0)
17+
throw new Error("invalid kinetic selection header");
18+
const w = new DataView(request.buffer, request.byteOffset, request.byteLength), count = w.getUint32(8, true);
19+
if (count > 1024 || request.length !== 16 + count || w.getUint32(12, true) !== 0)
20+
throw new Error("invalid kinetic selection facts");
21+
const result = new Uint8Array(8 + count); result[0] = 101; result[1] = 6;
22+
new DataView(result.buffer).setUint32(4, count, true);
23+
for (let i = 0; i < count; i++) result[8 + i] = (request[16 + i]! & (2 | 8 | 64)) === 2 ? 1 : 0;
24+
return result;
25+
}
26+
if (request.length !== 16 || request[0] !== 101 || request[1]! > 5 || request[2]! > 4 ||
27+
request[3]! > 2 || request[4]! > 1 || request[5] !== 0 || request[6] !== 0 || request[7] !== 0)
28+
throw new Error("invalid scroll mutation header");
29+
const w = new DataView(request.buffer, request.byteOffset, request.byteLength);
30+
const mode = request[1]!, phase = request[2]!, source = request[3]!, facts = w.getUint32(8, true);
31+
if (facts > 2047 || w.getUint32(12, true) !== 0 || (mode === 3 && source > 1) ||
32+
(mode === 5 ? phase !== 0 : mode === 4 ? phase > 4 : phase > 2))
33+
throw new Error("invalid scroll mutation facts");
34+
const actions: number[] = [];
35+
let next = 255;
36+
const granted = (facts & (request[4] === 1 ? 16 : 32)) !== 0;
37+
if (mode === 5) {
38+
if ((facts & 512) !== 0) actions.push(14, 15);
39+
} else if (phase === 0) {
40+
if ((facts & 1) !== 0) {
41+
if (mode === 0) {
42+
if ((facts & 4) !== 0) { if (granted) actions.push(4); }
43+
else if ((facts & 2) !== 0 && (facts & 8) === 0 && granted) { actions.push(0); next = 1; }
44+
} else if (mode === 1) {
45+
if ((facts & 4) !== 0) { actions.push(1); next = 1; }
46+
} else if ((facts & 2) !== 0 && (facts & 8) === 0 && (mode !== 4 || (facts & 64) === 0)) {
47+
actions.push(0); next = 1;
48+
}
49+
}
50+
} else if (phase === 1) {
51+
if ((facts & 256) === 0) { if (mode === 4) actions.push(24); }
52+
else if (mode === 0 || mode === 1) {
53+
actions.push(mode === 0 ? 2 : 3);
54+
actions.push(source === 0 ? 7 : mode === 0 && (facts & 128) !== 0 && (facts & 64) === 0 ? 5 : 6);
55+
// Detented engine input cancels momentum even at an unchanged offset.
56+
// Textareas never retain this temporary velocity in the first place.
57+
if (mode === 0 && source === 2) actions.push(8);
58+
actions.push(mode === 0 ? 9 : 10); next = 2;
59+
} else {
60+
actions.push(2);
61+
if (mode === 4) { if ((facts & 16) !== 0) actions.push(25); next = 2; }
62+
else {
63+
if ((facts & 16) !== 0) actions.push(mode === 2 ? 17 : 19);
64+
if ((facts & 32) !== 0) actions.push(mode === 2 ? 18 : 20);
65+
actions.push(21); next = 2;
66+
}
67+
}
68+
} else if (mode === 4 && phase === 2) {
69+
actions.push((facts & (16 | 1024)) === (16 | 1024) ? 27 : 29);
70+
if ((facts & 32) !== 0) actions.push(26);
71+
next = 3;
72+
} else if (mode === 4 && phase === 3) {
73+
actions.push((facts & (32 | 1024)) === (32 | 1024) ? 28 : 30);
74+
actions.push(21); next = 4;
75+
} else if ((facts & 512) !== 0) {
76+
if (mode <= 1) actions.push(11);
77+
else actions.push(22);
78+
if (mode !== 1) actions.push(mode === 0 ? 12 : 23, 13);
79+
if (mode === 4) actions.push(31);
80+
else actions.push(14, 15, 16);
81+
}
82+
const result = new Uint8Array(8 + actions.length);
83+
result[0] = 101; result[1] = mode; result[2] = next; result[3] = actions.length;
84+
for (let i = 0; i < actions.length; i++) result[8 + i] = actions[i]!;
85+
return result;
86+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
import { stripTypeScriptTypes } from "node:module";
4+
import { readFileSync } from "node:fs";
5+
const source = readFileSync(new URL("../src/scroll_mutation.ts", import.meta.url), "utf8");
6+
const { nscvScrollMutation: program } = await import(`data:text/javascript;base64,${Buffer.from(stripTypeScriptTypes(source) + "\nexport { nscvScrollMutation };").toString("base64")}`);
7+
function request(mode = 0, phase = 0, facts = 1 | 2 | 16 | 32, source = 0, vertical = 1): Uint8Array {
8+
const b = new Uint8Array(16); b.set([101, mode, phase, source, vertical]);
9+
new DataView(b.buffer).setUint32(8, facts, true); return b;
10+
}
11+
function plan(...args: Parameters<typeof request>): [number, number[]] {
12+
const p = program(request(...args)); return [p[2], [...p.subarray(8)]];
13+
}
14+
test("scroll mutation keeps retained writes before unchanged exits and publishes after geometry", () => {
15+
assert.deepEqual(plan(0, 1, 1 | 2 | 16 | 256, 2), [2, [2, 6, 8, 9]]);
16+
assert.deepEqual(plan(0, 2, 1 | 2 | 16 | 256), [255, []]);
17+
assert.deepEqual(plan(0, 2, 1 | 2 | 16 | 256 | 512), [255, [11, 12, 13, 14, 15, 16]]);
18+
assert.deepEqual(plan(1, 1, 1 | 4 | 256, 1), [2, [3, 6, 10]]);
19+
assert.deepEqual(plan(1, 2, 1 | 4 | 256 | 512), [255, [11, 14, 15, 16]]);
20+
assert.deepEqual(plan(2, 1, 1 | 2 | 16 | 32 | 256), [2, [2, 17, 18, 21]]);
21+
assert.deepEqual(plan(3, 1, 1 | 2 | 32 | 256, 1), [2, [2, 20, 21]]);
22+
assert.deepEqual(plan(2, 2, 512), [255, [22, 23, 13, 14, 15, 16]]);
23+
});
24+
test("scroll mutation admission separates textarea native momentum and rubber band ownership", () => {
25+
assert.deepEqual(plan(0, 0, 1 | 4 | 8 | 16), [255, [4]]);
26+
assert.deepEqual(plan(0, 0, 1 | 4 | 16, 0, 0), [255, []]);
27+
for (const facts of [0, 1, 1 | 2 | 8 | 16, 1 | 2]) assert.deepEqual(plan(0, 0, facts), [255, []]);
28+
assert.deepEqual(plan(1, 0, 1 | 4), [1, [1]]);
29+
assert.deepEqual(plan(4, 0, 1 | 2 | 16 | 64), [255, []]);
30+
assert.deepEqual(plan(2, 0, 1 | 2 | 16 | 64), [1, [0]]);
31+
assert.deepEqual(plan(0, 1, 256 | 128, 1), [2, [2, 5, 9]]);
32+
assert.deepEqual(plan(0, 1, 256 | 128 | 64, 1), [2, [2, 6, 9]]);
33+
assert.deepEqual(plan(0, 1, 256 | 128, 0), [2, [2, 7, 9]]);
34+
});
35+
test("scroll mutation kinetic stages store stopped axes and refresh once after all regions", () => {
36+
assert.deepEqual(plan(4, 1, 0), [255, [24]]);
37+
assert.deepEqual(plan(4, 1, 256 | 16), [2, [2, 25]]);
38+
assert.deepEqual(plan(4, 2, 16 | 32 | 1024), [3, [27, 26]]);
39+
assert.deepEqual(plan(4, 2, 32), [3, [29, 26]]);
40+
assert.deepEqual(plan(4, 3, 32 | 1024), [4, [28, 21]]);
41+
assert.deepEqual(plan(4, 3, 0), [4, [30, 21]]);
42+
assert.deepEqual(plan(4, 4, 512), [255, [22, 23, 13, 31]]);
43+
assert.deepEqual(plan(5, 0, 512), [255, [14, 15]]);
44+
assert.deepEqual(plan(5, 0, 0), [255, []]);
45+
});
46+
test("scroll mutation rejects truncated reserved and invalid state and owns result bytes", () => {
47+
const b = request(), result = program(b), saved = result.slice();
48+
for (let n = 0; n < 16; n++) assert.throws(() => program(b.subarray(0, n)));
49+
assert.throws(() => program(new Uint8Array([...b, 0])));
50+
for (const at of [0, 1, 2, 3, 4, 5, 6, 7, 9, 12, 15]) {
51+
const bad = b.slice(); bad[at] = 255; assert.throws(() => program(bad));
52+
}
53+
assert.throws(() => program(request(5, 1)));
54+
assert.throws(() => program(request(3, 0, 0, 2)));
55+
assert.throws(() => program(request(0, 3)));
56+
b.fill(255); program(request(4, 3)); assert.deepEqual(result, saved);
57+
});
58+
test("scroll mutation batches kinetic admission with bounded independently owned masks", () => {
59+
const b = new Uint8Array(16 + 1024); b[0] = 101; b[1] = 6;
60+
new DataView(b.buffer).setUint32(8, 1024, true);
61+
b[16 + 512] = 2; b[16 + 513] = 2 | 64; b[16 + 514] = 2 | 8;
62+
const result = program(b); assert.equal(result.length, 1032);
63+
assert.equal(result[8 + 512], 1); assert.equal(result.subarray(8).reduce((a: number, b: number) => a + b, 0), 1);
64+
b.fill(0); assert.equal(result[8 + 512], 1);
65+
const empty = request(6, 0, 0, 0, 0); assert.equal(program(empty).length, 8);
66+
for (const at of [0, 2, 3, 4, 5, 6, 7, 8, 12]) { const bad = empty.slice(); bad[at] = 255; assert.throws(() => program(bad)); }
67+
assert.throws(() => program(new Uint8Array([...empty, 0])));
68+
});

‎src/primitives/canvas/tokens.zig‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1935,6 +1935,8 @@ pub const DesignTokens = struct {
19351935
widget_reconcile_policy: ?*const fn ([]const u8, []u8) usize = null,
19361936
/// Portable retained scroll extents, moving subtrees and selected-tab reveals.
19371937
scroll_tree_policy: ?*const fn ([]const u8, []u8) usize = null,
1938+
/// Portable scroll mutation, retained state and publication sequencing.
1939+
scroll_mutation_policy: ?*const fn ([]const u8, []u8) usize = null,
19381940
widget_continuity_policy: ?*const fn ([]const u8, []u8) usize = null,
19391941
staged_rebuild_policy: ?*const fn ([]const u8, []u8) usize = null,
19401942
/// Portable retained damage geometry; native supplies font measurements.

‎src/runtime/core.zig‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1336,6 +1336,8 @@ const testingBuiltinBridgeErrorCode = builtinBridgeErrorCode;
13361336
const testingBuiltinBridgeErrorMessage = builtinBridgeErrorMessage;
13371337

13381338
pub const testing = struct {
1339+
pub const scroll_mutation_methods = @import("view_widget_scroll.zig");
1340+
pub const scroll_mutation_policy = @import("scroll_mutation_policy.zig");
13391341
pub const virtual_window_policy = @import("virtual_window_policy.zig");
13401342
pub const context_menu_policy = @import("context_menu_policy.zig");
13411343
pub const web_pane_test_cases = @import("web_pane_test_cases.zig");
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
//! Copied scroll capability programs. All state, identities, f32 numeric
2+
//! operations and geometry remain native-owned; compiled code orders them.
3+
const std = @import("std");
4+
const canvas = @import("canvas");
5+
const limits = @import("canvas_limits.zig");
6+
pub const Policy = *const fn ([]const u8, []u8) usize;
7+
pub const Mode = enum(u8) { axis, textarea, driver, keyboard, kinetic, finish };
8+
pub const Kind = enum(u8) {
9+
viewport,
10+
textarea_viewport,
11+
region_state,
12+
textarea_state,
13+
delegate_textarea,
14+
wheel,
15+
wheel_clamped,
16+
discrete,
17+
zero_velocity,
18+
store_axis,
19+
compare_axis,
20+
write_axis,
21+
translate_axis,
22+
event,
23+
semantics,
24+
revision,
25+
dirty,
26+
driver_y,
27+
driver_x,
28+
target_y,
29+
target_x,
30+
store_state,
31+
write_both,
32+
translate_both,
33+
clear_velocities,
34+
probe_y,
35+
probe_x,
36+
kinetic_y,
37+
kinetic_x,
38+
zero_y,
39+
zero_x,
40+
accumulate_dirty,
41+
};
42+
pub fn nodeFacts(node: canvas.WidgetLayoutNode) u8 {
43+
var facts: u8 = 1;
44+
if (node.widget.kind == .scroll_view) facts |= 2;
45+
if (node.widget.kind == .textarea) facts |= 4;
46+
if (node.widget.layout.virtualized and !canvas.widgetVirtualRuntimeScrolled(node.widget)) facts |= 8;
47+
if (canvas.widgetScrollsAxis(node.widget, .vertical)) facts |= 16;
48+
if (canvas.widgetScrollsAxis(node.widget, .horizontal)) facts |= 32;
49+
if (node.widget.runtime_flags.native_scroll) facts |= 64;
50+
return facts;
51+
}
52+
pub const Selection = struct {
53+
include: [limits.max_canvas_widget_nodes_per_view]bool = undefined,
54+
};
55+
pub fn kineticSelection(policy: Policy, nodes: []const canvas.WidgetLayoutNode) Selection {
56+
comptime {
57+
if (limits.max_canvas_widget_nodes_per_view != 1024) @compileError("update kinetic selection wire budget");
58+
}
59+
if (nodes.len > limits.max_canvas_widget_nodes_per_view) @panic("kinetic selection exceeds node budget");
60+
var input: [16 + limits.max_canvas_widget_nodes_per_view]u8 = @splat(0);
61+
input[0] = 101;
62+
input[1] = 6;
63+
std.mem.writeInt(u32, input[8..12], @intCast(nodes.len), .little);
64+
for (nodes, 0..) |node, i| input[16 + i] = nodeFacts(node);
65+
var output: [8 + limits.max_canvas_widget_nodes_per_view]u8 = undefined;
66+
if (policy(input[0 .. 16 + nodes.len], &output) != 8 + nodes.len or output[0] != 101 or output[1] != 6 or
67+
output[2] != 0 or output[3] != 0 or std.mem.readInt(u32, output[4..8], .little) != nodes.len)
68+
@panic("invalid kinetic selection header");
69+
var result: Selection = undefined;
70+
for (result.include[0..nodes.len], 0..) |*include, i| {
71+
if (output[8 + i] > 1) @panic("invalid kinetic selection capability");
72+
include.* = output[8 + i] == 1;
73+
}
74+
return result;
75+
}
76+
pub const Program = struct {
77+
bytes: [24]u8,
78+
count: usize,
79+
next: u8,
80+
pub fn instruction(self: *const Program, index: usize) Kind {
81+
std.debug.assert(index < self.count);
82+
return @enumFromInt(self.bytes[8 + index]);
83+
}
84+
};
85+
pub fn plan(policy: Policy, mode: Mode, phase: u8, source: u8, vertical: bool, facts: u32) Program {
86+
var input: [16]u8 = @splat(0);
87+
input[0..5].* = .{ 101, @intFromEnum(mode), phase, source, @intFromBool(vertical) };
88+
std.mem.writeInt(u32, input[8..12], facts, .little);
89+
var result: Program = undefined;
90+
const size = policy(&input, &result.bytes);
91+
if (size < 8 or size > result.bytes.len or result.bytes[0] != 101 or result.bytes[1] != @intFromEnum(mode) or
92+
std.mem.readInt(u32, result.bytes[4..8], .little) != 0)
93+
@panic("invalid scroll mutation program header");
94+
result.count = result.bytes[3];
95+
result.next = result.bytes[2];
96+
if (size != 8 + result.count or (result.next != 255 and (result.next != phase + 1 or result.next > (if (mode == .kinetic) @as(u8, 4) else 2))) or
97+
(mode == .finish and result.next != 255)) @panic("invalid scroll mutation program progress");
98+
if (mode != .finish and ((facts & 1 == 0 and result.count != 0) or
99+
(phase > 1 and facts & 256 == 0 and result.count != 0))) @panic("invalid scroll mutation storage admission");
100+
for (result.bytes[8..size]) |kind| {
101+
if (kind > @intFromEnum(Kind.accumulate_dirty)) @panic("invalid scroll mutation capability");
102+
const capability: Kind = @enumFromInt(kind);
103+
// Capability admission, independent of the program's semantic choices.
104+
const allowed = switch (capability) {
105+
.viewport => phase == 0 and mode != .textarea and mode != .finish,
106+
.textarea_viewport => phase == 0 and mode == .textarea,
107+
.delegate_textarea => phase == 0 and mode == .axis,
108+
.region_state => phase == 1 and mode != .textarea and mode != .finish,
109+
.textarea_state, .compare_axis => phase == 1 and mode == .textarea,
110+
.wheel, .wheel_clamped, .discrete => phase == 1 and (mode == .axis or mode == .textarea),
111+
.zero_velocity, .store_axis => phase == 1 and mode == .axis,
112+
.driver_y, .driver_x => phase == 1 and mode == .driver,
113+
.target_y, .target_x => phase == 1 and mode == .keyboard,
114+
.store_state => (phase == 1 and (mode == .driver or mode == .keyboard)) or (phase == 3 and mode == .kinetic),
115+
.clear_velocities => phase == 1 and mode == .kinetic,
116+
.probe_y => phase == 1 and mode == .kinetic,
117+
.probe_x, .kinetic_y, .zero_y => phase == 2 and mode == .kinetic,
118+
.kinetic_x, .zero_x => phase == 3 and mode == .kinetic,
119+
.semantics, .revision => (phase == 2 and mode != .kinetic and mode != .finish) or (phase == 0 and mode == .finish),
120+
.write_axis => phase == 2 and (mode == .axis or mode == .textarea),
121+
.translate_axis => phase == 2 and mode == .axis,
122+
.dirty => phase == 2 and mode != .kinetic and mode != .finish,
123+
.event => (phase == 2 and (mode == .axis or mode == .driver or mode == .keyboard)) or (phase == 4 and mode == .kinetic),
124+
.write_both, .translate_both => (phase == 2 and (mode == .driver or mode == .keyboard)) or (phase == 4 and mode == .kinetic),
125+
.accumulate_dirty => phase == 4 and mode == .kinetic,
126+
};
127+
if (!allowed) @panic("invalid scroll mutation capability admission");
128+
}
129+
return result;
130+
}

0 commit comments

Comments
 (0)