Files
emacs-doom/patches/0007-ns-announce-overlay-completion-candidates-for-VoiceO.patch
Daneel fcff3429b1 patches: rewrite 0007 - fix root cause of 'new line' announcement
Root cause: Vertico bumps BOTH BUF_MODIFF (text property face change
from vertico--prompt-selection) and BUF_OVERLAY_MODIFF (overlay-put)
in same cycle. Previous else-if chain meant overlay branch never fired.

Fixes:
1. Overlay check independent (if, not else-if)
2. BUF_CHARS_MODIFF gates ValueChanged (suppress property-only changes)
3. ensureTextCache no longer tracks overlay_modiff (prevents race)
4. Only AnnouncementRequested (no SelectedTextChanged - wrong line)
5. Two-reference face detection + single-candidate
6. Zoom tracking via UAZoomChangeFocus
2026-02-28 15:12:11 +01:00

405 lines
14 KiB
Diff

From fe040875150008a460b3cbbf74148a12d42fba76 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 patch, VoiceOver cannot read
overlay-based completion UIs.
Root cause analysis: Vertico's vertico--prompt-selection modifies
buffer text properties (face), which bumps BUF_MODIFF. In the same
command cycle, overlay-put bumps BUF_OVERLAY_MODIFF. If overlay
detection is an else-if subordinate to the modiff check, the modiff
branch always wins and overlay announcements never fire.
Fix with five changes:
1. Add ns_ax_selected_overlay_text to extract the highlighted
candidate from overlay strings. Determine the "normal" face by
comparing the first and last line faces via Fequal, then find the
outlier line. Handle single-candidate overlays and edge cases
where the selected candidate is at position 0 or N-1.
2. Make the overlay detection branch independent (if, not else-if)
of the text-change branch, so it fires even when BUF_MODIFF and
BUF_OVERLAY_MODIFF both change in the same cycle.
3. Use BUF_CHARS_MODIFF (not BUF_MODIFF) to gate ValueChanged
notifications. Text property changes (face updates) bump
BUF_MODIFF but not BUF_CHARS_MODIFF; posting ValueChanged for
property-only changes causes VoiceOver to say "new line".
4. Remove BUF_OVERLAY_MODIFF from ensureTextCache validity checks.
Overlay text is not included in the cached AX text; tracking
overlay_modiff there would silently update the cached counter and
prevent the notification dispatch from detecting changes.
5. Add Zoom tracking (UAZoomChangeFocus) for the selected overlay
candidate: find the glyph row in the minibuffer window matrix
and focus the Zoom lens there.
* src/nsterm.h (EmacsAccessibilityBuffer): Add cachedCharsModiff.
* src/nsterm.m (ns_ax_selected_overlay_text): New function.
(EmacsAccessibilityBuffer ensureTextCache): Remove overlay_modiff
from cache validity check.
(EmacsAccessibilityBuffer postAccessibilityNotificationsForFrame:):
Use BUF_CHARS_MODIFF for ValueChanged gating. Make overlay branch
independent. Announce candidate via AnnouncementRequested to NSApp
with Zoom tracking.
---
src/nsterm.h | 1 +
src/nsterm.m | 285 +++++++++++++++++++++++++++++++++++++++++++++++++--
2 files changed, 276 insertions(+), 10 deletions(-)
diff --git a/src/nsterm.h b/src/nsterm.h
index 51c30ca..dd0e226 100644
--- a/src/nsterm.h
+++ b/src/nsterm.h
@@ -507,6 +507,7 @@ typedef struct ns_ax_visible_run
@property (nonatomic, assign) ptrdiff_t cachedOverlayModiff;
@property (nonatomic, assign) ptrdiff_t cachedTextStart;
@property (nonatomic, assign) ptrdiff_t cachedModiff;
+@property (nonatomic, assign) ptrdiff_t cachedCharsModiff;
@property (nonatomic, assign) ptrdiff_t cachedPoint;
@property (nonatomic, assign) BOOL cachedMarkActive;
@property (nonatomic, copy) NSString *cachedCompletionAnnouncement;
diff --git a/src/nsterm.m b/src/nsterm.m
index 1780194..d8557c8 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -6915,11 +6915,166 @@ 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 == 0)
+ continue;
+
+ /* Single candidate: if it has a face, it is the selected one. */
+ if (nlines == 1)
+ {
+ Lisp_Object face
+ = Fget_text_property (make_fixnum (line_starts[0]),
+ Qface, str);
+ if (!NILP (face))
+ {
+ *out_line_index = 0;
+ Lisp_Object line
+ = Fsubstring_no_properties (
+ str,
+ make_fixnum (line_starts[0]),
+ make_fixnum (line_ends[0]));
+ if (SCHARS (line) > 0)
+ return [NSString stringWithLispString:line];
+ }
+ 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)
@@ -7556,6 +7711,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
@synthesize cachedOverlayModiff;
@synthesize cachedTextStart;
@synthesize cachedModiff;
+@synthesize cachedCharsModiff;
@synthesize cachedPoint;
@synthesize cachedMarkActive;
@synthesize cachedCompletionAnnouncement;
@@ -7609,16 +7765,15 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
return;
ptrdiff_t modiff = BUF_MODIFF (b);
- ptrdiff_t overlay_modiff = BUF_OVERLAY_MODIFF (b);
ptrdiff_t pt = BUF_PT (b);
NSUInteger textLen = cachedText ? [cachedText length] : 0;
- /* Track both BUF_MODIFF and BUF_OVERLAY_MODIFF. Overlay-only
- changes (e.g., timer-based completion highlight move without
- text edit) bump overlay_modiff but not modiff. Also detect
- narrowing/widening which changes BUF_BEGV without bumping
- either modiff counter. */
+ /* Cache validity: track BUF_MODIFF and buffer narrowing.
+ Do NOT track BUF_OVERLAY_MODIFF here --- overlay text is not
+ included in the cached AX text (it is handled separately via
+ explicit announcements). Including overlay_modiff would
+ silently update cachedOverlayModiff and prevent the
+ notification dispatch from detecting overlay changes. */
if (cachedText && cachedTextModiff == modiff
- && cachedOverlayModiff == overlay_modiff
&& cachedTextStart == BUF_BEGV (b)
&& pt >= cachedTextStart
&& (textLen == 0
@@ -7635,7 +7790,6 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
[cachedText release];
cachedText = [text retain];
cachedTextModiff = modiff;
- cachedOverlayModiff = overlay_modiff;
cachedTextStart = start;
if (visibleRuns)
@@ -8789,10 +8943,121 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
BOOL markActive = !NILP (BVAR (b, mark_active));
/* --- Text changed (edit) --- */
+ ptrdiff_t chars_modiff = BUF_CHARS_MODIFF (b);
if (modiff != self.cachedModiff)
{
self.cachedModiff = modiff;
- [self postTextChangedNotification:point];
+ /* Only post ValueChanged when actual characters changed.
+ Text property changes (e.g. face updates from
+ vertico--prompt-selection) bump BUF_MODIFF but not
+ BUF_CHARS_MODIFF. Posting ValueChanged for property-only
+ changes causes VoiceOver to say "new line" when the diff
+ is non-empty due to overlay content changes. */
+ if (chars_modiff != self.cachedCharsModiff)
+ {
+ self.cachedCharsModiff = chars_modiff;
+ [self postTextChangedNotification:point];
+ }
+ }
+
+
+ /* --- Overlay content changed (e.g. Vertico/Ivy candidate switch) ---
+ Check independently of the modiff branch above, because
+ frameworks like Vertico bump BOTH BUF_MODIFF (via text property
+ changes in vertico--prompt-selection) and BUF_OVERLAY_MODIFF
+ (via overlay-put) in the same command cycle. If this were an
+ else-if, the modiff branch would always win and overlay
+ announcements would never fire.
+ Do NOT invalidate the text cache --- the buffer text has not
+ changed, and cache invalidation causes VoiceOver to diff old
+ vs new AX text and announce spurious "new line". */
+ 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;
+
+ /* Announce the candidate text directly via NSApp.
+ Do NOT post SelectedTextChanged --- that would cause
+ VoiceOver to read the AX text at the cursor position
+ (the minibuffer input line), not the overlay candidate.
+ AnnouncementRequested with High priority interrupts
+ any current speech and announces our text. */
+ 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 ---
--
2.43.0