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 d4933b3..f39d180 100644 --- a/patches/0007-ns-announce-overlay-completion-candidates-for-VoiceO.patch +++ b/patches/0007-ns-announce-overlay-completion-candidates-for-VoiceO.patch @@ -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 Date: Sat, 28 Feb 2026 14:46:25 +0100 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 overlay-based completion UIs. -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. +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. -Fix with five changes: +Key implementation details: -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. +- 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. -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. +- Use BUF_CHARS_MODIFF to gate ValueChanged notifications, since + text property changes bump BUF_MODIFF but not BUF_CHARS_MODIFF. -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". +- 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. -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. +- 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). -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. +- 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_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 from cache validity check. (EmacsAccessibilityBuffer postAccessibilityNotificationsForFrame:): -Use BUF_CHARS_MODIFF for ValueChanged gating. Make overlay branch -independent. Announce candidate via AnnouncementRequested to NSApp -with Zoom tracking. +Use BUF_CHARS_MODIFF for ValueChanged gating; make overlay branch +independent; announce candidate with Zoom tracking. --- src/nsterm.h | 1 + - src/nsterm.m | 285 +++++++++++++++++++++++++++++++++++++++++++++++++-- - 2 files changed, 276 insertions(+), 10 deletions(-) + 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 @@ -66,26 +60,50 @@ index 51c30ca..dd0e226 100644 @property (nonatomic, assign) BOOL cachedMarkActive; @property (nonatomic, copy) NSString *cachedCompletionAnnouncement; diff --git a/src/nsterm.m b/src/nsterm.m -index 1780194..d8557c8 100644 +index 1780194..203d3a8 100644 --- a/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. */ #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 (Vertico, Ivy, Icomplete) render -+ candidates as overlay before-string/after-string and highlight the -+ current candidate with a distinct face (e.g. vertico-current). ++ 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). + -+ Strategy: collect line boundaries in the overlay string, determine -+ the "normal" (non-selected) face by comparing the first and last -+ lines, then find the outlier line whose face differs. ++ 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 return the 0-based line index of the selected candidate in -+ *OUT_LINE_INDEX (or -1 if not found) for Zoom positioning. ++ 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 distinctly-faced line is 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, @@ -113,8 +131,7 @@ index 1780194..d8557c8 100644 + if (slen == 0) + continue; + -+ /* Scan for newline positions using SDATA for efficiency -+ (avoids per-character Faref Lisp calls). */ ++ /* 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]; @@ -140,7 +157,6 @@ index 1780194..d8557c8 100644 + byte_pos++; + char_pos++; + } -+ /* Last line (no trailing newline). */ + if (char_pos > lstart && nlines < 512) + { + line_starts[nlines] = lstart; @@ -148,79 +164,36 @@ index 1780194..d8557c8 100644 + nlines++; + } + -+ 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, -+ 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; -+ } -+ ++ /* 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 lf ++ Lisp_Object face + = Fget_text_property (make_fixnum (line_starts[li]), + Qface, str); -+ if (NILP (Fequal (lf, normal_face))) ++ if (ns_ax_face_is_selected (face)) + { -+ *out_line_index = li; + Lisp_Object line + = Fsubstring_no_properties ( + str, + make_fixnum (line_starts[li]), + make_fixnum (line_ends[li])); -+ if (SCHARS (line) > 0) -+ return [NSString stringWithLispString:line]; ++ 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++; + } + } + } @@ -237,7 +210,7 @@ index 1780194..d8557c8 100644 static NSString * ns_ax_buffer_text (struct window *w, ptrdiff_t *out_start, 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 cachedTextStart; @synthesize cachedModiff; @@ -245,7 +218,7 @@ index 1780194..d8557c8 100644 @synthesize cachedPoint; @synthesize cachedMarkActive; @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; ptrdiff_t modiff = BUF_MODIFF (b); @@ -268,7 +241,7 @@ index 1780194..d8557c8 100644 && cachedTextStart == BUF_BEGV (b) && pt >= cachedTextStart && (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 = [text retain]; cachedTextModiff = modiff; @@ -276,7 +249,7 @@ index 1780194..d8557c8 100644 cachedTextStart = start; 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)); /* --- Text changed (edit) --- */ @@ -319,14 +292,9 @@ index 1780194..d8557c8 100644 + &selected_line); + if (candidate) + { -+ candidate = [candidate -+ stringByTrimmingCharactersInSet: -+ [NSCharacterSet whitespaceAndNewlineCharacterSet]]; -+ + /* Deduplicate: only announce when the candidate changed. */ -+ if ([candidate length] > 0 -+ && ![candidate isEqualToString: -+ self.cachedCompletionAnnouncement]) ++ if (![candidate isEqualToString: ++ self.cachedCompletionAnnouncement]) + { + self.cachedCompletionAnnouncement = candidate; +