Files
emacs-doom/patches/0007-ns-announce-overlay-completion-candidates-for-VoiceO.patch
Daneel 73563be72d patches: fix VoiceOver reads only first word in org-agenda
When AXSelectedTextChanged is posted from the parent EmacsView (NSView)
with UIElementsKey pointing to the EmacsAXBuffer element, VoiceOver calls
accessibilityLineForIndex: on the VIEW rather than on the focused element.
In specialised buffers (org-agenda, org-super-agenda) where line geometry
differs from plain text, the view returns an incorrect range and VoiceOver
reads only the first word at the cursor (e.g. 'La' or 'Liga') instead of
the full line.

Plain text buffers were unaffected because the fallback geometry happened
to be correct for simple line layouts.

Fix: post AXSelectedTextChanged on self (the EmacsAXBuffer element)
instead of on self.emacsView.  This causes VoiceOver to call
accessibilityLineForIndex: on the element that owns the selection, which
returns the correct line range in all buffer types.  Remove UIElementsKey
(unnecessary when posting from the element itself).

This aligns with the pre-review code (51f5944) which always posted
AX notifications directly on the focused element.
2026-03-03 09:43:26 +01:00

556 lines
22 KiB
Diff

From 6b9c326c56346bc55381d4d5c68cd34b59165417 Mon Sep 17 00:00:00 2001
From: Daneel <daneel@sukany.cz>
Date: Mon, 2 Mar 2026 18:39:46 +0100
Subject: [PATCH 7/8] ns: announce overlay completion candidates for VoiceOver
Completion frameworks such as Vertico, Ivy, and Icomplete render
candidates via overlay before-string/after-string properties. Without
this change VoiceOver cannot read overlay-based completion UIs.
* src/nsterm.m (ns_ax_face_is_selected): New static function; matches
'current', 'selected', 'selection' in face symbol names.
(ns_ax_selected_overlay_text): New function; scan overlay strings in
the window for a line with a selected face; return its text.
(EmacsAccessibilityBuffer(Notifications)
postAccessibilityNotificationsForFrame:): Handle BUF_OVERLAY_MODIFF
changes independently of text changes. Use BUF_CHARS_MODIFF to gate
ValueChanged; keep overlay_modiff out of ensureTextCache to prevent a
race where an AX query consumes the change before notification.
---
src/nsterm.h | 1 +
src/nsterm.m | 348 ++++++++++++++++++++++++++++++++++++++++++++-------
2 files changed, 307 insertions(+), 42 deletions(-)
diff --git a/src/nsterm.h b/src/nsterm.h
index 5746e9e9bd..21a93bc799 100644
--- a/src/nsterm.h
+++ b/src/nsterm.h
@@ -510,6 +510,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 a0598a73c2..8f744d1bf3 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -7263,11 +7263,154 @@ Accessibility virtual elements (macOS / Cocoa only)
/* ---- Helper: extract buffer text for accessibility ---- */
+/* 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));
+ /* Substring match is intentionally broad --- it catches
+ vertico-current, icomplete-selected-match, ivy-current-match,
+ company-tooltip-selection, and similar. False positives are
+ harmless since this runs only on overlay strings during
+ completion. */
+ if (strstr (name, "current") || strstr (name, "selected")
+ || strstr (name, "selection"))
+ 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, Qbefore_string);
+ strings[1] = Foverlay_get (ov, Qafter_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.
+ The data pointer is used only in this loop, before any
+ Lisp calls (Fget_text_property etc.) that could trigger
+ GC and relocate string data. */
+ const unsigned char *data = SDATA (str);
+ ptrdiff_t byte_len = SBYTES (str);
+ /* 512 lines is sufficient for any completion UI;
+ vertico-count defaults to 10. */
+ 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)
@@ -7340,7 +7483,7 @@ Accessibility virtual elements (macOS / Cocoa only)
/* Extract this visible run's text. Use
Fbuffer_substring_no_properties which correctly handles the
- buffer gap — raw BUF_BYTE_ADDRESS reads across the gap would
+ buffer gap --- raw BUF_BYTE_ADDRESS reads across the gap would
include garbage bytes when the run spans the gap position. */
Lisp_Object lstr = Fbuffer_substring_no_properties (
make_fixnum (pos), make_fixnum (run_end));
@@ -7421,7 +7564,7 @@ Mode lines using icon fonts (e.g. nerd-font icons)
return NSZeroRect;
/* charpos_start and charpos_len are already in buffer charpos
- space — the caller maps AX string indices through
+ space --- the caller maps AX string indices through
charposForAccessibilityIndex which handles invisible text. */
ptrdiff_t cp_start = charpos_start;
ptrdiff_t cp_end = cp_start + charpos_len;
@@ -7896,6 +8039,7 @@ @implementation EmacsAccessibilityBuffer
@synthesize cachedOverlayModiff;
@synthesize cachedTextStart;
@synthesize cachedModiff;
+@synthesize cachedCharsModiff;
@synthesize cachedPoint;
@synthesize cachedMarkActive;
@synthesize cachedCompletionAnnouncement;
@@ -7993,7 +8137,7 @@ - (void)ensureTextCache
NSTRACE ("EmacsAccessibilityBuffer ensureTextCache");
/* This method is only called from the main thread (AX getters
dispatch_sync to main first). Reads of cachedText/cachedTextModiff
- below are therefore safe without @synchronized — only the
+ below are therefore safe without @synchronized --- only the
write section at the end needs synchronization to protect
against concurrent reads from AX server thread. */
eassert ([NSThread isMainThread]);
@@ -8005,25 +8149,34 @@ - (void)ensureTextCache
if (!b)
return;
- /* Use BUF_CHARS_MODIFF, not BUF_MODIFF, for cache validity.
- BUF_MODIFF is bumped by every text-property change, including
- font-lock face applications on every redisplay. AX text contains
- only characters, not face data, so property-only changes do not
- affect the cached value. Rebuilding the full buffer text on
- each font-lock pass is O(buffer-size) per redisplay --- this
- causes progressive slowdown when scrolling through large files.
- BUF_CHARS_MODIFF is bumped only on actual character insertions
- and deletions, matching the semantic of "did the text change".
- This is the pattern used by WebKit and NSTextView.
- Do NOT track BUF_OVERLAY_MODIFF here --- overlay text is not
- included in the cached AX text (it is handled separately via
- explicit announcements in postAccessibilityNotificationsForFrame).
- Including overlay_modiff would silently update cachedOverlayModiff
- and prevent the notification dispatch from detecting changes. */
- ptrdiff_t chars_modiff = BUF_CHARS_MODIFF (b);
+ /* Use BUF_MODIFF, not BUF_CHARS_MODIFF, for cache validity.
+
+ Fold/unfold commands (org-mode, outline-mode, hideshow-mode) change
+ text visibility by modifying the 'invisible text property via
+ `put-text-property' or `add-text-properties'. These bump BUF_MODIFF
+ but NOT BUF_CHARS_MODIFF, because no characters are inserted or
+ deleted. Using only BUF_CHARS_MODIFF would serve stale AX text
+ across fold/unfold: VoiceOver would continue reading hidden content
+ as if it were visible, or miss newly revealed content entirely.
+
+ BUF_MODIFF is bumped by all buffer modifications including
+ text-property changes (e.g. font-lock face assignments), causing a
+ full text-cache rebuild on each redisplay cycle. This is acceptable
+ because `ensureTextCache' is only called when VoiceOver queries
+ accessibilityValue or related AX properties --- which happens at
+ human interaction speed, not at redisplay speed. The per-rebuild
+ cost is O(visible-buffer-text).
+
+ Do NOT use BUF_OVERLAY_MODIFF alone: org-mode >= 29 (org-fold-core)
+ uses text properties, not overlays, for folding, so
+ BUF_OVERLAY_MODIFF would miss those changes. Additionally, modes
+ like hl-line-mode bump BUF_OVERLAY_MODIFF on every
+ post-command-hook, yielding the same per-keystroke rebuild cost as
+ BUF_MODIFF, with none of its correctness guarantee. */
+ ptrdiff_t modiff = BUF_MODIFF (b);
ptrdiff_t pt = BUF_PT (b);
NSUInteger textLen = cachedText ? [cachedText length] : 0;
- if (cachedText && cachedTextModiff == chars_modiff
+ if (cachedText && cachedTextModiff == modiff
&& cachedTextStart == BUF_BEGV (b)
&& pt >= cachedTextStart
&& (textLen == 0
@@ -8039,7 +8192,7 @@ included in the cached AX text (it is handled separately via
{
[cachedText release];
cachedText = [text retain];
- cachedTextModiff = chars_modiff;
+ cachedTextModiff = modiff;
cachedTextStart = start;
if (visibleRuns)
@@ -8051,9 +8204,9 @@ included in the cached AX text (it is handled separately via
Walk the cached text once, recording the start offset of each
line. Uses NSString lineRangeForRange: --- O(N) in the total
text --- but this loop runs only on cache rebuild, which is
- gated on BUF_CHARS_MODIFF: actual character insertions or
- deletions. Font-lock (text property changes) does not trigger
- a rebuild, so the hot path (cursor movement, redisplay) never
+ gated on BUF_MODIFF changes. Rebuilds happen when any buffer
+ modification occurs (including fold/unfold), ensuring the line
+ index always matches the currently visible text.
enters this code. */
if (lineStartOffsets)
xfree (lineStartOffsets);
@@ -8108,7 +8261,7 @@ - (NSUInteger)accessibilityIndexForCharpos:(ptrdiff_t)charpos
/* Binary search: runs are sorted by charpos (ascending). Find the
run whose [charpos, charpos+length) range contains the target,
or the nearest run after an invisible gap. O(log n) instead of
- O(n) — matters for org-mode with many folded sections. */
+ O(n) --- matters for org-mode with many folded sections. */
NSUInteger lo = 0, hi = visibleRunCount;
while (lo < hi)
{
@@ -8157,10 +8310,10 @@ by run length (visible window), not total buffer size. */
/* Convert accessibility string index to buffer charpos.
Safe to call from any thread: uses only cachedText (NSString) and
- visibleRuns — no Lisp calls. */
+ visibleRuns --- no Lisp calls. */
- (ptrdiff_t)charposForAccessibilityIndex:(NSUInteger)ax_idx
{
- /* May be called from AX server thread — synchronize. */
+ /* May be called from AX server thread --- synchronize. */
@synchronized (self)
{
if (visibleRunCount == 0)
@@ -8202,7 +8355,7 @@ the slow path (composed character sequence walk), which is
return cp;
}
}
- /* Past end — return last charpos. */
+ /* Past end --- return last charpos. */
if (lo > 0)
{
ns_ax_visible_run *last = &visibleRuns[visibleRunCount - 1];
@@ -8224,7 +8377,7 @@ the slow path (composed character sequence walk), which is
deadlocking the AX server thread. This is prevented by:
1. validWindow checks WINDOW_LIVE_P and BUFFERP before every
- Lisp access — the window and buffer are verified live.
+ Lisp access --- the window and buffer are verified live.
2. All dispatch_sync blocks run on the main thread where no
concurrent Lisp code can modify state between checks.
3. block_input prevents timer events and process output from
@@ -8570,6 +8723,50 @@ - (NSInteger)accessibilityInsertionPointLineNumber
return [self lineForAXIndex:point_idx];
}
+- (NSString *)accessibilityStringForRange:(NSRange)range
+{
+ if (![NSThread isMainThread])
+ {
+ __block NSString *result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityStringForRange:range];
+ });
+ return result;
+ }
+ [self ensureTextCache];
+ if (!cachedText || range.location + range.length > [cachedText length])
+ return @"";
+ return [cachedText substringWithRange:range];
+}
+
+- (NSAttributedString *)accessibilityAttributedStringForRange:(NSRange)range
+{
+ NSString *str = [self accessibilityStringForRange:range];
+ return [[[NSAttributedString alloc] initWithString:str] autorelease];
+}
+
+- (NSInteger)accessibilityLineForIndex:(NSInteger)index
+{
+ if (![NSThread isMainThread])
+ {
+ __block NSInteger result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityLineForIndex:index];
+ });
+ return result;
+ }
+ [self ensureTextCache];
+ if (!cachedText || index < 0)
+ return 0;
+
+ NSUInteger idx = (NSUInteger) index;
+ if (idx > [cachedText length])
+ idx = [cachedText length];
+
+ return [self lineForAXIndex:idx];
+
+}
+
- (NSRange)accessibilityRangeForLine:(NSInteger)line
{
if (![NSThread isMainThread])
@@ -8792,7 +8989,7 @@ - (NSRect)accessibilityFrame
/* ===================================================================
- EmacsAccessibilityBuffer (Notifications) — AX event dispatch
+ EmacsAccessibilityBuffer (Notifications) --- AX event dispatch
These methods notify VoiceOver of text and selection changes.
Called from the redisplay cycle (postAccessibilityUpdates).
@@ -8807,7 +9004,7 @@ - (void)postTextChangedNotification:(ptrdiff_t)point
if (point > self.cachedPoint
&& point - self.cachedPoint == 1)
{
- /* Single char inserted — refresh cache and grab it. */
+ /* Single char inserted --- refresh cache and grab it. */
[self invalidateTextCache];
[self ensureTextCache];
if (cachedText)
@@ -8826,7 +9023,7 @@ - (void)postTextChangedNotification:(ptrdiff_t)point
/* Update cachedPoint here so the selection-move branch does NOT
fire for point changes caused by edits. WebKit and Chromium
never send both ValueChanged and SelectedTextChanged for the
- same user action — they are mutually exclusive. */
+ same user action --- they are mutually exclusive. */
self.cachedPoint = point;
NSDictionary *change = @{
@@ -9220,16 +9417,83 @@ - (void)postAccessibilityNotificationsForFrame:(struct frame *)f
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);
+
+ /* Overlay completion candidates (Vertico, Icomplete, Ivy) are
+ displayed in the minibuffer. In normal editing buffers,
+ font-lock and other modes change BUF_OVERLAY_MODIFF on
+ every redisplay, triggering O(overlays) work per keystroke.
+ Restrict the scan to minibuffer windows. */
+ if (!MINI_WINDOW_P (w))
+ goto skip_overlay_scan;
+
+ 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);
+
+ }
+ }
}
+ skip_overlay_scan:;
/* --- Cursor moved or selection changed ---
- Use 'else if' — edits and selection moves are mutually exclusive
- per the WebKit/Chromium pattern. */
- else if (point != self.cachedPoint || markActive != self.cachedMarkActive)
+ Independent check: the goto above may jump here from the overlay
+ branch, so this must be a standalone if, not else-if. */
+ if (point != self.cachedPoint || markActive != self.cachedMarkActive)
{
ptrdiff_t oldPoint = self.cachedPoint;
BOOL oldMarkActive = self.cachedMarkActive;
@@ -12402,7 +12666,7 @@ - (int) fullscreenState
if (WINDOW_LEAF_P (w))
{
- /* Buffer element — reuse existing if available. */
+ /* Buffer element --- reuse existing if available. */
EmacsAccessibilityBuffer *elem
= [existing objectForKey:[NSValue valueWithPointer:w]];
if (!elem)
@@ -12436,7 +12700,7 @@ - (int) fullscreenState
}
else
{
- /* Internal (combination) window — recurse into children. */
+ /* Internal (combination) window --- recurse into children. */
Lisp_Object child = w->contents;
while (!NILP (child))
{
@@ -12548,7 +12812,7 @@ - (void)postAccessibilityUpdates
accessibilityUpdating = YES;
/* Detect window tree change (split, delete, new buffer). Compare
- FRAME_ROOT_WINDOW — if it changed, the tree structure changed. */
+ FRAME_ROOT_WINDOW --- if it changed, the tree structure changed. */
Lisp_Object curRoot = FRAME_ROOT_WINDOW (emacsframe);
if (!EQ (curRoot, lastRootWindow))
{
@@ -12557,12 +12821,12 @@ - (void)postAccessibilityUpdates
}
/* If tree is stale, rebuild FIRST so we don't iterate freed
- window pointers. Skip notifications for this cycle — the
+ window pointers. Skip notifications for this cycle --- the
freshly-built elements have no previous state to diff against. */
if (!accessibilityTreeValid)
{
[self rebuildAccessibilityTree];
- /* Invalidate span cache — window layout changed. */
+ /* Invalidate span cache --- window layout changed. */
for (EmacsAccessibilityElement *elem in accessibilityElements)
if ([elem isKindOfClass: [EmacsAccessibilityBuffer class]])
[(EmacsAccessibilityBuffer *) elem invalidateInteractiveSpans];
--
2.43.0