From ca511140b95caf299ab1b24b7a22de03a2e5b543 Mon Sep 17 00:00:00 2001 From: Martin Sukany Date: Sat, 28 Feb 2026 12:58:11 +0100 Subject: [PATCH 5/8] ns: integrate accessibility with EmacsView and redisplay Wire the accessibility element tree into EmacsView and hook it into the redisplay cycle. * etc/NEWS: Document VoiceOver accessibility support. * src/nsterm.m (ns_update_end): Call -[EmacsView postAccessibilityUpdates]. (EmacsApp ns_update_accessibility_state): New method; query AXIsProcessTrustedWithOptions and UAZoomEnabled to set ns_accessibility_enabled automatically. (EmacsApp ns_accessibility_did_change:): New method; handle com.apple.accessibility.api distributed notification. (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 | 13 ++ src/nsterm.m | 474 +++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 475 insertions(+), 12 deletions(-) diff --git a/etc/NEWS b/etc/NEWS index 4c149e41d6..7f917f93b2 100644 --- a/etc/NEWS +++ b/etc/NEWS @@ -4385,6 +4385,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 b460beb00c..7c118045bd 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -1275,7 +1275,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; @@ -1393,7 +1393,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; @@ -1420,6 +1421,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 @@ -3567,7 +3571,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 @@ -6723,9 +6727,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 @@ -7628,7 +7679,6 @@ - (id)accessibilityTopLevelUIElement - static BOOL ns_ax_find_completion_overlay_range (struct buffer *b, ptrdiff_t point, ptrdiff_t *out_start, @@ -8741,7 +8791,6 @@ - (NSRect)accessibilityFrame @end - /* =================================================================== EmacsAccessibilityBuffer (Notifications) — AX event dispatch @@ -9235,6 +9284,50 @@ - (void)postAccessibilityNotificationsForFrame:(struct frame *)f granularity = ns_ax_text_selection_granularity_line; } + /* Programmatic jumps that cross a line boundary (]], [[, M-<, + xref, imenu, …) are discontiguous: the cursor teleported to an + arbitrary position, not one sequential step forward/backward. + Reporting AXTextSelectionDirectionDiscontiguous causes VoiceOver + to re-anchor its rotor browse cursor at the new + accessibilitySelectedTextRange rather than advancing linearly + from its previous internal position. */ + if (!isCtrlNP && granularity == ns_ax_text_selection_granularity_line) + direction = ns_ax_text_selection_direction_discontiguous; + + /* If Emacs moved the cursor (not VoiceOver), force discontiguous + so VoiceOver re-anchors its browse cursor to the current + accessibilitySelectedTextRange. This covers all Emacs-initiated + moves: editing commands, ELisp, isearch, etc. + Exception: C-n/C-p (isCtrlNP) already uses next/previous with + line granularity; those are already sequential and VoiceOver + handles them correctly. */ + if (emacsMovedCursor && !isCtrlNP) + direction = ns_ax_text_selection_direction_discontiguous; + + /* Re-anchor VoiceOver's browse cursor for discontiguous (teleport) + moves only. For sequential C-n/C-p (isCtrlNP), posting + FocusedUIElementChanged on the window races with the + AXSelectedTextChanged(granularity=line) notification and + causes VoiceOver to drop the line-read speech. Sequential + moves are already handled correctly by AXSelectedTextChanged + with direction=next/previous + granularity=line. */ + if (emacsMovedCursor && !isCtrlNP && [self isAccessibilityFocused]) + { + NSWindow *win = [self.emacsView window]; + if (win) + ns_ax_post_notification ( + win, + NSAccessibilityFocusedUIElementChangedNotification); + + NSDictionary *layoutInfo = @{ + NSAccessibilityUIElementsKey: @[self] + }; + ns_ax_post_notification_with_info ( + self.emacsView, + NSAccessibilityLayoutChangedNotification, + layoutInfo); + } + /* Post notifications for focused and non-focused elements. */ if ([self isAccessibilityFocused]) [self postFocusedCursorNotification:point @@ -9347,7 +9440,6 @@ - (NSRect)accessibilityFrame @end - /* =================================================================== EmacsAccessibilityInteractiveSpan --- helpers and implementation =================================================================== */ @@ -9682,6 +9774,7 @@ - (void)dealloc [layer release]; #endif + [accessibilityElements release]; [[self menu] release]; [super dealloc]; } @@ -11030,6 +11123,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 } @@ -12267,6 +12386,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 */ @@ -14263,12 +14708,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