From fb4d1411fcc4a18cefae80dbed856fda8fe8c85e 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, Company-box, and similar render candidates in a child frame rather than as overlay strings in the minibuffer. This patch extends the overlay announcement support (patch 7/8) to handle child frame popups. Detect child frames via FRAME_PARENT_FRAME in postAccessibilityUpdates. Scan the child frame buffer text line by line using Fget_char_property (which checks both text properties and overlay face properties) to find the selected candidate. Reuse ns_ax_face_is_selected from the overlay patch to identify "current", "selected", and "selection" faces. Safety: - record_unwind_current_buffer / set_buffer_internal_1 to switch to the child frame buffer for Fbuffer_substring_no_properties. - Re-entrance guard (accessibilityUpdating) before child frame dispatch. - BUF_MODIFF gating prevents redundant scans. - WINDOWP, BUFFERP validation for partially initialized frames. - Buffer size limit (10000 chars) skips non-completion child frames. When the child frame closes, post FocusedUIElementChangedNotification on the parent buffer element to restore VoiceOver's character echo and cursor tracking. The flag childFrameCompletionActive is set by the child frame handler and cleared on the parent's next accessibility cycle when no child frame is visible (via FOR_EACH_FRAME). Announce via AnnouncementRequested to NSApp with High priority. independently --- its ns_update_end runs after the parent's * src/nsterm.h (EmacsView): Add announceChildFrameCompletion, childFrameCompletionActive flag. * src/nsterm.m (ns_ax_selected_child_frame_text): New function. (EmacsView announceChildFrameCompletion): New method, set parent flag. (EmacsView postAccessibilityUpdates): Dispatch to child frame handler, refocus parent buffer element when child frame closes. --- doc/emacs/macos.texi | 6 - etc/NEWS | 4 +- src/nsterm.h | 5 + src/nsterm.m | 266 +++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 263 insertions(+), 18 deletions(-) diff --git a/doc/emacs/macos.texi b/doc/emacs/macos.texi index 4825cf9..97777e2 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 2102fb9..dd98d56 100644 --- a/src/nsterm.h +++ b/src/nsterm.h @@ -594,6 +594,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; @@ -663,6 +667,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 a3104d0..6e8a226 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -7398,6 +7398,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 @@ -8142,16 +8248,25 @@ - (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 pt = BUF_PT (b); + NSUInteger textLen = cachedText ? [cachedText length] : 0; + if (cachedText && cachedTextModiff == chars_modiff && cachedTextStart == BUF_BEGV (b) && pt >= cachedTextStart && (textLen == 0 @@ -8167,7 +8282,7 @@ included in the cached AX text (it is handled separately via { [cachedText release]; cachedText = [text retain]; - cachedTextModiff = modiff; + cachedTextModiff = chars_modiff; cachedTextStart = start; if (visibleRuns) @@ -9154,6 +9269,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) @@ -9312,6 +9428,7 @@ - (void)postCompletionAnnouncementForBuffer:(struct buffer *)b self.cachedCompletionOverlayEnd = 0; self.cachedCompletionPoint = 0; } + unblock_input (); } /* ---- Notification dispatch (main entry point) ---- */ @@ -9908,6 +10025,10 @@ - (void)dealloc #endif [accessibilityElements release]; +#ifdef NS_IMPL_COCOA + if (childFrameLastCandidate) + xfree (childFrameLastCandidate); +#endif [[self menu] release]; [super dealloc]; } @@ -11357,6 +11478,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; @@ -12665,6 +12789,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]"); @@ -12675,11 +12873,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