fix(macos): preserve file drop targets (#374)

* fix(macos): preserve file drop targets

- Route AppKit drops with labeled, view-local coordinates across canvas, WebView, and window fallback paths.
- Preserve drop metadata through Zig and cover widget routing, TypeScript contracts, docs, and examples.

Co-authored-by: Mohak Bajaj <77928693+MohakBajaj@users.noreply.github.com>

* fix(macos): preserve file drop coordinates

* fix: keep file drop targets sized

* fix: keep capability webview interactive

* fix: constrain capabilities window width

---------

Co-authored-by: Mohak Bajaj <77928693+MohakBajaj@users.noreply.github.com>
This commit is contained in:
Chris Tate
2026-08-16 23:11:12 -05:00
committed by GitHub
parent 393a0ed36e
commit a33d579177
8 changed files with 299 additions and 32 deletions
+1 -1
View File
@@ -320,7 +320,7 @@ export function dropMsg(drop: FileDropEvent): Msg | null {
}
```
The platform event is journaled before either route, so record/replay delivers the identical source, point, and path bytes. A host that cannot resolve the target view leaves `viewLabel` empty and `point` null.
The platform event is journaled before either route, so record/replay delivers the identical source, point, and path bytes. The macOS system host reports labeled, view-local points for canvas and WebView drops; an unlabeled window region keeps an empty `viewLabel` and reports a window-content point. A host that cannot resolve either leaves `viewLabel` empty and `point` null.
## Native scrolling and context menus
+3 -1
View File
@@ -8,7 +8,7 @@ This example shows guarded OS capabilities from trusted WebView code:
- Clipboard text read and write.
- Message dialogs.
- Credential set, get, and delete.
- File-drop events delivered to Zig and the WebView event bridge.
- File-drop events delivered to Zig and the WebView event bridge, plus a real canvas `drop_files` target.
- File association and custom URL scheme packaging metadata.
- App activation and deactivation events.
@@ -24,6 +24,8 @@ Run the headless test path:
zig build test -Dplatform=null
```
For the macOS host integration check, run the app with the system backend and drag a Finder file onto the right-hand **Drop files here** canvas. The status bar must report `Widget target 2 fired` and the dropped path. Dropping over the left WebView must still report the ordinary app-level drop without a widget target. The guest-VM harness cannot synthesize an AppKit drag session yet, so this is the documented manual receipt for the real host path.
Run all native-first example tests from the repository root:
```sh
+4 -1
View File
@@ -9,6 +9,7 @@
"webview",
"js_bridge",
"native_views",
"gpu_surfaces",
"open_url",
"reveal_path",
"recent_documents",
@@ -39,11 +40,13 @@
.title = "Native SDK Capabilities",
.width = 900,
.height = 620,
.min_width = 770,
.restore_policy = "center_on_primary",
.views = .{
.{ .label = "main", .kind = "webview", .url = "zero://inline", .fill = true },
.{ .label = "statusbar", .kind = "statusbar", .edge = "bottom", .height = 34, .role = "Status" },
.{ .label = "status-label", .kind = "label", .parent = "statusbar", .x = 14, .y = 8, .width = 640, .height = 18, .text = "Ready." },
.{ .label = "drop-canvas", .kind = "gpu_surface", .edge = "right", .width = 250, .min_width = 220, .role = "File drop canvas", .accessibility_label = "File drop target", .gpu_backend = "metal" },
.{ .label = "main", .kind = "webview", .url = "zero://inline", .fill = true, .min_width = 520 },
},
},
},
+125 -7
View File
@@ -10,7 +10,10 @@ const manifest_url_schemes = if (@hasField(@TypeOf(app_manifest), "url_schemes")
const window_width: f32 = 900;
const window_height: f32 = 620;
const window_min_width: f32 = 770;
const statusbar_height: f32 = 34;
const drop_canvas_label = "drop-canvas";
const drop_target_id: native_sdk.canvas.ObjectId = 2;
const html =
\\<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
@@ -54,24 +57,30 @@ const builtin_policies = [_]native_sdk.BridgeCommandPolicy{
.{ .name = "native-sdk.credentials.delete", .permissions = &credential_permission, .origins = &bridge_origins },
};
const shell_views = [_]native_sdk.ShellView{
.{ .label = "main", .kind = .webview, .url = "zero://inline", .fill = true },
.{ .label = "statusbar", .kind = .statusbar, .edge = .bottom, .height = statusbar_height, .layer = 20, .role = "Status" },
.{ .label = "status-label", .kind = .label, .parent = "statusbar", .x = 14, .y = 8, .width = 640, .height = 18, .layer = 21, .text = "Ready." },
.{ .label = drop_canvas_label, .kind = .gpu_surface, .edge = .right, .width = 250, .min_width = 220, .role = "File drop canvas", .accessibility_label = "File drop target", .gpu_backend = .metal },
.{ .label = "main", .kind = .webview, .url = "zero://inline", .fill = true, .min_width = 520 },
};
const shell_windows = [_]native_sdk.ShellWindow{.{
.label = "main",
.title = "Native SDK Capabilities",
.width = window_width,
.height = window_height,
.min_width = window_min_width,
.views = &shell_views,
}};
const shell_scene: native_sdk.ShellConfig = .{ .windows = &shell_windows };
const CapabilitiesApp = struct {
drop_count: u32 = 0,
widget_drop_count: u32 = 0,
activation_count: u32 = 0,
deactivation_count: u32 = 0,
last_drop_paths: []const []const u8 = &.{},
last_drop_target_id: native_sdk.canvas.ObjectId = 0,
pending_drop_target_id: ?native_sdk.canvas.ObjectId = null,
drop_target_installed: bool = false,
fn app(self: *@This()) native_sdk.App {
return .{
@@ -96,9 +105,34 @@ const CapabilitiesApp = struct {
self.last_drop_paths = drop.paths;
var status_buffer: [160]u8 = undefined;
const first_path = if (drop.paths.len > 0) drop.paths[0] else "";
const status = try std.fmt.bufPrint(&status_buffer, "Received file drop {d}: {d} file(s): {s}", .{ self.drop_count, drop.paths.len, first_path });
const drop_target_id_value = self.pending_drop_target_id;
self.pending_drop_target_id = null;
const status = if (drop_target_id_value) |target_id|
try std.fmt.bufPrint(&status_buffer, "Widget target {d} fired; app drop {d}: {d} file(s): {s}", .{ target_id, self.drop_count, drop.paths.len, first_path })
else
try std.fmt.bufPrint(&status_buffer, "Received file drop {d}: {d} file(s): {s}", .{ self.drop_count, drop.paths.len, first_path });
_ = try runtime.updateView(drop.window_id, "status-label", .{ .text = status });
},
.canvas_widget_file_drop => |drop| {
self.widget_drop_count += 1;
if (drop.target) |target| {
self.last_drop_target_id = target.id;
self.pending_drop_target_id = target.id;
} else {
self.pending_drop_target_id = null;
}
},
.gpu_surface_frame => |frame| {
if (!self.drop_target_installed and std.mem.eql(u8, frame.label, drop_canvas_label)) {
try installDropTarget(runtime, frame.window_id, frame.label, frame.size);
self.drop_target_installed = true;
}
},
.gpu_surface_resized => |resize| {
if (self.drop_target_installed and std.mem.eql(u8, resize.label, drop_canvas_label)) {
try installDropTarget(runtime, resize.window_id, resize.label, resize.frame.size());
}
},
.lifecycle => |lifecycle| switch (lifecycle) {
.activate => {
self.activation_count += 1;
@@ -110,11 +144,31 @@ const CapabilitiesApp = struct {
},
else => {},
},
.appearance_changed, .command, .shortcut, .timer, .effects_wake, .audio, .video, .gpu_surface_frame, .gpu_surface_resized, .gpu_surface_input, .canvas_widget_pointer, .canvas_widget_keyboard, .canvas_widget_scroll, .canvas_widget_file_drop, .canvas_widget_drag, .canvas_widget_context_menu, .canvas_widget_context_menu_shown, .canvas_widget_context_menu_dismissed, .canvas_widget_context_menu_request, .canvas_widget_dismiss, .canvas_widget_context_press, .canvas_widget_resize, .canvas_widget_change, .window_closed, .automation_provenance => {},
.appearance_changed, .command, .shortcut, .timer, .effects_wake, .audio, .video, .gpu_surface_input, .canvas_widget_pointer, .canvas_widget_keyboard, .canvas_widget_scroll, .canvas_widget_drag, .canvas_widget_context_menu, .canvas_widget_context_menu_shown, .canvas_widget_context_menu_dismissed, .canvas_widget_context_menu_request, .canvas_widget_dismiss, .canvas_widget_context_press, .canvas_widget_resize, .canvas_widget_change, .window_closed, .automation_provenance => {},
}
}
};
fn installDropTarget(runtime: *native_sdk.Runtime, window_id: native_sdk.WindowId, label: []const u8, size: native_sdk.geometry.SizeF) !void {
const canvas = native_sdk.canvas;
const margin: f32 = 24;
const target = canvas.Widget{
.id = drop_target_id,
.kind = .button,
.frame = native_sdk.geometry.RectF.init(margin, margin, @max(1, size.width - margin * 2), @max(1, size.height - margin * 2)),
.text = "Drop files here",
.semantics = .{ .label = "Drop files here", .actions = .{ .drop_files = true } },
};
var nodes: [2]canvas.WidgetLayoutNode = undefined;
const layout = try canvas.layoutWidgetTree(
.{ .id = 1, .kind = .panel, .children = &.{target} },
native_sdk.geometry.RectF.init(0, 0, size.width, size.height),
&nodes,
);
_ = try runtime.setCanvasWidgetLayout(window_id, label, layout);
_ = try runtime.emitCanvasWidgetDisplayList(window_id, label, .{});
}
pub fn main(init: std.process.Init) !void {
var app = CapabilitiesApp{};
try runner.runWithOptions(app.app(), .{
@@ -139,6 +193,7 @@ pub fn main(init: std.process.Init) !void {
test "capabilities bridge gates native services and dispatches file drops" {
const harness = try native_sdk.TestHarness().create(std.testing.allocator, .{ .size = native_sdk.geometry.SizeF.init(window_width, window_height) });
defer harness.destroy(std.testing.allocator);
harness.null_platform.gpu_surfaces = true;
harness.runtime.options.builtin_bridge = .{ .enabled = true, .commands = &builtin_policies };
harness.runtime.options.security = .{
.permissions = &app_permissions,
@@ -155,6 +210,30 @@ test "capabilities bridge gates native services and dispatches file drops" {
const app = app_state.app();
try harness.start(app);
var views_buffer: [8]native_sdk.ViewInfo = undefined;
const views = harness.runtime.listViews(1, &views_buffer);
const webview = viewByLabel(views, "main").?;
const drop_canvas = viewByLabel(views, drop_canvas_label).?;
const statusbar = viewByLabel(views, "statusbar").?;
try std.testing.expect(webview.parent == null);
try std.testing.expect(drop_canvas.parent == null);
try std.testing.expectEqual(native_sdk.geometry.RectF.init(0, 0, 650, window_height - statusbar_height), webview.frame);
try std.testing.expectEqual(native_sdk.geometry.RectF.init(650, 0, 250, window_height - statusbar_height), drop_canvas.frame);
try std.testing.expectEqual(native_sdk.geometry.RectF.init(0, window_height - statusbar_height, window_width, statusbar_height), statusbar.frame);
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{
.window_id = 1,
.label = drop_canvas_label,
.size = native_sdk.geometry.SizeF.init(250, window_height - statusbar_height),
.frame_index = 1,
.nonblank = true,
} });
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_resized = .{
.window_id = 1,
.label = drop_canvas_label,
.frame = native_sdk.geometry.RectF.init(0, 0, 350, window_height - statusbar_height),
} });
try dispatchBridge(harness, app, "{\"id\":\"notify\",\"command\":\"native-sdk.os.showNotification\",\"payload\":{\"title\":\"Capabilities\",\"subtitle\":\"native-sdk\",\"body\":\"Done\"}}");
try std.testing.expect(std.mem.indexOf(u8, harness.null_platform.lastBridgeResponse(), "\"ok\":true") != null);
try std.testing.expectEqual(@as(usize, 1), harness.null_platform.notificationCount());
@@ -194,15 +273,37 @@ test "capabilities bridge gates native services and dispatches file drops" {
try std.testing.expect(std.mem.indexOf(u8, harness.null_platform.lastBridgeResponse(), "\"result\":true") != null);
const dropped_paths = [_][]const u8{ "/tmp/one\nname.txt", "/tmp/two.txt" };
try harness.runtime.dispatchPlatformEvent(app, .{ .files_dropped = .{
.window_id = 1,
.paths = &dropped_paths,
} });
try harness.runtime.dispatchPlatformEvent(app, .{
.files_dropped = .{
.window_id = 1,
.view_label = drop_canvas_label,
// x=300 is outside the initial 250-point surface and proves the
// resize event rebuilt the retained widget hit-test geometry.
.point = native_sdk.geometry.PointF.init(300, 40),
.paths = &dropped_paths,
},
});
try std.testing.expectEqual(@as(u32, 1), app_state.widget_drop_count);
try std.testing.expectEqual(drop_target_id, app_state.last_drop_target_id);
try std.testing.expectEqual(@as(u32, 1), app_state.drop_count);
try std.testing.expectEqual(@as(usize, 2), app_state.last_drop_paths.len);
try std.testing.expectEqualStrings("/tmp/one\nname.txt", app_state.last_drop_paths[0]);
try std.testing.expectEqualStrings("/tmp/two.txt", app_state.last_drop_paths[1]);
try std.testing.expectEqualStrings("drop:files", harness.null_platform.lastWindowEventName());
try std.testing.expect(std.mem.startsWith(u8, nullViewText(harness, "status-label"), "Widget target 2 fired"));
try std.testing.expect(app_state.pending_drop_target_id == null);
const webview_paths = [_][]const u8{"/tmp/webview.txt"};
try harness.runtime.dispatchPlatformEvent(app, .{ .files_dropped = .{
.window_id = 1,
.view_label = "main",
.point = native_sdk.geometry.PointF.init(40, 40),
.paths = &webview_paths,
} });
try std.testing.expectEqual(@as(u32, 1), app_state.widget_drop_count);
try std.testing.expect(app_state.pending_drop_target_id == null);
try std.testing.expectEqual(@as(u32, 2), app_state.drop_count);
try std.testing.expect(std.mem.startsWith(u8, nullViewText(harness, "status-label"), "Received file drop 2"));
try harness.runtime.dispatchPlatformEvent(app, .app_activated);
try std.testing.expectEqual(@as(u32, 1), app_state.activation_count);
@@ -213,6 +314,9 @@ test "capabilities bridge gates native services and dispatches file drops" {
}
test "capabilities manifest declares package integration metadata" {
try std.testing.expectEqual(window_min_width, app_manifest.shell.windows[0].min_width);
try std.testing.expectEqual(window_min_width, shell_windows[0].min_width);
try std.testing.expectEqual(@as(usize, 1), manifest_file_associations.len);
try std.testing.expectEqualStrings("Native SDK Capability Document", manifest_file_associations[0].name);
try std.testing.expectEqualStrings("viewer", manifest_file_associations[0].role);
@@ -231,3 +335,17 @@ fn dispatchBridge(harness: *native_sdk.TestHarness(), app: native_sdk.App, bytes
.webview_label = "main",
} });
}
fn nullViewText(harness: *native_sdk.TestHarness(), label: []const u8) []const u8 {
for (harness.null_platform.views[0..harness.null_platform.view_count]) |view| {
if (std.mem.eql(u8, view.label, label)) return view.text;
}
return "";
}
fn viewByLabel(views: []const native_sdk.ViewInfo, label: []const u8) ?native_sdk.ViewInfo {
for (views) |view| {
if (std.mem.eql(u8, view.label, label)) return view;
}
return null;
}
+9
View File
@@ -4,6 +4,15 @@ This three-column board is authored entirely in TypeScript + Native markup. `src
Drop one or more files anywhere on the board to add their basenames as Todo cards. The desktop host sends the native file-drop event through the runtime, and the core's `dropMsg` maps the full path list into one deterministic `files_dropped` message before `update` changes the board.
To manually verify the real macOS host path (the guest-VM harness cannot synthesize an AppKit drag session yet):
1. Run `native dev` on macOS.
2. Drag a file from Finder onto a visible card in the canvas, not the titlebar.
3. Verify a new Todo card appears with the dropped file's basename.
4. Repeat over empty board space; the app-level drop still works there.
The first drop traverses the labeled `kanban-canvas` destination with a view-local, top-left-origin point before the app-level `dropMsg` runs. That is the same host data widget `drop_files` hit-testing consumes; the second verifies the ordinary canvas-level fallback remains intact.
Drag any card within a column or across Todo, Doing, and Done. The card itself lifts under the pointer at full opacity, leaving one blank, card-sized slot behind. As the pointer reaches another candidate position, that same reserved slot moves from the source to the candidate and neighboring cards glide around it—there are never two spaces for one card. On release, the same floating card eases from the pointer into the slot. Press Escape during a drag to cancel it and carry the card back to its source slot. Cards can move forwards, backwards, or directly across the board.
Each card represents an agent-owned ticket: the title sits above a compact metadata row with a Jira/Linear-style issue key and the assigned OpenAI or Claude avatar. The avatar artwork is rasterized from SVGL's [OpenAI](https://svgl.app/library/openai.svg) and [Claude AI](https://svgl.app/library/claude-ai-icon.svg) SVGs so it can travel through the app manifest's static image channel.
+10 -5
View File
@@ -247,8 +247,10 @@ export interface PinchEvent {
readonly y: number;
}
/// A file drop's optional point in view-local canvas coordinates. Desktop
/// hosts that only know the target window leave `FileDropEvent.point` null.
/// A file drop's optional point in top-left-origin local coordinates. A
/// non-empty `FileDropEvent.viewLabel` names its canvas or WebView coordinate
/// space; an empty label may carry window-content coordinates. Hosts that
/// cannot resolve local coordinates leave `FileDropEvent.point` null.
export interface FileDropPoint {
readonly x: number;
readonly y: number;
@@ -256,9 +258,12 @@ export interface FileDropPoint {
/// The app-level file-drop channel's record (`dropMsg(drop)`). `windowId`
/// and `viewLabel` identify the source; `point` is present when the host can
/// resolve view-local coordinates. Paths are byte text so arbitrary UTF-8
/// filesystem names cross without becoming runtime JS strings. Return null
/// from `dropMsg` to ignore a drop, or map it to an ordinary Msg.
/// resolve local coordinates. The macOS system host reports labeled canvas or
/// WebView coordinates and degrades unlabeled regions to window-content
/// coordinates; hosts without either leave `point` null. Paths are byte text
/// so arbitrary UTF-8 filesystem names cross without becoming runtime JS
/// strings. Return null from `dropMsg` to ignore a drop, or map it to an
/// ordinary Msg.
export interface FileDropEvent {
readonly windowId: number;
readonly viewLabel: string;
+79 -12
View File
@@ -483,7 +483,7 @@ static int NativeSdkCredentialStatus(OSStatus status, int missingCode) {
- (BOOL)emitSetSelectionAccessibilityValue:(id)value;
@end
@interface NativeSdkMetalSurfaceView : NSView <NSTextInputClient>
@interface NativeSdkMetalSurfaceView : NSView <NSTextInputClient, NSDraggingDestination>
@property(nonatomic, strong) id<MTLDevice> device;
@property(nonatomic, strong) id<MTLCommandQueue> commandQueue;
@property(nonatomic, strong) CAMetalLayer *metalLayer;
@@ -502,6 +502,7 @@ static int NativeSdkCredentialStatus(OSStatus status, int missingCode) {
@property(nonatomic, assign) NativeSdkAppKitHost *host;
@property(nonatomic, assign) uint64_t windowId;
@property(nonatomic, strong) NSString *surfaceLabel;
@property(nonatomic, strong) NSString *viewLabel;
@property(nonatomic, assign) NSUInteger frameIndex;
/* Whether this surface has completed at least one REAL present. Gates the
* occluded short-circuit: until the first present lands, occluded frames
@@ -1100,7 +1101,7 @@ static int NativeSdkCredentialStatus(OSStatus status, int missingCode) {
- (void)stop;
- (BOOL)drainPendingPreRunStop;
- (void)emitEvent:(native_sdk_appkit_event_t)event;
- (BOOL)emitDroppedFileURLs:(NSArray<NSURL *> *)urls windowId:(uint64_t)windowId;
- (BOOL)emitDroppedFileURLs:(NSArray<NSURL *> *)urls windowId:(uint64_t)windowId viewLabel:(NSString *)viewLabel point:(NSPoint)point;
- (void)startApplicationActivationObservers;
- (void)stopApplicationActivationObservers;
- (void)applicationDidBecomeActive:(NSNotification *)notification;
@@ -1199,6 +1200,14 @@ static void NativeSdkEmitGpuSurfaceResizes(NSView *view) {
}
}
// Convert an AppKit-local point to the runtime's top-left-origin space.
// Unflipped canvas/content views need the y inversion; flipped views such as
// WKWebView already use the runtime's orientation and must pass through.
static NSPoint NativeSdkViewLocalYDownPoint(NSView *view, NSPoint point) {
if (!view.isFlipped) point.y = view.bounds.size.height - point.y;
return point;
}
@implementation NativeSdkWindowDelegate
- (void)windowDidResize:(NSNotification *)notification {
@@ -1291,9 +1300,9 @@ static void NativeSdkEmitGpuSurfaceResizes(NSView *view) {
// The window is a dragging destination now that the main WebView (whose
// registration used to catch every drop) is lazy: NSWindow forwards
// these to its delegate, and the emit path is byte-identical to the
// WebView's. A present main/child WebView still wins (views outrank the
// window for registered types), and its handler emits the same event.
// these to its delegate. A present main/child WebView or canvas surface
// still wins (views outrank the window for registered types); the fallback
// hit-tests anyway so adopted/layered content keeps the most specific label.
- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)sender {
(void)sender;
return NSDragOperationCopy;
@@ -1303,7 +1312,36 @@ static void NativeSdkEmitGpuSurfaceResizes(NSView *view) {
NSPasteboard *pasteboard = sender.draggingPasteboard;
NSArray<NSURL *> *urls = [pasteboard readObjectsForClasses:@[ [NSURL class] ]
options:@{ NSPasteboardURLReadingFileURLsOnlyKey : @YES }];
return [self.host emitDroppedFileURLs:urls windowId:self.windowId];
NSWindow *window = self.host.windows[@(self.windowId)] ?: (self.windowId == 1 ? self.host.window : nil);
NSView *contentView = window.contentView;
if (!contentView) return NO;
const NSPoint windowPoint = sender.draggingLocation;
const NSPoint contentPoint = [contentView convertPoint:windowPoint fromView:nil];
NSView *targetView = [contentView hitTest:contentPoint];
NSString *viewLabel = @"";
for (NSView *candidate = targetView; candidate; candidate = candidate.superview) {
if ([candidate isKindOfClass:[NativeSdkMetalSurfaceView class]]) {
NSString *label = ((NativeSdkMetalSurfaceView *)candidate).viewLabel;
if (label.length > 0) {
targetView = candidate;
viewLabel = label;
break;
}
} else if ([candidate isKindOfClass:[NativeSdkWebView class]]) {
NSString *label = ((NativeSdkWebView *)candidate).viewLabel;
if (label.length > 0) {
targetView = candidate;
viewLabel = label;
break;
}
}
if (candidate == contentView) break;
}
NSView *coordinateView = viewLabel.length > 0 ? targetView : contentView;
NSPoint point = NativeSdkViewLocalYDownPoint(coordinateView, [coordinateView convertPoint:windowPoint fromView:nil]);
return [self.host emitDroppedFileURLs:urls windowId:self.windowId viewLabel:viewLabel point:point];
}
// close_policy .hide: the USER's close affordance (the red button,
@@ -1407,7 +1445,8 @@ static void NativeSdkEmitGpuSurfaceResizes(NSView *view) {
NSPasteboard *pasteboard = sender.draggingPasteboard;
NSArray<NSURL *> *urls = [pasteboard readObjectsForClasses:@[[NSURL class]]
options:@{ NSPasteboardURLReadingFileURLsOnlyKey: @YES }];
return [self.host emitDroppedFileURLs:urls windowId:self.windowId];
NSPoint point = NativeSdkViewLocalYDownPoint(self, [self convertPoint:sender.draggingLocation fromView:nil]);
return [self.host emitDroppedFileURLs:urls windowId:self.windowId viewLabel:self.viewLabel point:point];
}
@end
@@ -3713,6 +3752,14 @@ static void NativeSdkPremultiplyStraightRgba8(const uint8_t *source, uint8_t *de
@implementation NativeSdkMetalSurfaceView
- (NSString *)viewLabel {
return self.surfaceLabel;
}
- (void)setViewLabel:(NSString *)viewLabel {
self.surfaceLabel = viewLabel ?: @"";
}
- (instancetype)initWithFrame:(NSRect)frameRect {
self = [super initWithFrame:frameRect];
if (!self) return nil;
@@ -3761,7 +3808,7 @@ static void NativeSdkPremultiplyStraightRgba8(const uint8_t *source, uint8_t *de
- (void)configureWithHost:(NativeSdkAppKitHost *)host windowId:(uint64_t)windowId label:(NSString *)label {
self.host = host;
self.windowId = windowId;
self.surfaceLabel = label ?: @"";
self.viewLabel = label;
__weak NativeSdkMetalSurfaceView *weakSelf = self;
dispatch_async(dispatch_get_main_queue(), ^{
NativeSdkMetalSurfaceView *strongSelf = weakSelf;
@@ -3772,6 +3819,19 @@ static void NativeSdkPremultiplyStraightRgba8(const uint8_t *source, uint8_t *de
});
}
- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)sender {
(void)sender;
return NSDragOperationCopy;
}
- (BOOL)performDragOperation:(id<NSDraggingInfo>)sender {
NSPasteboard *pasteboard = sender.draggingPasteboard;
NSArray<NSURL *> *urls = [pasteboard readObjectsForClasses:@[[NSURL class]]
options:@{ NSPasteboardURLReadingFileURLsOnlyKey: @YES }];
NSPoint point = NativeSdkViewLocalYDownPoint(self, [self convertPoint:sender.draggingLocation fromView:nil]);
return [self.host emitDroppedFileURLs:urls windowId:self.windowId viewLabel:self.viewLabel point:point];
}
- (void)dealloc {
[self stopDisplayTimer];
[[NSNotificationCenter defaultCenter] removeObserver:self];
@@ -7019,7 +7079,7 @@ static BOOL NativeSdkScrollDriverCanConsumeHorizontally(NativeSdkScrollDriverVie
- (void)emitInputEventWithKind:(NSInteger)kind point:(NSPoint)point timestampNs:(uint64_t)timestampNs modifiers:(uint32_t)modifiers keyText:(NSString *)keyText inputText:(NSString *)inputText button:(NSInteger)button deltaX:(double)deltaX deltaY:(double)deltaY {
if (!self.host || self.surfaceLabel.length == 0) return;
CGFloat y = self.bounds.size.height - point.y;
const NSPoint yDownPoint = NativeSdkViewLocalYDownPoint(self, point);
const char *labelBytes = self.surfaceLabel.UTF8String ?: "";
NSString *safeKeyText = keyText ?: @"";
NSString *safeInputText = inputText ?: @"";
@@ -7029,8 +7089,8 @@ static BOOL NativeSdkScrollDriverCanConsumeHorizontally(NativeSdkScrollDriverVie
.kind = NATIVE_SDK_APPKIT_EVENT_GPU_SURFACE_INPUT,
.window_id = self.windowId,
.timestamp_ns = timestampNs,
.x = point.x,
.y = y,
.x = yDownPoint.x,
.y = yDownPoint.y,
.view_label = labelBytes,
.view_label_len = [self.surfaceLabel lengthOfBytesUsingEncoding:NSUTF8StringEncoding],
.key_text = keyBytes,
@@ -8449,6 +8509,7 @@ static float NativeSdkCaptureReadRemixedSample(const AudioBufferList *buffers, c
case NATIVE_SDK_APPKIT_VIEW_GPU_SURFACE: {
NativeSdkMetalSurfaceView *surface = [[NativeSdkMetalSurfaceView alloc] initWithFrame:NSZeroRect];
if (![surface isAvailable]) return nil;
[surface registerForDraggedTypes:@[NSPasteboardTypeFileURL]];
view = surface;
break;
}
@@ -12036,7 +12097,7 @@ static void NativeSdkVideoFittedSize(double naturalWidth, double naturalHeight,
}];
}
- (BOOL)emitDroppedFileURLs:(NSArray<NSURL *> *)urls windowId:(uint64_t)windowId {
- (BOOL)emitDroppedFileURLs:(NSArray<NSURL *> *)urls windowId:(uint64_t)windowId viewLabel:(NSString *)viewLabel point:(NSPoint)point {
if (urls.count == 0) return NO;
NSMutableArray<NSString *> *paths = [NSMutableArray array];
for (NSURL *url in urls) {
@@ -12053,9 +12114,15 @@ static void NativeSdkVideoFittedSize(double naturalWidth, double naturalHeight,
[data appendData:pathData];
}
if (data.length == 0) return NO;
NSString *safeViewLabel = viewLabel ?: @"";
const char *viewLabelBytes = safeViewLabel.UTF8String ?: "";
[self emitEvent:(native_sdk_appkit_event_t){
.kind = NATIVE_SDK_APPKIT_EVENT_FILES_DROPPED,
.window_id = windowId,
.x = point.x,
.y = point.y,
.view_label = viewLabelBytes,
.view_label_len = [safeViewLabel lengthOfBytesUsingEncoding:NSUTF8StringEncoding],
.drop_paths = data.bytes,
.drop_paths_len = data.length,
}];
+68 -5
View File
@@ -999,11 +999,7 @@ fn appkitCallback(context: ?*anyopaque, event: *const AppKitEvent) callconv(.c)
.wake => state.emit(.wake),
.files_dropped => {
var paths_buffer: [platform_mod.max_drop_paths][]const u8 = undefined;
const paths = platform_mod.splitDropPaths(event.drop_paths[0..event.drop_paths_len], paths_buffer[0..]);
state.emit(.{ .files_dropped = .{
.window_id = event.window_id,
.paths = paths,
} });
state.emit(.{ .files_dropped = fileDropEventFromAppKitEvent(event, paths_buffer[0..]) });
},
.gpu_surface_frame => state.emit(.{ .gpu_surface_frame = .{
.window_id = event.window_id,
@@ -1141,6 +1137,17 @@ fn gpuSurfaceInputEventFromAppKitEvent(event: *const AppKitEvent) platform_mod.G
};
}
fn fileDropEventFromAppKitEvent(event: *const AppKitEvent, paths_buffer: [][]const u8) platform_mod.FileDropEvent {
return .{
.window_id = event.window_id,
.view_label = appKitEventBytes(event.view_label, event.view_label_len),
// AppKit's system host converts every accepted drop to the same
// top-left-origin view space as gpu-surface input before emitting.
.point = geometry.PointF.init(@floatCast(event.x), @floatCast(event.y)),
.paths = platform_mod.splitDropPaths(appKitEventBytes(event.drop_paths, event.drop_paths_len), paths_buffer),
};
}
fn readClipboard(context: ?*anyopaque, buffer: []u8) anyerror![]const u8 {
const self: *MacPlatform = @ptrCast(@alignCast(context.?));
const len = native_sdk_appkit_clipboard_read(self.host, buffer.ptr, buffer.len);
@@ -3169,6 +3176,62 @@ test "mac gpu surface input preserves key and text" {
try std.testing.expect(input.modifiers.shift);
}
test "mac file drop bridge preserves view label point and paths" {
const label = "kanban-canvas";
const paths = "/tmp/spec.txt\x00/tmp/design.pdf";
var event = std.mem.zeroes(AppKitEvent);
event.kind = .files_dropped;
event.window_id = 7;
event.view_label = label.ptr;
event.view_label_len = label.len;
event.x = 42.5;
event.y = 91.25;
event.drop_paths = paths.ptr;
event.drop_paths_len = paths.len;
var paths_buffer: [platform_mod.max_drop_paths][]const u8 = undefined;
const drop = fileDropEventFromAppKitEvent(&event, paths_buffer[0..]);
try std.testing.expectEqual(@as(platform_mod.WindowId, 7), drop.window_id);
try std.testing.expectEqualStrings("kanban-canvas", drop.view_label);
try std.testing.expectEqualDeep(geometry.PointF.init(42.5, 91.25), drop.point.?);
try std.testing.expectEqual(@as(usize, 2), drop.paths.len);
try std.testing.expectEqualStrings("/tmp/spec.txt", drop.paths[0]);
try std.testing.expectEqualStrings("/tmp/design.pdf", drop.paths[1]);
}
test "mac file drops and pointer input share the host y-down conversion" {
const host_source = @embedFile("appkit_host.m");
try std.testing.expectEqual(@as(usize, 5), std.mem.count(u8, host_source, "NativeSdkViewLocalYDownPoint("));
try std.testing.expect(std.mem.indexOf(
u8,
host_source,
"if (!view.isFlipped) point.y = view.bounds.size.height - point.y;",
) != null);
try std.testing.expect(std.mem.indexOf(
u8,
host_source,
"const NSPoint yDownPoint = NativeSdkViewLocalYDownPoint(self, point);",
) != null);
try std.testing.expect(std.mem.indexOf(
u8,
host_source,
"NativeSdkViewLocalYDownPoint(self, [self convertPoint:sender.draggingLocation fromView:nil])",
) != null);
const label = "canvas";
var event = std.mem.zeroes(AppKitEvent);
event.view_label = label.ptr;
event.view_label_len = label.len;
event.x = 128.5;
event.y = 73.25;
var paths_buffer: [platform_mod.max_drop_paths][]const u8 = undefined;
const drop = fileDropEventFromAppKitEvent(&event, paths_buffer[0..]);
const input = gpuSurfaceInputEventFromAppKitEvent(&event);
try std.testing.expectEqual(input.x, drop.point.?.x);
try std.testing.expectEqual(input.y, drop.point.?.y);
}
test "mac gpu surface input preserves ime composition cursor" {
const label = "canvas";
const text = "compose";