From 8aa35132a10eaa12d0ff40389973c6b84e2ef659 Mon Sep 17 00:00:00 2001 From: Martin Sukany Date: Sat, 28 Feb 2026 14:46:25 +0100 Subject: [PATCH] ns: announce overlay and child frame completion for VoiceOver Completion frameworks render candidates either as overlay strings (Vertico, Ivy, Icomplete in the minibuffer) or in a child frame (Corfu, Company-box). Without this patch, VoiceOver cannot read either type of completion UI. Overlay completion (minibuffer): Identify the selected candidate by scanning overlay before-string and after-string properties for a face whose symbol name contains "current", "selected", or "selection" --- matching vertico-current, icomplete-selected-match, ivy-current-match, company-tooltip-selection, and similar framework faces without hard-coding any specific name. - The overlay detection branch runs independently (if, not else-if) of the text-change branch, because Vertico bumps both BUF_MODIFF and BUF_OVERLAY_MODIFF in the same command cycle. - Use BUF_CHARS_MODIFF to gate ValueChanged notifications, since text property changes bump BUF_MODIFF but not BUF_CHARS_MODIFF. - Remove BUF_OVERLAY_MODIFF from ensureTextCache validity checks to prevent a race where AX queries consume the overlay change before the notification dispatch runs. - Announce via AnnouncementRequested to NSApp with High priority. - Zoom tracking: store the candidate rect (at text area left edge, computed from FRAME_LINE_HEIGHT) in overlayZoomRect. The flag is cleared on typing or minibuffer exit. Child frame completion (in-buffer): Detect child frames in postAccessibilityUpdates via FRAME_PARENT_FRAME. Scan the child frame buffer text for a line with a selected face (via Fget_char_property, which checks both text properties and overlay faces). Post announcement and call UAZoomChangeFocus directly (the child frame renders after the parent's draw_window_cursor, so the last Zoom call wins). * src/nsterm.h (EmacsView): Add overlayZoomActive, overlayZoomRect, announceChildFrameCompletion. (EmacsAccessibilityBuffer): Add cachedCharsModiff. * src/nsterm.m (ns_ax_face_is_selected): New predicate. (ns_ax_selected_overlay_text): New function (overlay strings). (ns_ax_selected_child_frame_text): New function (buffer text). (ns_draw_window_cursor): Use overlayZoomRect when active. (EmacsAccessibilityBuffer ensureTextCache): Remove overlay_modiff. (EmacsAccessibilityBuffer postAccessibilityNotificationsForFrame:): Independent overlay branch with BUF_CHARS_MODIFF gating. (EmacsView announceChildFrameCompletion): New method. (EmacsView postAccessibilityUpdates): Dispatch to child frame handler for FRAME_PARENT_FRAME frames. --- src/nsterm.h | 4 + src/nsterm.m | 482 +++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 450 insertions(+), 36 deletions(-) diff --git a/src/nsterm.h b/src/nsterm.h index 51c30ca..21b2823 100644 --- a/src/nsterm.h +++ b/src/nsterm.h @@ -507,6 +507,7 @@ typedef struct ns_ax_visible_run @property (nonatomic, assign) ptrdiff_t cachedOverlayModiff; @property (nonatomic, assign) ptrdiff_t cachedTextStart; @property (nonatomic, assign) ptrdiff_t cachedModiff; +@property (nonatomic, assign) ptrdiff_t cachedCharsModiff; @property (nonatomic, assign) ptrdiff_t cachedPoint; @property (nonatomic, assign) BOOL cachedMarkActive; @property (nonatomic, copy) NSString *cachedCompletionAnnouncement; @@ -591,6 +592,8 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType) BOOL accessibilityUpdating; @public /* Accessed by ns_draw_phys_cursor (C function). */ NSRect lastAccessibilityCursorRect; + BOOL overlayZoomActive; + NSRect overlayZoomRect; #endif BOOL font_panel_active; NSFont *font_panel_result; @@ -654,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 1780194..59dc14d 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -3258,7 +3258,12 @@ ns_draw_window_cursor (struct window *w, struct glyph_row *glyph_row, && MAC_OS_X_VERSION_MIN_REQUIRED >= 101000 if (UAZoomEnabled ()) { - NSRect windowRect = [view convertRect:r toView:nil]; + /* When overlay completion is active (e.g. Vertico), + focus Zoom on the selected candidate row instead + of the text cursor. */ + NSRect zoomSrc = view->overlayZoomActive + ? view->overlayZoomRect : r; + NSRect windowRect = [view convertRect:zoomSrc toView:nil]; NSRect screenRect = [[view window] convertRectToScreen:windowRect]; CGRect cgRect = NSRectToCGRect (screenRect); @@ -6915,11 +6920,248 @@ ns_create_font_panel_buttons (id target, SEL select, SEL cancel_action) are truncated for accessibility purposes. */ #define NS_AX_TEXT_CAP 100000 +/* Return true if FACE is or contains a face symbol whose name + includes "current" or "selected", indicating a highlighted + completion candidate. Works for vertico-current, + icomplete-selected-match, ivy-current-match, etc. */ +static bool +ns_ax_face_is_selected (Lisp_Object face) +{ + if (SYMBOLP (face) && !NILP (face)) + { + const char *name = SSDATA (SYMBOL_NAME (face)); + /* Substring match is intentionally broad --- it catches + vertico-current, icomplete-selected-match, ivy-current-match, + company-tooltip-selection, and similar. False positives are + harmless since this runs only on overlay strings during + completion. */ + if (strstr (name, "current") || strstr (name, "selected") + || strstr (name, "selection")) + return true; + } + if (CONSP (face)) + { + for (Lisp_Object tail = face; CONSP (tail); tail = XCDR (tail)) + if (ns_ax_face_is_selected (XCAR (tail))) + return true; + } + return false; +} + +/* Extract the currently selected candidate text from overlay display + strings. Completion frameworks render candidates as overlay + before-string/after-string and highlight the current candidate + with a face whose name contains "current" or "selected" + (e.g. vertico-current, icomplete-selected-match, ivy-current-match). + + Scan all overlays in the buffer region [BEG, END), find the line + whose face matches the selection heuristic, and return it (already + trimmed of surrounding whitespace). + + Also set *OUT_LINE_INDEX to the 0-based visual line index of the + selected candidate (for Zoom positioning), counting only non-trivial + lines. Set to -1 if not found. + + Returns nil if no selected candidate is found. */ +static NSString * +ns_ax_selected_overlay_text (struct buffer *b, + ptrdiff_t beg, ptrdiff_t end, + int *out_line_index) +{ + *out_line_index = -1; + + Lisp_Object ov_list = Foverlays_in (make_fixnum (beg), + make_fixnum (end)); + + for (Lisp_Object tail = ov_list; CONSP (tail); tail = XCDR (tail)) + { + Lisp_Object ov = XCAR (tail); + Lisp_Object strings[2]; + strings[0] = Foverlay_get (ov, intern_c_string ("before-string")); + strings[1] = Foverlay_get (ov, intern_c_string ("after-string")); + + for (int s = 0; s < 2; s++) + { + if (!STRINGP (strings[s])) + continue; + + Lisp_Object str = strings[s]; + ptrdiff_t slen = SCHARS (str); + if (slen == 0) + continue; + + /* Scan for newline positions using SDATA for efficiency. + The data pointer is used only in this loop, before any + Lisp calls (Fget_text_property etc.) that could trigger + GC and relocate string data. */ + const unsigned char *data = SDATA (str); + ptrdiff_t byte_len = SBYTES (str); + /* 512 lines is sufficient for any completion UI; + vertico-count defaults to 10. */ + ptrdiff_t line_starts[512]; + ptrdiff_t line_ends[512]; + int nlines = 0; + ptrdiff_t char_pos = 0, byte_pos = 0, lstart = 0; + + while (byte_pos < byte_len && nlines < 512) + { + 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 < 512) + { + line_starts[nlines] = lstart; + line_ends[nlines] = char_pos; + nlines++; + } + + /* Find the line whose face indicates selection. Track + visual line index for Zoom (skip whitespace-only lines + like Vertico's leading cursor-space). */ + int candidate_idx = 0; + for (int li = 0; li < nlines; li++) + { + Lisp_Object face + = Fget_text_property (make_fixnum (line_starts[li]), + Qface, str); + 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 = candidate_idx; + return text; + } + } + + /* Count non-trivial lines as candidates for Zoom. */ + if (line_ends[li] - line_starts[li] > 1) + candidate_idx++; + } + } + } + + 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; + + /* 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) + 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; + return text; + } + } + } + + 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 with the count. Caller must free *OUT_RUNS with xfree(). */ - static NSString * ns_ax_buffer_text (struct window *w, ptrdiff_t *out_start, ns_ax_visible_run **out_runs, NSUInteger *out_nruns) @@ -6996,7 +7238,7 @@ ns_ax_buffer_text (struct window *w, ptrdiff_t *out_start, /* Extract this visible run's text. Use Fbuffer_substring_no_properties which correctly handles the - buffer gap — raw BUF_BYTE_ADDRESS reads across the gap would + buffer gap --- raw BUF_BYTE_ADDRESS reads across the gap would include garbage bytes when the run spans the gap position. */ Lisp_Object lstr = Fbuffer_substring_no_properties ( make_fixnum (pos), make_fixnum (run_end)); @@ -7077,7 +7319,7 @@ ns_ax_frame_for_range (struct window *w, EmacsView *view, return NSZeroRect; /* charpos_start and charpos_len are already in buffer charpos - space — the caller maps AX string indices through + space --- the caller maps AX string indices through charposForAccessibilityIndex which handles invisible text. */ ptrdiff_t cp_start = charpos_start; ptrdiff_t cp_end = cp_start + charpos_len; @@ -7556,6 +7798,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @synthesize cachedOverlayModiff; @synthesize cachedTextStart; @synthesize cachedModiff; +@synthesize cachedCharsModiff; @synthesize cachedPoint; @synthesize cachedMarkActive; @synthesize cachedCompletionAnnouncement; @@ -7596,7 +7839,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, NSTRACE ("EmacsAccessibilityBuffer ensureTextCache"); /* This method is only called from the main thread (AX getters dispatch_sync to main first). Reads of cachedText/cachedTextModiff - below are therefore safe without @synchronized — only the + below are therefore safe without @synchronized --- only the write section at the end needs synchronization to protect against concurrent reads from AX server thread. */ eassert ([NSThread isMainThread]); @@ -7609,16 +7852,15 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, return; ptrdiff_t modiff = BUF_MODIFF (b); - ptrdiff_t overlay_modiff = BUF_OVERLAY_MODIFF (b); ptrdiff_t pt = BUF_PT (b); NSUInteger textLen = cachedText ? [cachedText length] : 0; - /* Track both BUF_MODIFF and BUF_OVERLAY_MODIFF. Overlay-only - changes (e.g., timer-based completion highlight move without - text edit) bump overlay_modiff but not modiff. Also detect - narrowing/widening which changes BUF_BEGV without bumping - either modiff counter. */ + /* Cache validity: track BUF_MODIFF and buffer narrowing. + 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 - && cachedOverlayModiff == overlay_modiff && cachedTextStart == BUF_BEGV (b) && pt >= cachedTextStart && (textLen == 0 @@ -7635,7 +7877,6 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, [cachedText release]; cachedText = [text retain]; cachedTextModiff = modiff; - cachedOverlayModiff = overlay_modiff; cachedTextStart = start; if (visibleRuns) @@ -7661,7 +7902,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, /* Binary search: runs are sorted by charpos (ascending). Find the run whose [charpos, charpos+length) range contains the target, or the nearest run after an invisible gap. O(log n) instead of - O(n) — matters for org-mode with many folded sections. */ + O(n) --- matters for org-mode with many folded sections. */ NSUInteger lo = 0, hi = visibleRunCount; while (lo < hi) { @@ -7674,7 +7915,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, else { /* Found: charpos is inside this run. Compute UTF-16 delta - directly from cachedText — no Lisp calls needed. */ + directly from cachedText --- no Lisp calls needed. */ NSUInteger chars_in = (NSUInteger)(charpos - r->charpos); if (chars_in == 0 || !cachedText) return r->ax_start; @@ -7699,10 +7940,10 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, /* Convert accessibility string index to buffer charpos. Safe to call from any thread: uses only cachedText (NSString) and - visibleRuns — no Lisp calls. */ + visibleRuns --- no Lisp calls. */ - (ptrdiff_t)charposForAccessibilityIndex:(NSUInteger)ax_idx { - /* May be called from AX server thread — synchronize. */ + /* May be called from AX server thread --- synchronize. */ @synchronized (self) { if (visibleRunCount == 0) @@ -7736,7 +7977,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, return cp; } } - /* Past end — return last charpos. */ + /* Past end --- return last charpos. */ if (lo > 0) { ns_ax_visible_run *last = &visibleRuns[visibleRunCount - 1]; @@ -7758,7 +7999,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, deadlocking the AX server thread. This is prevented by: 1. validWindow checks WINDOW_LIVE_P and BUFFERP before every - Lisp access — the window and buffer are verified live. + Lisp access --- the window and buffer are verified live. 2. All dispatch_sync blocks run on the main thread where no concurrent Lisp code can modify state between checks. 3. block_input prevents timer events and process output from @@ -8166,7 +8407,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, if (idx > [cachedText length]) idx = [cachedText length]; - /* Count lines by iterating lineRangeForRange — O(lines). */ + /* Count lines by iterating lineRangeForRange --- O(lines). */ NSInteger line = 0; NSUInteger scan = 0; NSUInteger len = [cachedText length]; @@ -8422,7 +8663,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, /* =================================================================== - EmacsAccessibilityBuffer (Notifications) — AX event dispatch + EmacsAccessibilityBuffer (Notifications) --- AX event dispatch These methods notify VoiceOver of text and selection changes. Called from the redisplay cycle (postAccessibilityUpdates). @@ -8437,7 +8678,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, if (point > self.cachedPoint && point - self.cachedPoint == 1) { - /* Single char inserted — refresh cache and grab it. */ + /* Single char inserted --- refresh cache and grab it. */ [self invalidateTextCache]; [self ensureTextCache]; if (cachedText) @@ -8456,7 +8697,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, /* Update cachedPoint here so the selection-move branch does NOT fire for point changes caused by edits. WebKit and Chromium never send both ValueChanged and SelectedTextChanged for the - same user action — they are mutually exclusive. */ + same user action --- they are mutually exclusive. */ self.cachedPoint = point; NSDictionary *change = @{ @@ -8789,14 +9030,112 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, BOOL markActive = !NILP (BVAR (b, mark_active)); /* --- Text changed (edit) --- */ + ptrdiff_t chars_modiff = BUF_CHARS_MODIFF (b); if (modiff != self.cachedModiff) { self.cachedModiff = modiff; - [self postTextChangedNotification:point]; + /* Only post ValueChanged when actual characters changed. + Text property changes (e.g. face updates from + vertico--prompt-selection) bump BUF_MODIFF but not + BUF_CHARS_MODIFF. Posting ValueChanged for property-only + changes causes VoiceOver to say "new line" when the diff + is non-empty due to overlay content changes. */ + if (chars_modiff != self.cachedCharsModiff) + { + self.cachedCharsModiff = chars_modiff; + self.emacsView->overlayZoomActive = NO; + [self postTextChangedNotification:point]; + } + } + + + /* --- Overlay content changed (e.g. Vertico/Ivy candidate switch) --- + Check independently of the modiff branch above, because + frameworks like Vertico bump BOTH BUF_MODIFF (via text property + changes in vertico--prompt-selection) and BUF_OVERLAY_MODIFF + (via overlay-put) in the same command cycle. If this were an + else-if, the modiff branch would always win and overlay + announcements would never fire. + Do NOT invalidate the text cache --- the buffer text has not + changed, and cache invalidation causes VoiceOver to diff old + vs new AX text and announce spurious "new line". */ + if (BUF_OVERLAY_MODIFF (b) != self.cachedOverlayModiff) + { + self.cachedOverlayModiff = BUF_OVERLAY_MODIFF (b); + + int selected_line = -1; + NSString *candidate + = ns_ax_selected_overlay_text (b, BUF_BEGV (b), BUF_ZV (b), + &selected_line); + if (candidate) + { + /* Deduplicate: only announce when the candidate changed. */ + if (![candidate isEqualToString: + self.cachedCompletionAnnouncement]) + { + self.cachedCompletionAnnouncement = candidate; + + /* Announce the candidate text directly via NSApp. + Do NOT post SelectedTextChanged --- that would cause + VoiceOver to read the AX text at the cursor position + (the minibuffer input line), not the overlay candidate. + AnnouncementRequested with High priority interrupts + any current speech and announces our text. */ + NSDictionary *annInfo = @{ + NSAccessibilityAnnouncementKey: candidate, + NSAccessibilityPriorityKey: + @(NSAccessibilityPriorityHigh) + }; + ns_ax_post_notification_with_info ( + NSApp, + NSAccessibilityAnnouncementRequestedNotification, + annInfo); + + /* --- Zoom tracking for overlay candidates --- + Store the candidate row rect so draw_window_cursor + focuses Zoom there instead of on the text cursor. + Cleared when the user types (chars_modiff change). + + Use default line height to compute the Y offset: + row 0 is the input line, overlay candidates start + from row 1. This avoids fragile glyph matrix row + index mapping which can be off when group titles + or wrapped lines shift row numbering. */ + if (selected_line >= 0) + { + struct window *w2 = [self validWindow]; + if (w2) + { + EmacsView *view = self.emacsView; + struct frame *f2 = XFRAME (w2->frame); + int line_h = FRAME_LINE_HEIGHT (f2); + int y_off = (selected_line + 1) * line_h; + + if (y_off < w2->pixel_height) + { + view->overlayZoomRect = NSMakeRect ( + WINDOW_TEXT_TO_FRAME_PIXEL_X (w2, 0), + WINDOW_TO_FRAME_PIXEL_Y (w2, y_off), + FRAME_COLUMN_WIDTH (f2), + line_h); + view->overlayZoomActive = YES; + } + } + } + } + } + else + { + /* No selected candidate --- overlay completion ended + (minibuffer exit, C-g, etc.) or overlay has no + recognizable selection face. Return Zoom to the + text cursor. */ + self.emacsView->overlayZoomActive = NO; + } } /* --- Cursor moved or selection changed --- - Use 'else if' — edits and selection moves are mutually exclusive + Use 'else if' --- edits and selection moves are mutually exclusive per the WebKit/Chromium pattern. */ else if (point != self.cachedPoint || markActive != self.cachedMarkActive) { @@ -8966,7 +9305,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, /* =================================================================== - EmacsAccessibilityInteractiveSpan — helpers and implementation + EmacsAccessibilityInteractiveSpan --- helpers and implementation =================================================================== */ /* Scan visible range of window W for interactive spans. @@ -9157,7 +9496,7 @@ ns_ax_scan_interactive_spans (struct window *w, - (BOOL) isAccessibilityFocused { /* Read the cached point stored by EmacsAccessibilityBuffer on the main - thread — safe to read from any thread (plain ptrdiff_t, no Lisp calls). */ + thread --- safe to read from any thread (plain ptrdiff_t, no Lisp calls). */ EmacsAccessibilityBuffer *pb = self.parentBuffer; if (!pb) return NO; @@ -9174,7 +9513,7 @@ ns_ax_scan_interactive_spans (struct window *w, dispatch_async (dispatch_get_main_queue (), ^{ /* lwin is a Lisp_Object captured by value. This is GC-safe because Lisp_Objects are tagged integers/pointers that - remain valid across GC — GC does not relocate objects in + remain valid across GC --- GC does not relocate objects in Emacs. The WINDOW_LIVE_P check below guards against the window being deleted between capture and execution. */ if (!WINDOWP (lwin) || NILP (Fwindow_live_p (lwin))) @@ -9200,7 +9539,7 @@ ns_ax_scan_interactive_spans (struct window *w, @end -/* EmacsAccessibilityBuffer — InteractiveSpans category. +/* EmacsAccessibilityBuffer --- InteractiveSpans category. Methods are kept here (same .m file) so they access the ivars declared in the @interface ivar block. */ @implementation EmacsAccessibilityBuffer (InteractiveSpans) @@ -10520,13 +10859,13 @@ ns_in_echo_area (void) if (old_title == 0) { char *t = strdup ([[[self window] title] UTF8String]); - char *pos = strstr (t, " — "); + char *pos = strstr (t, " --- "); if (pos) *pos = '\0'; old_title = t; } size_title = xmalloc (strlen (old_title) + 40); - esprintf (size_title, "%s — (%d × %d)", old_title, cols, rows); + esprintf (size_title, "%s --- (%d × %d)", old_title, cols, rows); [window setTitle: [NSString stringWithUTF8String: size_title]]; [window display]; xfree (size_title); @@ -11922,7 +12261,7 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view, if (WINDOW_LEAF_P (w)) { - /* Buffer element — reuse existing if available. */ + /* Buffer element --- reuse existing if available. */ EmacsAccessibilityBuffer *elem = [existing objectForKey:[NSValue valueWithPointer:w]]; if (!elem) @@ -11956,7 +12295,7 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view, } else { - /* Internal (combination) window — recurse into children. */ + /* Internal (combination) window --- recurse into children. */ Lisp_Object child = w->contents; while (!NILP (child)) { @@ -12052,6 +12391,68 @@ 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; + + struct window *w = XWINDOW (emacsframe->selected_window); + struct buffer *b = XBUFFER (w->contents); + 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]"); @@ -12060,6 +12461,15 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view, if (!emacsframe || !ns_accessibility_enabled) return; + /* 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]; + return; + } + /* Re-entrance guard: VoiceOver callbacks during notification posting can trigger redisplay, which calls ns_update_end, which calls us again. Prevent infinite recursion. */ @@ -12068,7 +12478,7 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view, accessibilityUpdating = YES; /* Detect window tree change (split, delete, new buffer). Compare - FRAME_ROOT_WINDOW — if it changed, the tree structure changed. */ + FRAME_ROOT_WINDOW --- if it changed, the tree structure changed. */ Lisp_Object curRoot = FRAME_ROOT_WINDOW (emacsframe); if (!EQ (curRoot, lastRootWindow)) { @@ -12077,12 +12487,12 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view, } /* If tree is stale, rebuild FIRST so we don't iterate freed - window pointers. Skip notifications for this cycle — the + 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. */ + /* Invalidate span cache --- window layout changed. */ for (EmacsAccessibilityElement *elem in accessibilityElements) if ([elem isKindOfClass: [EmacsAccessibilityBuffer class]]) [(EmacsAccessibilityBuffer *) elem invalidateInteractiveSpans]; -- 2.43.0