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 a234ded3db

View File

@@ -1,44 +1,34 @@
From cb2c19955d681dd5fa6e865a8ddb553efa5a9ead Mon Sep 17 00:00:00 2001
From 33b2603ba61eb0e42286a5805d7acf5791ff6384 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Mon, 23 Feb 2026 10:27:44 +0100
Subject: [PATCH] ns: implement UAZoomChangeFocus + NSAccessibility for macOS
Zoom
Date: Wed, 25 Feb 2026 17:02:36 +0100
Subject: [PATCH] ns: implement macOS Zoom cursor tracking via NSAccessibility
+ UAZoomChangeFocus
Add full cursor tracking support for macOS Zoom 'Follow keyboard focus'
and other assistive technology tools.
Add cursor tracking support for macOS Zoom 'Follow keyboard focus' and
other assistive technology tools (VoiceOver, etc.).
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).
Two mechanisms are implemented:
Changes to src/nsterm.h:
- Add lastAccessibilityCursorRect ivar to EmacsView
1. NSAccessibility (primary): EmacsView reports as TextArea, exposes
accessibilityFrame returning cursor screen coordinates, and posts
SelectedTextChanged/FocusedUIElementChanged notifications. This is
the standard approach used by Terminal.app, iTerm2, and Firefox.
Works regardless of how Emacs is launched (app bundle or src/emacs).
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.
2. UAZoomChangeFocus (supplementary): Direct push to Zoom via
HIServices/UniversalAccess.h. Only called when running from a
proper .app bundle (detected via bundleIdentifier check), because
the window server identifies callers by CFBundleIdentifier and
silently ignores bare binaries.
See also: https://github.com/nicowillis/Ghostty/issues/4053
---
src/nsterm.h | 3 +
src/nsterm.m | 164 +++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 167 insertions(+)
src/nsterm.m | 219 +++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 222 insertions(+)
diff --git a/src/nsterm.h b/src/nsterm.h
index 7c1ee4cf535..6c1ff3434a3 100644
index 7c1ee4c..6c1ff34 100644
--- a/src/nsterm.h
+++ b/src/nsterm.h
@@ -485,6 +485,9 @@ enum ns_return_frame_mode
@@ -52,85 +42,135 @@ index 7c1ee4cf535..6c1ff3434a3 100644
/* AppKit-side interface. */
diff --git a/src/nsterm.m b/src/nsterm.m
index 932d209f56b..a377d70c6fb 100644
index 932d209..335d140 100644
--- a/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. */
r = NSIntersectionRect (r, ns_row_rect (w, glyph_row, TEXT_AREA));
+#ifdef NS_IMPL_COCOA
+ /* Update 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
+ UAZoomChangeFocus() from HIServices/UniversalAccess.h, which directly
+ tells Zoom the cursor's screen position. This is the same mechanism used
+ by iTerm2 (PTYTextView.m:refreshAccessibility) and Chromium. */
+ /* Accessibility cursor tracking for macOS Zoom and VoiceOver.
+
+ Emacs is a custom-drawn view: AppKit does not know where the text
+ cursor is, so we must explicitly notify assistive technology.
+
+ 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);
+ if (view)
+ {
+ /* Store cursor rect for accessibilityFrame and
+ accessibilityBoundsForRange: queries. */
+ 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,
+ NSAccessibilitySelectedTextChangedNotification);
+
+ /* Tell macOS Zoom exactly where the cursor is. UAZoomChangeFocus()
+ expects coordinates in the NSAccessibility coordinate system (origin
+ at top-left of primary screen). Convert: view → window → screen
+ (Quartz, origin bottom-left) → AX (origin top-left) via
+ accessibilityConvertScreenRect:. */
+ if (UAZoomEnabled ())
+ /* Mechanism 2: UAZoomChangeFocus -- direct push to Zoom.
+ Only effective from app bundles (window server ignores bare
+ binaries). UAZoomChangeFocus expects coordinates with origin
+ at the top-left of the primary screen. convertRectToScreen:
+ returns Quartz coordinates (origin bottom-left), so we flip
+ the y axis manually. */
+ if (UAZoomEnabled ()
+ && [[NSBundle mainBundle] bundleIdentifier] != nil)
+ {
+ NSRect windowRect = [view convertRect:r toView:nil];
+ NSRect screenRect = [[view window] convertRectToScreen:windowRect];
+ CGRect cgRect = NSRectToCGRect (screenRect);
+
+ /* UAZoomChangeFocus expects coordinates with origin at the
+ top-left of the primary screen (NSAccessibility coordinate
+ space). [window convertRectToScreen:] returns Quartz screen
+ coordinates with origin at the bottom-left of the primary
+ screen, so we flip the y axis manually. */
+ CGFloat primaryH = [[[NSScreen screens] firstObject] frame].size.height;
+ cgRect.origin.y = primaryH - cgRect.origin.y - cgRect.size.height;
+ /* Flip y: Quartz (bottom-left origin) -> accessibility
+ coordinate space (top-left origin). Uses primary screen
+ height as reference -- correct for all screens in the
+ global Quartz coordinate space. */
+ CGFloat primaryH
+ = [[[NSScreen screens] firstObject] frame].size.height;
+ cgRect.origin.y
+ = primaryH - cgRect.origin.y - cgRect.size.height;
+
+ UAZoomChangeFocus (&cgRect, &cgRect, kUAZoomFocusTypeInsertionPoint);
+ UAZoomChangeFocus (&cgRect, &cgRect,
+ kUAZoomFocusTypeInsertionPoint);
+ }
+ }
+ }
+#endif
+
+
ns_focus (f, NULL, 0);
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);
kbd_buffer_store_event (&event);
ns_send_appdefined (-1); // Kick main loop
+
+#ifdef NS_IMPL_COCOA
+ /* Notify accessibility clients (e.g. macOS Zoom) that the focused
+ UI element changed to this Emacs view. Zoom uses this to activate
+ keyboard focus tracking when the window gains focus. */
+ /* Notify assistive technology that the focused UI element changed
+ to this Emacs view. macOS Zoom uses this notification to:
+ (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,
+ NSAccessibilityFocusedUIElementChangedNotification);
+#endif
}
@@ -9474,6 +9524,120 @@ - (int) fullscreenState
@@ -9474,6 +9553,146 @@ - (int) fullscreenState
return fs_state;
}
+
+#ifdef NS_IMPL_COCOA
+/* Accessibility support for macOS Zoom and other assistive tools.
+ Implements both old (AXBoundsForRange parameterized attribute) and
+ new (accessibilityBoundsForRange:) APIs so cursor tracking works
+ on all macOS versions. */
+/* ----------------------------------------------------------------
+ Accessibility support for macOS Zoom and other assistive tools.
+
+ 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
+{
+ /* EmacsView must participate in the accessibility hierarchy. */
+ return NO;
+}
+
@@ -141,6 +181,7 @@ index 932d209f56b..a377d70c6fb 100644
+
+- (id)accessibilityFocusedUIElement
+{
+ /* This view is the focused element -- it contains the text cursor. */
+ return self;
+}
+
@@ -149,6 +190,27 @@ index 932d209f56b..a377d70c6fb 100644
+ 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 *superAttrs = [super accessibilityAttributeNames];
@@ -165,10 +227,9 @@ index 932d209f56b..a377d70c6fb 100644
+ if ([attribute isEqualToString:NSAccessibilityRoleAttribute])
+ return NSAccessibilityTextAreaRole;
+
+ /* macOS Zoom queries NSAccessibilitySelectedTextRangeAttribute before
+ calling AXBoundsForRange / accessibilityBoundsForRange:. We return
+ {0,0}; our bounds methods ignore the range and always return the
+ actual cursor rect, so any range value here works. */
+ /* Zoom queries SelectedTextRange before calling BoundsForRange.
+ We return {0,0} (collapsed caret); our bounds methods ignore
+ the range parameter and always return the actual cursor rect. */
+ if ([attribute isEqualToString:NSAccessibilitySelectedTextRangeAttribute])
+ return [NSValue valueWithRange:NSMakeRange (0, 0)];
+
@@ -178,9 +239,12 @@ index 932d209f56b..a377d70c6fb 100644
+ return [super accessibilityAttributeValue:attribute];
+}
+
+/* New NSAccessibilityProtocol (macOS 10.10+) — preferred by Zoom. */
+/* Modern NSAccessibilityProtocol (macOS 10.10+). */
+- (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;
+
+ if (viewRect.size.width < 1)
@@ -196,7 +260,7 @@ index 932d209f56b..a377d70c6fb 100644
+ return [win convertRectToScreen:windowRect];
+}
+
+/* Old parameterized attribute API fallback for older tools. */
+/* Legacy parameterized attribute API -- fallback for older AT tools. */
+- (NSArray *)accessibilityParameterizedAttributeNames
+{
+ NSArray *superAttrs = [super accessibilityParameterizedAttributeNames];
@@ -211,28 +275,9 @@ index 932d209f56b..a377d70c6fb 100644
+{
+ if ([attribute isEqualToString:
+ NSAccessibilityBoundsForRangeParameterizedAttribute])
+ {
+ NSRect viewRect = lastAccessibilityCursorRect;
+ return [NSValue valueWithRect:
+ [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];
+}
+#endif /* NS_IMPL_COCOA */