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:
@@ -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,80 +164,37 @@ 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,13 +292,8 @@ 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;
|
||||||
|
|||||||
Reference in New Issue
Block a user