v4 patch: dual mechanism with Apple doc references

UAZoomChangeFocus (documented Apple API) + NSAccessibility.
References to official Apple documentation in comments.
Pattern matches iTerm2 implementation.
This commit is contained in:
2026-02-25 18:11:05 +01:00
parent f3b8ff661c
commit 6933d8f9c0

View File

@@ -1,66 +1,95 @@
From fe9a8b85d064d68ce5676dcf5532583d2de82dd3 Mon Sep 17 00:00:00 2001
From 8d439114e6faf50c053eac290ae0e60176dfb3bf Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Wed, 25 Feb 2026 17:45:43 +0100
Subject: [PATCH] ns: implement macOS Zoom cursor tracking via NSAccessibility
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Date: Wed, 25 Feb 2026 18:10:58 +0100
Subject: [PATCH] ns: implement macOS Zoom cursor tracking + NSAccessibility
support
Add cursor tracking support for macOS Zoom "Follow keyboard focus" and
other assistive technology (VoiceOver, etc.).
Add cursor tracking for macOS Zoom 'Follow keyboard focus' and
full NSAccessibility support for VoiceOver and other AT tools.
EmacsView now implements the NSAccessibility protocol: it reports itself
as a TextArea role, exposes accessibilityFrame returning the cursor's
screen coordinates, and posts SelectedTextChanged and
FocusedUIElementChanged notifications on cursor movement and window
focus changes. This is the standard approach used by Terminal.app,
iTerm2, and Firefox — macOS Zoom monitors these notifications and
queries accessibilityFrame on the focused element to position its
viewport.
Two complementary mechanisms are used:
Both the modern protocol API (accessibilityBoundsForRange:, 10.10+) and
the legacy parameterized attribute API (AXBoundsForRange) are implemented
for compatibility with all AT tools.
1. UAZoomChangeFocus() from ApplicationServices/UniversalAccess.h:
Directly tells macOS Zoom where to position its viewport. This
is Apple's documented API for applications to control Zoom focus.
Same approach used by iTerm2 (PTYTextView.m:refreshAccessibility).
Ref: developer.apple.com/documentation/applicationservices/universalaccess_h
Ref: developer.apple.com/documentation/applicationservices/1458830-uazoomchangefocus
2. NSAccessibility protocol on EmacsView: reports as TextArea role,
exposes accessibilityFrame / accessibilityBoundsForRange: returning
cursor screen coordinates, and posts SelectedTextChanged and
FocusedUIElementChanged notifications. Serves VoiceOver and AT
tools that query the accessibility tree directly.
Ref: developer.apple.com/documentation/appkit/nsaccessibilityprotocol
Both mechanisms are needed: UAZoomChangeFocus serves Zoom's Follow
keyboard focus (which does not reliably track custom views through
NSAccessibility alone); NSAccessibility serves VoiceOver and other
screen readers.
---
src/nsterm.h | 3 +
src/nsterm.m | 180 +++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 183 insertions(+)
src/nsterm.h | 6 ++
src/nsterm.m | 286 +++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 292 insertions(+)
diff --git a/src/nsterm.h b/src/nsterm.h
index 7c1ee4c..6c1ff34 100644
index 7c1ee4c..664bce3 100644
--- a/src/nsterm.h
+++ b/src/nsterm.h
@@ -485,6 +485,9 @@ enum ns_return_frame_mode
@@ -485,6 +485,12 @@ enum ns_return_frame_mode
struct frame *emacsframe;
int scrollbarsNeedingUpdate;
NSRect ns_userRect;
+#ifdef NS_IMPL_COCOA
+ NSRect lastAccessibilityCursorRect;
+#endif
+#ifdef NS_IMPL_COCOA
+ NSRect lastAccessibilityCursorRect;
+#endif
}
/* AppKit-side interface. */
diff --git a/src/nsterm.m b/src/nsterm.m
index 932d209..9b73f65 100644
index 932d209..96b9d65 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -3232,6 +3232,35 @@ Note that CURSOR_WIDTH is meaningful only for (h)bar cursors.
@@ -3232,6 +3232,131 @@ 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
+ /* 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.
+ Emacs uses a custom-drawn cursor in a custom NSView. AppKit has no
+ knowledge of cursor position, so we must explicitly notify assistive
+ technology. Two complementary mechanisms are used:
+
+ When the cursor moves, we store the cursor rectangle and post
+ NSAccessibilitySelectedTextChangedNotification. macOS Zoom (and
+ VoiceOver) respond by querying accessibilityFrame on the focused
+ element (EmacsView) to find the cursor position. This is the
+ standard approach used by Terminal.app, iTerm2, and Firefox.
+ It works regardless of how Emacs is launched (app bundle or
+ src/emacs binary). */
+ 1. NSAccessibility notifications:
+ Post NSAccessibilitySelectedTextChangedNotification so that
+ VoiceOver and other AT tools can query cursor position via
+ accessibilityBoundsForRange: / accessibilityFrame.
+
+ 2. UAZoomChangeFocus() from ApplicationServices/UniversalAccess.h:
+ Directly tells macOS Zoom where to move its viewport. This is
+ the documented API for applications to control Zoom focus:
+
+ "This header file contains functions that give applications the
+ ability to control the zoom focus. Using these functions, an
+ application can tell the macOS Universal Access zoom feature
+ what part of its user interface needs focus."
+
+ Ref: https://developer.apple.com/documentation/applicationservices/universalaccess_h
+ Ref: https://developer.apple.com/documentation/applicationservices/1458830-uazoomchangefocus
+
+ This is the same approach used by iTerm2 (PTYTextView.m,
+ method refreshAccessibility).
+
+ Both mechanisms are needed: NSAccessibility serves VoiceOver and
+ screen readers; UAZoomChangeFocus serves macOS Zoom's "Follow
+ keyboard focus" feature, which does not reliably track custom views
+ through NSAccessibility notifications alone. */
+ {
+ EmacsView *view = FRAME_NS_VIEW (f);
+ if (view)
@@ -69,62 +98,148 @@ index 932d209..9b73f65 100644
+ accessibilityBoundsForRange: queries. */
+ view->lastAccessibilityCursorRect = r;
+
+ /* Post SelectedTextChanged so Zoom and other AT tools query
+ accessibilityFrame on the focused element (this view). */
+ /* Post NSAccessibility notification for VoiceOver and other
+ AT tools. */
+ NSAccessibilityPostNotification (view,
+ NSAccessibilitySelectedTextChangedNotification);
+
+ /* Tell macOS Zoom where the cursor is. UAZoomChangeFocus()
+ expects coordinates with origin at the top-left of the
+ primary screen (CG accessibility coordinate space).
+ convertRectToScreen: returns Quartz coordinates (origin at
+ bottom-left), so we flip the y axis. This coordinate
+ conversion follows the same pattern used by iTerm2's
+ accessibilityConvertScreenRect: method. */
+ if (UAZoomEnabled ())
+ {
+ NSRect windowRect = [view convertRect:r toView:nil];
+ NSRect screenRect = [[view window] convertRectToScreen:windowRect];
+ CGRect cgRect = NSRectToCGRect (screenRect);
+
+ CGFloat primaryH
+ = [[[NSScreen screens] firstObject] frame].size.height;
+ cgRect.origin.y
+ = primaryH - cgRect.origin.y - cgRect.size.height;
+
+ UAZoomChangeFocus (&cgRect, &cgRect,
+ kUAZoomFocusTypeInsertionPoint);
+ }
+ }
+ }
+#endif
+
+
+#ifdef NS_IMPL_COCOA
+ /* Accessibility: update cursor tracking for macOS Zoom and VoiceOver.
+
+ Emacs is a custom-drawn view. AppKit has no knowledge of where the
+ text cursor is, so we must explicitly notify assistive technology.
+
+ Two complementary mechanisms are used, matching the pattern used by
+ iTerm2 (PTYTextView.m:refreshAccessibility):
+
+ 1. NSAccessibility notifications -- inform AT clients (VoiceOver,
+ Zoom, Switch Control) that the selection/cursor changed.
+ See: Apple NSAccessibility Protocol Reference
+ https://developer.apple.com/documentation/appkit/nsaccessibilityprotocol
+
+ 2. UAZoomChangeFocus() -- explicitly tell macOS Zoom where to move
+ its viewport. This is the Apple-documented mechanism for custom
+ views to control the Zoom focus. NSAccessibility notifications
+ alone are NOT sufficient for Zoom cursor tracking in custom views.
+ See: Apple UniversalAccess.h Reference
+ https://developer.apple.com/documentation/applicationservices/universalaccess_h
+ https://developer.apple.com/documentation/applicationservices/1458830-uazoomchangefocus */
+ {
+ EmacsView *view = FRAME_NS_VIEW (f);
+ if (view)
+ {
+ view->lastAccessibilityCursorRect = r;
+
+ /* Post NSAccessibility notifications for VoiceOver and other AT. */
+ NSAccessibilityPostNotification (view,
+ NSAccessibilitySelectedTextChangedNotification);
+
+ /* Tell macOS Zoom where the cursor is. UAZoomChangeFocus expects
+ screen coordinates with origin at top-left of the primary screen
+ (accessibility coordinate space). We convert from Quartz screen
+ coordinates (origin bottom-left) by flipping the y axis, matching
+ the pattern used by iTerm2's accessibilityConvertScreenRect. */
+ if (UAZoomEnabled ())
+ {
+ NSRect windowRect = [view convertRect:r toView:nil];
+ NSRect screenRect = [[view window] convertRectToScreen:windowRect];
+ CGRect cgRect = NSRectToCGRect (screenRect);
+
+ CGFloat primaryH
+ = [[[NSScreen screens] firstObject] frame].size.height;
+ cgRect.origin.y
+ = primaryH - cgRect.origin.y - cgRect.size.height;
+
+ UAZoomChangeFocus (&cgRect, &cgRect,
+ kUAZoomFocusTypeInsertionPoint);
+ }
+ }
+ }
+#endif
+
+
ns_focus (f, NULL, 0);
NSGraphicsContext *ctx = [NSGraphicsContext currentContext];
@@ -8237,6 +8266,17 @@ - (void)windowDidBecomeKey /* for direct calls */
@@ -8237,6 +8362,25 @@ - (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 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). */
+ to this Emacs view. macOS Zoom uses this to activate keyboard
+ focus tracking when the window gains focus; VoiceOver uses it
+ to announce the newly focused element. */
+ NSAccessibilityPostNotification (self,
+ NSAccessibilityFocusedUIElementChangedNotification);
+#endif
+
+#ifdef NS_IMPL_COCOA
+ /* Notify AT that the focused UI element changed to this Emacs view.
+ macOS Zoom uses this to activate keyboard focus tracking when the
+ window gains focus.
+ See: NSAccessibilityFocusedUIElementChangedNotification
+ https://developer.apple.com/documentation/appkit/nsaccessibilityfocuseduielementchangednotification */
+ NSAccessibilityPostNotification (self,
+ NSAccessibilityFocusedUIElementChangedNotification);
+#endif
}
@@ -9474,6 +9514,146 @@ - (int) fullscreenState
@@ -9474,6 +9618,148 @@ - (int) fullscreenState
return fs_state;
}
+
+#ifdef NS_IMPL_COCOA
+/* ----------------------------------------------------------------
+ Accessibility support for macOS Zoom and other assistive tools.
+ Accessibility support for macOS Zoom, VoiceOver, and other AT tools.
+
+ This implements the NSAccessibility protocol on EmacsView so that
+ macOS Zoom's "Follow keyboard focus" can track the text cursor.
+ EmacsView implements the NSAccessibility protocol so that:
+ - macOS Zoom can query cursor position (accessibilityBoundsForRange:)
+ - VoiceOver can identify the view as a text area
+ - Accessibility Inspector shows correct element hierarchy
+
+ 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 stores the cursor
+ rect and 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.
+ The primary Zoom tracking mechanism is UAZoomChangeFocus() in
+ ns_draw_window_cursor above. These methods provide supplementary
+ support for AT tools that query the accessibility tree directly.
+
+ 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.
+
+ Ref: https://developer.apple.com/documentation/appkit/nsaccessibilityprotocol
+ Ref: https://developer.apple.com/documentation/appkit/nsaccessibilityboundsforrangeparameterizedattribute
+
+ Note: upstream EmacsWindow has separate accessibility code that
+ returns buffer text for VoiceOver. That code operates on the window,
+ returns buffer text content. That code operates on the window,
+ not the view, so there is no conflict.
+ ---------------------------------------------------------------- */
+
@@ -152,13 +267,13 @@ index 932d209..9b73f65 100644
+
+- (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.
+ /* Return the cursor's screen coordinates as the view's accessibility
+ frame. This allows AT tools that query accessibilityFrame (rather
+ than accessibilityBoundsForRange:) to locate the cursor.
+
+ lastAccessibilityCursorRect is in EmacsView coordinates (flipped:
+ origin top-left). convertRect:toView:nil automatically handles
+ the flipped-to-unflipped conversion because isFlipped returns YES. */
+ origin top-left). convertRect:toView:nil handles the
+ flipped-to-unflipped conversion automatically (isFlipped=YES). */
+ NSRect viewRect = lastAccessibilityCursorRect;
+ if (NSIsEmptyRect (viewRect))
+ return [super accessibilityFrame];
@@ -199,7 +314,8 @@ index 932d209..9b73f65 100644
+ return [super accessibilityAttributeValue:attribute];
+}
+
+/* Modern NSAccessibilityProtocol (macOS 10.10+). */
+/* Modern NSAccessibilityProtocol (macOS 10.10+).
+ Ref: https://developer.apple.com/documentation/appkit/nsaccessibilityprotocol */
+- (NSRect)accessibilityBoundsForRange:(NSRange)range
+{
+ /* Return cursor screen rect regardless of requested range.
@@ -220,7 +336,8 @@ index 932d209..9b73f65 100644
+ return [win convertRectToScreen:windowRect];
+}
+
+/* Legacy parameterized attribute API -- fallback for older AT tools. */
+/* Legacy parameterized attribute API -- fallback for older AT tools.
+ Ref: https://developer.apple.com/documentation/appkit/nsaccessibilityboundsforrangeparameterizedattribute */
+- (NSArray *)accessibilityParameterizedAttributeNames
+{
+ NSArray *superAttrs = [super accessibilityParameterizedAttributeNames];