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.
845 lines
35 KiB
Diff
845 lines
35 KiB
Diff
From 0cd27cd398ebcbaadd526b404cf7d549bfe53a4a 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 | 511 +++++++++++++++++++++++++++++++++++++------
|
|
4 files changed, 489 insertions(+), 74 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 8f744d1bf3..dc5b965468 100644
|
|
--- a/src/nsterm.m
|
|
+++ b/src/nsterm.m
|
|
@@ -1126,24 +1126,19 @@ Uses CFAbsoluteTimeGetCurrent() (~5 ns, a VDSO read) for timing. */
|
|
ivy-current-match, etc. by checking the face symbol name.
|
|
Defined here so the Zoom patch compiles independently of the
|
|
VoiceOver patches. */
|
|
+/* Forward declaration --- ns_ax_face_is_selected is defined in the
|
|
+ VoiceOver section below; ns_zoom_face_is_selected delegates to it. */
|
|
+static bool ns_ax_face_is_selected (Lisp_Object face);
|
|
+
|
|
static bool
|
|
ns_zoom_face_is_selected (Lisp_Object face)
|
|
{
|
|
- if (SYMBOLP (face))
|
|
- {
|
|
- const char *name = SSDATA (SYMBOL_NAME (face));
|
|
- return (strstr (name, "current") != NULL
|
|
- || strstr (name, "selected") != NULL
|
|
- || strstr (name, "selection") != NULL);
|
|
- }
|
|
- if (CONSP (face))
|
|
- {
|
|
- Lisp_Object tail;
|
|
- for (tail = face; CONSP (tail); tail = XCDR (tail))
|
|
- if (ns_zoom_face_is_selected (XCAR (tail)))
|
|
- return true;
|
|
- }
|
|
- return false;
|
|
+ /* Forward to ns_ax_face_is_selected (defined in the VoiceOver section
|
|
+ below) so that Zoom and VoiceOver agree on what constitutes a
|
|
+ "selected" face. Identical logic in two places would diverge over
|
|
+ time; one canonical implementation is preferable.
|
|
+ The forward declaration appears in nsterm.h. */
|
|
+ return ns_ax_face_is_selected (face);
|
|
}
|
|
|
|
/* Scan overlay before-string / after-string properties in the
|
|
@@ -1275,7 +1270,7 @@ 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 ())
|
|
+ if (!ns_zoom_enabled_p ())
|
|
return;
|
|
if (!WINDOWP (f->selected_window))
|
|
return;
|
|
@@ -1393,7 +1388,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 +3566,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 +7402,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
|
|
@@ -8605,6 +8706,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 +9159,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 +9290,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 +9365,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 +9546,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 +9582,7 @@ Text property changes (e.g. face updates from
|
|
{
|
|
self.cachedCharsModiff = chars_modiff;
|
|
[self postTextChangedNotification:point];
|
|
+ didTextChange = YES;
|
|
}
|
|
}
|
|
|
|
@@ -9453,8 +9605,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 +9659,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 +9682,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 +9697,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 +9730,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 +9905,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);
|
|
@@ -9858,6 +10036,7 @@ than O(chars). Fall back to pos+1 as safety net. */
|
|
pos = span_end;
|
|
}
|
|
|
|
+ unbind_to (blk_count, Qnil);
|
|
return [[spans copy] autorelease];
|
|
}
|
|
|
|
@@ -10039,6 +10218,10 @@ - (void)dealloc
|
|
#endif
|
|
|
|
[accessibilityElements release];
|
|
+#ifdef NS_IMPL_COCOA
|
|
+ if (childFrameLastCandidate)
|
|
+ xfree (childFrameLastCandidate);
|
|
+#endif
|
|
[[self menu] release];
|
|
[super dealloc];
|
|
}
|
|
@@ -11488,6 +11671,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;
|
|
@@ -12796,6 +12982,154 @@ - (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;
|
|
+ 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]");
|
|
@@ -12806,11 +13140,64 @@ - (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;
|
|
+ FOR_EACH_FRAME (tail, frame)
|
|
+ if (FRAME_PARENT_FRAME (XFRAME (frame)) == emacsframe
|
|
+ && FRAME_VISIBLE_P (XFRAME (frame)))
|
|
+ {
|
|
+ childStillVisible = YES;
|
|
+ break;
|
|
+ }
|
|
+
|
|
+ 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
|
|
|