diff --git a/src/app_runner/root.zig b/src/app_runner/root.zig index b60ae23dd..16a9e4619 100644 --- a/src/app_runner/root.zig +++ b/src/app_runner/root.zig @@ -138,6 +138,12 @@ pub const RunOptions = struct { // Close handling is host window state like the titlebar: // the manifest's declaration rides the host create. info.main_window.close_policy = manifestShellStartupClosePolicy(); + // Same for the window-control offset: the host re-applies it + // after every relayout, so it has to know it from create on. + info.main_window.window_controls_offset = .{ + .x = manifestShellStartupFloat("window_controls_offset_x", 0), + .y = manifestShellStartupFloat("window_controls_offset_y", 0), + }; } return info; } @@ -272,6 +278,10 @@ fn manifestWindow(comptime window: anytype, comptime index: usize) native_sdk.Wi .min_width = windowMinSize(window, "min_width"), .min_height = windowMinSize(window, "min_height"), .close_policy = windowClosePolicy(window), + .window_controls_offset = .{ + .x = windowFloat(window, "window_controls_offset_x", 0), + .y = windowFloat(window, "window_controls_offset_y", 0), + }, }; } @@ -350,6 +360,17 @@ fn manifestShellStartupMinSize(comptime field: []const u8) f32 { return windowMinSize(shell.windows[0], field); } +/// A plain float off the STARTUP window declaration. Unlike +/// `manifestShellStartupMinSize`, negative values are meaningful here: +/// the window-control offset moves the cluster left and up too. +fn manifestShellStartupFloat(comptime field: []const u8, comptime default_value: f32) f32 { + if (comptime !@hasField(@TypeOf(app_manifest), "shell")) return default_value; + const shell = app_manifest.shell; + if (comptime !@hasField(@TypeOf(shell), "windows")) return default_value; + if (comptime shell.windows.len == 0) return default_value; + return windowFloat(shell.windows[0], field, default_value); +} + /// Present-before-show for the STARTUP window: when app.zon's first /// shell window hosts a canvas (`gpu_surface` view), the host creates /// it ordered-out and it becomes visible after the first canvas frame diff --git a/src/platform/macos/appkit_host.m b/src/platform/macos/appkit_host.m index ca6d7d1a1..82df643ef 100644 --- a/src/platform/macos/appkit_host.m +++ b/src/platform/macos/appkit_host.m @@ -802,6 +802,16 @@ @interface NativeSdkAppKitHost : NSObject *windows; +/// Declared window-control offsets, by window id. Kept rather than +/// applied once: AppKit lays the standard buttons out again on its own +/// schedule (fullscreen transitions, toolbar visibility), so the offset +/// has to be re-appliable. +@property(nonatomic, strong) NSMutableDictionary *windowControlOffsets; +/// Where AppKit puts the three buttons when left alone, captured the +/// first time a window's cluster moves. Applies use base + offset, so +/// re-applying lands in the same place instead of nudging the buttons +/// further along each time. +@property(nonatomic, strong) NSMutableDictionary *> *windowControlBaseOrigins; @property(nonatomic, strong) NSMutableDictionary *webViews; @property(nonatomic, strong) NSMutableDictionary *delegates; @property(nonatomic, strong) NSMutableDictionary *bridgeScriptHandlers; @@ -1033,6 +1043,7 @@ @interface NativeSdkAppKitHost : NSObject 0 ? windowLabel : @"main"; self.windows = [[NSMutableDictionary alloc] init]; + self.windowControlOffsets = [[NSMutableDictionary alloc] init]; + self.windowControlBaseOrigins = [[NSMutableDictionary alloc] init]; self.webViews = [[NSMutableDictionary alloc] init]; self.delegates = [[NSMutableDictionary alloc] init]; self.bridgeScriptHandlers = [[NSMutableDictionary alloc] init]; @@ -8349,6 +8371,48 @@ - (BOOL)startWindowDragWithId:(uint64_t)windowId { return YES; } +/// Put the window's three standard buttons at their declared offset. +/// Callable as often as AppKit relayouts them: it sets an absolute +/// origin from the captured base rather than nudging the current +/// frames, which would walk the buttons across the titlebar one +/// fullscreen toggle at a time. +/// +/// The declared offset is positive-down like the rest of the SDK's +/// geometry, but the titlebar's view space is positive-up — hence the +/// negated y here and nowhere else. +- (void)applyWindowControlsOffsetForWindowId:(uint64_t)windowId { + NSValue *stored = self.windowControlOffsets[@(windowId)]; + if (!stored) return; + NSWindow *window = self.windows[@(windowId)]; + if (!window) return; + NSButton *buttons[3] = { + [window standardWindowButton:NSWindowCloseButton], + [window standardWindowButton:NSWindowMiniaturizeButton], + [window standardWindowButton:NSWindowZoomButton], + }; + for (int index = 0; index < 3; index += 1) { + if (!buttons[index]) return; + } + + NSArray *base = self.windowControlBaseOrigins[@(windowId)]; + if (!base) { + base = @[ + [NSValue valueWithPoint:buttons[0].frame.origin], + [NSValue valueWithPoint:buttons[1].frame.origin], + [NSValue valueWithPoint:buttons[2].frame.origin], + ]; + self.windowControlBaseOrigins[@(windowId)] = base; + } + + NSPoint offset = stored.pointValue; + for (int index = 0; index < 3; index += 1) { + NSPoint origin = base[index].pointValue; + NSRect frame = buttons[index].frame; + frame.origin = NSMakePoint(origin.x + offset.x, origin.y - offset.y); + buttons[index].frame = frame; + } +} + // Chrome overlay geometry for hidden-titlebar windows: how far the // transparent titlebar (top) and the traffic lights (leading edge) // overlay the content view, plus the traffic-light cluster's bounding @@ -12860,6 +12924,20 @@ int native_sdk_appkit_create_window(native_sdk_appkit_host_t *host, uint64_t win return [object createWindowWithId:window_id title:titleString ?: @"" label:labelString ?: @"" x:x y:y width:width height:height restoreFrame:(restore_frame != 0) initialPlacement:initial_placement restorePolicy:restore_policy resizable:(resizable != 0) titlebarStyle:titlebar_style showPolicy:show_policy windowFlags:window_flags makeMain:NO] ? 1 : 0; } +int native_sdk_appkit_set_window_controls_offset(native_sdk_appkit_host_t *host, uint64_t window_id, double dx, double dy) { + NativeSdkAppKitHost *object = (__bridge NativeSdkAppKitHost *)host; + NSWindow *window = object.windows[@(window_id)]; + if (!window) return 0; + if (dx == 0 && dy == 0) { + [object.windowControlOffsets removeObjectForKey:@(window_id)]; + return 1; + } + // Store before applying: the apply reads this back on every relayout. + object.windowControlOffsets[@(window_id)] = [NSValue valueWithPoint:NSMakePoint(dx, dy)]; + [object applyWindowControlsOffsetForWindowId:window_id]; + return 1; +} + int native_sdk_appkit_set_window_content_min_size(native_sdk_appkit_host_t *host, uint64_t window_id, double min_width, double min_height) { NativeSdkAppKitHost *object = (__bridge NativeSdkAppKitHost *)host; NSWindow *window = object.windows[@(window_id)]; diff --git a/src/platform/macos/root.zig b/src/platform/macos/root.zig index 0fbc51bb7..ffee3c1de 100644 --- a/src/platform/macos/root.zig +++ b/src/platform/macos/root.zig @@ -190,6 +190,7 @@ extern fn native_sdk_appkit_set_shortcuts(host: *AppKitHost, ids: [*]const [*]co extern fn native_sdk_appkit_request_frame(host: *AppKitHost) void; extern fn native_sdk_appkit_create_window(host: *AppKitHost, window_id: u64, window_title: [*]const u8, window_title_len: usize, window_label: [*]const u8, window_label_len: usize, x: f64, y: f64, width: f64, height: f64, restore_frame: c_int, initial_placement: c_int, restore_policy: c_int, resizable: c_int, titlebar_style: c_int, show_policy: c_int, window_flags: u32) c_int; extern fn native_sdk_appkit_set_window_content_min_size(host: *AppKitHost, window_id: u64, min_width: f64, min_height: f64) c_int; +extern fn native_sdk_appkit_set_window_controls_offset(host: *AppKitHost, window_id: u64, dx: f64, dy: f64) c_int; extern fn native_sdk_appkit_focus_window(host: *AppKitHost, window_id: u64) c_int; extern fn native_sdk_appkit_close_window(host: *AppKitHost, window_id: u64) c_int; extern fn native_sdk_appkit_minimize_window(host: *AppKitHost, window_id: u64) c_int; @@ -690,6 +691,10 @@ pub const MacPlatform = struct { // .hide threads through here so the STARTUP window's red // button hides from the first frame on. applyWindowClosePolicy(host, window_options.id, window_options.close_policy); + // And the control offset, which has to land before the first + // chrome query: `WindowChrome.buttons` is measured off the real + // button frames, so a late apply reports the unmoved cluster. + applyWindowControlsOffset(host, window_options.id, window_options.window_controls_offset); return .{ .host = host, .web_engine = web_engine, @@ -1473,6 +1478,15 @@ fn applyWindowContentMinSize(host: *AppKitHost, window_id: u64, min_width: f32, _ = native_sdk_appkit_set_window_content_min_size(host, window_id, width, height); } +/// Move a created window's control cluster to its declared offset. Zero +/// means "leave AppKit's placement alone" and skips the call, so a +/// window that declares nothing never registers an offset at all. +fn applyWindowControlsOffset(host: *AppKitHost, window_id: u64, offset: geometry.PointF) void { + if (!std.math.isFinite(offset.x) or !std.math.isFinite(offset.y)) return; + if (offset.x == 0 and offset.y == 0) return; + _ = native_sdk_appkit_set_window_controls_offset(host, window_id, offset.x, offset.y); +} + fn createWindow(context: ?*anyopaque, options: platform_mod.WindowOptions) anyerror!platform_mod.WindowInfo { const self: *MacPlatform = @ptrCast(@alignCast(context.?)); try refuseUnsupportedTransparentWindow(self.web_engine, options); @@ -1481,6 +1495,7 @@ fn createWindow(context: ?*anyopaque, options: platform_mod.WindowOptions) anyer if (native_sdk_appkit_create_window(self.host, options.id, title.ptr, title.len, options.label.ptr, options.label.len, frame.x, frame.y, frame.width, frame.height, if (options.restore_state) 1 else 0, initialPlacementInt(options.initial_placement), restorePolicyInt(options.restore_policy), if (options.resizable) 1 else 0, titlebarStyleInt(options.titlebar), showModeInt(options.show), windowFlags(options)) == 0) return error.CreateFailed; applyWindowContentMinSize(self.host, options.id, options.min_width, options.min_height); applyWindowClosePolicy(self.host, options.id, options.close_policy); + applyWindowControlsOffset(self.host, options.id, options.window_controls_offset); return .{ .id = options.id, .label = options.label, diff --git a/src/platform/null_platform.zig b/src/platform/null_platform.zig index 986e1a54e..2a70d2432 100644 --- a/src/platform/null_platform.zig +++ b/src/platform/null_platform.zig @@ -464,6 +464,10 @@ pub const NullPlatform = struct { /// seam-regression purpose as `window_resizable`. window_min_width: [max_windows]f32 = [_]f32{0} ** max_windows, window_min_height: [max_windows]f32 = [_]f32{0} ** max_windows, + /// Captured `WindowOptions.window_controls_offset` per created + /// window: only macOS acts on it, so nothing else would notice a + /// caller's offset being dropped before the create. + window_controls_offset: [max_windows]geometry.PointF = [_]geometry.PointF{geometry.PointF.init(0, 0)} ** max_windows, /// Live visibility per window, modeling the macOS host: immediate /// windows are visible at create; `.on_first_present` windows stay /// hidden until their first gpu-surface present (or an explicit @@ -1187,6 +1191,7 @@ pub const NullPlatform = struct { self.window_close_policy[self.window_count] = options.close_policy; self.window_min_width[self.window_count] = options.min_width; self.window_min_height[self.window_count] = options.min_height; + self.window_controls_offset[self.window_count] = options.window_controls_offset; self.window_first_present_seq[self.window_count] = 0; // Present-before-show: deferred windows are created hidden and // become visible on their first gpu-surface present. diff --git a/src/platform/types.zig b/src/platform/types.zig index d47e05b23..b0f77f876 100644 --- a/src/platform/types.zig +++ b/src/platform/types.zig @@ -676,6 +676,21 @@ pub const WindowOptions = struct { /// What the user's close affordance does — see `WindowClosePolicy`. /// Fixed at create like the titlebar: it is host window state. close_policy: WindowClosePolicy = .quit, + /// Move the window-control cluster from where the platform puts it, + /// in points, positive right and down — macOS's traffic lights, + /// ignored elsewhere. Zero, the default, leaves it where it is. + /// + /// A titlebar style fixes both the band's height and where the + /// system centres the controls in it, so an app whose header is a + /// different height than any offered band cannot line up with them. + /// This decouples the two: keep the band, move the controls. + /// + /// Host window state like the titlebar, so it rides the create; the + /// host re-applies it wherever the platform relayouts the controls + /// (macOS: fullscreen transitions). `WindowChrome.buttons` reports + /// the moved cluster, so a header centring against it needs no + /// second source of truth. + window_controls_offset: geometry.PointF = geometry.PointF.init(0, 0), pub fn resolvedTitle(self: WindowOptions, app_name: []const u8) []const u8 { return if (self.title.len > 0) self.title else app_name; @@ -750,6 +765,8 @@ pub const WindowCreateOptions = struct { min_height: f32 = 0, /// See `WindowOptions.close_policy`. close_policy: WindowClosePolicy = .quit, + /// See `WindowOptions.window_controls_offset`. + window_controls_offset: geometry.PointF = geometry.PointF.init(0, 0), source: ?WebViewSource = null, pub fn windowOptions(self: WindowCreateOptions, id: WindowId, label: []const u8) WindowOptions { @@ -780,6 +797,7 @@ pub const WindowCreateOptions = struct { .min_width = self.min_width, .min_height = self.min_height, .close_policy = self.close_policy, + .window_controls_offset = self.window_controls_offset, }; } }; diff --git a/src/primitives/app_manifest/comptime_scene.zig b/src/primitives/app_manifest/comptime_scene.zig index 71368d01c..1d7ffb6b3 100644 --- a/src/primitives/app_manifest/comptime_scene.zig +++ b/src/primitives/app_manifest/comptime_scene.zig @@ -92,6 +92,12 @@ fn shellWindowFrom(comptime window: anytype) types.ShellWindow { if (@hasField(@TypeOf(window), "close_policy")) { out.close_policy = enumField(types.WindowClosePolicy, window.close_policy, "close_policy"); } + if (@hasField(@TypeOf(window), "window_controls_offset_x")) { + out.window_controls_offset_x = window.window_controls_offset_x; + } + if (@hasField(@TypeOf(window), "window_controls_offset_y")) { + out.window_controls_offset_y = window.window_controls_offset_y; + } if (@hasField(@TypeOf(window), "views")) { var views: []const types.ShellView = &.{}; for (window.views) |view| { @@ -293,6 +299,8 @@ test "shellConfigFrom converts a rich scene without exhausting the branch quota" .titlebar = "hidden_inset_tall", .min_width = 1144, .min_height = 720, + .window_controls_offset_x = 4, + .window_controls_offset_y = 8, .views = .{ rich_view, .{ .label = "side", .kind = "sidebar", .edge = "right", .axis = "row", .width = 240 }, @@ -336,6 +344,12 @@ test "shellConfigFrom converts a rich scene without exhausting the branch quota" try std.testing.expectEqual(@as(usize, 3), scene.windows.len); try std.testing.expectEqual(types.WindowTitlebarStyle.hidden_inset_tall, scene.windows[0].titlebar); try std.testing.expectEqual(types.WindowRestorePolicy.center_on_primary, scene.windows[0].restore_policy); + // A declared offset survives the ZON read; a window that declares + // none reads zero, the sentinel the host checks before applying. + try std.testing.expectEqual(@as(f32, 4), scene.windows[0].window_controls_offset_x); + try std.testing.expectEqual(@as(f32, 8), scene.windows[0].window_controls_offset_y); + try std.testing.expectEqual(@as(f32, 0), scene.windows[1].window_controls_offset_x); + try std.testing.expectEqual(@as(f32, 0), scene.windows[1].window_controls_offset_y); try std.testing.expectEqual(@as(usize, 4), scene.windows[0].views.len); try std.testing.expectEqual(types.ShellEdge.left, scene.windows[0].views[0].edge.?); try std.testing.expectEqual(types.ShellAxis.column, scene.windows[0].views[0].axis.?); diff --git a/src/primitives/app_manifest/types.zig b/src/primitives/app_manifest/types.zig index 5507b8ebf..5f56d84d9 100644 --- a/src/primitives/app_manifest/types.zig +++ b/src/primitives/app_manifest/types.zig @@ -529,6 +529,15 @@ pub const ShellWindow = struct { /// STARTUP window threads it through the host create, and /// runtime-created windows apply their own declaration at create. close_policy: WindowClosePolicy = .quit, + /// Move the window-control cluster from where the platform puts it, + /// in points, positive right and down — macOS's traffic lights, + /// ignored elsewhere. A titlebar style fixes both the band height + /// and where the controls sit inside it, so a header of any other + /// height cannot line up with them; this decouples the two. Zero + /// leaves the platform's placement alone. Two scalars rather than a + /// point, to keep the ZON flat like `min_width`/`min_height`. + window_controls_offset_x: f32 = 0, + window_controls_offset_y: f32 = 0, views: []const ShellView = &.{}, }; diff --git a/src/runtime/window_command_bridge_tests.zig b/src/runtime/window_command_bridge_tests.zig index b6a8f25c5..2f722ac6a 100644 --- a/src/runtime/window_command_bridge_tests.zig +++ b/src/runtime/window_command_bridge_tests.zig @@ -182,6 +182,36 @@ test "runtime-created window store miss preserves authored placement" { try std.testing.expectEqual(platform.WindowInitialPlacement.explicit, harness.null_platform.window_placement[1]); } +test "an imperative window's control offset survives to the platform create" { + const TestApp = struct { + fn app(self: *@This()) App { + return .{ .context = self, .name = "controls-offset", .source = platform.WebViewSource.html("

Main

") }; + } + }; + + const harness = try TestHarness().create(std.testing.allocator, .{}); + defer harness.destroy(std.testing.allocator); + var app_state: TestApp = .{}; + try harness.start(app_state.app()); + + // `WindowCreateOptions` converts to the `WindowOptions` the create + // seam takes, so a field the conversion forgets is dropped in silence. + const tools = try harness.runtime.createWindow(.{ + .label = "tools", + .title = "Tools", + .window_controls_offset = geometry.PointF.init(6, -4), + }); + const index: usize = @intCast(tools.id - 1); + try std.testing.expectEqual(@as(f32, 6), harness.null_platform.window_controls_offset[index].x); + try std.testing.expectEqual(@as(f32, -4), harness.null_platform.window_controls_offset[index].y); + + // A window that declares nothing keeps the zero sentinel. + const plain = try harness.runtime.createWindow(.{ .label = "plain", .title = "Plain" }); + const plain_index: usize = @intCast(plain.id - 1); + try std.testing.expectEqual(@as(f32, 0), harness.null_platform.window_controls_offset[plain_index].x); + try std.testing.expectEqual(@as(f32, 0), harness.null_platform.window_controls_offset[plain_index].y); +} + test "transparent imperative window without explicit source stays canvas-only" { const TestApp = struct { fn app(self: *@This()) App {