Files
emacs-doom/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch

251 lines
8.7 KiB
Diff

From fe9a8b85d064d68ce5676dcf5532583d2de82dd3 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
Add cursor tracking support for macOS Zoom "Follow keyboard focus" and
other assistive technology (VoiceOver, etc.).
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.
Both the modern protocol API (accessibilityBoundsForRange:, 10.10+) and
the legacy parameterized attribute API (AXBoundsForRange) are implemented
for compatibility with all AT tools.
---
src/nsterm.h | 3 +
src/nsterm.m | 180 +++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 183 insertions(+)
diff --git a/src/nsterm.h b/src/nsterm.h
index 7c1ee4c..6c1ff34 100644
--- a/src/nsterm.h
+++ b/src/nsterm.h
@@ -485,6 +485,9 @@ enum ns_return_frame_mode
struct frame *emacsframe;
int scrollbarsNeedingUpdate;
NSRect ns_userRect;
+#ifdef NS_IMPL_COCOA
+ NSRect lastAccessibilityCursorRect;
+#endif
}
/* AppKit-side interface. */
diff --git a/src/nsterm.m b/src/nsterm.m
index 932d209..9b73f65 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -3232,6 +3232,35 @@ 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.
+
+ 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). */
+ {
+ EmacsView *view = FRAME_NS_VIEW (f);
+ if (view)
+ {
+ /* Store cursor rect for accessibilityFrame and
+ accessibilityBoundsForRange: queries. */
+ view->lastAccessibilityCursorRect = r;
+
+ /* Post SelectedTextChanged so Zoom and other AT tools query
+ accessibilityFrame on the focused element (this view). */
+ NSAccessibilityPostNotification (view,
+ NSAccessibilitySelectedTextChangedNotification);
+ }
+ }
+#endif
+
ns_focus (f, NULL, 0);
NSGraphicsContext *ctx = [NSGraphicsContext currentContext];
@@ -8237,6 +8266,17 @@ - (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). */
+ NSAccessibilityPostNotification (self,
+ NSAccessibilityFocusedUIElementChangedNotification);
+#endif
}
@@ -9474,6 +9514,146 @@ - (int) fullscreenState
return fs_state;
}
+
+#ifdef NS_IMPL_COCOA
+/* ----------------------------------------------------------------
+ Accessibility support for macOS Zoom and other assistive tools.
+
+ This implements the NSAccessibility protocol on EmacsView so that
+ macOS Zoom's "Follow keyboard focus" can track the text cursor.
+
+ 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.
+
+ 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.
+
+ Note: upstream EmacsWindow has separate accessibility code that
+ returns buffer text for VoiceOver. That code operates on the window,
+ not the view, so there is no conflict.
+ ---------------------------------------------------------------- */
+
+- (BOOL)accessibilityIsIgnored
+{
+ /* EmacsView must participate in the accessibility hierarchy. */
+ return NO;
+}
+
+- (BOOL)isAccessibilityElement
+{
+ return YES;
+}
+
+- (id)accessibilityFocusedUIElement
+{
+ /* This view is the focused element -- it contains the text cursor. */
+ return self;
+}
+
+- (NSString *)accessibilityRole
+{
+ return NSAccessibilityTextAreaRole;
+}
+
+- (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.
+
+ lastAccessibilityCursorRect is in EmacsView coordinates (flipped:
+ origin top-left). convertRect:toView:nil automatically handles
+ the flipped-to-unflipped conversion because isFlipped returns YES. */
+ NSRect viewRect = lastAccessibilityCursorRect;
+ if (NSIsEmptyRect (viewRect))
+ return [super accessibilityFrame];
+
+ NSWindow *win = [self window];
+ if (win == nil)
+ return [super accessibilityFrame];
+
+ NSRect windowRect = [self convertRect:viewRect toView:nil];
+ return [win convertRectToScreen:windowRect];
+}
+
+- (NSArray *)accessibilityAttributeNames
+{
+ NSArray *superAttrs = [super accessibilityAttributeNames];
+ if (superAttrs == nil)
+ superAttrs = @[];
+ return [superAttrs arrayByAddingObjectsFromArray:
+ @[NSAccessibilityRoleAttribute,
+ NSAccessibilitySelectedTextRangeAttribute,
+ NSAccessibilityNumberOfCharactersAttribute]];
+}
+
+- (id)accessibilityAttributeValue:(NSString *)attribute
+{
+ if ([attribute isEqualToString:NSAccessibilityRoleAttribute])
+ return NSAccessibilityTextAreaRole;
+
+ /* Zoom queries SelectedTextRange before calling BoundsForRange.
+ We return {0,0} (collapsed caret); our bounds methods ignore
+ the range parameter and always return the actual cursor rect. */
+ if ([attribute isEqualToString:NSAccessibilitySelectedTextRangeAttribute])
+ return [NSValue valueWithRange:NSMakeRange (0, 0)];
+
+ if ([attribute isEqualToString:NSAccessibilityNumberOfCharactersAttribute])
+ return @(0);
+
+ return [super accessibilityAttributeValue:attribute];
+}
+
+/* Modern NSAccessibilityProtocol (macOS 10.10+). */
+- (NSRect)accessibilityBoundsForRange:(NSRange)range
+{
+ /* Return cursor screen rect regardless of requested range.
+ Emacs does not expose a character-level text model to AppKit,
+ so we always return the cursor position. */
+ NSRect viewRect = lastAccessibilityCursorRect;
+
+ if (viewRect.size.width < 1)
+ viewRect.size.width = 1;
+ if (viewRect.size.height < 1)
+ viewRect.size.height = 8;
+
+ NSWindow *win = [self window];
+ if (win == nil)
+ return NSZeroRect;
+
+ NSRect windowRect = [self convertRect:viewRect toView:nil];
+ return [win convertRectToScreen:windowRect];
+}
+
+/* Legacy parameterized attribute API -- fallback for older AT tools. */
+- (NSArray *)accessibilityParameterizedAttributeNames
+{
+ NSArray *superAttrs = [super accessibilityParameterizedAttributeNames];
+ if (superAttrs == nil)
+ superAttrs = @[];
+ return [superAttrs arrayByAddingObjectsFromArray:
+ @[NSAccessibilityBoundsForRangeParameterizedAttribute]];
+}
+
+- (id)accessibilityAttributeValue:(NSString *)attribute
+ forParameter:(id)parameter
+{
+ if ([attribute isEqualToString:
+ NSAccessibilityBoundsForRangeParameterizedAttribute])
+ return [NSValue valueWithRect:
+ [self accessibilityBoundsForRange:NSMakeRange (0, 0)]];
+
+ return [super accessibilityAttributeValue:attribute forParameter:parameter];
+}
+#endif /* NS_IMPL_COCOA */
+
@end /* EmacsView */
--
2.43.0