From c939857ab06ca96b76c61d2ffeeb80334bc38e36 Mon Sep 17 00:00:00 2001 From: sonhyrd Date: Mon, 27 Jul 2026 17:12:42 +0700 Subject: [PATCH] feat(effects): add fx.focusView so a pure update can move view focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Runtime.focusView` and the `native-sdk.view.focus` bridge command both ship, but the effects channel had no focus verb — so a native-rendered app's `update`, which never sees the Runtime, could not move focus between its own views at all. That is load-bearing for a shell whose window holds a canvas beside a `` and a WebView: AppKit's key-view loop does not span them (nothing in `appkit_host.m` wires `nextKeyView`, and `insertTab:` synthesizes a tab KEY into the canvas ring instead of calling `selectNextKeyView:`), and a focused terminal correctly keeps Tab for its child. Such an app has to own pane cycling on its own chord, and `Runtime.focusView` is exactly the primitive for it. Adds `focus_view_fn` to `WindowActionBinding`, `focusView` to `Effects(Msg)` beside the other window verbs, and `focus_view_count` / `lastViewLabel()` to the `WindowActionState` mirror — the window label rides `lastLabel()` with every other verb's, so the two together are the whole request. Refusals stay where they already live: `Runtime.focusView` reports unknown-view and unfocusable-view as errors, and the fire-and-forget contract turns them into no-ops. --- docs/src/app/native-controls/page.mdx | 2 + src/runtime/effects.zig | 55 +++++++++++++++++-- src/runtime/ui_app.zig | 11 ++++ src/runtime/ui_app_window_tests.zig | 78 +++++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 5 deletions(-) diff --git a/docs/src/app/native-controls/page.mdx b/docs/src/app/native-controls/page.mdx index 2494eb7a3..a62444cbe 100644 --- a/docs/src/app/native-controls/page.mdx +++ b/docs/src/app/native-controls/page.mdx @@ -130,3 +130,5 @@ For declarative shell views, use `edge` for docked chrome, `fill = true` for con Use `accessibility_label` for the announced control name when it should differ from visible text. Use `role` for semantic/fallback accessibility text. Use `text` for visible labels, button titles, and text/search placeholders. `ViewInfo.focused` reports the last successfully focused view in a window. Use `runtime.focusNextView(...)`, `runtime.focusPreviousView(...)`, or `window.zero.views.focusNext()` / `focusPrevious()` to move through visible, enabled native controls and WebView-backed views in stable view order. Automation snapshots include the same focus state for native controls and WebView-backed views. + +From a native-rendered app's `update`, the same move is `fx.focusView(window_label, view_label)` — the effect-channel route to `runtime.focusView`, which a pure `update` cannot reach directly. It is fire-and-forget like the window verbs: an unknown window or view label is a no-op, and the fake executor records the request in `windowActionState()` (`focus_view_count`, `lastLabel()`, `lastViewLabel()`) so hermetic tests can pin it. This is the seam behind app-owned pane cycling — a shell that mounts a canvas beside a `` and a WebView owns focus policy itself, because the platform key-view loop does not span them and a focused terminal deliberately keeps Tab for its child. diff --git a/src/runtime/effects.zig b/src/runtime/effects.zig index 016d6f6ab..fd8c84550 100644 --- a/src/runtime/effects.zig +++ b/src/runtime/effects.zig @@ -265,6 +265,10 @@ pub const WindowActionBinding = struct { minimize_fn: *const fn (context: *anyopaque, window_label: []const u8) bool, show_fn: *const fn (context: *anyopaque, window_label: []const u8) bool, quit_fn: *const fn (context: *anyopaque) bool, + /// Move keyboard focus to a VIEW inside a window, both named by + /// their declared labels — the effect-channel route to + /// `Runtime.focusView`, which `update` cannot reach directly. + focus_view_fn: *const fn (context: *anyopaque, window_label: []const u8, view_label: []const u8) bool, }; /// Type-erased handle to the embedding host's named-command services, @@ -291,31 +295,47 @@ pub const HostCallBinding = struct { }; /// Window-action label capacity (`Effects.closeWindow`/`minimizeWindow`/ -/// `showWindow`): the mirror copies the last requested label so tests -/// can pin it. +/// `showWindow`/`focusView`): the mirror copies the last requested label +/// so tests can pin it. pub const max_window_action_label = 64; /// The window-action mirror: observable state for every close/minimize/ -/// show/quit request made through the channel, recorded before the -/// runtime call (and INSTEAD of it under the fake executor — hermetic -/// tests pin the counts, live runs also perform the verb). +/// show/quit/focus-view request made through the channel, recorded +/// before the runtime call (and INSTEAD of it under the fake executor — +/// hermetic tests pin the counts, live runs also perform the verb). pub const WindowActionState = struct { close_count: u32 = 0, minimize_count: u32 = 0, show_count: u32 = 0, quit_count: u32 = 0, + focus_view_count: u32 = 0, last_label_buffer: [max_window_action_label]u8 = @splat(0), last_label_len: usize = 0, + /// `focusView`'s second label. The window label rides + /// `lastLabel` with every other verb's, so the two together are + /// the whole request. + last_view_label_buffer: [max_window_action_label]u8 = @splat(0), + last_view_label_len: usize = 0, pub fn lastLabel(self: *const WindowActionState) []const u8 { return self.last_label_buffer[0..self.last_label_len]; } + pub fn lastViewLabel(self: *const WindowActionState) []const u8 { + return self.last_view_label_buffer[0..self.last_view_label_len]; + } + fn record(self: *WindowActionState, label: []const u8) void { const len = @min(label.len, max_window_action_label); @memcpy(self.last_label_buffer[0..len], label[0..len]); self.last_label_len = len; } + + fn recordView(self: *WindowActionState, label: []const u8) void { + const len = @min(label.len, max_window_action_label); + @memcpy(self.last_view_label_buffer[0..len], label[0..len]); + self.last_view_label_len = len; + } }; /// How a spawn's stdout comes back. `.lines` streams each line as an @@ -7791,6 +7811,31 @@ pub fn Effects(comptime Msg: type) type { _ = binding.show_fn(binding.context, window_label); } + /// Move keyboard focus to a view by its declared label — the + /// effect-channel route to `Runtime.focusView`, and the only + /// one a pure `update` has. The seam behind app-owned pane + /// cycling: a shell that mounts a canvas beside `` + /// panes and a webview owns focus policy itself, because + /// AppKit's key-view loop does not span them and a focused + /// terminal deliberately keeps Tab for its child. + /// + /// Fire-and-forget, same contract as `closeWindow`: an unknown + /// window or view label is a no-op, a view that refuses focus + /// (hidden, disabled) is a no-op, and the fake executor only + /// records the request in the mirror + /// (`windowActionState().focus_view_count` / + /// `lastLabel()` / `lastViewLabel()`). The focus change itself + /// is observable the way every focus change is — through the + /// view's `focused` flag. + pub fn focusView(self: *Self, window_label: []const u8, view_label: []const u8) void { + self.window_action_state.focus_view_count += 1; + self.window_action_state.record(window_label); + self.window_action_state.recordView(view_label); + if (self.executor == .fake) return; + const binding = self.window_actions orelse return; + _ = binding.focus_view_fn(binding.context, window_label, view_label); + } + /// Quit the app for real — the graceful terminate, and the tray /// "Quit" consequence of the menu-bar-app loop. Rides the same /// shutdown event path as today's last-window close (the host diff --git a/src/runtime/ui_app.zig b/src/runtime/ui_app.zig index ebe93644c..72b0bb403 100644 --- a/src/runtime/ui_app.zig +++ b/src/runtime/ui_app.zig @@ -1361,6 +1361,7 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe .minimize_fn = effectsMinimizeWindowByLabel, .show_fn = effectsShowWindowByLabel, .quit_fn = effectsQuitApp, + .focus_view_fn = effectsFocusViewByLabel, }); if (runtime.options.session_recorder) |recorder| { self.effects.bindJournal(recorder.effectJournal()); @@ -5971,6 +5972,16 @@ fn effectsShowWindowByLabel(context: *anyopaque, window_label: []const u8) bool return true; } +fn effectsFocusViewByLabel(context: *anyopaque, window_label: []const u8, view_label: []const u8) bool { + const runtime: *Runtime = @ptrCast(@alignCast(context)); + const window_id = effectsWindowIdByLabel(runtime, window_label) orelse return false; + // `Runtime.focusView` owns every refusal — unknown view, a view the + // platform will not focus — and reports it as an error, which the + // fire-and-forget contract turns into a no-op here. + runtime.focusView(window_id, view_label) catch return false; + return true; +} + fn effectsQuitApp(context: *anyopaque) bool { const runtime: *Runtime = @ptrCast(@alignCast(context)); runtime.quitApp() catch return false; diff --git a/src/runtime/ui_app_window_tests.zig b/src/runtime/ui_app_window_tests.zig index dbe5ba16b..c094e78ca 100644 --- a/src/runtime/ui_app_window_tests.zig +++ b/src/runtime/ui_app_window_tests.zig @@ -17,6 +17,9 @@ const support = @import("test_support.zig"); const canvas_label = "panel-canvas"; const settings_canvas_label = "settings-canvas"; const settings_window_label = "settings"; +/// A second view inside the MAIN window — the pane `fx.focusView` +/// moves focus to, standing in for a shell's terminal or webview pane. +const side_view_label = "side-pane"; const PanelModel = struct { settings_open: bool = false, @@ -678,6 +681,8 @@ const VerbMsg = union(enum) { show_settings, quit, settings_closed, + focus_side_view, + focus_missing_view, }; const VerbApp = ui_app_model.UiApp(VerbModel, VerbMsg); @@ -692,6 +697,8 @@ fn verbUpdate(model: *VerbModel, msg: VerbMsg, fx: *VerbApp.Effects) void { .show_settings => fx.showWindow(settings_window_label), .quit => fx.quitApp(), .settings_closed => model.settings_open = false, + .focus_side_view => fx.focusView("main", side_view_label), + .focus_missing_view => fx.focusView("main", "no-such-view"), } } @@ -823,6 +830,77 @@ test "window-action effects resolve labels to live windows and drive the real ve try harness.runtime.dispatchPlatformEvent(app, shutdown_event); } +test "fx.focusView moves keyboard focus between views from a pure update" { + // The shape this exists for: a shell whose window holds a canvas + // beside other panes cannot use AppKit's key-view loop to cycle + // them (it does not span a webview, and a focused terminal keeps + // Tab for its child), so it owns focus policy itself — from + // `update`, which never sees the Runtime. + 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 VerbApp.create(std.heap.page_allocator, .{ + .name = "ui-app-focus-view", + .scene = panel_scene, + .canvas_label = canvas_label, + .update_fx = verbUpdate, + .view = verbView, + .windows_fn = verbWindows, + .window_view = verbWindowView, + }); + defer app_state.destroy(); + const app = app_state.app(); + try harness.start(app); + try harness.runtime.dispatchPlatformEvent(app, .{ .window_frame_changed = .{ + .id = 1, + .label = "main", + .title = "Panel", + .frame = geometry.RectF.init(0, 0, 400, 300), + .scale_factor = 2, + .open = true, + .focused = true, + } }); + _ = try harness.runtime.createView(.{ + .window_id = 1, + .label = side_view_label, + .kind = .gpu_surface, + .frame = geometry.RectF.init(0, 150, 400, 150), + }); + + // Focus starts on the app's canvas: the side pane is mounted and + // unfocused, which is the state a pane-cycling chord acts on. + try harness.runtime.focusView(1, canvas_label); + try std.testing.expect(!viewFocused(&harness.runtime, 1, side_view_label)); + + // The effect crosses from `update` to the real verb: the view takes + // focus, the canvas loses it, and the mirror carries BOTH labels — + // the window and the view together are the whole request. + try app_state.dispatch(&harness.runtime, 1, .focus_side_view); + try std.testing.expect(viewFocused(&harness.runtime, 1, side_view_label)); + try std.testing.expect(!viewFocused(&harness.runtime, 1, canvas_label)); + try std.testing.expectEqual(@as(u32, 1), app_state.effects.windowActionState().focus_view_count); + try std.testing.expectEqualStrings("main", app_state.effects.windowActionState().lastLabel()); + try std.testing.expectEqualStrings(side_view_label, app_state.effects.windowActionState().lastViewLabel()); + + // An unknown view label is a no-op, not a crash and not a focus + // move — `Runtime.focusView` owns the refusal and the + // fire-and-forget contract swallows it. The mirror still counts the + // request, exactly like a close of a window that is not there. + try app_state.dispatch(&harness.runtime, 1, .focus_missing_view); + try std.testing.expect(viewFocused(&harness.runtime, 1, side_view_label)); + try std.testing.expectEqual(@as(u32, 2), app_state.effects.windowActionState().focus_view_count); +} + +/// The runtime's own focus flag for a view — where `setFocusedView` +/// writes, and what the canvas render state reads. +fn viewFocused(runtime: anytype, window_id: support.platform.WindowId, label: []const u8) bool { + for (runtime.views[0..runtime.view_count]) |view| { + if (view.window_id != window_id) continue; + if (std.mem.eql(u8, view.label, label)) return view.focused; + } + return false; +} + test "the .hide close then showWindow round-trip: tray Open brings the hidden window back" { const harness = try core.TestHarness().create(std.testing.allocator, .{ .size = geometry.SizeF.init(400, 300) }); defer harness.destroy(std.testing.allocator);