251 lines
8.7 KiB
Diff
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
|
|
|