Files
emacs-doom/patches/0007-ns-include-overlay-display-strings-in-accessibility-.patch
Daneel 6c502c7af5 patches: squash 0007+0008+0009 into single clean 0007
All overlay fixes in one patch: Fequal face detection,
NSApp announcement target, SelectedTextChanged interrupt.
2026-02-28 14:46:43 +01:00

268 lines
8.5 KiB
Diff

From e12982f4ac111f9814a198763124a64540f4640b Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
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