From 52205e38d3ea670f2685e345591eac43d7a9b613 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Marks?= Date: Thu, 12 Mar 2026 14:23:51 -0400 Subject: [PATCH] Inform macOS Accessibility Zoom of cursor position (bug#80624) Enable cursor focus tracking for visually-impaired users that rely on macOS Zoom screen magnification. * src/nsterm.m: Import ApplicationServices.h. (ns_ua_zoom_enabled_p, ns_cg_rect_flip_y, ns_UAZoomChangeFocus): New static function. Advise UAZoomChangeFocus of potentially new cursor position. (ns_update_end): Call ns_UAZoomChangeFocus. (ns_draw_window_cursor): Cache the cursor position. (applicationDidFinishLaunching): NSLog Accessibility API permissions AXIsProcessTrusted. (windowDidBecomeKey): Schedule a call to ns_UAZoomChangeFocus. (deferred_UAZoomChangeFocus_handler): New view method to call ns_UAZoomChangeFocus. (accessibilityFrame): New view method to help UAZoomChangeFocus. (initFrameFromEmacs): Initialize ns_UAZoom_cursor_rect_new and ns_UAZoom_cursor_rect_old. * etc/NEWS: Announce the change. --- etc/NEWS | 7 ++ src/nsterm.m | 197 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 204 insertions(+) diff --git a/etc/NEWS b/etc/NEWS index af2549b0154..528eb09eff8 100644 --- a/etc/NEWS +++ b/etc/NEWS @@ -4497,6 +4497,13 @@ singleton list. * Changes in Emacs 31.1 on Non-Free Operating Systems +--- +** Support macOS Accessibility Zoom focus tracking. +This is an important change for visually-impaired users. If macOS +Accessibility Zoom is enabled (System Settings, Accessibility, Zoom) +with keyboard focus tracking (Advanced...), Zoom is informed of updated +cursor positions during each redisplay cycle. + --- ** Process execution has been optimized on Android. The run-time performance of subprocesses on recent Android releases, diff --git a/src/nsterm.m b/src/nsterm.m index af38847b344..b16d020ebad 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -71,6 +71,12 @@ Updated by Christian Limpach (chris@nice.ch) #include "macfont.h" #include #include +/* ApplicationServices provides the macOS accessibility Zoom API + UAZoomEnabled and UAZoomChangeFocus (UniversalAccess framework). + Carbon.h already pulls in ApplicationServices on most SDK versions, + but the explicit import makes the dependency visible and guards + against SDK changes. */ +#import #endif static EmacsMenu *dockMenu; @@ -1080,6 +1086,126 @@ static NSRect constrain_frame_rect(NSRect frameRect, bool isFullscreen) [view lockFocus]; } +/* -------------------------------------------------------------------------- + macOS Accessibility Zoom Support + -------------------------------------------------------------------------- */ +#ifdef NS_IMPL_COCOA + +static BOOL ns_is_UAZoomEnabled = NO; +static unsigned long ns_UAZoomEnabled_last_called_time_ns = 0; +static const unsigned long NS_UAZOOMENABLED_CACHE_INTERVAL_NS = + (unsigned long)(500 * NSEC_PER_MSEC); /* 500ms. */ +static NSTimeInterval NS_UAZOOMENABLED_DEFER_INTERVAL_SECS = 0.2; /* 200ms. */ +static NSTimer *ns_deferred_UAZoomChangeFocus_timer = nil; + +static BOOL +ns_ua_zoom_enabled_p (void) +/* -------------------------------------------------------------------------- + Return the cached result of UAZoomEnabled. Refresh the cache every + NS_UAZOOMENABLED_CACHE_INTERVAL_NS nanoseconds. + + We cache the result to avoid the macOS Mach IPC Accessibility Server + round trip cost on every Emacs cursor update. Since enabling Zoom + requires an explicit user UI action that takes real user time, the + cache TTL should be invisible to the user. + + Use clock_gettime_nsec_np not CFAbsoluteTimeGetCurrent which depends + on the wall clock which can be reset by the user or by NTP. + + Main-thread-only and called from ns_update_end, below. + -------------------------------------------------------------------------- */ +{ + /* User-space equivalent to mach_absolute_time. */ + unsigned long now_ns = clock_gettime_nsec_np (CLOCK_UPTIME_RAW); + if (now_ns - ns_UAZoomEnabled_last_called_time_ns + > NS_UAZOOMENABLED_CACHE_INTERVAL_NS) + { + ns_is_UAZoomEnabled = UAZoomEnabled (); + ns_UAZoomEnabled_last_called_time_ns = now_ns; + } + return ns_is_UAZoomEnabled; +} + +static inline CGRect +ns_cg_rect_flip_y (CGRect r) +/* -------------------------------------------------------------------------- + Convert a CGRect from Cocoa screen coordinates (origin at bottom-left + of the primary display) to CoreGraphics coordinates (origin at + top-left of the primary display). CoreGraphics defines its + coordinate origin at the top-left corner of the primary display and + all screens share this global coordinate space, so the flip always + uses the primary display height regardless of which screen R is on. + -------------------------------------------------------------------------- */ +{ + CGDirectDisplayID mainID = CGMainDisplayID (); + if (mainID == kCGNullDirectDisplay) + return r; + CGFloat primaryH = CGDisplayBounds (mainID).size.height; + if (primaryH <= 0) + return r; + r.origin.y = primaryH - r.origin.y - r.size.height; + return r; +} + +/* Cache cursor rects to call UAZoomChangeFocus only when the cursor + position has changed, not merely when the cursor is blinking. + See ns_draw_window_cursor and ns_update_end. */ +static NSRect ns_UAZoom_cursor_rect_new; +static NSRect ns_UAZoom_cursor_rect_old; + +/* Track Zoom state per display cycle. Update the macOS Zoom cursor + position when Zoom transitions to enabled. */ +static BOOL ns_update_was_UAZoomEnabled = NO; + +static void +ns_UAZoomChangeFocus (EmacsView *view, BOOL force) +/* -------------------------------------------------------------------------- + Advise macOS Accessibility Zoom UAZoomChangeFocus of a potentially + new cursor position. Force an updated position when Zoom transitions + to enabled, or when the frame gets focus. + -------------------------------------------------------------------------- */ +{ + if (ns_ua_zoom_enabled_p ()) + { + force = force || !ns_update_was_UAZoomEnabled; + ns_update_was_UAZoomEnabled = YES; + if (NSIsEmptyRect (ns_UAZoom_cursor_rect_new)) + return; + if (force || !NSEqualRects (ns_UAZoom_cursor_rect_new, + ns_UAZoom_cursor_rect_old)) + { + ns_UAZoom_cursor_rect_old = ns_UAZoom_cursor_rect_new; + NSRect windowRect = [view convertRect:ns_UAZoom_cursor_rect_new + toView:nil]; + NSRect screenRect = [[view window] convertRectToScreen:windowRect]; + CGRect cgRect = ns_cg_rect_flip_y (NSRectToCGRect (screenRect)); + /* Some versions of macOS can ignore tiny rects, so we + slightly expand a tiny one. Since we care mostly about its + origin, this should be innocuous. */ + cgRect.size.width = MAX (cgRect.size.width, 6); + cgRect.size.height = MAX (cgRect.size.height, 10); + if (force) + { + /* UAZoomChangeFocus needs old and new cursor positions to + be different, and also it sometimes needs a kick. In + both cases, we fake a cursor move followed by the real + cursor move. */ + CGRect cgRectJiggle = CGRectOffset (cgRect, 1.0, 1.0); + if (UAZoomChangeFocus (&cgRectJiggle, NULL, + kUAZoomFocusTypeInsertionPoint)) + NSLog (@"UAZoomChangeFocus jiggle failed"); + } + if (UAZoomChangeFocus (&cgRect, NULL, + kUAZoomFocusTypeInsertionPoint)) + NSLog (@"UAZoomChangeFocus failed"); + NSAccessibilityPostNotification + (view, NSAccessibilityFocusedUIElementChangedNotification); + } + } + else + ns_update_was_UAZoomEnabled = NO; +} +#endif /* NS_IMPL_COCOA */ static void ns_update_end (struct frame *f) @@ -1102,6 +1228,10 @@ static NSRect constrain_frame_rect(NSRect frameRect, bool isFullscreen) [[view window] flushWindow]; #endif +#ifdef NS_IMPL_COCOA + ns_UAZoomChangeFocus (view, false); +#endif + unblock_input (); ns_updating_frame = NULL; } @@ -3232,6 +3362,16 @@ Note that CURSOR_WIDTH is meaningful only for (h)bar cursors. /* Prevent the cursor from being drawn outside the text area. */ r = NSIntersectionRect (r, ns_row_rect (w, glyph_row, TEXT_AREA)); +#ifdef NS_IMPL_COCOA + /* Cache the cursor rect for macOS Accessibility Zoom integration (see + ns_update_end). Only store the rect for the active cursor --- + inactive windows must not overwrite the value because redisplay may + draw multiple windows per frame and the drawing order is not + guaranteed. */ + if (active_p) + ns_UAZoom_cursor_rect_new = r; +#endif + ns_focus (f, NULL, 0); NSGraphicsContext *ctx = [NSGraphicsContext currentContext]; @@ -6377,6 +6517,14 @@ - (void)applicationDidFinishLaunching: (NSNotification *)notification } #endif +#ifdef NS_IMPL_COCOA + /* Is accessibility enabled for this process/bundle? */ + if (AXIsProcessTrusted()) + NSLog (@"Emacs is macOS AXIsProcessTrusted"); + else + NSLog (@"Emacs is not macOS AXIsProcessTrusted"); +#endif + ns_send_appdefined (-2); } @@ -7288,6 +7436,12 @@ - (NSRect) firstRectForCharacterRange: (NSRange) range return [self firstRectForCharacterRange: range]; } +- (NSRect)accessibilityFrame +{ + EmacsView *view = FRAME_NS_VIEW (emacsframe); + return [[view window] convertRectToScreen: ns_UAZoom_cursor_rect_new]; +} + #endif /* NS_IMPL_COCOA */ /*********************************************************************** @@ -8242,12 +8396,48 @@ - (void)windowDidBecomeKey /* for direct calls */ ns_frame_rehighlight (emacsframe); [self adjustEmacsFrameRect]; +#ifdef NS_IMPL_COCOA + EmacsView *view = FRAME_NS_VIEW (emacsframe); + /* Make sure we have focus and the timer isn't already scheduled. */ + if (self.window.firstResponder == view + && !ns_deferred_UAZoomChangeFocus_timer) + { + /* Calls to ns_UAZoomChangeFocus are synchronous. We defer the + call to give macOS time to finish window compositing or the + calls can be silently ignored by the Zoom daemon and with no + errors reported. This also helps ensure ns_draw_window_cursor + has populated ns_UAZoom_cursor_rect_new. The 200 ms delay was + chosen as a balance between macOS headroom and user + perception. */ + ns_deferred_UAZoomChangeFocus_timer + = [[NSTimer + scheduledTimerWithTimeInterval: + NS_UAZOOMENABLED_DEFER_INTERVAL_SECS + target: self + selector: + @selector (deferred_UAZoomChangeFocus_handler:) + userInfo: 0 + repeats: NO] + retain]; + } +#endif + event.kind = FOCUS_IN_EVENT; XSETFRAME (event.frame_or_window, emacsframe); kbd_buffer_store_event (&event); ns_send_appdefined (-1); // Kick main loop } +#ifdef NS_IMPL_COCOA +- (void)deferred_UAZoomChangeFocus_handler: (NSTimer *)timer +{ + EmacsView *view = FRAME_NS_VIEW (emacsframe); + ns_UAZoomChangeFocus (view, true); + [ns_deferred_UAZoomChangeFocus_timer invalidate]; + [ns_deferred_UAZoomChangeFocus_timer release]; + ns_deferred_UAZoomChangeFocus_timer = nil; +} +#endif - (void)windowDidResignKey: (NSNotification *)notification /* cf. x_detect_focus_change(), x_focus_changed(), x_new_focus_frame() */ @@ -8344,6 +8534,13 @@ - (instancetype) initFrameFromEmacs: (struct frame *)f FRAME_NS_VIEW (f) = self; emacsframe = f; + +#ifdef NS_IMPL_COCOA + /* macOS Accessibility Zoom Support. */ + ns_UAZoom_cursor_rect_new = NSZeroRect; + ns_UAZoom_cursor_rect_old = NSZeroRect; +#endif + #ifdef NS_IMPL_COCOA old_title = 0; maximizing_resize = NO;