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
2 changes: 2 additions & 0 deletions docs/src/app/native-controls/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<terminal>` 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.
55 changes: 50 additions & 5 deletions src/runtime/effects.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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 `<terminal>`
/// 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
Expand Down
11 changes: 11 additions & 0 deletions src/runtime/ui_app.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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;
Expand Down
78 changes: 78 additions & 0 deletions src/runtime/ui_app_window_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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"),
}
}

Expand Down Expand Up @@ -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);
Expand Down