511 lines
18 KiB
Objective-C
511 lines
18 KiB
Objective-C
/*
|
|
* MACDRV CGEventTap-based cursor clipping class
|
|
*
|
|
* Copyright 2011, 2012, 2013 Ken Thomases for CodeWeavers Inc.
|
|
* Copyright 2021 Tim Clem for CodeWeavers Inc.
|
|
*
|
|
* This library is free software; you can redistribute it and/or
|
|
* modify it under the terms of the GNU Lesser General Public
|
|
* License as published by the Free Software Foundation; either
|
|
* version 2.1 of the License, or (at your option) any later version.
|
|
*
|
|
* This library is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
* Lesser General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Lesser General Public
|
|
* License along with this library; if not, write to the Free Software
|
|
* Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA
|
|
*/
|
|
|
|
#import "cocoa_app.h"
|
|
#import "cocoa_cursorclipping.h"
|
|
#import "cocoa_window.h"
|
|
|
|
|
|
/* Neither Quartz nor Cocoa has an exact analog for Win32 cursor clipping.
|
|
*
|
|
* Historically, we've used a CGEventTap and the
|
|
* CGAssociateMouseAndMouseCursorPosition function, as implemented in
|
|
* the WineEventTapClipCursorHandler class.
|
|
*
|
|
* As of macOS 10.13, there is an undocumented alternative,
|
|
* -[NSWindow setMouseConfinementRect:]. It comes with its own drawbacks,
|
|
* but is generally far simpler. It is described and implemented in
|
|
* the WineConfinementClipCursorHandler class.
|
|
*
|
|
* On macOS 10.13+, WineConfinementClipCursorHandler is the default.
|
|
* The Mac driver registry key UseConfinementCursorClipping can be set
|
|
* to "n" to use the event tap implementation.
|
|
*/
|
|
|
|
|
|
/* Clipping via CGEventTap and CGAssociateMouseAndMouseCursorPosition:
|
|
*
|
|
* For one simple case, clipping to a 1x1 rectangle, Quartz does have an
|
|
* equivalent: CGAssociateMouseAndMouseCursorPosition(false). For the
|
|
* general case, we leverage that. We disassociate mouse movements from
|
|
* the cursor position and then move the cursor manually, keeping it within
|
|
* the clipping rectangle.
|
|
*
|
|
* Moving the cursor manually isn't enough. We need to modify the event
|
|
* stream so that the events have the new location, too. We need to do
|
|
* this at a point before the events enter Cocoa, so that Cocoa will assign
|
|
* the correct window to the event. So, we install a Quartz event tap to
|
|
* do that.
|
|
*
|
|
* Also, there's a complication when we move the cursor. We use
|
|
* CGWarpMouseCursorPosition(). That doesn't generate mouse movement
|
|
* events, but the change of cursor position is incorporated into the
|
|
* deltas of the next mouse move event. When the mouse is disassociated
|
|
* from the cursor position, we need the deltas to only reflect actual
|
|
* device movement, not programmatic changes. So, the event tap cancels
|
|
* out the change caused by our calls to CGWarpMouseCursorPosition().
|
|
*/
|
|
|
|
|
|
@interface WarpRecord : NSObject
|
|
{
|
|
CGEventTimestamp timeBefore, timeAfter;
|
|
CGPoint from, to;
|
|
}
|
|
|
|
@property (nonatomic) CGEventTimestamp timeBefore;
|
|
@property (nonatomic) CGEventTimestamp timeAfter;
|
|
@property (nonatomic) CGPoint from;
|
|
@property (nonatomic) CGPoint to;
|
|
|
|
@end
|
|
|
|
|
|
@implementation WarpRecord
|
|
|
|
@synthesize timeBefore, timeAfter, from, to;
|
|
|
|
@end;
|
|
|
|
|
|
static void clip_cursor_location(CGRect cursorClipRect, CGPoint *location)
|
|
{
|
|
if (location->x < CGRectGetMinX(cursorClipRect))
|
|
location->x = CGRectGetMinX(cursorClipRect);
|
|
if (location->y < CGRectGetMinY(cursorClipRect))
|
|
location->y = CGRectGetMinY(cursorClipRect);
|
|
if (location->x > CGRectGetMaxX(cursorClipRect) - 1)
|
|
location->x = CGRectGetMaxX(cursorClipRect) - 1;
|
|
if (location->y > CGRectGetMaxY(cursorClipRect) - 1)
|
|
location->y = CGRectGetMaxY(cursorClipRect) - 1;
|
|
}
|
|
|
|
|
|
static void scale_rect_for_retina_mode(int mode, CGRect *cursorClipRect)
|
|
{
|
|
double scale = mode ? 0.5 : 2.0;
|
|
cursorClipRect->origin.x *= scale;
|
|
cursorClipRect->origin.y *= scale;
|
|
cursorClipRect->size.width *= scale;
|
|
cursorClipRect->size.height *= scale;
|
|
}
|
|
|
|
|
|
@implementation WineEventTapClipCursorHandler
|
|
|
|
@synthesize clippingCursor, cursorClipRect;
|
|
|
|
- (id) init
|
|
{
|
|
self = [super init];
|
|
if (self)
|
|
{
|
|
warpRecords = [[NSMutableArray alloc] init];
|
|
}
|
|
|
|
return self;
|
|
}
|
|
|
|
- (void) dealloc
|
|
{
|
|
[warpRecords release];
|
|
[super dealloc];
|
|
}
|
|
|
|
- (BOOL) warpCursorTo:(CGPoint*)newLocation from:(const CGPoint*)currentLocation
|
|
{
|
|
CGPoint oldLocation;
|
|
|
|
if (currentLocation)
|
|
oldLocation = *currentLocation;
|
|
else
|
|
oldLocation = NSPointToCGPoint([[WineApplicationController sharedController] flippedMouseLocation:[NSEvent mouseLocation]]);
|
|
|
|
if (!CGPointEqualToPoint(oldLocation, *newLocation))
|
|
{
|
|
WarpRecord* warpRecord = [[[WarpRecord alloc] init] autorelease];
|
|
CGError err;
|
|
|
|
warpRecord.from = oldLocation;
|
|
warpRecord.timeBefore = [[NSProcessInfo processInfo] systemUptime] * NSEC_PER_SEC;
|
|
|
|
/* Actually move the cursor. */
|
|
err = CGWarpMouseCursorPosition(*newLocation);
|
|
if (err != kCGErrorSuccess)
|
|
return FALSE;
|
|
|
|
warpRecord.timeAfter = [[NSProcessInfo processInfo] systemUptime] * NSEC_PER_SEC;
|
|
*newLocation = NSPointToCGPoint([[WineApplicationController sharedController] flippedMouseLocation:[NSEvent mouseLocation]]);
|
|
|
|
if (!CGPointEqualToPoint(oldLocation, *newLocation))
|
|
{
|
|
warpRecord.to = *newLocation;
|
|
[warpRecords addObject:warpRecord];
|
|
}
|
|
}
|
|
|
|
return TRUE;
|
|
}
|
|
|
|
- (BOOL) isMouseMoveEventType:(CGEventType)type
|
|
{
|
|
switch(type)
|
|
{
|
|
case kCGEventMouseMoved:
|
|
case kCGEventLeftMouseDragged:
|
|
case kCGEventRightMouseDragged:
|
|
case kCGEventOtherMouseDragged:
|
|
return TRUE;
|
|
default:
|
|
return FALSE;
|
|
}
|
|
}
|
|
|
|
- (int) warpsFinishedByEventTime:(CGEventTimestamp)eventTime location:(CGPoint)eventLocation
|
|
{
|
|
int warpsFinished = 0;
|
|
for (WarpRecord* warpRecord in warpRecords)
|
|
{
|
|
if (warpRecord.timeAfter < eventTime ||
|
|
(warpRecord.timeBefore <= eventTime && CGPointEqualToPoint(eventLocation, warpRecord.to)))
|
|
warpsFinished++;
|
|
else
|
|
break;
|
|
}
|
|
|
|
return warpsFinished;
|
|
}
|
|
|
|
- (CGEventRef) eventTapWithProxy:(CGEventTapProxy)proxy
|
|
type:(CGEventType)type
|
|
event:(CGEventRef)event
|
|
{
|
|
CGEventTimestamp eventTime;
|
|
CGPoint eventLocation, cursorLocation;
|
|
|
|
if (type == kCGEventTapDisabledByUserInput)
|
|
return event;
|
|
if (type == kCGEventTapDisabledByTimeout)
|
|
{
|
|
CGEventTapEnable(cursorClippingEventTap, TRUE);
|
|
return event;
|
|
}
|
|
|
|
if (!clippingCursor)
|
|
return event;
|
|
|
|
eventTime = CGEventGetTimestamp(event);
|
|
lastEventTapEventTime = eventTime / (double)NSEC_PER_SEC;
|
|
|
|
eventLocation = CGEventGetLocation(event);
|
|
|
|
cursorLocation = NSPointToCGPoint([[WineApplicationController sharedController] flippedMouseLocation:[NSEvent mouseLocation]]);
|
|
|
|
if ([self isMouseMoveEventType:type])
|
|
{
|
|
double deltaX, deltaY;
|
|
int warpsFinished = [self warpsFinishedByEventTime:eventTime location:eventLocation];
|
|
int i;
|
|
|
|
deltaX = CGEventGetDoubleValueField(event, kCGMouseEventDeltaX);
|
|
deltaY = CGEventGetDoubleValueField(event, kCGMouseEventDeltaY);
|
|
|
|
for (i = 0; i < warpsFinished; i++)
|
|
{
|
|
WarpRecord* warpRecord = [warpRecords objectAtIndex:0];
|
|
deltaX -= warpRecord.to.x - warpRecord.from.x;
|
|
deltaY -= warpRecord.to.y - warpRecord.from.y;
|
|
[warpRecords removeObjectAtIndex:0];
|
|
}
|
|
|
|
if (warpsFinished)
|
|
{
|
|
CGEventSetDoubleValueField(event, kCGMouseEventDeltaX, deltaX);
|
|
CGEventSetDoubleValueField(event, kCGMouseEventDeltaY, deltaY);
|
|
}
|
|
|
|
synthesizedLocation.x += deltaX;
|
|
synthesizedLocation.y += deltaY;
|
|
}
|
|
|
|
// If the event is destined for another process, don't clip it. This may
|
|
// happen if the user activates Exposé or Mission Control. In that case,
|
|
// our app does not resign active status, so clipping is still in effect,
|
|
// but the cursor should not actually be clipped.
|
|
//
|
|
// In addition, the fact that mouse moves may have been delivered to a
|
|
// different process means we have to treat the next one we receive as
|
|
// absolute rather than relative.
|
|
if (CGEventGetIntegerValueField(event, kCGEventTargetUnixProcessID) == getpid())
|
|
[self clipCursorLocation:&synthesizedLocation];
|
|
else
|
|
[WineApplicationController sharedController].lastSetCursorPositionTime = lastEventTapEventTime;
|
|
|
|
[self warpCursorTo:&synthesizedLocation from:&cursorLocation];
|
|
if (!CGPointEqualToPoint(eventLocation, synthesizedLocation))
|
|
CGEventSetLocation(event, synthesizedLocation);
|
|
|
|
return event;
|
|
}
|
|
|
|
CGEventRef WineAppEventTapCallBack(CGEventTapProxy proxy, CGEventType type,
|
|
CGEventRef event, void *refcon)
|
|
{
|
|
WineEventTapClipCursorHandler* handler = refcon;
|
|
return [handler eventTapWithProxy:proxy type:type event:event];
|
|
}
|
|
|
|
- (BOOL) installEventTap
|
|
{
|
|
CGEventMask mask = CGEventMaskBit(kCGEventLeftMouseDown) |
|
|
CGEventMaskBit(kCGEventLeftMouseUp) |
|
|
CGEventMaskBit(kCGEventRightMouseDown) |
|
|
CGEventMaskBit(kCGEventRightMouseUp) |
|
|
CGEventMaskBit(kCGEventMouseMoved) |
|
|
CGEventMaskBit(kCGEventLeftMouseDragged) |
|
|
CGEventMaskBit(kCGEventRightMouseDragged) |
|
|
CGEventMaskBit(kCGEventOtherMouseDown) |
|
|
CGEventMaskBit(kCGEventOtherMouseUp) |
|
|
CGEventMaskBit(kCGEventOtherMouseDragged) |
|
|
CGEventMaskBit(kCGEventScrollWheel);
|
|
CFRunLoopSourceRef source;
|
|
|
|
if (cursorClippingEventTap)
|
|
return TRUE;
|
|
|
|
// We create an annotated session event tap rather than a process-specific
|
|
// event tap because we need to programmatically move the cursor even when
|
|
// mouse moves are directed to other processes. We disable our tap when
|
|
// other processes are active, but things like Exposé are handled by other
|
|
// processes even when we remain active.
|
|
cursorClippingEventTap = CGEventTapCreate(kCGAnnotatedSessionEventTap, kCGHeadInsertEventTap,
|
|
kCGEventTapOptionDefault, mask, WineAppEventTapCallBack, self);
|
|
if (!cursorClippingEventTap)
|
|
return FALSE;
|
|
|
|
CGEventTapEnable(cursorClippingEventTap, FALSE);
|
|
|
|
source = CFMachPortCreateRunLoopSource(NULL, cursorClippingEventTap, 0);
|
|
if (!source)
|
|
{
|
|
CFRelease(cursorClippingEventTap);
|
|
cursorClippingEventTap = NULL;
|
|
return FALSE;
|
|
}
|
|
|
|
CFRunLoopAddSource(CFRunLoopGetCurrent(), source, kCFRunLoopCommonModes);
|
|
CFRelease(source);
|
|
return TRUE;
|
|
}
|
|
|
|
- (BOOL) setCursorPosition:(CGPoint)pos
|
|
{
|
|
BOOL ret;
|
|
|
|
[self clipCursorLocation:&pos];
|
|
|
|
ret = [self warpCursorTo:&pos from:NULL];
|
|
synthesizedLocation = pos;
|
|
if (ret)
|
|
{
|
|
// We want to discard mouse-move events that have already been
|
|
// through the event tap, because it's too late to account for
|
|
// the setting of the cursor position with them. However, the
|
|
// events that may be queued with times after that but before
|
|
// the above warp can still be used. So, use the last event
|
|
// tap event time so that -sendEvent: doesn't discard them.
|
|
[WineApplicationController sharedController].lastSetCursorPositionTime = lastEventTapEventTime;
|
|
}
|
|
|
|
return ret;
|
|
}
|
|
|
|
- (BOOL) startClippingCursor:(CGRect)rect
|
|
{
|
|
CGError err;
|
|
|
|
if (!cursorClippingEventTap && ![self installEventTap])
|
|
return FALSE;
|
|
|
|
err = CGAssociateMouseAndMouseCursorPosition(false);
|
|
if (err != kCGErrorSuccess)
|
|
return FALSE;
|
|
|
|
clippingCursor = TRUE;
|
|
cursorClipRect = rect;
|
|
|
|
CGEventTapEnable(cursorClippingEventTap, TRUE);
|
|
|
|
return TRUE;
|
|
}
|
|
|
|
- (BOOL) stopClippingCursor
|
|
{
|
|
CGError err = CGAssociateMouseAndMouseCursorPosition(true);
|
|
if (err != kCGErrorSuccess)
|
|
return FALSE;
|
|
|
|
clippingCursor = FALSE;
|
|
|
|
CGEventTapEnable(cursorClippingEventTap, FALSE);
|
|
[warpRecords removeAllObjects];
|
|
|
|
return TRUE;
|
|
}
|
|
|
|
- (void) clipCursorLocation:(CGPoint*)location
|
|
{
|
|
clip_cursor_location(cursorClipRect, location);
|
|
}
|
|
|
|
- (void) setRetinaMode:(int)mode
|
|
{
|
|
scale_rect_for_retina_mode(mode, &cursorClipRect);
|
|
}
|
|
|
|
@end
|
|
|
|
|
|
/* Clipping via mouse confinement rects:
|
|
*
|
|
* The undocumented -[NSWindow setMouseConfinementRect:] method is almost
|
|
* perfect for our needs. It has two main drawbacks compared to the CGEventTap
|
|
* approach:
|
|
* 1. It requires macOS 10.13+
|
|
* 2. A mouse confinement rect is tied to a region of a particular window. If
|
|
* an app calls ClipCursor with a rect that is outside the bounds of a
|
|
* window, the best we can do is intersect that rect with the window's bounds
|
|
* and clip to the result. If no windows are visible in the app, we can't do
|
|
* any clipping. Switching between windows in the same app while clipping is
|
|
* active is likewise impossible.
|
|
*
|
|
* But it has two major benefits:
|
|
* 1. The code is far simpler.
|
|
* 2. CGEventTap started requiring Accessibility permissions from macOS in
|
|
* Catalina. It's a hassle to enable, and if it's triggered while an app is
|
|
* fullscreen (which is often the case with clipping), it's easy to miss.
|
|
*/
|
|
|
|
|
|
@interface NSWindow (UndocumentedMouseConfinement)
|
|
/* Confines the system's mouse location to the provided window-relative rect
|
|
* while the app is frontmost and the window is key or a child of the key
|
|
* window. Confinement rects will be unioned among the key window and its
|
|
* children. The app should invoke this any time internal window geometry
|
|
* changes to keep the region up to date. Set NSZeroRect to remove mouse
|
|
* location confinement.
|
|
*
|
|
* These have been available since 10.13.
|
|
*/
|
|
- (NSRect) mouseConfinementRect;
|
|
- (void) setMouseConfinementRect:(NSRect)mouseConfinementRect;
|
|
@end
|
|
|
|
|
|
@implementation WineConfinementClipCursorHandler
|
|
|
|
@synthesize clippingCursor, cursorClipRect;
|
|
|
|
+ (BOOL) isAvailable
|
|
{
|
|
if ([NSProcessInfo instancesRespondToSelector:@selector(isOperatingSystemAtLeastVersion:)])
|
|
{
|
|
NSOperatingSystemVersion requiredVersion = { 10, 13, 0 };
|
|
return [[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:requiredVersion] &&
|
|
[NSWindow instancesRespondToSelector:@selector(setMouseConfinementRect:)];
|
|
}
|
|
|
|
return FALSE;
|
|
}
|
|
|
|
/* Returns the region of the given rect that intersects with the given
|
|
* window. The rect should be in screen coordinates. The result will be in
|
|
* window-relative coordinates.
|
|
*
|
|
* Returns NSZeroRect if the rect lies entirely outside the window.
|
|
*/
|
|
+ (NSRect) rectForScreenRect:(CGRect)rect inWindow:(NSWindow*)window
|
|
{
|
|
NSRect flippedRect = NSRectFromCGRect(rect);
|
|
[[WineApplicationController sharedController] flipRect:&flippedRect];
|
|
|
|
NSRect intersection = NSIntersectionRect([window frame], flippedRect);
|
|
|
|
if (NSIsEmptyRect(intersection))
|
|
return NSZeroRect;
|
|
|
|
return [window convertRectFromScreen:intersection];
|
|
}
|
|
|
|
- (BOOL) startClippingCursor:(CGRect)rect
|
|
{
|
|
if (clippingCursor && ![self stopClippingCursor])
|
|
return FALSE;
|
|
|
|
WineWindow *ownerWindow = [[WineApplicationController sharedController] frontWineWindow];
|
|
if (!ownerWindow)
|
|
{
|
|
/* There's nothing we can do here in this case, since confinement
|
|
* rects must be tied to a window. */
|
|
return FALSE;
|
|
}
|
|
|
|
NSRect clipRectInWindowCoords = [WineConfinementClipCursorHandler rectForScreenRect:rect
|
|
inWindow:ownerWindow];
|
|
|
|
if (NSIsEmptyRect(clipRectInWindowCoords))
|
|
{
|
|
/* If the clip region is entirely outside of the bounds of the
|
|
* window, there's again nothing we can do. */
|
|
return FALSE;
|
|
}
|
|
|
|
[ownerWindow setMouseConfinementRect:clipRectInWindowCoords];
|
|
|
|
clippingWindowNumber = ownerWindow.windowNumber;
|
|
cursorClipRect = rect;
|
|
clippingCursor = TRUE;
|
|
|
|
return TRUE;
|
|
}
|
|
|
|
- (BOOL) stopClippingCursor
|
|
{
|
|
NSWindow *ownerWindow = [NSApp windowWithWindowNumber:clippingWindowNumber];
|
|
[ownerWindow setMouseConfinementRect:NSZeroRect];
|
|
|
|
clippingCursor = FALSE;
|
|
|
|
return TRUE;
|
|
}
|
|
|
|
- (void) clipCursorLocation:(CGPoint*)location
|
|
{
|
|
clip_cursor_location(cursorClipRect, location);
|
|
}
|
|
|
|
- (void) setRetinaMode:(int)mode
|
|
{
|
|
scale_rect_for_retina_mode(mode, &cursorClipRect);
|
|
}
|
|
|
|
@end
|