2 * MACDRV CGEventTap-based cursor clipping class
4 * Copyright 2011, 2012, 2013 Ken Thomases for CodeWeavers Inc.
5 * Copyright 2021 Tim Clem for CodeWeavers Inc.
7 * This library is free software; you can redistribute it and/or
8 * modify it under the terms of the GNU Lesser General Public
9 * License as published by the Free Software Foundation; either
10 * version 2.1 of the License, or (at your option) any later version.
12 * This library is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
15 * Lesser General Public License for more details.
17 * You should have received a copy of the GNU Lesser General Public
18 * License along with this library; if not, write to the Free Software
19 * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA
23 #import "cocoa_cursorclipping.h"
24 #import "cocoa_window.h"
27 /* Neither Quartz nor Cocoa has an exact analog for Win32 cursor clipping.
29 * Historically, we've used a CGEventTap and the
30 * CGAssociateMouseAndMouseCursorPosition function, as implemented in
31 * the WineEventTapClipCursorHandler class.
33 * As of macOS 10.13, there is an undocumented alternative,
34 * -[NSWindow setMouseConfinementRect:]. It comes with its own drawbacks,
35 * but is generally far simpler. It is described and implemented in
36 * the WineConfinementClipCursorHandler class.
38 * On macOS 10.13+, WineConfinementClipCursorHandler is the default.
39 * The Mac driver registry key UseConfinementCursorClipping can be set
40 * to "n" to use the event tap implementation.
44 /* Clipping via CGEventTap and CGAssociateMouseAndMouseCursorPosition:
46 * For one simple case, clipping to a 1x1 rectangle, Quartz does have an
47 * equivalent: CGAssociateMouseAndMouseCursorPosition(false). For the
48 * general case, we leverage that. We disassociate mouse movements from
49 * the cursor position and then move the cursor manually, keeping it within
50 * the clipping rectangle.
52 * Moving the cursor manually isn't enough. We need to modify the event
53 * stream so that the events have the new location, too. We need to do
54 * this at a point before the events enter Cocoa, so that Cocoa will assign
55 * the correct window to the event. So, we install a Quartz event tap to
58 * Also, there's a complication when we move the cursor. We use
59 * CGWarpMouseCursorPosition(). That doesn't generate mouse movement
60 * events, but the change of cursor position is incorporated into the
61 * deltas of the next mouse move event. When the mouse is disassociated
62 * from the cursor position, we need the deltas to only reflect actual
63 * device movement, not programmatic changes. So, the event tap cancels
64 * out the change caused by our calls to CGWarpMouseCursorPosition().
68 @interface WarpRecord : NSObject
70 CGEventTimestamp timeBefore, timeAfter;
74 @property (nonatomic) CGEventTimestamp timeBefore;
75 @property (nonatomic) CGEventTimestamp timeAfter;
76 @property (nonatomic) CGPoint from;
77 @property (nonatomic) CGPoint to;
82 @implementation WarpRecord
84 @synthesize timeBefore, timeAfter, from, to;
89 static void clip_cursor_location(CGRect cursorClipRect, CGPoint *location)
91 if (location->x < CGRectGetMinX(cursorClipRect))
92 location->x = CGRectGetMinX(cursorClipRect);
93 if (location->y < CGRectGetMinY(cursorClipRect))
94 location->y = CGRectGetMinY(cursorClipRect);
95 if (location->x > CGRectGetMaxX(cursorClipRect) - 1)
96 location->x = CGRectGetMaxX(cursorClipRect) - 1;
97 if (location->y > CGRectGetMaxY(cursorClipRect) - 1)
98 location->y = CGRectGetMaxY(cursorClipRect) - 1;
102 static void scale_rect_for_retina_mode(int mode, CGRect *cursorClipRect)
104 double scale = mode ? 0.5 : 2.0;
105 cursorClipRect->origin.x *= scale;
106 cursorClipRect->origin.y *= scale;
107 cursorClipRect->size.width *= scale;
108 cursorClipRect->size.height *= scale;
112 @implementation WineEventTapClipCursorHandler
114 @synthesize clippingCursor, cursorClipRect;
121 warpRecords = [[NSMutableArray alloc] init];
129 [warpRecords release];
133 - (BOOL) warpCursorTo:(CGPoint*)newLocation from:(const CGPoint*)currentLocation
138 oldLocation = *currentLocation;
140 oldLocation = NSPointToCGPoint([[WineApplicationController sharedController] flippedMouseLocation:[NSEvent mouseLocation]]);
142 if (!CGPointEqualToPoint(oldLocation, *newLocation))
144 WarpRecord* warpRecord = [[[WarpRecord alloc] init] autorelease];
147 warpRecord.from = oldLocation;
148 warpRecord.timeBefore = [[NSProcessInfo processInfo] systemUptime] * NSEC_PER_SEC;
150 /* Actually move the cursor. */
151 err = CGWarpMouseCursorPosition(*newLocation);
152 if (err != kCGErrorSuccess)
155 warpRecord.timeAfter = [[NSProcessInfo processInfo] systemUptime] * NSEC_PER_SEC;
156 *newLocation = NSPointToCGPoint([[WineApplicationController sharedController] flippedMouseLocation:[NSEvent mouseLocation]]);
158 if (!CGPointEqualToPoint(oldLocation, *newLocation))
160 warpRecord.to = *newLocation;
161 [warpRecords addObject:warpRecord];
168 - (BOOL) isMouseMoveEventType:(CGEventType)type
172 case kCGEventMouseMoved:
173 case kCGEventLeftMouseDragged:
174 case kCGEventRightMouseDragged:
175 case kCGEventOtherMouseDragged:
182 - (int) warpsFinishedByEventTime:(CGEventTimestamp)eventTime location:(CGPoint)eventLocation
184 int warpsFinished = 0;
185 for (WarpRecord* warpRecord in warpRecords)
187 if (warpRecord.timeAfter < eventTime ||
188 (warpRecord.timeBefore <= eventTime && CGPointEqualToPoint(eventLocation, warpRecord.to)))
194 return warpsFinished;
197 - (CGEventRef) eventTapWithProxy:(CGEventTapProxy)proxy
198 type:(CGEventType)type
199 event:(CGEventRef)event
201 CGEventTimestamp eventTime;
202 CGPoint eventLocation, cursorLocation;
204 if (type == kCGEventTapDisabledByUserInput)
206 if (type == kCGEventTapDisabledByTimeout)
208 CGEventTapEnable(cursorClippingEventTap, TRUE);
215 eventTime = CGEventGetTimestamp(event);
216 lastEventTapEventTime = eventTime / (double)NSEC_PER_SEC;
218 eventLocation = CGEventGetLocation(event);
220 cursorLocation = NSPointToCGPoint([[WineApplicationController sharedController] flippedMouseLocation:[NSEvent mouseLocation]]);
222 if ([self isMouseMoveEventType:type])
224 double deltaX, deltaY;
225 int warpsFinished = [self warpsFinishedByEventTime:eventTime location:eventLocation];
228 deltaX = CGEventGetDoubleValueField(event, kCGMouseEventDeltaX);
229 deltaY = CGEventGetDoubleValueField(event, kCGMouseEventDeltaY);
231 for (i = 0; i < warpsFinished; i++)
233 WarpRecord* warpRecord = [warpRecords objectAtIndex:0];
234 deltaX -= warpRecord.to.x - warpRecord.from.x;
235 deltaY -= warpRecord.to.y - warpRecord.from.y;
236 [warpRecords removeObjectAtIndex:0];
241 CGEventSetDoubleValueField(event, kCGMouseEventDeltaX, deltaX);
242 CGEventSetDoubleValueField(event, kCGMouseEventDeltaY, deltaY);
245 synthesizedLocation.x += deltaX;
246 synthesizedLocation.y += deltaY;
249 // If the event is destined for another process, don't clip it. This may
250 // happen if the user activates Exposé or Mission Control. In that case,
251 // our app does not resign active status, so clipping is still in effect,
252 // but the cursor should not actually be clipped.
254 // In addition, the fact that mouse moves may have been delivered to a
255 // different process means we have to treat the next one we receive as
256 // absolute rather than relative.
257 if (CGEventGetIntegerValueField(event, kCGEventTargetUnixProcessID) == getpid())
258 [self clipCursorLocation:&synthesizedLocation];
260 [WineApplicationController sharedController].lastSetCursorPositionTime = lastEventTapEventTime;
262 [self warpCursorTo:&synthesizedLocation from:&cursorLocation];
263 if (!CGPointEqualToPoint(eventLocation, synthesizedLocation))
264 CGEventSetLocation(event, synthesizedLocation);
269 CGEventRef WineAppEventTapCallBack(CGEventTapProxy proxy, CGEventType type,
270 CGEventRef event, void *refcon)
272 WineEventTapClipCursorHandler* handler = refcon;
273 return [handler eventTapWithProxy:proxy type:type event:event];
276 - (BOOL) installEventTap
278 CGEventMask mask = CGEventMaskBit(kCGEventLeftMouseDown) |
279 CGEventMaskBit(kCGEventLeftMouseUp) |
280 CGEventMaskBit(kCGEventRightMouseDown) |
281 CGEventMaskBit(kCGEventRightMouseUp) |
282 CGEventMaskBit(kCGEventMouseMoved) |
283 CGEventMaskBit(kCGEventLeftMouseDragged) |
284 CGEventMaskBit(kCGEventRightMouseDragged) |
285 CGEventMaskBit(kCGEventOtherMouseDown) |
286 CGEventMaskBit(kCGEventOtherMouseUp) |
287 CGEventMaskBit(kCGEventOtherMouseDragged) |
288 CGEventMaskBit(kCGEventScrollWheel);
289 CFRunLoopSourceRef source;
291 if (cursorClippingEventTap)
294 // We create an annotated session event tap rather than a process-specific
295 // event tap because we need to programmatically move the cursor even when
296 // mouse moves are directed to other processes. We disable our tap when
297 // other processes are active, but things like Exposé are handled by other
298 // processes even when we remain active.
299 cursorClippingEventTap = CGEventTapCreate(kCGAnnotatedSessionEventTap, kCGHeadInsertEventTap,
300 kCGEventTapOptionDefault, mask, WineAppEventTapCallBack, self);
301 if (!cursorClippingEventTap)
304 CGEventTapEnable(cursorClippingEventTap, FALSE);
306 source = CFMachPortCreateRunLoopSource(NULL, cursorClippingEventTap, 0);
309 CFRelease(cursorClippingEventTap);
310 cursorClippingEventTap = NULL;
314 CFRunLoopAddSource(CFRunLoopGetCurrent(), source, kCFRunLoopCommonModes);
319 - (BOOL) setCursorPosition:(CGPoint)pos
323 [self clipCursorLocation:&pos];
325 ret = [self warpCursorTo:&pos from:NULL];
326 synthesizedLocation = pos;
329 // We want to discard mouse-move events that have already been
330 // through the event tap, because it's too late to account for
331 // the setting of the cursor position with them. However, the
332 // events that may be queued with times after that but before
333 // the above warp can still be used. So, use the last event
334 // tap event time so that -sendEvent: doesn't discard them.
335 [WineApplicationController sharedController].lastSetCursorPositionTime = lastEventTapEventTime;
341 - (BOOL) startClippingCursor:(CGRect)rect
345 if (!cursorClippingEventTap && ![self installEventTap])
348 err = CGAssociateMouseAndMouseCursorPosition(false);
349 if (err != kCGErrorSuccess)
352 clippingCursor = TRUE;
353 cursorClipRect = rect;
355 CGEventTapEnable(cursorClippingEventTap, TRUE);
360 - (BOOL) stopClippingCursor
362 CGError err = CGAssociateMouseAndMouseCursorPosition(true);
363 if (err != kCGErrorSuccess)
366 clippingCursor = FALSE;
368 CGEventTapEnable(cursorClippingEventTap, FALSE);
369 [warpRecords removeAllObjects];
374 - (void) clipCursorLocation:(CGPoint*)location
376 clip_cursor_location(cursorClipRect, location);
379 - (void) setRetinaMode:(int)mode
381 scale_rect_for_retina_mode(mode, &cursorClipRect);
387 /* Clipping via mouse confinement rects:
389 * The undocumented -[NSWindow setMouseConfinementRect:] method is almost
390 * perfect for our needs. It has two main drawbacks compared to the CGEventTap
392 * 1. It requires macOS 10.13+
393 * 2. A mouse confinement rect is tied to a region of a particular window. If
394 * an app calls ClipCursor with a rect that is outside the bounds of a
395 * window, the best we can do is intersect that rect with the window's bounds
396 * and clip to the result. If no windows are visible in the app, we can't do
397 * any clipping. Switching between windows in the same app while clipping is
398 * active is likewise impossible.
400 * But it has two major benefits:
401 * 1. The code is far simpler.
402 * 2. CGEventTap started requiring Accessibility permissions from macOS in
403 * Catalina. It's a hassle to enable, and if it's triggered while an app is
404 * fullscreen (which is often the case with clipping), it's easy to miss.
408 @interface NSWindow (UndocumentedMouseConfinement)
409 /* Confines the system's mouse location to the provided window-relative rect
410 * while the app is frontmost and the window is key or a child of the key
411 * window. Confinement rects will be unioned among the key window and its
412 * children. The app should invoke this any time internal window geometry
413 * changes to keep the region up to date. Set NSZeroRect to remove mouse
414 * location confinement.
416 * These have been available since 10.13.
418 - (NSRect) mouseConfinementRect;
419 - (void) setMouseConfinementRect:(NSRect)mouseConfinementRect;
423 @implementation WineConfinementClipCursorHandler
425 @synthesize clippingCursor, cursorClipRect;
429 if ([NSProcessInfo instancesRespondToSelector:@selector(isOperatingSystemAtLeastVersion:)])
431 NSOperatingSystemVersion requiredVersion = { 10, 13, 0 };
432 return [[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:requiredVersion] &&
433 [NSWindow instancesRespondToSelector:@selector(setMouseConfinementRect:)];
439 /* Returns the region of the given rect that intersects with the given
440 * window. The rect should be in screen coordinates. The result will be in
441 * window-relative coordinates.
443 * Returns NSZeroRect if the rect lies entirely outside the window.
445 + (NSRect) rectForScreenRect:(CGRect)rect inWindow:(NSWindow*)window
447 NSRect flippedRect = NSRectFromCGRect(rect);
448 [[WineApplicationController sharedController] flipRect:&flippedRect];
450 NSRect intersection = NSIntersectionRect([window frame], flippedRect);
452 if (NSIsEmptyRect(intersection))
455 return [window convertRectFromScreen:intersection];
458 - (BOOL) startClippingCursor:(CGRect)rect
460 if (clippingCursor && ![self stopClippingCursor])
463 WineWindow *ownerWindow = [[WineApplicationController sharedController] frontWineWindow];
466 /* There's nothing we can do here in this case, since confinement
467 * rects must be tied to a window. */
471 NSRect clipRectInWindowCoords = [WineConfinementClipCursorHandler rectForScreenRect:rect
472 inWindow:ownerWindow];
474 if (NSIsEmptyRect(clipRectInWindowCoords))
476 /* If the clip region is entirely outside of the bounds of the
477 * window, there's again nothing we can do. */
481 [ownerWindow setMouseConfinementRect:clipRectInWindowCoords];
483 clippingWindowNumber = ownerWindow.windowNumber;
484 cursorClipRect = rect;
485 clippingCursor = TRUE;
490 - (BOOL) stopClippingCursor
492 NSWindow *ownerWindow = [NSApp windowWithWindowNumber:clippingWindowNumber];
493 [ownerWindow setMouseConfinementRect:NSZeroRect];
495 clippingCursor = FALSE;
500 - (void) clipCursorLocation:(CGPoint*)location
502 clip_cursor_location(cursorClipRect, location);
505 - (void) setRetinaMode:(int)mode
507 scale_rect_for_retina_mode(mode, &cursorClipRect);