From 10d8d56ed0364c9bc387600431a66dabe37b3e2a 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 | 18 +- etc/NEWS | 18 +- src/nsterm.h | 13 ++ src/nsterm.m | 468 +++++++++++++++++++++++++++++++++++++++---- 4 files changed, 466 insertions(+), 51 deletions(-) diff --git a/doc/emacs/macos.texi b/doc/emacs/macos.texi index 6514dfc..bcf74b3 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,10 +308,15 @@ 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. + Echo area messages are announced automatically. When a background +operation completes and displays a message (e.g., @samp{Git finished}, +@samp{Wrote file}), VoiceOver reads it without requiring any action. +Messages are suppressed while the minibuffer is active (i.e., while +you are typing a command) to avoid interrupting prompt reading. + + VoiceOver's rotor browse cursor stays synchronized with the Emacs +cursor after large programmatic jumps (for example, heading navigation +in Org mode, @code{xref-find-definitions}, or @code{imenu}). @vindex ns-accessibility-enabled To disable the accessibility interface entirely (for instance, to @@ -341,8 +345,8 @@ but @code{accessibilityRangeForPosition} hit-testing assumes left-to-right glyph layout. @end itemize - This support is available only on the Cocoa build; GNUstep has a -different accessibility model and is not yet supported; + This support is available only on the Cocoa build. GNUstep has a +different accessibility model and is not yet supported. @xref{GNUstep Support}. Evil-mode block cursors are handled correctly: character navigation announces the character at the cursor position, not the character before it. diff --git a/etc/NEWS b/etc/NEWS index 2b1f9e6..5766428 100644 --- a/etc/NEWS +++ b/etc/NEWS @@ -4400,16 +4400,20 @@ allowing Emacs users access to speech recognition utilities. Note: Accepting this permission allows the use of system APIs, which may 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 -(buttons, links, completion candidates), and completion announcements -for the *Completions* buffer. The implementation uses a virtual -accessibility tree with per-window elements, hybrid SelectedTextChanged -and AnnouncementRequested notifications, and thread-safe text caching. +includes: +- Line and word navigation announcements via standard movement keys. +- Echo area messages (e.g., "Wrote file", "Git finished") announced + automatically as they appear, without user interaction. +- VoiceOver rotor cursor synchronization after large programmatic + jumps (]], M-<, xref, imenu, etc.). +- Tab-navigable interactive spans (buttons, links, completion + candidates) within a buffer. +- Completion announcements for the *Completions* buffer and overlay + and child-frame completion UIs (Vertico, Corfu, Company-box). Set 'ns-accessibility-enabled' to nil to disable the accessibility interface and eliminate the associated overhead. diff --git a/src/nsterm.h b/src/nsterm.h index 21a93bc..8f2143b 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,14 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType) Lisp_Object lastRootWindow; BOOL accessibilityTreeValid; BOOL accessibilityUpdating; + BOOL childFrameCompletionActive; + char *childFrameLastCandidate; + Lisp_Object childFrameLastBuffer; + EMACS_INT childFrameLastModiff; + /* Last BUF_CHARS_MODIFF seen for echo_area_buffer[0]. Used by + postEchoAreaAnnouncementIfNeeded to detect new echo area messages + independently of the per-element notification cycle. */ + ptrdiff_t lastEchoCharsModiff; #endif BOOL font_panel_active; NSFont *font_panel_result; @@ -665,6 +677,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..34d7ee2 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -1126,24 +1126,19 @@ Uses CFAbsoluteTimeGetCurrent() (~5 ns, a VDSO read) for timing. */ ivy-current-match, etc. by checking the face symbol name. Defined here so the Zoom patch compiles independently of the VoiceOver patches. */ +/* Forward declaration --- ns_ax_face_is_selected is defined in the + VoiceOver section below; ns_zoom_face_is_selected delegates to it. */ +static bool ns_ax_face_is_selected (Lisp_Object face); + static bool ns_zoom_face_is_selected (Lisp_Object face) { - if (SYMBOLP (face)) - { - const char *name = SSDATA (SYMBOL_NAME (face)); - return (strstr (name, "current") != NULL - || strstr (name, "selected") != NULL - || strstr (name, "selection") != NULL); - } - if (CONSP (face)) - { - Lisp_Object tail; - for (tail = face; CONSP (tail); tail = XCDR (tail)) - if (ns_zoom_face_is_selected (XCAR (tail))) - return true; - } - return false; + /* Forward to ns_ax_face_is_selected (defined in the VoiceOver section + below) so that Zoom and VoiceOver agree on what constitutes a + "selected" face. Identical logic in two places would diverge over + time; one canonical implementation is preferable. + The forward declaration appears in nsterm.h. */ + return ns_ax_face_is_selected (face); } /* Scan overlay before-string / after-string properties in the @@ -1356,6 +1351,12 @@ so the visual offset is (ov_line + 1) * line_h from UAZoomChangeFocus (&cgRect, &cgRect, kUAZoomFocusTypeInsertionPoint); } + /* Unbind record_unwind_current_buffer for both overlay and child + frame paths. The overlay path calls unbind_to before return; + this call covers the child frame path and the no-candidate path. + unbind_to is idempotent if count equals the current specpdl + index (i.e. already unbound by the overlay path). */ + unbind_to (count, Qnil); } #endif /* MAC_OS_X_VERSION_MIN_REQUIRED >= 101000 */ @@ -7415,6 +7416,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 +8153,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 +8267,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 +8310,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 +9187,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 ( @@ -9107,12 +9236,17 @@ derive its own speech (it would read the wrong character } } - /* For focused line moves: always announce line text explicitly. - SelectedTextChanged with granularity=line works for arrow keys, - but C-n/C-p need the explicit announcement (VoiceOver processes - these keystrokes differently from arrows). + /* Announce the destination line text for all line-granularity moves. + This covers two cases: + - C-n/C-p: SelectedTextChanged carries granularity=line, but + VoiceOver processes those keystrokes specially and may not + produce speech; the explicit announcement is the reliable path. + - Discontiguous jumps (]], M-<, xref, imenu, …): granularity=line + in the notification is omitted (see above) so VoiceOver will + not announce automatically; this explicit announcement fills + the gap. In completion-list-mode, read the completion candidate instead - of the whole line. */ + of the full line. */ if (cachedText && granularity == ns_ax_text_selection_granularity_line) { @@ -9175,7 +9309,14 @@ - (void)postCompletionAnnouncementForBuffer:(struct buffer *)b ptrdiff_t currentOverlayStart = 0; ptrdiff_t currentOverlayEnd = 0; + block_input (); specpdl_ref count2 = SPECPDL_INDEX (); + /* Register unblock_input as an unwind action so that if any Lisp + call below signals (triggering a longjmp through unbind_to), + block_input is always paired with an unblock_input. The explicit + unblock_input() at the end of the function is still needed for + the normal (non-signal) path. */ + record_unwind_protect_void (unblock_input); record_unwind_current_buffer (); if (b != current_buffer) set_buffer_internal_1 (b); @@ -9352,12 +9493,29 @@ - (void)postAccessibilityNotificationsForFrame:(struct frame *)f if (!b) return; + /* Echo area announcements are handled in + postEchoAreaAnnouncementIfNeeded (called from postAccessibilityUpdates + before this per-element loop) so that they are never lost to a + concurrent tree rebuild. For the inactive minibuffer (minibuf_level + == 0), skip normal cursor and completion processing — there is no + meaningful cursor to track. */ + if (MINI_WINDOW_P (w) && minibuf_level == 0) + return; + ptrdiff_t modiff = BUF_MODIFF (b); ptrdiff_t point = BUF_PT (b); BOOL markActive = !NILP (BVAR (b, mark_active)); /* --- Text changed (edit) --- */ ptrdiff_t chars_modiff = BUF_CHARS_MODIFF (b); + /* Track whether the user typed a character this redisplay cycle. + Used below to suppress overlay completion announcements: when the + user types, character echo (via postTextChangedNotification) must + take priority over overlay candidate updates. Without this guard, + Vertico/Ivy updates its overlay immediately after each keystroke, + and the High-priority overlay announcement interrupts the character + echo, effectively silencing typed characters. */ + BOOL didTextChange = NO; if (modiff != self.cachedModiff) { self.cachedModiff = modiff; @@ -9371,6 +9529,7 @@ Text property changes (e.g. face updates from { self.cachedCharsModiff = chars_modiff; [self postTextChangedNotification:point]; + didTextChange = YES; } } @@ -9393,8 +9552,15 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property displayed in the minibuffer. In normal editing buffers, font-lock and other modes change BUF_OVERLAY_MODIFF on every redisplay, triggering O(overlays) work per keystroke. - Restrict the scan to minibuffer windows. */ - if (!MINI_WINDOW_P (w)) + Restrict the scan to minibuffer windows. + Skip overlay announcements when the user just typed a character + (didTextChange). Completion frameworks update their overlay + immediately after each keystroke; without this guard, the + overlay High-priority announcement would interrupt the character + echo produced by postTextChangedNotification, making typed + characters inaudible. VoiceOver should read the overlay + candidate only when the user navigates (C-n/C-p), not types. */ + if (!MINI_WINDOW_P (w) || didTextChange) goto skip_overlay_scan; int selected_line = -1; @@ -9488,6 +9654,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 @@ -9630,6 +9806,17 @@ - (NSRect)accessibilityFrame if (vis_start >= vis_end) return @[]; + /* block_input for the duration of the scan: the Lisp calls below + (Ftext_properties_at, Fplist_get, Foverlays_in, Foverlay_get, + Fnext_single_property_change, Fbuffer_substring_no_properties) + must not be interleaved with timer events or process sentinels + that could modify buffer state (e.g. invalidate vis_end). + record_unwind_protect_void guarantees unblock_input even if + a Lisp call signals. */ + block_input (); + specpdl_ref blk_count = SPECPDL_INDEX (); + record_unwind_protect_void (unblock_input); + /* Symbols are interned once at startup via DEFSYM in syms_of_nsterm; reference them directly here (GC-safe, no repeated obarray lookup). */ @@ -9750,6 +9937,7 @@ than O(chars). Fall back to pos+1 as safety net. */ pos = span_end; } + unbind_to (blk_count, Qnil); return [[spans copy] autorelease]; } @@ -9931,6 +10119,10 @@ - (void)dealloc #endif [accessibilityElements release]; +#ifdef NS_IMPL_COCOA + if (childFrameLastCandidate) + xfree (childFrameLastCandidate); +#endif [[self menu] release]; [super dealloc]; } @@ -11380,6 +11572,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 +12883,152 @@ - (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 new echo area messages to VoiceOver. + + This is called at the top of postAccessibilityUpdates, before any + tree rebuild. Keeping it here, rather than in the per-element loop + in postAccessibilityNotificationsForFrame, guarantees that echo area + messages (including "Quit" from C-g) are announced even when the + accessibility element tree is in the process of being rebuilt. + + The guard minibuf_level == 0 ensures we only announce passive status + messages. While the user is actively typing (minibuf_level > 0), + character echo and completion announcements take precedence. + + Reads echo_area_buffer[0] directly because with_echo_area_buffer() + sets current_buffer via set_buffer_internal_1() but does NOT call + Fset_window_buffer(), so the minibuffer window's contents pointer + still points to the inactive " *Minibuf-0*" buffer. */ +- (void)postEchoAreaAnnouncementIfNeeded +{ + if (minibuf_level != 0) + return; + Lisp_Object ea = echo_area_buffer[0]; + if (!BUFFERP (ea)) + return; + struct buffer *eb = XBUFFER (ea); + if (!BUFFER_LIVE_P (eb)) + return; + ptrdiff_t echo_chars = BUF_CHARS_MODIFF (eb); + if (echo_chars == lastEchoCharsModiff || BUF_ZV (eb) <= BUF_BEGV (eb)) + return; + lastEchoCharsModiff = echo_chars; + /* Use specpdl to restore current_buffer if Fbuffer_string signals. + set_buffer_internal_1 is preferred over set_buffer_internal in + a redisplay context: it skips point-motion hooks that could + trigger further redisplay or modify buffer state unexpectedly. */ + specpdl_ref count = SPECPDL_INDEX (); + record_unwind_current_buffer (); + set_buffer_internal_1 (eb); + Lisp_Object ls = Fbuffer_string (); + unbind_to (count, Qnil); + /* stringWithLispString: converts Emacs's internal multibyte encoding + to NSString correctly; a raw SSDATA cast would produce invalid + UTF-8 for non-ASCII characters. */ + NSString *raw = [NSString stringWithLispString: ls]; + NSString *msg = [raw stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if ([msg length] == 0) + return; + NSDictionary *info = @{ + NSAccessibilityAnnouncementKey: msg, + NSAccessibilityPriorityKey: @(NSAccessibilityPriorityHigh) + }; + ns_ax_post_notification_with_info ( + NSApp, NSAccessibilityAnnouncementRequestedNotification, info); +} + +/* 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; + /* Compare buffer identity using the raw pointer, not a Lisp_Object. + A killed buffer can be GC'd even if we hold a Lisp_Object for it + (EmacsView is not GC-visible). Storing and comparing struct buffer * + is safe because we only test identity (not dereference) here, and + we guard all actual buffer field reads with BUFFER_LIVE_P below. */ + if ((struct buffer *) XLP (childFrameLastBuffer) == b + && modiff == childFrameLastModiff) + return; + childFrameLastBuffer = make_lisp_ptr (b, Lisp_Vectorlike); + childFrameLastModiff = modiff; + + if (!BUFFER_LIVE_P (b)) + return; + + /* 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; + /* block_input prevents timer events and process output from + interleaving with the Lisp calls inside + ns_ax_selected_child_frame_text (Fbuffer_substring_no_properties, + Fget_char_property, etc.). record_unwind_protect_void ensures + unblock_input is called even if a Lisp call signals. */ + block_input (); + specpdl_ref blk_count = SPECPDL_INDEX (); + record_unwind_protect_void (unblock_input); + NSString *candidate + = ns_ax_selected_child_frame_text (b, w->contents, &selected_line); + unbind_to (blk_count, Qnil); + + 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 +13039,64 @@ - (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; + /* Announce echo area messages (e.g. "Quit", "Wrote file") before + any tree-rebuild check. This must run even when the element tree + is being rebuilt to avoid missing time-sensitive status messages. */ + [self postEchoAreaAnnouncementIfNeeded]; + + /* 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