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
This commit is contained in:
2026-02-28 15:12:11 +01:00
parent 9408e37a90
commit fcff3429b1

View File

@@ -1,45 +1,75 @@
From 313b4e4489a617fdd074f577ba024dec88eda87e Mon Sep 17 00:00:00 2001
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, VoiceOver cannot read overlay-based
completion UIs.
than buffer text. Without this patch, VoiceOver cannot read
overlay-based completion UIs.
Add ns_ax_selected_overlay_text to extract the currently highlighted
candidate from overlay strings. The function determines the normal
(non-selected) face by comparing the first and last lines via Fequal,
then returns the line with a different face (the selected candidate).
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.
In the notification dispatch, detect overlay-only changes via
BUF_OVERLAY_MODIFF (which is bumped by overlay property changes but
not buffer text edits). Crucially, do NOT invalidate the text cache
on overlay changes --- the buffer text is unchanged and cache
invalidation causes VoiceOver to diff old vs new text, announcing
spurious newlines. Instead, post SelectedTextChanged to interrupt
current speech, then AnnouncementRequested to NSApp with the
candidate text.
Fix with five changes:
Add Zoom tracking for overlay candidates: find the glyph row
corresponding to the selected candidate and call UAZoomChangeFocus
so the Zoom lens follows the selection.
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.
Returns the selected overlay candidate text and its line index.
(EmacsAccessibilityBuffer ensureTextCache): Remove overlay_modiff
from cache validity check.
(EmacsAccessibilityBuffer postAccessibilityNotificationsForFrame:):
Handle BUF_OVERLAY_MODIFF changes with candidate announcement and
Zoom focus tracking.
Use BUF_CHARS_MODIFF for ValueChanged gating. Make overlay branch
independent. Announce candidate via AnnouncementRequested to NSApp
with Zoom tracking.
---
src/nsterm.m | 240 ++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 239 insertions(+), 1 deletion(-)
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..4cf7b0c 100644
index 1780194..d8557c8 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -6915,11 +6915,146 @@ ns_create_font_panel_buttons (id target, SEL select, SEL cancel_action)
@@ -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
@@ -118,9 +148,29 @@ index 1780194..4cf7b0c 100644
+ nlines++;
+ }
+
+ if (nlines < 2)
+ 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,
@@ -187,18 +237,79 @@ index 1780194..4cf7b0c 100644
static NSString *
ns_ax_buffer_text (struct window *w, ptrdiff_t *out_start,
ns_ax_visible_run **out_runs, NSUInteger *out_nruns)
@@ -8795,6 +8930,109 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
[self postTextChangedNotification:point];
}
@@ -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) ---
+ Overlay-only changes (before-string, after-string, display) bump
+ BUF_OVERLAY_MODIFF but not BUF_MODIFF. Do NOT invalidate the
+ text cache here --- the buffer text itself has not changed, and
+ cache invalidation would cause VoiceOver to diff the old vs new
+ AX text and announce spurious "new line" from newlines.
+ Instead, announce the selected candidate explicitly. */
+ else if (BUF_OVERLAY_MODIFF (b) != self.cachedOverlayModiff)
+ 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);
+
@@ -219,19 +330,12 @@ index 1780194..4cf7b0c 100644
+ {
+ self.cachedCompletionAnnouncement = candidate;
+
+ /* Post SelectedTextChanged to interrupt VoiceOver's
+ current speech, then announce the candidate text
+ via NSApp (Apple requires app-level element). */
+ NSDictionary *moveInfo = @{
+ @"AXTextStateChangeType":
+ @(ns_ax_text_state_change_selection_move),
+ @"AXTextChangeElement": self
+ };
+ ns_ax_post_notification_with_info (
+ self,
+ NSAccessibilitySelectedTextChangedNotification,
+ moveInfo);
+
+ /* 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:
@@ -292,11 +396,9 @@ index 1780194..4cf7b0c 100644
+#endif
+ }
+ }
+ }
+
}
/* --- Cursor moved or selection changed ---
Use 'else if' — edits and selection moves are mutually exclusive
per the WebKit/Chromium pattern. */
--
2.43.0