diff --git a/patches/0007-ns-announce-overlay-completion-candidates-for-VoiceO.patch b/patches/0007-ns-announce-overlay-completion-candidates-for-VoiceO.patch new file mode 100644 index 0000000..d698d82 --- /dev/null +++ b/patches/0007-ns-announce-overlay-completion-candidates-for-VoiceO.patch @@ -0,0 +1,617 @@ +From 8157451dedda9b43de47f82d1deb85c9d2853a35 Mon Sep 17 00:00:00 2001 +From: Martin Sukany +Date: Sat, 28 Feb 2026 14:46:25 +0100 +Subject: [PATCH 1/2] ns: announce overlay completion candidates for VoiceOver + +Completion frameworks such as Vertico, Ivy, and Icomplete render +candidates via overlay before-string/after-string properties rather +than buffer text. Without this patch, VoiceOver cannot read +overlay-based completion UIs. + +Identify the selected candidate by scanning overlay strings for a +face whose symbol name contains "current", "selected", or +"selection" --- this matches vertico-current, icomplete-selected-match, +ivy-current-match, company-tooltip-selection, and similar framework +faces without hard-coding any specific name. + +Key implementation details: + +- The overlay detection branch runs independently (if, not else-if) + of the text-change branch, because Vertico bumps both BUF_MODIFF + (via text property changes in vertico--prompt-selection) and + BUF_OVERLAY_MODIFF (via overlay-put) 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 condition where VoiceOver AX queries silently + consume the overlay change before the notification dispatch runs. + +- Announce via AnnouncementRequested to NSApp with High priority. + Do not post SelectedTextChanged (that reads the AX text at cursor + position, which is the minibuffer input, not the candidate). + +- Zoom tracking: store the selected candidate's rect (at the text + area left edge, computed from FRAME_LINE_HEIGHT) in overlayZoomRect. + ns_draw_window_cursor checks overlayZoomActive and uses the stored + rect instead of the text cursor rect, keeping Zoom focused on the + candidate line start. The flag is cleared when the user types + (BUF_CHARS_MODIFF changes) or when no candidate is found + (minibuffer exit, C-g). + +* src/nsterm.h (EmacsView): Add overlayZoomActive, overlayZoomRect. +(EmacsAccessibilityBuffer): Add cachedCharsModiff. +* src/nsterm.m (ns_ax_face_is_selected): New predicate. Match +"current", "selected", and "selection" in face symbol names. +(ns_ax_selected_overlay_text): New function. +(ns_draw_window_cursor): Use overlayZoomRect when active. +(EmacsAccessibilityBuffer ensureTextCache): Remove overlay_modiff. +(EmacsAccessibilityBuffer postAccessibilityNotificationsForFrame:): +Independent overlay branch, BUF_CHARS_MODIFF gating, candidate +announcement with overlay Zoom rect storage. +--- + src/nsterm.h | 3 + + src/nsterm.m | 319 +++++++++++++++++++++++++++++++++++++++++++++------ + 2 files changed, 286 insertions(+), 36 deletions(-) + +diff --git a/src/nsterm.h b/src/nsterm.h +index 51c30ca..5c15639 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; +diff --git a/src/nsterm.m b/src/nsterm.m +index 1780194..d13c5c7 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,156 @@ 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; ++} ++ ++ + /* 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 +7146,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 +7227,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 +7706,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 +7747,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 +7760,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 +7785,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 +7810,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 +7823,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 +7848,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 +7885,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 +7907,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 +8315,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 +8571,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 +8586,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 +8605,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 +8938,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 +9213,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 +9404,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 +9421,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 +9447,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 +10767,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 +12169,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 +12203,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)) + { +@@ -12068,7 +12315,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 +12324,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 + diff --git a/patches/0008-ns-announce-child-frame-completion-candidates-for-Vo.patch b/patches/0008-ns-announce-child-frame-completion-candidates-for-Vo.patch new file mode 100644 index 0000000..5e2f174 --- /dev/null +++ b/patches/0008-ns-announce-child-frame-completion-candidates-for-Vo.patch @@ -0,0 +1,240 @@ +From 959180846d5fb99044c57509c15de14451125119 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. + +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. + +Deduplication uses a C string cache (xstrdup/xfree) to avoid ObjC +memory management complexity in static storage. + +* 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. +--- + src/nsterm.h | 1 + + src/nsterm.m | 163 +++++++++++++++++++++++++++++++++++++++++++++++++++ + 2 files changed, 164 insertions(+) + +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..59dc14d 100644 +--- a/src/nsterm.m ++++ b/src/nsterm.m +@@ -7066,6 +7066,98 @@ 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; ++ ++ /* 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 +@@ -12299,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]"); +@@ -12307,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. */ +-- +2.43.0 +