Files
emacs-doom/patches/0008-ns-announce-child-frame-completion-candidates-for-Vo.patch
Daneel 3bb6c989c9 patches: address maintainer review findings (C1/C2/H1/H2/M5/M6)
C1 - block_input ordering in ns_ax_buffer_text:
block_input() now called before record_unwind_protect_void(unblock_input).
Previously the unwind handler could have been called without a matching
block_input, corrupting the input-blocking reference count.

C2 - unbind_to missing in patch 0004:
unbind_to(blk_count, Qnil) moved from patch 0008 to patch 0004 so that
ns_ax_scan_interactive_spans has a complete block_input/unbind_to pair
when patches 0000-0004 are applied independently.

H1 - Zoom patch forward dependency on VoiceOver:
Removed forward declaration 'static bool ns_ax_face_is_selected' and
the delegation from ns_zoom_face_is_selected.  Restored standalone
implementation of ns_zoom_face_is_selected in the Zoom patch so patch
0000 compiles and links independently of the VoiceOver patches.

H2 - ns_accessibility_enabled removal undocumented:
Added comment to ns_zoom_track_completion explaining that Zoom cursor
tracking is gated only on ns_zoom_enabled_p(), not ns_accessibility_enabled.
Users running Zoom without VoiceOver must still get completion tracking.

M5 - childFrameLastBuffer GC safety undocumented:
Added comment at the assignment site explaining why BVAR(b, name) (an
interned symbol reachable from obarray) is GC-safe without staticpro.

M6 - FOR_EACH_FRAME without block_input:
Added block_input/unblock_input around the FOR_EACH_FRAME loop in
postAccessibilityUpdates that checks for visible child frames.
Vframe_list must not be modified by timers or process sentinels
during iteration.
2026-03-03 10:11:39 +01:00

833 lines
35 KiB
Diff

From 7cb9bca680a56848fe4bd4ddb51f643e8946e7b8 Mon Sep 17 00:00:00 2001
From: Daneel <daneel@sukany.cz>
Date: Mon, 2 Mar 2026 18:49:13 +0100
Subject: [PATCH 8/8] ns: announce child frame completion candidates for
VoiceOver
Child frame popups (Corfu, Company-mode child frames) render completion
candidates in a separate frame whose buffer is not accessible via the
minibuffer overlay path. This patch scans child frame buffers for
selected candidates and announces them via VoiceOver.
* src/nsterm.h (EmacsView): Add childFrameLastBuffer, childFrameLastModiff,
childFrameLastCandidate, childFrameCompletionActive, lastEchoCharsModiff
ivars; remove cachedOverlayModiffForText (unused after BUF_OVERLAY_MODIFF
removed from ensureTextCache to prevent hl-line-mode O(N) rebuilds).
Initialize voiceoverSetPoint, childFrameLastBuffer in initFrameFromEmacs:.
(EmacsAXBuffer): Add voiceoverSetPoint ivar.
* src/nsterm.m (ns_ax_buffer_text): Add block_input protection for
Lisp calls; use record_unwind_protect_void to guarantee unblock_input.
(ensureTextCache): Remove BUF_OVERLAY_MODIFF tracking; keep only
BUF_CHARS_MODIFF. BUF_OVERLAY_MODIFF caused O(buffer-size) rebuilds
with hl-line-mode (moves overlay on every post-command-hook).
(announceChildFrameCompletion): New method; scans child frame buffers
for selected completion candidates. Store childFrameLastBuffer as
BVAR(b, name) (buffer name symbol, GC-reachable via obarray) rather
than make_lisp_ptr to avoid dangling pointer after buffer kill.
(postEchoAreaAnnouncementIfNeeded): New method; announces echo area
changes for commands like C-g.
(postAccessibilityNotificationsForFrame:): Drive child frame and echo
area announcements.
* doc/emacs/macos.texi: Fix dangling semicolon in GNUstep paragraph.
---
doc/emacs/macos.texi | 14 +-
etc/NEWS | 18 +-
src/nsterm.h | 20 ++
src/nsterm.m | 507 +++++++++++++++++++++++++++++++++++++++----
4 files changed, 499 insertions(+), 60 deletions(-)
diff --git a/doc/emacs/macos.texi b/doc/emacs/macos.texi
index 8d4a7825d8..03a657f970 100644
--- a/doc/emacs/macos.texi
+++ b/doc/emacs/macos.texi
@@ -278,7 +278,6 @@ restart Emacs to access newly-available services.
@cindex VoiceOver
@cindex accessibility (macOS)
@cindex screen reader (macOS)
-@cindex Zoom, cursor tracking (macOS)
When built with the Cocoa interface on macOS, Emacs exposes buffer
content, cursor position, mode lines, and interactive elements to the
@@ -309,10 +308,15 @@ Shift-modified movement announces selected or deselected text.
The @file{*Completions*} buffer announces each completion candidate
as you navigate, even while keyboard focus remains in the minibuffer.
- macOS Zoom (System Settings, Accessibility, Zoom) tracks the Emacs
-cursor automatically when set to follow keyboard focus. The cursor
-position is communicated via @code{UAZoomChangeFocus} and the
-@code{AXBoundsForRange} accessibility attribute.
+ Echo area messages are announced automatically. When a background
+operation completes and displays a message (e.g., @samp{Git finished},
+@samp{Wrote file}), VoiceOver reads it without requiring any action.
+Messages are suppressed while the minibuffer is active (i.e., while
+you are typing a command) to avoid interrupting prompt reading.
+
+ VoiceOver's rotor browse cursor stays synchronized with the Emacs
+cursor after large programmatic jumps (for example, heading navigation
+in Org mode, @code{xref-find-definitions}, or @code{imenu}).
@vindex ns-accessibility-enabled
To disable the accessibility interface entirely (for instance, to
diff --git a/etc/NEWS b/etc/NEWS
index 7f917f93b2..d7631fa6c7 100644
--- a/etc/NEWS
+++ b/etc/NEWS
@@ -4385,16 +4385,20 @@ allowing Emacs users access to speech recognition utilities.
Note: Accepting this permission allows the use of system APIs, which may
send user data to Apple's speech recognition servers.
----
++++
** VoiceOver accessibility support on macOS.
Emacs now exposes buffer content, cursor position, and interactive
elements to the macOS accessibility subsystem (VoiceOver). This
-includes AXBoundsForRange for macOS Zoom cursor tracking, line and
-word navigation announcements, Tab-navigable interactive spans
-(buttons, links, completion candidates), and completion announcements
-for the *Completions* buffer. The implementation uses a virtual
-accessibility tree with per-window elements, hybrid SelectedTextChanged
-and AnnouncementRequested notifications, and thread-safe text caching.
+includes:
+- Line and word navigation announcements via standard movement keys.
+- Echo area messages (e.g., "Wrote file", "Git finished") announced
+ automatically as they appear, without user interaction.
+- VoiceOver rotor cursor synchronization after large programmatic
+ jumps (]], M-<, xref, imenu, etc.).
+- Tab-navigable interactive spans (buttons, links, completion
+ candidates) within a buffer.
+- Completion announcements for the *Completions* buffer and overlay
+ and child-frame completion UIs (Vertico, Corfu, Company-box).
Set 'ns-accessibility-enabled' to nil to disable the accessibility
interface and eliminate the associated overhead.
diff --git a/src/nsterm.h b/src/nsterm.h
index 21a93bc799..bdd40b8eb7 100644
--- a/src/nsterm.h
+++ b/src/nsterm.h
@@ -504,9 +504,20 @@ typedef struct ns_ax_visible_run
NSUInteger lineCount; /* Entries in lineStartOffsets. */
NSMutableArray *cachedInteractiveSpans;
BOOL interactiveSpansDirty;
+ /* Set to YES in setAccessibilitySelectedTextRange: (VoiceOver moved
+ the cursor); reset to NO in postAccessibilityNotificationsForFrame:.
+ When YES, cursor notifications use sequential direction so VoiceOver
+ continues smooth line/character navigation without re-anchoring.
+ When NO, Emacs moved the cursor independently; use discontiguous
+ direction so VoiceOver re-anchors its browse cursor to the new
+ accessibilitySelectedTextRange. */
+ BOOL voiceoverSetPoint;
}
@property (nonatomic, retain) NSString *cachedText;
@property (nonatomic, assign) ptrdiff_t cachedTextModiff;
+/* Overlay modiff at last text cache rebuild. Tracked separately from
+ cachedOverlayModiff (which is used for completion announcements) so
+ that fold/unfold detection is independent of notification dispatch. */
@property (nonatomic, assign) ptrdiff_t cachedOverlayModiff;
@property (nonatomic, assign) ptrdiff_t cachedTextStart;
@property (nonatomic, assign) ptrdiff_t cachedModiff;
@@ -596,6 +607,14 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType)
Lisp_Object lastRootWindow;
BOOL accessibilityTreeValid;
BOOL accessibilityUpdating;
+ BOOL childFrameCompletionActive;
+ char *childFrameLastCandidate;
+ Lisp_Object childFrameLastBuffer;
+ EMACS_INT childFrameLastModiff;
+ /* Last BUF_CHARS_MODIFF seen for echo_area_buffer[0]. Used by
+ postEchoAreaAnnouncementIfNeeded to detect new echo area messages
+ independently of the per-element notification cycle. */
+ ptrdiff_t lastEchoCharsModiff;
#endif
BOOL font_panel_active;
NSFont *font_panel_result;
@@ -665,6 +684,7 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType)
- (void)rebuildAccessibilityTree;
- (void)invalidateAccessibilityTree;
- (void)postAccessibilityUpdates;
+- (void)announceChildFrameCompletion;
#endif
@end
diff --git a/src/nsterm.m b/src/nsterm.m
index 8ef344d9fe..5038f9830d 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -1275,7 +1275,13 @@ If a completion candidate is selected (overlay or child frame),
static void
ns_zoom_track_completion (struct frame *f, EmacsView *view)
{
- if (!ns_accessibility_enabled || !ns_zoom_enabled_p ())
+ /* Zoom cursor tracking is controlled exclusively by
+ ns_zoom_enabled_p (). We do NOT gate on ns_accessibility_enabled:
+ users can run Zoom without VoiceOver, and those users should still
+ get completion-candidate tracking. ns_accessibility_enabled is
+ only set when a screen reader (VoiceOver or similar) activates the
+ AX layer; it has no bearing on the Zoom feature. */
+ if (!ns_zoom_enabled_p ())
return;
if (!WINDOWP (f->selected_window))
return;
@@ -1393,7 +1399,7 @@ so the visual offset is (ov_line + 1) * line_h from
(zoomCursorUpdated is NO). */
#if defined (MAC_OS_X_VERSION_MIN_REQUIRED) \
&& MAC_OS_X_VERSION_MIN_REQUIRED >= 101000
- if (ns_accessibility_enabled && view && !view->zoomCursorUpdated
+ if (view && !view->zoomCursorUpdated
&& ns_zoom_enabled_p ()
&& !NSIsEmptyRect (view->lastCursorRect))
{
@@ -3571,7 +3577,7 @@ EmacsView pixels (AppKit, flipped, top-left origin)
#if defined (MAC_OS_X_VERSION_MIN_REQUIRED) \
&& MAC_OS_X_VERSION_MIN_REQUIRED >= 101000
- if (ns_accessibility_enabled && ns_zoom_enabled_p ())
+ if (ns_zoom_enabled_p ())
{
NSRect windowRect = [view convertRect:r toView:nil];
NSRect screenRect
@@ -7407,6 +7413,112 @@ visual line index for Zoom (skip whitespace-only lines
return nil;
}
+
+
+/* Scan buffer text of a child frame for the selected completion
+ candidate. Used for frameworks that render candidates in a
+ child frame (e.g. Corfu, Company-box) rather than as overlay
+ strings. Check the effective face (text properties + overlays)
+ at the start of each line via Fget_char_property.
+
+ Returns the candidate text (trimmed) or nil. Sets
+ *OUT_LINE_INDEX to the 0-based line index for Zoom. */
+static NSString *
+ns_ax_selected_child_frame_text (struct buffer *b, Lisp_Object buf_obj,
+ int *out_line_index)
+{
+ *out_line_index = -1;
+ ptrdiff_t beg = BUF_BEGV (b);
+ ptrdiff_t end = BUF_ZV (b);
+
+ if (beg >= end)
+ return nil;
+
+ /* Temporarily switch to the child frame buffer.
+ Fbuffer_substring_no_properties operates on current_buffer,
+ which may be a different buffer (e.g., the parent frame's). */
+ specpdl_ref count = SPECPDL_INDEX ();
+ record_unwind_current_buffer ();
+ set_buffer_internal_1 (b);
+
+ /* Get buffer text as a Lisp string for efficient scanning.
+ The buffer is a small completion popup (typically < 20 lines). */
+ Lisp_Object str
+ = Fbuffer_substring_no_properties (make_fixnum (beg),
+ make_fixnum (end));
+ if (!STRINGP (str) || SCHARS (str) == 0)
+ {
+ unbind_to (count, Qnil);
+ return nil;
+ }
+
+ /* Scan newlines (same pattern as ns_ax_selected_overlay_text).
+ The data pointer is used only in this loop, before Lisp calls. */
+ const unsigned char *data = SDATA (str);
+ ptrdiff_t byte_len = SBYTES (str);
+ ptrdiff_t line_starts[128];
+ ptrdiff_t line_ends[128];
+ int nlines = 0;
+ ptrdiff_t char_pos = 0, byte_pos = 0, lstart = 0;
+
+ while (byte_pos < byte_len && nlines < 128)
+ {
+ 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 < 128)
+ {
+ line_starts[nlines] = lstart;
+ line_ends[nlines] = char_pos;
+ nlines++;
+ }
+
+ /* Find the line with a selected face. Use Fget_char_property on
+ the BUFFER (not the string) so overlay faces are included.
+ Offset string positions by beg to get buffer positions. */
+ for (int li = 0; li < nlines; li++)
+ {
+ ptrdiff_t buf_pos = beg + line_starts[li];
+ Lisp_Object face
+ = Fget_char_property (make_fixnum (buf_pos), Qface, buf_obj);
+
+ 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 = li;
+ unbind_to (count, Qnil);
+ return text;
+ }
+ }
+ }
+
+ unbind_to (count, Qnil);
+ 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
@@ -7440,9 +7552,13 @@ visual line index for Zoom (skip whitespace-only lines
return @"";
specpdl_ref count = SPECPDL_INDEX ();
+ /* block_input must precede record_unwind_protect_void (unblock_input):
+ if anything between SPECPDL_INDEX and block_input were to throw,
+ the unwind handler would call unblock_input without a matching
+ block_input, corrupting the input-blocking reference count. */
+ block_input ();
record_unwind_current_buffer ();
record_unwind_protect_void (unblock_input);
- block_input ();
if (b != current_buffer)
set_buffer_internal_1 (b);
@@ -8605,6 +8721,11 @@ - (void)setAccessibilitySelectedTextRange:(NSRange)range
[self ensureTextCache];
+ /* Record that VoiceOver (not Emacs) is moving the cursor so that the
+ subsequent postAccessibilityNotificationsForFrame: call can use the
+ correct sequential direction rather than forcing a re-anchor. */
+ voiceoverSetPoint = YES;
+
specpdl_ref count = SPECPDL_INDEX ();
record_unwind_current_buffer ();
/* Ensure block_input is always matched by unblock_input even if
@@ -9053,20 +9174,38 @@ - (void)postFocusedCursorNotification:(ptrdiff_t)point
&& granularity
== ns_ax_text_selection_granularity_character);
- /* Always post SelectedTextChanged to interrupt VoiceOver reading
- and update cursor tracking / braille displays. */
+ /* Post SelectedTextChanged to interrupt VoiceOver reading and
+ update cursor tracking / braille displays.
+ For sequential moves (direction = next/previous): include
+ direction + granularity so VoiceOver reads the destination line
+ or word without additional state queries.
+ For discontiguous jumps (teleports, multi-line leaps): omit
+ direction and granularity and let VoiceOver determine what to read
+ from its own navigation state. This matches the pre-review
+ behaviour and ensures VoiceOver reads the full destination line
+ even when the jump skips blank or invisible lines (e.g. org-agenda
+ items separated by blank lines, where adjacency detection cannot
+ classify the move as singleLineMove). */
NSMutableDictionary *moveInfo = [NSMutableDictionary dictionary];
moveInfo[@"AXTextStateChangeType"]
= @(ns_ax_text_state_change_selection_move);
- moveInfo[@"AXTextSelectionDirection"] = @(direction);
moveInfo[@"AXTextChangeElement"] = self;
- /* Omit granularity for character moves so VoiceOver does not
- derive its own speech (it would read the wrong character
- for block-cursor mode). Include it for word/line/
- selection so VoiceOver reads the appropriate text. */
- if (!isCharMove)
- moveInfo[@"AXTextSelectionGranularity"] = @(granularity);
-
+ BOOL isDiscontiguous
+ = (direction == ns_ax_text_selection_direction_discontiguous);
+ if (!isDiscontiguous && !isCharMove)
+ {
+ moveInfo[@"AXTextSelectionDirection"] = @(direction);
+ moveInfo[@"AXTextSelectionGranularity"] = @(granularity);
+ }
+
+ /* Post on self (the EmacsAXBuffer element), not on the parent
+ EmacsView. When the notification originates from the element
+ whose selection changed, VoiceOver calls accessibilityLineForIndex:
+ on that element to determine the line to read. Posting from the
+ parent view with UIElementsKey causes VoiceOver to call
+ accessibilityLineForIndex: on the view instead, which returns an
+ incorrect range in specialised buffers (org-agenda, org-super-agenda)
+ where line geometry differs from plain text. */
ns_ax_post_notification_with_info (
self,
NSAccessibilitySelectedTextChangedNotification,
@@ -9166,12 +9305,17 @@ user expectation ("w" jumps to next word and reads it). */
}
}
- /* For focused line moves: always announce line text explicitly.
- SelectedTextChanged with granularity=line works for arrow keys,
- but C-n/C-p need the explicit announcement (VoiceOver processes
- these keystrokes differently from arrows).
+ /* Announce the destination line text for all line-granularity moves.
+ This covers two cases:
+ - C-n/C-p: SelectedTextChanged carries granularity=line, but
+ VoiceOver processes those keystrokes specially and may not
+ produce speech; the explicit announcement is the reliable path.
+ - Discontiguous jumps (]], M-<, xref, imenu, …): granularity=line
+ in the notification is omitted (see above) so VoiceOver will
+ not announce automatically; this explicit announcement fills
+ the gap.
In completion-list-mode, read the completion candidate instead
- of the whole line. */
+ of the full line. */
if (cachedText
&& granularity == ns_ax_text_selection_granularity_line)
{
@@ -9236,6 +9380,11 @@ - (void)postCompletionAnnouncementForBuffer:(struct buffer *)b
block_input ();
specpdl_ref count2 = SPECPDL_INDEX ();
+ /* Register unblock_input as an unwind action so that if any Lisp
+ call below signals (triggering a longjmp through unbind_to),
+ block_input is always paired with an unblock_input. The
+ unbind_to call at the end of the function unwinds this.
+ record_unwind_protect_void plus unbind_to is idempotent. */
record_unwind_protect_void (unblock_input);
record_unwind_current_buffer ();
if (b != current_buffer)
@@ -9412,12 +9561,29 @@ - (void)postAccessibilityNotificationsForFrame:(struct frame *)f
if (!b)
return;
+ /* Echo area announcements are handled in
+ postEchoAreaAnnouncementIfNeeded (called from postAccessibilityUpdates
+ before this per-element loop) so that they are never lost to a
+ concurrent tree rebuild. For the inactive minibuffer (minibuf_level
+ == 0), skip normal cursor and completion processing — there is no
+ meaningful cursor to track. */
+ if (MINI_WINDOW_P (w) && minibuf_level == 0)
+ return;
+
ptrdiff_t modiff = BUF_MODIFF (b);
ptrdiff_t point = BUF_PT (b);
BOOL markActive = !NILP (BVAR (b, mark_active));
/* --- Text changed (edit) --- */
ptrdiff_t chars_modiff = BUF_CHARS_MODIFF (b);
+ /* Track whether the user typed a character this redisplay cycle.
+ Used below to suppress overlay completion announcements: when the
+ user types, character echo (via postTextChangedNotification) must
+ take priority over overlay candidate updates. Without this guard,
+ Vertico/Ivy updates its overlay immediately after each keystroke,
+ and the High-priority overlay announcement interrupts the character
+ echo, effectively silencing typed characters. */
+ BOOL didTextChange = NO;
if (modiff != self.cachedModiff)
{
self.cachedModiff = modiff;
@@ -9431,6 +9597,7 @@ Text property changes (e.g. face updates from
{
self.cachedCharsModiff = chars_modiff;
[self postTextChangedNotification:point];
+ didTextChange = YES;
}
}
@@ -9453,8 +9620,15 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property
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))
+ Restrict the scan to minibuffer windows.
+ Skip overlay announcements when the user just typed a character
+ (didTextChange). Completion frameworks update their overlay
+ immediately after each keystroke; without this guard, the
+ overlay High-priority announcement would interrupt the character
+ echo produced by postTextChangedNotification, making typed
+ characters inaudible. VoiceOver should read the overlay
+ candidate only when the user navigates (C-n/C-p), not types. */
+ if (!MINI_WINDOW_P (w) || didTextChange)
goto skip_overlay_scan;
int selected_line = -1;
@@ -9500,7 +9674,18 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property
self.cachedPoint = point;
self.cachedMarkActive = markActive;
- /* Compute direction. */
+ /* Compute direction.
+ When VoiceOver moved the cursor via setAccessibilitySelectedTextRange:
+ (voiceoverSetPoint == YES), use sequential next/previous so VoiceOver
+ continues smooth navigation from its current position.
+ When Emacs moved the cursor independently (voiceoverSetPoint == NO),
+ force discontiguous direction so VoiceOver re-anchors its browse
+ cursor to accessibilitySelectedTextRange; without this, VoiceOver's
+ internal browse position diverges from the Emacs insertion point and
+ subsequent VO+arrow navigation starts from the wrong location. */
+ BOOL emacsMovedCursor = !voiceoverSetPoint;
+ voiceoverSetPoint = NO; /* Consume the flag. */
+
NSInteger direction = ns_ax_text_selection_direction_discontiguous;
if (point > oldPoint)
direction = ns_ax_text_selection_direction_next;
@@ -9512,6 +9697,7 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property
/* --- Granularity detection --- */
NSInteger granularity = ns_ax_text_selection_granularity_unknown;
+ BOOL singleLineMove = NO;
[self ensureTextCache];
if (cachedText && oldPoint > 0)
{
@@ -9526,7 +9712,18 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property
NSRange newLine = [cachedText lineRangeForRange:
NSMakeRange (newIdx, 0)];
if (oldLine.location != newLine.location)
- granularity = ns_ax_text_selection_granularity_line;
+ {
+ granularity = ns_ax_text_selection_granularity_line;
+ /* Detect single adjacent-line move while oldLine/newLine
+ are in scope. Any command that steps exactly one line ---
+ C-n/C-p, evil j/k, outline-next-heading, etc. --- is
+ sequential. Multi-line teleports (]], M-<, xref, ...) are
+ not adjacent and will be marked discontiguous below.
+ Detected structurally: no package-specific code needed. */
+ BOOL adjFwd = (newLine.location == NSMaxRange (oldLine));
+ BOOL adjBwd = (NSMaxRange (newLine) == oldLine.location);
+ singleLineMove = adjFwd || adjBwd;
+ }
else
{
NSUInteger dist = (newIdx > oldIdx
@@ -9548,34 +9745,23 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property
granularity = ns_ax_text_selection_granularity_line;
}
- /* Programmatic jumps that cross a line boundary (]], [[, M-<,
- xref, imenu, …) are discontiguous: the cursor teleported to an
- arbitrary position, not one sequential step forward/backward.
- Reporting AXTextSelectionDirectionDiscontiguous causes VoiceOver
- to re-anchor its rotor browse cursor at the new
- accessibilitySelectedTextRange rather than advancing linearly
- from its previous internal position. */
- if (!isCtrlNP && granularity == ns_ax_text_selection_granularity_line)
+
+ /* Multi-line teleports are discontiguous; single adjacent-line
+ steps stay sequential. */
+ if (!isCtrlNP && !singleLineMove
+ && granularity == ns_ax_text_selection_granularity_line)
direction = ns_ax_text_selection_direction_discontiguous;
- /* If Emacs moved the cursor (not VoiceOver), force discontiguous
- so VoiceOver re-anchors its browse cursor to the current
- accessibilitySelectedTextRange. This covers all Emacs-initiated
- moves: editing commands, ELisp, isearch, etc.
- Exception: C-n/C-p (isCtrlNP) already uses next/previous with
- line granularity; those are already sequential and VoiceOver
- handles them correctly. */
- if (emacsMovedCursor && !isCtrlNP)
+ /* Emacs-initiated teleports need re-anchor; sequential steps
+ (C-n/C-p or any adjacent-line command) do not. */
+ if (emacsMovedCursor && !isCtrlNP && !singleLineMove)
direction = ns_ax_text_selection_direction_discontiguous;
- /* Re-anchor VoiceOver's browse cursor for discontiguous (teleport)
- moves only. For sequential C-n/C-p (isCtrlNP), posting
- FocusedUIElementChanged on the window races with the
- AXSelectedTextChanged(granularity=line) notification and
- causes VoiceOver to drop the line-read speech. Sequential
- moves are already handled correctly by AXSelectedTextChanged
- with direction=next/previous + granularity=line. */
- if (emacsMovedCursor && !isCtrlNP && [self isAccessibilityFocused])
+ /* FocusedUIElementChanged only for teleports: posting it for
+ sequential moves races with AXSelectedTextChanged(granularity=line)
+ and causes VoiceOver to drop the line-read speech. */
+ if (emacsMovedCursor && !isCtrlNP && !singleLineMove
+ && [self isAccessibilityFocused])
{
NSWindow *win = [self.emacsView window];
if (win)
@@ -9734,6 +9920,13 @@ - (NSRect)accessibilityFrame
if (vis_start >= vis_end)
return @[];
+ /* block_input for the duration of the scan: the Lisp calls below
+ (Ftext_properties_at, Fplist_get, Foverlays_in, Foverlay_get,
+ Fnext_single_property_change, Fbuffer_substring_no_properties)
+ must not be interleaved with timer events or process sentinels
+ that could modify buffer state (e.g. invalidate vis_end).
+ record_unwind_protect_void guarantees unblock_input even if
+ a Lisp call signals. */
block_input ();
specpdl_ref blk_count = SPECPDL_INDEX ();
record_unwind_protect_void (unblock_input);
@@ -10040,6 +10233,10 @@ - (void)dealloc
#endif
[accessibilityElements release];
+#ifdef NS_IMPL_COCOA
+ if (childFrameLastCandidate)
+ xfree (childFrameLastCandidate);
+#endif
[[self menu] release];
[super dealloc];
}
@@ -11489,6 +11686,9 @@ - (instancetype) initFrameFromEmacs: (struct frame *)f
windowClosing = NO;
processingCompose = NO;
+#ifdef NS_IMPL_COCOA
+ childFrameLastBuffer = Qnil;
+#endif
scrollbarsNeedingUpdate = 0;
fs_state = FULLSCREEN_NONE;
fs_before_fs = next_maximized = -1;
@@ -12797,6 +12997,159 @@ - (id)accessibilityFocusedUIElement
The existing elements carry cached state (modiff, point) from the
previous redisplay cycle. Rebuilding first would create fresh
elements with current values, making change detection impossible. */
+
+/* Announce new echo area messages to VoiceOver.
+
+ This is called at the top of postAccessibilityUpdates, before any
+ tree rebuild. Keeping it here, rather than in the per-element loop
+ in postAccessibilityNotificationsForFrame, guarantees that echo area
+ messages (including "Quit" from C-g) are announced even when the
+ accessibility element tree is in the process of being rebuilt.
+
+ The guard minibuf_level == 0 ensures we only announce passive status
+ messages. While the user is actively typing (minibuf_level > 0),
+ character echo and completion announcements take precedence.
+
+ Reads echo_area_buffer[0] directly because with_echo_area_buffer()
+ sets current_buffer via set_buffer_internal_1() but does NOT call
+ Fset_window_buffer(), so the minibuffer window's contents pointer
+ still points to the inactive " *Minibuf-0*" buffer. */
+- (void)postEchoAreaAnnouncementIfNeeded
+{
+ if (minibuf_level != 0)
+ return;
+ Lisp_Object ea = echo_area_buffer[0];
+ if (!BUFFERP (ea))
+ return;
+ struct buffer *eb = XBUFFER (ea);
+ if (!BUFFER_LIVE_P (eb))
+ return;
+ ptrdiff_t echo_chars = BUF_CHARS_MODIFF (eb);
+ if (echo_chars == lastEchoCharsModiff || BUF_ZV (eb) <= BUF_BEGV (eb))
+ return;
+ lastEchoCharsModiff = echo_chars;
+ /* Use specpdl to restore current_buffer if Fbuffer_string signals.
+ set_buffer_internal_1 is preferred over set_buffer_internal in
+ a redisplay context: it skips point-motion hooks that could
+ trigger further redisplay or modify buffer state unexpectedly. */
+ block_input ();
+ specpdl_ref count = SPECPDL_INDEX ();
+ record_unwind_protect_void (unblock_input);
+ record_unwind_current_buffer ();
+ set_buffer_internal_1 (eb);
+ Lisp_Object ls = Fbuffer_string ();
+ unbind_to (count, Qnil);
+ /* stringWithLispString: converts Emacs's internal multibyte encoding
+ to NSString correctly; a raw SSDATA cast would produce invalid
+ UTF-8 for non-ASCII characters. */
+ NSString *raw = [NSString stringWithLispString: ls];
+ NSString *msg = [raw stringByTrimmingCharactersInSet:
+ [NSCharacterSet whitespaceAndNewlineCharacterSet]];
+ if ([msg length] == 0)
+ return;
+ NSDictionary *info = @{
+ NSAccessibilityAnnouncementKey: msg,
+ NSAccessibilityPriorityKey: @(NSAccessibilityPriorityHigh)
+ };
+ ns_ax_post_notification_with_info (
+ NSApp, NSAccessibilityAnnouncementRequestedNotification, info);
+}
+
+/* Announce the selected candidate in a child frame completion popup.
+ Handles Corfu, Company-box, and similar frameworks that render
+ candidates in a separate child frame rather than as overlay strings
+ in the minibuffer. */
+- (void)announceChildFrameCompletion
+{
+
+ /* Validate frame state --- child frames may be partially
+ initialized during creation. */
+ if (!WINDOWP (emacsframe->selected_window))
+ return;
+ struct window *w = XWINDOW (emacsframe->selected_window);
+ if (!BUFFERP (w->contents))
+ return;
+ struct buffer *b = XBUFFER (w->contents);
+
+ /* Only scan when the buffer content has actually changed.
+ This prevents redundant work on every redisplay tick and
+ also guards against re-entrance: if Lisp calls below
+ trigger redisplay, the modiff check short-circuits. */
+ if (!BUFFER_LIVE_P (b))
+ return;
+ EMACS_INT modiff = BUF_MODIFF (b);
+ /* Compare buffer identity via the buffer name symbol, which is always
+ GC-reachable through the obarray. Storing the name avoids keeping
+ a direct buffer pointer in a non-GC-visible ObjC ivar: if the buffer
+ were killed and GC swept, a stale make_lisp_ptr value could collide
+ with a newly-allocated buffer at the same address. */
+ if (EQ (childFrameLastBuffer, BVAR (b, name))
+ && modiff == childFrameLastModiff)
+ return;
+ /* Store the buffer name symbol (an interned Lisp_Object from
+ obarray) rather than a raw pointer to struct buffer.
+ Interned symbols are reachable from obarray and will not be
+ garbage-collected, so no staticpro() registration is needed
+ for this ivar. */
+ childFrameLastBuffer = BVAR (b, name);
+ childFrameLastModiff = modiff;
+
+ if (!BUFFER_LIVE_P (b))
+ return;
+
+ /* Skip buffers larger than a typical completion popup.
+ This avoids scanning eldoc, which-key, or other child
+ frame buffers that are not completion UIs. */
+ if (BUF_ZV (b) - BUF_BEGV (b) > 10000)
+ return;
+
+ int selected_line = -1;
+ /* block_input prevents timer events and process output from
+ interleaving with the Lisp calls inside
+ ns_ax_selected_child_frame_text (Fbuffer_substring_no_properties,
+ Fget_char_property, etc.). record_unwind_protect_void ensures
+ unblock_input is called even if a Lisp call signals. */
+ block_input ();
+ specpdl_ref blk_count = SPECPDL_INDEX ();
+ record_unwind_protect_void (unblock_input);
+ NSString *candidate
+ = ns_ax_selected_child_frame_text (b, w->contents, &selected_line);
+ unbind_to (blk_count, Qnil);
+
+ if (!candidate)
+ return;
+
+ /* Deduplicate --- avoid re-announcing the same candidate. */
+ const char *cstr = [candidate UTF8String];
+ if (childFrameLastCandidate && strcmp (cstr, childFrameLastCandidate) == 0)
+ return;
+ xfree (childFrameLastCandidate);
+ childFrameLastCandidate = xstrdup (cstr);
+
+ NSDictionary *annInfo = @{
+ NSAccessibilityAnnouncementKey: candidate,
+ NSAccessibilityPriorityKey:
+ @(NSAccessibilityPriorityHigh)
+ };
+ ns_ax_post_notification_with_info (
+ NSApp,
+ NSAccessibilityAnnouncementRequestedNotification,
+ annInfo);
+
+ /* Mark the parent as having an active child frame completion.
+ When the child frame closes, the parent's next accessibility
+ cycle will post FocusedUIElementChanged to restore VoiceOver's
+ focus to the buffer text element. */
+ struct frame *parent = FRAME_PARENT_FRAME (emacsframe);
+ if (parent)
+ {
+ EmacsView *parentView = FRAME_NS_VIEW (parent);
+ if (parentView)
+ parentView->childFrameCompletionActive = YES;
+ }
+
+}
+
- (void)postAccessibilityUpdates
{
NSTRACE ("[EmacsView postAccessibilityUpdates]");
@@ -12807,11 +13160,69 @@ - (void)postAccessibilityUpdates
/* Re-entrance guard: VoiceOver callbacks during notification posting
can trigger redisplay, which calls ns_update_end, which calls us
- again. Prevent infinite recursion. */
+ again. Prevent infinite recursion. This MUST come before the
+ child frame check --- announceChildFrameCompletion makes Lisp
+ calls that can trigger redisplay. */
if (accessibilityUpdating)
return;
accessibilityUpdating = YES;
+ /* Announce echo area messages (e.g. "Quit", "Wrote file") before
+ any tree-rebuild check. This must run even when the element tree
+ is being rebuilt to avoid missing time-sensitive status messages. */
+ [self postEchoAreaAnnouncementIfNeeded];
+
+ /* Child frame completion popup (Corfu, Company-box, etc.).
+ Child frames don't participate in the accessibility tree;
+ announce the selected candidate directly. */
+ if (FRAME_PARENT_FRAME (emacsframe))
+ {
+ [self announceChildFrameCompletion];
+ accessibilityUpdating = NO;
+ return;
+ }
+
+ /* If a child frame completion was recently active but no child
+ frame is visible anymore, refocus VoiceOver on the buffer
+ element so character echo and cursor tracking resume.
+ Skip if a child frame still exists (completion still open). */
+ if (childFrameCompletionActive)
+ {
+ Lisp_Object tail, frame;
+ BOOL childStillVisible = NO;
+ /* block_input protects the FOR_EACH_FRAME iteration: the
+ frame list (Vframe_list) is a Lisp_Object chain and must not
+ be modified by a timer or process sentinel mid-iteration. */
+ block_input ();
+ FOR_EACH_FRAME (tail, frame)
+ if (FRAME_PARENT_FRAME (XFRAME (frame)) == emacsframe
+ && FRAME_VISIBLE_P (XFRAME (frame)))
+ {
+ childStillVisible = YES;
+ break;
+ }
+ unblock_input ();
+
+ if (!childStillVisible)
+ {
+ childFrameCompletionActive = NO;
+ EmacsAccessibilityBuffer *focused = nil;
+ for (id elem in accessibilityElements)
+ if ([elem isKindOfClass:
+ [EmacsAccessibilityBuffer class]]
+ && [(EmacsAccessibilityBuffer *)elem
+ isAccessibilityFocused])
+ {
+ focused = elem;
+ break;
+ }
+ if (focused)
+ ns_ax_post_notification (
+ focused,
+ NSAccessibilityFocusedUIElementChangedNotification);
+ }
+ }
+
/* Detect window tree change (split, delete, new buffer). Compare
FRAME_ROOT_WINDOW --- if it changed, the tree structure changed. */
Lisp_Object curRoot = FRAME_ROOT_WINDOW (emacsframe);
--
2.43.0