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> From: Martin Sukany <martin@sukany.cz>
Date: Wed, 25 Feb 2026 17:45:43 +0100 Date: Wed, 25 Feb 2026 18:10:58 +0100
Subject: [PATCH] ns: implement macOS Zoom cursor tracking via NSAccessibility Subject: [PATCH] ns: implement macOS Zoom cursor tracking + NSAccessibility
MIME-Version: 1.0 support
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add cursor tracking support for macOS Zoom "Follow keyboard focus" and Add cursor tracking for macOS Zoom 'Follow keyboard focus' and
other assistive technology (VoiceOver, etc.). full NSAccessibility support for VoiceOver and other AT tools.
EmacsView now implements the NSAccessibility protocol: it reports itself Two complementary mechanisms are used:
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.
Both the modern protocol API (accessibilityBoundsForRange:, 10.10+) and 1. UAZoomChangeFocus() from ApplicationServices/UniversalAccess.h:
the legacy parameterized attribute API (AXBoundsForRange) are implemented Directly tells macOS Zoom where to position its viewport. This
for compatibility with all AT tools. 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.h | 6 ++
src/nsterm.m | 180 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/nsterm.m | 286 +++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 183 insertions(+) 2 files changed, 292 insertions(+)
diff --git a/src/nsterm.h b/src/nsterm.h diff --git a/src/nsterm.h b/src/nsterm.h
index 7c1ee4c..6c1ff34 100644 index 7c1ee4c..664bce3 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,12 @@ enum ns_return_frame_mode
struct frame *emacsframe; struct frame *emacsframe;
int scrollbarsNeedingUpdate; int scrollbarsNeedingUpdate;
NSRect ns_userRect; NSRect ns_userRect;
+#ifdef NS_IMPL_COCOA +#ifdef NS_IMPL_COCOA
+ NSRect lastAccessibilityCursorRect; + NSRect lastAccessibilityCursorRect;
+#endif
+#ifdef NS_IMPL_COCOA
+ NSRect lastAccessibilityCursorRect;
+#endif +#endif
} }
/* 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 932d209..9b73f65 100644 index 932d209..96b9d65 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/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. */ /* 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
+ /* Accessibility cursor tracking for macOS Zoom and VoiceOver. + /* Accessibility cursor tracking for macOS Zoom and VoiceOver.
+ +
+ Emacs is a custom-drawn view: AppKit does not know where the text + Emacs uses a custom-drawn cursor in a custom NSView. AppKit has no
+ cursor is, so we must explicitly notify assistive technology. + 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 + 1. NSAccessibility notifications:
+ NSAccessibilitySelectedTextChangedNotification. macOS Zoom (and + Post NSAccessibilitySelectedTextChangedNotification so that
+ VoiceOver) respond by querying accessibilityFrame on the focused + VoiceOver and other AT tools can query cursor position via
+ element (EmacsView) to find the cursor position. This is the + accessibilityBoundsForRange: / accessibilityFrame.
+ standard approach used by Terminal.app, iTerm2, and Firefox. +
+ It works regardless of how Emacs is launched (app bundle or + 2. UAZoomChangeFocus() from ApplicationServices/UniversalAccess.h:
+ src/emacs binary). */ + 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); + EmacsView *view = FRAME_NS_VIEW (f);
+ if (view) + if (view)
@@ -69,62 +98,148 @@ index 932d209..9b73f65 100644
+ accessibilityBoundsForRange: queries. */ + accessibilityBoundsForRange: queries. */
+ view->lastAccessibilityCursorRect = r; + view->lastAccessibilityCursorRect = r;
+ +
+ /* Post SelectedTextChanged so Zoom and other AT tools query + /* Post NSAccessibility notification for VoiceOver and other
+ accessibilityFrame on the focused element (this view). */ + AT tools. */
+ NSAccessibilityPostNotification (view, + NSAccessibilityPostNotification (view,
+ NSAccessibilitySelectedTextChangedNotification); + 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 +#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); ns_focus (f, NULL, 0);
NSGraphicsContext *ctx = [NSGraphicsContext currentContext]; 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); 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 assistive technology that the focused UI element changed + /* Notify assistive technology that the focused UI element changed
+ to this Emacs view. macOS Zoom uses this notification to: + to this Emacs view. macOS Zoom uses this to activate keyboard
+ (a) activate keyboard focus tracking for the newly focused window, + focus tracking when the window gains focus; VoiceOver uses it
+ (b) query accessibilityFrame on the focused element to position + to announce the newly focused element. */
+ the Zoom viewport. + NSAccessibilityPostNotification (self,
+ This works for all launch methods (bundle and non-bundle). */ + 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, + NSAccessibilityPostNotification (self,
+ NSAccessibilityFocusedUIElementChangedNotification); + NSAccessibilityFocusedUIElementChangedNotification);
+#endif +#endif
} }
@@ -9474,6 +9514,146 @@ - (int) fullscreenState @@ -9474,6 +9618,148 @@ - (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. + Accessibility support for macOS Zoom, VoiceOver, and other AT tools.
+ +
+ This implements the NSAccessibility protocol on EmacsView so that + EmacsView implements the NSAccessibility protocol so that:
+ macOS Zoom's "Follow keyboard focus" can track the text cursor. + - 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: + The primary Zoom tracking mechanism is UAZoomChangeFocus() in
+ - EmacsView declares itself as a TextArea role (the standard role + ns_draw_window_cursor above. These methods provide supplementary
+ for editable text regions). + support for AT tools that query the accessibility tree directly.
+ - 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.
+ +
+ Both the modern protocol API (accessibilityBoundsForRange:, 10.10+) + Both the modern protocol API (accessibilityBoundsForRange:, 10.10+)
+ and the legacy parameterized attribute API (AXBoundsForRange) are + and the legacy parameterized attribute API (AXBoundsForRange) are
+ implemented for compatibility with all macOS versions and AT tools. + 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 + 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. + not the view, so there is no conflict.
+ ---------------------------------------------------------------- */ + ---------------------------------------------------------------- */
+ +
@@ -152,13 +267,13 @@ index 932d209..9b73f65 100644
+ +
+- (NSRect)accessibilityFrame +- (NSRect)accessibilityFrame
+{ +{
+ /* Return the cursor's screen coordinates. This is the KEY method + /* Return the cursor's screen coordinates as the view's accessibility
+ that macOS Zoom reads after receiving a focus/selection notification. + frame. This allows AT tools that query accessibilityFrame (rather
+ Terminal.app and iTerm2 use this same pattern. + than accessibilityBoundsForRange:) to locate the cursor.
+ +
+ lastAccessibilityCursorRect is in EmacsView coordinates (flipped: + lastAccessibilityCursorRect is in EmacsView coordinates (flipped:
+ origin top-left). convertRect:toView:nil automatically handles + origin top-left). convertRect:toView:nil handles the
+ the flipped-to-unflipped conversion because isFlipped returns YES. */ + flipped-to-unflipped conversion automatically (isFlipped=YES). */
+ NSRect viewRect = lastAccessibilityCursorRect; + NSRect viewRect = lastAccessibilityCursorRect;
+ if (NSIsEmptyRect (viewRect)) + if (NSIsEmptyRect (viewRect))
+ return [super accessibilityFrame]; + return [super accessibilityFrame];
@@ -199,7 +314,8 @@ index 932d209..9b73f65 100644
+ return [super accessibilityAttributeValue:attribute]; + 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 +- (NSRect)accessibilityBoundsForRange:(NSRange)range
+{ +{
+ /* Return cursor screen rect regardless of requested range. + /* Return cursor screen rect regardless of requested range.
@@ -220,7 +336,8 @@ index 932d209..9b73f65 100644
+ return [win convertRectToScreen:windowRect]; + 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 *)accessibilityParameterizedAttributeNames
+{ +{
+ NSArray *superAttrs = [super accessibilityParameterizedAttributeNames]; + NSArray *superAttrs = [super accessibilityParameterizedAttributeNames];