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.
373 lines
13 KiB
Diff
373 lines
13 KiB
Diff
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
|
|
|
|
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.
|
|
|
|
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.
|
|
|
|
Key implementation details:
|
|
|
|
- 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.
|
|
|
|
- Use BUF_CHARS_MODIFF to gate ValueChanged notifications, since
|
|
text property changes bump BUF_MODIFF but not BUF_CHARS_MODIFF.
|
|
|
|
- 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.
|
|
|
|
- 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).
|
|
|
|
- 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_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 with Zoom tracking.
|
|
---
|
|
src/nsterm.h | 1 +
|
|
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
|
|
--- 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..203d3a8 100644
|
|
--- a/src/nsterm.m
|
|
+++ b/src/nsterm.m
|
|
@@ -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 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).
|
|
+
|
|
+ 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 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 selected candidate 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. */
|
|
+ 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++;
|
|
+ }
|
|
+ if (char_pos > lstart && nlines < 512)
|
|
+ {
|
|
+ line_starts[nlines] = lstart;
|
|
+ line_ends[nlines] = char_pos;
|
|
+ nlines++;
|
|
+ }
|
|
+
|
|
+ /* 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 face
|
|
+ = Fget_text_property (make_fixnum (line_starts[li]),
|
|
+ Qface, str);
|
|
+ if (ns_ax_face_is_selected (face))
|
|
+ {
|
|
+ Lisp_Object line
|
|
+ = Fsubstring_no_properties (
|
|
+ str,
|
|
+ make_fixnum (line_starts[li]),
|
|
+ make_fixnum (line_ends[li]));
|
|
+ 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++;
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+
|
|
+ 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 +7690,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 +7744,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 +7769,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 +8922,116 @@ 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)
|
|
+ {
|
|
+ /* Deduplicate: only announce when the candidate changed. */
|
|
+ if (![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
|
|
|