From 8d1b276ce29e5c4cc193ce4207a2b929a550c8ce Mon Sep 17 00:00:00 2001 From: Martin Sukany Date: Sat, 28 Feb 2026 14:16:29 +0100 Subject: [PATCH] ns: include overlay display strings in accessibility text Completion frameworks like Vertico, Ivy, and Icomplete render candidates via overlay before-string / after-string properties rather than as buffer text. The accessibility text extraction only reads buffer content, making overlay-based UIs invisible to VoiceOver. This patch adds three enhancements: 1. Walk overlays in the visible range after buffer text extraction and append their before-string / after-string content, with virtual visible-run entries for index mapping. 2. Detect overlay-only changes (BUF_OVERLAY_MODIFF) in the notification dispatch. 3. When overlays change, find the highlighted candidate (the text with a face property in the overlay string) and announce it via NSAccessibilityAnnouncementRequestedNotification. This makes VoiceOver read the specific selected candidate rather than announcing 'new line'. * src/nsterm.m (ns_ax_selected_overlay_text): New function. Walk overlay before-string / after-string text properties to find the face-highlighted (selected) portion, extract its full line. (ns_ax_buffer_text): Append overlay display strings with virtual visible-run entries. (EmacsAccessibilityBuffer postAccessibilityNotificationsForFrame:): Check BUF_OVERLAY_MODIFF; announce selected candidate text. Tested on macOS 14 with VoiceOver and icomplete-vertical-mode. --- src/nsterm.m | 178 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) diff --git a/src/nsterm.m b/src/nsterm.m index 1780194..2a50a53 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -6921,6 +6921,92 @@ ns_create_font_panel_buttons (id target, SEL select, SEL cancel_action) with the count. Caller must free *OUT_RUNS with xfree(). */ static NSString * +/* Extract the currently selected candidate text from overlay display + strings in window W. Completion frameworks (Vertico, Ivy, Icomplete) + highlight the current candidate by applying a face property to a + portion of the overlay's before-string or after-string. We find + that highlighted portion and return it as an NSString. + + Returns nil if no highlighted overlay text 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 len = SCHARS (str); + ptrdiff_t pos = 0; + + while (pos < len) + { + Lisp_Object face + = Fget_text_property (make_fixnum (pos), + Qface, str); + if (!NILP (face)) + { + /* Found highlighted text. Extract the full line + containing this position. */ + ptrdiff_t line_start = pos; + while (line_start > 0) + { + /* Check character before line_start. */ + Lisp_Object ch + = Faref (str, make_fixnum (line_start - 1)); + if (FIXNUMP (ch) && XFIXNUM (ch) == '\n') + break; + line_start--; + } + + ptrdiff_t line_end = pos; + while (line_end < len) + { + Lisp_Object ch + = Faref (str, make_fixnum (line_end)); + if (FIXNUMP (ch) && XFIXNUM (ch) == '\n') + break; + line_end++; + } + + Lisp_Object line + = Fsubstring_no_properties (str, + make_fixnum (line_start), + make_fixnum (line_end)); + if (SCHARS (line) > 0) + return [NSString stringWithLispString:line]; + } + + /* Skip to next face change. */ + Lisp_Object next + = Fnext_single_property_change (make_fixnum (pos), + Qface, str, + make_fixnum (len)); + ptrdiff_t npos + = FIXNUMP (next) ? XFIXNUM (next) : len; + if (npos <= pos) + break; + pos = npos; + } + } + } + + return nil; +} + + ns_ax_buffer_text (struct window *w, ptrdiff_t *out_start, ns_ax_visible_run **out_runs, NSUInteger *out_nruns) { @@ -7021,6 +7107,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 +8943,36 @@ 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) + { + NSDictionary *info = @{ + NSAccessibilityAnnouncementKey: candidate, + NSAccessibilityPriorityKey: + @(NSAccessibilityPriorityHigh) + }; + ns_ax_post_notification_with_info ( + self, + NSAccessibilityAnnouncementRequestedNotification, + info); + } + } + /* --- Cursor moved or selection changed --- Use 'else if' — edits and selection moves are mutually exclusive per the WebKit/Chromium pattern. */ -- 2.43.0