From e11d0688f119046827cd1895008d3a93e22f6d0d 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. Announce via AnnouncementRequested to NSApp with High priority. Use direct UAZoomChangeFocus (not the overlayZoomRect flag used for minibuffer overlay completion) because the child frame renders independently --- its ns_update_end runs after the parent frame's draw_window_cursor, so the last Zoom call wins. * 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 | 205 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 205 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..092bc11 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,93 @@ 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); + + if (!candidate) + 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); + + NSDictionary *annInfo = @{ + NSAccessibilityAnnouncementKey: candidate, + NSAccessibilityPriorityKey: + @(NSAccessibilityPriorityHigh) + }; + ns_ax_post_notification_with_info ( + NSApp, + NSAccessibilityAnnouncementRequestedNotification, + annInfo); + + /* Zoom tracking: focus on the selected row in the child frame. + Use direct UAZoomChangeFocus rather than overlayZoomRect because + the child frame renders independently of the parent. */ + if (selected_line >= 0 && UAZoomEnabled ()) + { + int line_h = FRAME_LINE_HEIGHT (emacsframe); + int y_off = selected_line * line_h; + NSRect r = NSMakeRect ( + WINDOW_TEXT_TO_FRAME_PIXEL_X (w, 0), + WINDOW_TO_FRAME_PIXEL_Y (w, y_off), + FRAME_COLUMN_WIDTH (emacsframe), + line_h); + NSRect winRect = [self convertRect:r toView:nil]; + NSRect screenRect + = [[self window] convertRectToScreen:winRect]; + CGRect cgRect = NSRectToCGRect (screenRect); + CGFloat primaryH + = [[[NSScreen screens] firstObject] frame].size.height; + cgRect.origin.y + = primaryH - cgRect.origin.y - cgRect.size.height; + UAZoomChangeFocus (&cgRect, &cgRect, + kUAZoomFocusTypeInsertionPoint); + } +} + - (void)postAccessibilityUpdates { NSTRACE ("[EmacsView postAccessibilityUpdates]"); @@ -12309,11 +12500,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