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> From: Martin Sukany <martin@sukany.cz>
Date: Mon, 23 Feb 2026 10:27:44 +0100 Date: Wed, 25 Feb 2026 17:02:36 +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' Add cursor tracking support for macOS Zoom 'Follow keyboard focus' and
and other assistive technology tools. other assistive technology tools (VoiceOver, etc.).
ROOT CAUSE: NSAccessibility notifications alone are insufficient for Two mechanisms are implemented:
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: 1. NSAccessibility (primary): EmacsView reports as TextArea, exposes
- Add lastAccessibilityCursorRect ivar to EmacsView 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: 2. UAZoomChangeFocus (supplementary): Direct push to Zoom via
- ns_draw_window_cursor: store cursor rect, post SelectedTextChanged, HIServices/UniversalAccess.h. Only called when running from a
call UAZoomChangeFocus() with cursor position in AX screen coordinates proper .app bundle (detected via bundleIdentifier check), because
(y-flipped: UAZoomChangeFocus expects origin at top-left of screen) the window server identifies callers by CFBundleIdentifier and
- windowDidBecomeKey: post FocusedUIElementChangedNotification silently ignores bare binaries.
- 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 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 +42,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 +181,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 +190,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 +227,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 +239,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 +260,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 +275,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 */