From f10036eeadf681cd87bbec7ec0581b572053c38a Mon Sep 17 00:00:00 2001 From: Martin Sukany Date: Sat, 28 Feb 2026 12:58:11 +0100 Subject: [PATCH 6/9] 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 | 369 +++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 374 insertions(+), 8 deletions(-) diff --git a/etc/NEWS b/etc/NEWS index f10d17e..f48d05b 100644 --- a/etc/NEWS +++ b/etc/NEWS @@ -4397,6 +4397,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 907ce47..d813274 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -1133,6 +1133,9 @@ static NSRect constrain_frame_rect(NSRect frameRect, bool isFullscreen) if (view) view->zoomCursorUpdated = NO; #endif + + /* Post accessibility notifications after each redisplay cycle. */ + [view postAccessibilityUpdates]; #endif /* NS_IMPL_COCOA */ } @@ -3263,11 +3266,11 @@ Note that CURSOR_WIDTH is meaningful only for (h)bar cursors. r = NSIntersectionRect (r, ns_row_rect (w, glyph_row, TEXT_AREA)); #ifdef NS_IMPL_COCOA - /* Zoom integration: inform macOS Zoom of the cursor position. - Zoom (System Settings -> Accessibility -> Zoom) tracks a focus - element to keep the zoomed viewport centered on the cursor. - - Coordinate conversion: + /* Store cursor rect and inform macOS Zoom / VoiceOver. + lastCursorRect is used by: + - Zoom: UAZoomChangeFocus below (unconditional when active) + - VoiceOver: accessibilityBoundsForRange: fallback + Coordinate conversion for Zoom: EmacsView pixels (AppKit, flipped, top-left origin) -> NSWindow (convertRect:toView:nil) -> NSScreen (convertRectToScreen:) @@ -7349,7 +7352,6 @@ - (id)accessibilityTopLevelUIElement - static BOOL ns_ax_find_completion_overlay_range (struct buffer *b, ptrdiff_t point, ptrdiff_t *out_start, @@ -8444,7 +8446,6 @@ - (NSRect)accessibilityFrame @end - /* =================================================================== EmacsAccessibilityBuffer (Notifications) — AX event dispatch @@ -8989,7 +8990,6 @@ - (NSRect)accessibilityFrame @end - /* =================================================================== EmacsAccessibilityInteractiveSpan — helpers and implementation =================================================================== */ @@ -9319,6 +9319,7 @@ - (void)dealloc [layer release]; #endif + [accessibilityElements release]; [[self menu] release]; [super dealloc]; } @@ -10667,6 +10668,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 } @@ -11904,6 +11931,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 */ -- 2.43.0