lib/windowing/src/cocoa.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

   1 const std = @import("std");
   2 const builtin = @import("builtin");
   3 const sys = @import("sys");
   4 const clipboard = @import("clipboard.zig");
   5 const event_module = @import("event.zig");
   6 const Event = event_module.Event;
   7 const MouseButtonEvent = event_module.MouseButtonEvent;
   8 const CursorShape = @import("cursor.zig").CursorShape;
   9 const gamepad = @import("gamepad.zig");
  10 const input = @import("input.zig");
  11 const Key = @import("key.zig").Key;
  12 const MouseButton = @import("mouse.zig").MouseButton;
  13 const Modifier = @import("modifier.zig").Modifier;
  14 const surface = @import("surface.zig");
  15 const WindowConfig = @import("config.zig").WindowConfig;
  16 const CreateError = @import("window.zig").CreateError;
  17 const Size = @import("window.zig").Size;
  18 const Scale = @import("window.zig").Scale;
  19 const PollError = @import("window.zig").PollError;
  20 const PresentError = @import("window.zig").PresentError;
  21 const PresentRegion = @import("window.zig").PresentRegion;
  22 const EventIterator = event_module.EventIterator;
  23 const presentation = @import("presentation.zig");
  24 const PresentationOutcome = presentation.Outcome;
  25 const PresentationStatus = presentation.Status;
  26 const PresentationStorageStatus = presentation.StorageStatus;
  27 const WaylandStorageStatus = @import("wayland").runtime.StorageStatus;
  28 
  29 const cocoa = sys.apple.cocoa;
  30 const foundation = sys.apple.foundation;
  31 const coregraphics = sys.apple.coregraphics;
  32 const objc = sys.apple.objc;
  33 
  34 fn cls(name: [*:0]const u8) CreateError!objc.Id {
  35     return objc.class(name) orelse return error.WindowCreationFailed;
  36 }
  37 
  38 const window_delegate_class_name: [*:0]const u8 = "TinyWindowingWindowDelegate";
  39 const window_delegate_backend_ivar: [*:0]const u8 = "tinyBackend";
  40 const window_delegate_bool_method: [*:0]const u8 = if (objc.BOOL == bool) "B@:@" else "c@:@";
  41 const window_delegate_void_method: [*:0]const u8 = "v@:@";
  42 
  43 fn windowDelegateClass() CreateError!objc.Class {
  44     if (objc.lookupClass(window_delegate_class_name)) |class_value| return class_value;
  45 
  46     const superclass = objc.lookupClass("NSObject") orelse return error.WindowCreationFailed;
  47     const class_value = objc.allocateClassPair(
  48         superclass,
  49         window_delegate_class_name,
  50     ) orelse return error.WindowCreationFailed;
  51     errdefer objc.disposeClassPair(class_value);
  52     if (!objc.addPointerIvar(class_value, window_delegate_backend_ivar)) {
  53         return error.WindowCreationFailed;
  54     }
  55     if (!objc.addMethod(
  56         class_value,
  57         objc.selector("windowShouldClose:"),
  58         @ptrCast(&windowShouldCloseCallback),
  59         window_delegate_bool_method,
  60     )) return error.WindowCreationFailed;
  61     if (!objc.addMethod(
  62         class_value,
  63         objc.selector("windowDidBecomeKey:"),
  64         @ptrCast(&windowDidBecomeKeyCallback),
  65         window_delegate_void_method,
  66     )) return error.WindowCreationFailed;
  67     if (!objc.addMethod(
  68         class_value,
  69         objc.selector("windowDidResignKey:"),
  70         @ptrCast(&windowDidResignKeyCallback),
  71         window_delegate_void_method,
  72     )) return error.WindowCreationFailed;
  73     objc.registerClassPair(class_value);
  74     return class_value;
  75 }
  76 
  77 fn delegateBackend(delegate: objc.Id) *CocoaBackend {
  78     const pointer = objc.instancePointer(delegate, window_delegate_backend_ivar).?;
  79     return @ptrCast(@alignCast(pointer));
  80 }
  81 
  82 fn windowShouldCloseCallback(
  83     delegate: objc.Id,
  84     _: objc.SEL,
  85     _: objc.Id,
  86 ) callconv(.c) objc.BOOL {
  87     delegateBackend(delegate).recordClose();
  88     return objc.boolean(true);
  89 }
  90 
  91 fn windowDidBecomeKeyCallback(
  92     delegate: objc.Id,
  93     _: objc.SEL,
  94     _: objc.Id,
  95 ) callconv(.c) void {
  96     delegateBackend(delegate).recordFocus(true);
  97 }
  98 
  99 fn windowDidResignKeyCallback(
 100     delegate: objc.Id,
 101     _: objc.SEL,
 102     _: objc.Id,
 103 ) callconv(.c) void {
 104     delegateBackend(delegate).recordFocus(false);
 105 }
 106 
 107 const Selectors = struct {
 108     shared_application: objc.SEL,
 109     set_activation_policy: objc.SEL,
 110     alloc: objc.SEL,
 111     object_init: objc.SEL,
 112     init_with_content_rect: objc.SEL,
 113     set_delegate: objc.SEL,
 114     set_released_when_closed: objc.SEL,
 115     set_title: objc.SEL,
 116     set_content_min_size: objc.SEL,
 117     backing_scale_factor: objc.SEL,
 118     perform_miniaturize: objc.SEL,
 119     zoom: objc.SEL,
 120     is_zoomed: objc.SEL,
 121     mouse_location_outside_of_event_stream: objc.SEL,
 122     make_key_and_order_front: objc.SEL,
 123     next_event: objc.SEL,
 124     send_event: objc.SEL,
 125     event_type: objc.SEL,
 126     key_code: objc.SEL,
 127     modifier_flags: objc.SEL,
 128     is_a_repeat: objc.SEL,
 129     button_number: objc.SEL,
 130     location_in_window: objc.SEL,
 131     scrolling_delta_x: objc.SEL,
 132     scrolling_delta_y: objc.SEL,
 133     characters: objc.SEL,
 134     utf8_string: objc.SEL,
 135     general_pasteboard: objc.SEL,
 136     string_for_type: objc.SEL,
 137     clear_contents: objc.SEL,
 138     set_string_for_type: objc.SEL,
 139     distant_past: objc.SEL,
 140     close: objc.SEL,
 141     content_view: objc.SEL,
 142     frame: objc.SEL,
 143     set_wants_layer: objc.SEL,
 144     layer: objc.SEL,
 145     set_contents: objc.SEL,
 146     set_contents_scale: objc.SEL,
 147     finish_launching: objc.SEL,
 148     init_with_utf8: objc.SEL,
 149     arrow_cursor: objc.SEL,
 150     resize_up_down_cursor: objc.SEL,
 151     resize_left_right_cursor: objc.SEL,
 152     set_cursor: objc.SEL,
 153 
 154     fn init() Selectors {
 155         return .{
 156             .shared_application = objc.selector("sharedApplication"),
 157             .set_activation_policy = objc.selector("setActivationPolicy:"),
 158             .alloc = objc.selector("alloc"),
 159             .object_init = objc.selector("init"),
 160             .init_with_content_rect = objc.selector("initWithContentRect:styleMask:backing:defer:"),
 161             .set_delegate = objc.selector("setDelegate:"),
 162             .set_released_when_closed = objc.selector("setReleasedWhenClosed:"),
 163             .set_title = objc.selector("setTitle:"),
 164             .set_content_min_size = objc.selector("setContentMinSize:"),
 165             .backing_scale_factor = objc.selector("backingScaleFactor"),
 166             .perform_miniaturize = objc.selector("performMiniaturize:"),
 167             .zoom = objc.selector("zoom:"),
 168             .is_zoomed = objc.selector("isZoomed"),
 169             .mouse_location_outside_of_event_stream = objc.selector("mouseLocationOutsideOfEventStream"),
 170             .make_key_and_order_front = objc.selector("makeKeyAndOrderFront:"),
 171             .next_event = objc.selector("nextEventMatchingMask:untilDate:inMode:dequeue:"),
 172             .send_event = objc.selector("sendEvent:"),
 173             .event_type = objc.selector("type"),
 174             .key_code = objc.selector("keyCode"),
 175             .modifier_flags = objc.selector("modifierFlags"),
 176             .is_a_repeat = objc.selector("isARepeat"),
 177             .button_number = objc.selector("buttonNumber"),
 178             .location_in_window = objc.selector("locationInWindow"),
 179             .scrolling_delta_x = objc.selector("scrollingDeltaX"),
 180             .scrolling_delta_y = objc.selector("scrollingDeltaY"),
 181             .characters = objc.selector("characters"),
 182             .utf8_string = objc.selector("UTF8String"),
 183             .general_pasteboard = objc.selector("generalPasteboard"),
 184             .string_for_type = objc.selector("stringForType:"),
 185             .clear_contents = objc.selector("clearContents"),
 186             .set_string_for_type = objc.selector("setString:forType:"),
 187             .distant_past = objc.selector("distantPast"),
 188             .close = objc.selector("close"),
 189             .content_view = objc.selector("contentView"),
 190             .frame = objc.selector("frame"),
 191             .set_wants_layer = objc.selector("setWantsLayer:"),
 192             .layer = objc.selector("layer"),
 193             .set_contents = objc.selector("setContents:"),
 194             .set_contents_scale = objc.selector("setContentsScale:"),
 195             .finish_launching = objc.selector("finishLaunching"),
 196             .init_with_utf8 = objc.selector("initWithUTF8String:"),
 197             .arrow_cursor = objc.selector("arrowCursor"),
 198             .resize_up_down_cursor = objc.selector("resizeUpDownCursor"),
 199             .resize_left_right_cursor = objc.selector("resizeLeftRightCursor"),
 200             .set_cursor = objc.selector("set"),
 201         };
 202     }
 203 };
 204 
 205 const Lifecycle = struct {
 206     should_close: bool = false,
 207     focused: bool = false,
 208     pending_focus: ?bool = null,
 209     pending_close: bool = false,
 210     polling: bool = false,
 211 };
 212 
 213 const CocoaStorage = struct {
 214     clipboard_capacity: clipboard.Capacity,
 215     clipboard_source: clipboard.Source,
 216     gamepads: gamepad.State,
 217     input: input.State,
 218     events: event_module.Queue,
 219     frames: FrameStorage,
 220 
 221     fn init(allocator: std.mem.Allocator, config: WindowConfig) CreateError!CocoaStorage {
 222         const clipboard_capacity = clipboard.Capacity.derive(config.clipboard) catch
 223             return error.InvalidCapacity;
 224         const event_capacity = event_module.Capacity.derive(config.events) catch
 225             return error.InvalidCapacity;
 226         const input_capacity = input.Capacity.derive(config.input) catch
 227             return error.InvalidCapacity;
 228         const gamepad_capacity = gamepad.Capacity.derive(config.gamepads) catch
 229             return error.InvalidCapacity;
 230         const presentation_capacity = presentation.Capacity.derive(config.presentation) catch
 231             return error.InvalidCapacity;
 232         var frames = FrameStorage.init(allocator, presentation_capacity) catch
 233             return error.OutOfMemory;
 234         errdefer frames.deinit();
 235         var events = event_module.Queue.init(allocator, event_capacity) catch
 236             return error.OutOfMemory;
 237         errdefer events.deinit();
 238         var input_state = input.State.init(allocator, input_capacity) catch
 239             return error.OutOfMemory;
 240         errdefer input_state.deinit();
 241         var gamepad_state = gamepad.State.init(allocator, gamepad_capacity) catch
 242             return error.OutOfMemory;
 243         errdefer gamepad_state.deinit();
 244         const clipboard_source = clipboard.Source.init(allocator, clipboard_capacity) catch
 245             return error.OutOfMemory;
 246         return .{
 247             .clipboard_capacity = clipboard_capacity,
 248             .clipboard_source = clipboard_source,
 249             .gamepads = gamepad_state,
 250             .input = input_state,
 251             .events = events,
 252             .frames = frames,
 253         };
 254     }
 255 
 256     fn deinit(self: *CocoaStorage) void {
 257         self.clipboard_source.deinit();
 258         self.gamepads.deinit();
 259         self.input.deinit();
 260         self.events.deinit();
 261         self.frames.deinit();
 262         self.* = undefined;
 263     }
 264 };
 265 
 266 const NativeWindow = struct {
 267     app: objc.Id,
 268     window: objc.Id,
 269     delegate: objc.Id,
 270     layer: objc.Id,
 271 
 272     fn init(sels: Selectors, config: WindowConfig) CreateError!NativeWindow {
 273         const delegate_class = try windowDelegateClass();
 274         const delegate_alloc = objc.send(?objc.Id, @ptrCast(delegate_class), sels.alloc, .{}) orelse
 275             return error.WindowCreationFailed;
 276         const delegate = objc.send(?objc.Id, delegate_alloc, sels.object_init, .{}) orelse
 277             return error.WindowCreationFailed;
 278         errdefer objc.release(delegate);
 279 
 280         const app_class = try cls("NSApplication");
 281         const app = objc.send(objc.Id, app_class, sels.shared_application, .{});
 282         objc.send(void, app, sels.set_activation_policy, .{@as(foundation.NSInteger, 0)});
 283 
 284         const window_class = try cls("NSWindow");
 285         const allocated = objc.send(objc.Id, window_class, sels.alloc, .{});
 286         const rect = cocoa.NSRect{
 287             .origin = .{ .x = 100, .y = 100 },
 288             .size = .{
 289                 .width = @floatFromInt(config.width),
 290                 .height = @floatFromInt(config.height),
 291             },
 292         };
 293         const window = objc.send(?objc.Id, allocated, sels.init_with_content_rect, .{
 294             rect,
 295             windowStyle(config.resizable),
 296             @as(foundation.NSUInteger, 2),
 297             objc.boolean(false),
 298         }) orelse return error.WindowCreationFailed;
 299         errdefer releaseWindow(window, sels);
 300         objc.send(void, window, sels.set_released_when_closed, .{objc.boolean(false)});
 301 
 302         const title = retainedString(sels, config.title) orelse
 303             return error.WindowCreationFailed;
 304         defer objc.release(title);
 305         objc.send(void, window, sels.set_title, .{title});
 306         const content_view = objc.send(objc.Id, window, sels.content_view, .{});
 307         objc.send(void, content_view, sels.set_wants_layer, .{objc.boolean(true)});
 308         return .{
 309             .app = app,
 310             .window = window,
 311             .delegate = delegate,
 312             .layer = objc.send(objc.Id, content_view, sels.layer, .{}),
 313         };
 314     }
 315 
 316     fn deinit(self: NativeWindow, sels: Selectors) void {
 317         releaseWindow(self.window, sels);
 318         objc.release(self.delegate);
 319     }
 320 };
 321 
 322 fn windowStyle(resizable: bool) foundation.NSUInteger {
 323     var style = cocoa.NSWindowStyleMaskTitled |
 324         cocoa.NSWindowStyleMaskClosable |
 325         cocoa.NSWindowStyleMaskMiniaturizable;
 326     if (resizable) style |= cocoa.NSWindowStyleMaskResizable;
 327     return style;
 328 }
 329 
 330 fn releaseWindow(window: objc.Id, sels: Selectors) void {
 331     objc.send(void, window, sels.close, .{});
 332     objc.release(window);
 333 }
 334 
 335 fn retainedString(sels: Selectors, bytes: [:0]const u8) ?objc.Id {
 336     const string_class = objc.class("NSString") orelse return null;
 337     const allocated = objc.send(?objc.Id, string_class, sels.alloc, .{}) orelse return null;
 338     return objc.send(?objc.Id, allocated, sels.init_with_utf8, .{bytes.ptr});
 339 }
 340 
 341 pub const CocoaBackend = struct {
 342     app: objc.Id,
 343     window: objc.Id,
 344     delegate: objc.Id,
 345     layer: objc.Id,
 346     sels: Selectors,
 347     lifecycle: Lifecycle,
 348     width: u32,
 349     height: u32,
 350     clipboard_capacity: clipboard.Capacity,
 351     clipboard_source: clipboard.Source,
 352     gamepads: gamepad.State,
 353     input: input.State,
 354     events: event_module.Queue,
 355     frames: FrameStorage,
 356 
 357     pub fn init(
 358         self: *CocoaBackend,
 359         allocator: std.mem.Allocator,
 360         config: WindowConfig,
 361     ) CreateError!void {
 362         if (config.backend != .auto) return error.UnsupportedPlatform;
 363         var autorelease_pool = objc.AutoreleasePool.init();
 364         defer autorelease_pool.deinit();
 365         const sels = Selectors.init();
 366         var storage = try CocoaStorage.init(allocator, config);
 367         errdefer storage.deinit();
 368         const native = try NativeWindow.init(sels, config);
 369         errdefer native.deinit(sels);
 370 
 371         self.* = .{
 372             .app = native.app,
 373             .window = native.window,
 374             .delegate = native.delegate,
 375             .layer = native.layer,
 376             .sels = sels,
 377             .lifecycle = .{},
 378             .width = config.width,
 379             .height = config.height,
 380             .clipboard_capacity = storage.clipboard_capacity,
 381             .clipboard_source = storage.clipboard_source,
 382             .gamepads = storage.gamepads,
 383             .input = storage.input,
 384             .events = storage.events,
 385             .frames = storage.frames,
 386         };
 387         const backend_set = objc.setInstancePointer(
 388             native.delegate,
 389             window_delegate_backend_ivar,
 390             @ptrCast(self),
 391         );
 392         if (!backend_set) {
 393             return error.WindowCreationFailed;
 394         }
 395         objc.send(void, native.window, sels.set_delegate, .{native.delegate});
 396 
 397         if (config.visible) {
 398             objc.send(void, native.window, sels.make_key_and_order_front, .{objc.nil});
 399         }
 400         objc.send(void, native.app, sels.finish_launching, .{});
 401     }
 402 
 403     pub fn deinit(self: *CocoaBackend) void {
 404         objc.send(void, self.window, self.sels.set_delegate, .{objc.nil});
 405         _ = objc.setInstancePointer(self.delegate, window_delegate_backend_ivar, null);
 406         objc.send(void, self.layer, self.sels.set_contents, .{objc.nil});
 407         self.frames.deinit();
 408         objc.send(void, self.window, self.sels.close, .{});
 409         objc.release(self.window);
 410         objc.release(self.delegate);
 411         self.gamepads.deinit();
 412         self.input.deinit();
 413         self.events.deinit();
 414         self.clipboard_source.deinit();
 415         self.* = undefined;
 416     }
 417 
 418     pub fn pollEvents(self: *CocoaBackend) PollError!EventIterator {
 419         var autorelease_pool = objc.AutoreleasePool.init();
 420         defer autorelease_pool.deinit();
 421         self.input.beginPoll();
 422         self.gamepads.poll();
 423         self.events.beginPoll();
 424         self.flushPendingLifecycle();
 425         self.lifecycle.polling = true;
 426         defer self.lifecycle.polling = false;
 427 
 428         const ns_date_cls = cls("NSDate") catch return self.events.iterator();
 429         const distant_past = objc.send(objc.Id, ns_date_cls, self.sels.distant_past, .{});
 430 
 431         const mode = foundation.stringFromBytes("kCFRunLoopDefaultMode") orelse
 432             return self.events.iterator();
 433         defer objc.release(mode);
 434 
 435         while (true) {
 436             const ns_event = objc.send(
 437                 ?objc.Id,
 438                 self.app,
 439                 self.sels.next_event,
 440                 .{
 441                     cocoa.NSAnyEventMask,
 442                     distant_past,
 443                     mode,
 444                     objc.boolean(true),
 445                 },
 446             ) orelse break;
 447 
 448             self.translateEvent(ns_event);
 449 
 450             objc.send(void, self.app, self.sels.send_event, .{ns_event});
 451         }
 452 
 453         self.syncContentSize();
 454 
 455         return self.events.iterator();
 456     }
 457 
 458     pub fn shouldClose(self: *const CocoaBackend) bool {
 459         return self.lifecycle.should_close;
 460     }
 461 
 462     fn recordClose(self: *CocoaBackend) void {
 463         if (self.lifecycle.should_close) return;
 464         self.lifecycle.should_close = true;
 465         if (self.lifecycle.polling) {
 466             self.pushEvent(.window_close);
 467         } else {
 468             self.lifecycle.pending_close = true;
 469         }
 470     }
 471 
 472     fn recordFocus(self: *CocoaBackend, focused: bool) void {
 473         if (self.lifecycle.focused == focused) return;
 474         self.lifecycle.focused = focused;
 475         if (self.lifecycle.polling) {
 476             self.pushEvent(.{ .window_focus = .{ .focused = focused } });
 477         } else {
 478             self.lifecycle.pending_focus = focused;
 479         }
 480     }
 481 
 482     fn flushPendingLifecycle(self: *CocoaBackend) void {
 483         std.debug.assert(!self.lifecycle.polling);
 484         if (self.lifecycle.pending_focus) |focused| {
 485             self.lifecycle.pending_focus = null;
 486             self.pushEvent(.{ .window_focus = .{ .focused = focused } });
 487         }
 488         if (self.lifecycle.pending_close) {
 489             self.lifecycle.pending_close = false;
 490             self.pushEvent(.window_close);
 491         }
 492     }
 493 
 494     pub fn eventSource(_: *const CocoaBackend) ?@import("window.zig").EventSource {
 495         return null;
 496     }
 497 
 498     pub fn getSize(self: *const CocoaBackend) Size {
 499         return .{ .width = self.width, .height = self.height };
 500     }
 501 
 502     pub fn inputState(self: *const CocoaBackend) *const input.State {
 503         return &self.input;
 504     }
 505 
 506     pub fn inputStateMut(self: *CocoaBackend) *input.State {
 507         return &self.input;
 508     }
 509 
 510     pub fn modifiers(self: *const CocoaBackend) Modifier {
 511         return self.input.modifiers();
 512     }
 513 
 514     pub fn gamepadState(self: *const CocoaBackend) *const gamepad.State {
 515         return &self.gamepads;
 516     }
 517 
 518     pub fn setTitle(self: *CocoaBackend, title: [:0]const u8) void {
 519         const title_str = retainedString(self.sels, title) orelse return;
 520         defer objc.release(title_str);
 521         objc.send(void, self.window, self.sels.set_title, .{title_str});
 522     }
 523 
 524     pub fn setMinSize(self: *CocoaBackend, width: u32, height: u32) void {
 525         objc.send(void, self.window, self.sels.set_content_min_size, .{cocoa.NSSize{
 526             .width = @floatFromInt(width),
 527             .height = @floatFromInt(height),
 528         }});
 529     }
 530 
 531     pub fn setCursor(self: *CocoaBackend, shape: CursorShape) void {
 532         const ns_cursor_cls = cls("NSCursor") catch return;
 533         const cursor = objc.send(objc.Id, ns_cursor_cls, self.cursorSelector(shape), .{});
 534         objc.send(void, cursor, self.sels.set_cursor, .{});
 535     }
 536 
 537     pub fn isCursorOnScreen(self: *const CocoaBackend) bool {
 538         const loc = objc.send(cocoa.NSPoint, self.window, self.sels.mouse_location_outside_of_event_stream, .{});
 539         return pointInsideSize(loc, self.width, self.height);
 540     }
 541 
 542     pub fn minimize(self: *CocoaBackend) void {
 543         objc.send(void, self.window, self.sels.perform_miniaturize, .{objc.nil});
 544     }
 545 
 546     pub fn maximize(self: *CocoaBackend) void {
 547         if (!self.isMaximized()) objc.send(void, self.window, self.sels.zoom, .{objc.nil});
 548     }
 549 
 550     pub fn restore(self: *CocoaBackend) void {
 551         if (self.isMaximized()) objc.send(void, self.window, self.sels.zoom, .{objc.nil});
 552     }
 553 
 554     pub fn isMaximized(self: *const CocoaBackend) bool {
 555         return objc.isTrue(objc.send(objc.BOOL, self.window, self.sels.is_zoomed, .{}));
 556     }
 557 
 558     pub fn getClipboardTextAlloc(self: *CocoaBackend, allocator: std.mem.Allocator) !?[]u8 {
 559         const pasteboard_cls = cls("NSPasteboard") catch return null;
 560         const pasteboard = objc.send(objc.Id, pasteboard_cls, self.sels.general_pasteboard, .{});
 561         const type_string = foundation.stringFromBytes(std.mem.span(pasteboard_string_type)) orelse
 562             return null;
 563         defer objc.release(type_string);
 564         const string = objc.send(?objc.Id, pasteboard, self.sels.string_for_type, .{type_string}) orelse return null;
 565         const raw = objc.send(?[*:0]const u8, string, self.sels.utf8_string, .{}) orelse return null;
 566         const text = std.mem.span(raw);
 567         if (text.len > self.clipboard_capacity.received_text_byte_count) {
 568             return error.TransferTooLarge;
 569         }
 570         return try allocator.dupe(u8, text);
 571     }
 572 
 573     pub fn setClipboardText(self: *CocoaBackend, text: []const u8) clipboard.PublishError!void {
 574         try self.clipboard_source.replace(text);
 575         const publication = clipboardPublication(&self.clipboard_source);
 576         var autorelease_pool = objc.AutoreleasePool.init();
 577         defer autorelease_pool.deinit();
 578         const pasteboard_cls = cls("NSPasteboard") catch return error.Unavailable;
 579         const pasteboard = objc.send(objc.Id, pasteboard_cls, self.sels.general_pasteboard, .{});
 580         const type_string = foundation.stringFromBytes(publication.pasteboard_type) orelse
 581             return error.Unexpected;
 582         defer objc.release(type_string);
 583         const string = foundation.stringFromBytes(publication.text) orelse
 584             return error.Unexpected;
 585         defer objc.release(string);
 586         _ = objc.send(foundation.NSInteger, pasteboard, self.sels.clear_contents, .{});
 587         const accepted = objc.send(objc.BOOL, pasteboard, self.sels.set_string_for_type, .{
 588             string,
 589             type_string,
 590         });
 591         if (!objc.isTrue(accepted)) return error.Unexpected;
 592     }
 593 
 594     pub fn clipboardStatus(self: *const CocoaBackend) clipboard.Status {
 595         return self.clipboard_source.status();
 596     }
 597 
 598     pub fn x11StorageStatus(_: *const CocoaBackend) sys.x11.Status {
 599         return .{};
 600     }
 601 
 602     pub fn waylandDmabuf(_: *CocoaBackend) ?@import("wayland/root.zig").DmabufSurface {
 603         return null;
 604     }
 605 
 606     pub fn waylandStorageStatus(_: *const CocoaBackend) WaylandStorageStatus {
 607         return .{};
 608     }
 609 
 610     pub fn nextPresentationOutcome(_: *CocoaBackend) ?PresentationOutcome {
 611         return null;
 612     }
 613 
 614     pub fn presentationStatus(_: *const CocoaBackend) PresentationStatus {
 615         return .unavailable;
 616     }
 617 
 618     pub fn presentationStorageStatus(self: *const CocoaBackend) PresentationStorageStatus {
 619         return .{
 620             .frame_capacity_rejection_count = self.frames.capacity_rejection_count,
 621         };
 622     }
 623 
 624     pub fn droppedEventCount(self: *const CocoaBackend) u64 {
 625         return self.events.droppedEventCount();
 626     }
 627 
 628     pub fn getFramebufferSize(self: *const CocoaBackend) Size {
 629         const scale = self.getContentScale();
 630         return .{
 631             .width = scaledToU32(self.width, scale.x),
 632             .height = scaledToU32(self.height, scale.y),
 633         };
 634     }
 635 
 636     pub fn nativeSurface(self: *CocoaBackend) surface.Surface {
 637         const scale = self.getContentScale();
 638         const framebuffer = self.getFramebufferSize();
 639         return .{ .cocoa = .{
 640             .app = self.app,
 641             .window = self.window,
 642             .layer = self.layer,
 643             .extent = surface.extentFromSizes(
 644                 self.width,
 645                 self.height,
 646                 framebuffer.width,
 647                 framebuffer.height,
 648                 scale.x,
 649                 scale.y,
 650             ),
 651         } };
 652     }
 653 
 654     pub fn presentRgba8(
 655         self: *CocoaBackend,
 656         rgba8: []const u8,
 657         width: u32,
 658         height: u32,
 659     ) PresentError!void {
 660         const byte_count = presentation.rgba8ByteCount(width, height) orelse
 661             return error.UnsupportedFormat;
 662         if (rgba8.len < byte_count) return error.UnsupportedFormat;
 663 
 664         const next = try self.frames.prepare(byte_count);
 665         @memcpy(next.pixels, rgba8[0..byte_count]);
 666         try self.setLayerRgba8(next.pixels, width, height);
 667         self.frames.commit(next.index, width, height);
 668     }
 669 
 670     pub fn presentRgba8Region(
 671         self: *CocoaBackend,
 672         rgba8: []const u8,
 673         width: u32,
 674         height: u32,
 675         region: PresentRegion,
 676     ) PresentError!void {
 677         const byte_count = presentation.rgba8ByteCount(width, height) orelse
 678             return error.UnsupportedFormat;
 679         if (rgba8.len < byte_count) return error.UnsupportedFormat;
 680         const active_region = region.clamped(width, height);
 681         if (active_region.pixelCount() == 0) return;
 682 
 683         if (!self.frames.matches(width, height)) {
 684             return self.presentRgba8(rgba8, width, height);
 685         }
 686 
 687         const next = try self.frames.prepare(byte_count);
 688         @memcpy(next.pixels, self.frames.active(byte_count));
 689         copyRgba8Region(next.pixels, rgba8, width, active_region);
 690         try self.setLayerRgba8(next.pixels, width, height);
 691         self.frames.commit(next.index, width, height);
 692     }
 693 
 694     pub fn getContentScale(self: *const CocoaBackend) Scale {
 695         const scale: f32 = @floatCast(objc.send(coregraphics.CGFloat, self.window, self.sels.backing_scale_factor, .{}));
 696         const sanitized = sanitizeScale(scale);
 697         return .{ .x = sanitized, .y = sanitized };
 698     }
 699 
 700     fn cursorSelector(self: *const CocoaBackend, shape: CursorShape) objc.SEL {
 701         return switch (shape) {
 702             .default => self.sels.arrow_cursor,
 703             .resize_ns => self.sels.resize_up_down_cursor,
 704             .resize_ew => self.sels.resize_left_right_cursor,
 705             .resize_nwse, .resize_nesw => self.sels.arrow_cursor,
 706         };
 707     }
 708 
 709     fn translateEvent(self: *CocoaBackend, ns_event: objc.Id) void {
 710         const event_type = objc.send(foundation.NSUInteger, ns_event, self.sels.event_type, .{});
 711 
 712         switch (event_type) {
 713             cocoa.NSEventTypeKeyDown => self.translateKeyDown(ns_event),
 714             cocoa.NSEventTypeKeyUp => self.translateKeyUp(ns_event),
 715             cocoa.NSEventTypeFlagsChanged => self.translateModifier(ns_event),
 716             cocoa.NSEventTypeLeftMouseDown,
 717             cocoa.NSEventTypeRightMouseDown,
 718             cocoa.NSEventTypeOtherMouseDown,
 719             => self.translateMouseButton(ns_event, true),
 720             cocoa.NSEventTypeLeftMouseUp,
 721             cocoa.NSEventTypeRightMouseUp,
 722             cocoa.NSEventTypeOtherMouseUp,
 723             => self.translateMouseButton(ns_event, false),
 724             cocoa.NSEventTypeMouseMoved,
 725             cocoa.NSEventTypeLeftMouseDragged,
 726             cocoa.NSEventTypeRightMouseDragged,
 727             cocoa.NSEventTypeOtherMouseDragged,
 728             => {
 729                 const loc = objc.send(cocoa.NSPoint, ns_event, self.sels.location_in_window, .{});
 730                 self.input.moveMouse(@intFromFloat(loc.x), @intFromFloat(loc.y));
 731                 self.pushEvent(.{ .mouse_move = .{
 732                     .x = @intFromFloat(loc.x),
 733                     .y = @intFromFloat(loc.y),
 734                 } });
 735             },
 736             cocoa.NSEventTypeScrollWheel => {
 737                 const loc = objc.send(cocoa.NSPoint, ns_event, self.sels.location_in_window, .{});
 738                 const mods = objc.send(foundation.NSUInteger, ns_event, self.sels.modifier_flags, .{});
 739                 const delta_x: f32 = @floatCast(objc.send(coregraphics.CGFloat, ns_event, self.sels.scrolling_delta_x, .{}));
 740                 const delta_y: f32 = @floatCast(objc.send(coregraphics.CGFloat, ns_event, self.sels.scrolling_delta_y, .{}));
 741                 self.input.setMousePosition(@intFromFloat(loc.x), @intFromFloat(loc.y));
 742                 self.input.addMouseWheel(delta_x, delta_y);
 743                 self.pushEvent(.{ .mouse_wheel = .{
 744                     .x = @intFromFloat(loc.x),
 745                     .y = @intFromFloat(loc.y),
 746                     .delta_x = delta_x,
 747                     .delta_y = delta_y,
 748                     .modifiers = cocoaModsToModifier(mods),
 749                 } });
 750             },
 751             else => {},
 752         }
 753     }
 754 
 755     fn translateMouseButton(self: *CocoaBackend, ns_event: objc.Id, pressed: bool) void {
 756         const location = objc.send(
 757             cocoa.NSPoint,
 758             ns_event,
 759             self.sels.location_in_window,
 760             .{},
 761         );
 762         const flags = objc.send(
 763             foundation.NSUInteger,
 764             ns_event,
 765             self.sels.modifier_flags,
 766             .{},
 767         );
 768         const number = objc.send(
 769             foundation.NSInteger,
 770             ns_event,
 771             self.sels.button_number,
 772             .{},
 773         );
 774         const button = cocoaMouseButton(number) orelse return;
 775         const event: MouseButtonEvent = .{
 776             .button = button,
 777             .x = @as(i32, @intFromFloat(location.x)),
 778             .y = @as(i32, @intFromFloat(location.y)),
 779             .modifiers = cocoaModsToModifier(flags),
 780         };
 781         self.input.setMousePosition(event.x, event.y);
 782         if (pressed) {
 783             self.input.pressMouse(button);
 784             self.pushEvent(.{ .mouse_press = event });
 785         } else {
 786             self.input.releaseMouse(button);
 787             self.pushEvent(.{ .mouse_release = event });
 788         }
 789     }
 790 
 791     fn translateKeyDown(self: *CocoaBackend, ns_event: objc.Id) void {
 792         const keycode = objc.send(u16, ns_event, self.sels.key_code, .{});
 793         const flags = objc.send(
 794             foundation.NSUInteger,
 795             ns_event,
 796             self.sels.modifier_flags,
 797             .{},
 798         );
 799         const repeated = objc.isTrue(objc.send(
 800             objc.BOOL,
 801             ns_event,
 802             self.sels.is_a_repeat,
 803             .{},
 804         ));
 805         const active_modifiers = cocoaModsToModifier(flags);
 806         self.recordKey(macKeycodeToKey(keycode), active_modifiers, true, repeated);
 807         self.pushTextInput(ns_event, active_modifiers);
 808     }
 809 
 810     fn translateKeyUp(self: *CocoaBackend, ns_event: objc.Id) void {
 811         const keycode = objc.send(u16, ns_event, self.sels.key_code, .{});
 812         const flags = objc.send(
 813             foundation.NSUInteger,
 814             ns_event,
 815             self.sels.modifier_flags,
 816             .{},
 817         );
 818         self.recordKey(macKeycodeToKey(keycode), cocoaModsToModifier(flags), false, false);
 819     }
 820 
 821     fn translateModifier(self: *CocoaBackend, ns_event: objc.Id) void {
 822         const keycode = objc.send(u16, ns_event, self.sels.key_code, .{});
 823         const flags = objc.send(
 824             foundation.NSUInteger,
 825             ns_event,
 826             self.sels.modifier_flags,
 827             .{},
 828         );
 829         const key = macKeycodeToKey(keycode);
 830         const key_flag = cocoaModifierFlag(key) orelse return;
 831         const pressed = cocoaModifierPressed(self.input.isKeyDown(key), key_flag, flags);
 832         self.recordKey(key, cocoaModsToModifier(flags), pressed, false);
 833     }
 834 
 835     fn recordKey(
 836         self: *CocoaBackend,
 837         key: Key,
 838         active_modifiers: Modifier,
 839         pressed: bool,
 840         repeated: bool,
 841     ) void {
 842         if (pressed) {
 843             self.input.pressKey(key);
 844             self.pushEvent(.{ .key_press = .{
 845                 .key = key,
 846                 .modifiers = active_modifiers,
 847                 .repeated = repeated,
 848             } });
 849         } else {
 850             self.input.releaseKey(key);
 851             self.pushEvent(.{ .key_release = .{
 852                 .key = key,
 853                 .modifiers = active_modifiers,
 854             } });
 855         }
 856     }
 857 
 858     fn pushEvent(self: *CocoaBackend, event: Event) void {
 859         _ = self.events.push(event);
 860     }
 861 
 862     fn pushTextInput(self: *CocoaBackend, ns_event: objc.Id, active_modifiers: Modifier) void {
 863         const string = objc.send(?objc.Id, ns_event, self.sels.characters, .{}) orelse return;
 864         const raw = objc.send(?[*:0]const u8, string, self.sels.utf8_string, .{}) orelse return;
 865         if (Event.textInput(std.mem.span(raw), active_modifiers)) |event| {
 866             _ = self.input.pushTextInput(event.text_input.text());
 867             self.pushEvent(event);
 868         }
 869     }
 870 
 871     fn syncContentSize(self: *CocoaBackend) void {
 872         const view = objc.send(objc.Id, self.window, self.sels.content_view, .{});
 873         const frame = objc.send(cocoa.NSRect, view, self.sels.frame, .{});
 874         const size = frameToSize(frame);
 875         if (size.width != self.width or size.height != self.height) {
 876             self.width = size.width;
 877             self.height = size.height;
 878             self.pushEvent(.{ .window_resize = .{ .width = size.width, .height = size.height } });
 879         }
 880     }
 881 
 882     fn setLayerRgba8(self: *CocoaBackend, pixels: []u8, width: u32, height: u32) PresentError!void {
 883         const color_space = coregraphics.createDeviceRgbColorSpace() orelse return error.Unexpected;
 884         defer coregraphics.releaseColorSpace(color_space);
 885 
 886         const provider = coregraphics.createDataProvider(pixels) orelse return error.Unexpected;
 887         defer coregraphics.releaseDataProvider(provider);
 888 
 889         const image = coregraphics.createRgba8Image(width, height, @as(usize, width) * 4, color_space, provider) orelse return error.Unexpected;
 890         defer coregraphics.releaseImage(image);
 891 
 892         objc.send(void, self.layer, self.sels.set_contents_scale, .{layerContentsScale(width, self.width)});
 893         objc.send(void, self.layer, self.sels.set_contents, .{@as(objc.Id, @ptrCast(image))});
 894     }
 895 };
 896 
 897 const PreparedFrame = struct {
 898     index: usize,
 899     pixels: []u8,
 900 };
 901 
 902 const FrameStorage = struct {
 903     allocator: std.mem.Allocator,
 904     bytes: []u8,
 905     slot_byte_count: usize,
 906     active_index: ?usize = null,
 907     width: u32 = 0,
 908     height: u32 = 0,
 909     capacity_rejection_count: u64 = 0,
 910 
 911     fn init(
 912         allocator: std.mem.Allocator,
 913         capacity: presentation.Capacity,
 914     ) std.mem.Allocator.Error!FrameStorage {
 915         std.debug.assert(capacity.cocoa_frame_slot_count == 2);
 916         std.debug.assert(
 917             capacity.cocoa_frame_storage_bytes / capacity.cocoa_frame_slot_count ==
 918                 capacity.retained_frame_byte_count,
 919         );
 920         return .{
 921             .allocator = allocator,
 922             .bytes = try allocator.alloc(u8, capacity.cocoa_frame_storage_bytes),
 923             .slot_byte_count = capacity.retained_frame_byte_count,
 924         };
 925     }
 926 
 927     fn deinit(self: *FrameStorage) void {
 928         self.allocator.free(self.bytes);
 929         self.* = undefined;
 930     }
 931 
 932     fn prepare(self: *FrameStorage, byte_count: usize) error{CapacityExceeded}!PreparedFrame {
 933         if (byte_count > self.slot_byte_count) {
 934             self.capacity_rejection_count +|= 1;
 935             return error.CapacityExceeded;
 936         }
 937         const index = if (self.active_index) |active_index| active_index ^ 1 else 0;
 938         return .{ .index = index, .pixels = self.slot(index)[0..byte_count] };
 939     }
 940 
 941     fn active(self: *FrameStorage, byte_count: usize) []const u8 {
 942         std.debug.assert(byte_count <= self.slot_byte_count);
 943         return self.slot(self.active_index.?)[0..byte_count];
 944     }
 945 
 946     fn matches(self: *const FrameStorage, width: u32, height: u32) bool {
 947         return self.active_index != null and self.width == width and self.height == height;
 948     }
 949 
 950     fn commit(self: *FrameStorage, index: usize, width: u32, height: u32) void {
 951         std.debug.assert(index < 2);
 952         self.active_index = index;
 953         self.width = width;
 954         self.height = height;
 955     }
 956 
 957     fn slot(self: *FrameStorage, index: usize) []u8 {
 958         std.debug.assert(index < 2);
 959         const start = index * self.slot_byte_count;
 960         return self.bytes[start..][0..self.slot_byte_count];
 961     }
 962 };
 963 
 964 const pasteboard_string_type: [*:0]const u8 = "public.utf8-plain-text";
 965 
 966 const ClipboardPublication = struct {
 967     pasteboard_type: []const u8,
 968     text: []const u8,
 969 };
 970 
 971 fn clipboardPublication(source: *const clipboard.Source) ClipboardPublication {
 972     return .{
 973         .pasteboard_type = std.mem.span(pasteboard_string_type),
 974         .text = source.text(),
 975     };
 976 }
 977 
 978 fn frameToSize(frame: cocoa.NSRect) Size {
 979     return .{
 980         .width = floatToU32(frame.size.width),
 981         .height = floatToU32(frame.size.height),
 982     };
 983 }
 984 
 985 fn sanitizeScale(scale: f32) f32 {
 986     if (!std.math.isFinite(scale) or scale <= 0) return 1;
 987     return scale;
 988 }
 989 
 990 fn layerContentsScale(pixel_width: u32, logical_width: u32) coregraphics.CGFloat {
 991     if (pixel_width == 0 or logical_width == 0) return 1;
 992     return @as(coregraphics.CGFloat, @floatFromInt(pixel_width)) / @as(coregraphics.CGFloat, @floatFromInt(logical_width));
 993 }
 994 
 995 fn scaledToU32(value: u32, scale: f32) u32 {
 996     return floatToU32(@as(f64, @floatFromInt(value)) * @as(f64, sanitizeScale(scale)));
 997 }
 998 
 999 fn copyRgba8Region(dst: []u8, src: []const u8, frame_width: u32, region: PresentRegion) void {
1000     const row_bytes = @as(usize, region.width) * 4;
1001     var row: u32 = 0;
1002     while (row < region.height) : (row += 1) {
1003         const offset = (@as(usize, region.y + row) * @as(usize, frame_width) + @as(usize, region.x)) * 4;
1004         @memcpy(dst[offset..][0..row_bytes], src[offset..][0..row_bytes]);
1005     }
1006 }
1007 
1008 fn floatToU32(value: f64) u32 {
1009     if (!std.math.isFinite(value) or value <= 0) return 0;
1010     if (value >= @as(f64, @floatFromInt(std.math.maxInt(u32)))) return std.math.maxInt(u32);
1011     return @intFromFloat(@round(value));
1012 }
1013 
1014 fn pointInsideSize(point: cocoa.NSPoint, width: u32, height: u32) bool {
1015     return point.x >= 0 and
1016         point.y >= 0 and
1017         point.x < @as(coregraphics.CGFloat, @floatFromInt(width)) and
1018         point.y < @as(coregraphics.CGFloat, @floatFromInt(height));
1019 }
1020 
1021 fn macKeycodeToKey(keycode: u16) Key {
1022     return switch (keycode) {
1023         0x00 => .a,
1024         0x01 => .s,
1025         0x02 => .d,
1026         0x03 => .f,
1027         0x04 => .h,
1028         0x05 => .g,
1029         0x06 => .z,
1030         0x07 => .x,
1031         0x08 => .c,
1032         0x09 => .v,
1033         0x0B => .b,
1034         0x0C => .q,
1035         0x0D => .w,
1036         0x0E => .e,
1037         0x0F => .r,
1038         0x10 => .y,
1039         0x11 => .t,
1040         0x12 => .@"1",
1041         0x13 => .@"2",
1042         0x14 => .@"3",
1043         0x15 => .@"4",
1044         0x16 => .@"6",
1045         0x17 => .@"5",
1046         0x18 => .equal,
1047         0x19 => .@"9",
1048         0x1A => .@"7",
1049         0x1B => .minus,
1050         0x1C => .@"8",
1051         0x1D => .@"0",
1052         0x1E => .right_bracket,
1053         0x1F => .o,
1054         0x20 => .u,
1055         0x21 => .left_bracket,
1056         0x22 => .i,
1057         0x23 => .p,
1058         0x24 => .enter,
1059         0x25 => .l,
1060         0x26 => .j,
1061         0x27 => .apostrophe,
1062         0x28 => .k,
1063         0x29 => .semicolon,
1064         0x2A => .backslash,
1065         0x2B => .comma,
1066         0x2C => .slash,
1067         0x2D => .n,
1068         0x2E => .m,
1069         0x2F => .period,
1070         0x30 => .tab,
1071         0x31 => .space,
1072         0x32 => .grave,
1073         0x33 => .backspace,
1074         0x35 => .escape,
1075         0x36 => .right_super,
1076         0x37 => .left_super,
1077         0x38 => .left_shift,
1078         0x39 => .caps_lock,
1079         0x3A => .left_alt,
1080         0x3B => .left_ctrl,
1081         0x3C => .right_shift,
1082         0x3D => .right_alt,
1083         0x3E => .right_ctrl,
1084         0x60 => .f5,
1085         0x61 => .f6,
1086         0x62 => .f7,
1087         0x63 => .f3,
1088         0x64 => .f8,
1089         0x65 => .f9,
1090         0x67 => .f11,
1091         0x6D => .f10,
1092         0x6F => .f12,
1093         0x72 => .insert,
1094         0x73 => .home,
1095         0x74 => .page_up,
1096         0x75 => .delete,
1097         0x76 => .f4,
1098         0x77 => .end,
1099         0x78 => .f2,
1100         0x79 => .page_down,
1101         0x7A => .f1,
1102         0x7B => .left,
1103         0x7C => .right,
1104         0x7D => .down,
1105         0x7E => .up,
1106         else => .unknown,
1107     };
1108 }
1109 
1110 fn cocoaModifierFlag(key: Key) ?foundation.NSUInteger {
1111     return switch (key) {
1112         .caps_lock => cocoa.NSEventModifierFlagCapsLock,
1113         .left_shift, .right_shift => cocoa.NSEventModifierFlagShift,
1114         .left_ctrl, .right_ctrl => cocoa.NSEventModifierFlagControl,
1115         .left_alt, .right_alt => cocoa.NSEventModifierFlagOption,
1116         .left_super, .right_super => cocoa.NSEventModifierFlagCommand,
1117         else => null,
1118     };
1119 }
1120 
1121 fn cocoaModifierPressed(
1122     key_down: bool,
1123     key_flag: foundation.NSUInteger,
1124     flags: foundation.NSUInteger,
1125 ) bool {
1126     return flags & key_flag != 0 and !key_down;
1127 }
1128 
1129 fn cocoaMouseButton(number: foundation.NSInteger) ?MouseButton {
1130     return switch (number) {
1131         0 => .left,
1132         1 => .right,
1133         2 => .middle,
1134         3 => .x1,
1135         4 => .x2,
1136         else => null,
1137     };
1138 }
1139 
1140 fn cocoaModsToModifier(flags: foundation.NSUInteger) Modifier {
1141     return .{
1142         .shift = (flags & cocoa.NSEventModifierFlagShift) != 0,
1143         .ctrl = (flags & cocoa.NSEventModifierFlagControl) != 0,
1144         .alt = (flags & cocoa.NSEventModifierFlagOption) != 0,
1145         .super = (flags & cocoa.NSEventModifierFlagCommand) != 0,
1146     };
1147 }
1148 
1149 test "Cocoa cursor screen check compares point to window size" {
1150     try std.testing.expect(pointInsideSize(.{ .x = 0, .y = 0 }, 10, 10));
1151     try std.testing.expect(pointInsideSize(.{ .x = 9.5, .y = 9.5 }, 10, 10));
1152     try std.testing.expect(!pointInsideSize(.{ .x = -0.5, .y = 0 }, 10, 10));
1153     try std.testing.expect(!pointInsideSize(.{ .x = 10, .y = 0 }, 10, 10));
1154     try std.testing.expect(!pointInsideSize(.{ .x = 0, .y = 10 }, 10, 10));
1155 }
1156 
1157 test "Cocoa modifier flags preserve side transitions and caps lock" {
1158     const shift = cocoa.NSEventModifierFlagShift;
1159     try std.testing.expectEqual(shift, cocoaModifierFlag(.left_shift).?);
1160     try std.testing.expectEqual(shift, cocoaModifierFlag(.right_shift).?);
1161     try std.testing.expectEqual(
1162         cocoa.NSEventModifierFlagCapsLock,
1163         cocoaModifierFlag(.caps_lock).?,
1164     );
1165     try std.testing.expect(cocoaModifierFlag(.a) == null);
1166     try std.testing.expect(cocoaModifierPressed(false, shift, shift));
1167     try std.testing.expect(!cocoaModifierPressed(true, shift, shift));
1168     try std.testing.expect(!cocoaModifierPressed(false, shift, 0));
1169     try std.testing.expectEqual(Key.caps_lock, macKeycodeToKey(0x39));
1170 }
1171 
1172 test "Cocoa mouse button numbers map the public domain" {
1173     try std.testing.expectEqual(MouseButton.left, cocoaMouseButton(0).?);
1174     try std.testing.expectEqual(MouseButton.right, cocoaMouseButton(1).?);
1175     try std.testing.expectEqual(MouseButton.middle, cocoaMouseButton(2).?);
1176     try std.testing.expectEqual(MouseButton.x1, cocoaMouseButton(3).?);
1177     try std.testing.expectEqual(MouseButton.x2, cocoaMouseButton(4).?);
1178     try std.testing.expect(cocoaMouseButton(-1) == null);
1179     try std.testing.expect(cocoaMouseButton(5) == null);
1180 }
1181 
1182 test "Cocoa backend declarations compile against Apple ABIs" {
1183     if (comptime builtin.os.tag != .macos) return;
1184     std.testing.refAllDecls(CocoaBackend);
1185 }
1186 
1187 test "Cocoa clipboard publication retains exact UTF-8 source bytes" {
1188     const capacity = try clipboard.Capacity.derive(.{
1189         .published_text_byte_count = 8,
1190     });
1191     var source = try clipboard.Source.init(std.testing.allocator, capacity);
1192     defer source.deinit();
1193     try source.replace("copy λ");
1194     const publication = clipboardPublication(&source);
1195     try std.testing.expectEqualStrings(
1196         "public.utf8-plain-text",
1197         publication.pasteboard_type,
1198     );
1199     try std.testing.expectEqualStrings("copy λ", publication.text);
1200     try std.testing.expectError(error.CapacityExceeded, source.replace("123456789"));
1201     try std.testing.expectEqualStrings("copy λ", clipboardPublication(&source).text);
1202 }
1203 
1204 test "Cocoa frame storage alternates exact bounded handoff slots" {
1205     const capacity = try presentation.Capacity.derive(.{
1206         .retained_frame_byte_count = 16,
1207     });
1208     var storage = try FrameStorage.init(std.testing.allocator, capacity);
1209     defer storage.deinit();
1210 
1211     const first = try storage.prepare(16);
1212     @memset(first.pixels, 0x31);
1213     storage.commit(first.index, 2, 2);
1214     try std.testing.expect(storage.matches(2, 2));
1215     const second = try storage.prepare(16);
1216     try std.testing.expect(first.pixels.ptr != second.pixels.ptr);
1217     @memcpy(second.pixels, storage.active(16));
1218     storage.commit(second.index, 2, 2);
1219     const third = try storage.prepare(16);
1220     try std.testing.expectEqual(first.pixels.ptr, third.pixels.ptr);
1221     try std.testing.expectError(error.CapacityExceeded, storage.prepare(17));
1222     try std.testing.expectEqual(@as(u64, 1), storage.capacity_rejection_count);
1223     storage.capacity_rejection_count = std.math.maxInt(u64);
1224     try std.testing.expectError(error.CapacityExceeded, storage.prepare(17));
1225     try std.testing.expectEqual(std.math.maxInt(u64), storage.capacity_rejection_count);
1226 }
1227 
1228 test "Cocoa frame storage reports acquisition failure" {
1229     const capacity = try presentation.Capacity.derive(.{
1230         .retained_frame_byte_count = 16,
1231     });
1232     var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{
1233         .fail_index = 0,
1234     });
1235     try std.testing.expectError(
1236         error.OutOfMemory,
1237         FrameStorage.init(failing.allocator(), capacity),
1238     );
1239 }
1240 
1241 test "Cocoa region copy only mutates dirty rows" {
1242     const width: u32 = 4;
1243     const height: u32 = 3;
1244     var dst: [width * height * 4]u8 = @as([(width * height * 4)]u8, @splat(0x10));
1245     var src: [width * height * 4]u8 = undefined;
1246     for (0..height) |y| {
1247         for (0..width) |x| {
1248             const offset = (y * width + x) * 4;
1249             src[offset + 0] = @intCast(40 + x);
1250             src[offset + 1] = @intCast(50 + y);
1251             src[offset + 2] = @intCast(60 + x + y);
1252             src[offset + 3] = 0xFF;
1253         }
1254     }
1255 
1256     const region = PresentRegion{ .x = 1, .y = 1, .width = 2, .height = 1 };
1257     copyRgba8Region(&dst, &src, width, region);
1258 
1259     for (0..height) |y| {
1260         for (0..width) |x| {
1261             const offset = (y * width + x) * 4;
1262             const inside = x >= region.x and x < region.x + region.width and y >= region.y and y < region.y + region.height;
1263             if (inside) {
1264                 try std.testing.expectEqualSlices(u8, src[offset..][0..4], dst[offset..][0..4]);
1265             } else {
1266                 try std.testing.expectEqualSlices(u8, &[_]u8{ 0x10, 0x10, 0x10, 0x10 }, dst[offset..][0..4]);
1267             }
1268         }
1269     }
1270 }