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>
Date: Sat, 28 Feb 2026 14:46:25 +0100
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
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.
Identify the selected candidate by scanning overlay strings for a
face whose symbol name contains "current" or "selected" --- this
matches vertico-current, icomplete-selected-match, ivy-current-match
and similar framework faces without hard-coding any specific name.
Fix with five changes:
Key implementation details:
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.
- The overlay detection branch runs independently (if, not else-if)
of the text-change branch, because Vertico bumps both BUF_MODIFF
(via text property changes in vertico--prompt-selection) and
BUF_OVERLAY_MODIFF (via overlay-put) in the same command cycle.
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.
- Use BUF_CHARS_MODIFF to gate ValueChanged notifications, since
text property changes bump BUF_MODIFF but not BUF_CHARS_MODIFF.
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".
- Remove BUF_OVERLAY_MODIFF from ensureTextCache validity checks
to prevent a race condition where VoiceOver AX queries silently
consume the overlay change before the notification dispatch runs.
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.
- Announce via AnnouncementRequested to NSApp with High priority.
Do not post SelectedTextChanged (that reads the AX text at cursor
position, which is the minibuffer input, not the candidate).
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.
- Add Zoom tracking (UAZoomChangeFocus) for the selected candidate
glyph row in the minibuffer window matrix.
* 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
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.
Use BUF_CHARS_MODIFF for ValueChanged gating; make overlay branch
independent; announce candidate with Zoom tracking.
---
src/nsterm.h | 1 +
src/nsterm.m | 285 +++++++++++++++++++++++++++++++++++++++++++++++++--
2 files changed, 276 insertions(+), 10 deletions(-)
src/nsterm.m | 259 +++++++++++++++++++++++++++++++++++++++++++++++++--
2 files changed, 250 insertions(+), 10 deletions(-)
diff --git a/src/nsterm.h b/src/nsterm.h
index 51c30ca..dd0e226 100644
@@ -66,26 +60,50 @@ index 51c30ca..dd0e226 100644
@property (nonatomic, assign) BOOL cachedMarkActive;
@property (nonatomic, copy) NSString *cachedCompletionAnnouncement;
diff --git a/src/nsterm.m b/src/nsterm.m
index 1780194..d8557c8 100644
index 1780194..203d3a8 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)
@@ -6915,11 +6915,145 @@ ns_create_font_panel_buttons (id target, SEL select, SEL cancel_action)
are truncated for accessibility purposes. */
#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
+ 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).
+ strings. Completion frameworks render candidates as overlay
+ before-string/after-string and highlight the current candidate
+ 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
+ the "normal" (non-selected) face by comparing the first and last
+ lines, then find the outlier line whose face differs.
+ Scan all overlays in the buffer region [BEG, END), find the line
+ whose face matches the selection heuristic, and return it (already
+ trimmed of surrounding whitespace).
+
+ Also return the 0-based line index of the selected candidate in
+ *OUT_LINE_INDEX (or -1 if not found) for Zoom positioning.
+ Also set *OUT_LINE_INDEX to the 0-based visual line index of the
+ 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 *
+ns_ax_selected_overlay_text (struct buffer *b,
+ ptrdiff_t beg, ptrdiff_t end,
@@ -113,8 +131,7 @@ index 1780194..d8557c8 100644
+ if (slen == 0)
+ continue;
+
+ /* Scan for newline positions using SDATA for efficiency
+ (avoids per-character Faref Lisp calls). */
+ /* Scan for newline positions using SDATA for efficiency. */
+ const unsigned char *data = SDATA (str);
+ ptrdiff_t byte_len = SBYTES (str);
+ ptrdiff_t line_starts[512];
@@ -140,7 +157,6 @@ index 1780194..d8557c8 100644
+ byte_pos++;
+ char_pos++;
+ }
+ /* Last line (no trailing newline). */
+ if (char_pos > lstart && nlines < 512)
+ {
+ line_starts[nlines] = lstart;
@@ -148,79 +164,36 @@ index 1780194..d8557c8 100644
+ 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;
+ }
+
+ /* Find the line whose face indicates selection. Track
+ visual line index for Zoom (skip whitespace-only lines
+ like Vertico's leading cursor-space). */
+ int candidate_idx = 0;
+ for (int li = 0; li < nlines; li++)
+ {
+ Lisp_Object lf
+ Lisp_Object face
+ = Fget_text_property (make_fixnum (line_starts[li]),
+ Qface, str);
+ if (NILP (Fequal (lf, normal_face)))
+ if (ns_ax_face_is_selected (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];
+ NSString *text = [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 *
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,
@@ -7556,6 +7690,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
@synthesize cachedOverlayModiff;
@synthesize cachedTextStart;
@synthesize cachedModiff;
@@ -245,7 +218,7 @@ index 1780194..d8557c8 100644
@synthesize cachedPoint;
@synthesize cachedMarkActive;
@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;
ptrdiff_t modiff = BUF_MODIFF (b);
@@ -268,7 +241,7 @@ index 1780194..d8557c8 100644
&& cachedTextStart == BUF_BEGV (b)
&& pt >= cachedTextStart
&& (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 = [text retain];
cachedTextModiff = modiff;
@@ -276,7 +249,7 @@ index 1780194..d8557c8 100644
cachedTextStart = start;
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));
/* --- Text changed (edit) --- */
@@ -319,14 +292,9 @@ index 1780194..d8557c8 100644
+ &selected_line);
+ if (candidate)
+ {
+ candidate = [candidate
+ stringByTrimmingCharactersInSet:
+ [NSCharacterSet whitespaceAndNewlineCharacterSet]];
+
+ /* Deduplicate: only announce when the candidate changed. */
+ if ([candidate length] > 0
+ && ![candidate isEqualToString:
+ self.cachedCompletionAnnouncement])
+ if (![candidate isEqualToString:
+ self.cachedCompletionAnnouncement])
+ {
+ self.cachedCompletionAnnouncement = candidate;
+