From d3915c6f58a0db4f8c0e3d681e207dbcda0e18f1 Mon Sep 17 00:00:00 2001 From: Martin Sukany Date: Sat, 28 Feb 2026 16:01:29 +0100 Subject: [PATCH 8/8] ns: announce child frame completion candidates for VoiceOver Completion frameworks such as Corfu and Company-box render candidates in a child frame. This extends the overlay announcement support to handle child frame popups. * src/nsterm.m (ns_ax_selected_child_frame_text): New function; scan a child frame buffer line by line using Fget_char_property to find the selected candidate; uses record_unwind_current_buffer for safety. (EmacsView announceChildFrameCompletion): New method. (EmacsView postAccessibilityUpdates): Detect child frames via FRAME_PARENT_FRAME; call announceChildFrameCompletion. Post NSAccessibilityFocusedUIElementChangedNotification on the parent buffer element when a child frame completion closes. --- doc/emacs/macos.texi | 6 - etc/NEWS | 4 +- src/nsterm.h | 9 ++ src/nsterm.m | 341 +++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 337 insertions(+), 23 deletions(-) diff --git a/doc/emacs/macos.texi b/doc/emacs/macos.texi index 6514dfc..f47929e 100644 --- a/doc/emacs/macos.texi +++ b/doc/emacs/macos.texi @@ -278,7 +278,6 @@ restart Emacs to access newly-available services. @cindex VoiceOver @cindex accessibility (macOS) @cindex screen reader (macOS) -@cindex Zoom, cursor tracking (macOS) When built with the Cocoa interface on macOS, Emacs exposes buffer content, cursor position, mode lines, and interactive elements to the @@ -309,11 +308,6 @@ Shift-modified movement announces selected or deselected text. The @file{*Completions*} buffer announces each completion candidate as you navigate, even while keyboard focus remains in the minibuffer. - macOS Zoom (System Settings, Accessibility, Zoom) tracks the Emacs -cursor automatically when set to follow keyboard focus. The cursor -position is communicated via @code{UAZoomChangeFocus} and the -@code{AXBoundsForRange} accessibility attribute. - @vindex ns-accessibility-enabled To disable the accessibility interface entirely (for instance, to eliminate overhead on systems where assistive technology is not in diff --git a/etc/NEWS b/etc/NEWS index 2b1f9e6..8a40850 100644 --- a/etc/NEWS +++ b/etc/NEWS @@ -4404,8 +4404,8 @@ 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 +includes 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 diff --git a/src/nsterm.h b/src/nsterm.h index 21a93bc..bbce9fe 100644 --- a/src/nsterm.h +++ b/src/nsterm.h @@ -507,6 +507,10 @@ typedef struct ns_ax_visible_run } @property (nonatomic, retain) NSString *cachedText; @property (nonatomic, assign) ptrdiff_t cachedTextModiff; +/* Overlay modiff at last text cache rebuild. Tracked separately from + cachedOverlayModiff (which is used for completion announcements) so + that fold/unfold detection is independent of notification dispatch. */ +@property (nonatomic, assign) ptrdiff_t cachedOverlayModiffForText; @property (nonatomic, assign) ptrdiff_t cachedOverlayModiff; @property (nonatomic, assign) ptrdiff_t cachedTextStart; @property (nonatomic, assign) ptrdiff_t cachedModiff; @@ -596,6 +600,10 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType) Lisp_Object lastRootWindow; BOOL accessibilityTreeValid; BOOL accessibilityUpdating; + BOOL childFrameCompletionActive; + char *childFrameLastCandidate; + Lisp_Object childFrameLastBuffer; + EMACS_INT childFrameLastModiff; #endif BOOL font_panel_active; NSFont *font_panel_result; @@ -665,6 +673,7 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType) - (void)rebuildAccessibilityTree; - (void)invalidateAccessibilityTree; - (void)postAccessibilityUpdates; +- (void)announceChildFrameCompletion; #endif @end diff --git a/src/nsterm.m b/src/nsterm.m index 8d44b5f..a6d2bf1 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -7415,6 +7415,112 @@ visual line index for Zoom (skip whitespace-only lines return nil; } + + +/* Scan buffer text of a child frame for the selected completion + candidate. Used for frameworks that render candidates in a + child frame (e.g. Corfu, Company-box) rather than as overlay + strings. Check the effective face (text properties + overlays) + at the start of each line via Fget_char_property. + + Returns the candidate text (trimmed) or nil. Sets + *OUT_LINE_INDEX to the 0-based line index for Zoom. */ +static NSString * +ns_ax_selected_child_frame_text (struct buffer *b, Lisp_Object buf_obj, + int *out_line_index) +{ + *out_line_index = -1; + ptrdiff_t beg = BUF_BEGV (b); + ptrdiff_t end = BUF_ZV (b); + + if (beg >= end) + return nil; + + /* Temporarily switch to the child frame buffer. + Fbuffer_substring_no_properties operates on current_buffer, + which may be a different buffer (e.g., the parent frame's). */ + specpdl_ref count = SPECPDL_INDEX (); + record_unwind_current_buffer (); + set_buffer_internal_1 (b); + + /* Get buffer text as a Lisp string for efficient scanning. + The buffer is a small completion popup (typically < 20 lines). */ + Lisp_Object str + = Fbuffer_substring_no_properties (make_fixnum (beg), + make_fixnum (end)); + if (!STRINGP (str) || SCHARS (str) == 0) + { + unbind_to (count, Qnil); + return nil; + } + + /* Scan newlines (same pattern as ns_ax_selected_overlay_text). + The data pointer is used only in this loop, before Lisp calls. */ + const unsigned char *data = SDATA (str); + ptrdiff_t byte_len = SBYTES (str); + ptrdiff_t line_starts[128]; + ptrdiff_t line_ends[128]; + int nlines = 0; + ptrdiff_t char_pos = 0, byte_pos = 0, lstart = 0; + + while (byte_pos < byte_len && nlines < 128) + { + if (data[byte_pos] == '\n') + { + if (char_pos > lstart) + { + line_starts[nlines] = lstart; + line_ends[nlines] = char_pos; + nlines++; + } + lstart = char_pos + 1; + } + if (STRING_MULTIBYTE (str)) + byte_pos += BYTES_BY_CHAR_HEAD (data[byte_pos]); + else + byte_pos++; + char_pos++; + } + if (char_pos > lstart && nlines < 128) + { + line_starts[nlines] = lstart; + line_ends[nlines] = char_pos; + nlines++; + } + + /* Find the line with a selected face. Use Fget_char_property on + the BUFFER (not the string) so overlay faces are included. + Offset string positions by beg to get buffer positions. */ + for (int li = 0; li < nlines; li++) + { + ptrdiff_t buf_pos = beg + line_starts[li]; + Lisp_Object face + = Fget_char_property (make_fixnum (buf_pos), Qface, buf_obj); + + if (ns_ax_face_is_selected (face)) + { + Lisp_Object line + = Fsubstring_no_properties (str, + make_fixnum (line_starts[li]), + make_fixnum (line_ends[li])); + NSString *text = [NSString stringWithLispString:line]; + text = [text stringByTrimmingCharactersInSet: + [NSCharacterSet + whitespaceAndNewlineCharacterSet]]; + if ([text length] > 0) + { + *out_line_index = li; + unbind_to (count, Qnil); + return text; + } + } + } + + unbind_to (count, Qnil); + return nil; +} + + /* Build accessibility text for window W, skipping invisible text. Populates *OUT_START with the buffer start charpos. Populates *OUT_RUNS with an array of visible runs and *OUT_NRUNS @@ -8046,6 +8152,7 @@ that remapped bindings (e.g., C-j -> next-line) are recognized. @implementation EmacsAccessibilityBuffer @synthesize cachedText; @synthesize cachedTextModiff; +@synthesize cachedOverlayModiffForText; @synthesize cachedOverlayModiff; @synthesize cachedTextStart; @synthesize cachedModiff; @@ -8159,16 +8266,34 @@ - (void)ensureTextCache if (!b) return; - ptrdiff_t modiff = BUF_MODIFF (b); - ptrdiff_t pt = BUF_PT (b); - NSUInteger textLen = cachedText ? [cachedText length] : 0; - /* Cache validity: track BUF_MODIFF and buffer narrowing. + /* Use BUF_CHARS_MODIFF, not BUF_MODIFF, for cache validity. + BUF_MODIFF is bumped by every text-property change, including + font-lock face applications on every redisplay. AX text contains + only characters, not face data, so property-only changes do not + affect the cached value. Rebuilding the full buffer text on + each font-lock pass is O(buffer-size) per redisplay --- this + causes progressive slowdown when scrolling through large files. + BUF_CHARS_MODIFF is bumped only on actual character insertions + and deletions, matching the semantic of "did the text change". + This is the pattern used by WebKit and NSTextView. Do NOT track BUF_OVERLAY_MODIFF here --- overlay text is not included in the cached AX text (it is handled separately via - explicit announcements). Including overlay_modiff would - silently update cachedOverlayModiff and prevent the - notification dispatch from detecting overlay changes. */ - if (cachedText && cachedTextModiff == modiff + explicit announcements in postAccessibilityNotificationsForFrame). + Including overlay_modiff would silently update cachedOverlayModiff + and prevent the notification dispatch from detecting changes. */ + ptrdiff_t chars_modiff = BUF_CHARS_MODIFF (b); + ptrdiff_t overlay_modiff = BUF_OVERLAY_MODIFF (b); + ptrdiff_t pt = BUF_PT (b); + NSUInteger textLen = cachedText ? [cachedText length] : 0; + /* Cache is valid when neither characters nor fold-state have changed. + BUF_CHARS_MODIFF guards against character edits and is not bumped + by font-lock (text-property changes), preserving the O(1) hot path. + BUF_OVERLAY_MODIFF catches fold/unfold: outline-mode, org-mode, and + hideshow all use overlays (via outline-flag-region) in Emacs 28+. + Font-lock uses only text properties, so adding BUF_OVERLAY_MODIFF + here does not reintroduce the per-redisplay rebuild. */ + if (cachedText && cachedTextModiff == chars_modiff + && cachedOverlayModiffForText == overlay_modiff && cachedTextStart == BUF_BEGV (b) && pt >= cachedTextStart && (textLen == 0 @@ -8184,7 +8309,8 @@ included in the cached AX text (it is handled separately via { [cachedText release]; cachedText = [text retain]; - cachedTextModiff = modiff; + cachedTextModiff = chars_modiff; + cachedOverlayModiffForText = overlay_modiff; cachedTextStart = start; if (visibleRuns) @@ -9060,11 +9186,13 @@ - (void)postFocusedCursorNotification:(ptrdiff_t)point = @(ns_ax_text_state_change_selection_move); moveInfo[@"AXTextSelectionDirection"] = @(direction); moveInfo[@"AXTextChangeElement"] = self; - /* Omit granularity for character moves so VoiceOver does not - derive its own speech (it would read the wrong character - for evil block-cursor mode). Include it for word/line/ - selection so VoiceOver reads the appropriate text. */ - if (!isCharMove) + /* Include granularity for sequential moves so VoiceOver reads the + appropriate unit. Omit for character moves (announced explicitly + below) and for discontiguous jumps (destination line announced + explicitly; omitting granularity lets VoiceOver use its default + behaviour and re-anchor its browse cursor). */ + if (!isCharMove + && direction != ns_ax_text_selection_direction_discontiguous) moveInfo[@"AXTextSelectionGranularity"] = @(granularity); ns_ax_post_notification_with_info ( @@ -9175,6 +9303,7 @@ - (void)postCompletionAnnouncementForBuffer:(struct buffer *)b ptrdiff_t currentOverlayStart = 0; ptrdiff_t currentOverlayEnd = 0; + block_input (); specpdl_ref count2 = SPECPDL_INDEX (); record_unwind_current_buffer (); if (b != current_buffer) @@ -9352,6 +9481,49 @@ - (void)postAccessibilityNotificationsForFrame:(struct frame *)f if (!b) return; + /* --- Echo area announcements --- + When the minibuffer is not active for user input (minibuf_level == 0) + and its character content changes, announce the new message to + VoiceOver. This surfaces error messages, completion notices, and + process status updates (e.g. "Wrote file", "Git finished"). + + Priority High interrupts ongoing speech, which matches the urgency + of a status change. While minibuf_level > 0 the user is composing + a command; we fall through to normal cursor and completion tracking + in that case. */ + if (MINI_WINDOW_P (w) && minibuf_level == 0) + { + ptrdiff_t echo_chars = BUF_CHARS_MODIFF (b); + if (echo_chars != self.cachedCharsModiff + && BUF_ZV (b) > BUF_BEGV (b)) + { + self.cachedCharsModiff = echo_chars; + struct buffer *prev = current_buffer; + set_buffer_internal (b); + Lisp_Object ls = Fbuffer_string (); + set_buffer_internal (prev); + /* Use stringWithLispString: — it converts Emacs's internal + multibyte encoding to NSString correctly, unlike a raw + UTF-8 cast via SSDATA which fails for non-ASCII text. */ + NSString *raw = [NSString stringWithLispString: ls]; + NSString *msg = [raw stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if ([msg length] > 0) + { + NSDictionary *info = @{ + NSAccessibilityAnnouncementKey: msg, + NSAccessibilityPriorityKey: + @(NSAccessibilityPriorityHigh) + }; + ns_ax_post_notification_with_info ( + NSApp, + NSAccessibilityAnnouncementRequestedNotification, + info); + } + } + return; + } + ptrdiff_t modiff = BUF_MODIFF (b); ptrdiff_t point = BUF_PT (b); BOOL markActive = !NILP (BVAR (b, mark_active)); @@ -9488,6 +9660,16 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property 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; + /* Post notifications for focused and non-focused elements. */ if ([self isAccessibilityFocused]) [self postFocusedCursorNotification:point @@ -9931,6 +10113,10 @@ - (void)dealloc #endif [accessibilityElements release]; +#ifdef NS_IMPL_COCOA + if (childFrameLastCandidate) + xfree (childFrameLastCandidate); +#endif [[self menu] release]; [super dealloc]; } @@ -11380,6 +11566,9 @@ - (instancetype) initFrameFromEmacs: (struct frame *)f windowClosing = NO; processingCompose = NO; +#ifdef NS_IMPL_COCOA + childFrameLastBuffer = Qnil; +#endif scrollbarsNeedingUpdate = 0; fs_state = FULLSCREEN_NONE; fs_before_fs = next_maximized = -1; @@ -12688,6 +12877,80 @@ - (id)accessibilityFocusedUIElement 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. */ + +/* Announce the selected candidate in a child frame completion popup. + Handles Corfu, Company-box, and similar frameworks that render + candidates in a separate child frame rather than as overlay strings + in the minibuffer. */ +- (void)announceChildFrameCompletion +{ + + /* Validate frame state --- child frames may be partially + initialized during creation. */ + if (!WINDOWP (emacsframe->selected_window)) + return; + struct window *w = XWINDOW (emacsframe->selected_window); + if (!BUFFERP (w->contents)) + return; + struct buffer *b = XBUFFER (w->contents); + + /* Only scan when the buffer content has actually changed. + This prevents redundant work on every redisplay tick and + also guards against re-entrance: if Lisp calls below + trigger redisplay, the modiff check short-circuits. */ + EMACS_INT modiff = BUF_MODIFF (b); + if (!BUFFER_LIVE_P (b)) + return; + if (EQ (childFrameLastBuffer, make_lisp_ptr (b, Lisp_Vectorlike)) + && modiff == childFrameLastModiff) + return; + childFrameLastBuffer = make_lisp_ptr (b, Lisp_Vectorlike); + childFrameLastModiff = modiff; + + /* Skip buffers larger than a typical completion popup. + This avoids scanning eldoc, which-key, or other child + frame buffers that are not completion UIs. */ + if (BUF_ZV (b) - BUF_BEGV (b) > 10000) + return; + + int selected_line = -1; + NSString *candidate + = ns_ax_selected_child_frame_text (b, w->contents, &selected_line); + + if (!candidate) + return; + + /* Deduplicate --- avoid re-announcing the same candidate. */ + const char *cstr = [candidate UTF8String]; + if (childFrameLastCandidate && strcmp (cstr, childFrameLastCandidate) == 0) + return; + xfree (childFrameLastCandidate); + childFrameLastCandidate = xstrdup (cstr); + + NSDictionary *annInfo = @{ + NSAccessibilityAnnouncementKey: candidate, + NSAccessibilityPriorityKey: + @(NSAccessibilityPriorityHigh) + }; + ns_ax_post_notification_with_info ( + NSApp, + NSAccessibilityAnnouncementRequestedNotification, + annInfo); + + /* Mark the parent as having an active child frame completion. + When the child frame closes, the parent's next accessibility + cycle will post FocusedUIElementChanged to restore VoiceOver's + focus to the buffer text element. */ + struct frame *parent = FRAME_PARENT_FRAME (emacsframe); + if (parent) + { + EmacsView *parentView = FRAME_NS_VIEW (parent); + if (parentView) + parentView->childFrameCompletionActive = YES; + } + +} + - (void)postAccessibilityUpdates { NSTRACE ("[EmacsView postAccessibilityUpdates]"); @@ -12698,11 +12961,59 @@ - (void)postAccessibilityUpdates /* Re-entrance guard: VoiceOver callbacks during notification posting can trigger redisplay, which calls ns_update_end, which calls us - again. Prevent infinite recursion. */ + again. Prevent infinite recursion. This MUST come before the + child frame check --- announceChildFrameCompletion makes Lisp + calls that can trigger redisplay. */ if (accessibilityUpdating) return; accessibilityUpdating = YES; + /* Child frame completion popup (Corfu, Company-box, etc.). + Child frames don't participate in the accessibility tree; + announce the selected candidate directly. */ + if (FRAME_PARENT_FRAME (emacsframe)) + { + [self announceChildFrameCompletion]; + accessibilityUpdating = NO; + return; + } + + /* If a child frame completion was recently active but no child + frame is visible anymore, refocus VoiceOver on the buffer + element so character echo and cursor tracking resume. + Skip if a child frame still exists (completion still open). */ + if (childFrameCompletionActive) + { + Lisp_Object tail, frame; + BOOL childStillVisible = NO; + FOR_EACH_FRAME (tail, frame) + if (FRAME_PARENT_FRAME (XFRAME (frame)) == emacsframe + && FRAME_VISIBLE_P (XFRAME (frame))) + { + childStillVisible = YES; + break; + } + + if (!childStillVisible) + { + childFrameCompletionActive = NO; + EmacsAccessibilityBuffer *focused = nil; + for (id elem in accessibilityElements) + if ([elem isKindOfClass: + [EmacsAccessibilityBuffer class]] + && [(EmacsAccessibilityBuffer *)elem + isAccessibilityFocused]) + { + focused = elem; + break; + } + if (focused) + ns_ax_post_notification ( + focused, + NSAccessibilityFocusedUIElementChangedNotification); + } + } + /* 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); -- 2.43.0