patches: rewrite 0007 overlay support

Key changes from previous version:
- Remove overlay text from ns_ax_buffer_text (was causing spurious
  'new line' announcements via VoiceOver text diff)
- Do NOT invalidate text cache on overlay change
- Two-reference face detection (handles selected at any position)
- SDATA scan instead of per-char Faref for newline detection
- Zoom tracking via UAZoomChangeFocus for selected candidate row
- Deduplication via cachedCompletionAnnouncement
This commit is contained in:
2026-02-28 14:57:00 +01:00
parent 6c502c7af5
commit 9408e37a90
2 changed files with 302 additions and 267 deletions

View File

@@ -0,0 +1,302 @@
From 313b4e4489a617fdd074f577ba024dec88eda87e 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: 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, VoiceOver cannot read overlay-based
completion UIs.
Add ns_ax_selected_overlay_text to extract the currently highlighted
candidate from overlay strings. The function determines the normal
(non-selected) face by comparing the first and last lines via Fequal,
then returns the line with a different face (the selected candidate).
In the notification dispatch, detect overlay-only changes via
BUF_OVERLAY_MODIFF (which is bumped by overlay property changes but
not buffer text edits). Crucially, do NOT invalidate the text cache
on overlay changes --- the buffer text is unchanged and cache
invalidation causes VoiceOver to diff old vs new text, announcing
spurious newlines. Instead, post SelectedTextChanged to interrupt
current speech, then AnnouncementRequested to NSApp with the
candidate text.
Add Zoom tracking for overlay candidates: find the glyph row
corresponding to the selected candidate and call UAZoomChangeFocus
so the Zoom lens follows the selection.
* src/nsterm.m (ns_ax_selected_overlay_text): New function.
Returns the selected overlay candidate text and its line index.
(EmacsAccessibilityBuffer postAccessibilityNotificationsForFrame:):
Handle BUF_OVERLAY_MODIFF changes with candidate announcement and
Zoom focus tracking.
---
src/nsterm.m | 240 ++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 239 insertions(+), 1 deletion(-)
diff --git a/src/nsterm.m b/src/nsterm.m
index 1780194..4cf7b0c 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -6915,11 +6915,146 @@ 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 < 2)
+ 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)
@@ -8795,6 +8930,109 @@ 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. Do NOT invalidate the
+ text cache here --- the buffer text itself has not changed, and
+ cache invalidation would cause VoiceOver to diff the old vs new
+ AX text and announce spurious "new line" from newlines.
+ Instead, announce the selected candidate explicitly. */
+ else 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;
+
+ /* Post SelectedTextChanged to interrupt VoiceOver's
+ current speech, then announce the candidate text
+ via NSApp (Apple requires app-level element). */
+ NSDictionary *moveInfo = @{
+ @"AXTextStateChangeType":
+ @(ns_ax_text_state_change_selection_move),
+ @"AXTextChangeElement": self
+ };
+ ns_ax_post_notification_with_info (
+ self,
+ NSAccessibilitySelectedTextChangedNotification,
+ moveInfo);
+
+ 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 ---
Use 'else if' — edits and selection moves are mutually exclusive
per the WebKit/Chromium pattern. */
--
2.43.0

View File

@@ -1,267 +0,0 @@
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