From fe040875150008a460b3cbbf74148a12d42fba76 Mon Sep 17 00:00:00 2001 From: Martin Sukany Date: Sat, 28 Feb 2026 14:46:25 +0100 Subject: [PATCH] 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. Root cause analysis: Vertico's vertico--prompt-selection modifies buffer text properties (face), which bumps BUF_MODIFF. In the same command cycle, overlay-put bumps BUF_OVERLAY_MODIFF. If overlay detection is an else-if subordinate to the modiff check, the modiff branch always wins and overlay announcements never fire. Fix with five changes: 1. Add ns_ax_selected_overlay_text to extract the highlighted candidate from overlay strings. Determine the "normal" face by comparing the first and last line faces via Fequal, then find the outlier line. Handle single-candidate overlays and edge cases where the selected candidate is at position 0 or N-1. 2. Make the overlay detection branch independent (if, not else-if) of the text-change branch, so it fires even when BUF_MODIFF and BUF_OVERLAY_MODIFF both change in the same cycle. 3. Use BUF_CHARS_MODIFF (not BUF_MODIFF) to gate ValueChanged notifications. Text property changes (face updates) bump BUF_MODIFF but not BUF_CHARS_MODIFF; posting ValueChanged for property-only changes causes VoiceOver to say "new line". 4. Remove BUF_OVERLAY_MODIFF from ensureTextCache validity checks. Overlay text is not included in the cached AX text; tracking overlay_modiff there would silently update the cached counter and prevent the notification dispatch from detecting changes. 5. Add Zoom tracking (UAZoomChangeFocus) for the selected overlay candidate: find the glyph row in the minibuffer window matrix and focus the Zoom lens there. * src/nsterm.h (EmacsAccessibilityBuffer): Add cachedCharsModiff. * src/nsterm.m (ns_ax_selected_overlay_text): New function. (EmacsAccessibilityBuffer ensureTextCache): Remove overlay_modiff from cache validity check. (EmacsAccessibilityBuffer postAccessibilityNotificationsForFrame:): Use BUF_CHARS_MODIFF for ValueChanged gating. Make overlay branch independent. Announce candidate via AnnouncementRequested to NSApp with Zoom tracking. --- src/nsterm.h | 1 + src/nsterm.m | 285 +++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 276 insertions(+), 10 deletions(-) diff --git a/src/nsterm.h b/src/nsterm.h index 51c30ca..dd0e226 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; diff --git a/src/nsterm.m b/src/nsterm.m index 1780194..d8557c8 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -6915,11 +6915,166 @@ ns_create_font_panel_buttons (id target, SEL select, SEL cancel_action) are truncated for accessibility purposes. */ #define NS_AX_TEXT_CAP 100000 +/* Extract the currently selected candidate text from overlay display + strings. Completion frameworks (Vertico, Ivy, Icomplete) render + candidates as overlay before-string/after-string and highlight the + current candidate with a distinct face (e.g. vertico-current). + + Strategy: collect line boundaries in the overlay string, determine + the "normal" (non-selected) face by comparing the first and last + lines, then find the outlier line whose face differs. + + Also return the 0-based line index of the selected candidate in + *OUT_LINE_INDEX (or -1 if not found) for Zoom positioning. + + Returns nil if no distinctly-faced line 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 + (avoids per-character Faref Lisp calls). */ + const unsigned char *data = SDATA (str); + ptrdiff_t byte_len = SBYTES (str); + 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++; + } + /* Last line (no trailing newline). */ + if (char_pos > lstart && nlines < 512) + { + line_starts[nlines] = lstart; + line_ends[nlines] = char_pos; + nlines++; + } + + if (nlines == 0) + continue; + + /* Single candidate: if it has a face, it is the selected one. */ + if (nlines == 1) + { + Lisp_Object face + = Fget_text_property (make_fixnum (line_starts[0]), + Qface, str); + if (!NILP (face)) + { + *out_line_index = 0; + Lisp_Object line + = Fsubstring_no_properties ( + str, + make_fixnum (line_starts[0]), + make_fixnum (line_ends[0])); + if (SCHARS (line) > 0) + return [NSString stringWithLispString:line]; + } + continue; + } + + /* Determine the "normal" face using two references. + If first and last line share the same face, any line + that differs is the selected candidate. If they differ, + compare against the second line to resolve which end + is the outlier. */ + Lisp_Object face_first + = Fget_text_property (make_fixnum (line_starts[0]), + Qface, str); + Lisp_Object face_last + = Fget_text_property (make_fixnum (line_starts[nlines - 1]), + Qface, str); + + Lisp_Object normal_face; + if (!NILP (Fequal (face_first, face_last))) + { + normal_face = face_first; + } + else if (nlines >= 3) + { + Lisp_Object face_second + = Fget_text_property (make_fixnum (line_starts[1]), + Qface, str); + if (!NILP (Fequal (face_second, face_first))) + normal_face = face_first; + else + normal_face = face_last; + } + else + { + /* Only 2 lines, different faces --- use second as normal + (in most UIs, selected item is shown first). */ + normal_face = face_last; + } + + for (int li = 0; li < nlines; li++) + { + Lisp_Object lf + = Fget_text_property (make_fixnum (line_starts[li]), + Qface, str); + if (NILP (Fequal (lf, normal_face))) + { + *out_line_index = li; + Lisp_Object line + = Fsubstring_no_properties ( + str, + make_fixnum (line_starts[li]), + make_fixnum (line_ends[li])); + if (SCHARS (line) > 0) + return [NSString stringWithLispString:line]; + } + } + } + } + + 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) @@ -7556,6 +7711,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @synthesize cachedOverlayModiff; @synthesize cachedTextStart; @synthesize cachedModiff; +@synthesize cachedCharsModiff; @synthesize cachedPoint; @synthesize cachedMarkActive; @synthesize cachedCompletionAnnouncement; @@ -7609,16 +7765,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 +7790,6 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, [cachedText release]; cachedText = [text retain]; cachedTextModiff = modiff; - cachedOverlayModiff = overlay_modiff; cachedTextStart = start; if (visibleRuns) @@ -8789,10 +8943,121 @@ 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 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) + { + candidate = [candidate + stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + + /* Deduplicate: only announce when the candidate changed. */ + if ([candidate length] > 0 + && ![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 --- + The overlay candidates appear as visual lines in the + minibuffer window. Row 0 is the input line; overlay + candidates start from row 1 onward. Find the glyph + row for the selected candidate and focus Zoom there. */ +#if defined (MAC_OS_X_VERSION_MIN_REQUIRED) \ + && MAC_OS_X_VERSION_MIN_REQUIRED >= 101000 + if (selected_line >= 0 && UAZoomEnabled ()) + { + struct window *w2 = [self validWindow]; + if (w2 && w2->current_matrix) + { + EmacsView *view = self.emacsView; + int target_vrow = selected_line + 1; + int nrows = w2->current_matrix->nrows; + if (target_vrow < nrows) + { + struct glyph_row *row + = w2->current_matrix->rows + target_vrow; + if (row->enabled_p + && row->visible_height > 0) + { + NSRect r = NSMakeRect ( + w2->pixel_left, + WINDOW_TOP_EDGE_Y (w2) + row->y, + w2->pixel_width, + row->visible_height); + NSRect winRect + = [view convertRect:r toView:nil]; + NSRect screenRect + = [[view 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); + } + } + } + } +#endif + } + } } /* --- Cursor moved or selection changed --- -- 2.43.0