[PATCH] ui/cocoa: Support hardware cursor interface

Elliot Nunn posted 1 patch 1 week ago
Failed in applying to current master (apply log)
ui/cocoa.m | 263 ++++++++++++++++++++++++++++++++++++++++++++++++-----
1 file changed, 240 insertions(+), 23 deletions(-)
[PATCH] ui/cocoa: Support hardware cursor interface
Posted by Elliot Nunn 1 week ago
Implement dpy_cursor_define() and dpy_mouse_set() on macOS.

The main benefit is from dpy_cursor_define: in absolute pointing mode, the
host can redraw the cursor on the guest's behalf much faster than the guest
can itself.

To provide the programmatic movement expected from a hardware cursor,
dpy_mouse_set is also implemented.

Tricky cases are handled:
- dpy_mouse_set() avoids rounded window corners.
- The sometimes-delay between warping the cursor and an affected mouse-move
  event is accounted for.
- Cursor bitmaps are nearest-neighbor scaled to Retina size.

Signed-off-by: Elliot Nunn <elliot@nunn.io>
---
 ui/cocoa.m | 263 ++++++++++++++++++++++++++++++++++++++++++++++++-----
 1 file changed, 240 insertions(+), 23 deletions(-)

diff --git a/ui/cocoa.m b/ui/cocoa.m
index 5a8bd5dd84..f9d54448e4 100644
--- a/ui/cocoa.m
+++ b/ui/cocoa.m
@@ -85,12 +85,20 @@ static void cocoa_switch(DisplayChangeListener *dcl,
 
 static void cocoa_refresh(DisplayChangeListener *dcl);
 
+static void cocoa_mouse_set(DisplayChangeListener *dcl,
+                            int x, int y, int on);
+
+static void cocoa_cursor_define(DisplayChangeListener *dcl,
+                                QEMUCursor *c);
+
 static NSWindow *normalWindow;
 static const DisplayChangeListenerOps dcl_ops = {
     .dpy_name          = "cocoa",
     .dpy_gfx_update = cocoa_update,
     .dpy_gfx_switch = cocoa_switch,
     .dpy_refresh = cocoa_refresh,
+    .dpy_mouse_set = cocoa_mouse_set,
+    .dpy_cursor_define = cocoa_cursor_define,
 };
 static DisplayChangeListener dcl = {
     .ops = &dcl_ops,
@@ -313,6 +321,13 @@ @interface QemuCocoaView : NSView
     BOOL isFullscreen;
     BOOL isAbsoluteEnabled;
     CFMachPortRef eventsTap;
+    NSCursor *guestCursor;
+    BOOL cursorHiddenByMe;
+    BOOL guestCursorVis;
+    int guestCursorX, guestCursorY;
+    int lastWarpX, lastWarpY;
+    int warpDeltaX, warpDeltaY;
+    BOOL ignoreNextMouseMove;
 }
 - (void) switchSurface:(pixman_image_t *)image;
 - (void) grabMouse;
@@ -323,6 +338,10 @@ - (void) handleMonitorInput:(NSEvent *)event;
 - (bool) handleEvent:(NSEvent *)event;
 - (bool) handleEventLocked:(NSEvent *)event;
 - (void) setAbsoluteEnabled:(BOOL)tIsAbsoluteEnabled;
+- (void) cursorDefine:(NSCursor *)cursor;
+- (void) mouseSetX:(int)x Y:(int)y on:(int)on;
+- (void) setCursorAppearance;
+- (void) setCursorPosition;
 /* The state surrounding mouse grabbing is potentially confusing.
  * isAbsoluteEnabled tracks qemu_input_is_absolute() [ie "is the emulated
  *   pointing device an absolute-position one?"], but is only updated on
@@ -432,22 +451,6 @@ - (CGPoint) screenLocationOfEvent:(NSEvent *)ev
     }
 }
 
-- (void) hideCursor
-{
-    if (!cursor_hide) {
-        return;
-    }
-    [NSCursor hide];
-}
-
-- (void) unhideCursor
-{
-    if (!cursor_hide) {
-        return;
-    }
-    [NSCursor unhide];
-}
-
 - (void) drawRect:(NSRect) rect
 {
     COCOA_DEBUG("QemuCocoaView: drawRect\n");
@@ -635,6 +638,8 @@ - (void) switchSurface:(pixman_image_t *)image
         screen.height = h;
         [self setContentDimensions];
         [self setFrame:NSMakeRect(cx, cy, cw, ch)];
+        [self setCursorAppearance];
+        [self setCursorPosition];
     }
 
     // update screenBuffer
@@ -681,6 +686,7 @@ - (void) toggleFullScreen:(id)sender
             styleMask:NSWindowStyleMaskBorderless
             backing:NSBackingStoreBuffered
             defer:NO];
+        [fullScreenWindow disableCursorRects];
         [fullScreenWindow setAcceptsMouseMovedEvents: YES];
         [fullScreenWindow setHasShadow:NO];
         [fullScreenWindow setBackgroundColor: [NSColor blackColor]];
@@ -812,6 +818,7 @@ - (bool) handleEventLocked:(NSEvent *)event
     int buttons = 0;
     int keycode = 0;
     bool mouse_event = false;
+    bool mousemoved_event = false;
     // Location of event in virtual screen coordinates
     NSPoint p = [self screenLocationOfEvent:event];
     NSUInteger modifiers = [event modifierFlags];
@@ -1023,6 +1030,7 @@ - (bool) handleEventLocked:(NSEvent *)event
                 }
             }
             mouse_event = true;
+            mousemoved_event = true;
             break;
         case NSEventTypeLeftMouseDown:
             buttons |= MOUSE_EVENT_LBUTTON;
@@ -1039,14 +1047,17 @@ - (bool) handleEventLocked:(NSEvent *)event
         case NSEventTypeLeftMouseDragged:
             buttons |= MOUSE_EVENT_LBUTTON;
             mouse_event = true;
+            mousemoved_event = true;
             break;
         case NSEventTypeRightMouseDragged:
             buttons |= MOUSE_EVENT_RBUTTON;
             mouse_event = true;
+            mousemoved_event = true;
             break;
         case NSEventTypeOtherMouseDragged:
             buttons |= MOUSE_EVENT_MBUTTON;
             mouse_event = true;
+            mousemoved_event = true;
             break;
         case NSEventTypeLeftMouseUp:
             mouse_event = true;
@@ -1121,7 +1132,12 @@ - (bool) handleEventLocked:(NSEvent *)event
             qemu_input_update_buttons(dcl.con, bmap, last_buttons, buttons);
             last_buttons = buttons;
         }
-        if (isMouseGrabbed) {
+
+        if (!isMouseGrabbed) {
+            return false;
+        }
+
+        if (mousemoved_event) {
             if (isAbsoluteEnabled) {
                 /* Note that the origin for Cocoa mouse coords is bottom left, not top left.
                  * The check on screenContainsPoint is to avoid sending out of range values for
@@ -1132,11 +1148,38 @@ - (bool) handleEventLocked:(NSEvent *)event
                     qemu_input_queue_abs(dcl.con, INPUT_AXIS_Y, screen.height - p.y, 0, screen.height);
                 }
             } else {
-                qemu_input_queue_rel(dcl.con, INPUT_AXIS_X, (int)[event deltaX]);
-                qemu_input_queue_rel(dcl.con, INPUT_AXIS_Y, (int)[event deltaY]);
+                if (ignoreNextMouseMove) {
+                    // Discard the first mouse-move event after a grab, because
+                    // it includes the warp delta from an unknown initial position.
+                    ignoreNextMouseMove = NO;
+                    warpDeltaX = warpDeltaY = 0;
+                } else {
+                    // Correct subsequent events to remove the known warp delta.
+                    // The warp delta is sometimes late to be reported, so never
+                    // allow the delta compensation to alter the direction.
+                    int dX = (int)[event deltaX];
+                    int dY = (int)[event deltaY];
+
+                    if (dX == 0 || (dX ^ (dX - warpDeltaX)) < 0) { // Flipped sign?
+                        warpDeltaX -= dX; // Save excess correction for later
+                        dX = 0;
+                    } else {
+                        dX -= warpDeltaX; // Apply entire correction
+                        warpDeltaX = 0;
+                    }
+
+                    if (dY == 0 || (dY ^ (dY - warpDeltaY)) < 0) {
+                        warpDeltaY -= dY;
+                        dY = 0;
+                    } else {
+                        dY -= warpDeltaY;
+                        warpDeltaY = 0;
+                    }
+
+                    qemu_input_queue_rel(dcl.con, INPUT_AXIS_X, dX);
+                    qemu_input_queue_rel(dcl.con, INPUT_AXIS_Y, dY);
+                }
             }
-        } else {
-            return false;
         }
         qemu_input_event_sync();
     }
@@ -1153,9 +1196,15 @@ - (void) grabMouse
         else
             [normalWindow setTitle:@"QEMU - (Press ctrl + alt + g to release Mouse)"];
     }
-    [self hideCursor];
     CGAssociateMouseAndMouseCursorPosition(isAbsoluteEnabled);
     isMouseGrabbed = TRUE; // while isMouseGrabbed = TRUE, QemuCocoaApp sends all events to [cocoaView handleEvent:]
+    [self setCursorAppearance];
+    [self setCursorPosition];
+
+    // We took over and warped the mouse, so ignore the next mouse-move
+    if (!isAbsoluteEnabled) {
+        ignoreNextMouseMove = YES;
+    }
 }
 
 - (void) ungrabMouse
@@ -1168,9 +1217,14 @@ - (void) ungrabMouse
         else
             [normalWindow setTitle:@"QEMU"];
     }
-    [self unhideCursor];
     CGAssociateMouseAndMouseCursorPosition(TRUE);
     isMouseGrabbed = FALSE;
+    [self setCursorAppearance];
+
+    if (!isAbsoluteEnabled) {
+        ignoreNextMouseMove = NO;
+        warpDeltaX = warpDeltaY = 0;
+    }
 }
 
 - (void) setAbsoluteEnabled:(BOOL)tIsAbsoluteEnabled {
@@ -1179,6 +1233,116 @@ - (void) setAbsoluteEnabled:(BOOL)tIsAbsoluteEnabled {
         CGAssociateMouseAndMouseCursorPosition(isAbsoluteEnabled);
     }
 }
+
+// Indirectly called by dpy_cursor_define() in the virtual GPU
+- (void) cursorDefine:(NSCursor *)cursor {
+    guestCursor = cursor;
+    [self setCursorAppearance];
+}
+
+// Indirectly called by dpy_mouse_set() in the virtual GPU
+- (void) mouseSetX:(int)x Y:(int)y on:(int)on {
+    if (!on != !guestCursorVis) {
+        guestCursorVis = on;
+        [self setCursorAppearance];
+    }
+
+    if (on && (x != guestCursorX || y != guestCursorY)) {
+        guestCursorX = x;
+        guestCursorY = y;
+        [self setCursorPosition];
+    }
+}
+
+// Change the cursor image to the default, the guest cursor bitmap or hidden.
+// Said to be an expensive operation on macOS Monterey, so use sparingly.
+- (void) setCursorAppearance {
+    NSCursor *cursor = NULL; // NULL means hidden
+
+    if (!isMouseGrabbed) {
+        cursor = [NSCursor arrowCursor];
+    } else if (!guestCursor && !cursor_hide) {
+        cursor = [NSCursor arrowCursor];
+    } else if (guestCursorVis && guestCursor) {
+        cursor = guestCursor;
+    } else {
+        cursor = NULL;
+    }
+
+    if (cursor != NULL) {
+        [cursor set];
+
+        if (cursorHiddenByMe) {
+            [NSCursor unhide];
+            cursorHiddenByMe = NO;
+        }
+    } else {
+        if (!cursorHiddenByMe) {
+            [NSCursor hide];
+            cursorHiddenByMe = YES;
+        }
+    }
+}
+
+// Move the cursor within the virtual screen
+- (void) setCursorPosition {
+    // Ignore the guest's request if the cursor belongs to Cocoa
+    if (!isMouseGrabbed || isAbsoluteEnabled) {
+        return;
+    }
+
+    // Get guest screen rect in Cocoa coordinates (bottom-left origin).
+    NSRect virtualScreen = [[self window] convertRectToScreen:[self frame]];
+
+    // Convert to top-left origin.
+    NSInteger hostScreenH = [NSScreen screens][0].frame.size.height;
+    int scrX = virtualScreen.origin.x;
+    int scrY = hostScreenH - virtualScreen.origin.y - virtualScreen.size.height;
+    int scrW = virtualScreen.size.width;
+    int scrH = virtualScreen.size.height;
+
+    int cursX = scrX + guestCursorX;
+    int cursY = scrY + guestCursorY;
+
+    // Clip to edges
+    cursX = MIN(MAX(scrX, cursX), scrX + scrW - 1);
+    cursY = MIN(MAX(scrY, cursY), scrY + scrH - 1);
+
+    // Move diagonally towards the center to avoid rounded window corners.
+    // Limit the number of hit-tests and discard failed attempts.
+    int betterX = cursX, betterY = cursY;
+    for (int i=0; i<16; i++) {
+        if ([NSWindow windowNumberAtPoint:NSMakePoint(betterX, hostScreenH - betterY)
+            belowWindowWithWindowNumber:0] == self.window.windowNumber) {
+            cursX = betterX;
+            cursY = betterY;
+            break;
+        };
+
+        if (betterX < scrX + scrW/2) {
+            betterX++;
+        } else {
+            betterX--;
+        }
+
+        if (betterY < scrY + scrH/2) {
+            betterY++;
+        } else {
+            betterY--;
+        }
+    }
+
+    // Subtract this warp delta from the next NSEventTypeMouseMoved.
+    // These are in down-is-positive coords, same as NSEvent deltaX/deltaY.
+    warpDeltaX += cursX - lastWarpX;
+    warpDeltaY += cursY - lastWarpY;
+
+    CGWarpMouseCursorPosition(NSMakePoint(cursX, cursY));
+
+    lastWarpX = cursX;
+    lastWarpY = cursY;
+}
+
 - (BOOL) isMouseGrabbed {return isMouseGrabbed;}
 - (BOOL) isAbsoluteEnabled {return isAbsoluteEnabled;}
 - (float) cdx {return cdx;}
@@ -1251,6 +1415,7 @@ - (id) init
             error_report("(cocoa) can't create window");
             exit(1);
         }
+        [normalWindow disableCursorRects];
         [normalWindow setAcceptsMouseMovedEvents:YES];
         [normalWindow setTitle:@"QEMU"];
         [normalWindow setContentView:cocoaView];
@@ -2123,6 +2288,58 @@ static void cocoa_display_init(DisplayState *ds, DisplayOptions *opts)
     qemu_clipboard_peer_register(&cbpeer);
 }
 
+static void cocoa_mouse_set(DisplayChangeListener *dcl, int x, int y, int on) {
+    dispatch_async(dispatch_get_main_queue(), ^{
+        [cocoaView mouseSetX:x Y:y on:on];
+    });
+}
+
+// Convert QEMUCursor to NSCursor, then call cursorDefine
+static void cocoa_cursor_define(DisplayChangeListener *dcl, QEMUCursor *cursor) {
+    CFDataRef cfdata = CFDataCreate(
+        /*allocator*/ NULL,
+        /*bytes*/ (void *)cursor->data,
+        /*length*/ sizeof(uint32_t) * cursor->width * cursor->height);
+
+    CGDataProviderRef dataprovider = CGDataProviderCreateWithCFData(cfdata);
+
+    CGImageRef cgimage = CGImageCreate(
+        cursor->width, cursor->height,
+        /*bitsPerComponent*/ 8,
+        /*bitsPerPixel*/ 32,
+        /*bytesPerRow*/ sizeof(uint32_t) * cursor->width,
+        /*colorspace*/ CGColorSpaceCreateWithName(kCGColorSpaceSRGB),
+        /*bitmapInfo*/ kCGBitmapByteOrder32Host | kCGImageAlphaLast,
+        /*provider*/ dataprovider,
+        /*decode*/ NULL,
+        /*shouldInterpolate*/ FALSE,
+        /*intent*/ kCGRenderingIntentDefault);
+
+    NSImage *unscaled = [[NSImage alloc] initWithCGImage:cgimage size:NSZeroSize];
+
+    CFRelease(cfdata);
+    CGDataProviderRelease(dataprovider);
+    CGImageRelease(cgimage);
+
+    // Nearest-neighbor scale to the possibly "Retina" cursor size
+    NSImage *scaled = [NSImage
+        imageWithSize:NSMakeSize(cursor->width, cursor->height)
+        flipped:NO
+        drawingHandler:^BOOL(NSRect dest) {
+            [NSGraphicsContext currentContext].imageInterpolation = NSImageInterpolationNone;
+            [unscaled drawInRect:dest];
+            return YES;
+        }];
+
+    NSCursor *nscursor = [[NSCursor alloc]
+        initWithImage:scaled
+        hotSpot:NSMakePoint(cursor->hot_x, cursor->hot_y)];
+
+    dispatch_async(dispatch_get_main_queue(), ^{
+        [cocoaView cursorDefine:nscursor];
+    });
+}
+
 static QemuDisplay qemu_display_cocoa = {
     .type       = DISPLAY_TYPE_COCOA,
     .init       = cocoa_display_init,