Files
emacs-doom/patches/0008-ns-announce-child-frame-completion-candidates-for-Vo.patch

451 lines
16 KiB
Diff

From ff2517c8af798fd41f8ba7d33af7a5b4cb15d8a6 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 16:01:29 +0100
Subject: [PATCH 8/8] ns: announce child frame completion candidates for
VoiceOver
Completion frameworks such as Corfu and Company-box render candidates
in a child frame. This extends the overlay announcement support to
handle child frame popups.
* src/nsterm.m (ns_ax_selected_child_frame_text): New function; scan
a child frame buffer line by line using Fget_char_property to find the
selected candidate; uses record_unwind_current_buffer for safety.
(EmacsView announceChildFrameCompletion): New method.
(EmacsView postAccessibilityUpdates): Detect child frames via
FRAME_PARENT_FRAME; call announceChildFrameCompletion. Post
NSAccessibilityFocusedUIElementChangedNotification on the parent buffer
element when a child frame completion closes.
---
doc/emacs/macos.texi | 6 -
etc/NEWS | 4 +-
src/nsterm.h | 9 ++
src/nsterm.m | 276 +++++++++++++++++++++++++++++++++++++++++--
4 files changed, 277 insertions(+), 18 deletions(-)
diff --git a/doc/emacs/macos.texi b/doc/emacs/macos.texi
index 6514dfc..f47929e 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,11 +308,6 @@ 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.
-
@vindex ns-accessibility-enabled
To disable the accessibility interface entirely (for instance, to
eliminate overhead on systems where assistive technology is not in
diff --git a/etc/NEWS b/etc/NEWS
index 2b1f9e6..8a40850 100644
--- a/etc/NEWS
+++ b/etc/NEWS
@@ -4404,8 +4404,8 @@ 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
+includes 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
diff --git a/src/nsterm.h b/src/nsterm.h
index 21a93bc..bbce9fe 100644
--- a/src/nsterm.h
+++ b/src/nsterm.h
@@ -507,6 +507,10 @@ typedef struct ns_ax_visible_run
}
@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 cachedOverlayModiffForText;
@property (nonatomic, assign) ptrdiff_t cachedOverlayModiff;
@property (nonatomic, assign) ptrdiff_t cachedTextStart;
@property (nonatomic, assign) ptrdiff_t cachedModiff;
@@ -596,6 +600,10 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType)
Lisp_Object lastRootWindow;
BOOL accessibilityTreeValid;
BOOL accessibilityUpdating;
+ BOOL childFrameCompletionActive;
+ char *childFrameLastCandidate;
+ Lisp_Object childFrameLastBuffer;
+ EMACS_INT childFrameLastModiff;
#endif
BOOL font_panel_active;
NSFont *font_panel_result;
@@ -665,6 +673,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 8d44b5f..5dec999 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -7415,6 +7415,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
@@ -8046,6 +8152,7 @@ that remapped bindings (e.g., C-j -> next-line) are recognized.
@implementation EmacsAccessibilityBuffer
@synthesize cachedText;
@synthesize cachedTextModiff;
+@synthesize cachedOverlayModiffForText;
@synthesize cachedOverlayModiff;
@synthesize cachedTextStart;
@synthesize cachedModiff;
@@ -8159,16 +8266,34 @@ - (void)ensureTextCache
if (!b)
return;
- ptrdiff_t modiff = BUF_MODIFF (b);
- ptrdiff_t pt = BUF_PT (b);
- NSUInteger textLen = cachedText ? [cachedText length] : 0;
- /* Cache validity: track BUF_MODIFF and buffer narrowing.
+ /* 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). Including overlay_modiff would
- silently update cachedOverlayModiff and prevent the
- notification dispatch from detecting overlay changes. */
- if (cachedText && cachedTextModiff == modiff
+ 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);
+ ptrdiff_t overlay_modiff = BUF_OVERLAY_MODIFF (b);
+ ptrdiff_t pt = BUF_PT (b);
+ NSUInteger textLen = cachedText ? [cachedText length] : 0;
+ /* Cache is valid when neither characters nor fold-state have changed.
+ BUF_CHARS_MODIFF guards against character edits and is not bumped
+ by font-lock (text-property changes), preserving the O(1) hot path.
+ BUF_OVERLAY_MODIFF catches fold/unfold: outline-mode, org-mode, and
+ hideshow all use overlays (via outline-flag-region) in Emacs 28+.
+ Font-lock uses only text properties, so adding BUF_OVERLAY_MODIFF
+ here does not reintroduce the per-redisplay rebuild. */
+ if (cachedText && cachedTextModiff == chars_modiff
+ && cachedOverlayModiffForText == overlay_modiff
&& cachedTextStart == BUF_BEGV (b)
&& pt >= cachedTextStart
&& (textLen == 0
@@ -8184,7 +8309,8 @@ included in the cached AX text (it is handled separately via
{
[cachedText release];
cachedText = [text retain];
- cachedTextModiff = modiff;
+ cachedTextModiff = chars_modiff;
+ cachedOverlayModiffForText = overlay_modiff;
cachedTextStart = start;
if (visibleRuns)
@@ -9175,6 +9301,7 @@ - (void)postCompletionAnnouncementForBuffer:(struct buffer *)b
ptrdiff_t currentOverlayStart = 0;
ptrdiff_t currentOverlayEnd = 0;
+ block_input ();
specpdl_ref count2 = SPECPDL_INDEX ();
record_unwind_current_buffer ();
if (b != current_buffer)
@@ -9931,6 +10058,10 @@ - (void)dealloc
#endif
[accessibilityElements release];
+#ifdef NS_IMPL_COCOA
+ if (childFrameLastCandidate)
+ xfree (childFrameLastCandidate);
+#endif
[[self menu] release];
[super dealloc];
}
@@ -11380,6 +11511,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;
@@ -12688,6 +12822,80 @@ - (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 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. */
+ EMACS_INT modiff = BUF_MODIFF (b);
+ if (!BUFFER_LIVE_P (b))
+ return;
+ if (EQ (childFrameLastBuffer, make_lisp_ptr (b, Lisp_Vectorlike))
+ && modiff == childFrameLastModiff)
+ return;
+ childFrameLastBuffer = make_lisp_ptr (b, Lisp_Vectorlike);
+ childFrameLastModiff = modiff;
+
+ /* 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;
+ NSString *candidate
+ = ns_ax_selected_child_frame_text (b, w->contents, &selected_line);
+
+ 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]");
@@ -12698,11 +12906,59 @@ - (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;
+ /* 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