From 6933d8f9c04dd9dea7dda99af84f2541b5b0ac07 Mon Sep 17 00:00:00 2001 From: Daneel Date: Wed, 25 Feb 2026 18:11:05 +0100 Subject: [PATCH] v4 patch: dual mechanism with Apple doc references UAZoomChangeFocus (documented Apple API) + NSAccessibility. References to official Apple documentation in comments. Pattern matches iTerm2 implementation. --- ...oundsForRange-for-macOS-Zoom-cursor-.patch | 243 +++++++++++++----- 1 file changed, 180 insertions(+), 63 deletions(-) diff --git a/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch b/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch index 0b546b2..996e126 100644 --- a/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch +++ b/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch @@ -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 -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];