fix: UAZoomChangeFocus() — actual fix for macOS Zoom cursor tracking

Root cause found via research pipeline:
NSAccessibility notifications alone are insufficient for custom NSView.
macOS Zoom 'Follow keyboard focus' requires UAZoomChangeFocus() from
HIServices/UniversalAccess.h — same as iTerm2 and Chromium.

Squashed into single clean patch. 159 insertions, 2 files.
This commit is contained in:
2026-02-23 10:22:59 +01:00
parent 56db072f06
commit 5196674d9e

View File

@@ -1,34 +1,40 @@
From ea7573e32a140ffce0758485abd959954e74c271 Mon Sep 17 00:00:00 2001 From 09f293daca75996e7348c89e2b0d31a16c1c2500 Mon Sep 17 00:00:00 2001
From: Daneel <daneel@sukany.cz> From: Daneel <daneel@sukany.cz>
Date: Mon, 23 Feb 2026 10:03:39 +0100 Date: Mon, 23 Feb 2026 10:22:47 +0100
Subject: [PATCH] ns: implement NSAccessibility cursor tracking for macOS Zoom Subject: [PATCH] ns: implement UAZoomChangeFocus + NSAccessibility for macOS
Zoom
Add full NSAccessibility support to EmacsView so that macOS Zoom Add full cursor tracking support for macOS Zoom 'Follow keyboard focus'
'Follow keyboard focus' tracks the text cursor position. and other assistive technology tools.
Key insight: macOS Zoom is event-driven. It only calls ROOT CAUSE: NSAccessibility notifications alone are insufficient for
accessibilityBoundsForRange: / AXBoundsForRange AFTER receiving custom-drawn views. macOS Zoom requires an explicit call to
NSAccessibilitySelectedTextChangedNotification. Without posting that UAZoomChangeFocus() from HIServices/UniversalAccess.h. This is the
notification, Zoom never queries cursor position -- even if the bounds same mechanism used by iTerm2 (PTYTextView.m:refreshAccessibility) and
methods are correctly implemented. Chromium (render_widget_host_view_mac.mm:OnSelectionBoundsChanged).
Changes: Changes to src/nsterm.h:
- Add lastAccessibilityCursorRect ivar to EmacsView (nsterm.h) - Add lastAccessibilityCursorRect ivar to EmacsView
- Store cursor rect in ns_draw_window_cursor + post SelectedTextChanged
- Post FocusedUIElementChanged in windowDidBecomeKey Changes to src/nsterm.m:
- Implement accessibilityIsIgnored, accessibilityFocusedUIElement - ns_draw_window_cursor: store cursor rect, post SelectedTextChanged,
- Implement accessibilityRole (NSAccessibilityTextAreaRole) call UAZoomChangeFocus() with cursor rect in AX screen coordinates
- Implement isAccessibilityElement (new API, returns YES) - windowDidBecomeKey: post FocusedUIElementChangedNotification
- Implement accessibilityAttributeNames + accessibilityAttributeValue: - EmacsView: add accessibilityIsIgnored (NO), isAccessibilityElement (YES),
responds to NSAccessibilitySelectedTextRangeAttribute with {0,0} accessibilityFocusedUIElement (self), accessibilityRole (TextAreaRole),
- Implement accessibilityBoundsForRange: (new NSAccessibilityProtocol) accessibilityAttributeNames, accessibilityAttributeValue: (responds to
- Implement accessibilityParameterizedAttributeNames + old API fallback NSAccessibilitySelectedTextRangeAttribute with {0,0}),
accessibilityBoundsForRange: (new NSAccessibilityProtocol, macOS 10.10+),
accessibilityParameterizedAttributeNames + old AXBoundsForRange fallback
Carbon/Carbon.h (already included in NS_IMPL_COCOA) provides UAZoomEnabled(),
UAZoomChangeFocus(), and 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 | 139 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/nsterm.m | 156 +++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 142 insertions(+) 2 files changed, 159 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 7c1ee4cf535..6c1ff3434a3 100644
@@ -45,26 +51,43 @@ 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..906c76bc468 100644 index 932d209f56b..38d946f1d9f 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/src/nsterm.m +++ b/src/nsterm.m
@@ -3232,6 +3232,23 @@ Note that CURSOR_WIDTH is meaningful only for (h)bar cursors. @@ -3232,6 +3232,40 @@ 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
+ /* Store cursor rect for accessibility and notify assistive tools + /* Update accessibility cursor tracking for macOS Zoom and VoiceOver.
+ (e.g. macOS Zoom "Follow keyboard focus") that the cursor moved. + NSAccessibility notifications alone are NOT sufficient for custom-drawn
+ macOS Zoom is event-driven: it only calls accessibilityBoundsForRange: + views -- macOS Zoom "Follow keyboard focus" requires an explicit call to
+ AFTER receiving NSAccessibilitySelectedTextChangedNotification. + UAZoomChangeFocus() from HIServices/UniversalAccess.h, which directly
+ Without posting this notification, Zoom never queries cursor position. */ + tells Zoom the cursor's screen position. This is the same mechanism used
+ by iTerm2 (PTYTextView.m:refreshAccessibility) and Chromium. */
+ { + {
+ EmacsView *view = FRAME_NS_VIEW (f); + EmacsView *view = FRAME_NS_VIEW (f);
+ if (view) + if (view)
+ { + {
+ view->lastAccessibilityCursorRect = r; + view->lastAccessibilityCursorRect = r;
+
+ /* Post standard AX notification for VoiceOver and other AT tools. */
+ NSAccessibilityPostNotification (view, + NSAccessibilityPostNotification (view,
+ NSAccessibilitySelectedTextChangedNotification); + NSAccessibilitySelectedTextChangedNotification);
+
+ /* Tell macOS Zoom exactly where the cursor is. UAZoomChangeFocus()
+ expects coordinates in the NSAccessibility coordinate system (origin
+ at top-left of primary screen). Convert: view → window → screen
+ (Quartz, origin bottom-left) → AX (origin top-left) via
+ accessibilityConvertScreenRect:. */
+ if (UAZoomEnabled ())
+ {
+ NSRect windowRect = [view convertRect:r toView:nil];
+ NSRect screenRect = [[view window] convertRectToScreen:windowRect];
+ CGRect cgRect = [view accessibilityConvertScreenRect:
+ NSRectToCGRect (screenRect)];
+ UAZoomChangeFocus (&cgRect, &cgRect, kUAZoomFocusTypeInsertionPoint);
+ }
+ } + }
+ } + }
+#endif +#endif
@@ -72,7 +95,7 @@ index 932d209f56b..906c76bc468 100644
ns_focus (f, NULL, 0); ns_focus (f, NULL, 0);
NSGraphicsContext *ctx = [NSGraphicsContext currentContext]; NSGraphicsContext *ctx = [NSGraphicsContext currentContext];
@@ -8237,6 +8254,14 @@ - (void)windowDidBecomeKey /* for direct calls */ @@ -8237,6 +8271,14 @@ - (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
@@ -87,7 +110,7 @@ index 932d209f56b..906c76bc468 100644
} }
@@ -9474,6 +9499,120 @@ - (int) fullscreenState @@ -9474,6 +9516,120 @@ - (int) fullscreenState
return fs_state; return fs_state;
} }