diff --git a/changelog.d/escape-edit-derivation.md b/changelog.d/escape-edit-derivation.md new file mode 100644 index 00000000..5a47f858 --- /dev/null +++ b/changelog.d/escape-edit-derivation.md @@ -0,0 +1,4 @@ +fix: **Escape in a search field now reaches your core**: Escape's clear (and its composition cancel) was a runtime-local editor operation — the field emptied on screen while the model kept the stale query and the list stayed filtered. Every keyboard-driven editor mutation now derives ONE edit that is applied to the retained editor AND stamped onto the dispatched event, so `on-input` hears Escape exactly like typing, paste, and the clear affordance — on both authoring tiers, and byte-identically under record→replay. +- **Automation and accessibility composition verbs ride the real input path**: `widget-action set_composition/commit_composition/cancel_composition` now dispatch the same ime input events a live IME session produces (journaled, mirrored to the core), and `set_selection` reaches the core's selection mirror through the stamped-edit channel. +- **Accessibility actions replay without double-dispatch**: a journaled assistive action (press, toggle, set_text, drag, ...) no longer also journals the key/text events its verb synthesizes — replaying the action re-derives them, so recorded AX-driven sessions replay each input exactly once instead of twice. +- **Direct verb calls journal outer-wins too**: an embed host's `widgetAction` and automation `widget_action` commands now record the same single `widget_accessibility_action` record the platform accessibility path does (the enum gained the composition kinds), so replay re-runs the verb — focus included — instead of delivering its untargeted key/ime children to whichever field the session happened to leave focused. diff --git a/examples/soundboard/src/tests.zig b/examples/soundboard/src/tests.zig index 4ece518f..6880f0a7 100644 --- a/examples/soundboard/src/tests.zig +++ b/examples/soundboard/src/tests.zig @@ -924,6 +924,42 @@ test "search filters albums and songs through typed dispatch" { try testing.expect(findByLabel(tree.root, "No albums match") != null); } +test "live: Escape clears the search field and the model hears it" { + // The post-launch live-GUI smoke bug: Escape made the runtime's + // editor clear the field VISUALLY while the model kept the stale + // query — the grid stayed filtered against a term the screen no + // longer showed, and the next keystroke spliced into it. The + // keyboard derivation now stamps the clear it applies onto the + // dispatched event, so the model's on-input mirror hears Escape + // exactly like the clear affordance and every keystroke. + const live = try LiveApp.start(true); + defer live.stop(); + const app_state = live.app_state; + + // Focus the search field and type a one-album query through the + // real platform text channel. + try live.focusWidget(try live.widgetIdByLabel(.search_field, "Search library")); + try live.harness.runtime.dispatchPlatformEvent(app_state.app(), .{ .gpu_surface_input = .{ + .label = main.canvas_label, + .kind = .text_input, + .text = "channel", + } }); + try testing.expectEqualStrings("channel", app_state.model.search()); + try testing.expectEqual(@as(usize, 1), countListItems(app_state.tree.?.root)); + + // Escape through the same raw key path a physical press produces: + // the model clears WITH the field, and the grid unfilters. + try live.keyDown("escape", ""); + try testing.expectEqualStrings("", app_state.model.search()); + try testing.expectEqual(model_mod.albums.len, countListItems(app_state.tree.?.root)); + + // The retained editor agrees with the model — no visual/model split. + const layout = try live.harness.runtime.canvasWidgetLayout(1, main.canvas_label); + for (layout.nodes) |node| { + if (node.widget.kind == .search_field) try testing.expectEqualStrings("", node.widget.text); + } +} + test "a full session: open an album, play it, and use the context menus" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); diff --git a/src/platform/types.zig b/src/platform/types.zig index 69acc8b9..8fbd0fa8 100644 --- a/src/platform/types.zig +++ b/src/platform/types.zig @@ -1817,6 +1817,15 @@ pub const WidgetAccessibilityActionKind = enum(c_int) { drag = 8, drop_files = 9, dismiss = 10, + // The composition verbs never arrive from desktop assistive-tech + // bridges (AppKit has no AX composition action); they exist so the + // DIRECT verb surfaces (embed hosts, automation commands) and the + // session journal can carry every accessibility verb as one + // `widget_accessibility_action` — replay re-runs the verb, focus + // included, instead of replaying its untargeted ime children. + set_composition = 11, + commit_composition = 12, + cancel_composition = 13, }; pub const WidgetAccessibilityActionEvent = struct { diff --git a/src/primitives/canvas/ui.zig b/src/primitives/canvas/ui.zig index 0c5bc7ee..e4c3c759 100644 --- a/src/primitives/canvas/ui.zig +++ b/src/primitives/canvas/ui.zig @@ -1143,11 +1143,21 @@ pub fn Ui(comptime Msg: type) type { if (self.msgFor(target_id, .submit)) |msg| return msg; } if (isTextEntryWidget(widget) and !widget.state.disabled) { - // The textarea newline mapping resolves BEFORE the - // generic key mapping (which has no Enter case), so - // the model's `on_input` hears the same newline the - // runtime applied to the retained text. - const edit = canvas.widgetKeyboardNewlineTextEditEvent(widget.kind, keyboard) orelse keyboard.textEditEvent(); + // Events routed through the runtime arrive with the + // edit the retained editor actually applied STAMPED + // onto `keyboard.edit` (the runtime's one derivation + // seam — Escape's search-field clear, composition + // cancels, and clipboard edits all ride it), and + // `textEditEvent()` returns the stamp first. The + // local derivation below is the fallback for events + // that never crossed the runtime (direct Tree + // consumers, tests); the textarea newline mapping + // resolves BEFORE it because the generic key mapping + // has no Enter case. + const edit = if (keyboard.edit != null) + keyboard.edit + else + canvas.widgetKeyboardNewlineTextEditEvent(widget.kind, keyboard) orelse keyboard.textEditEvent(); if (edit) |text_edit| { if (self.msgForTextEdit(target_id, text_edit)) |msg| return msg; } diff --git a/src/runtime/automation_widget_dispatch.zig b/src/runtime/automation_widget_dispatch.zig index 28733bcd..1704924a 100644 --- a/src/runtime/automation_widget_dispatch.zig +++ b/src/runtime/automation_widget_dispatch.zig @@ -16,7 +16,6 @@ const AutomationProvenanceTarget = automation_commands.AutomationProvenanceTarge const AutomationWidgetWheel = automation_commands.AutomationWidgetWheel; const AutomationWidgetKey = automation_commands.AutomationWidgetKey; const AutomationWidgetPointerDrag = automation_commands.AutomationWidgetPointerDrag; -const automationWidgetActionSupported = automation_commands.automationWidgetActionSupported; const parseAutomationTextSelection = automation_commands.parseAutomationTextSelection; const parseAutomationDragDelta = automation_commands.parseAutomationDragDelta; const parseAutomationDropPaths = automation_commands.parseAutomationDropPaths; @@ -27,24 +26,40 @@ const validateViewLabel = validation.validateViewLabel; pub fn RuntimeAutomationWidgetDispatch(comptime Runtime: type) type { return struct { + /// The automation `widget_action` command surface delegates to + /// the accessibility dispatch rather than switching over the + /// verbs itself: one choke point performs the verb AND stages + /// the outer-wins `widget_accessibility_action` journal record, + /// so a recorded session replays the verb — focus included — + /// instead of replaying its untargeted synthesized children + /// against whatever focus the session happened to hold. pub fn dispatchAutomationWidgetAction(self: *Runtime, app: runtime_api.App(Runtime), action: AutomationWidgetAction) anyerror!void { - const view_index = try automationWidgetActionViewIndex(self, action); - switch (action.action) { - .focus => try focusAutomationCanvasWidget(self, view_index, action.id), - .press => try dispatchAutomationWidgetKey(self, app, view_index, action.id, "enter"), - .toggle => try dispatchAutomationWidgetKey(self, app, view_index, action.id, "space"), - .increment => try dispatchAutomationWidgetKey(self, app, view_index, action.id, self.views[view_index].canvasWidgetStepKey(action.id, .increment)), - .decrement => try dispatchAutomationWidgetKey(self, app, view_index, action.id, self.views[view_index].canvasWidgetStepKey(action.id, .decrement)), - .set_text => try setAutomationCanvasWidgetText(self, app, view_index, action.id, action.value), - .set_selection => try editAutomationCanvasWidgetText(self, view_index, action.id, .{ .set_selection = try parseAutomationTextSelection(action.value) }), - .set_composition => try editAutomationCanvasWidgetText(self, view_index, action.id, .{ .set_composition = .{ .text = action.value } }), - .commit_composition => try editAutomationCanvasWidgetText(self, view_index, action.id, .commit_composition), - .cancel_composition => try editAutomationCanvasWidgetText(self, view_index, action.id, .cancel_composition), - .select => try selectAutomationCanvasWidget(self, view_index, action.id), - .drag => try dispatchAutomationCanvasWidgetDrag(self, app, view_index, action.id, action.value), - .drop_files => try dispatchAutomationCanvasWidgetFileDrop(self, app, view_index, action.id, action.value), - .dismiss => try dismissAutomationCanvasWidget(self, app, view_index, action.id), - } + const view_index = try automationGpuSurfaceViewIndexByLabel(self, action.view_label); + _ = try self.dispatchCanvasWidgetAccessibilityAction(app, self.views[view_index].window_id, self.views[view_index].label, .{ + .id = action.id, + .action = canvasWidgetActionKindFromAutomation(action.action), + .text = action.value, + .selection = if (action.action == .set_selection) try parseAutomationTextSelection(action.value) else null, + }); + } + + fn canvasWidgetActionKindFromAutomation(kind: automation_commands.AutomationWidgetActionKind) runtime_api.CanvasWidgetAccessibilityActionKind { + return switch (kind) { + .focus => .focus, + .press => .press, + .toggle => .toggle, + .increment => .increment, + .decrement => .decrement, + .set_text => .set_text, + .set_selection => .set_selection, + .set_composition => .set_composition, + .commit_composition => .commit_composition, + .cancel_composition => .cancel_composition, + .select => .select, + .drag => .drag, + .drop_files => .drop_files, + .dismiss => .dismiss, + }; } pub fn dispatchAutomationWidgetClick(self: *Runtime, app: runtime_api.App(Runtime), target: AutomationWidgetTarget) anyerror!void { @@ -450,15 +465,57 @@ pub fn RuntimeAutomationWidgetDispatch(comptime Runtime: type) type { } }); } - pub fn editAutomationCanvasWidgetText(self: *Runtime, view_index: usize, id: canvas.ObjectId, edit: canvas.TextInputEvent) anyerror!void { + /// Composition edits ride the SAME ime input events a real IME + /// session produces (`setAutomationCanvasWidgetText`'s + /// philosophy): the dispatch applies the edit to the retained + /// editor, stamps it onto the routed keyboard event so the app's + /// `on_input` mirror hears it, and journals the input so a + /// recorded session replays the composition byte-identically. + /// Writing the editor directly (the previous shape) kept the + /// model out of the loop — the same divergence the keyboard + /// choke point closes for Escape's clear. + pub fn composeAutomationCanvasWidgetText( + self: *Runtime, + app: runtime_api.App(Runtime), + view_index: usize, + id: canvas.ObjectId, + kind: platform.GpuSurfaceInputKind, + text: []const u8, + ) anyerror!void { try focusAutomationCanvasWidget(self, view_index, id); if (!self.views[view_index].canEditCanvasWidgetText(id)) return error.InvalidCommand; - const dirty = try self.views[view_index].applyCanvasWidgetTextEdit(id, edit) orelse return; + try self.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = self.views[view_index].window_id, + .label = self.views[view_index].label, + .kind = kind, + .timestamp_ns = automationInputTimestampNs(), + .text = text, + } }); + } + + /// Selection edits have no platform input kind to ride, so the + /// verb synthesizes the routed keyboard event the clipboard and + /// context-menu edits use: the keyboard choke point applies the + /// stamped edit to the retained editor and the app dispatch + /// keeps the model's selection mirror honest. (The journal sees + /// this verb as the outer `widget_accessibility_action` record + /// its dispatch stages; replaying that record re-runs the verb.) + pub fn editAutomationCanvasWidgetText(self: *Runtime, app: runtime_api.App(Runtime), view_index: usize, id: canvas.ObjectId, edit: canvas.TextInputEvent) anyerror!void { + try focusAutomationCanvasWidget(self, view_index, id); + if (!self.views[view_index].canEditCanvasWidgetText(id)) return error.InvalidCommand; + const target = self.views[view_index].widgetLayoutTree().focusTargetById(id) orelse return error.InvalidCommand; + var keyboard_event: runtime_api.CanvasWidgetKeyboardEvent = .{ + .window_id = self.views[view_index].window_id, + .view_label = self.views[view_index].label, + .keyboard = .{ .phase = .key_down, .focused_id = id, .edit = edit }, + .target = target, + }; // Same observability contract as selectAutomationCanvasWidget: // the repaint this edit triggers must publish its completing // frame, so record the automation input it resolves. self.views[view_index].recordGpuSurfaceInputTimestamp(automationInputTimestampNs()); - try CanvasWidgetEventMethods().invalidateForCanvasWidgetDirty(self, view_index, dirty); + try CanvasWidgetEventMethods().updateCanvasWidgetTextFromKeyboard(self, &keyboard_event); + try self.dispatchEvent(app, .{ .canvas_widget_keyboard = keyboard_event }); } pub fn dispatchAutomationCanvasWidgetDrag(self: *Runtime, app: runtime_api.App(Runtime), view_index: usize, id: canvas.ObjectId, value: []const u8) anyerror!void { @@ -520,13 +577,6 @@ pub fn RuntimeAutomationWidgetDispatch(comptime Runtime: type) type { } }); } - fn automationWidgetActionViewIndex(self: *Runtime, action: AutomationWidgetAction) anyerror!usize { - const view_index = try automationGpuSurfaceViewIndexByLabel(self, action.view_label); - const actions = canvasWidgetActionsForId(self, view_index, action.id) orelse return error.InvalidCommand; - if (!automationWidgetActionSupported(actions, action.action)) return error.InvalidCommand; - return view_index; - } - fn automationWidgetTargetViewIndex(self: *Runtime, target: AutomationWidgetTarget) anyerror!usize { return automationGpuSurfaceViewIndexByLabel(self, target.view_label); } diff --git a/src/runtime/canvas_widget_context_menu.zig b/src/runtime/canvas_widget_context_menu.zig index 81af45f1..dc49c630 100644 --- a/src/runtime/canvas_widget_context_menu.zig +++ b/src/runtime/canvas_widget_context_menu.zig @@ -274,8 +274,9 @@ pub fn RuntimeCanvasWidgetContextMenu(comptime Runtime: type) type { /// the event to the app — the same two motions keyboard-driven /// edits perform, so runtime widget and app model stay in sync. fn applyEditKeyboardEvent(self: *Runtime, app: runtime_api.App(Runtime), keyboard_event: runtime_api.CanvasWidgetKeyboardEvent) anyerror!void { - try CanvasWidgetEventMethods().updateCanvasWidgetTextFromKeyboard(self, keyboard_event); - try self.dispatchEvent(app, .{ .canvas_widget_keyboard = keyboard_event }); + var event = keyboard_event; + try CanvasWidgetEventMethods().updateCanvasWidgetTextFromKeyboard(self, &event); + try self.dispatchEvent(app, .{ .canvas_widget_keyboard = event }); } fn editableSelectionText(self: *Runtime, view_index: usize, target_id: canvas.ObjectId) ?[]const u8 { diff --git a/src/runtime/canvas_widget_events.zig b/src/runtime/canvas_widget_events.zig index f4f26e75..8574d53e 100644 --- a/src/runtime/canvas_widget_events.zig +++ b/src/runtime/canvas_widget_events.zig @@ -619,11 +619,27 @@ pub fn RuntimeCanvasWidgetEvents(comptime Runtime: type) type { return widget.text[range.start..range.end]; } - pub fn updateCanvasWidgetTextFromKeyboard(self: *Runtime, keyboard_event: CanvasWidgetKeyboardEvent) anyerror!void { + /// The ONE derivation seam for keyboard-driven editor mutations: + /// `canvasWidgetKeyboardTextEdit` maps the routed key to the edit + /// the retained editor applies, and that same edit is STAMPED + /// onto the event the app dispatch consumes — so the model's + /// `on_input` hears exactly what the editor did (the cut/paste + /// and clear-button stamping precedent, made the rule). Before + /// the stamp, edits that only THIS derivation produced — Escape's + /// search-field clear, Escape's composition cancel, the + /// single-line ArrowUp/Down caret jumps — mutated the editor + /// while the app-side dispatch re-derived the key on its own and + /// heard nothing: the field visibly cleared while `model.query` + /// kept the stale term, and the next keystroke dispatched + /// against it. Stamping happens even when applying changes + /// nothing (an Escape in an already-empty field), so a model + /// whose mirror diverged still hears the clear and resyncs. + pub fn updateCanvasWidgetTextFromKeyboard(self: *Runtime, keyboard_event: *CanvasWidgetKeyboardEvent) anyerror!void { const index = runtimeFindViewIndex(self, keyboard_event.window_id, keyboard_event.view_label) orelse return; if (self.views[index].kind != .gpu_surface) return; const target = keyboard_event.target orelse return; const edit = self.views[index].canvasWidgetKeyboardTextEdit(target, keyboard_event.keyboard) orelse return; + keyboard_event.keyboard.edit = edit; const dirty = try self.views[index].applyCanvasWidgetTextEdit(target.id, edit) orelse return; if (canvasDirtyRegionForView(self.views[index].frame, dirty)) |dirty_region| { diff --git a/src/runtime/canvas_widget_state.zig b/src/runtime/canvas_widget_state.zig index 1c95ea7b..30e661c1 100644 --- a/src/runtime/canvas_widget_state.zig +++ b/src/runtime/canvas_widget_state.zig @@ -25,6 +25,7 @@ const CanvasWidgetTextReconcileEntry = canvas_widget_runtime.CanvasWidgetTextRec const canvasWidgetLayoutTreeWithRuntimeReconcileState = canvas_widget_runtime.canvasWidgetLayoutTreeWithRuntimeReconcileState; const canvasWidgetEditableTextKind = canvas_widget_runtime.canvasWidgetEditableTextKind; const canvasWidgetAccessibilityActionSupported = widget_bridge.canvasWidgetAccessibilityActionSupported; +const platformWidgetAccessibilityActionKindFromCanvas = widget_bridge.platformWidgetAccessibilityActionKindFromCanvas; const canvasWidgetBooleanSelected = canvas_widget_runtime.canvasWidgetBooleanSelected; pub fn RuntimeCanvasWidgetState(comptime Runtime: type) type { @@ -187,6 +188,38 @@ pub fn RuntimeCanvasWidgetState(comptime Runtime: type) type { const actions = AutomationWidgetMethods(Runtime).canvasWidgetActionsForId(self, index, action.id) orelse return error.InvalidCommand; if (!canvasWidgetAccessibilityActionSupported(actions, action.action)) return error.InvalidCommand; + // Session recording, outer-wins on BOTH entry surfaces: the + // platform AX path arrives with its journaled tag-23 event + // already on the recorder's staging stack (flow.zig staged + // it), but the DIRECT verb surfaces (an embed host's + // `widgetAction`, automation `widget_action` commands) reach + // here with no platform event at all. Their verbs journal + // only UNTARGETED children routed by focus, while the focus + // write itself (`focusAutomationCanvasWidget`) is a direct + // unjournaled state change — so a fresh replay delivered the + // children against whatever focus the session happened to + // hold and a first-in-session composition landed nowhere. + // Stage the same synthetic `widget_accessibility_action` + // record the platform path carries: its suppression window + // keeps the children out of the journal and replay re-runs + // this whole dispatch — focus included — through the tag-23 + // arm in flow.zig. When the platform event IS already staged, + // this nested stage lands inside its suppression window as a + // never-written placeholder, so no double record; the commit + // below pops it symmetrically. Effect results drained during + // the verb still write through ahead of the record, and + // event_count/checkpoint ordinals only ever see the one + // surviving record (checkpoints fire at staging depth 0). + if (self.options.session_recorder) |recorder| recorder.stageEvent(.{ .widget_accessibility_action = .{ + .window_id = window_id, + .label = label, + .id = action.id, + .action = platformWidgetAccessibilityActionKindFromCanvas(action.action), + .text = action.text, + .selection = if (action.selection) |selection| .{ .start = selection.anchor, .end = selection.focus } else null, + } }); + defer if (self.options.session_recorder) |recorder| recorder.commitEvent(); + // Every assistive action rides the SAME machinery the // automation widget verbs use — the key-driven activation for // press/toggle/increment/decrement (quiet-list-row ring @@ -209,10 +242,10 @@ pub fn RuntimeCanvasWidgetState(comptime Runtime: type) type { .increment => try AutomationWidgetMethods(Runtime).dispatchAutomationWidgetKey(self, app, index, action.id, self.views[index].canvasWidgetStepKey(action.id, .increment)), .decrement => try AutomationWidgetMethods(Runtime).dispatchAutomationWidgetKey(self, app, index, action.id, self.views[index].canvasWidgetStepKey(action.id, .decrement)), .set_text => try AutomationWidgetMethods(Runtime).setAutomationCanvasWidgetText(self, app, index, action.id, action.text), - .set_selection => try AutomationWidgetMethods(Runtime).editAutomationCanvasWidgetText(self, index, action.id, .{ .set_selection = action.selection orelse return error.InvalidCommand }), - .set_composition => try AutomationWidgetMethods(Runtime).editAutomationCanvasWidgetText(self, index, action.id, .{ .set_composition = .{ .text = action.text } }), - .commit_composition => try AutomationWidgetMethods(Runtime).editAutomationCanvasWidgetText(self, index, action.id, .commit_composition), - .cancel_composition => try AutomationWidgetMethods(Runtime).editAutomationCanvasWidgetText(self, index, action.id, .cancel_composition), + .set_selection => try AutomationWidgetMethods(Runtime).editAutomationCanvasWidgetText(self, app, index, action.id, .{ .set_selection = action.selection orelse return error.InvalidCommand }), + .set_composition => try AutomationWidgetMethods(Runtime).composeAutomationCanvasWidgetText(self, app, index, action.id, .ime_set_composition, action.text), + .commit_composition => try AutomationWidgetMethods(Runtime).composeAutomationCanvasWidgetText(self, app, index, action.id, .ime_commit_composition, ""), + .cancel_composition => try AutomationWidgetMethods(Runtime).composeAutomationCanvasWidgetText(self, app, index, action.id, .ime_cancel_composition, ""), .select => try AutomationWidgetMethods(Runtime).selectAutomationCanvasWidget(self, index, action.id), .drag => try AutomationWidgetMethods(Runtime).dispatchAutomationCanvasWidgetDrag(self, app, index, action.id, action.text), .drop_files => try AutomationWidgetMethods(Runtime).dispatchAutomationCanvasWidgetFileDrop(self, app, index, action.id, action.text), diff --git a/src/runtime/core.zig b/src/runtime/core.zig index 564b9f0c..da64c4bf 100644 --- a/src/runtime/core.zig +++ b/src/runtime/core.zig @@ -791,6 +791,7 @@ pub const Runtime = struct { const selectAutomationCanvasWidget = AutomationWidgetMethods.selectAutomationCanvasWidget; const setAutomationCanvasWidgetText = AutomationWidgetMethods.setAutomationCanvasWidgetText; const editAutomationCanvasWidgetText = AutomationWidgetMethods.editAutomationCanvasWidgetText; + const composeAutomationCanvasWidgetText = AutomationWidgetMethods.composeAutomationCanvasWidgetText; const dispatchAutomationCanvasWidgetDrag = AutomationWidgetMethods.dispatchAutomationCanvasWidgetDrag; const dispatchAutomationCanvasWidgetFileDrop = AutomationWidgetMethods.dispatchAutomationCanvasWidgetFileDrop; }; diff --git a/src/runtime/gpu_surface_events.zig b/src/runtime/gpu_surface_events.zig index b688167d..971c4bf0 100644 --- a/src/runtime/gpu_surface_events.zig +++ b/src/runtime/gpu_surface_events.zig @@ -297,11 +297,14 @@ pub fn RuntimeGpuSurfaceEvents(comptime Runtime: type) type { &clipboard_paste_buffer, ); } - if (widget_keyboard_event) |keyboard_event| { - try CanvasWidgetEventMethods().updateCanvasWidgetControlFromKeyboard(self, keyboard_event); + // The text pass stamps the edit it derived and applied onto + // the event (Escape's clear included), so the app dispatch + // below hears exactly the edit the retained editor performed. + if (widget_keyboard_event) |*keyboard_event| { + try CanvasWidgetEventMethods().updateCanvasWidgetControlFromKeyboard(self, keyboard_event.*); try CanvasWidgetEventMethods().updateCanvasWidgetTextFromKeyboard(self, keyboard_event); } - const widget_text_input_event = if (widget_surface_dismissed) + var widget_text_input_event = if (widget_surface_dismissed) null else CanvasWidgetEventMethods().routeCanvasWidgetTextInput(self, input_event, &self.widget_event_route_entries) catch |err| switch (err) { @@ -311,7 +314,7 @@ pub fn RuntimeGpuSurfaceEvents(comptime Runtime: type) type { => null, else => return err, }; - if (widget_text_input_event) |text_input_event| { + if (widget_text_input_event) |*text_input_event| { try CanvasWidgetEventMethods().updateCanvasWidgetTextFromKeyboard(self, text_input_event); } // The refresh batch stays open across the app dispatches diff --git a/src/runtime/session_journal.zig b/src/runtime/session_journal.zig index 5ea2b516..acff69ad 100644 --- a/src/runtime/session_journal.zig +++ b/src/runtime/session_journal.zig @@ -44,16 +44,17 @@ //! reordering any journaled enum is a format break: bump //! `format_version` when one moves. //! -//! Coverage note: automation driving that synthesizes PLATFORM events is -//! fully journaled (widget-click/hold/context-press/drag/wheel/key, -//! widget-action press/toggle/increment/decrement/set_text, resize, -//! menu-command, shortcut, tray-action). The few automation verbs that -//! mutate runtime widget state directly (widget-action -//! focus/select/set_selection and the composition edits) bypass the -//! platform boundary and do not journal in v1 — a recorded session using -//! them replays without those mutations, and the next fingerprint -//! checkpoint says so loudly. Drive text through `widget-key` and -//! selection through pointer/key input when recording. +//! Coverage note: automation driving is fully journaled. Verbs that +//! synthesize PLATFORM events (widget-click/hold/context-press/drag/ +//! wheel/key, resize, menu-command, shortcut, tray-action) journal +//! those events; every widget-action verb — including the direct- +//! surface ones (focus, select, set_selection, set_text, and the +//! composition edits) — journals as the outer-wins +//! `widget_accessibility_action` record its dispatch stages, with the +//! events it synthesizes inside suppressed as that record's children. +//! Replaying the action record re-runs the verb through the same +//! dispatch, so recorded sessions may drive text and selection through +//! any verb. const std = @import("std"); const geometry = @import("geometry"); @@ -70,8 +71,12 @@ pub const magic = "NSDKSJNL"; /// enum orders bumps this; readers refuse other versions loudly rather /// than misreading yesterday's shape. v2 added the stream `buffering` /// flag to audio event and audio effect records; v3 added the spectrum -/// band bytes to both (and the `.spectrum` audio kind). -pub const format_version: u32 = 3; +/// band bytes to both (and the `.spectrum` audio kind); v4 added the +/// composition verbs (`set_composition`/`commit_composition`/ +/// `cancel_composition`, codes 11-13) to the accessibility-action +/// record's verb enum — a v3 reader hitting one of those codes would +/// have reported the file corrupt instead of refusing the skew. +pub const format_version: u32 = 4; // ------------------------------------------------------------- budgets // @@ -1067,7 +1072,7 @@ pub const Reader = struct { pub fn describeError(err: JournalError) []const u8 { return switch (err) { error.JournalBadMagic => "not a session journal (bad magic) - record one with NATIVE_SDK_SESSION_RECORD=", - error.JournalUnsupportedVersion => "journal format version differs from this build - re-record the session with the same build that replays it", + error.JournalUnsupportedVersion => std.fmt.comptimePrint("journal format version differs from the v{d} this build reads and writes - re-record the session with the same build that replays it", .{format_version}), error.JournalTruncated => "journal ends mid-record (the recording was cut off) - re-record, and keep the app running until it exits cleanly", error.JournalCorrupt => "journal payload does not decode (damaged or hand-edited bytes)", error.JournalRecordOverBudget => "journal claims a record beyond max_session_record_bytes - the file is damaged or hostile", @@ -1435,6 +1440,13 @@ test "reader refuses bad magic and version skew" { @memcpy(skewed[0..len], buffer[0..len]); std.mem.writeInt(u32, skewed[magic.len..][0..4], format_version + 1, .little); try testing.expectError(error.JournalUnsupportedVersion, Reader.init(skewed[0..len])); + // Older journals are refused the same way — and, symmetrically, a + // reader built before a version bump refuses a NEWER journal at the + // preamble instead of reporting an unknown payload code as + // corruption (the v4 bump's reason: v3 readers would have called a + // journaled composition verb code corrupt). + std.mem.writeInt(u32, skewed[magic.len..][0..4], format_version - 1, .little); + try testing.expectError(error.JournalUnsupportedVersion, Reader.init(skewed[0..len])); } test "reader fails loudly on truncation at every boundary" { diff --git a/src/runtime/session_record.zig b/src/runtime/session_record.zig index 1f8240cd..4e53a96e 100644 --- a/src/runtime/session_record.zig +++ b/src/runtime/session_record.zig @@ -6,6 +6,9 @@ //! BEFORE the event record — the ordering replay depends on (feed the //! stub executor, then dispatch). Nested dispatches (automation commands //! inside `frame_requested`) commit innermost-first for the same reason. +//! One exception: events nested inside a dispatched accessibility +//! action are suppressed — replaying the action re-derives them, so the +//! outer record is the whole representation (see `stageEvent`). //! //! Recording must never take the app down: any failure — a sink write //! error, an over-budget event, a journal past its size budget — flips @@ -46,7 +49,14 @@ pub const SessionRecorder = struct { /// checkpoint follows each published frame. last_checkpoint_frame: u64 = 0, depth: usize = 0, + /// Staging-stack depth of the `widget_accessibility_action` event + /// currently being dispatched, if any. While set, nested + /// stage/commit pairs are suppressed: the outer action record is the + /// journal's WHOLE representation of the interaction (see + /// `stageEvent`). + suppress_owner_depth: ?usize = null, staged_lens: [journal.max_session_event_depth]usize = [_]usize{0} ** journal.max_session_event_depth, + staged_suppressed: [journal.max_session_event_depth]bool = [_]bool{false} ** journal.max_session_event_depth, staged: [journal.max_session_event_depth][journal.max_session_event_bytes]u8 = undefined, /// Encode scratch for effect payloads (up to a whole file read). effect_buffer: [journal.max_session_record_bytes]u8 = undefined, @@ -72,15 +82,55 @@ pub const SessionRecorder = struct { /// Serialize `event` into the staging stack. Effect results drained /// during its dispatch write directly to the stream; `commitEvent` /// then appends the event record after them. + /// + /// Accessibility actions journal OUTER-WINS: a dispatched + /// `widget_accessibility_action` synthesizes real platform events + /// (a press dispatches its Enter key, set_text a select-all plus a + /// text input, composition its ime inputs) through the same choke + /// point, so recording both the action and its children would make + /// replay dispatch the children twice — once from their own records, + /// then again when replaying the action re-runs the verb. The outer + /// record wins because the children are deterministic derivations of + /// the action against runtime state replay has already rebuilt, and + /// because it keeps the ASSISTIVE semantics visible in the journal + /// ("press on widget 12", not an anonymous key event). While the + /// action sits on the staging stack, nested stage/commit pairs are + /// therefore suppressed — staged as placeholders so commit pairing + /// stays balanced, never written, never counted (`event_count` must + /// keep matching the records a replay reader will actually see; + /// checkpoints fire only at depth 0, so no ordinal can land inside + /// the suppressed window). Effect results are NOT suppressed: they + /// write directly to the stream and still precede the action record, + /// exactly the feed-then-dispatch order replay depends on. BOTH + /// entry surfaces stage the action record: the platform AX event + /// path stages it at the dispatch choke point, and the direct verb + /// surfaces (an embed host's `widgetAction`, automation + /// `widget_action` commands) stage a synthetic one inside + /// `dispatchCanvasWidgetAccessibilityAction` — without it, their + /// journaled children were untargeted inputs routed by focus while + /// the verb's focus write stayed unjournaled, so a fresh replay + /// delivered them against the wrong editor. A second accessibility + /// action staged while one is already on the stack (the direct + /// dispatch reached through a staged platform AX event) is just a + /// suppressed placeholder: the outermost action owns the window and + /// the journal keeps exactly one record. pub fn stageEvent(self: *SessionRecorder, event: platform.Event) void { if (!self.began or self.failed or self.finished) return; if (self.depth >= journal.max_session_event_depth) { return self.fail("dispatch nesting exceeded max_session_event_depth - this is a runtime bug, not a session shape"); } + if (self.suppress_owner_depth != null) { + self.staged_suppressed[self.depth] = true; + self.staged_lens[self.depth] = 0; + self.depth += 1; + return; + } const encoded = journal.encodeEvent(event, &self.staged[self.depth]) catch { return self.fail("a platform event exceeded max_session_event_bytes"); }; self.staged_lens[self.depth] = encoded.len; + self.staged_suppressed[self.depth] = false; + if (event == .widget_accessibility_action) self.suppress_owner_depth = self.depth; self.depth += 1; } @@ -90,6 +140,10 @@ pub const SessionRecorder = struct { if (!self.began or self.failed or self.finished) return; if (self.depth == 0) return; self.depth -= 1; + if (self.staged_suppressed[self.depth]) return; + if (self.suppress_owner_depth) |owner_depth| { + if (owner_depth == self.depth) self.suppress_owner_depth = null; + } self.writeRecord(.event, self.staged[self.depth][0..self.staged_lens[self.depth]]); if (!self.failed) self.event_count += 1; } @@ -294,6 +348,118 @@ test "recorder commits nested events innermost-first" { try testing.expect(outer.event == .frame_requested); } +test "accessibility actions suppress their synthesized children in the journal" { + var buffer_sink = BufferSink{}; + const recorder = try testing.allocator.create(SessionRecorder); + defer testing.allocator.destroy(recorder); + recorder.* = SessionRecorder.init(buffer_sink.sink()); + recorder.begin(.{ .platform_name = "test", .app_name = "demo" }); + + // An AX press dispatches its synthesized Enter key through the same + // choke point: the nested event must not land — replaying the action + // re-derives it — but an effect result drained during the dispatch + // still writes through, ahead of the action record. + recorder.stageEvent(.{ .widget_accessibility_action = .{ + .window_id = 1, + .label = "canvas", + .id = 7, + .action = .press, + } }); + recorder.stageEvent(.{ .gpu_surface_input = .{ + .window_id = 1, + .label = "canvas", + .kind = .key_down, + .key = "enter", + } }); + recorder.recordEffect(.{ .kind = .line, .key = 3, .payload = "drained" }); + recorder.commitEvent(); + recorder.commitEvent(); + // Suppression ends with the action: a later top-level input records. + recorder.stageEvent(.{ .gpu_surface_input = .{ + .window_id = 1, + .label = "canvas", + .kind = .key_down, + .key = "tab", + } }); + recorder.commitEvent(); + recorder.recordCheckpoint(1, 0xbeef); + recorder.finish(); + try testing.expect(!recorder.failed); + + var reader = try journal.Reader.init(buffer_sink.bytes()); + _ = (try reader.next()).?; // header + const effect = (try reader.next()).?; + try testing.expectEqualStrings("drained", effect.effect.payload); + const action = (try reader.next()).?; + try testing.expectEqual(platform.WidgetAccessibilityActionKind.press, action.event.widget_accessibility_action.action); + const key = (try reader.next()).?; + try testing.expectEqualStrings("tab", key.event.gpu_surface_input.key); + // The checkpoint ordinal and end counts see only the two surviving + // events — coherent with what a replay reader will process. + const checkpoint = (try reader.next()).?; + try testing.expectEqual(@as(u64, 2), checkpoint.checkpoint.event_ordinal); + const end = (try reader.next()).?; + try testing.expectEqual(@as(u64, 2), end.end.event_count); + try testing.expectEqual(@as(u64, 1), end.end.effect_count); +} + +test "a nested accessibility action stays a suppressed placeholder" { + var buffer_sink = BufferSink{}; + const recorder = try testing.allocator.create(SessionRecorder); + defer testing.allocator.destroy(recorder); + recorder.* = SessionRecorder.init(buffer_sink.sink()); + recorder.begin(.{ .platform_name = "test", .app_name = "demo" }); + + // The platform AX event path stages tag 23 at the dispatch choke + // point, then `dispatchCanvasWidgetAccessibilityAction` stages its + // synthetic copy of the same action: the inner stage must ride the + // suppression window as a placeholder (one journal record, the + // outer one), and suppression must survive the inner commit so the + // verb's children staged AFTER it stay suppressed too. + recorder.stageEvent(.{ .widget_accessibility_action = .{ + .window_id = 1, + .label = "canvas", + .id = 7, + .action = .set_text, + .text = "outer", + } }); + recorder.stageEvent(.{ .widget_accessibility_action = .{ + .window_id = 1, + .label = "canvas", + .id = 7, + .action = .set_text, + .text = "synthetic", + } }); + recorder.stageEvent(.{ .gpu_surface_input = .{ + .window_id = 1, + .label = "canvas", + .kind = .text_input, + .text = "outer", + } }); + recorder.commitEvent(); + recorder.commitEvent(); + recorder.commitEvent(); + // The window closed with the outer action: a later input records. + recorder.stageEvent(.{ .gpu_surface_input = .{ + .window_id = 1, + .label = "canvas", + .kind = .key_down, + .key = "tab", + } }); + recorder.commitEvent(); + recorder.finish(); + try testing.expect(!recorder.failed); + + var reader = try journal.Reader.init(buffer_sink.bytes()); + _ = (try reader.next()).?; // header + const action = (try reader.next()).?; + try testing.expectEqualStrings("outer", action.event.widget_accessibility_action.text); + const key = (try reader.next()).?; + try testing.expectEqualStrings("tab", key.event.gpu_surface_input.key); + const end = (try reader.next()).?; + try testing.expectEqual(@as(u64, 2), end.end.event_count); +} + test "recorder fails loudly once and seals nothing after a sink error" { var buffer_sink = BufferSink{ .fail_after = 32 }; const recorder = try testing.allocator.create(SessionRecorder); diff --git a/src/runtime/session_tests.zig b/src/runtime/session_tests.zig index 8849402b..d4be5f12 100644 --- a/src/runtime/session_tests.zig +++ b/src/runtime/session_tests.zig @@ -7,6 +7,7 @@ const std = @import("std"); const geometry = @import("geometry"); +const canvas = @import("canvas"); const app_manifest = @import("app_manifest"); const core = @import("core.zig"); const ui_app_mod = @import("ui_app.zig"); @@ -32,10 +33,36 @@ const SessionModel = struct { /// pin, without 32 array fields in the equality check. spectrum_count: u32 = 0, band_checksum: u64 = 0, + /// The search query's `on_input` mirror, zero-padded so the deep + /// model equality below stays deterministic. The reference session + /// types into the field and Escape-clears it: the journal records + /// only the RAW click/type/Escape platform events, so replay must + /// re-derive the same clear edit the recording's editor applied. + query: [32]u8 = [_]u8{0} ** 32, + query_len: usize = 0, + query_anchor: usize = 0, + query_focus: usize = 0, + query_edits: u32 = 0, + /// A SECOND editable field's mirror: the direct-verb replay tests + /// need two editors so a composition targeting one can prove it + /// does not land on whichever field the session left focused. + name: [32]u8 = [_]u8{0} ** 32, + name_len: usize = 0, + name_anchor: usize = 0, + name_focus: usize = 0, + name_edits: u32 = 0, fn bodyText(self: *const SessionModel) []const u8 { return self.body[0..self.body_len]; } + + fn queryText(self: *const SessionModel) []const u8 { + return self.query[0..self.query_len]; + } + + fn nameText(self: *const SessionModel) []const u8 { + return self.name[0..self.name_len]; + } }; const SessionMsg = union(enum) { @@ -44,6 +71,8 @@ const SessionMsg = union(enum) { start_fetch, start_spawn, start_audio, + query_edit: canvas.TextInputEvent, + name_edit: canvas.TextInputEvent, fetched: effects_mod.EffectResponse, line: effects_mod.EffectLine, exited: effects_mod.EffectExit, @@ -92,17 +121,48 @@ fn sessionUpdate(model: *SessionModel, msg: SessionMsg, fx: *SessionApp.Effects) for (event.bands) |band| checksum = checksum *% 31 +% band; model.band_checksum = checksum; }, + .query_edit => |edit| applyMirrorEdit(&model.query, &model.query_len, &model.query_anchor, &model.query_focus, &model.query_edits, edit), + .name_edit => |edit| applyMirrorEdit(&model.name, &model.name_len, &model.name_anchor, &model.name_focus, &model.name_edits, edit), .line => model.line_count += 1, .exited => |exit| model.exit_code = exit.code, .tick => |timer| model.tick_timestamp_ns = timer.timestamp_ns, } } +/// One editable-field mirror step: apply `edit` to the zero-padded +/// text + selection mirror the deep model equality checks compare. +fn applyMirrorEdit(store: *[32]u8, len: *usize, anchor: *usize, focus: *usize, edits: *u32, edit: canvas.TextInputEvent) void { + var scratch: [32]u8 = undefined; + const next = (canvas.TextEditState{ + .text = store[0..len.*], + .selection = .{ .anchor = anchor.*, .focus = focus.* }, + }).apply(edit, &scratch) catch return; + var out = [_]u8{0} ** 32; + const out_len = @min(next.text.len, out.len); + std.mem.copyForwards(u8, out[0..out_len], next.text[0..out_len]); + store.* = out; + len.* = out_len; + anchor.* = next.selection.anchor; + focus.* = next.selection.focus; + edits.* += 1; +} + fn sessionView(ui: *SessionApp.Ui, model: *const SessionModel) SessionApp.Ui.Node { return ui.column(.{ .gap = 8, .padding = 12 }, .{ ui.text(.{}, ui.fmt("Count {d}", .{model.count})), ui.text(.{}, ui.fmt("Body {s} ({d})", .{ model.bodyText(), model.fetch_status })), ui.text(.{}, ui.fmt("Lines {d} Exit {d} Tick {d} Stamp {d}", .{ model.line_count, model.exit_code, model.tick_timestamp_ns, model.stamp_ms })), + ui.el(.search_field, .{ + .text = model.queryText(), + .placeholder = "Search", + .on_input = SessionApp.Ui.inputMsg(.query_edit), + }, .{}), + ui.el(.text_field, .{ + .text = model.nameText(), + .placeholder = "Name", + .on_input = SessionApp.Ui.inputMsg(.name_edit), + }, .{}), + ui.text(.{}, ui.fmt("Query {s} ({d}) Name {s} ({d})", .{ model.queryText(), model.query_edits, model.nameText(), model.name_edits })), ui.button(.{ .on_press = .increment }, "Increment"), }); } @@ -242,6 +302,41 @@ fn recordReferenceSession(gpa: std.mem.Allocator, buffer: *JournalBuffer, web_la try harness.runtime.dispatchPlatformEvent(app, .wake); try harness.runtime.dispatchPlatformEvent(app, .frame_requested); + // An Escape-clear in the search field is DERIVED input state: the + // journal records only the raw click/type/Escape platform events, + // and replay must re-derive the same clear edit the recording's + // editor applied — model mirror, retained editor, and fingerprint + // all landing identically. + const layout = try harness.runtime.canvasWidgetLayout(1, canvas_label); + var search_frame: ?geometry.RectF = null; + for (layout.nodes) |node| { + if (node.widget.kind == .search_field) search_frame = node.frame; + } + const field_frame = search_frame.?; + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = canvas_label, + .kind = .pointer_down, + .x = field_frame.x + field_frame.width * 0.5, + .y = field_frame.y + field_frame.height * 0.5, + } }); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = canvas_label, + .kind = .text_input, + .text = "glass", + } }); + try std.testing.expectEqualStrings("glass", app_state.model.queryText()); + try harness.runtime.dispatchPlatformEvent(app, .frame_requested); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = canvas_label, + .kind = .key_down, + .key = "escape", + } }); + try std.testing.expectEqualStrings("", app_state.model.queryText()); + try harness.runtime.dispatchPlatformEvent(app, .frame_requested); + recorder.finish(); try std.testing.expect(!recorder.failed); @@ -294,6 +389,10 @@ test "a recorded session replays to identical model state and fingerprints" { try std.testing.expect(recorded.model.stamp_ms != 0); try std.testing.expectEqual(@as(u32, 1), recorded.model.spectrum_count); try std.testing.expect(recorded.model.band_checksum != 0); + // The typed insert and the DERIVED Escape-clear both reached the + // model's `on_input` mirror, and the clear left it empty. + try std.testing.expectEqual(@as(u32, 2), recorded.model.query_edits); + try std.testing.expectEqualStrings("", recorded.model.queryText()); const replayed = try replayIntoFreshApp(gpa, buffer.journalBytes(), true); try std.testing.expect(replayed.report.ok()); @@ -327,6 +426,360 @@ test "a native-only session records and replays like a web-layer one" { try std.testing.expectEqual(recorded.fingerprint, replayed.fingerprint); } +test "accessibility actions journal once and replay without double-dispatch" { + // A journaled `widget_accessibility_action` re-runs its verb on + // replay, and the verb synthesizes REAL platform events (press its + // Enter key, set_text a select-all plus a text input). If those + // children also landed in the journal, replay would dispatch them + // twice — the stray child arrives first, against the focus the + // PREVIOUS action left behind, so a repeated press increments an + // extra time and a repeated set_text edits the field extra times. + // The recorder journals outer-wins: exactly one record per action, + // and live/replay model counters match exactly. + const gpa = std.testing.allocator; + const buffer = try std.heap.page_allocator.create(JournalBuffer); + defer std.heap.page_allocator.destroy(buffer); + buffer.len = 0; + + const recorder = try std.heap.page_allocator.create(session_record.SessionRecorder); + defer std.heap.page_allocator.destroy(recorder); + recorder.* = session_record.SessionRecorder.init(buffer.sink()); + recorder.begin(.{ .platform_name = "test", .app_name = "session-demo", .window_width = 400, .window_height = 300 }); + + const harness = try core.TestHarness().create(gpa, .{ .size = geometry.SizeF.init(400, 300) }); + defer harness.destroy(gpa); + harness.null_platform.gpu_surfaces = true; + harness.runtime.options.session_recorder = recorder; + + const app_state = try gpa.create(SessionApp); + defer gpa.destroy(app_state); + app_state.* = SessionApp.init(std.heap.page_allocator, .{}, sessionOptions()); + defer app_state.deinit(); + app_state.effects.executor = .fake; + const app = app_state.app(); + + try harness.start(app); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{ + .label = canvas_label, + .size = geometry.SizeF.init(400, 300), + .scale_factor = 2, + .frame_index = 1, + .timestamp_ns = 1_000_000, + } }); + try harness.runtime.dispatchPlatformEvent(app, .frame_requested); + + const layout = try harness.runtime.canvasWidgetLayout(1, canvas_label); + var button_id: canvas.ObjectId = 0; + var field_id: canvas.ObjectId = 0; + for (layout.nodes) |node| { + if (node.widget.kind == .button) button_id = node.widget.id; + if (node.widget.kind == .search_field) field_id = node.widget.id; + } + try std.testing.expect(button_id != 0); + try std.testing.expect(field_id != 0); + + // Two AX presses: the second is the doubling witness — with the + // synthesized Enter also journaled, replay delivers it against the + // already-focused button and the count lands at 3, not 2. + try harness.runtime.dispatchPlatformEvent(app, .{ .widget_accessibility_action = .{ + .window_id = 1, + .label = canvas_label, + .id = button_id, + .action = .press, + } }); + try harness.runtime.dispatchPlatformEvent(app, .{ .widget_accessibility_action = .{ + .window_id = 1, + .label = canvas_label, + .id = button_id, + .action = .press, + } }); + try std.testing.expectEqual(@as(u32, 2), app_state.model.count); + try harness.runtime.dispatchPlatformEvent(app, .frame_requested); + + // Two AX set_texts: the second one's stray select-all + text input + // would hit the focused field on replay and inflate `query_edits`. + try harness.runtime.dispatchPlatformEvent(app, .{ .widget_accessibility_action = .{ + .window_id = 1, + .label = canvas_label, + .id = field_id, + .action = .set_text, + .text = "glass", + } }); + try harness.runtime.dispatchPlatformEvent(app, .{ .widget_accessibility_action = .{ + .window_id = 1, + .label = canvas_label, + .id = field_id, + .action = .set_text, + .text = "brass", + } }); + try std.testing.expectEqualStrings("brass", app_state.model.queryText()); + try harness.runtime.dispatchPlatformEvent(app, .frame_requested); + const edits_after_set_text = app_state.model.query_edits; + + // An IME composition through the DIRECT verb surface (the embed + // host's `widgetAction` path): no platform event arrives, so the + // dispatch stages its own synthetic `widget_accessibility_action` + // record — outer-wins exactly like the platform path — and the + // synthesized ime children stay out of the journal. Replay re-runs + // the verb, focus included. + _ = try harness.runtime.dispatchCanvasWidgetAccessibilityAction(app, 1, canvas_label, .{ + .id = field_id, + .action = .set_composition, + .text = "ne", + }); + _ = try harness.runtime.dispatchCanvasWidgetAccessibilityAction(app, 1, canvas_label, .{ + .id = field_id, + .action = .commit_composition, + }); + try std.testing.expectEqualStrings("brassne", app_state.model.queryText()); + // Both composition edits reached the model's `on_input` mirror. + try std.testing.expect(app_state.model.query_edits > edits_after_set_text); + try harness.runtime.dispatchPlatformEvent(app, .frame_requested); + + recorder.finish(); + try std.testing.expect(!recorder.failed); + const recorded_model = app_state.model; + const recorded_fingerprint = harness.runtime.sessionStateFingerprint(); + + // The journal carries one record per AX action — platform events + // AND direct verb calls — and none of their synthesized key/text/ime + // children. + var reader = try journal.Reader.init(buffer.journalBytes()); + var action_records: usize = 0; + while (try reader.next()) |record| { + if (record != .event) continue; + switch (record.event) { + .widget_accessibility_action => action_records += 1, + .gpu_surface_input => |input| switch (input.kind) { + .ime_set_composition, .ime_commit_composition, .ime_cancel_composition, .key_down, .text_input => return error.SynthesizedChildJournaled, + else => {}, + }, + else => {}, + } + } + try std.testing.expectEqual(@as(usize, 6), action_records); + + // Replay into a fresh app: every counter must match exactly — a + // double-dispatched child shows up as +1 on `count` or extra + // `query_edits`. + const replayed = try replayIntoFreshApp(gpa, buffer.journalBytes(), true); + try std.testing.expect(replayed.report.ok()); + try std.testing.expectEqual(@as(u32, 2), replayed.model.count); + try std.testing.expectEqual(recorded_model.query_edits, replayed.model.query_edits); + try std.testing.expectEqualDeep(recorded_model, replayed.model); + try std.testing.expectEqual(recorded_fingerprint, replayed.fingerprint); +} + +/// Shared scaffold for the direct-verb replay tests: recorder, harness, +/// started app, one presented frame, and the two editors' widget ids. +const DirectVerbSession = struct { + recorder: *session_record.SessionRecorder, + harness: *core.TestHarness(), + app_state: *SessionApp, + app: core.App, + search_id: canvas.ObjectId, + name_id: canvas.ObjectId, + + fn start(gpa: std.mem.Allocator, buffer: *JournalBuffer) !DirectVerbSession { + const recorder = try std.heap.page_allocator.create(session_record.SessionRecorder); + errdefer std.heap.page_allocator.destroy(recorder); + recorder.* = session_record.SessionRecorder.init(buffer.sink()); + recorder.begin(.{ .platform_name = "test", .app_name = "session-demo", .window_width = 400, .window_height = 300 }); + + const harness = try core.TestHarness().create(gpa, .{ .size = geometry.SizeF.init(400, 300) }); + errdefer harness.destroy(gpa); + harness.null_platform.gpu_surfaces = true; + harness.runtime.options.session_recorder = recorder; + + const app_state = try gpa.create(SessionApp); + errdefer gpa.destroy(app_state); + app_state.* = SessionApp.init(std.heap.page_allocator, .{}, sessionOptions()); + app_state.effects.executor = .fake; + const app = app_state.app(); + + try harness.start(app); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{ + .label = canvas_label, + .size = geometry.SizeF.init(400, 300), + .scale_factor = 2, + .frame_index = 1, + .timestamp_ns = 1_000_000, + } }); + try harness.runtime.dispatchPlatformEvent(app, .frame_requested); + + const layout = try harness.runtime.canvasWidgetLayout(1, canvas_label); + var search_id: canvas.ObjectId = 0; + var name_id: canvas.ObjectId = 0; + for (layout.nodes) |node| { + if (node.widget.kind == .search_field) search_id = node.widget.id; + if (node.widget.kind == .text_field) name_id = node.widget.id; + } + try std.testing.expect(search_id != 0); + try std.testing.expect(name_id != 0); + + return .{ + .recorder = recorder, + .harness = harness, + .app_state = app_state, + .app = app, + .search_id = search_id, + .name_id = name_id, + }; + } + + fn destroy(self: *const DirectVerbSession, gpa: std.mem.Allocator) void { + self.app_state.deinit(); + gpa.destroy(self.app_state); + self.harness.destroy(gpa); + std.heap.page_allocator.destroy(self.recorder); + } +}; + +/// The direct-verb journal must carry the AX action records and NONE of +/// their synthesized children — a child record is exactly the stray +/// input that replays against the wrong focus. +fn expectActionOnlyJournal(journal_bytes: []const u8, expected_actions: usize) !void { + var reader = try journal.Reader.init(journal_bytes); + var action_records: usize = 0; + while (try reader.next()) |record| { + if (record != .event) continue; + switch (record.event) { + .widget_accessibility_action => action_records += 1, + .gpu_surface_input => |input| switch (input.kind) { + .ime_set_composition, .ime_commit_composition, .ime_cancel_composition, .key_down, .text_input => return error.SynthesizedChildJournaled, + else => {}, + }, + else => {}, + } + } + try std.testing.expectEqual(expected_actions, action_records); +} + +test "a direct-verb composition first in a session replays onto its target editor" { + // The P1 this pins: a direct-verb call (embed `widgetAction`, + // automation `widget_action`) journaled only its synthesized ime + // children — untargeted inputs routed by focus — while the verb's + // own focus write never reached the journal. A composition as the + // FIRST action of a session therefore replayed against a fresh + // runtime with NO focused editor and the text vanished (the old + // test passed only because earlier journaled AX records happened to + // re-focus the same field). The synthetic outer record replays the + // verb, which re-runs the focus. + const gpa = std.testing.allocator; + const buffer = try std.heap.page_allocator.create(JournalBuffer); + defer std.heap.page_allocator.destroy(buffer); + buffer.len = 0; + + const session = try DirectVerbSession.start(gpa, buffer); + defer session.destroy(gpa); + + _ = try session.harness.runtime.dispatchCanvasWidgetAccessibilityAction(session.app, 1, canvas_label, .{ + .id = session.search_id, + .action = .set_composition, + .text = "ne", + }); + _ = try session.harness.runtime.dispatchCanvasWidgetAccessibilityAction(session.app, 1, canvas_label, .{ + .id = session.search_id, + .action = .commit_composition, + }); + try std.testing.expectEqualStrings("ne", session.app_state.model.queryText()); + try session.harness.runtime.dispatchPlatformEvent(session.app, .frame_requested); + + session.recorder.finish(); + try std.testing.expect(!session.recorder.failed); + try expectActionOnlyJournal(buffer.journalBytes(), 2); + + const replayed = try replayIntoFreshApp(gpa, buffer.journalBytes(), true); + try std.testing.expect(replayed.report.ok()); + try std.testing.expectEqualStrings("ne", replayed.model.queryText()); + try std.testing.expectEqualDeep(session.app_state.model, replayed.model); + try std.testing.expectEqual(session.harness.runtime.sessionStateFingerprint(), replayed.fingerprint); +} + +test "a direct-verb composition targets its editor while another field holds focus" { + // Focus field B (a real journaled click), then compose into field A + // through the direct verb surface. Live, the verb re-focuses A; a + // replay that only saw the ime children would deliver them to B — + // the field the session left focused — writing the text into the + // wrong editor. The outer record re-runs the verb against A. + const gpa = std.testing.allocator; + const buffer = try std.heap.page_allocator.create(JournalBuffer); + defer std.heap.page_allocator.destroy(buffer); + buffer.len = 0; + + const session = try DirectVerbSession.start(gpa, buffer); + defer session.destroy(gpa); + + const layout = try session.harness.runtime.canvasWidgetLayout(1, canvas_label); + const search_frame = layout.findById(session.search_id).?.frame; + try session.harness.runtime.dispatchPlatformEvent(session.app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = canvas_label, + .kind = .pointer_down, + .x = search_frame.x + search_frame.width * 0.5, + .y = search_frame.y + search_frame.height * 0.5, + } }); + try session.harness.runtime.dispatchPlatformEvent(session.app, .frame_requested); + + _ = try session.harness.runtime.dispatchCanvasWidgetAccessibilityAction(session.app, 1, canvas_label, .{ + .id = session.name_id, + .action = .set_composition, + .text = "ada", + }); + _ = try session.harness.runtime.dispatchCanvasWidgetAccessibilityAction(session.app, 1, canvas_label, .{ + .id = session.name_id, + .action = .commit_composition, + }); + try std.testing.expectEqualStrings("ada", session.app_state.model.nameText()); + try std.testing.expectEqualStrings("", session.app_state.model.queryText()); + try session.harness.runtime.dispatchPlatformEvent(session.app, .frame_requested); + + session.recorder.finish(); + try std.testing.expect(!session.recorder.failed); + try expectActionOnlyJournal(buffer.journalBytes(), 2); + + const replayed = try replayIntoFreshApp(gpa, buffer.journalBytes(), true); + try std.testing.expect(replayed.report.ok()); + // The composition landed on A, and B stayed clean — the wrong-focus + // failure writes "ada" into `query` instead. + try std.testing.expectEqualStrings("ada", replayed.model.nameText()); + try std.testing.expectEqualStrings("", replayed.model.queryText()); + try std.testing.expectEqualDeep(session.app_state.model, replayed.model); + try std.testing.expectEqual(session.harness.runtime.sessionStateFingerprint(), replayed.fingerprint); +} + +test "a direct-surface set_text first in a session replays onto its target editor" { + // Same hole, set_text shape: the direct verb journaled a select-all + // key plus a text input — both routed by focus — so first-in-session + // they replayed into nothing. One outer record, replayed through the + // verb, re-runs focus + select-all + insert exactly as live. + const gpa = std.testing.allocator; + const buffer = try std.heap.page_allocator.create(JournalBuffer); + defer std.heap.page_allocator.destroy(buffer); + buffer.len = 0; + + const session = try DirectVerbSession.start(gpa, buffer); + defer session.destroy(gpa); + + _ = try session.harness.runtime.dispatchCanvasWidgetAccessibilityAction(session.app, 1, canvas_label, .{ + .id = session.search_id, + .action = .set_text, + .text = "glass", + }); + try std.testing.expectEqualStrings("glass", session.app_state.model.queryText()); + try session.harness.runtime.dispatchPlatformEvent(session.app, .frame_requested); + + session.recorder.finish(); + try std.testing.expect(!session.recorder.failed); + try expectActionOnlyJournal(buffer.journalBytes(), 1); + + const replayed = try replayIntoFreshApp(gpa, buffer.journalBytes(), true); + try std.testing.expect(replayed.report.ok()); + try std.testing.expectEqualStrings("glass", replayed.model.queryText()); + try std.testing.expectEqualDeep(session.app_state.model, replayed.model); + try std.testing.expectEqual(session.harness.runtime.sessionStateFingerprint(), replayed.fingerprint); +} + test "a truncated journal is refused loudly" { const gpa = std.testing.allocator; const buffer = try std.heap.page_allocator.create(JournalBuffer); diff --git a/src/runtime/ui_app_tests.zig b/src/runtime/ui_app_tests.zig index e7219d97..a7d18c78 100644 --- a/src/runtime/ui_app_tests.zig +++ b/src/runtime/ui_app_tests.zig @@ -1986,6 +1986,466 @@ test "automation set_text routes through the input path so the elm mirror stays } } +// -------------------------------------------- edit-derivation choke point + +const search_mirror_canvas_label = "search-mirror-canvas"; + +const SearchMirrorModel = struct { + query: canvas.TextBuffer(64) = .{}, + edit_count: u32 = 0, +}; + +const SearchMirrorMsg = union(enum) { + query_edit: canvas.TextInputEvent, +}; + +const SearchMirrorApp = ui_app_model.UiApp(SearchMirrorModel, SearchMirrorMsg); + +fn searchMirrorUpdate(model: *SearchMirrorModel, msg: SearchMirrorMsg) void { + switch (msg) { + .query_edit => |edit| { + model.query.apply(edit); + model.edit_count += 1; + }, + } +} + +fn searchMirrorView(ui: *SearchMirrorApp.Ui, model: *const SearchMirrorModel) SearchMirrorApp.Ui.Node { + return ui.column(.{ .gap = 8, .padding = 12 }, .{ + ui.el(.search_field, .{ + .text = model.query.text(), + .placeholder = "Search", + .on_input = SearchMirrorApp.Ui.inputMsg(.query_edit), + }, .{}), + ui.text(.{}, if (model.query.len == 0) "Unfiltered" else "Filtered"), + }); +} + +const search_mirror_views = [_]app_manifest.ShellView{ + .{ .label = search_mirror_canvas_label, .kind = .gpu_surface, .fill = true, .gpu_backend = .metal }, +}; +const search_mirror_windows = [_]app_manifest.ShellWindow{.{ + .label = "main", + .title = "SearchMirror", + .width = 400, + .height = 300, + .views = &search_mirror_views, +}}; +const search_mirror_scene: app_manifest.ShellConfig = .{ .windows = &search_mirror_windows }; + +fn startSearchMirror(harness: *core.TestHarness(), app_state: *SearchMirrorApp) !void { + app_state.* = SearchMirrorApp.init(std.heap.page_allocator, .{}, .{ + .name = "ui-app-search-mirror", + .scene = search_mirror_scene, + .canvas_label = search_mirror_canvas_label, + .update = searchMirrorUpdate, + .view = searchMirrorView, + }); + const app = app_state.app(); + try harness.start(app); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{ + .label = search_mirror_canvas_label, + .size = geometry.SizeF.init(400, 300), + .scale_factor = 1, + .frame_index = 1, + .timestamp_ns = 1_000_000, + .nonblank = true, + } }); + try std.testing.expect(app_state.installed); +} + +fn searchMirrorHasText(app_state: *const SearchMirrorApp, text: []const u8) bool { + const tree = app_state.tree orelse return false; + return widgetTreeHasText(tree.root, text); +} + +fn widgetTreeHasText(widget: canvas.Widget, text: []const u8) bool { + if (widget.kind == .text and std.mem.eql(u8, widget.text, text)) return true; + for (widget.children) |child| { + if (widgetTreeHasText(child, text)) return true; + } + return false; +} + +test "Escape's search-field clear reaches the model through the edit-derivation seam" { + // The post-launch live-GUI bug: Escape made the runtime editor clear + // the field VISUALLY while the model's `on_input` mirror never heard + // anything — the list stayed filtered against a query the screen no + // longer showed, and the next keystroke dispatched against the stale + // term. The keyboard derivation now stamps the edit it applies onto + // the dispatched event, so every formerly runtime-only edit (the + // Escape clear, the composition cancel, the single-line ArrowUp/Down + // caret jumps) reaches the model exactly as applied. + const harness = try core.TestHarness().create(std.testing.allocator, .{ .size = geometry.SizeF.init(400, 300) }); + defer harness.destroy(std.testing.allocator); + harness.null_platform.gpu_surfaces = true; + + const app_state = try std.testing.allocator.create(SearchMirrorApp); + defer std.testing.allocator.destroy(app_state); + try startSearchMirror(harness, app_state); + defer app_state.deinit(); + const app = app_state.app(); + + const field_id = findWidgetIdByKind(app_state.tree.?.root, .search_field).?; + const field_frame = (try harness.runtime.canvasWidgetLayout(1, search_mirror_canvas_label)).findById(field_id).?.frame; + + // Click into the (empty) field to focus it and type through the + // platform text channel. + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = search_mirror_canvas_label, + .kind = .pointer_down, + .x = field_frame.x + field_frame.width * 0.5, + .y = field_frame.y + field_frame.height * 0.5, + } }); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = search_mirror_canvas_label, + .kind = .text_input, + .text = "glass", + } }); + try std.testing.expectEqualStrings("glass", app_state.model.query.text()); + try std.testing.expect(searchMirrorHasText(app_state, "Filtered")); + + // ArrowUp jumps the caret to the start in a single-line field — a + // runtime-only derivation before the stamp; the model's selection + // mirror must follow so its next splice lands where the editor's + // does. + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = search_mirror_canvas_label, + .kind = .key_down, + .key = "arrowup", + } }); + try std.testing.expectEqualDeep(canvas.TextSelection.collapsed(0), app_state.model.query.selection); + + // THE pin: Escape clears the field AND the model hears it. + const edits_before_escape = app_state.model.edit_count; + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = search_mirror_canvas_label, + .kind = .key_down, + .key = "escape", + } }); + try std.testing.expect(app_state.model.edit_count > edits_before_escape); + try std.testing.expectEqualStrings("", app_state.model.query.text()); + try std.testing.expect(searchMirrorHasText(app_state, "Unfiltered")); + + // The visual state agrees with the model: the retained editor and + // the automation snapshot both show the cleared field. + const cleared_layout = try harness.runtime.canvasWidgetLayout(1, search_mirror_canvas_label); + try std.testing.expectEqualStrings("", cleared_layout.findById(field_id).?.widget.text); + const snapshot = harness.runtime.automationSnapshot("SearchMirror"); + for (snapshot.widgets) |widget| { + if (widget.id == field_id) try std.testing.expectEqualStrings("", widget.text_value); + } + + // Escape during composition cancels the composition FIRST — and the + // model hears that too (the second formerly runtime-only arm). + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = search_mirror_canvas_label, + .kind = .ime_set_composition, + .text = "ne", + } }); + try std.testing.expectEqualStrings("ne", app_state.model.query.text()); + try std.testing.expect(app_state.model.query.composition != null); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = search_mirror_canvas_label, + .kind = .key_down, + .key = "escape", + } }); + try std.testing.expectEqualStrings("", app_state.model.query.text()); + try std.testing.expect(app_state.model.query.composition == null); + const canceled_layout = try harness.runtime.canvasWidgetLayout(1, search_mirror_canvas_label); + try std.testing.expectEqualStrings("", canceled_layout.findById(field_id).?.widget.text); +} + +test "automation composition and selection verbs keep the model mirror consistent" { + // The automation/accessibility text verbs (`widget-action ... + // set_composition/commit_composition/cancel_composition`) used to + // write the runtime editor directly — on-screen composition the + // model never heard, the `set_text` bug's composition twin. They + // now ride the SAME ime input events a real IME session produces + // (journaled, stamped, dispatched); `set_selection` synthesizes the + // stamped keyboard event the clipboard edits use. + const harness = try core.TestHarness().create(std.testing.allocator, .{ .size = geometry.SizeF.init(400, 300) }); + defer harness.destroy(std.testing.allocator); + harness.null_platform.gpu_surfaces = true; + + const app_state = try std.testing.allocator.create(SearchMirrorApp); + defer std.testing.allocator.destroy(app_state); + try startSearchMirror(harness, app_state); + defer app_state.deinit(); + const app = app_state.app(); + + const field_id = findWidgetIdByKind(app_state.tree.?.root, .search_field).?; + + // Compose marked text: the editor shows it AND the model hears it. + try core.testing.dispatchAutomationWidgetAction(&harness.runtime, app, .{ + .view_label = search_mirror_canvas_label, + .id = field_id, + .action = .set_composition, + .value = "ing", + }); + try std.testing.expectEqualStrings("ing", app_state.model.query.text()); + try std.testing.expectEqualDeep(@as(?canvas.TextRange, canvas.TextRange.init(0, 3)), app_state.model.query.composition); + const composing_layout = try harness.runtime.canvasWidgetLayout(1, search_mirror_canvas_label); + try std.testing.expectEqualStrings("ing", composing_layout.findById(field_id).?.widget.text); + + // Commit: composition resolves to plain text in both. + try core.testing.dispatchAutomationWidgetAction(&harness.runtime, app, .{ + .view_label = search_mirror_canvas_label, + .id = field_id, + .action = .commit_composition, + }); + try std.testing.expectEqualStrings("ing", app_state.model.query.text()); + try std.testing.expect(app_state.model.query.composition == null); + + // Select a range: the model's selection mirror follows. + try core.testing.dispatchAutomationWidgetAction(&harness.runtime, app, .{ + .view_label = search_mirror_canvas_label, + .id = field_id, + .action = .set_selection, + .value = "0 3", + }); + try std.testing.expectEqualDeep(canvas.TextSelection{ .anchor = 0, .focus = 3 }, app_state.model.query.selection); + + // Cancel a fresh composition: the marked run vanishes from both. + try core.testing.dispatchAutomationWidgetAction(&harness.runtime, app, .{ + .view_label = search_mirror_canvas_label, + .id = field_id, + .action = .set_composition, + .value = "aro", + }); + try std.testing.expectEqualStrings("aro", app_state.model.query.text()); + try core.testing.dispatchAutomationWidgetAction(&harness.runtime, app, .{ + .view_label = search_mirror_canvas_label, + .id = field_id, + .action = .cancel_composition, + }); + try std.testing.expectEqualStrings("", app_state.model.query.text()); + try std.testing.expect(app_state.model.query.composition == null); + const canceled_layout = try harness.runtime.canvasWidgetLayout(1, search_mirror_canvas_label); + try std.testing.expectEqualStrings("", canceled_layout.findById(field_id).?.widget.text); +} + +// --------------------------------- combobox open-arrow (mirror invariant) + +const combo_mirror_canvas_label = "combo-mirror-canvas"; + +const ComboMirrorModel = struct { + query: canvas.TextBuffer(64) = .{}, + note: canvas.TextBuffer(64) = .{}, + open: bool = false, + opens: u32 = 0, + query_edits: u32 = 0, +}; + +const ComboMirrorMsg = union(enum) { + open_picker, + close_picker, + query_edit: canvas.TextInputEvent, + note_edit: canvas.TextInputEvent, +}; + +const ComboMirrorApp = ui_app_model.UiApp(ComboMirrorModel, ComboMirrorMsg); + +fn comboMirrorUpdate(model: *ComboMirrorModel, msg: ComboMirrorMsg) void { + switch (msg) { + .open_picker => { + model.open = true; + model.opens += 1; + }, + .close_picker => model.open = false, + .query_edit => |edit| { + model.query.apply(edit); + model.query_edits += 1; + }, + .note_edit => |edit| model.note.apply(edit), + } +} + +fn comboMirrorView(ui: *ComboMirrorApp.Ui, model: *const ComboMirrorModel) ComboMirrorApp.Ui.Node { + const trigger = ui.el(.combobox, .{ + .text = model.query.text(), + .placeholder = "Filter", + .width = 200, + .expanded = model.open, + .on_press = .open_picker, + .on_input = ComboMirrorApp.Ui.inputMsg(.query_edit), + }, .{}); + const picker = if (model.open) ui.stack(.{ .height = 28 }, .{ + trigger, + ui.el(.dropdown_menu, .{ + .anchor = .below, + .anchor_alignment = .stretch, + .width = 200, + .height = 60, + .on_dismiss = .close_picker, + }, .{ + ui.el(.menu_item, .{ .key = .{ .int = 0 }, .text = "glass bead", .height = 26, .on_press = .close_picker }, .{}), + ui.el(.menu_item, .{ .key = .{ .int = 1 }, .text = "glass jar", .height = 26, .on_press = .close_picker }, .{}), + }), + }) else ui.stack(.{ .height = 28 }, .{trigger}); + return ui.column(.{ .gap = 8, .padding = 12 }, .{ + picker, + ui.el(.text_field, .{ + .text = model.note.text(), + .placeholder = "Note", + .width = 200, + .on_input = ComboMirrorApp.Ui.inputMsg(.note_edit), + }, .{}), + }); +} + +const combo_mirror_views = [_]app_manifest.ShellView{ + .{ .label = combo_mirror_canvas_label, .kind = .gpu_surface, .fill = true, .gpu_backend = .metal }, +}; +const combo_mirror_windows = [_]app_manifest.ShellWindow{.{ + .label = "main", + .title = "ComboMirror", + .width = 400, + .height = 300, + .views = &combo_mirror_views, +}}; +const combo_mirror_scene: app_manifest.ShellConfig = .{ .windows = &combo_mirror_windows }; + +fn comboMirrorKey(harness: *core.TestHarness(), app: core.App, key: []const u8) !void { + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = combo_mirror_canvas_label, + .kind = .key_down, + .key = key, + } }); +} + +fn comboMirrorRetainedSelection(harness: *core.TestHarness(), id: canvas.ObjectId) !?canvas.TextSelection { + const layout = try harness.runtime.canvasWidgetLayout(1, combo_mirror_canvas_label); + return layout.findById(id).?.widget.text_selection; +} + +test "a closed combobox's open arrows move neither the retained caret nor the model mirror" { + // The split-brain escapee: a CLOSED combobox maps ArrowUp/Down to + // BOTH its open press (`widgetKeyboardControlIntent`'s menu-open + // keys) and — through the single-line caret derivation — a stamped + // caret jump. The app dispatch resolves the press FIRST, so the + // runtime editor moved its caret while the model's mirror heard + // nothing, and the next insert landed at two different offsets. + // Opening wins (the platform convention): the derivation yields no + // edit, and BOTH sides agree the arrow moved no caret. + const harness = try core.TestHarness().create(std.testing.allocator, .{ .size = geometry.SizeF.init(400, 300) }); + defer harness.destroy(std.testing.allocator); + harness.null_platform.gpu_surfaces = true; + + const app_state = try std.testing.allocator.create(ComboMirrorApp); + defer std.testing.allocator.destroy(app_state); + app_state.* = ComboMirrorApp.init(std.heap.page_allocator, .{}, .{ + .name = "ui-app-combo-mirror", + .scene = combo_mirror_scene, + .canvas_label = combo_mirror_canvas_label, + .update = comboMirrorUpdate, + .view = comboMirrorView, + }); + defer app_state.deinit(); + const app = app_state.app(); + try harness.start(app); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{ + .label = combo_mirror_canvas_label, + .size = geometry.SizeF.init(400, 300), + .scale_factor = 1, + .frame_index = 1, + .timestamp_ns = 1_000_000, + .nonblank = true, + } }); + try std.testing.expect(app_state.installed); + + const combo_id = findWidgetIdByKind(app_state.tree.?.root, .combobox).?; + + // Focus WITHOUT pressing (a click on a combobox IS its open press), + // type, and walk the caret off the end so a divergence would show. + try core.testing.dispatchAutomationWidgetAction(&harness.runtime, app, .{ + .view_label = combo_mirror_canvas_label, + .id = combo_id, + .action = .focus, + }); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = combo_mirror_canvas_label, + .kind = .text_input, + .text = "glass", + } }); + try comboMirrorKey(harness, app, "arrowleft"); + try comboMirrorKey(harness, app, "arrowleft"); + try std.testing.expectEqualStrings("glass", app_state.model.query.text()); + try std.testing.expectEqualDeep(canvas.TextSelection.collapsed(3), app_state.model.query.selection); + + // THE pin: ArrowDown on the closed trigger opens the picker and + // both carets stay at 3 — no query edit is heard or applied. + const edits_before_open = app_state.model.query_edits; + try comboMirrorKey(harness, app, "arrowdown"); + try std.testing.expect(app_state.model.open); + try std.testing.expectEqual(@as(u32, 1), app_state.model.opens); + try std.testing.expectEqual(edits_before_open, app_state.model.query_edits); + try std.testing.expectEqualDeep(canvas.TextSelection.collapsed(3), app_state.model.query.selection); + try std.testing.expectEqualDeep(@as(?canvas.TextSelection, canvas.TextSelection.collapsed(3)), try comboMirrorRetainedSelection(harness, combo_id)); + + // The OPEN-picker truth, pinned as-is: the next arrow walks the + // keyboard INTO the mounted menu (the focus step consumes it before + // routing reaches the trigger), so it is no caret edit either. + const first_item_id = findWidgetIdByText(app_state.tree.?, .menu_item, "glass bead").?; + try comboMirrorKey(harness, app, "arrowdown"); + try std.testing.expectEqual(first_item_id, harness.runtime.views[0].canvas_widget_focused_id); + try std.testing.expectEqual(edits_before_open, app_state.model.query_edits); + try std.testing.expectEqualDeep(canvas.TextSelection.collapsed(3), app_state.model.query.selection); + try std.testing.expectEqualDeep(@as(?canvas.TextSelection, canvas.TextSelection.collapsed(3)), try comboMirrorRetainedSelection(harness, combo_id)); + + // Escape is consumed by the DISMISSAL pass while the menu floats: + // the picker closes through `on_dismiss` and the combobox's + // Escape-clear never runs — the query survives. + try comboMirrorKey(harness, app, "escape"); + try std.testing.expect(!app_state.model.open); + try std.testing.expectEqualStrings("glass", app_state.model.query.text()); + try std.testing.expectEqual(combo_id, harness.runtime.views[0].canvas_widget_focused_id); + + // ArrowUp on the closed trigger is the same open key: opens, and + // both carets stay put again. + try comboMirrorKey(harness, app, "arrowup"); + try std.testing.expect(app_state.model.open); + try std.testing.expectEqual(@as(u32, 2), app_state.model.opens); + try std.testing.expectEqual(edits_before_open, app_state.model.query_edits); + try std.testing.expectEqualDeep(canvas.TextSelection.collapsed(3), app_state.model.query.selection); + try std.testing.expectEqualDeep(@as(?canvas.TextSelection, canvas.TextSelection.collapsed(3)), try comboMirrorRetainedSelection(harness, combo_id)); + try comboMirrorKey(harness, app, "escape"); + try std.testing.expect(!app_state.model.open); + + // The suppression is combobox-only: a plain text field keeps the + // single-line ArrowUp/Down caret jumps, and the model mirror hears + // them through the stamped edit (the #129 seam, unregressed). + const note_id = findWidgetIdByKind(app_state.tree.?.root, .text_field).?; + const note_frame = (try harness.runtime.canvasWidgetLayout(1, combo_mirror_canvas_label)).findById(note_id).?.frame; + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = combo_mirror_canvas_label, + .kind = .pointer_down, + .x = note_frame.x + note_frame.width * 0.5, + .y = note_frame.y + note_frame.height * 0.5, + } }); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = combo_mirror_canvas_label, + .kind = .text_input, + .text = "abc", + } }); + try comboMirrorKey(harness, app, "arrowup"); + try std.testing.expectEqualDeep(canvas.TextSelection.collapsed(0), app_state.model.note.selection); + try std.testing.expectEqualDeep(@as(?canvas.TextSelection, canvas.TextSelection.collapsed(0)), try comboMirrorRetainedSelection(harness, note_id)); + try comboMirrorKey(harness, app, "arrowdown"); + try std.testing.expectEqualDeep(canvas.TextSelection.collapsed(3), app_state.model.note.selection); + try std.testing.expectEqualDeep(@as(?canvas.TextSelection, canvas.TextSelection.collapsed(3)), try comboMirrorRetainedSelection(harness, note_id)); +} + // ------------------------------------------------- autofocus (notes flow) const autofocus_canvas_label = "autofocus-canvas"; // shell scene label below diff --git a/src/runtime/view_widget_text.zig b/src/runtime/view_widget_text.zig index a4e0b07a..ae46ccc4 100644 --- a/src/runtime/view_widget_text.zig +++ b/src/runtime/view_widget_text.zig @@ -63,7 +63,24 @@ pub fn RuntimeViewCanvasWidgetText(comptime RuntimeView: type) type { return newline_edit; } - if (canvasWidgetSingleLineTextKind(widget.kind) and keyboard.phase == .key_down and keyboard.text.len == 0 and !keyboard.modifiers.hasNavigationModifier()) { + // On a CLOSED combobox these same arrows are the trigger's + // OPEN keys (`widgetKeyboardControlIntent`'s menu-open + // mapping, which the app dispatch resolves BEFORE any + // stamped edit): platform convention is that opening wins + // and the caret does not move, so the derivation yields no + // edit and the retained editor agrees with the model's "no + // edit" verdict. The app-side fallback derivation for + // events that never crossed the runtime + // (`textEditEvent()`'s generic keymap) has no ArrowUp/Down + // arm at all, so both derivations stay in agreement. Once + // the picker is OPEN the focus step walks the arrows into + // the mounted menu before routing reaches the trigger; an + // arrow that still lands on an EXPANDED trigger (no + // focusable menu entry mounted) keeps the caret jump — the + // control resolver ignores it there, so both sides hear + // the same move. + const arrow_opens_combobox = widget.kind == .combobox and !(widget.state.expanded orelse false); + if (!arrow_opens_combobox and canvasWidgetSingleLineTextKind(widget.kind) and keyboard.phase == .key_down and keyboard.text.len == 0 and !keyboard.modifiers.hasNavigationModifier()) { if (std.ascii.eqlIgnoreCase(keyboard.key, "arrowup")) return .{ .move_caret = .{ .direction = .start, .extend = keyboard.modifiers.shift } }; if (std.ascii.eqlIgnoreCase(keyboard.key, "arrowdown")) return .{ .move_caret = .{ .direction = .end, .extend = keyboard.modifiers.shift } }; } diff --git a/src/runtime/widget_bridge.zig b/src/runtime/widget_bridge.zig index 217ac33a..3a4446ca 100644 --- a/src/runtime/widget_bridge.zig +++ b/src/runtime/widget_bridge.zig @@ -223,6 +223,32 @@ pub fn canvasWidgetAccessibilityActionKindFromPlatform(action: platform.WidgetAc .decrement => .decrement, .set_text => .set_text, .set_selection => .set_selection, + .set_composition => .set_composition, + .commit_composition => .commit_composition, + .cancel_composition => .cancel_composition, + .select => .select, + .drag => .drag, + .drop_files => .drop_files, + .dismiss => .dismiss, + }; +} + +/// The inverse of `canvasWidgetAccessibilityActionKindFromPlatform`: the +/// direct verb surfaces (embed hosts, automation `widget_action` +/// commands) reach the dispatch without a platform event, and the +/// session journal needs one to record the verb outer-wins. +pub fn platformWidgetAccessibilityActionKindFromCanvas(action: CanvasWidgetAccessibilityActionKind) platform.WidgetAccessibilityActionKind { + return switch (action) { + .focus => .focus, + .press => .press, + .toggle => .toggle, + .increment => .increment, + .decrement => .decrement, + .set_text => .set_text, + .set_selection => .set_selection, + .set_composition => .set_composition, + .commit_composition => .commit_composition, + .cancel_composition => .cancel_composition, .select => .select, .drag => .drag, .drop_files => .drop_files, diff --git a/tests/ts-core/soundboard_e2e_tests.zig b/tests/ts-core/soundboard_e2e_tests.zig index 29e57184..8cf4f8d4 100644 --- a/tests/ts-core/soundboard_e2e_tests.zig +++ b/tests/ts-core/soundboard_e2e_tests.zig @@ -559,6 +559,26 @@ test "search drives the full text engine from raw platform input and filters bot try std.testing.expect(h.hasText("68 of 68")); } +test "Escape clears the search field and the TS core hears it" { + // The post-launch live-GUI smoke bug, TS tier: Escape's clear was a + // runtime-local editor operation — the field emptied on screen while + // the core's search mirror kept the stale term and the list stayed + // filtered. The keyboard derivation now stamps the clear it applies + // onto the dispatched event, so the transpiled core hears it through + // the same on-input channel every keystroke uses. + const h = try Harness.create(); + defer h.destroy(); + + try h.click(h.findId(.search_field, "").?); + try h.textInput("night"); + try std.testing.expectEqualStrings("night", Bridge.model().search.bytes); + try std.testing.expect(h.hasText("1 of 8")); + + try h.keyDown("escape"); + try std.testing.expectEqualStrings("", Bridge.model().search.bytes); + try std.testing.expect(h.hasText("8 of 8")); +} + test "automation set_text replaces the search term: the select-all sentinel translates into the core's i64 mirror" { const h = try Harness.create(); defer h.destroy();