patches: v3 0007 - face name heuristic for candidate detection

Previous two-reference algorithm failed because:
- Vertico's cursor-space line (face=nil) confused the reference
- Count overlay processed before candidates overlay
- Group titles have distinct faces too

New approach: ns_ax_face_is_selected checks if face symbol name
contains 'current' or 'selected'. Works for all major frameworks
(Vertico, Icomplete, Ivy) without framework-specific code.
This commit is contained in:
2026-02-28 15:25:52 +01:00
parent fcff3429b1
commit 9359277143

View File

@@ -1,4 +1,4 @@
From fe040875150008a460b3cbbf74148a12d42fba76 Mon Sep 17 00:00:00 2001 From bfba1d81a0b70651fb626da57c0f3cc68e77998c Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 14:46:25 +0100 Date: Sat, 28 Feb 2026 14:46:25 +0100
Subject: [PATCH] ns: announce overlay completion candidates for VoiceOver Subject: [PATCH] ns: announce overlay completion candidates for VoiceOver
@@ -8,50 +8,44 @@ candidates via overlay before-string/after-string properties rather
than buffer text. Without this patch, VoiceOver cannot read than buffer text. Without this patch, VoiceOver cannot read
overlay-based completion UIs. overlay-based completion UIs.
Root cause analysis: Vertico's vertico--prompt-selection modifies Identify the selected candidate by scanning overlay strings for a
buffer text properties (face), which bumps BUF_MODIFF. In the same face whose symbol name contains "current" or "selected" --- this
command cycle, overlay-put bumps BUF_OVERLAY_MODIFF. If overlay matches vertico-current, icomplete-selected-match, ivy-current-match
detection is an else-if subordinate to the modiff check, the modiff and similar framework faces without hard-coding any specific name.
branch always wins and overlay announcements never fire.
Fix with five changes: Key implementation details:
1. Add ns_ax_selected_overlay_text to extract the highlighted - The overlay detection branch runs independently (if, not else-if)
candidate from overlay strings. Determine the "normal" face by of the text-change branch, because Vertico bumps both BUF_MODIFF
comparing the first and last line faces via Fequal, then find the (via text property changes in vertico--prompt-selection) and
outlier line. Handle single-candidate overlays and edge cases BUF_OVERLAY_MODIFF (via overlay-put) in the same command cycle.
where the selected candidate is at position 0 or N-1.
2. Make the overlay detection branch independent (if, not else-if) - Use BUF_CHARS_MODIFF to gate ValueChanged notifications, since
of the text-change branch, so it fires even when BUF_MODIFF and text property changes bump BUF_MODIFF but not BUF_CHARS_MODIFF.
BUF_OVERLAY_MODIFF both change in the same cycle.
3. Use BUF_CHARS_MODIFF (not BUF_MODIFF) to gate ValueChanged - Remove BUF_OVERLAY_MODIFF from ensureTextCache validity checks
notifications. Text property changes (face updates) bump to prevent a race condition where VoiceOver AX queries silently
BUF_MODIFF but not BUF_CHARS_MODIFF; posting ValueChanged for consume the overlay change before the notification dispatch runs.
property-only changes causes VoiceOver to say "new line".
4. Remove BUF_OVERLAY_MODIFF from ensureTextCache validity checks. - Announce via AnnouncementRequested to NSApp with High priority.
Overlay text is not included in the cached AX text; tracking Do not post SelectedTextChanged (that reads the AX text at cursor
overlay_modiff there would silently update the cached counter and position, which is the minibuffer input, not the candidate).
prevent the notification dispatch from detecting changes.
5. Add Zoom tracking (UAZoomChangeFocus) for the selected overlay - Add Zoom tracking (UAZoomChangeFocus) for the selected candidate
candidate: find the glyph row in the minibuffer window matrix glyph row in the minibuffer window matrix.
and focus the Zoom lens there.
* src/nsterm.h (EmacsAccessibilityBuffer): Add cachedCharsModiff. * src/nsterm.h (EmacsAccessibilityBuffer): Add cachedCharsModiff.
* src/nsterm.m (ns_ax_selected_overlay_text): New function. * src/nsterm.m (ns_ax_face_is_selected): New predicate.
(ns_ax_selected_overlay_text): New function.
(EmacsAccessibilityBuffer ensureTextCache): Remove overlay_modiff (EmacsAccessibilityBuffer ensureTextCache): Remove overlay_modiff
from cache validity check. from cache validity check.
(EmacsAccessibilityBuffer postAccessibilityNotificationsForFrame:): (EmacsAccessibilityBuffer postAccessibilityNotificationsForFrame:):
Use BUF_CHARS_MODIFF for ValueChanged gating. Make overlay branch Use BUF_CHARS_MODIFF for ValueChanged gating; make overlay branch
independent. Announce candidate via AnnouncementRequested to NSApp independent; announce candidate with Zoom tracking.
with Zoom tracking.
--- ---
src/nsterm.h | 1 + src/nsterm.h | 1 +
src/nsterm.m | 285 +++++++++++++++++++++++++++++++++++++++++++++++++-- src/nsterm.m | 259 +++++++++++++++++++++++++++++++++++++++++++++++++--
2 files changed, 276 insertions(+), 10 deletions(-) 2 files changed, 250 insertions(+), 10 deletions(-)
diff --git a/src/nsterm.h b/src/nsterm.h diff --git a/src/nsterm.h b/src/nsterm.h
index 51c30ca..dd0e226 100644 index 51c30ca..dd0e226 100644
@@ -66,26 +60,50 @@ index 51c30ca..dd0e226 100644
@property (nonatomic, assign) BOOL cachedMarkActive; @property (nonatomic, assign) BOOL cachedMarkActive;
@property (nonatomic, copy) NSString *cachedCompletionAnnouncement; @property (nonatomic, copy) NSString *cachedCompletionAnnouncement;
diff --git a/src/nsterm.m b/src/nsterm.m diff --git a/src/nsterm.m b/src/nsterm.m
index 1780194..d8557c8 100644 index 1780194..203d3a8 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/src/nsterm.m +++ b/src/nsterm.m
@@ -6915,11 +6915,166 @@ ns_create_font_panel_buttons (id target, SEL select, SEL cancel_action) @@ -6915,11 +6915,145 @@ ns_create_font_panel_buttons (id target, SEL select, SEL cancel_action)
are truncated for accessibility purposes. */ are truncated for accessibility purposes. */
#define NS_AX_TEXT_CAP 100000 #define NS_AX_TEXT_CAP 100000
+/* Return true if FACE is or contains a face symbol whose name
+ includes "current" or "selected", indicating a highlighted
+ completion candidate. Works for vertico-current,
+ icomplete-selected-match, ivy-current-match, etc. */
+static bool
+ns_ax_face_is_selected (Lisp_Object face)
+{
+ if (SYMBOLP (face) && !NILP (face))
+ {
+ const char *name = SSDATA (SYMBOL_NAME (face));
+ if (strstr (name, "current") || strstr (name, "selected"))
+ return true;
+ }
+ if (CONSP (face))
+ {
+ for (Lisp_Object tail = face; CONSP (tail); tail = XCDR (tail))
+ if (ns_ax_face_is_selected (XCAR (tail)))
+ return true;
+ }
+ return false;
+}
+
+/* Extract the currently selected candidate text from overlay display +/* Extract the currently selected candidate text from overlay display
+ strings. Completion frameworks (Vertico, Ivy, Icomplete) render + strings. Completion frameworks render candidates as overlay
+ candidates as overlay before-string/after-string and highlight the + before-string/after-string and highlight the current candidate
+ current candidate with a distinct face (e.g. vertico-current). + with a face whose name contains "current" or "selected"
+ (e.g. vertico-current, icomplete-selected-match, ivy-current-match).
+ +
+ Strategy: collect line boundaries in the overlay string, determine + Scan all overlays in the buffer region [BEG, END), find the line
+ the "normal" (non-selected) face by comparing the first and last + whose face matches the selection heuristic, and return it (already
+ lines, then find the outlier line whose face differs. + trimmed of surrounding whitespace).
+ +
+ Also return the 0-based line index of the selected candidate in + Also set *OUT_LINE_INDEX to the 0-based visual line index of the
+ *OUT_LINE_INDEX (or -1 if not found) for Zoom positioning. + selected candidate (for Zoom positioning), counting only non-trivial
+ lines. Set to -1 if not found.
+ +
+ Returns nil if no distinctly-faced line is found. */ + Returns nil if no selected candidate is found. */
+static NSString * +static NSString *
+ns_ax_selected_overlay_text (struct buffer *b, +ns_ax_selected_overlay_text (struct buffer *b,
+ ptrdiff_t beg, ptrdiff_t end, + ptrdiff_t beg, ptrdiff_t end,
@@ -113,8 +131,7 @@ index 1780194..d8557c8 100644
+ if (slen == 0) + if (slen == 0)
+ continue; + continue;
+ +
+ /* Scan for newline positions using SDATA for efficiency + /* Scan for newline positions using SDATA for efficiency. */
+ (avoids per-character Faref Lisp calls). */
+ const unsigned char *data = SDATA (str); + const unsigned char *data = SDATA (str);
+ ptrdiff_t byte_len = SBYTES (str); + ptrdiff_t byte_len = SBYTES (str);
+ ptrdiff_t line_starts[512]; + ptrdiff_t line_starts[512];
@@ -140,7 +157,6 @@ index 1780194..d8557c8 100644
+ byte_pos++; + byte_pos++;
+ char_pos++; + char_pos++;
+ } + }
+ /* Last line (no trailing newline). */
+ if (char_pos > lstart && nlines < 512) + if (char_pos > lstart && nlines < 512)
+ { + {
+ line_starts[nlines] = lstart; + line_starts[nlines] = lstart;
@@ -148,79 +164,36 @@ index 1780194..d8557c8 100644
+ nlines++; + nlines++;
+ } + }
+ +
+ if (nlines == 0) + /* Find the line whose face indicates selection. Track
+ continue; + visual line index for Zoom (skip whitespace-only lines
+ + like Vertico's leading cursor-space). */
+ /* Single candidate: if it has a face, it is the selected one. */ + int candidate_idx = 0;
+ 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++) + for (int li = 0; li < nlines; li++)
+ { + {
+ Lisp_Object lf + Lisp_Object face
+ = Fget_text_property (make_fixnum (line_starts[li]), + = Fget_text_property (make_fixnum (line_starts[li]),
+ Qface, str); + Qface, str);
+ if (NILP (Fequal (lf, normal_face))) + if (ns_ax_face_is_selected (face))
+ { + {
+ *out_line_index = li;
+ Lisp_Object line + Lisp_Object line
+ = Fsubstring_no_properties ( + = Fsubstring_no_properties (
+ str, + str,
+ make_fixnum (line_starts[li]), + make_fixnum (line_starts[li]),
+ make_fixnum (line_ends[li])); + make_fixnum (line_ends[li]));
+ if (SCHARS (line) > 0) + NSString *text = [NSString stringWithLispString:line];
+ return [NSString stringWithLispString:line]; + text = [text stringByTrimmingCharactersInSet:
+ [NSCharacterSet
+ whitespaceAndNewlineCharacterSet]];
+ if ([text length] > 0)
+ {
+ *out_line_index = candidate_idx;
+ return text;
+ }
+ } + }
+
+ /* Count non-trivial lines as candidates for Zoom. */
+ if (line_ends[li] - line_starts[li] > 1)
+ candidate_idx++;
+ } + }
+ } + }
+ } + }
@@ -237,7 +210,7 @@ index 1780194..d8557c8 100644
static NSString * static NSString *
ns_ax_buffer_text (struct window *w, ptrdiff_t *out_start, ns_ax_buffer_text (struct window *w, ptrdiff_t *out_start,
ns_ax_visible_run **out_runs, NSUInteger *out_nruns) ns_ax_visible_run **out_runs, NSUInteger *out_nruns)
@@ -7556,6 +7711,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -7556,6 +7690,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
@synthesize cachedOverlayModiff; @synthesize cachedOverlayModiff;
@synthesize cachedTextStart; @synthesize cachedTextStart;
@synthesize cachedModiff; @synthesize cachedModiff;
@@ -245,7 +218,7 @@ index 1780194..d8557c8 100644
@synthesize cachedPoint; @synthesize cachedPoint;
@synthesize cachedMarkActive; @synthesize cachedMarkActive;
@synthesize cachedCompletionAnnouncement; @synthesize cachedCompletionAnnouncement;
@@ -7609,16 +7765,15 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -7609,16 +7744,15 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
return; return;
ptrdiff_t modiff = BUF_MODIFF (b); ptrdiff_t modiff = BUF_MODIFF (b);
@@ -268,7 +241,7 @@ index 1780194..d8557c8 100644
&& cachedTextStart == BUF_BEGV (b) && cachedTextStart == BUF_BEGV (b)
&& pt >= cachedTextStart && pt >= cachedTextStart
&& (textLen == 0 && (textLen == 0
@@ -7635,7 +7790,6 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -7635,7 +7769,6 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
[cachedText release]; [cachedText release];
cachedText = [text retain]; cachedText = [text retain];
cachedTextModiff = modiff; cachedTextModiff = modiff;
@@ -276,7 +249,7 @@ index 1780194..d8557c8 100644
cachedTextStart = start; cachedTextStart = start;
if (visibleRuns) if (visibleRuns)
@@ -8789,10 +8943,121 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -8789,10 +8922,116 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
BOOL markActive = !NILP (BVAR (b, mark_active)); BOOL markActive = !NILP (BVAR (b, mark_active));
/* --- Text changed (edit) --- */ /* --- Text changed (edit) --- */
@@ -319,14 +292,9 @@ index 1780194..d8557c8 100644
+ &selected_line); + &selected_line);
+ if (candidate) + if (candidate)
+ { + {
+ candidate = [candidate
+ stringByTrimmingCharactersInSet:
+ [NSCharacterSet whitespaceAndNewlineCharacterSet]];
+
+ /* Deduplicate: only announce when the candidate changed. */ + /* Deduplicate: only announce when the candidate changed. */
+ if ([candidate length] > 0 + if (![candidate isEqualToString:
+ && ![candidate isEqualToString: + self.cachedCompletionAnnouncement])
+ self.cachedCompletionAnnouncement])
+ { + {
+ self.cachedCompletionAnnouncement = candidate; + self.cachedCompletionAnnouncement = candidate;
+ +