From f197b95c05126993e1736b5e931fa5ee554d3dbc Mon Sep 17 00:00:00 2001 From: phall Date: Mon, 3 Aug 2026 05:37:44 -0400 Subject: [PATCH 1/7] feat(canvas): add context menu policy --- changelog.d/widget-context-menu-policy.md | 1 + src/automation/protocol.zig | 2 +- src/automation/snapshot.zig | 9 ++ src/primitives/canvas/root.zig | 1 + src/primitives/canvas/ui.zig | 8 ++ src/primitives/canvas/ui_tests.zig | 14 ++ src/primitives/canvas/widgets.zig | 15 +++ src/runtime/automation_snapshot.zig | 7 + src/runtime/automation_widget_dispatch.zig | 9 +- src/runtime/canvas_widget_context_menu.zig | 51 ++++++- .../canvas_widget_context_menu_tests.zig | 124 +++++++++++++++++- src/runtime/gpu_surface_events.zig | 9 +- 12 files changed, 235 insertions(+), 15 deletions(-) create mode 100644 changelog.d/widget-context-menu-policy.md diff --git a/changelog.d/widget-context-menu-policy.md b/changelog.d/widget-context-menu-policy.md new file mode 100644 index 000000000..ec34f9a1b --- /dev/null +++ b/changelog.d/widget-context-menu-policy.md @@ -0,0 +1 @@ +feature: **Per-widget context-menu policy**: Zig views can retain native widget semantics and pointer routing while choosing automatic SDK menus, app-declared menus only, or no menu handling for a widget. diff --git a/src/automation/protocol.zig b/src/automation/protocol.zig index 3c16a2886..2947580f6 100644 --- a/src/automation/protocol.zig +++ b/src/automation/protocol.zig @@ -51,7 +51,7 @@ pub const fingerprint: u64 = layout_fingerprint.hash(layoutDescription(semantic_ /// enum). Declared-shape changes — new verbs, renamed actions, budget /// or naming changes — need NO action here: the fingerprint moves on /// its own. -pub const semantic_epoch: u32 = 1; +pub const semantic_epoch: u32 = 2; /// The canonical description the protocol fingerprint hashes: the /// command vocabulary (the `Action` enum, reflected — names and values, diff --git a/src/automation/snapshot.zig b/src/automation/snapshot.zig index 6e7248236..1572c4e6c 100644 --- a/src/automation/snapshot.zig +++ b/src/automation/snapshot.zig @@ -211,6 +211,8 @@ pub const Widget = struct { /// fallback surface) and what `widget-context-menu` invokes by /// index. context_menu: []const WidgetContextMenuItem = &.{}, + /// Non-default context-menu policy name. Empty means `automatic`. + context_menu_policy: []const u8 = "", }; /// One status-item dropdown row as the runtime last applied it. Slices @@ -585,6 +587,7 @@ pub fn writeText(input: Input, writer: anytype) !void { try writeWidgetActions(widget.actions, writer); try writeWidgetTextRanges(widget, writer); try writeWidgetContextMenu(widget, writer); + try writeWidgetContextMenuPolicy(widget, writer); try writer.writeByte('\n'); } if (input.tray) |tray| { @@ -706,6 +709,7 @@ pub fn writeA11yText(input: Input, writer: anytype) !void { try writeWidgetActions(widget.actions, writer); try writeWidgetTextRanges(widget, writer); try writeWidgetContextMenu(widget, writer); + try writeWidgetContextMenuPolicy(widget, writer); try writer.writeByte('\n'); } } @@ -853,6 +857,11 @@ fn writeWidgetContextMenu(widget: Widget, writer: anytype) !void { try writer.writeByte(']'); } +fn writeWidgetContextMenuPolicy(widget: Widget, writer: anytype) !void { + if (widget.context_menu_policy.len == 0) return; + try writer.print(" context_menu_policy={s}", .{widget.context_menu_policy}); +} + test "snapshot emits window and source" { var buffer: [512]u8 = undefined; var writer = std.Io.Writer.fixed(&buffer); diff --git a/src/primitives/canvas/root.zig b/src/primitives/canvas/root.zig index 8e46dc6c7..792bce8c4 100644 --- a/src/primitives/canvas/root.zig +++ b/src/primitives/canvas/root.zig @@ -451,6 +451,7 @@ pub const WidgetActions = widget_model.WidgetActions; pub const WidgetSemantics = widget_model.WidgetSemantics; pub const WidgetContextMenuItem = widget_model.WidgetContextMenuItem; pub const CodeDiffLines = widget_model.CodeDiffLines; +pub const WidgetContextMenuPolicy = widget_model.WidgetContextMenuPolicy; pub const Widget = widget_model.Widget; pub const BuiltinComponentOptions = widget_model.BuiltinComponentOptions; pub const WidgetCommandPart = widget_model.WidgetCommandPart; diff --git a/src/primitives/canvas/ui.zig b/src/primitives/canvas/ui.zig index 53ef03762..fd2d1b3a8 100644 --- a/src/primitives/canvas/ui.zig +++ b/src/primitives/canvas/ui.zig @@ -894,6 +894,13 @@ pub fn Ui(comptime Msg: type) type { /// authored menu, platform-appropriate presentation. Markup /// authors declare this with a `` child element. context_menu: []const ContextMenuItem = &.{}, + /// Which context menus this widget permits. `.automatic` keeps + /// the backward-compatible declared-menu-then-SDK-default + /// behavior. `.declared_only` suppresses SDK-provided defaults + /// while preserving `context_menu`; `.disabled` suppresses all + /// menu handling and leaves secondary-button input on the + /// ordinary routed/captured pointer path. + context_menu_policy: canvas.WidgetContextMenuPolicy = .automatic, }; /// One `ElementOptions.context_menu` entry: the chrome-menu item @@ -3687,6 +3694,7 @@ pub fn Ui(comptime Msg: type) type { }, .style = options.style, .semantics = options.semantics, + .context_menu_policy = options.context_menu_policy, .window_drag = options.window_drag, .overscroll = options.overscroll, .resize_duration_ms = options.resize_duration, diff --git a/src/primitives/canvas/ui_tests.zig b/src/primitives/canvas/ui_tests.zig index b31273786..dcd75dde0 100644 --- a/src/primitives/canvas/ui_tests.zig +++ b/src/primitives/canvas/ui_tests.zig @@ -1539,6 +1539,20 @@ test "virtualWindow without a source falls back to the request viewport" { try testing.expect(ui.virtualWindow(options).isEmpty()); } +test "terminal context menu policy flows from ElementOptions into the widget tree" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + var ui = InboxUi.init(arena_state.allocator()); + + const tree = try ui.finalize(ui.terminal(.{ + .pty = 7, + .context_menu_policy = .disabled, + })); + + try testing.expectEqual(canvas.WidgetKind.terminal, tree.root.kind); + try testing.expectEqual(canvas.WidgetContextMenuPolicy.disabled, tree.root.context_menu_policy); +} + test "widget kind codes are pinned: assigned at birth, declaration-order-independent" { // The FULL golden table. `structuralId` hashes `widgetKindCode`, so // this table IS the id vocabulary persisted state references: diff --git a/src/primitives/canvas/widgets.zig b/src/primitives/canvas/widgets.zig index 25f80f7bf..6243bba30 100644 --- a/src/primitives/canvas/widgets.zig +++ b/src/primitives/canvas/widgets.zig @@ -761,6 +761,17 @@ pub const WidgetContextMenuItem = struct { separator: bool = false, }; +/// Which context menus a widget permits. `.automatic` preserves the +/// platform defaults (declared items first, then SDK text/terminal menus), +/// `.declared_only` suppresses those SDK defaults while retaining an +/// app-declared menu, and `.disabled` bypasses context-menu handling so the +/// secondary-button stream follows ordinary widget routing and capture. +pub const WidgetContextMenuPolicy = enum { + automatic, + declared_only, + disabled, +}; + /// Per-region edge behavior of a scroll container. `.default` follows /// the `ScrollPhysics.overscroll` design token (off by default: scroll /// regions pin at their content edges); `.none` and `.rubber_band` pin @@ -981,6 +992,10 @@ pub const Widget = struct { semantics: WidgetSemantics = .{}, /// App-declared native context menu for this widget (empty = none). context_menu: []const WidgetContextMenuItem = &.{}, + /// Context-menu selection policy. The default preserves every existing + /// declared and SDK-provided menu; `.disabled` also leaves secondary + /// input available to the ordinary pointer route. + context_menu_policy: WidgetContextMenuPolicy = .automatic, /// True when the runtime installed a native scroll driver for this /// `.scroll_view`: the engine's drawn scrollbar and kinetic physics /// stand down — the OS scroller owns feel and the overlay scroller. diff --git a/src/runtime/automation_snapshot.zig b/src/runtime/automation_snapshot.zig index 70f5831d6..979516926 100644 --- a/src/runtime/automation_snapshot.zig +++ b/src/runtime/automation_snapshot.zig @@ -234,6 +234,7 @@ pub fn RuntimeAutomationSnapshot(comptime Runtime: type) type { .text_selection = canvasTextRange(node.text_selection), .text_composition = canvasTextRange(node.text_composition), .context_menu = automationWidgetContextMenu(self, layout, node.id, menu_item_count), + .context_menu_policy = automationWidgetContextMenuPolicy(layout, node.id), }; widget_count.* += 1; } @@ -263,6 +264,12 @@ pub fn RuntimeAutomationSnapshot(comptime Runtime: type) type { return self.automation_widget_menu_items[start .. start + count]; } + fn automationWidgetContextMenuPolicy(layout: canvas.WidgetLayoutTree, id: canvas.ObjectId) []const u8 { + const node = layout.findById(id) orelse return ""; + if (node.widget.context_menu_policy == .automatic) return ""; + return @tagName(node.widget.context_menu_policy); + } + pub fn frameDiagnostics(self: *Runtime) FrameDiagnostics { return self.last_diagnostics; } diff --git a/src/runtime/automation_widget_dispatch.zig b/src/runtime/automation_widget_dispatch.zig index 14c912cb9..79cb5c627 100644 --- a/src/runtime/automation_widget_dispatch.zig +++ b/src/runtime/automation_widget_dispatch.zig @@ -198,14 +198,15 @@ pub fn RuntimeAutomationWidgetDispatch(comptime Runtime: type) type { /// replays, and resolves through `dispatchContextMenuAction` /// into the widget's `.context_menu` handler. Named errors say /// why an invocation cannot happen: no declared menu, an index - /// past the declared items, a separator slot, a disabled item — - /// the same items the snapshot lists per widget — or a dismissal - /// handler that presented a superseding menu, or closed the - /// target's view, mid-verb. + /// past the declared items, a separator slot, a disabled item or + /// widget policy — the same items and policy the snapshot lists — + /// or a dismissal handler that presented a superseding menu, or + /// closed the target's view, mid-verb. pub fn dispatchAutomationWidgetContextMenuItem(self: *Runtime, app: runtime_api.App(Runtime), item: automation_commands.AutomationWidgetContextMenuItem) anyerror!void { const view_index = try automationWidgetTargetViewIndex(self, item.target); const node_index = self.views[view_index].canvasWidgetNodeIndexById(item.target.id) orelse return error.InvalidCommand; const widget = self.views[view_index].widget_layout_nodes[node_index].widget; + if (widget.context_menu_policy == .disabled) return error.ContextMenuDisabled; if (widget.context_menu.len == 0) return error.ContextMenuUndeclared; if (item.item_index >= widget.context_menu.len) return error.ContextMenuItemOutOfRange; const declared = widget.context_menu[item.item_index]; diff --git a/src/runtime/canvas_widget_context_menu.zig b/src/runtime/canvas_widget_context_menu.zig index d69aafa2e..63b7c0d43 100644 --- a/src/runtime/canvas_widget_context_menu.zig +++ b/src/runtime/canvas_widget_context_menu.zig @@ -20,6 +20,8 @@ //! native menu they present no synthesized surface (there are no //! app-declared items to mount), while their available keyboard paths //! remain unchanged. +//! `ElementOptions.context_menu_policy` can keep only tier 1 or bypass +//! this menu path entirely; its `.automatic` default preserves the order. //! //! Presentation is asynchronous (macOS `popUpMenuPositioningItem` runs a //! nested tracking loop): the platform emits a `context_menu_action` @@ -93,12 +95,22 @@ pub fn RuntimeCanvasWidgetContextMenu(comptime Runtime: type) type { /// must be consumed by the context-menu path instead of the /// primary pointer pipeline (a right-click must never act as a /// press). - pub fn canvasWidgetContextPointerInput(input_event: platform.GpuSurfaceInputEvent) bool { + pub fn canvasWidgetContextPointerInput(self: *Runtime, input_event: platform.GpuSurfaceInputEvent) bool { if (input_event.button != 1) return false; - return switch (input_event.kind) { + const context_pointer = switch (input_event.kind) { .pointer_down, .pointer_up, .pointer_drag, .pointer_move, .pointer_cancel => true, else => false, }; + if (!context_pointer) return false; + + // A disabled policy turns the whole secondary-button lifetime + // back into ordinary pointer input. Route here before the menu + // branch so the initiating down and its captured drag/up agree, + // even when the pointer leaves the widget before release. + const routed = CanvasWidgetEventMethods().routeCanvasWidgetPointerInput(self, input_event, &self.widget_event_route_entries) catch return true; + const pointer_event = routed orelse return true; + const index = runtimeFindViewIndex(self, input_event.window_id, input_event.label) orelse return true; + return contextMenuPolicyForRoute(self, index, pointer_event.route) != .disabled; } /// Present the context menu for a secondary-button press: hit-test @@ -190,6 +202,18 @@ pub fn RuntimeCanvasWidgetContextMenu(comptime Runtime: type) type { return; } + // `.declared_only` stops after the declared-menu tier. Keep the + // existing context-press fallback (the on-hold alternative), + // but never synthesize an SDK text/terminal menu. + if (contextMenuPolicyForRoute(self, index, pointer_event.route) == .declared_only) { + try self.dispatchEvent(app, .{ .canvas_widget_context_press = .{ + .window_id = input_event.window_id, + .view_label = self.views[index].label, + .press_target = pointer_event.press_target, + } }); + return; + } + // 2/3. Editable text and terminal targets: standard menus // wired to the existing clipboard and committed-input // paths. Focus the target first so paste lands where the @@ -548,7 +572,7 @@ pub fn RuntimeCanvasWidgetContextMenu(comptime Runtime: type) type { for (route) |entry| { if (entry.node_index >= self.views[view_index].widget_layout_node_count) continue; const node = self.views[view_index].widget_layout_nodes[entry.node_index]; - if (node.widget.context_menu.len == 0 or node.widget.state.disabled) continue; + if (node.widget.context_menu.len == 0 or node.widget.state.disabled or node.widget.context_menu_policy == .disabled) continue; if (result == null or node.depth >= result_depth) { result = entry.node_index; result_depth = node.depth; @@ -557,6 +581,27 @@ pub fn RuntimeCanvasWidgetContextMenu(comptime Runtime: type) type { return result; } + /// The deepest explicit policy on a hit route governs the surface. + /// This lets a composite widget suppress menus for its plain-text + /// descendants while the default `.automatic` adds no inheritance + /// or behavior change to existing trees. + fn contextMenuPolicyForRoute(self: *const Runtime, view_index: usize, route: []const canvas.WidgetEventRouteEntry) canvas.WidgetContextMenuPolicy { + var policy: canvas.WidgetContextMenuPolicy = .automatic; + var policy_depth: usize = 0; + var found = false; + for (route) |entry| { + if (entry.node_index >= self.views[view_index].widget_layout_node_count) continue; + const node = self.views[view_index].widget_layout_nodes[entry.node_index]; + if (node.widget.context_menu_policy == .automatic) continue; + if (!found or node.depth >= policy_depth) { + policy = node.widget.context_menu_policy; + policy_depth = node.depth; + found = true; + } + } + return policy; + } + fn CanvasWidgetEventMethods() type { return runtime_canvas_widget_events.RuntimeCanvasWidgetEvents(Runtime); } diff --git a/src/runtime/canvas_widget_context_menu_tests.zig b/src/runtime/canvas_widget_context_menu_tests.zig index a5a4fa112..efb9ed0e5 100644 --- a/src/runtime/canvas_widget_context_menu_tests.zig +++ b/src/runtime/canvas_widget_context_menu_tests.zig @@ -21,6 +21,10 @@ const canvas_limits = @import("canvas_limits.zig"); const MenuTestApp = struct { pointer_count: u32 = 0, raw_input_count: u32 = 0, + context_press_count: u32 = 0, + last_pointer_target: canvas.ObjectId = 0, + last_pointer_captured: canvas.ObjectId = 0, + last_pointer_phase: canvas.WidgetPointerPhase = .hover, menu_count: u32 = 0, last_menu_target: canvas.ObjectId = 0, last_menu_item_index: usize = 0, @@ -64,8 +68,14 @@ const MenuTestApp = struct { fn event(context: *anyopaque, runtime: *Runtime, event_value: Event) anyerror!void { const self: *@This() = @ptrCast(@alignCast(context)); switch (event_value) { - .canvas_widget_pointer => self.pointer_count += 1, + .canvas_widget_pointer => |pointer_event| { + self.pointer_count += 1; + self.last_pointer_target = if (pointer_event.target) |target| target.id else 0; + self.last_pointer_captured = pointer_event.pointer.captured_id orelse 0; + self.last_pointer_phase = pointer_event.pointer.phase; + }, .gpu_surface_input => self.raw_input_count += 1, + .canvas_widget_context_press => self.context_press_count += 1, .canvas_widget_context_menu => |menu_event| { self.menu_count += 1; self.last_menu_target = menu_event.target_id; @@ -178,6 +188,7 @@ test "right click over a widget with a declared menu presents it natively and di .frame = geometry.RectF.init(10, 10, 200, 40), .text = "Task", .context_menu = &items, + .context_menu_policy = .declared_only, }; var nodes: [2]canvas.WidgetLayoutNode = undefined; const layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &.{row} }, geometry.RectF.init(0, 0, 320, 200), &nodes); @@ -511,6 +522,8 @@ test "right click on a terminal presents Copy and Paste wired to selection and c // Secondary-click focus ensures the eventual paste addresses this // terminal even when another editor previously owned focus. try std.testing.expectEqual(@as(canvas.ObjectId, 2), harness.runtime.views[0].canvas_widget_focused_id); + try std.testing.expectEqual(canvas.WidgetContextMenuPolicy.automatic, harness.runtime.views[0].widget_layout_nodes[1].widget.context_menu_policy); + try std.testing.expectEqual(@as(u32, 0), app_state.pointer_count); try harness.runtime.dispatchPlatformEvent(app, menuAction(harness.null_platform.context_menu_token, 2)); var clipboard_buffer: [64]u8 = undefined; @@ -545,6 +558,100 @@ test "right click on a terminal presents Copy and Paste wired to selection and c try std.testing.expectEqualStrings(shortcut_paste, app_state.last_edit_insert[0..app_state.last_edit_insert_len]); } +test "disabled terminal context menus bypass menu handling and retain pointer routing capture and semantics" { + var app_state: MenuTestApp = .{}; + const app = app_state.app(); + const harness = try createMenuHarness(app); + defer harness.destroy(std.testing.allocator); + + var grid = canvas.TerminalGrid{ + .background = canvas.Color.rgba(0, 0, 0, 1), + .foreground = canvas.Color.rgba(1, 1, 1, 1), + .cursor_color = canvas.Color.rgba(1, 1, 1, 1), + .selection_color = canvas.Color.rgba(0, 0.5, 1, 1), + .screen_text = "alpha beta", + }; + const terminal = canvas.Widget{ + .id = 2, + .kind = .terminal, + .frame = geometry.RectF.init(12, 16, 280, 120), + .text = grid.screen_text, + .terminal = .{ .pty = 7, .grid = &grid }, + .context_menu_policy = .disabled, + .semantics = .{ .label = "Session" }, + }; + var nodes: [2]canvas.WidgetLayoutNode = undefined; + const layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &.{terminal} }, geometry.RectF.init(0, 0, 320, 200), &nodes); + _ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout); + + // The terminal remains the terminal: textbox semantics expose its live + // value, and hover keeps the native I-beam cursor. + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = "canvas", + .kind = .pointer_move, + .x = 100, + .y = 40, + } }); + try std.testing.expectEqual(platform.Cursor.text, harness.runtime.views[0].canvas_widget_cursor); + const retained = try harness.runtime.canvasWidgetLayout(1, "canvas"); + try std.testing.expectEqual(canvas.WidgetKind.terminal, retained.nodes[1].widget.kind); + try std.testing.expectEqualStrings("alpha beta", retained.nodes[1].widget.text); + try std.testing.expectEqual(canvas.WidgetContextMenuPolicy.disabled, retained.nodes[1].widget.context_menu_policy); + + const snapshot = harness.runtime.automationSnapshot("Terminal policy"); + var terminal_snapshot: ?automation.snapshot.Widget = null; + for (snapshot.widgets) |widget| { + if (widget.id == 2) terminal_snapshot = widget; + } + try std.testing.expectEqualStrings("textbox", terminal_snapshot.?.role); + try std.testing.expectEqualStrings("alpha beta", terminal_snapshot.?.text_value); + try std.testing.expectEqualStrings("disabled", terminal_snapshot.?.context_menu_policy); + var snapshot_buffer: [16384]u8 = undefined; + var snapshot_writer = std.Io.Writer.fixed(&snapshot_buffer); + try automation.snapshot.writeText(snapshot, &snapshot_writer); + try std.testing.expect(std.mem.indexOf(u8, snapshot_writer.buffered(), "context_menu_policy=disabled") != null); + + app_state.pointer_count = 0; + app_state.raw_input_count = 0; + try harness.runtime.dispatchPlatformEvent(app, rightClick(100, 40)); + try std.testing.expectEqual(@as(usize, 0), harness.null_platform.context_menu_request_count); + try std.testing.expectEqual(@as(u32, 0), app_state.request_count); + try std.testing.expectEqual(@as(u32, 0), app_state.context_press_count); + try std.testing.expectEqual(@as(u32, 1), app_state.pointer_count); + try std.testing.expectEqual(@as(canvas.ObjectId, 2), app_state.last_pointer_target); + try std.testing.expectEqual(@as(canvas.ObjectId, 2), harness.runtime.views[0].canvas_widget_pressed_id); + + // Ordinary routing owns the gesture lifetime: movement and release + // outside the terminal remain captured by the terminal target. + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = "canvas", + .kind = .pointer_drag, + .x = 310, + .y = 180, + .delta_x = 210, + .delta_y = 140, + } }); + try std.testing.expectEqual(canvas.WidgetPointerPhase.move, app_state.last_pointer_phase); + try std.testing.expectEqual(@as(canvas.ObjectId, 2), app_state.last_pointer_target); + try std.testing.expectEqual(@as(canvas.ObjectId, 2), app_state.last_pointer_captured); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = "canvas", + .kind = .pointer_up, + .button = 1, + .x = 310, + .y = 180, + } }); + try std.testing.expectEqual(canvas.WidgetPointerPhase.up, app_state.last_pointer_phase); + try std.testing.expectEqual(@as(canvas.ObjectId, 2), app_state.last_pointer_target); + try std.testing.expectEqual(@as(canvas.ObjectId, 2), app_state.last_pointer_captured); + try std.testing.expectEqual(@as(canvas.ObjectId, 0), harness.runtime.views[0].canvas_widget_pressed_id); + try std.testing.expectEqual(@as(u32, 3), app_state.pointer_count); + try std.testing.expectEqual(@as(u32, 3), app_state.raw_input_count); +} + test "terminal Paste disables after exit and a pending live menu revalidates before dispatch" { var app_state: MenuTestApp = .{}; const app = app_state.app(); @@ -725,8 +832,16 @@ test "the widget-context-menu verb dispatches selections through context_menu_ac .frame = geometry.RectF.init(10, 60, 200, 40), .text = "Bare", }; - var nodes: [3]canvas.WidgetLayoutNode = undefined; - const layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &.{ row, plain } }, geometry.RectF.init(0, 0, 320, 200), &nodes); + const disabled = canvas.Widget{ + .id = 4, + .kind = .list_item, + .frame = geometry.RectF.init(10, 110, 200, 40), + .text = "Disabled menu", + .context_menu = &items, + .context_menu_policy = .disabled, + }; + var nodes: [4]canvas.WidgetLayoutNode = undefined; + const layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &.{ row, plain, disabled } }, geometry.RectF.init(0, 0, 320, 200), &nodes); _ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout); // Invoking "Delete" (index 2) takes the same dispatch a real pick @@ -745,6 +860,7 @@ test "the widget-context-menu verb dispatches selections through context_menu_ac try std.testing.expectError(error.ContextMenuItemOutOfRange, harness.runtime.dispatchAutomationCommand(app, "widget-context-menu canvas 2 4")); try std.testing.expectError(error.ContextMenuItemSeparator, harness.runtime.dispatchAutomationCommand(app, "widget-context-menu canvas 2 1")); try std.testing.expectError(error.ContextMenuItemDisabled, harness.runtime.dispatchAutomationCommand(app, "widget-context-menu canvas 2 3")); + try std.testing.expectError(error.ContextMenuDisabled, harness.runtime.dispatchAutomationCommand(app, "widget-context-menu canvas 4 0")); try std.testing.expectEqual(@as(u32, 1), app_state.menu_count); } @@ -1064,6 +1180,7 @@ test "automation snapshots list each widget's declared context-menu items in inv .frame = geometry.RectF.init(10, 10, 200, 40), .text = "Task", .context_menu = &items, + .context_menu_policy = .declared_only, }; var nodes: [2]canvas.WidgetLayoutNode = undefined; const layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &.{row} }, geometry.RectF.init(0, 0, 320, 200), &nodes); @@ -1075,6 +1192,7 @@ test "automation snapshots list each widget's declared context-menu items in inv // List position = the widget-context-menu item index; separators // keep their slots and disabled items say so. try std.testing.expect(std.mem.indexOf(u8, writer.buffered(), "context_menu=[\"Complete\",separator,\"Archive\"(disabled)]") != null); + try std.testing.expect(std.mem.indexOf(u8, writer.buffered(), "context_menu_policy=declared_only") != null); } test "automation snapshots report per-view context-menu item headroom" { diff --git a/src/runtime/gpu_surface_events.zig b/src/runtime/gpu_surface_events.zig index 81ed60436..c25111c2b 100644 --- a/src/runtime/gpu_surface_events.zig +++ b/src/runtime/gpu_surface_events.zig @@ -198,10 +198,11 @@ pub fn RuntimeGpuSurfaceEvents(comptime Runtime: type) type { // pacing no-op. self.options.platform.services.noteGpuSurfaceInput(input_event.window_id, input_event.label) catch {}; // Secondary-button (right/ctrl-click, touch long-press) input - // is the context-menu gesture: the press presents the - // native menu and the whole button-1 stream is consumed so a - // right-click never acts as a primary press. - if (ContextMenuMethods().canvasWidgetContextPointerInput(input_event)) { + // is normally the context-menu gesture: the press presents the + // native menu and the whole button-1 stream is consumed. A + // widget with context-menu policy `.disabled` deliberately + // keeps the stream on the ordinary routed/captured path. + if (ContextMenuMethods().canvasWidgetContextPointerInput(self, input_event)) { if (runtimeFindViewIndex(self, input_event.window_id, input_event.label)) |index| { self.views[index].recordGpuSurfaceInputTimestamp(input_event.timestamp_ns); // A consumed cancel is still the pointer leaving the From c8a64333bc221f2073884c9607490a3bdb92d8ac Mon Sep 17 00:00:00 2001 From: phall Date: Mon, 3 Aug 2026 06:03:43 -0400 Subject: [PATCH 2/7] fix(canvas): retain secondary gesture ownership --- changelog.d/widget-context-menu-policy.md | 1 + src/runtime/canvas_widget_context_menu.zig | 86 +++++++++-- .../canvas_widget_context_menu_tests.zig | 145 ++++++++++++++++++ src/runtime/gpu_surface_events.zig | 11 +- src/runtime/view.zig | 17 ++ 5 files changed, 237 insertions(+), 23 deletions(-) diff --git a/changelog.d/widget-context-menu-policy.md b/changelog.d/widget-context-menu-policy.md index ec34f9a1b..3730448b9 100644 --- a/changelog.d/widget-context-menu-policy.md +++ b/changelog.d/widget-context-menu-policy.md @@ -1 +1,2 @@ feature: **Per-widget context-menu policy**: Zig views can retain native widget semantics and pointer routing while choosing automatic SDK menus, app-declared menus only, or no menu handling for a widget. +- **Gesture-stable ownership**: secondary-button routing is fixed at pointer-down and retained through matching release or cancellation, so rebuilds cannot leak menu gestures into capture or strand ordinary capture. diff --git a/src/runtime/canvas_widget_context_menu.zig b/src/runtime/canvas_widget_context_menu.zig index 63b7c0d43..15a5b24a8 100644 --- a/src/runtime/canvas_widget_context_menu.zig +++ b/src/runtime/canvas_widget_context_menu.zig @@ -91,26 +91,66 @@ pub const PendingCanvasWidgetContextMenu = struct { pub fn RuntimeCanvasWidgetContextMenu(comptime Runtime: type) type { return struct { - /// True when the input event belongs to the secondary button and - /// must be consumed by the context-menu path instead of the - /// primary pointer pipeline (a right-click must never act as a - /// press). + /// Choose context-menu versus ordinary routing on secondary down, + /// then retain that owner through the matching pointer's later + /// phases. Widget-tree rebuilds may change policy but never an + /// in-flight gesture. Returns true for the consumed menu owner. pub fn canvasWidgetContextPointerInput(self: *Runtime, input_event: platform.GpuSurfaceInputEvent) bool { - if (input_event.button != 1) return false; - const context_pointer = switch (input_event.kind) { + const pointer_phase = switch (input_event.kind) { .pointer_down, .pointer_up, .pointer_drag, .pointer_move, .pointer_cancel => true, else => false, }; - if (!context_pointer) return false; - - // A disabled policy turns the whole secondary-button lifetime - // back into ordinary pointer input. Route here before the menu - // branch so the initiating down and its captured drag/up agree, - // even when the pointer leaves the widget before release. - const routed = CanvasWidgetEventMethods().routeCanvasWidgetPointerInput(self, input_event, &self.widget_event_route_entries) catch return true; - const pointer_event = routed orelse return true; - const index = runtimeFindViewIndex(self, input_event.window_id, input_event.label) orelse return true; - return contextMenuPolicyForRoute(self, index, pointer_event.route) != .disabled; + if (!pointer_phase) return false; + const index = runtimeFindViewIndex(self, input_event.window_id, input_event.label) orelse return input_event.button == 1; + const view = &self.views[index]; + + if (input_event.kind == .pointer_down) { + if (input_event.button != 1) return false; + // A new secondary down supersedes any stale owner on this + // view, matching the existing one-pressed-widget capacity. + const routed = CanvasWidgetEventMethods().routeCanvasWidgetPointerInput(self, input_event, &self.widget_event_route_entries) catch { + view.canvas_widget_secondary_gesture_owner = .context_menu; + view.canvas_widget_secondary_gesture_pointer_id = input_event.pointer_id; + return true; + }; + const owner: @TypeOf(view.canvas_widget_secondary_gesture_owner) = if (routed) |pointer_event| + if (contextMenuPolicyForRoute(self, index, pointer_event.route) == .disabled) .ordinary else .context_menu + else + .context_menu; + view.canvas_widget_secondary_gesture_owner = owner; + view.canvas_widget_secondary_gesture_pointer_id = input_event.pointer_id; + return owner == .context_menu; + } + + const owner = view.canvas_widget_secondary_gesture_owner; + if (owner == .none) { + // A labelled secondary phase with no matching down stays + // consumed, preserving the pre-policy fail-closed behavior. + return input_event.button == 1; + } + if (owner == .context_menu and input_event.button != 1 and view.canvas_widget_pressed_id != 0) { + // Async presenters (GTK) can leave the menu open while an + // ordinary primary press rebuilds the underlying app. That + // press owns the shared pressed-widget slot; route its + // buttonless drag/up/cancel ordinarily without retiring the + // still-open menu gesture (the menu action or secondary + // terminal phase does that). + return false; + } + if (view.canvas_widget_secondary_gesture_pointer_id != input_event.pointer_id) { + // One view has one pressed-widget slot. Until the owning + // pointer ends, no other pointer phase may enter that shared + // pipeline and release or replace its capture, even when the + // host leaves button=0 on the later phase. + return true; + } + + const consume = owner == .context_menu; + if (input_event.kind == .pointer_up or input_event.kind == .pointer_cancel) { + view.canvas_widget_secondary_gesture_owner = .none; + view.canvas_widget_secondary_gesture_pointer_id = 0; + } + return consume; } /// Present the context menu for a secondary-button press: hit-test @@ -396,6 +436,12 @@ pub fn RuntimeCanvasWidgetContextMenu(comptime Runtime: type) type { // the main queue); GTK popovers are asynchronous and CAN. if (pending.window_id != event.window_id or pending.token != event.token) return; self.canvas_widget_context_menu_pending = null; + // Native presenters may resolve selection/dismissal without + // forwarding the physical secondary up (the platform menu owns + // that tracking loop). The action is therefore a second valid + // terminal for a menu-owned gesture. Never clear `.ordinary`: + // policy-disabled capture still requires its matching up/cancel. + clearCanvasWidgetContextGesture(self, pending.window_id, pending.viewLabel()); if (event.item_id == 0) { // Dismissed without a selection. App menus tell the app: // UiApp disarms the token's presented-items snapshot and @@ -602,6 +648,14 @@ pub fn RuntimeCanvasWidgetContextMenu(comptime Runtime: type) type { return policy; } + fn clearCanvasWidgetContextGesture(self: *Runtime, window_id: platform.WindowId, label: []const u8) void { + const index = runtimeFindViewIndex(self, window_id, label) orelse return; + const view = &self.views[index]; + if (view.canvas_widget_secondary_gesture_owner != .context_menu) return; + view.canvas_widget_secondary_gesture_owner = .none; + view.canvas_widget_secondary_gesture_pointer_id = 0; + } + fn CanvasWidgetEventMethods() type { return runtime_canvas_widget_events.RuntimeCanvasWidgetEvents(Runtime); } diff --git a/src/runtime/canvas_widget_context_menu_tests.zig b/src/runtime/canvas_widget_context_menu_tests.zig index efb9ed0e5..6f50ed506 100644 --- a/src/runtime/canvas_widget_context_menu_tests.zig +++ b/src/runtime/canvas_widget_context_menu_tests.zig @@ -171,6 +171,53 @@ fn createMenuHarness(app: App) !*TestHarness() { return harness; } +fn installTerminal( + harness: *TestHarness(), + grid: *canvas.TerminalGrid, + policy: canvas.WidgetContextMenuPolicy, + pty: u64, + text: []const u8, +) !void { + grid.screen_text = text; + const terminal = canvas.Widget{ + .id = 2, + .kind = .terminal, + .frame = geometry.RectF.init(12, 16, 280, 120), + .text = text, + .terminal = .{ .pty = pty, .grid = grid }, + .context_menu_policy = policy, + .semantics = .{ .label = "Session" }, + }; + var nodes: [2]canvas.WidgetLayoutNode = undefined; + const layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &.{terminal} }, geometry.RectF.init(0, 0, 320, 200), &nodes); + _ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout); +} + +fn testTerminalGrid() canvas.TerminalGrid { + return .{ + .background = canvas.Color.rgba(0, 0, 0, 1), + .foreground = canvas.Color.rgba(1, 1, 1, 1), + .cursor_color = canvas.Color.rgba(1, 1, 1, 1), + .selection_color = canvas.Color.rgba(0, 0.5, 1, 1), + }; +} + +fn secondaryPointer(kind: platform.GpuSurfaceInputKind, pointer_id: u64, x: f32, y: f32) platform.Event { + return .{ .gpu_surface_input = .{ + .window_id = 1, + .label = "canvas", + .kind = kind, + .button = switch (kind) { + .pointer_down, .pointer_up, .pointer_cancel => 1, + else => 0, + }, + .pointer_id = pointer_id, + .x = x, + .y = y, + .timestamp_ns = 1_000_000_000, + } }; +} + test "right click over a widget with a declared menu presents it natively and dispatches the selection" { var app_state: MenuTestApp = .{}; const app = app_state.app(); @@ -652,6 +699,104 @@ test "disabled terminal context menus bypass menu handling and retain pointer ro try std.testing.expectEqual(@as(u32, 3), app_state.raw_input_count); } +test "disabled secondary down retains ordinary capture through automatic terminal rebuild" { + var app_state: MenuTestApp = .{}; + const app = app_state.app(); + const harness = try createMenuHarness(app); + defer harness.destroy(std.testing.allocator); + + var grid = testTerminalGrid(); + try installTerminal(harness, &grid, .disabled, 7, "shell: idle"); + + try harness.runtime.dispatchPlatformEvent(app, secondaryPointer(.pointer_down, 41, 100, 40)); + try std.testing.expectEqual(@as(u32, 1), app_state.pointer_count); + try std.testing.expectEqual(@as(canvas.ObjectId, 2), harness.runtime.views[0].canvas_widget_pressed_id); + try std.testing.expectEqual(.ordinary, harness.runtime.views[0].canvas_widget_secondary_gesture_owner); + + // The model switches terminal process/mode while the gesture stands: + // same semantic terminal identity, new pty/text, automatic menu policy. + // Retained capture and gesture ownership must survive the adoption. + try installTerminal(harness, &grid, .automatic, 8, "agent: running"); + const rebuilt = try harness.runtime.canvasWidgetLayout(1, "canvas"); + try std.testing.expectEqual(@as(u64, 8), rebuilt.nodes[1].widget.terminal.pty); + try std.testing.expectEqualStrings("agent: running", rebuilt.nodes[1].widget.text); + try std.testing.expectEqual(@as(canvas.ObjectId, 2), harness.runtime.views[0].canvas_widget_pressed_id); + + // A different pointer's buttonless release is consumed but cannot + // terminate pointer 41's ordinary gesture or clear its shared capture. + var other_pointer_up = secondaryPointer(.pointer_up, 42, 100, 40); + other_pointer_up.gpu_surface_input.button = 0; + try harness.runtime.dispatchPlatformEvent(app, other_pointer_up); + try std.testing.expectEqual(@as(u32, 1), app_state.pointer_count); + try std.testing.expectEqual(@as(canvas.ObjectId, 2), harness.runtime.views[0].canvas_widget_pressed_id); + try std.testing.expectEqual(.ordinary, harness.runtime.views[0].canvas_widget_secondary_gesture_owner); + + // The matching up follows the down-time ordinary decision despite the + // live widget now requesting automatic menus, and releases capture. + try harness.runtime.dispatchPlatformEvent(app, secondaryPointer(.pointer_up, 41, 100, 40)); + try std.testing.expectEqual(@as(u32, 2), app_state.pointer_count); + try std.testing.expectEqual(canvas.WidgetPointerPhase.up, app_state.last_pointer_phase); + try std.testing.expectEqual(@as(canvas.ObjectId, 2), app_state.last_pointer_target); + try std.testing.expectEqual(@as(canvas.ObjectId, 2), app_state.last_pointer_captured); + try std.testing.expectEqual(@as(canvas.ObjectId, 0), harness.runtime.views[0].canvas_widget_pressed_id); + try std.testing.expectEqual(.none, harness.runtime.views[0].canvas_widget_secondary_gesture_owner); + try std.testing.expectEqual(@as(usize, 0), harness.null_platform.context_menu_request_count); +} + +test "disabled secondary down retains ordinary cancel through declared policy rebuild" { + var app_state: MenuTestApp = .{}; + const app = app_state.app(); + const harness = try createMenuHarness(app); + defer harness.destroy(std.testing.allocator); + + var grid = testTerminalGrid(); + try installTerminal(harness, &grid, .disabled, 7, "shell: idle"); + try harness.runtime.dispatchPlatformEvent(app, secondaryPointer(.pointer_down, 51, 100, 40)); + try installTerminal(harness, &grid, .declared_only, 9, "shell: command mode"); + + // A secondary-labelled cancel still follows the down-time ordinary + // owner after policy changes, clearing pressed capture. + try harness.runtime.dispatchPlatformEvent(app, secondaryPointer(.pointer_cancel, 51, 100, 40)); + try std.testing.expectEqual(@as(u32, 2), app_state.pointer_count); + try std.testing.expectEqual(canvas.WidgetPointerPhase.cancel, app_state.last_pointer_phase); + try std.testing.expectEqual(@as(canvas.ObjectId, 2), app_state.last_pointer_target); + try std.testing.expectEqual(@as(canvas.ObjectId, 2), app_state.last_pointer_captured); + try std.testing.expectEqual(@as(canvas.ObjectId, 0), harness.runtime.views[0].canvas_widget_pressed_id); + try std.testing.expectEqual(.none, harness.runtime.views[0].canvas_widget_secondary_gesture_owner); + try std.testing.expectEqual(@as(usize, 0), harness.null_platform.context_menu_request_count); +} + +test "automatic terminal menu gesture stays consumed after disabled rebuild" { + var app_state: MenuTestApp = .{}; + const app = app_state.app(); + const harness = try createMenuHarness(app); + defer harness.destroy(std.testing.allocator); + + var grid = testTerminalGrid(); + try installTerminal(harness, &grid, .automatic, 7, "shell: idle"); + try harness.runtime.dispatchPlatformEvent(app, secondaryPointer(.pointer_down, 61, 100, 40)); + try std.testing.expectEqual(@as(usize, 1), harness.null_platform.context_menu_request_count); + try std.testing.expectEqual(@as(u32, 0), app_state.pointer_count); + try std.testing.expectEqual(@as(canvas.ObjectId, 0), harness.runtime.views[0].canvas_widget_pressed_id); + try std.testing.expectEqual(.context_menu, harness.runtime.views[0].canvas_widget_secondary_gesture_owner); + + try installTerminal(harness, &grid, .disabled, 8, "agent: running"); + // Motion commonly carries button=0. The menu-owned decision still + // consumes it, so no ordinary press/capture appears after the rebuild. + try harness.runtime.dispatchPlatformEvent(app, secondaryPointer(.pointer_drag, 61, 140, 70)); + try harness.runtime.dispatchPlatformEvent(app, secondaryPointer(.pointer_up, 61, 140, 70)); + try std.testing.expectEqual(@as(u32, 0), app_state.pointer_count); + try std.testing.expectEqual(@as(canvas.ObjectId, 0), harness.runtime.views[0].canvas_widget_pressed_id); + try std.testing.expectEqual(.none, harness.runtime.views[0].canvas_widget_secondary_gesture_owner); + try std.testing.expectEqual(@as(usize, 1), harness.null_platform.context_menu_request_count); + + // Default behavior remains repeatable after the gesture retires. + try installTerminal(harness, &grid, .automatic, 8, "agent: running"); + try harness.runtime.dispatchPlatformEvent(app, secondaryPointer(.pointer_down, 62, 100, 40)); + try std.testing.expectEqual(@as(usize, 2), harness.null_platform.context_menu_request_count); + try std.testing.expectEqual(@as(u32, 0), app_state.pointer_count); +} + test "terminal Paste disables after exit and a pending live menu revalidates before dispatch" { var app_state: MenuTestApp = .{}; const app = app_state.app(); diff --git a/src/runtime/gpu_surface_events.zig b/src/runtime/gpu_surface_events.zig index c25111c2b..5539b7fbd 100644 --- a/src/runtime/gpu_surface_events.zig +++ b/src/runtime/gpu_surface_events.zig @@ -210,13 +210,10 @@ pub fn RuntimeGpuSurfaceEvents(comptime Runtime: type) type { // the proven pointer's departure retires hover-Msg // containment too, so a window exit mid-secondary // stream never strands an entered element. Honesty - // about scope: only the secondary DOWN/UP/CANCEL - // stream is consumed here — hosts report drag - // MOTION without a button, so it rides the primary - // path and containment follows the pointer through - // a right-drag (the mouseenter/mouseleave - // convention), exactly as the wash does for the - // same journaled moves. + // Gesture ownership was fixed on down: a menu-owned + // drag stays consumed even on hosts whose motion leaves + // button=0, while a policy-disabled down keeps every + // matching phase on ordinary routing/capture. if (input_event.kind == .pointer_cancel and self.views[index].canvas_widget_hover_pointer_live and self.views[index].canvas_widget_hover_pointer_id == input_event.pointer_id) diff --git a/src/runtime/view.zig b/src/runtime/view.zig index 8f649ccff..d52761299 100644 --- a/src/runtime/view.zig +++ b/src/runtime/view.zig @@ -134,6 +134,17 @@ pub const CanvasWidgetClaimedKeyGrace = enum { } }; +/// Owner chosen by a secondary-button down for that gesture's full +/// lifetime. The SDK currently retains one pressed widget per view, so one +/// secondary gesture per view is the matching honest capacity; pointer_id +/// prevents a different pointer from terminating that standing gesture on +/// hosts that distinguish identities (desktop mouse hosts use id 0). +pub const CanvasWidgetSecondaryGestureOwner = enum { + none, + context_menu, + ordinary, +}; + /// Blur-side IME hygiene, shared by EVERY focus-mutation entry point — /// the pointer-driven focus move, the programmatic `focusView`, and the /// window-level `clearFocusedView` blur all route here so the class is @@ -567,6 +578,12 @@ pub const RuntimeView = struct { canvas_widget_focus_visible_keyboard: bool = false, canvas_widget_hovered_id: canvas.ObjectId = 0, canvas_widget_pressed_id: canvas.ObjectId = 0, + /// Context-menu versus ordinary routing is chosen exactly once on a + /// secondary down and survives widget-tree rebuilds until the matching + /// pointer's up/cancel. This prevents a policy change from leaking a + /// consumed menu gesture into capture, or stranding ordinary capture. + canvas_widget_secondary_gesture_owner: CanvasWidgetSecondaryGestureOwner = .none, + canvas_widget_secondary_gesture_pointer_id: u64 = 0, /// The STANDING hover-Msg containment chain: every widget on the /// last resolved raw hover hit's ancestor path that listens for /// hover Msgs (`Widget.hover_msgs`), outermost first From f3d6b5e7f10903177f20c2f09a356ed10a690e01 Mon Sep 17 00:00:00 2001 From: phall Date: Wed, 5 Aug 2026 02:51:47 -0400 Subject: [PATCH 3/7] fix(canvas): preserve full terminal viewport --- src/primitives/canvas/terminal_grid.zig | 129 +++++++++++------- src/primitives/canvas/terminal_grid_tests.zig | 41 +++++- 2 files changed, 118 insertions(+), 52 deletions(-) diff --git a/src/primitives/canvas/terminal_grid.zig b/src/primitives/canvas/terminal_grid.zig index f36fa7d8c..95b9e21bf 100644 --- a/src/primitives/canvas/terminal_grid.zig +++ b/src/primitives/canvas/terminal_grid.zig @@ -28,14 +28,14 @@ const canvas = @import("root.zig"); const geometry = @import("geometry"); const box = @import("terminal_box.zig"); -/// Grid ceilings, derived from the per-view canvas budgets: the glyph -/// budget bounds how many cells can hold ink at once, and the command -/// budget bounds per-row style runs. A viewport is clamped to these -/// before it reaches an emulator, so a huge window degrades to a -/// bounded grid instead of a budget error. +/// Grid ceilings bound allocation and command-id geometry. Cell count is not a +/// glyph-atlas proxy: the atlas charges distinct glyph/subpixel keys, while a +/// terminal commonly repeats a small alphabet across tens of thousands of +/// cells. The painter's own distinct-glyph, text, path, and command preflights +/// remain the resource fences. pub const max_cols: usize = 320; pub const max_rows: usize = 96; -pub const max_cells: usize = 7168; +pub const max_cells: usize = max_cols * max_rows; /// Painter budgets when the grid rides the WIDGET tree, where it shares /// the per-view stores with every other widget. The command reserve is @@ -225,15 +225,14 @@ pub fn cellMetrics(tokens: canvas.DesignTokens) TerminalCellMetrics { return .{ .width = width, .height = height, .font_size = font_size }; } -/// Clamp a proposed grid to the canvas budgets: the glyph budget bounds -/// total cells, so a very wide viewport trades rows for columns to stay -/// under the cell ceiling instead of overflowing the frame. +/// Clamp a proposed grid to the allocation and command-id geometry bounds. +/// Painter resources are content-dependent and are fenced during paint; they +/// must not shrink the PTY to a fraction of its visible pane. pub fn clampGrid(proposed_cols: usize, proposed_rows: usize) TerminalCellPos { - var c = std.math.clamp(proposed_cols, 2, max_cols); - var r = std.math.clamp(proposed_rows, 2, max_rows); - if (c * r > max_cells) r = @max(2, max_cells / c); - if (c * r > max_cells) c = @max(2, max_cells / r); - return .{ .x = @intCast(c), .y = @intCast(r) }; + return .{ + .x = @intCast(std.math.clamp(proposed_cols, 2, max_cols)), + .y = @intCast(std.math.clamp(proposed_rows, 2, max_rows)), + }; } // ------------------------------------------------------------- painting @@ -444,53 +443,83 @@ fn displayListPathElements(list: canvas.DisplayList) usize { return total; } -/// An UPPER BOUND on the display-list commands one row emits, assuming -/// no runs merge (the pathological worst case): a background run per -/// cell that carries one, plus the cell's ink — eight commands for a -/// pure-double box joint, two (text run + underline) for a styled -/// character, none for an empty cell — plus one selection wash. Real -/// rows merge runs and cost far less; this bound is what the per-row -/// preflight checks so a row is only started when it can finish whole, -/// and it is content-accurate (a cheap ASCII row costs ~2/column, not a -/// flat worst-case-per-column reserve that would starve wide grids). +fn multiCodepointCluster(cell: TerminalCell) bool { + if (cell.cp == 0 or cell.cluster.len == 0) return false; + const primary_len = std.unicode.utf8CodepointSequenceLength(cell.cp) catch 1; + return cell.cluster.len > primary_len; +} + +/// Exact upper bound on the commands emitted for one row. This mirrors the +/// painter's background and ink run boundaries instead of pricing every cell +/// as an independent background + text command. Full-screen TUIs often carry +/// hundreds of sparse colored cells per row; pessimistically charging their +/// empty spans was truncating otherwise representable screens halfway down. fn rowCommandCost(row: TerminalRow) usize { - var total: usize = 1; // the selection wash + var total: usize = if (row.selection != null) 1 else 0; + + // Backgrounds emit one command per contiguous same-color run. + var run_color: ?canvas.Color = null; + var background_index: usize = 0; + while (background_index <= row.cells.len) : (background_index += 1) { + const bg: ?canvas.Color = if (background_index < row.cells.len) + row.cells[background_index].bg + else + null; + if (run_color) |color| { + const same = if (bg) |next| colorEql(color, next) else false; + if (!same) { + total += 1; + run_color = bg; + } + } else if (bg != null) { + run_color = bg; + } + } + + // Ink emits merged text or box runs plus an underline only when present. var i: usize = 0; - while (i < row.cells.len) : (i += 1) { + while (i < row.cells.len) { const cell = row.cells[i]; - if (cell.wide == .spacer) continue; - if (cell.bg != null) total += 1; - if (cell.cp == 0) continue; + if (cell.wide == .spacer or cell.cp == 0) { + i += 1; + continue; + } if (box.isBoxDrawing(cell.cp)) { + var span: usize = 1; if (box.mergesHorizontally(cell.cp)) { - // A horizontally-merged run (a long `─` border) paints - // ONE geometry command plus a possible underline, no - // matter how wide — mirror the paint loop's merge (same - // code point, foreground, and underline) so the estimate - // does not charge nine per column for what collapses to - // two commands. Backgrounds still count per cell (their - // own run pass is separate). - var span: usize = 1; while (i + span < row.cells.len) : (span += 1) { const next = row.cells[i + span]; if (next.cp != cell.cp or !colorEql(next.fg, cell.fg) or next.underline != cell.underline) break; - if (next.bg != null) total += 1; } - // A merged run's worst-case ink: a double piece (═) - // paints TWO parallel bars, plus a possible underline. - total += 3; - i += span - 1; - } else { - // The glyph's own worst case (kept lockstep with the - // paint switch by `box.maxCommands`) plus a possible - // underline — a row of one-bar pieces costs what it - // paints, never a flat joint-worst-case that starves - // wide grids. - total += box.maxCommands(cell.cp) + 1; } - } else { - total += 2; // the text run plus its underline + total += box.maxCommands(cell.cp) + @intFromBool(cell.underline); + i += span; + continue; + } + + // Combining clusters deliberately paint alone. Plain cells merge while + // foreground, decoration, and scratch capacity agree. + if (multiCodepointCluster(cell)) { + total += 1 + @intFromBool(cell.underline); + i += 1; + continue; + } + const fg = cell.fg; + const underline = cell.underline; + var text_bytes = cell.cluster.len; + var span: usize = 1; + while (i + span < row.cells.len) : (span += 1) { + const next = row.cells[i + span]; + if (next.wide == .spacer or next.cp == 0 or box.isBoxDrawing(next.cp) or + multiCodepointCluster(next) or !colorEql(next.fg, fg) or + next.underline != underline or text_bytes + next.cluster.len > text_scratch_bytes) + { + break; + } + text_bytes += next.cluster.len; } + total += 1 + @intFromBool(underline); + i += span; } return total; } diff --git a/src/primitives/canvas/terminal_grid_tests.zig b/src/primitives/canvas/terminal_grid_tests.zig index ef7c02776..2b8c5e10a 100644 --- a/src/primitives/canvas/terminal_grid_tests.zig +++ b/src/primitives/canvas/terminal_grid_tests.zig @@ -750,10 +750,11 @@ test "the widget diff reports paint damage for a changed bound grid" { try testing.expect(saw_paint_dirty); } -test "clampGrid trades rows for columns under the cell ceiling" { +test "clampGrid preserves full bounded viewport geometry" { const clamped = grid_model.clampGrid(400, 100); try testing.expectEqual(@as(u16, grid_model.max_cols), clamped.x); - try testing.expect(@as(usize, clamped.x) * @as(usize, clamped.y) <= grid_model.max_cells); + try testing.expectEqual(@as(u16, grid_model.max_rows), clamped.y); + try testing.expectEqual(grid_model.max_cols * grid_model.max_rows, grid_model.max_cells); const tiny = grid_model.clampGrid(0, 0); try testing.expectEqual(@as(u16, 2), tiny.x); try testing.expectEqual(@as(u16, 2), tiny.y); @@ -922,6 +923,42 @@ test "a wide cheap terminal paints its rows under the widget command budget" { try testing.expectEqual(@as(usize, 6), text_rows); } +test "a tall sparse colored terminal paints every representable row" { + // cmatrix-like content: a tall viewport with sparse colored streaks. The + // old per-cell upper bound charged every empty span and stopped around the + // middle even though actual merged runs fit comfortably in the envelope. + const row_count = 60; + const col_count = 200; + var storage: [row_count][col_count]grid_model.TerminalCell = @splat(@splat(.{})); + var rows: [row_count]grid_model.TerminalRow = undefined; + for (&storage, 0..) |*row_cells, row_index| { + for (0..10) |streak| { + const col = (streak * 19 + row_index * 3) % col_count; + row_cells[col] = cell('x', "x", if (streak % 3 == 0) white else red); + } + rows[row_index] = .{ .cells = row_cells }; + } + + var commands: [2048]canvas.CanvasCommand = undefined; + var builder = try paintInto(baseGrid(&rows), &commands, .{ + .frame = geometry.RectF.init(0, 0, 1600, 1200), + .tokens = .{}, + .command_budget = 1792, + }); + var painted_rows: usize = 0; + var last_y: ?f32 = null; + for (builder.displayList().commands) |command| { + if (command != .draw_text) continue; + const y = command.draw_text.origin.y; + if (last_y == null or last_y.? != y) { + painted_rows += 1; + last_y = y; + } + } + try testing.expectEqual(@as(usize, row_count), painted_rows); + try testing.expect(builder.displayList().commands.len <= 1792); +} + test "a focused bound terminal's ring id never collides with a grid command" { const row_cells = comptime asciiRow("shell", white); const rows = [_]grid_model.TerminalRow{.{ .cells = &row_cells }}; From 3883c127d8976ce8ce1e4728a6e28ff32660795a Mon Sep 17 00:00:00 2001 From: phall Date: Wed, 5 Aug 2026 03:05:52 -0400 Subject: [PATCH 4/7] fix(canvas): admit complete terminal fonts --- src/primitives/canvas/font_ttf.zig | 13 +++++++------ src/primitives/canvas/font_ttf_tests.zig | 8 ++++++-- src/primitives/canvas/reference.zig | 7 +++---- src/primitives/canvas/vector.zig | 17 ++++++++--------- 4 files changed, 24 insertions(+), 21 deletions(-) diff --git a/src/primitives/canvas/font_ttf.zig b/src/primitives/canvas/font_ttf.zig index e43b35790..4cc91d3a6 100644 --- a/src/primitives/canvas/font_ttf.zig +++ b/src/primitives/canvas/font_ttf.zig @@ -52,11 +52,12 @@ pub const Error = error{ /// Noto Sans KR 479 61 0 0 0 0 /// Noto Serif JP 465 40 0 0 0 0 /// Yuji Mai (brush kanji) 738 22 198 5 1 3 +/// JetBrainsMono Nerd 4050 132 79 4 1 3 /// -/// 1024 points / 128 contours cover the densest measured glyph (Yuji -/// Mai's 738-point brush kanji; Noto Sans TC's 685) with ~1.4x headroom; -/// the depth/element budgets carry 1.3-2x over the deepest measured -/// use (the bundled Geist's own accent stacking). +/// 4096 points / 256 contours cover the complete patched terminal face as +/// well as the measured CJK/brush faces. The high point count comes from a +/// handful of dense icon outlines, not ordinary text; registration remains +/// bounded and rejects anything beyond this measured production ceiling. /// /// The composite budgets bound a composite glyph's FLATTENED outline /// (`maxp.maxCompositePoints`/`maxCompositeContours`: totals across the @@ -75,8 +76,8 @@ pub const Error = error{ /// ONE frame at a time — simple glyphs are leaves, so composite /// recursion stacks only the small component-walk frames (depth <= 4), /// never these arrays. -pub const max_glyph_points: usize = 1024; -pub const max_glyph_contours: usize = 128; +pub const max_glyph_points: usize = 4096; +pub const max_glyph_contours: usize = 256; pub const max_composite_points: usize = max_glyph_points; pub const max_composite_contours: usize = max_glyph_contours; pub const max_composite_depth: usize = 4; diff --git a/src/primitives/canvas/font_ttf_tests.zig b/src/primitives/canvas/font_ttf_tests.zig index c1fe0ac61..5fbe70520 100644 --- a/src/primitives/canvas/font_ttf_tests.zig +++ b/src/primitives/canvas/font_ttf_tests.zig @@ -279,7 +279,7 @@ test "out of range glyph ids error instead of reading wild" { /// Fixed-buffer big-endian byte builder for synthetic tables. const ByteBuilder = struct { - bytes: [32768]u8 = undefined, + bytes: [131072]u8 = undefined, len: usize = 0, fn appendU8(self: *ByteBuilder, value: u8) void { @@ -532,7 +532,11 @@ test "synthetic dense glyphs beyond the old Latin-sized budgets parse and outlin // The dense glyph outlines completely: every on-curve ring is // moveTo + 12 lineTo (the walk returns to the start point) + close, // so 84 * 14 elements, starting at the first ring's origin. - var builder = vector.PathBuilder(2048){}; + const path_capacity = @max( + font_ttf.max_glyph_points + 3 * font_ttf.max_glyph_contours, + font_ttf.max_composite_points + 3 * font_ttf.max_composite_contours, + ); + var builder = vector.PathBuilder(path_capacity){}; try face.glyphOutline(1, Affine.identity(), &builder); try std.testing.expectEqual(@as(usize, 84 * 14), builder.slice().len); const first = builder.slice()[0]; diff --git a/src/primitives/canvas/reference.zig b/src/primitives/canvas/reference.zig index bbf819b58..4fdaf945c 100644 --- a/src/primitives/canvas/reference.zig +++ b/src/primitives/canvas/reference.zig @@ -51,9 +51,9 @@ const font_ttf = @import("font_ttf.zig"); /// admits — a simple glyph's maxima and a composite's flattened maxima /// (`maxp.maxCompositePoints`/`maxCompositeContours`, which is what /// this builder actually receives when a composite renders). The -/// budgets are currently equal, so the max is 1408 either way; the +/// budgets are currently equal, so the max is 4864 either way; the /// derivation keeps capacity honest if they ever diverge. Stack shape: -/// at 28 B per element this is ~39 KiB in `drawGlyphOutline`; the edge +/// at 28 B per element this is ~133 KiB in `drawGlyphOutline`; the edge /// accumulator below it is the per-thread heap-resident /// `vector.GlyphRasterizer` (see `reference_glyph_raster_scratch`), so /// the builder is the only glyph raster state on the stack. @@ -65,7 +65,7 @@ const reference_glyph_path_capacity: usize = @max( /// Per-thread rasterizer for glyph fills: `vector.GlyphRasterizer`'s /// derived budgets guarantee every outline the font registration gate /// admits rasterizes (never a block fallback), which sizes it at -/// ~508 KiB — a per-thread heap slot behind one TLS pointer (the +/// ~1.9 MiB — a per-thread heap slot behind one TLS pointer (the /// lazy_tls pattern), not a stack temporary and not static TLS. Only /// threads that ink a glyph through the reference renderer allocate it. /// The array carries no default and stays uninitialized, exactly like @@ -1124,7 +1124,6 @@ fn referenceScaleRect(rect: geometry.RectF, scale: f32) geometry.RectF { return geometry.RectF.init(rect.x * scale, rect.y * scale, rect.width * scale, rect.height * scale); } - fn referencePixelCenter(x: usize, y: usize) geometry.PointF { return geometry.PointF.init(@as(f32, @floatFromInt(x)) + 0.5, @as(f32, @floatFromInt(y)) + 0.5); } diff --git a/src/primitives/canvas/vector.zig b/src/primitives/canvas/vector.zig index 9c7ed609f..32659e0e2 100644 --- a/src/primitives/canvas/vector.zig +++ b/src/primitives/canvas/vector.zig @@ -92,18 +92,18 @@ const max_piece_points: usize = 4 * max_curve_segments + 4; // so the arithmetic is written out and lockstep-pinned by a test in // font_ttf_tests.zig against the source constants). // -// Worst admitted glyph (1024 points / 128 contours, simple and +// Worst admitted glyph (4096 points / 256 contours, simple and // flattened-composite maxima are gated equal; glyph outlines emit only // move/line/quad/close): // -// quads <= points + contours = 1024 + 128 = 1152 +// quads <= points + contours = 4096 + 256 = 4352 // (each point emits at most one line or quad; each contour adds at // most one closing quad — the same walk the glyph path capacity in // reference.zig is derived from) -// edges <= quads * segments + contours = 1152 * 16 + 128 = 18,560 +// edges <= quads * segments + contours = 4352 * 16 + 256 = 69,888 // (each quad flattens to at most `max_glyph_curve_segments` line // segments, lines and the per-contour close edge are one each) -// scanline crossings <= edges = 18,560 +// scanline crossings <= edges = 69,888 // (an edge crosses a scanline at most once — the half-open span // test in `sweep` — so the edge bound is also the airtight crossing // bound, with no reliance on curve monotonicity under float @@ -115,8 +115,7 @@ const max_piece_points: usize = 4 * max_curve_segments + 4; // flattening has no finite bound at all — a full-em quad at an // S-pixel em wants ceil(sqrt((S/2) / 0.25)) segments (128 at S = 8192) // — and even the generic `max_curve_segments` cap of 48 would put the -// worst case at 1152 * 48 + 128 = 55,424 edges = 1.06 MiB of edge -// storage plus 433 KiB of crossings per rasterizer: memory-unreasonable +// worst case at 4352 * 48 + 256 = 209,152 edges, which is memory-unreasonable // for a per-thread buffer. Flattening tolerance is a quality knob, not // correctness, so glyph fills clamp subdivision instead: at 16 segments // the clamp only binds when a curve's flatness deviation exceeds @@ -127,13 +126,13 @@ const max_piece_points: usize = 4 * max_curve_segments + 4; // span fractions of the em, so everything the golden suite renders // stays below the clamp and byte-identical. // -// Storage: 18,560 edges * 20 B = 362.5 KiB plus 18,560 crossings * 8 B -// = 145 KiB, ~508 KiB per `GlyphRasterizer`. The glyph raster caller +// Storage: 69,888 edges * 20 B plus 69,888 crossings * 8 B is ~1.9 MiB per +// `GlyphRasterizer`. The glyph raster caller // (reference.zig) keeps ONE per render thread behind a lazily // heap-allocated pointer (`canvas.lazy_tls`), so it costs neither stack // nor static TLS, and threads that never ink a glyph never allocate it. pub const max_glyph_curve_segments: usize = 16; -pub const max_glyph_fill_edges: usize = 18_560; +pub const max_glyph_fill_edges: usize = 69_888; pub const max_glyph_scanline_crossings: usize = max_glyph_fill_edges; /// Half-open device-pixel clip window: pixels with `x0 <= x < x1` and From 750bce59a9aa4377d648424914a771732dfdb1d0 Mon Sep 17 00:00:00 2001 From: phall Date: Wed, 5 Aug 2026 03:17:59 -0400 Subject: [PATCH 5/7] fix(canvas): cost underlined terminal runs safely --- src/primitives/canvas/terminal_grid.zig | 4 ++-- src/primitives/canvas/terminal_grid_tests.zig | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/primitives/canvas/terminal_grid.zig b/src/primitives/canvas/terminal_grid.zig index 95b9e21bf..8c241a583 100644 --- a/src/primitives/canvas/terminal_grid.zig +++ b/src/primitives/canvas/terminal_grid.zig @@ -500,7 +500,7 @@ fn rowCommandCost(row: TerminalRow) usize { // Combining clusters deliberately paint alone. Plain cells merge while // foreground, decoration, and scratch capacity agree. if (multiCodepointCluster(cell)) { - total += 1 + @intFromBool(cell.underline); + total += 1 + @as(usize, @intFromBool(cell.underline)); i += 1; continue; } @@ -518,7 +518,7 @@ fn rowCommandCost(row: TerminalRow) usize { } text_bytes += next.cluster.len; } - total += 1 + @intFromBool(underline); + total += 1 + @as(usize, @intFromBool(underline)); i += span; } return total; diff --git a/src/primitives/canvas/terminal_grid_tests.zig b/src/primitives/canvas/terminal_grid_tests.zig index 2b8c5e10a..87aa1e94d 100644 --- a/src/primitives/canvas/terminal_grid_tests.zig +++ b/src/primitives/canvas/terminal_grid_tests.zig @@ -534,6 +534,23 @@ test "an underlined merged double run stays within the command budget" { try testing.expect(builder.displayList().commands.len <= 1000); } +test "ordinary underlined text is costed without integer overflow" { + var cells: [320]grid_model.TerminalCell = undefined; + for (&cells, 0..) |*c, i| { + c.* = cell('x', "x", if (i % 2 == 0) white else red); + c.underline = true; + } + const rows = [_]grid_model.TerminalRow{.{ .cells = &cells }}; + + var commands: [1024]canvas.CanvasCommand = undefined; + var builder = try paintInto(baseGrid(&rows), &commands, .{ + .frame = geometry.RectF.init(0, 0, 2600, 40), + .tokens = .{}, + .command_budget = 700, + }); + try testing.expect(builder.displayList().commands.len <= 700); +} + test "an anonymous grid keeps the unkeyed convention: every command id 0" { const cells = [_]grid_model.TerminalCell{ cell('x', "x", white), From cd4c133abc8ff6498be0cab396c390ff13024bc1 Mon Sep 17 00:00:00 2001 From: phall Date: Wed, 5 Aug 2026 12:01:25 -0400 Subject: [PATCH 6/7] chore: follow history-driven changelog workflow --- changelog.d/widget-context-menu-policy.md | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 changelog.d/widget-context-menu-policy.md diff --git a/changelog.d/widget-context-menu-policy.md b/changelog.d/widget-context-menu-policy.md deleted file mode 100644 index 3730448b9..000000000 --- a/changelog.d/widget-context-menu-policy.md +++ /dev/null @@ -1,2 +0,0 @@ -feature: **Per-widget context-menu policy**: Zig views can retain native widget semantics and pointer routing while choosing automatic SDK menus, app-declared menus only, or no menu handling for a widget. -- **Gesture-stable ownership**: secondary-button routing is fixed at pointer-down and retained through matching release or cancellation, so rebuilds cannot leak menu gestures into capture or strand ordinary capture. From 2b8f0908b7cbdd7f6f7023fdca2d487f0efb41cc Mon Sep 17 00:00:00 2001 From: phall Date: Wed, 5 Aug 2026 12:47:33 -0400 Subject: [PATCH 7/7] fix(canvas): preserve context menu invariants --- src/primitives/canvas/font_ttf.zig | 15 +++- src/primitives/canvas/reference.zig | 40 +++++----- src/primitives/canvas/ui.zig | 7 +- src/primitives/canvas/ui_tests.zig | 2 +- src/primitives/canvas/vector.zig | 6 +- src/primitives/canvas/widget_invalidation.zig | 3 +- src/primitives/canvas/widget_runtime.zig | 25 ++++++ .../canvas/widget_runtime_tests.zig | 32 ++++++++ src/primitives/canvas/widgets.zig | 7 +- src/runtime/automation_snapshot.zig | 6 +- src/runtime/automation_widget_dispatch.zig | 2 +- src/runtime/canvas_widget_context_menu.zig | 38 ++++----- .../canvas_widget_context_menu_tests.zig | 80 ++++++++++++++++--- 13 files changed, 195 insertions(+), 68 deletions(-) diff --git a/src/primitives/canvas/font_ttf.zig b/src/primitives/canvas/font_ttf.zig index 4cc91d3a6..e70c0fcd5 100644 --- a/src/primitives/canvas/font_ttf.zig +++ b/src/primitives/canvas/font_ttf.zig @@ -72,10 +72,12 @@ pub const Error = error{ /// 198 points (Yuji Mai), 10 contours (Geist Mono) — 5-12x headroom. /// /// Stack shape: the simple-glyph parse buffers -/// (`flags`/`xs`/`ys`/`end_points`) total ~9.5 KiB and live in exactly +/// (`flags`/`xs`/`ys`/`end_points`) total 36.5 KiB and live in exactly /// ONE frame at a time — simple glyphs are leaves, so composite /// recursion stacks only the small component-walk frames (depth <= 4), -/// never these arrays. +/// never these arrays. The reference renderer's 133 KiB path builder is +/// separate per-thread heap scratch, so it does not overlap this storage +/// on the render-thread stack. pub const max_glyph_points: usize = 4096; pub const max_glyph_contours: usize = 256; pub const max_composite_points: usize = max_glyph_points; @@ -83,6 +85,15 @@ pub const max_composite_contours: usize = max_glyph_contours; pub const max_composite_depth: usize = 4; pub const max_composite_components: usize = 8; +const simple_glyph_stack_scratch_bytes = + max_glyph_contours * @sizeOf(u16) + + max_glyph_points * (@sizeOf(u8) + 2 * @sizeOf(f32)); +comptime { + if (simple_glyph_stack_scratch_bytes > 40 * 1024) { + @compileError("TrueType simple-glyph stack scratch exceeds its supported 40 KiB bound"); + } +} + /// The bundled Geist Regular face (OFL), embedded so the reference /// renderer paints real text without any platform font machinery. It /// serves the sans font ids (weight/italic span variants included) at diff --git a/src/primitives/canvas/reference.zig b/src/primitives/canvas/reference.zig index 4fdaf945c..8adab3548 100644 --- a/src/primitives/canvas/reference.zig +++ b/src/primitives/canvas/reference.zig @@ -52,29 +52,28 @@ const font_ttf = @import("font_ttf.zig"); /// (`maxp.maxCompositePoints`/`maxCompositeContours`, which is what /// this builder actually receives when a composite renders). The /// budgets are currently equal, so the max is 4864 either way; the -/// derivation keeps capacity honest if they ever diverge. Stack shape: -/// at 28 B per element this is ~133 KiB in `drawGlyphOutline`; the edge -/// accumulator below it is the per-thread heap-resident -/// `vector.GlyphRasterizer` (see `reference_glyph_raster_scratch`), so -/// the builder is the only glyph raster state on the stack. +/// derivation keeps capacity honest if they ever diverge. At 28 B per +/// element the builder is ~133 KiB, so it lives beside the rasterizer in +/// per-thread heap scratch rather than in `drawGlyphOutline`'s stack. const reference_glyph_path_capacity: usize = @max( font_ttf.max_glyph_points + 3 * font_ttf.max_glyph_contours, font_ttf.max_composite_points + 3 * font_ttf.max_composite_contours, ); -/// Per-thread rasterizer for glyph fills: `vector.GlyphRasterizer`'s +const ReferenceGlyphPathBuilder = vector.PathBuilder(reference_glyph_path_capacity); + +/// Per-thread path and raster scratch for glyph fills: the path builder is +/// ~133 KiB and `vector.GlyphRasterizer` is ~1.9 MiB. The latter's /// derived budgets guarantee every outline the font registration gate -/// admits rasterizes (never a block fallback), which sizes it at -/// ~1.9 MiB — a per-thread heap slot behind one TLS pointer (the -/// lazy_tls pattern), not a stack temporary and not static TLS. Only -/// threads that ink a glyph through the reference renderer allocate it. -/// The array carries no default and stays uninitialized, exactly like -/// the stack `Rasterizer` it replaces; `vector.fillGlyphPath` resets it -/// per glyph. -const ReferenceGlyphRasterScratch = struct { +/// admits rasterizes (never a block fallback). Together they occupy ~2.0 +/// MiB behind one lazy TLS pointer, not the render-thread stack or static +/// TLS, and only threads that ink a glyph allocate them. The arrays carry +/// no defaults and stay uninitialized; each operation resets their lengths. +const ReferenceGlyphScratch = struct { + path: ReferenceGlyphPathBuilder, raster: vector.GlyphRasterizer, }; -const reference_glyph_raster_scratch = @import("lazy_tls.zig").LazyTls(ReferenceGlyphRasterScratch); +const reference_glyph_scratch = @import("lazy_tls.zig").LazyTls(ReferenceGlyphScratch); const referenceBlurKernel = reference_blur.referenceBlurKernel; const referenceBlurSampleWithKernel = reference_blur.referenceBlurSampleWithKernel; @@ -866,9 +865,10 @@ pub const ReferenceRenderSurface = struct { const local = Affine{ .a = scale, .b = 0, .c = 0, .d = -scale, .tx = pen_x + cell_inset, .ty = baseline }; const total = command.transform.multiply(local); - var builder = vector.PathBuilder(reference_glyph_path_capacity){}; - face.glyphOutline(glyph, total, &builder) catch return false; - if (builder.slice().len == 0) return true; // Space: nothing to ink. + const scratch = reference_glyph_scratch.get(); + scratch.path.reset(); + face.glyphOutline(glyph, total, &scratch.path) catch return false; + if (scratch.path.slice().len == 0) return true; // Space: nothing to ink. const pixel_rect = referencePixelRect(draw_bounds, self.width, self.height) orelse return true; // Glyph coverage blends in sRGB, not linear light (see @@ -890,8 +890,8 @@ pub const ReferenceRenderSurface = struct { // `max_raster_width` (surface-shaped, not font-shaped) and // nothing else. vector.fillGlyphPath( - &reference_glyph_raster_scratch.get().raster, - builder.slice(), + &scratch.raster, + scratch.path.slice(), Affine.identity(), .nonzero, vector.default_tolerance, diff --git a/src/primitives/canvas/ui.zig b/src/primitives/canvas/ui.zig index fd2d1b3a8..3da39d0c1 100644 --- a/src/primitives/canvas/ui.zig +++ b/src/primitives/canvas/ui.zig @@ -3693,8 +3693,11 @@ pub fn Ui(comptime Msg: type) type { .max_size = if (kind == .resizable) .{} else .{ .width = options.width, .height = options.height }, }, .style = options.style, - .semantics = options.semantics, - .context_menu_policy = options.context_menu_policy, + .semantics = semantics: { + var semantics = options.semantics; + semantics.context_menu_policy = options.context_menu_policy; + break :semantics semantics; + }, .window_drag = options.window_drag, .overscroll = options.overscroll, .resize_duration_ms = options.resize_duration, diff --git a/src/primitives/canvas/ui_tests.zig b/src/primitives/canvas/ui_tests.zig index dcd75dde0..a21d6f397 100644 --- a/src/primitives/canvas/ui_tests.zig +++ b/src/primitives/canvas/ui_tests.zig @@ -1550,7 +1550,7 @@ test "terminal context menu policy flows from ElementOptions into the widget tre })); try testing.expectEqual(canvas.WidgetKind.terminal, tree.root.kind); - try testing.expectEqual(canvas.WidgetContextMenuPolicy.disabled, tree.root.context_menu_policy); + try testing.expectEqual(canvas.WidgetContextMenuPolicy.disabled, tree.root.semantics.context_menu_policy); } test "widget kind codes are pinned: assigned at birth, declaration-order-independent" { diff --git a/src/primitives/canvas/vector.zig b/src/primitives/canvas/vector.zig index 32659e0e2..27cb9109f 100644 --- a/src/primitives/canvas/vector.zig +++ b/src/primitives/canvas/vector.zig @@ -541,7 +541,7 @@ pub fn RasterizerType(comptime edge_capacity: usize, comptime crossing_capacity: max_x: f32 = 0, max_y: f32 = 0, /// Scanline scratch for `sweep`, in the struct rather than its - /// stack frame so the glyph instantiation's 145 KiB rides the + /// stack frame so the glyph instantiation's ~546 KiB rides the /// same per-thread heap slot as its edges. crossings: [crossing_capacity]Crossing = undefined, @@ -670,7 +670,7 @@ pub fn RasterizerType(comptime edge_capacity: usize, comptime crossing_capacity: pub const Rasterizer = RasterizerType(max_edges, max_scanline_crossings); /// The glyph-fill instantiation, sized by the derived budgets above so -/// any outline the font registration gate admits rasterizes (~508 KiB — +/// any outline the font registration gate admits rasterizes (~1.9 MiB — /// callers keep one per thread on the heap, never on the stack; see /// `fillGlyphPath`). pub const GlyphRasterizer = RasterizerType(max_glyph_fill_edges, max_glyph_scanline_crossings); @@ -742,7 +742,7 @@ pub fn fillPath( /// budget-admitted glyph NEVER fails with `VectorPathTooComplex` — the /// registration promise ("a registered face always resolves at render /// time") holds at the raster layer too. `raster` is caller-owned -/// storage (~508 KiB: keep one per thread on the heap, e.g. behind +/// storage (~1.9 MiB: keep one per thread on the heap, e.g. behind /// `canvas.lazy_tls`, never on the stack); it is reset here, so it needs /// no initialization beyond existing. pub fn fillGlyphPath( diff --git a/src/primitives/canvas/widget_invalidation.zig b/src/primitives/canvas/widget_invalidation.zig index 1b7a043b0..e6d7bf6ba 100644 --- a/src/primitives/canvas/widget_invalidation.zig +++ b/src/primitives/canvas/widget_invalidation.zig @@ -819,7 +819,8 @@ fn widgetSemanticsEqual(a: WidgetSemantics, b: WidgetSemantics) bool { a.list_item_count == b.list_item_count and widgetActionsEqual(a.actions, b.actions) and a.hidden == b.hidden and - a.focusable == b.focusable; + a.focusable == b.focusable and + a.context_menu_policy == b.context_menu_policy; } fn widgetActionsEqual(a: WidgetActions, b: WidgetActions) bool { diff --git a/src/primitives/canvas/widget_runtime.zig b/src/primitives/canvas/widget_runtime.zig index 0a5e8b0d4..d10ba16a6 100644 --- a/src/primitives/canvas/widget_runtime.zig +++ b/src/primitives/canvas/widget_runtime.zig @@ -23,6 +23,7 @@ const WidgetKind = widget_model.WidgetKind; const WidgetCursor = widget_model.WidgetCursor; const WidgetState = widget_model.WidgetState; const WidgetRenderState = widget_model.WidgetRenderState; +const WidgetContextMenuPolicy = widget_model.WidgetContextMenuPolicy; const Widget = widget_model.Widget; const WidgetLayoutNode = event_model.WidgetLayoutNode; const WidgetHit = event_model.WidgetHit; @@ -73,6 +74,30 @@ pub const WidgetLayoutTree = struct { return null; } + /// The nearest explicit context-menu policy from `node_index` toward + /// the root. `.automatic` inherits; an invalid node fails open to the + /// compatibility default. + pub fn contextMenuPolicyAt(self: WidgetLayoutTree, node_index: usize) WidgetContextMenuPolicy { + var current: ?usize = node_index; + while (current) |index| { + if (index >= self.nodes.len) return .automatic; + const node = self.nodes[index]; + if (node.widget.semantics.context_menu_policy != .automatic) { + return node.widget.semantics.context_menu_policy; + } + current = node.parent_index; + } + return .automatic; + } + + pub fn contextMenuPolicyById(self: WidgetLayoutTree, id: ObjectId) WidgetContextMenuPolicy { + if (id == 0) return .automatic; + for (self.nodes, 0..) |node, index| { + if (node.widget.id == id) return self.contextMenuPolicyAt(index); + } + return .automatic; + } + pub fn virtualRangeById(self: WidgetLayoutTree, id: ObjectId) ?VirtualListRange { if (id == 0) return null; for (self.nodes) |node| { diff --git a/src/primitives/canvas/widget_runtime_tests.zig b/src/primitives/canvas/widget_runtime_tests.zig index 76405ae93..cbb3c6032 100644 --- a/src/primitives/canvas/widget_runtime_tests.zig +++ b/src/primitives/canvas/widget_runtime_tests.zig @@ -811,6 +811,38 @@ test "widget layout diff separates paint and semantics dirtiness" { try expectRect(geometry.RectF.init(8, 12, 80, 48), image_invalidations[0].dirty_bounds); } +test "context menu policy-only changes publish semantics invalidation" { + const previous_child = [_]Widget{.{ + .id = 2, + .kind = .terminal, + .frame = geometry.RectF.init(10, 10, 100, 30), + }}; + const disabled_child = [_]Widget{.{ + .id = 2, + .kind = .terminal, + .frame = geometry.RectF.init(10, 10, 100, 30), + .semantics = .{ .context_menu_policy = .disabled }, + }}; + var previous_nodes: [2]WidgetLayoutNode = undefined; + var disabled_nodes: [2]WidgetLayoutNode = undefined; + const previous = try layoutWidgetTree(.{ .kind = .stack, .children = &previous_child }, geometry.RectF.init(0, 0, 140, 80), &previous_nodes); + const disabled = try layoutWidgetTree(.{ .kind = .stack, .children = &disabled_child }, geometry.RectF.init(0, 0, 140, 80), &disabled_nodes); + + var invalidations_buffer: [2]WidgetInvalidation = undefined; + const invalidations = try WidgetLayoutTree.diff(previous, disabled, &invalidations_buffer); + try std.testing.expectEqual(@as(usize, 1), invalidations.len); + try std.testing.expect(!invalidations[0].layout_dirty); + try std.testing.expect(!invalidations[0].paint_dirty); + try std.testing.expect(invalidations[0].semantics_dirty); + try std.testing.expect(invalidations[0].dirty_bounds == null); +} + +test "context menu policy storage stays within the v0.8.1 Widget size budget" { + if (@sizeOf(usize) == 8) { + try std.testing.expect(@sizeOf(Widget) <= 776); + } +} + test "widget layout diff marks style changes as paint dirty" { const previous_child = [_]Widget{.{ .id = 2, diff --git a/src/primitives/canvas/widgets.zig b/src/primitives/canvas/widgets.zig index 6243bba30..dd66162d2 100644 --- a/src/primitives/canvas/widgets.zig +++ b/src/primitives/canvas/widgets.zig @@ -750,6 +750,9 @@ pub const WidgetSemantics = struct { actions: WidgetActions = .{}, hidden: bool = false, focusable: bool = false, + /// Context-menu selection policy. This is retained action metadata and + /// occupies existing struct padding, keeping every `Widget` compact. + context_menu_policy: WidgetContextMenuPolicy = .automatic, }; /// One declared context-menu entry carried on a widget (label/enabled/ @@ -992,10 +995,6 @@ pub const Widget = struct { semantics: WidgetSemantics = .{}, /// App-declared native context menu for this widget (empty = none). context_menu: []const WidgetContextMenuItem = &.{}, - /// Context-menu selection policy. The default preserves every existing - /// declared and SDK-provided menu; `.disabled` also leaves secondary - /// input available to the ordinary pointer route. - context_menu_policy: WidgetContextMenuPolicy = .automatic, /// True when the runtime installed a native scroll driver for this /// `.scroll_view`: the engine's drawn scrollbar and kinetic physics /// stand down — the OS scroller owns feel and the overlay scroller. diff --git a/src/runtime/automation_snapshot.zig b/src/runtime/automation_snapshot.zig index 979516926..2fac848d2 100644 --- a/src/runtime/automation_snapshot.zig +++ b/src/runtime/automation_snapshot.zig @@ -265,9 +265,9 @@ pub fn RuntimeAutomationSnapshot(comptime Runtime: type) type { } fn automationWidgetContextMenuPolicy(layout: canvas.WidgetLayoutTree, id: canvas.ObjectId) []const u8 { - const node = layout.findById(id) orelse return ""; - if (node.widget.context_menu_policy == .automatic) return ""; - return @tagName(node.widget.context_menu_policy); + const policy = layout.contextMenuPolicyById(id); + if (policy == .automatic) return ""; + return @tagName(policy); } pub fn frameDiagnostics(self: *Runtime) FrameDiagnostics { diff --git a/src/runtime/automation_widget_dispatch.zig b/src/runtime/automation_widget_dispatch.zig index 79cb5c627..86364c76b 100644 --- a/src/runtime/automation_widget_dispatch.zig +++ b/src/runtime/automation_widget_dispatch.zig @@ -206,7 +206,7 @@ pub fn RuntimeAutomationWidgetDispatch(comptime Runtime: type) type { const view_index = try automationWidgetTargetViewIndex(self, item.target); const node_index = self.views[view_index].canvasWidgetNodeIndexById(item.target.id) orelse return error.InvalidCommand; const widget = self.views[view_index].widget_layout_nodes[node_index].widget; - if (widget.context_menu_policy == .disabled) return error.ContextMenuDisabled; + if (self.views[view_index].widgetLayoutTree().contextMenuPolicyAt(node_index) == .disabled) return error.ContextMenuDisabled; if (widget.context_menu.len == 0) return error.ContextMenuUndeclared; if (item.item_index >= widget.context_menu.len) return error.ContextMenuItemOutOfRange; const declared = widget.context_menu[item.item_index]; diff --git a/src/runtime/canvas_widget_context_menu.zig b/src/runtime/canvas_widget_context_menu.zig index 15a5b24a8..9ab682e1a 100644 --- a/src/runtime/canvas_widget_context_menu.zig +++ b/src/runtime/canvas_widget_context_menu.zig @@ -114,7 +114,7 @@ pub fn RuntimeCanvasWidgetContextMenu(comptime Runtime: type) type { return true; }; const owner: @TypeOf(view.canvas_widget_secondary_gesture_owner) = if (routed) |pointer_event| - if (contextMenuPolicyForRoute(self, index, pointer_event.route) == .disabled) .ordinary else .context_menu + if (contextMenuPolicyForTarget(self, index, pointer_event.target) == .disabled) .ordinary else .context_menu else .context_menu; view.canvas_widget_secondary_gesture_owner = owner; @@ -145,6 +145,15 @@ pub fn RuntimeCanvasWidgetContextMenu(comptime Runtime: type) type { return true; } + // Desktop mouse streams share pointer_id=0 across every button. + // Only the secondary up that matches the initiating action may + // retire this owner; a chorded primary release remains ordinary + // and leaves the later secondary release live. Pointer cancel is + // whole-pointer termination and remains valid without a button. + if (input_event.kind == .pointer_up and input_event.button != 1) { + return false; + } + const consume = owner == .context_menu; if (input_event.kind == .pointer_up or input_event.kind == .pointer_cancel) { view.canvas_widget_secondary_gesture_owner = .none; @@ -172,6 +181,7 @@ pub fn RuntimeCanvasWidgetContextMenu(comptime Runtime: type) type { const pointer_event = routed orelse return; const index = runtimeFindViewIndex(self, input_event.window_id, input_event.label) orelse return; const point = geometry.PointF.init(input_event.x, input_event.y); + const policy = contextMenuPolicyForTarget(self, index, pointer_event.target); var items: [platform.max_context_menu_items]platform.ContextMenuItem = undefined; const has_presenter = self.options.platform.services.show_context_menu_fn != null; @@ -245,7 +255,7 @@ pub fn RuntimeCanvasWidgetContextMenu(comptime Runtime: type) type { // `.declared_only` stops after the declared-menu tier. Keep the // existing context-press fallback (the on-hold alternative), // but never synthesize an SDK text/terminal menu. - if (contextMenuPolicyForRoute(self, index, pointer_event.route) == .declared_only) { + if (policy == .declared_only) { try self.dispatchEvent(app, .{ .canvas_widget_context_press = .{ .window_id = input_event.window_id, .view_label = self.views[index].label, @@ -618,7 +628,7 @@ pub fn RuntimeCanvasWidgetContextMenu(comptime Runtime: type) type { for (route) |entry| { if (entry.node_index >= self.views[view_index].widget_layout_node_count) continue; const node = self.views[view_index].widget_layout_nodes[entry.node_index]; - if (node.widget.context_menu.len == 0 or node.widget.state.disabled or node.widget.context_menu_policy == .disabled) continue; + if (node.widget.context_menu.len == 0 or node.widget.state.disabled) continue; if (result == null or node.depth >= result_depth) { result = entry.node_index; result_depth = node.depth; @@ -627,25 +637,9 @@ pub fn RuntimeCanvasWidgetContextMenu(comptime Runtime: type) type { return result; } - /// The deepest explicit policy on a hit route governs the surface. - /// This lets a composite widget suppress menus for its plain-text - /// descendants while the default `.automatic` adds no inheritance - /// or behavior change to existing trees. - fn contextMenuPolicyForRoute(self: *const Runtime, view_index: usize, route: []const canvas.WidgetEventRouteEntry) canvas.WidgetContextMenuPolicy { - var policy: canvas.WidgetContextMenuPolicy = .automatic; - var policy_depth: usize = 0; - var found = false; - for (route) |entry| { - if (entry.node_index >= self.views[view_index].widget_layout_node_count) continue; - const node = self.views[view_index].widget_layout_nodes[entry.node_index]; - if (node.widget.context_menu_policy == .automatic) continue; - if (!found or node.depth >= policy_depth) { - policy = node.widget.context_menu_policy; - policy_depth = node.depth; - found = true; - } - } - return policy; + fn contextMenuPolicyForTarget(self: *const Runtime, view_index: usize, target: ?canvas.WidgetHit) canvas.WidgetContextMenuPolicy { + const hit = target orelse return .automatic; + return self.views[view_index].widgetLayoutTree().contextMenuPolicyAt(hit.index); } fn clearCanvasWidgetContextGesture(self: *Runtime, window_id: platform.WindowId, label: []const u8) void { diff --git a/src/runtime/canvas_widget_context_menu_tests.zig b/src/runtime/canvas_widget_context_menu_tests.zig index 6f50ed506..4827e6620 100644 --- a/src/runtime/canvas_widget_context_menu_tests.zig +++ b/src/runtime/canvas_widget_context_menu_tests.zig @@ -185,8 +185,7 @@ fn installTerminal( .frame = geometry.RectF.init(12, 16, 280, 120), .text = text, .terminal = .{ .pty = pty, .grid = grid }, - .context_menu_policy = policy, - .semantics = .{ .label = "Session" }, + .semantics = .{ .label = "Session", .context_menu_policy = policy }, }; var nodes: [2]canvas.WidgetLayoutNode = undefined; const layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &.{terminal} }, geometry.RectF.init(0, 0, 320, 200), &nodes); @@ -235,7 +234,7 @@ test "right click over a widget with a declared menu presents it natively and di .frame = geometry.RectF.init(10, 10, 200, 40), .text = "Task", .context_menu = &items, - .context_menu_policy = .declared_only, + .semantics = .{ .context_menu_policy = .declared_only }, }; var nodes: [2]canvas.WidgetLayoutNode = undefined; const layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &.{row} }, geometry.RectF.init(0, 0, 320, 200), &nodes); @@ -569,7 +568,7 @@ test "right click on a terminal presents Copy and Paste wired to selection and c // Secondary-click focus ensures the eventual paste addresses this // terminal even when another editor previously owned focus. try std.testing.expectEqual(@as(canvas.ObjectId, 2), harness.runtime.views[0].canvas_widget_focused_id); - try std.testing.expectEqual(canvas.WidgetContextMenuPolicy.automatic, harness.runtime.views[0].widget_layout_nodes[1].widget.context_menu_policy); + try std.testing.expectEqual(canvas.WidgetContextMenuPolicy.automatic, harness.runtime.views[0].widget_layout_nodes[1].widget.semantics.context_menu_policy); try std.testing.expectEqual(@as(u32, 0), app_state.pointer_count); try harness.runtime.dispatchPlatformEvent(app, menuAction(harness.null_platform.context_menu_token, 2)); @@ -624,8 +623,7 @@ test "disabled terminal context menus bypass menu handling and retain pointer ro .frame = geometry.RectF.init(12, 16, 280, 120), .text = grid.screen_text, .terminal = .{ .pty = 7, .grid = &grid }, - .context_menu_policy = .disabled, - .semantics = .{ .label = "Session" }, + .semantics = .{ .label = "Session", .context_menu_policy = .disabled }, }; var nodes: [2]canvas.WidgetLayoutNode = undefined; const layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &.{terminal} }, geometry.RectF.init(0, 0, 320, 200), &nodes); @@ -644,7 +642,7 @@ test "disabled terminal context menus bypass menu handling and retain pointer ro const retained = try harness.runtime.canvasWidgetLayout(1, "canvas"); try std.testing.expectEqual(canvas.WidgetKind.terminal, retained.nodes[1].widget.kind); try std.testing.expectEqualStrings("alpha beta", retained.nodes[1].widget.text); - try std.testing.expectEqual(canvas.WidgetContextMenuPolicy.disabled, retained.nodes[1].widget.context_menu_policy); + try std.testing.expectEqual(canvas.WidgetContextMenuPolicy.disabled, retained.nodes[1].widget.semantics.context_menu_policy); const snapshot = harness.runtime.automationSnapshot("Terminal policy"); var terminal_snapshot: ?automation.snapshot.Widget = null; @@ -699,6 +697,43 @@ test "disabled terminal context menus bypass menu handling and retain pointer ro try std.testing.expectEqual(@as(u32, 3), app_state.raw_input_count); } +test "disabled ancestor policy blocks child menus across pointer automation and snapshots" { + var app_state: MenuTestApp = .{}; + const app = app_state.app(); + const harness = try createMenuHarness(app); + defer harness.destroy(std.testing.allocator); + + const items = [_]canvas.WidgetContextMenuItem{.{ .label = "Open" }}; + const child = canvas.Widget{ + .id = 2, + .kind = .list_item, + .frame = geometry.RectF.init(10, 10, 200, 40), + .text = "Task", + .context_menu = &items, + }; + var nodes: [2]canvas.WidgetLayoutNode = undefined; + const layout = try canvas.layoutWidgetTree(.{ + .id = 1, + .kind = .stack, + .children = &.{child}, + .semantics = .{ .context_menu_policy = .disabled }, + }, geometry.RectF.init(0, 0, 320, 200), &nodes); + _ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout); + + const snapshot = harness.runtime.automationSnapshot("Inherited policy"); + var child_snapshot: ?automation.snapshot.Widget = null; + for (snapshot.widgets) |widget| { + if (widget.id == 2) child_snapshot = widget; + } + try std.testing.expectEqualStrings("disabled", child_snapshot.?.context_menu_policy); + try std.testing.expectError(error.ContextMenuDisabled, harness.runtime.dispatchAutomationCommand(app, "widget-context-menu canvas 2 0")); + + try harness.runtime.dispatchPlatformEvent(app, secondaryPointer(.pointer_down, 0, 50, 20)); + try harness.runtime.dispatchPlatformEvent(app, secondaryPointer(.pointer_up, 0, 50, 20)); + try std.testing.expectEqual(@as(usize, 0), harness.null_platform.context_menu_request_count); + try std.testing.expectEqual(@as(u32, 2), app_state.pointer_count); +} + test "disabled secondary down retains ordinary capture through automatic terminal rebuild" { var app_state: MenuTestApp = .{}; const app = app_state.app(); @@ -743,6 +778,33 @@ test "disabled secondary down retains ordinary capture through automatic termina try std.testing.expectEqual(@as(usize, 0), harness.null_platform.context_menu_request_count); } +test "disabled secondary owner survives a chorded primary release with desktop pointer id" { + var app_state: MenuTestApp = .{}; + const app = app_state.app(); + const harness = try createMenuHarness(app); + defer harness.destroy(std.testing.allocator); + + var grid = testTerminalGrid(); + try installTerminal(harness, &grid, .disabled, 7, "shell: idle"); + + var primary_down = secondaryPointer(.pointer_down, 0, 100, 40); + primary_down.gpu_surface_input.button = 0; + try harness.runtime.dispatchPlatformEvent(app, primary_down); + try harness.runtime.dispatchPlatformEvent(app, secondaryPointer(.pointer_down, 0, 100, 40)); + try std.testing.expectEqual(.ordinary, harness.runtime.views[0].canvas_widget_secondary_gesture_owner); + + var primary_up = secondaryPointer(.pointer_up, 0, 100, 40); + primary_up.gpu_surface_input.button = 0; + try harness.runtime.dispatchPlatformEvent(app, primary_up); + try std.testing.expectEqual(.ordinary, harness.runtime.views[0].canvas_widget_secondary_gesture_owner); + try std.testing.expectEqual(@as(u32, 3), app_state.pointer_count); + + try harness.runtime.dispatchPlatformEvent(app, secondaryPointer(.pointer_up, 0, 100, 40)); + try std.testing.expectEqual(.none, harness.runtime.views[0].canvas_widget_secondary_gesture_owner); + try std.testing.expectEqual(@as(u32, 4), app_state.pointer_count); + try std.testing.expectEqual(canvas.WidgetPointerPhase.up, app_state.last_pointer_phase); +} + test "disabled secondary down retains ordinary cancel through declared policy rebuild" { var app_state: MenuTestApp = .{}; const app = app_state.app(); @@ -983,7 +1045,7 @@ test "the widget-context-menu verb dispatches selections through context_menu_ac .frame = geometry.RectF.init(10, 110, 200, 40), .text = "Disabled menu", .context_menu = &items, - .context_menu_policy = .disabled, + .semantics = .{ .context_menu_policy = .disabled }, }; var nodes: [4]canvas.WidgetLayoutNode = undefined; const layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &.{ row, plain, disabled } }, geometry.RectF.init(0, 0, 320, 200), &nodes); @@ -1325,7 +1387,7 @@ test "automation snapshots list each widget's declared context-menu items in inv .frame = geometry.RectF.init(10, 10, 200, 40), .text = "Task", .context_menu = &items, - .context_menu_policy = .declared_only, + .semantics = .{ .context_menu_policy = .declared_only }, }; var nodes: [2]canvas.WidgetLayoutNode = undefined; const layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &.{row} }, geometry.RectF.init(0, 0, 320, 200), &nodes);