From ee7441e38dc428f86c36045648bac2c487768f48 Mon Sep 17 00:00:00 2001 From: Martin Sukany Date: Sat, 28 Feb 2026 16:01:29 +0100 Subject: [PATCH 2/2] 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 measures: - Use record_unwind_current_buffer / set_buffer_internal_1 to temporarily switch to the child frame buffer before calling Fbuffer_substring_no_properties (which operates on current_buffer). All return paths call unbind_to to restore the original buffer. - The re-entrance guard (accessibilityUpdating) MUST come before the child frame dispatch, because Lisp calls in the scan function can trigger redisplay. - BUF_MODIFF gating prevents redundant scans on every redisplay tick and provides a secondary re-entrance guard. - Frame state validation (WINDOWP, BUFFERP) handles partially initialized child frames during creation. - Buffer size limit (10000 chars) skips non-completion child frames such as eldoc documentation or which-key popups. Remove the child frame window from the accessibility tree via setAccessibilityElement:NO to prevent VoiceOver's blocking "X window" announcement. Our candidate announcements go to NSApp (not the window element) and are unaffected. Zoom tracking stores the candidate rect in the PARENT frame's overlayZoomRect (via child view -> screen -> parent view coordinate conversion), so that the parent's draw_window_cursor focuses Zoom on the candidate instead of the text cursor. * src/nsterm.h (EmacsView): Add announceChildFrameCompletion. * src/nsterm.m (ns_ax_selected_child_frame_text): New function. (EmacsView announceChildFrameCompletion): New method. (EmacsView postAccessibilityUpdates): Dispatch to child frame handler for FRAME_PARENT_FRAME frames, under re-entrance guard. --- src/nsterm.h | 1 + src/nsterm.m | 231 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 231 insertions(+), 1 deletion(-) diff --git a/src/nsterm.h b/src/nsterm.h index 5c15639..21b2823 100644 --- a/src/nsterm.h +++ b/src/nsterm.h @@ -657,6 +657,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 d13c5c7..3735f01 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -7066,6 +7066,110 @@ ns_ax_selected_overlay_text (struct buffer *b, } +/* 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 @@ -12299,6 +12403,119 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view, 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. Uses direct UAZoomChangeFocus (not the + overlayZoomRect flag) because the child frame's ns_update_end runs + after the parent's draw_window_cursor. */ +- (void)announceChildFrameCompletion +{ + static char *lastCandidate; + static struct buffer *lastBuffer; + static EMACS_INT lastModiff; + + /* 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 (b == lastBuffer && modiff == lastModiff) + return; + lastBuffer = b; + lastModiff = 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); + + struct frame *parent = FRAME_PARENT_FRAME (emacsframe); + + if (!candidate) + { + /* No selected candidate --- clear parent Zoom override. */ + if (parent) + { + EmacsView *parentView = FRAME_NS_VIEW (parent); + if (parentView) + parentView->overlayZoomActive = NO; + } + return; + } + + /* Deduplicate --- avoid re-announcing the same candidate. */ + const char *cstr = [candidate UTF8String]; + if (lastCandidate && strcmp (cstr, lastCandidate) == 0) + return; + xfree (lastCandidate); + lastCandidate = xstrdup (cstr); + + /* Suppress VoiceOver's automatic "X window" announcement for + the child frame by removing it from the accessibility tree. + Our candidate announcement goes to NSApp (not this window), + so it still reaches VoiceOver. Idempotent. */ + [[self window] setAccessibilityElement:NO]; + + NSDictionary *annInfo = @{ + NSAccessibilityAnnouncementKey: candidate, + NSAccessibilityPriorityKey: + @(NSAccessibilityPriorityHigh) + }; + ns_ax_post_notification_with_info ( + NSApp, + NSAccessibilityAnnouncementRequestedNotification, + annInfo); + + /* Zoom tracking: store the candidate rect in the PARENT frame's + overlayZoomRect so that draw_window_cursor focuses Zoom on the + candidate instead of the text cursor. Convert through screen + coordinates to handle arbitrary child frame positioning. */ + if (selected_line >= 0 && parent) + { + EmacsView *parentView = FRAME_NS_VIEW (parent); + if (parentView) + { + int line_h = FRAME_LINE_HEIGHT (emacsframe); + int y_off = selected_line * line_h; + NSRect childRect = NSMakeRect ( + WINDOW_TEXT_TO_FRAME_PIXEL_X (w, 0), + WINDOW_TO_FRAME_PIXEL_Y (w, y_off), + FRAME_COLUMN_WIDTH (emacsframe), + line_h); + + /* Child view → screen → parent view. */ + NSRect childWinR + = [self convertRect:childRect toView:nil]; + NSRect screenR + = [[self window] convertRectToScreen:childWinR]; + NSRect parentWinR + = [[parentView window] + convertRectFromScreen:screenR]; + NSRect parentViewR + = [parentView convertRect:parentWinR fromView:nil]; + + parentView->overlayZoomRect = parentViewR; + parentView->overlayZoomActive = YES; + } + } +} + - (void)postAccessibilityUpdates { NSTRACE ("[EmacsView postAccessibilityUpdates]"); @@ -12309,11 +12526,23 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view, /* 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; + } + /* 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