Files
emacs-doom/patches/0005-ns-integrate-accessibility-with-EmacsView-and-redisp.patch
Daneel 0c13f5d6a3 patches: fix O(position) lag — O(1) fast path in accessibilityIndexForCharpos:
accessibilityIndexForCharpos: walked composed character sequences
from run.ax_start up to the target charpos offset.  For a run
covering an entire ASCII buffer, chars_in = pt - BUF_BEGV, making
each call O(cursor_position).

This method is called from ensureTextCache on EVERY redisplay frame
(as part of the cache validity check), making each frame O(position)
even when the buffer is completely unchanged.  At line 34,000 of a
large file this is ~1,000,000 iterations per frame.

Fix: when ax_length == length for a run (all single-unit characters),
the ax_index is simply ax_start + chars_in.  O(1) instead of O(N).

This is the symmetric counterpart to the charposForAccessibilityIndex:
fast path added in the previous commit.  Both conversion directions
now run in O(1) for pure-ASCII buffers.
2026-03-01 09:14:52 +01:00

577 lines
19 KiB
Diff

From 7aab25d3ba9b41d3d81693b04d8d74812cad9139 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 12:58:11 +0100
Subject: [PATCH 5/8] ns: integrate accessibility with EmacsView and redisplay
Wire the accessibility infrastructure into EmacsView and the
* src/nsterm.m (ns_update_end): Call [view postAccessibilityUpdates].
(EmacsView dealloc): Release accessibilityElements.
(EmacsView windowDidBecomeKey): Post accessibility focus notification.
(ns_ax_collect_windows): New function.
(EmacsView rebuildAccessibilityTree, invalidateAccessibilityTree)
(accessibilityChildren, accessibilityFocusedUIElement)
(postAccessibilityUpdates, accessibilityBoundsForRange:)
(accessibilityParameterizedAttributeNames)
(accessibilityAttributeValue:forParameter:): New methods.
* etc/NEWS: Document VoiceOver accessibility support.
Tested on macOS 14 with VoiceOver. End-to-end: buffer
navigation, cursor tracking, window switching, completions, evil-mode
block cursor, org-mode folded headings, indirect buffers.
Known limitations documented in patch 6 Texinfo node.
---
etc/NEWS | 13 ++
src/nsterm.m | 430 +++++++++++++++++++++++++++++++++++++++++++++++++--
2 files changed, 431 insertions(+), 12 deletions(-)
diff --git a/etc/NEWS b/etc/NEWS
index 80661a9..2b1f9e6 100644
--- a/etc/NEWS
+++ b/etc/NEWS
@@ -4400,6 +4400,19 @@ allowing Emacs users access to speech recognition utilities.
Note: Accepting this permission allows the use of system APIs, which may
send user data to Apple's speech recognition servers.
+---
+** VoiceOver accessibility support on macOS.
+Emacs now exposes buffer content, cursor position, and interactive
+elements to the macOS accessibility subsystem (VoiceOver). This
+includes AXBoundsForRange for macOS Zoom cursor tracking, line and
+word navigation announcements, Tab-navigable interactive spans
+(buttons, links, completion candidates), and completion announcements
+for the *Completions* buffer. The implementation uses a virtual
+accessibility tree with per-window elements, hybrid SelectedTextChanged
+and AnnouncementRequested notifications, and thread-safe text caching.
+Set 'ns-accessibility-enabled' to nil to disable the accessibility
+interface and eliminate the associated overhead.
+
---
** Re-introduced dictation, lost in Emacs v30 (macOS).
We lost macOS dictation in v30 when migrating to NSTextInputClient.
diff --git a/src/nsterm.m b/src/nsterm.m
index 94a4602..51813b5 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -1258,7 +1258,7 @@ If a completion candidate is selected (overlay or child frame),
static void
ns_zoom_track_completion (struct frame *f, EmacsView *view)
{
- if (!ns_zoom_enabled_p ())
+ if (!ns_accessibility_enabled || !ns_zoom_enabled_p ())
return;
if (!WINDOWP (f->selected_window))
return;
@@ -1375,7 +1375,8 @@ so the visual offset is (ov_line + 1) * line_h from
(zoomCursorUpdated is NO). */
#if defined (MAC_OS_X_VERSION_MIN_REQUIRED) \
&& MAC_OS_X_VERSION_MIN_REQUIRED >= 101000
- if (view && !view->zoomCursorUpdated && ns_zoom_enabled_p ()
+ if (ns_accessibility_enabled && view && !view->zoomCursorUpdated
+ && ns_zoom_enabled_p ()
&& !NSIsEmptyRect (view->lastCursorRect))
{
NSRect r = view->lastCursorRect;
@@ -1402,6 +1403,9 @@ so the visual offset is (ov_line + 1) * line_h from
if (view)
ns_zoom_track_completion (f, view);
#endif /* NS_IMPL_COCOA */
+
+ /* Post accessibility notifications after each redisplay cycle. */
+ [view postAccessibilityUpdates];
}
static void
@@ -3549,7 +3553,7 @@ EmacsView pixels (AppKit, flipped, top-left origin)
#if defined (MAC_OS_X_VERSION_MIN_REQUIRED) \
&& MAC_OS_X_VERSION_MIN_REQUIRED >= 101000
- if (ns_zoom_enabled_p ())
+ if (ns_accessibility_enabled && ns_zoom_enabled_p ())
{
NSRect windowRect = [view convertRect:r toView:nil];
NSRect screenRect
@@ -6714,9 +6718,56 @@ - (void)applicationDidFinishLaunching: (NSNotification *)notification
}
#endif
+#ifdef NS_IMPL_COCOA
+ /* Auto-detect Zoom and VoiceOver at startup and whenever their state
+ changes. The "com.apple.accessibility.api" distributed notification
+ fires when any assistive technology connects or disconnects.
+ Both code paths set ns_accessibility_enabled so that one variable
+ gates all our accessibility overhead. */
+ [self ns_update_accessibility_state];
+ [[NSDistributedNotificationCenter defaultCenter]
+ addObserver: self
+ selector: @selector(ns_accessibility_did_change:)
+ name: @"com.apple.accessibility.api"
+ object: nil
+suspensionBehavior: NSNotificationSuspensionBehaviorDeliverImmediately];
+#endif
+
ns_send_appdefined (-2);
}
+#ifdef NS_IMPL_COCOA
+/* Set ns_accessibility_enabled based on current AT state.
+ Called at startup and from the "com.apple.accessibility.api"
+ distributed notification handler. Checks both UAZoomEnabled()
+ (Zoom) and AXIsProcessTrustedWithOptions() (VoiceOver and other
+ ATs that have connected to this process). */
+- (void) ns_update_accessibility_state
+{
+ NSTRACE ("[EmacsApp ns_update_accessibility_state]");
+ BOOL zoom_on = UAZoomEnabled ();
+ NSDictionary *opts = @{(__bridge id) kAXTrustedCheckOptionPrompt: @NO};
+ BOOL at_on = AXIsProcessTrustedWithOptions ((__bridge CFDictionaryRef) opts);
+ BOOL new_state = zoom_on || at_on;
+ if ((BOOL) ns_accessibility_enabled != new_state)
+ {
+ ns_accessibility_enabled = new_state;
+ /* Reset the UAZoomEnabled cache so ns_zoom_enabled_p() reflects
+ the new Zoom state on its next call. */
+ ns_zoom_cache_time = 0;
+ }
+}
+
+/* Handler for the "com.apple.accessibility.api" distributed notification,
+ posted by macOS when any AT (VoiceOver, Switch Control, etc.) starts
+ or stops. */
+- (void) ns_accessibility_did_change: (NSNotification *) notification
+{
+ NSTRACE ("[EmacsApp ns_accessibility_did_change:]");
+ [self ns_update_accessibility_state];
+}
+#endif
+
- (void)antialiasThresholdDidChange:(NSNotification *)notification
{
#ifdef NS_IMPL_COCOA
@@ -7617,7 +7668,6 @@ - (id)accessibilityTopLevelUIElement
-
static BOOL
ns_ax_find_completion_overlay_range (struct buffer *b, ptrdiff_t point,
ptrdiff_t *out_start,
@@ -8743,7 +8793,6 @@ - (NSRect)accessibilityFrame
@end
-
/* ===================================================================
EmacsAccessibilityBuffer (Notifications) — AX event dispatch
@@ -9288,7 +9337,6 @@ - (NSRect)accessibilityFrame
@end
-
/* ===================================================================
EmacsAccessibilityInteractiveSpan — helpers and implementation
=================================================================== */
@@ -9618,6 +9666,7 @@ - (void)dealloc
[layer release];
#endif
+ [accessibilityElements release];
[[self menu] release];
[super dealloc];
}
@@ -10966,6 +11015,32 @@ - (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 VoiceOver that the focused accessibility element changed.
+ Post on the focused virtual element so VoiceOver starts tracking it.
+ This is critical for initial focus and app-switch scenarios. */
+ if (ns_accessibility_enabled)
+ {
+ id focused = [self accessibilityFocusedUIElement];
+ if (focused
+ && [focused isKindOfClass:[EmacsAccessibilityBuffer class]])
+ {
+ ns_ax_post_notification (focused,
+ NSAccessibilityFocusedUIElementChangedNotification);
+ NSDictionary *info = @{
+ @"AXTextStateChangeType":
+ @(ns_ax_text_state_change_selection_move),
+ @"AXTextChangeElement": focused
+ };
+ ns_ax_post_notification_with_info (focused,
+ NSAccessibilitySelectedTextChangedNotification, info);
+ }
+ else if (focused)
+ ns_ax_post_notification (focused,
+ NSAccessibilityFocusedUIElementChangedNotification);
+ }
+#endif
}
@@ -12203,6 +12278,332 @@ - (int) fullscreenState
return fs_state;
}
+#ifdef NS_IMPL_COCOA
+
+/* ---- Accessibility: walk the Emacs window tree ---- */
+
+static void
+ns_ax_collect_windows (Lisp_Object window, EmacsView *view,
+ NSMutableArray *elements,
+ NSDictionary *existing)
+{
+ if (NILP (window))
+ return;
+
+ struct window *w = XWINDOW (window);
+
+ if (WINDOW_LEAF_P (w))
+ {
+ /* Buffer element — reuse existing if available. */
+ EmacsAccessibilityBuffer *elem
+ = [existing objectForKey:[NSValue valueWithPointer:w]];
+ if (!elem)
+ {
+ elem = [[EmacsAccessibilityBuffer alloc] init];
+ elem.emacsView = view;
+
+ /* Initialize cached state to -1 to force first notification. */
+ elem.cachedModiff = -1;
+ elem.cachedPoint = -1;
+ elem.cachedMarkActive = NO;
+ }
+ else
+ {
+ [elem retain];
+ }
+ elem.lispWindow = window;
+ [elements addObject:elem];
+ [elem release];
+
+ /* Mode line element (skip for minibuffer). */
+ if (!MINI_WINDOW_P (w))
+ {
+ EmacsAccessibilityModeLine *ml
+ = [[EmacsAccessibilityModeLine alloc] init];
+ ml.emacsView = view;
+ ml.lispWindow = window;
+ [elements addObject:ml];
+ [ml release];
+ }
+ }
+ else
+ {
+ /* Internal (combination) window — recurse into children. */
+ Lisp_Object child = w->contents;
+ while (!NILP (child))
+ {
+ ns_ax_collect_windows (child, view, elements, existing);
+ child = XWINDOW (child)->next;
+ }
+ }
+}
+
+- (void)rebuildAccessibilityTree
+{
+ NSTRACE ("[EmacsView rebuildAccessibilityTree]");
+ if (!emacsframe)
+ return;
+
+ /* Build map of existing elements by window pointer for reuse. */
+ NSMutableDictionary *existing = [NSMutableDictionary dictionary];
+ if (accessibilityElements)
+ {
+ for (EmacsAccessibilityElement *elem in accessibilityElements)
+ {
+ if ([elem isKindOfClass:[EmacsAccessibilityBuffer class]]
+ && !NILP (elem.lispWindow))
+ [existing setObject:elem
+ forKey:[NSValue valueWithPointer:
+ XWINDOW (elem.lispWindow)]];
+ }
+ }
+
+ NSMutableArray *newElements = [NSMutableArray arrayWithCapacity:8];
+
+ /* Collect from main window tree. */
+ Lisp_Object root = FRAME_ROOT_WINDOW (emacsframe);
+ ns_ax_collect_windows (root, self, newElements, existing);
+
+ /* Include minibuffer. */
+ Lisp_Object mini = emacsframe->minibuffer_window;
+ if (!NILP (mini))
+ ns_ax_collect_windows (mini, self, newElements, existing);
+
+ [accessibilityElements release];
+ accessibilityElements = [newElements retain];
+ accessibilityTreeValid = YES;
+}
+
+- (void)invalidateAccessibilityTree
+{
+ accessibilityTreeValid = NO;
+}
+
+- (NSAccessibilityRole)accessibilityRole
+{
+ return NSAccessibilityGroupRole;
+}
+
+- (NSString *)accessibilityLabel
+{
+ return @"Emacs";
+}
+
+- (BOOL)isAccessibilityElement
+{
+ return YES;
+}
+
+- (NSArray *)accessibilityChildren
+{
+ if (!accessibilityElements || !accessibilityTreeValid)
+ [self rebuildAccessibilityTree];
+ return accessibilityElements;
+}
+
+- (id)accessibilityFocusedUIElement
+{
+ if (!emacsframe)
+ return self;
+
+ if (!accessibilityElements || !accessibilityTreeValid)
+ [self rebuildAccessibilityTree];
+
+ for (EmacsAccessibilityElement *elem in accessibilityElements)
+ {
+ if ([elem isKindOfClass:[EmacsAccessibilityBuffer class]]
+ && EQ (elem.lispWindow, emacsframe->selected_window))
+ return elem;
+ }
+ return self;
+}
+
+/* Called from ns_update_end to post AX notifications.
+
+ Important: post notifications BEFORE rebuilding the tree.
+ The existing elements carry cached state (modiff, point) from the
+ previous redisplay cycle. Rebuilding first would create fresh
+ elements with current values, making change detection impossible. */
+- (void)postAccessibilityUpdates
+{
+ NSTRACE ("[EmacsView postAccessibilityUpdates]");
+ eassert ([NSThread isMainThread]);
+
+ if (!emacsframe || !ns_accessibility_enabled)
+ return;
+
+ /* Re-entrance guard: VoiceOver callbacks during notification posting
+ can trigger redisplay, which calls ns_update_end, which calls us
+ again. Prevent infinite recursion. */
+ if (accessibilityUpdating)
+ return;
+ accessibilityUpdating = YES;
+
+ /* Detect window tree change (split, delete, new buffer). Compare
+ FRAME_ROOT_WINDOW — if it changed, the tree structure changed. */
+ Lisp_Object curRoot = FRAME_ROOT_WINDOW (emacsframe);
+ if (!EQ (curRoot, lastRootWindow))
+ {
+ lastRootWindow = curRoot;
+ accessibilityTreeValid = NO;
+ }
+
+ /* If tree is stale, rebuild FIRST so we don't iterate freed
+ window pointers. Skip notifications for this cycle — the
+ freshly-built elements have no previous state to diff against. */
+ if (!accessibilityTreeValid)
+ {
+ [self rebuildAccessibilityTree];
+ /* Invalidate span cache — window layout changed. */
+ for (EmacsAccessibilityElement *elem in accessibilityElements)
+ if ([elem isKindOfClass: [EmacsAccessibilityBuffer class]])
+ [(EmacsAccessibilityBuffer *) elem invalidateInteractiveSpans];
+ ns_ax_post_notification (self,
+ NSAccessibilityLayoutChangedNotification);
+
+ /* Post focus change so VoiceOver picks up the new tree. */
+ id focused = [self accessibilityFocusedUIElement];
+ if (focused && focused != self)
+ ns_ax_post_notification (focused,
+ NSAccessibilityFocusedUIElementChangedNotification);
+
+ lastSelectedWindow = emacsframe->selected_window;
+ accessibilityUpdating = NO;
+ return;
+ }
+
+ /* Post per-buffer notifications using EXISTING elements that have
+ cached state from the previous cycle. Validate each window
+ pointer before use. */
+ for (EmacsAccessibilityElement *elem in accessibilityElements)
+ {
+ if ([elem isKindOfClass:[EmacsAccessibilityBuffer class]])
+ {
+ struct window *w = [elem validWindow];
+ if (w && WINDOW_LEAF_P (w)
+ && BUFFERP (w->contents) && XBUFFER (w->contents))
+ [(EmacsAccessibilityBuffer *) elem
+ postAccessibilityNotificationsForFrame:emacsframe];
+ }
+ }
+
+ /* Check for window switch (C-x o). */
+ Lisp_Object curSel = emacsframe->selected_window;
+ BOOL windowSwitched = !EQ (curSel, lastSelectedWindow);
+ if (windowSwitched)
+ {
+ lastSelectedWindow = curSel;
+ id focused = [self accessibilityFocusedUIElement];
+ if (focused && focused != self
+ && [focused isKindOfClass:[EmacsAccessibilityBuffer class]])
+ {
+ ns_ax_post_notification (focused,
+ NSAccessibilityFocusedUIElementChangedNotification);
+ NSDictionary *info = @{
+ @"AXTextStateChangeType":
+ @(ns_ax_text_state_change_selection_move),
+ @"AXTextChangeElement": focused
+ };
+ ns_ax_post_notification_with_info (focused,
+ NSAccessibilitySelectedTextChangedNotification, info);
+ }
+ else if (focused && focused != self)
+ ns_ax_post_notification (focused,
+ NSAccessibilityFocusedUIElementChangedNotification);
+ }
+
+ accessibilityUpdating = NO;
+}
+
+/* ---- Cursor position for Zoom (via accessibilityBoundsForRange:) ----
+
+ accessibilityFrame returns the VIEW's frame (standard behavior).
+ The cursor location is exposed through accessibilityBoundsForRange:
+ which AT tools query using the selectedTextRange. */
+
+- (NSRect)accessibilityBoundsForRange:(NSRange)range
+{
+ /* Delegate to the focused buffer element for accurate per-range
+ geometry when possible. Fall back to the cached cursor rect
+ (set by ns_draw_phys_cursor) for Zoom and simple AT queries. */
+ id focused = [self accessibilityFocusedUIElement];
+ if ([focused isKindOfClass:[EmacsAccessibilityBuffer class]])
+ {
+ NSRect bufRect = [(EmacsAccessibilityBuffer *) focused
+ accessibilityFrameForRange:range];
+ if (!NSIsEmptyRect (bufRect))
+ return bufRect;
+ }
+
+ NSRect viewRect = lastCursorRect;
+
+ 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];
+}
+
+/* Modern NSAccessibility protocol entry point. Delegates to
+ accessibilityBoundsForRange: which holds the real implementation
+ shared with the legacy parameterized-attribute API. */
+- (NSRect)accessibilityFrameForRange:(NSRange)range
+{
+ return [self accessibilityBoundsForRange:range];
+}
+
+/* Delegate to the focused virtual buffer element so both the modern
+ and legacy APIs return the correct string data. */
+- (NSString *)accessibilityStringForRange:(NSRange)range
+{
+ id focused = [self accessibilityFocusedUIElement];
+ if ([focused isKindOfClass:[EmacsAccessibilityBuffer class]])
+ return [(EmacsAccessibilityBuffer *) focused
+ accessibilityStringForRange:range];
+ return @"";
+}
+
+/* ---- Legacy parameterized attribute APIs (Zoom uses these) ---- */
+
+- (NSArray *)accessibilityParameterizedAttributeNames
+{
+ NSArray *superAttrs = [super accessibilityParameterizedAttributeNames];
+ if (superAttrs == nil)
+ superAttrs = @[];
+ return [superAttrs arrayByAddingObjectsFromArray:
+ @[NSAccessibilityBoundsForRangeParameterizedAttribute,
+ NSAccessibilityStringForRangeParameterizedAttribute]];
+}
+
+- (id)accessibilityAttributeValue:(NSString *)attribute
+ forParameter:(id)parameter
+{
+ if ([attribute isEqualToString:
+ NSAccessibilityBoundsForRangeParameterizedAttribute])
+ {
+ NSRange range = [(NSValue *) parameter rangeValue];
+ return [NSValue valueWithRect:
+ [self accessibilityBoundsForRange:range]];
+ }
+
+ if ([attribute isEqualToString:
+ NSAccessibilityStringForRangeParameterizedAttribute])
+ {
+ NSRange range = [(NSValue *) parameter rangeValue];
+ return [self accessibilityStringForRange:range];
+ }
+
+ return [super accessibilityAttributeValue:attribute forParameter:parameter];
+}
+
+#endif /* NS_IMPL_COCOA */
+
@end /* EmacsView */
@@ -14203,12 +14604,17 @@ Nil means use fullscreen the old (< 10.7) way. The old way works better with
ns_use_srgb_colorspace = YES;
DEFVAR_BOOL ("ns-accessibility-enabled", ns_accessibility_enabled,
- doc: /* Non-nil means expose buffer content to the macOS accessibility
-subsystem (VoiceOver, Zoom, and other assistive technology).
-When nil, the accessibility virtual element tree is not built and no
-notifications are posted, eliminating the associated overhead.
-Requires the Cocoa (NS) build on macOS; ignored on GNUstep.
-Default is nil. Set to t to enable VoiceOver support. */);
+ doc: /* Non-nil enables Zoom cursor tracking and VoiceOver support.
+Emacs sets this automatically at startup when macOS Zoom is active or
+any assistive technology (VoiceOver, Switch Control, etc.) is connected,
+and updates it whenever that state changes. You can override manually:
+
+ (setq ns-accessibility-enabled t) ; always on
+ (setq ns-accessibility-enabled nil) ; always off
+
+When nil, no AX tree is built and no notifications are posted,
+giving zero per-redisplay overhead.
+Requires the Cocoa (NS) build on macOS; ignored on GNUstep. */);
ns_accessibility_enabled = NO;
DEFVAR_BOOL ("ns-use-mwheel-acceleration",
--
2.43.0