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