From fcff3429b10cf7d2cd6b8874690b64330fc3a2ee Mon Sep 17 00:00:00 2001 From: Daneel Date: Sat, 28 Feb 2026 15:12:11 +0100 Subject: [PATCH] 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 --- ...lay-completion-candidates-for-VoiceO.patch | 208 +++++++++++++----- 1 file changed, 155 insertions(+), 53 deletions(-) diff --git a/patches/0007-ns-announce-overlay-completion-candidates-for-VoiceO.patch b/patches/0007-ns-announce-overlay-completion-candidates-for-VoiceO.patch index 01cd6ba..d4933b3 100644 --- a/patches/0007-ns-announce-overlay-completion-candidates-for-VoiceO.patch +++ b/patches/0007-ns-announce-overlay-completion-candidates-for-VoiceO.patch @@ -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 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