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"
26 #pragma GCC diagnostic ignored "-Wdeclaration-after-statement"
29 /* Neither Quartz nor Cocoa has an exact analog for Win32 cursor clipping.
31 * Historically, we've used a CGEventTap and the
32 * CGAssociateMouseAndMouseCursorPosition function, as implemented in
33 * the WineEventTapClipCursorHandler class.
35 * As of macOS 10.13, there is an undocumented alternative,
36 * -[NSWindow setMouseConfinementRect:]. It comes with its own drawbacks,
37 * but is generally far simpler. It is described and implemented in
38 * the WineConfinementClipCursorHandler class.
40 * On macOS 10.13+, WineConfinementClipCursorHandler is the default.
41 * The Mac driver registry key UseConfinementCursorClipping can be set
42 * to "n" to use the event tap implementation.
46 /* Clipping via CGEventTap and CGAssociateMouseAndMouseCursorPosition:
48 * For one simple case, clipping to a 1x1 rectangle, Quartz does have an
49 * equivalent: CGAssociateMouseAndMouseCursorPosition(false). For the
50 * general case, we leverage that. We disassociate mouse movements from
51 * the cursor position and then move the cursor manually, keeping it within
52 * the clipping rectangle.
54 * Moving the cursor manually isn't enough. We need to modify the event
55 * stream so that the events have the new location, too. We need to do
56 * this at a point before the events enter Cocoa, so that Cocoa will assign
57 * the correct window to the event. So, we install a Quartz event tap to
60 * Also, there's a complication when we move the cursor. We use
61 * CGWarpMouseCursorPosition(). That doesn't generate mouse movement
62 * events, but the change of cursor position is incorporated into the
63 * deltas of the next mouse move event. When the mouse is disassociated
64 * from the cursor position, we need the deltas to only reflect actual
65 * device movement, not programmatic changes. So, the event tap cancels
66 * out the change caused by our calls to CGWarpMouseCursorPosition().
70 @interface WarpRecord : NSObject
72 CGEventTimestamp timeBefore, timeAfter;
76 @property (nonatomic) CGEventTimestamp timeBefore;
77 @property (nonatomic) CGEventTimestamp timeAfter;
78 @property (nonatomic) CGPoint from;
79 @property (nonatomic) CGPoint to;
84 @implementation WarpRecord
86 @synthesize timeBefore, timeAfter, from, to;
91 static void clip_cursor_location(CGRect cursorClipRect, CGPoint *location)
93 if (location->x < CGRectGetMinX(cursorClipRect))
94 location->x = CGRectGetMinX(cursorClipRect);
95 if (location->y < CGRectGetMinY(cursorClipRect))
96 location->y = CGRectGetMinY(cursorClipRect);
97 if (location->x > CGRectGetMaxX(cursorClipRect) - 1)
98 location->x = CGRectGetMaxX(cursorClipRect) - 1;
99 if (location->y > CGRectGetMaxY(cursorClipRect) - 1)
100 location->y = CGRectGetMaxY(cursorClipRect) - 1;
104 static void scale_rect_for_retina_mode(int mode, CGRect *cursorClipRect)
106 double scale = mode ? 0.5 : 2.0;
107 cursorClipRect->origin.x *= scale;
108 cursorClipRect->origin.y *= scale;
109 cursorClipRect->size.width *= scale;
110 cursorClipRect->size.height *= scale;
114 @implementation WineEventTapClipCursorHandler
116 @synthesize clippingCursor, cursorClipRect;
123 warpRecords = [[NSMutableArray alloc] init];
131 [warpRecords release];
135 - (BOOL) warpCursorTo:(CGPoint*)newLocation from:(const CGPoint*)currentLocation
140 oldLocation = *currentLocation;
142 oldLocation = NSPointToCGPoint([[WineApplicationController sharedController] flippedMouseLocation:[NSEvent mouseLocation]]);
144 if (!CGPointEqualToPoint(oldLocation, *newLocation))
146 WarpRecord* warpRecord = [[[WarpRecord alloc] init] autorelease];
149 warpRecord.from = oldLocation;
150 warpRecord.timeBefore = [[NSProcessInfo processInfo] systemUptime] * NSEC_PER_SEC;
152 /* Actually move the cursor. */
153 err = CGWarpMouseCursorPosition(*newLocation);
154 if (err != kCGErrorSuccess)
157 warpRecord.timeAfter = [[NSProcessInfo processInfo] systemUptime] * NSEC_PER_SEC;
158 *newLocation = NSPointToCGPoint([[WineApplicationController sharedController] flippedMouseLocation:[NSEvent mouseLocation]]);
160 if (!CGPointEqualToPoint(oldLocation, *newLocation))
162 warpRecord.to = *newLocation;
163 [warpRecords addObject:warpRecord];
170 - (BOOL) isMouseMoveEventType:(CGEventType)type
174 case kCGEventMouseMoved:
175 case kCGEventLeftMouseDragged:
176 case kCGEventRightMouseDragged:
177 case kCGEventOtherMouseDragged:
184 - (int) warpsFinishedByEventTime:(CGEventTimestamp)eventTime location:(CGPoint)eventLocation
186 int warpsFinished = 0;
187 for (WarpRecord* warpRecord in warpRecords)
189 if (warpRecord.timeAfter < eventTime ||
190 (warpRecord.timeBefore <= eventTime && CGPointEqualToPoint(eventLocation, warpRecord.to)))
196 return warpsFinished;
199 - (CGEventRef) eventTapWithProxy:(CGEventTapProxy)proxy
200 type:(CGEventType)type
201 event:(CGEventRef)event
203 CGEventTimestamp eventTime;
204 CGPoint eventLocation, cursorLocation;
206 if (type == kCGEventTapDisabledByUserInput)
208 if (type == kCGEventTapDisabledByTimeout)
210 CGEventTapEnable(cursorClippingEventTap, TRUE);
217 eventTime = CGEventGetTimestamp(event);
218 lastEventTapEventTime = eventTime / (double)NSEC_PER_SEC;
220 eventLocation = CGEventGetLocation(event);
222 cursorLocation = NSPointToCGPoint([[WineApplicationController sharedController] flippedMouseLocation:[NSEvent mouseLocation]]);
224 if ([self isMouseMoveEventType:type])
226 double deltaX, deltaY;
227 int warpsFinished = [self warpsFinishedByEventTime:eventTime location:eventLocation];
230 deltaX = CGEventGetDoubleValueField(event, kCGMouseEventDeltaX);
231 deltaY = CGEventGetDoubleValueField(event, kCGMouseEventDeltaY);
233 for (i = 0; i < warpsFinished; i++)
235 WarpRecord* warpRecord = warpRecords[0];
236 deltaX -= warpRecord.to.x - warpRecord.from.x;
237 deltaY -= warpRecord.to.y - warpRecord.from.y;
238 [warpRecords removeObjectAtIndex:0];
243 CGEventSetDoubleValueField(event, kCGMouseEventDeltaX, deltaX);
244 CGEventSetDoubleValueField(event, kCGMouseEventDeltaY, deltaY);
247 synthesizedLocation.x += deltaX;
248 synthesizedLocation.y += deltaY;
251 // If the event is destined for another process, don't clip it. This may
252 // happen if the user activates Exposé or Mission Control. In that case,
253 // our app does not resign active status, so clipping is still in effect,
254 // but the cursor should not actually be clipped.
256 // In addition, the fact that mouse moves may have been delivered to a
257 // different process means we have to treat the next one we receive as
258 // absolute rather than relative.
259 if (CGEventGetIntegerValueField(event, kCGEventTargetUnixProcessID) == getpid())
260 [self clipCursorLocation:&synthesizedLocation];
262 [WineApplicationController sharedController].lastSetCursorPositionTime = lastEventTapEventTime;
264 [self warpCursorTo:&synthesizedLocation from:&cursorLocation];
265 if (!CGPointEqualToPoint(eventLocation, synthesizedLocation))
266 CGEventSetLocation(event, synthesizedLocation);
271 CGEventRef WineAppEventTapCallBack(CGEventTapProxy proxy, CGEventType type,
272 CGEventRef event, void *refcon)
274 WineEventTapClipCursorHandler* handler = refcon;
275 return [handler eventTapWithProxy:proxy type:type event:event];
278 - (BOOL) installEventTap
280 CGEventMask mask = CGEventMaskBit(kCGEventLeftMouseDown) |
281 CGEventMaskBit(kCGEventLeftMouseUp) |
282 CGEventMaskBit(kCGEventRightMouseDown) |
283 CGEventMaskBit(kCGEventRightMouseUp) |
284 CGEventMaskBit(kCGEventMouseMoved) |
285 CGEventMaskBit(kCGEventLeftMouseDragged) |
286 CGEventMaskBit(kCGEventRightMouseDragged) |
287 CGEventMaskBit(kCGEventOtherMouseDown) |
288 CGEventMaskBit(kCGEventOtherMouseUp) |
289 CGEventMaskBit(kCGEventOtherMouseDragged) |
290 CGEventMaskBit(kCGEventScrollWheel);
291 CFRunLoopSourceRef source;
293 if (cursorClippingEventTap)
296 // We create an annotated session event tap rather than a process-specific
297 // event tap because we need to programmatically move the cursor even when
298 // mouse moves are directed to other processes. We disable our tap when
299 // other processes are active, but things like Exposé are handled by other
300 // processes even when we remain active.
301 cursorClippingEventTap = CGEventTapCreate(kCGAnnotatedSessionEventTap, kCGHeadInsertEventTap,
302 kCGEventTapOptionDefault, mask, WineAppEventTapCallBack, self);
303 if (!cursorClippingEventTap)
306 CGEventTapEnable(cursorClippingEventTap, FALSE);
308 source = CFMachPortCreateRunLoopSource(NULL, cursorClippingEventTap, 0);
311 CFRelease(cursorClippingEventTap);
312 cursorClippingEventTap = NULL;
316 CFRunLoopAddSource(CFRunLoopGetCurrent(), source, kCFRunLoopCommonModes);
321 - (BOOL) setCursorPosition:(CGPoint)pos
325 [self clipCursorLocation:&pos];
327 ret = [self warpCursorTo:&pos from:NULL];
328 synthesizedLocation = pos;
331 // We want to discard mouse-move events that have already been
332 // through the event tap, because it's too late to account for
333 // the setting of the cursor position with them. However, the
334 // events that may be queued with times after that but before
335 // the above warp can still be used. So, use the last event
336 // tap event time so that -sendEvent: doesn't discard them.
337 [WineApplicationController sharedController].lastSetCursorPositionTime = lastEventTapEventTime;
343 - (BOOL) startClippingCursor:(CGRect)rect
347 if (!cursorClippingEventTap && ![self installEventTap])
350 err = CGAssociateMouseAndMouseCursorPosition(false);
351 if (err != kCGErrorSuccess)
354 clippingCursor = TRUE;
355 cursorClipRect = rect;
357 CGEventTapEnable(cursorClippingEventTap, TRUE);
362 - (BOOL) stopClippingCursor
364 CGError err = CGAssociateMouseAndMouseCursorPosition(true);
365 if (err != kCGErrorSuccess)
368 clippingCursor = FALSE;
370 CGEventTapEnable(cursorClippingEventTap, FALSE);
371 [warpRecords removeAllObjects];
376 - (void) clipCursorLocation:(CGPoint*)location
378 clip_cursor_location(cursorClipRect, location);
381 - (void) setRetinaMode:(int)mode
383 scale_rect_for_retina_mode(mode, &cursorClipRect);
389 /* Clipping via mouse confinement rects:
391 * The undocumented -[NSWindow setMouseConfinementRect:] method is almost
392 * perfect for our needs. It has two main drawbacks compared to the CGEventTap
394 * 1. It requires macOS 10.13+
395 * 2. A mouse confinement rect is tied to a region of a particular window. If
396 * an app calls ClipCursor with a rect that is outside the bounds of a
397 * window, the best we can do is intersect that rect with the window's bounds
398 * and clip to the result. If no windows are visible in the app, we can't do
399 * any clipping. Switching between windows in the same app while clipping is
400 * active is likewise impossible.
402 * But it has two major benefits:
403 * 1. The code is far simpler.
404 * 2. CGEventTap started requiring Accessibility permissions from macOS in
405 * Catalina. It's a hassle to enable, and if it's triggered while an app is
406 * fullscreen (which is often the case with clipping), it's easy to miss.
410 @interface NSWindow (UndocumentedMouseConfinement)
411 /* Confines the system's mouse location to the provided window-relative rect
412 * while the app is frontmost and the window is key or a child of the key
413 * window. Confinement rects will be unioned among the key window and its
414 * children. The app should invoke this any time internal window geometry
415 * changes to keep the region up to date. Set NSZeroRect to remove mouse
416 * location confinement.
418 * These have been available since 10.13.
420 - (NSRect) mouseConfinementRect;
421 - (void) setMouseConfinementRect:(NSRect)mouseConfinementRect;
425 @implementation WineConfinementClipCursorHandler
427 @synthesize clippingCursor, cursorClipRect;
431 if ([NSProcessInfo instancesRespondToSelector:@selector(isOperatingSystemAtLeastVersion:)])
433 NSOperatingSystemVersion requiredVersion = { 10, 13, 0 };
434 return [[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:requiredVersion] &&
435 [NSWindow instancesRespondToSelector:@selector(setMouseConfinementRect:)];
441 /* Returns the region of the given rect that intersects with the given
442 * window. The rect should be in screen coordinates. The result will be in
443 * window-relative coordinates.
445 * Returns NSZeroRect if the rect lies entirely outside the window.
447 + (NSRect) rectForScreenRect:(CGRect)rect inWindow:(NSWindow*)window
449 NSRect flippedRect = NSRectFromCGRect(rect);
450 [[WineApplicationController sharedController] flipRect:&flippedRect];
452 NSRect intersection = NSIntersectionRect([window frame], flippedRect);
454 if (NSIsEmptyRect(intersection))
457 return [window convertRectFromScreen:intersection];
460 - (BOOL) startClippingCursor:(CGRect)rect
462 if (clippingCursor && ![self stopClippingCursor])
465 WineWindow *ownerWindow = [[WineApplicationController sharedController] frontWineWindow];
468 /* There's nothing we can do here in this case, since confinement
469 * rects must be tied to a window. */
473 NSRect clipRectInWindowCoords = [WineConfinementClipCursorHandler rectForScreenRect:rect
474 inWindow:ownerWindow];
476 if (NSIsEmptyRect(clipRectInWindowCoords))
478 /* If the clip region is entirely outside of the bounds of the
479 * window, there's again nothing we can do. */
483 [ownerWindow setMouseConfinementRect:clipRectInWindowCoords];
485 clippingWindowNumber = ownerWindow.windowNumber;
486 cursorClipRect = rect;
487 clippingCursor = TRUE;
492 - (BOOL) stopClippingCursor
494 NSWindow *ownerWindow = [NSApp windowWithWindowNumber:clippingWindowNumber];
495 [ownerWindow setMouseConfinementRect:NSZeroRect];
497 clippingCursor = FALSE;
502 - (void) clipCursorLocation:(CGPoint*)location
504 clip_cursor_location(cursorClipRect, location);
507 - (void) setRetinaMode:(int)mode
509 scale_rect_for_retina_mode(mode, &cursorClipRect);