Rewrite Zoom a11y patch: NSAccessibility primary, UAZoomChangeFocus supplementary

Key changes:
- Add accessibilityFrame on EmacsView returning cursor screen rect
  (the standard mechanism used by Terminal.app, iTerm2, Firefox)
- Guard UAZoomChangeFocus with bundleIdentifier check (bare binaries
  are silently ignored by the window server)
- Extensive English comments explaining both mechanisms
- Deduplicate legacy parameterized attribute via accessibilityBoundsForRange:

Root cause of Stéphane's failure: UAZoomChangeFocus communicates via the
window server which identifies apps by CFBundleIdentifier. Bare binaries
(src/emacs, symlinks) have no bundle ID and are ignored. NSAccessibility
notifications + accessibilityFrame work for ALL launch methods.
This commit is contained in:
2026-02-25 16:31:34 +01:00
parent 2313fd52c9
commit cd516a4a04

View File

@@ -1,44 +1,16 @@
From cb2c19955d681dd5fa6e865a8ddb553efa5a9ead Mon Sep 17 00:00:00 2001 From c35b1e0804cf0541ed377c3d25ee66760b9eb9da Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Mon, 23 Feb 2026 10:27:44 +0100 Date: Wed, 25 Feb 2026 17:00:55 +0100
Subject: [PATCH] ns: implement UAZoomChangeFocus + NSAccessibility for macOS Subject: [PATCH] ns: implement macOS Zoom cursor tracking via NSAccessibility
Zoom + UAZoomChangeFocus
Add full cursor tracking support for macOS Zoom 'Follow keyboard focus'
and other assistive technology tools.
ROOT CAUSE: NSAccessibility notifications alone are insufficient for
custom-drawn views. macOS Zoom 'Follow keyboard focus' requires an
explicit call to UAZoomChangeFocus() from HIServices/UniversalAccess.h.
This is the same mechanism used by iTerm2 (PTYTextView.m:refreshAccessibility)
and Chromium (render_widget_host_view_mac.mm:OnSelectionBoundsChanged).
Changes to src/nsterm.h:
- Add lastAccessibilityCursorRect ivar to EmacsView
Changes to src/nsterm.m:
- ns_draw_window_cursor: store cursor rect, post SelectedTextChanged,
call UAZoomChangeFocus() with cursor position in AX screen coordinates
(y-flipped: UAZoomChangeFocus expects origin at top-left of screen)
- windowDidBecomeKey: post FocusedUIElementChangedNotification
- EmacsView: accessibilityIsIgnored=NO, isAccessibilityElement=YES,
accessibilityFocusedUIElement=self, accessibilityRole=TextAreaRole,
accessibilityAttributeNames, accessibilityAttributeValue: for
NSAccessibilitySelectedTextRangeAttribute -> {0,0},
accessibilityBoundsForRange: (new API, macOS 10.10+),
accessibilityAttributeValue:forParameter: (old AXBoundsForRange fallback)
Carbon/Carbon.h (already included) provides UAZoomEnabled(),
UAZoomChangeFocus(), kUAZoomFocusTypeInsertionPoint.
See also: https://github.com/nicowillis/Ghostty/issues/4053
--- ---
src/nsterm.h | 3 + src/nsterm.h | 3 +
src/nsterm.m | 164 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/nsterm.m | 219 +++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 167 insertions(+) 2 files changed, 222 insertions(+)
diff --git a/src/nsterm.h b/src/nsterm.h diff --git a/src/nsterm.h b/src/nsterm.h
index 7c1ee4cf535..6c1ff3434a3 100644 index 7c1ee4c..6c1ff34 100644
--- a/src/nsterm.h --- a/src/nsterm.h
+++ b/src/nsterm.h +++ b/src/nsterm.h
@@ -485,6 +485,9 @@ enum ns_return_frame_mode @@ -485,6 +485,9 @@ enum ns_return_frame_mode
@@ -52,85 +24,135 @@ index 7c1ee4cf535..6c1ff3434a3 100644
/* AppKit-side interface. */ /* AppKit-side interface. */
diff --git a/src/nsterm.m b/src/nsterm.m diff --git a/src/nsterm.m b/src/nsterm.m
index 932d209f56b..a377d70c6fb 100644 index 932d209..335d140 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/src/nsterm.m +++ b/src/nsterm.m
@@ -3232,6 +3232,48 @@ Note that CURSOR_WIDTH is meaningful only for (h)bar cursors. @@ -3232,6 +3232,74 @@ Note that CURSOR_WIDTH is meaningful only for (h)bar cursors.
/* Prevent the cursor from being drawn outside the text area. */ /* Prevent the cursor from being drawn outside the text area. */
r = NSIntersectionRect (r, ns_row_rect (w, glyph_row, TEXT_AREA)); r = NSIntersectionRect (r, ns_row_rect (w, glyph_row, TEXT_AREA));
+#ifdef NS_IMPL_COCOA +#ifdef NS_IMPL_COCOA
+ /* Update accessibility cursor tracking for macOS Zoom and VoiceOver. + /* Accessibility cursor tracking for macOS Zoom and VoiceOver.
+ NSAccessibility notifications alone are NOT sufficient for custom-drawn +
+ views -- macOS Zoom "Follow keyboard focus" requires an explicit call to + Emacs is a custom-drawn view: AppKit does not know where the text
+ UAZoomChangeFocus() from HIServices/UniversalAccess.h, which directly + cursor is, so we must explicitly notify assistive technology.
+ tells Zoom the cursor's screen position. This is the same mechanism used +
+ by iTerm2 (PTYTextView.m:refreshAccessibility) and Chromium. */ + Two mechanisms are used:
+
+ 1. NSAccessibility notifications (PRIMARY, always active):
+ Post NSAccessibilitySelectedTextChangedNotification so that
+ macOS Zoom (and VoiceOver) queries the focused element's
+ accessibilityFrame / accessibilityBoundsForRange: to find
+ the cursor position. This is how Terminal.app, iTerm2,
+ and Firefox implement Zoom cursor tracking. Works regardless
+ of how Emacs is launched (app bundle, src/emacs, etc.).
+
+ 2. UAZoomChangeFocus (SUPPLEMENTARY, bundle-only):
+ Directly tells Zoom the cursor's screen coordinates via
+ HIServices/UniversalAccess.h. This only works when Emacs
+ runs from a proper .app bundle -- the window server identifies
+ callers by CFBundleIdentifier, and bare binaries (src/emacs)
+ are silently ignored even when AXIsProcessTrusted() returns
+ true. We detect this by checking [[NSBundle mainBundle]
+ bundleIdentifier] and skip the call when nil. */
+ { + {
+ EmacsView *view = FRAME_NS_VIEW (f); + EmacsView *view = FRAME_NS_VIEW (f);
+ if (view) + if (view)
+ { + {
+ /* Store cursor rect for accessibilityFrame and
+ accessibilityBoundsForRange: queries. */
+ view->lastAccessibilityCursorRect = r; + view->lastAccessibilityCursorRect = r;
+ +
+ /* Post standard AX notification for VoiceOver and other AT tools. */ + /* Mechanism 1: Standard NSAccessibility notification.
+ Zoom monitors this and reads accessibilityFrame from the
+ focused element (EmacsView) to determine cursor position. */
+ NSAccessibilityPostNotification (view, + NSAccessibilityPostNotification (view,
+ NSAccessibilitySelectedTextChangedNotification); + NSAccessibilitySelectedTextChangedNotification);
+ +
+ /* Tell macOS Zoom exactly where the cursor is. UAZoomChangeFocus() + /* Mechanism 2: UAZoomChangeFocus -- direct push to Zoom.
+ expects coordinates in the NSAccessibility coordinate system (origin + Only effective from app bundles (window server ignores bare
+ at top-left of primary screen). Convert: view → window → screen + binaries). UAZoomChangeFocus expects coordinates with origin
+ (Quartz, origin bottom-left) → AX (origin top-left) via + at the top-left of the primary screen. convertRectToScreen:
+ accessibilityConvertScreenRect:. */ + returns Quartz coordinates (origin bottom-left), so we flip
+ if (UAZoomEnabled ()) + the y axis manually. */
+ if (UAZoomEnabled ()
+ && [[NSBundle mainBundle] bundleIdentifier] != nil)
+ { + {
+ NSRect windowRect = [view convertRect:r toView:nil]; + NSRect windowRect = [view convertRect:r toView:nil];
+ NSRect screenRect = [[view window] convertRectToScreen:windowRect]; + NSRect screenRect = [[view window] convertRectToScreen:windowRect];
+ CGRect cgRect = NSRectToCGRect (screenRect); + CGRect cgRect = NSRectToCGRect (screenRect);
+ +
+ /* UAZoomChangeFocus expects coordinates with origin at the + /* Flip y: Quartz (bottom-left origin) -> accessibility
+ top-left of the primary screen (NSAccessibility coordinate + coordinate space (top-left origin). Uses primary screen
+ space). [window convertRectToScreen:] returns Quartz screen + height as reference -- correct for all screens in the
+ coordinates with origin at the bottom-left of the primary + global Quartz coordinate space. */
+ screen, so we flip the y axis manually. */ + CGFloat primaryH
+ CGFloat primaryH = [[[NSScreen screens] firstObject] frame].size.height; + = [[[NSScreen screens] firstObject] frame].size.height;
+ cgRect.origin.y = primaryH - cgRect.origin.y - cgRect.size.height; + cgRect.origin.y
+ = primaryH - cgRect.origin.y - cgRect.size.height;
+ +
+ UAZoomChangeFocus (&cgRect, &cgRect, kUAZoomFocusTypeInsertionPoint); + UAZoomChangeFocus (&cgRect, &cgRect,
+ kUAZoomFocusTypeInsertionPoint);
+ } + }
+ } + }
+ } + }
+#endif +#endif
+
+ +
ns_focus (f, NULL, 0); ns_focus (f, NULL, 0);
NSGraphicsContext *ctx = [NSGraphicsContext currentContext]; NSGraphicsContext *ctx = [NSGraphicsContext currentContext];
@@ -8237,6 +8279,14 @@ - (void)windowDidBecomeKey /* for direct calls */ @@ -8237,6 +8305,17 @@ - (void)windowDidBecomeKey /* for direct calls */
XSETFRAME (event.frame_or_window, emacsframe); XSETFRAME (event.frame_or_window, emacsframe);
kbd_buffer_store_event (&event); kbd_buffer_store_event (&event);
ns_send_appdefined (-1); // Kick main loop ns_send_appdefined (-1); // Kick main loop
+ +
+#ifdef NS_IMPL_COCOA +#ifdef NS_IMPL_COCOA
+ /* Notify accessibility clients (e.g. macOS Zoom) that the focused + /* Notify assistive technology that the focused UI element changed
+ UI element changed to this Emacs view. Zoom uses this to activate + to this Emacs view. macOS Zoom uses this notification to:
+ keyboard focus tracking when the window gains focus. */ + (a) activate keyboard focus tracking for the newly focused window,
+ (b) query accessibilityFrame on the focused element to position
+ the Zoom viewport.
+ This works for all launch methods (bundle and non-bundle). */
+ NSAccessibilityPostNotification (self, + NSAccessibilityPostNotification (self,
+ NSAccessibilityFocusedUIElementChangedNotification); + NSAccessibilityFocusedUIElementChangedNotification);
+#endif +#endif
} }
@@ -9474,6 +9524,120 @@ - (int) fullscreenState @@ -9474,6 +9553,146 @@ - (int) fullscreenState
return fs_state; return fs_state;
} }
+
+#ifdef NS_IMPL_COCOA +#ifdef NS_IMPL_COCOA
+/* Accessibility support for macOS Zoom and other assistive tools. +/* ----------------------------------------------------------------
+ Implements both old (AXBoundsForRange parameterized attribute) and + Accessibility support for macOS Zoom and other assistive tools.
+ new (accessibilityBoundsForRange:) APIs so cursor tracking works +
+ on all macOS versions. */ + This implements the NSAccessibility protocol on EmacsView so that
+ macOS Zoom's "Follow keyboard focus" can track the text cursor.
+
+ How it works:
+ - EmacsView declares itself as a TextArea role (the standard role
+ for editable text regions).
+ - When the cursor moves, ns_draw_window_cursor posts
+ NSAccessibilitySelectedTextChangedNotification.
+ - Zoom (and VoiceOver) respond by querying accessibilityFrame and/or
+ accessibilityBoundsForRange: on the focused element (this view).
+ - We return the stored cursor rectangle in screen coordinates.
+
+ Both the modern protocol API (accessibilityBoundsForRange:, 10.10+)
+ and the legacy parameterized attribute API (AXBoundsForRange) are
+ implemented for compatibility with all macOS versions and AT tools.
+
+ Note: upstream EmacsWindow has separate accessibility code that
+ returns buffer text for VoiceOver. That code operates on the window,
+ not the view, so there is no conflict.
+ ---------------------------------------------------------------- */
+ +
+- (BOOL)accessibilityIsIgnored +- (BOOL)accessibilityIsIgnored
+{ +{
+ /* EmacsView must participate in the accessibility hierarchy. */
+ return NO; + return NO;
+} +}
+ +
@@ -141,6 +163,7 @@ index 932d209f56b..a377d70c6fb 100644
+ +
+- (id)accessibilityFocusedUIElement +- (id)accessibilityFocusedUIElement
+{ +{
+ /* This view is the focused element -- it contains the text cursor. */
+ return self; + return self;
+} +}
+ +
@@ -149,6 +172,27 @@ index 932d209f56b..a377d70c6fb 100644
+ return NSAccessibilityTextAreaRole; + return NSAccessibilityTextAreaRole;
+} +}
+ +
+- (NSRect)accessibilityFrame
+{
+ /* Return the cursor's screen coordinates. This is the KEY method
+ that macOS Zoom reads after receiving a focus/selection notification.
+ Terminal.app and iTerm2 use this same pattern.
+
+ lastAccessibilityCursorRect is in EmacsView coordinates (flipped:
+ origin top-left). convertRect:toView:nil automatically handles
+ the flipped-to-unflipped conversion because isFlipped returns YES. */
+ NSRect viewRect = lastAccessibilityCursorRect;
+ if (NSIsEmptyRect (viewRect))
+ return [super accessibilityFrame];
+
+ NSWindow *win = [self window];
+ if (win == nil)
+ return [super accessibilityFrame];
+
+ NSRect windowRect = [self convertRect:viewRect toView:nil];
+ return [win convertRectToScreen:windowRect];
+}
+
+- (NSArray *)accessibilityAttributeNames +- (NSArray *)accessibilityAttributeNames
+{ +{
+ NSArray *superAttrs = [super accessibilityAttributeNames]; + NSArray *superAttrs = [super accessibilityAttributeNames];
@@ -165,10 +209,9 @@ index 932d209f56b..a377d70c6fb 100644
+ if ([attribute isEqualToString:NSAccessibilityRoleAttribute]) + if ([attribute isEqualToString:NSAccessibilityRoleAttribute])
+ return NSAccessibilityTextAreaRole; + return NSAccessibilityTextAreaRole;
+ +
+ /* macOS Zoom queries NSAccessibilitySelectedTextRangeAttribute before + /* Zoom queries SelectedTextRange before calling BoundsForRange.
+ calling AXBoundsForRange / accessibilityBoundsForRange:. We return + We return {0,0} (collapsed caret); our bounds methods ignore
+ {0,0}; our bounds methods ignore the range and always return the + the range parameter and always return the actual cursor rect. */
+ actual cursor rect, so any range value here works. */
+ if ([attribute isEqualToString:NSAccessibilitySelectedTextRangeAttribute]) + if ([attribute isEqualToString:NSAccessibilitySelectedTextRangeAttribute])
+ return [NSValue valueWithRange:NSMakeRange (0, 0)]; + return [NSValue valueWithRange:NSMakeRange (0, 0)];
+ +
@@ -178,9 +221,12 @@ index 932d209f56b..a377d70c6fb 100644
+ return [super accessibilityAttributeValue:attribute]; + return [super accessibilityAttributeValue:attribute];
+} +}
+ +
+/* New NSAccessibilityProtocol (macOS 10.10+) — preferred by Zoom. */ +/* Modern NSAccessibilityProtocol (macOS 10.10+). */
+- (NSRect)accessibilityBoundsForRange:(NSRange)range +- (NSRect)accessibilityBoundsForRange:(NSRange)range
+{ +{
+ /* Return cursor screen rect regardless of requested range.
+ Emacs does not expose a character-level text model to AppKit,
+ so we always return the cursor position. */
+ NSRect viewRect = lastAccessibilityCursorRect; + NSRect viewRect = lastAccessibilityCursorRect;
+ +
+ if (viewRect.size.width < 1) + if (viewRect.size.width < 1)
@@ -196,7 +242,7 @@ index 932d209f56b..a377d70c6fb 100644
+ return [win convertRectToScreen:windowRect]; + return [win convertRectToScreen:windowRect];
+} +}
+ +
+/* Old parameterized attribute API fallback for older tools. */ +/* Legacy parameterized attribute API -- fallback for older AT tools. */
+- (NSArray *)accessibilityParameterizedAttributeNames +- (NSArray *)accessibilityParameterizedAttributeNames
+{ +{
+ NSArray *superAttrs = [super accessibilityParameterizedAttributeNames]; + NSArray *superAttrs = [super accessibilityParameterizedAttributeNames];
@@ -211,28 +257,9 @@ index 932d209f56b..a377d70c6fb 100644
+{ +{
+ if ([attribute isEqualToString: + if ([attribute isEqualToString:
+ NSAccessibilityBoundsForRangeParameterizedAttribute]) + NSAccessibilityBoundsForRangeParameterizedAttribute])
+ { + return [NSValue valueWithRect:
+ NSRect viewRect = lastAccessibilityCursorRect; + [self accessibilityBoundsForRange:NSMakeRange (0, 0)]];
+ +
+ /* lastAccessibilityCursorRect is in EmacsView coordinates
+ (flipped: y=0 at top). convertRect:toView:nil handles
+ the flipped-to-unflipped conversion automatically for
+ flipped views, so no manual flip is needed. */
+
+ if (viewRect.size.width < 1)
+ viewRect.size.width = 1;
+ if (viewRect.size.height < 1)
+ viewRect.size.height = 8;
+
+ NSWindow *win = [self window];
+ if (win == nil)
+ return [NSValue valueWithRect:NSZeroRect];
+
+ NSRect windowRect = [self convertRect:viewRect toView:nil];
+ NSRect screenRect = [win convertRectToScreen:windowRect];
+
+ return [NSValue valueWithRect:screenRect];
+ }
+ return [super accessibilityAttributeValue:attribute forParameter:parameter]; + return [super accessibilityAttributeValue:attribute forParameter:parameter];
+} +}
+#endif /* NS_IMPL_COCOA */ +#endif /* NS_IMPL_COCOA */