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>
Date: Mon, 23 Feb 2026 10:03:39 +0100
Subject: [PATCH] ns: implement NSAccessibility cursor tracking for macOS Zoom
Date: Mon, 23 Feb 2026 10:22:47 +0100
Subject: [PATCH] ns: implement UAZoomChangeFocus + NSAccessibility for macOS
Zoom
Add full NSAccessibility support to EmacsView so that macOS Zoom
'Follow keyboard focus' tracks the text cursor position.
Add full cursor tracking support for macOS Zoom 'Follow keyboard focus'
and other assistive technology tools.
Key insight: macOS Zoom is event-driven. It only calls
accessibilityBoundsForRange: / AXBoundsForRange AFTER receiving
NSAccessibilitySelectedTextChangedNotification. Without posting that
notification, Zoom never queries cursor position -- even if the bounds
methods are correctly implemented.
ROOT CAUSE: NSAccessibility notifications alone are insufficient for
custom-drawn views. macOS Zoom requires an explicit call to
UAZoomChangeFocus() from HIServices/UniversalAccess.h. This is the
same mechanism used by iTerm2 (PTYTextView.m:refreshAccessibility) and
Chromium (render_widget_host_view_mac.mm:OnSelectionBoundsChanged).
Changes:
- Add lastAccessibilityCursorRect ivar to EmacsView (nsterm.h)
- Store cursor rect in ns_draw_window_cursor + post SelectedTextChanged
- Post FocusedUIElementChanged in windowDidBecomeKey
- Implement accessibilityIsIgnored, accessibilityFocusedUIElement
- Implement accessibilityRole (NSAccessibilityTextAreaRole)
- Implement isAccessibilityElement (new API, returns YES)
- Implement accessibilityAttributeNames + accessibilityAttributeValue:
responds to NSAccessibilitySelectedTextRangeAttribute with {0,0}
- Implement accessibilityBoundsForRange: (new NSAccessibilityProtocol)
- Implement accessibilityParameterizedAttributeNames + old API fallback
Changes to src/nsterm.h:
- Add lastAccessibilityCursorRect ivar to EmacsView
Changes to src/nsterm.m:
- ns_draw_window_cursor: store cursor rect, post SelectedTextChanged,
call UAZoomChangeFocus() with cursor rect in AX screen coordinates
- windowDidBecomeKey: post FocusedUIElementChangedNotification
- EmacsView: add accessibilityIsIgnored (NO), isAccessibilityElement (YES),
accessibilityFocusedUIElement (self), accessibilityRole (TextAreaRole),
accessibilityAttributeNames, accessibilityAttributeValue: (responds to
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
---
src/nsterm.h | 3 ++
src/nsterm.m | 139 +++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 142 insertions(+)
src/nsterm.h | 3 +
src/nsterm.m | 156 +++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 159 insertions(+)
diff --git a/src/nsterm.h b/src/nsterm.h
index 7c1ee4cf535..6c1ff3434a3 100644
@@ -45,26 +51,43 @@ index 7c1ee4cf535..6c1ff3434a3 100644
/* AppKit-side interface. */
diff --git a/src/nsterm.m b/src/nsterm.m
index 932d209f56b..906c76bc468 100644
index 932d209f56b..38d946f1d9f 100644
--- a/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. */
r = NSIntersectionRect (r, ns_row_rect (w, glyph_row, TEXT_AREA));
+#ifdef NS_IMPL_COCOA
+ /* Store cursor rect for accessibility and notify assistive tools
+ (e.g. macOS Zoom "Follow keyboard focus") that the cursor moved.
+ macOS Zoom is event-driven: it only calls accessibilityBoundsForRange:
+ AFTER receiving NSAccessibilitySelectedTextChangedNotification.
+ Without posting this notification, Zoom never queries cursor position. */
+ /* Update accessibility cursor tracking for macOS Zoom and VoiceOver.
+ NSAccessibility notifications alone are NOT sufficient for custom-drawn
+ views -- macOS Zoom "Follow keyboard focus" requires an explicit call to
+ UAZoomChangeFocus() from HIServices/UniversalAccess.h, which directly
+ 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);
+ if (view)
+ {
+ view->lastAccessibilityCursorRect = r;
+
+ /* Post standard AX notification for VoiceOver and other AT tools. */
+ NSAccessibilityPostNotification (view,
+ 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
@@ -72,7 +95,7 @@ index 932d209f56b..906c76bc468 100644
ns_focus (f, NULL, 0);
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);
kbd_buffer_store_event (&event);
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;
}