From e12982f4ac111f9814a198763124a64540f4640b Mon Sep 17 00:00:00 2001 From: Martin Sukany Date: Sat, 28 Feb 2026 14:46:25 +0100 Subject: [PATCH] ns: include overlay display strings in accessibility text 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 any overlay-based completion UI. This patch adds three pieces: 1. ns_ax_selected_overlay_text: extract the currently highlighted candidate from overlay strings. Compare each line's face against the first line's face via Fequal; the line with a different face (e.g. vertico-current) is the selected candidate. 2. ns_ax_buffer_text: append overlay before-string and after-string content after the buffer text, with virtual visible-run entries anchored at the overlay's buffer position. 3. Notification dispatch: detect overlay-only changes via BUF_OVERLAY_MODIFF. Post SelectedTextChanged to interrupt VoiceOver, then AnnouncementRequested (to NSApp) with the candidate text. * src/nsterm.m (ns_ax_selected_overlay_text): New function. (ns_ax_buffer_text): Append overlay display strings. (EmacsAccessibilityBuffer postAccessibilityNotificationsForFrame:): Handle BUF_OVERLAY_MODIFF changes with candidate announcement. --- src/nsterm.m | 201 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 200 insertions(+), 1 deletion(-) diff --git a/src/nsterm.m b/src/nsterm.m index 1780194..35edd39 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -6915,11 +6915,99 @@ 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) highlight + the current candidate with a distinct face. We find the line whose + face DIFFERS from the first line's face (the "normal" candidate + face) — that is the selected candidate. + + 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) +{ + 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; + + /* Collect line boundaries. */ + ptrdiff_t line_starts[512]; + ptrdiff_t line_ends[512]; + int nlines = 0; + ptrdiff_t lstart = 0; + + for (ptrdiff_t i = 0; i <= slen && nlines < 512; i++) + { + bool is_nl = false; + if (i < slen) + { + Lisp_Object ch = Faref (str, make_fixnum (i)); + is_nl = (FIXNUMP (ch) && XFIXNUM (ch) == '\n'); + } + if (is_nl || i == slen) + { + if (i > lstart) + { + line_starts[nlines] = lstart; + line_ends[nlines] = i; + nlines++; + } + lstart = i + 1; + } + } + + if (nlines < 2) + continue; + + /* Get the face of the first line (the "normal" face). */ + Lisp_Object normal_face + = Fget_text_property (make_fixnum (line_starts[0]), + Qface, str); + + /* Find the first line with a DIFFERENT face. */ + for (int li = 0; li < nlines; li++) + { + Lisp_Object line_face + = Fget_text_property (make_fixnum (line_starts[li]), + Qface, str); + if (NILP (Fequal (line_face, normal_face))) + { + 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) @@ -7021,6 +7109,68 @@ ns_ax_buffer_text (struct window *w, ptrdiff_t *out_start, pos = run_end; } + /* Append overlay display strings (before-string, after-string). + Many completion frameworks (Vertico, Ivy, Icomplete) render + candidates via overlay properties rather than buffer text. + Without this, VoiceOver cannot read overlay-based content. + + We append overlay strings after the buffer text and give each + a virtual run mapped to the overlay's buffer position, so cursor + tracking and index mapping remain functional. */ + { + Lisp_Object ov_list = Foverlays_in (make_fixnum (begv), + make_fixnum (zv)); + for (Lisp_Object tail = ov_list; CONSP (tail); tail = XCDR (tail)) + { + Lisp_Object ov = XCAR (tail); + Lisp_Object props[2]; + ptrdiff_t anchors[2]; + + props[0] = Foverlay_get (ov, intern_c_string ("before-string")); + anchors[0] = XFIXNUM (Foverlay_start (ov)); + props[1] = Foverlay_get (ov, intern_c_string ("after-string")); + anchors[1] = XFIXNUM (Foverlay_end (ov)); + + for (int k = 0; k < 2; k++) + { + if (!STRINGP (props[k])) + continue; + + /* Cap total text. */ + if (ax_offset >= NS_AX_TEXT_CAP) + break; + + NSString *nsstr + = [NSString stringWithLispString:props[k]]; + NSUInteger ns_len = [nsstr length]; + if (ns_len == 0) + continue; + + if (ax_offset + ns_len > NS_AX_TEXT_CAP) + ns_len = NS_AX_TEXT_CAP - ax_offset; + + if (ns_len < [nsstr length]) + nsstr = [nsstr substringToIndex:ns_len]; + + [result appendString:nsstr]; + + if (nruns >= run_capacity) + { + run_capacity *= 2; + runs = xrealloc (runs, run_capacity + * sizeof (ns_ax_visible_run)); + } + runs[nruns].charpos = anchors[k]; + runs[nruns].length = 0; /* virtual — no buffer chars */ + runs[nruns].ax_start = ax_offset; + runs[nruns].ax_length = ns_len; + nruns++; + + ax_offset += ns_len; + } + } + } + unbind_to (count, Qnil); *out_runs = runs; @@ -8795,6 +8945,55 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, [self postTextChangedNotification:point]; } + /* --- Overlay content changed (e.g. Vertico/Ivy candidate switch) --- + Overlay-only changes (before-string, after-string, display) bump + BUF_OVERLAY_MODIFF but not BUF_MODIFF. Instead of a generic + ValueChanged (which causes VoiceOver to say "new line"), find the + highlighted candidate in the overlay and announce it directly. */ + else if (BUF_OVERLAY_MODIFF (b) != self.cachedOverlayModiff) + { + self.cachedOverlayModiff = BUF_OVERLAY_MODIFF (b); + [self invalidateTextCache]; + + /* Find the selected candidate text from overlay face + properties. Completion frameworks highlight the current + candidate with a text face (e.g. vertico-current, + icomplete-selected-match). */ + NSString *candidate + = ns_ax_selected_overlay_text (b, BUF_BEGV (b), BUF_ZV (b)); + if (candidate) + { + /* Post SelectedTextChanged first to interrupt VoiceOver, + then AnnouncementRequested with candidate text. + Target NSApp for announcements (Apple docs require it). */ + NSDictionary *moveInfo = @{ + @"AXTextStateChangeType": + @(ns_ax_text_state_change_selection_move), + @"AXTextChangeElement": self + }; + ns_ax_post_notification_with_info ( + self, + NSAccessibilitySelectedTextChangedNotification, + moveInfo); + + candidate = [candidate + stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if ([candidate length] > 0) + { + NSDictionary *annInfo = @{ + NSAccessibilityAnnouncementKey: candidate, + NSAccessibilityPriorityKey: + @(NSAccessibilityPriorityHigh) + }; + ns_ax_post_notification_with_info ( + NSApp, + NSAccessibilityAnnouncementRequestedNotification, + annInfo); + } + } + } + /* --- Cursor moved or selection changed --- Use 'else if' — edits and selection moves are mutually exclusive per the WebKit/Chromium pattern. */ -- 2.43.0