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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user