Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog.d/escape-edit-derivation.md
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 36 additions & 0 deletions examples/soundboard/src/tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
9 changes: 9 additions & 0 deletions src/platform/types.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
20 changes: 15 additions & 5 deletions src/primitives/canvas/ui.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
106 changes: 78 additions & 28 deletions src/runtime/automation_widget_dispatch.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
Expand Down
5 changes: 3 additions & 2 deletions src/runtime/canvas_widget_context_menu.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 17 additions & 1 deletion src/runtime/canvas_widget_events.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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| {
Expand Down
Loading
Loading