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.
This commit is contained in:
Stéphane Marks 2026-03-12 14:23:51 -04:00 committed by Alan Third
parent e0ca1d8822
commit 52205e38d3
2 changed files with 204 additions and 0 deletions

View file

@ -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,

View file

@ -71,6 +71,12 @@ Updated by Christian Limpach (chris@nice.ch)
#include "macfont.h"
#include <Carbon/Carbon.h>
#include <IOSurface/IOSurface.h>
/* 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 <ApplicationServices/ApplicationServices.h>
#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;