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:
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user