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

328 lines
12 KiB
Diff

From a06955c951aaad2330add250f815c34d60128948 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 16:01:29 +0100
Subject: [PATCH 2/2] ns: announce child frame completion candidates for
VoiceOver
Completion frameworks such as Corfu, Company-box, and similar
render candidates in a child frame rather than as overlay strings
in the minibuffer. This patch extends the overlay announcement
support (patch 7/8) to handle child frame popups.
Detect child frames via FRAME_PARENT_FRAME in postAccessibilityUpdates.
Scan the child frame buffer text line by line using Fget_char_property
(which checks both text properties and overlay face properties) to
find the selected candidate. Reuse ns_ax_face_is_selected from
the overlay patch to identify "current", "selected", and
"selection" faces.
Safety measures:
- Use record_unwind_current_buffer / set_buffer_internal_1 to
temporarily switch to the child frame buffer before calling
Fbuffer_substring_no_properties (which operates on current_buffer).
All return paths call unbind_to to restore the original buffer.
- The re-entrance guard (accessibilityUpdating) MUST come before the
child frame dispatch, because Lisp calls in the scan function can
trigger redisplay.
- BUF_MODIFF gating prevents redundant scans on every redisplay tick
and provides a secondary re-entrance guard.
- Frame state validation (WINDOWP, BUFFERP) handles partially
initialized child frames during creation.
- Buffer size limit (10000 chars) skips non-completion child frames
such as eldoc documentation or which-key popups.
Suppress VoiceOver's automatic "X window" announcement by setting
the child frame's accessibility subrole to FloatingWindow (popovers
and floating windows don't trigger window-appeared announcements).
Zoom tracking stores the candidate rect in the PARENT frame's
overlayZoomRect (via screen coordinate conversion), so that the
parent's draw_window_cursor focuses Zoom on the candidate instead
of the text cursor. Cleared when no candidate is found.
* src/nsterm.h (EmacsView): Add announceChildFrameCompletion.
* src/nsterm.m (ns_ax_selected_child_frame_text): New function.
(EmacsView announceChildFrameCompletion): New method.
(EmacsView postAccessibilityUpdates): Dispatch to child frame
handler for FRAME_PARENT_FRAME frames, under re-entrance guard.
---
src/nsterm.h | 1 +
src/nsterm.m | 231 ++++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 231 insertions(+), 1 deletion(-)
diff --git a/src/nsterm.h b/src/nsterm.h
index 5c15639..21b2823 100644
--- a/src/nsterm.h
+++ b/src/nsterm.h
@@ -657,6 +657,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 d13c5c7..ea2a914 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -7066,6 +7066,110 @@ ns_ax_selected_overlay_text (struct buffer *b,
}
+/* 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
@@ -12299,6 +12403,119 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view,
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. Uses direct UAZoomChangeFocus (not the
+ overlayZoomRect flag) because the child frame's ns_update_end runs
+ after the parent's draw_window_cursor. */
+- (void)announceChildFrameCompletion
+{
+ static char *lastCandidate;
+ static struct buffer *lastBuffer;
+ static EMACS_INT lastModiff;
+
+ /* 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 (b == lastBuffer && modiff == lastModiff)
+ return;
+ lastBuffer = b;
+ lastModiff = 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);
+
+ struct frame *parent = FRAME_PARENT_FRAME (emacsframe);
+
+ if (!candidate)
+ {
+ /* No selected candidate --- clear parent Zoom override. */
+ if (parent)
+ {
+ EmacsView *parentView = FRAME_NS_VIEW (parent);
+ if (parentView)
+ parentView->overlayZoomActive = NO;
+ }
+ return;
+ }
+
+ /* Deduplicate --- avoid re-announcing the same candidate. */
+ const char *cstr = [candidate UTF8String];
+ if (lastCandidate && strcmp (cstr, lastCandidate) == 0)
+ return;
+ xfree (lastCandidate);
+ lastCandidate = xstrdup (cstr);
+
+ /* Suppress VoiceOver's automatic "X window" announcement for
+ the child frame. Completion popups are semantically popovers,
+ not windows. Setting the subrole once is idempotent. */
+ [[self window]
+ setAccessibilitySubrole:NSAccessibilityFloatingWindowSubrole];
+
+ NSDictionary *annInfo = @{
+ NSAccessibilityAnnouncementKey: candidate,
+ NSAccessibilityPriorityKey:
+ @(NSAccessibilityPriorityHigh)
+ };
+ ns_ax_post_notification_with_info (
+ NSApp,
+ NSAccessibilityAnnouncementRequestedNotification,
+ annInfo);
+
+ /* Zoom tracking: store the candidate rect in the PARENT frame's
+ overlayZoomRect so that draw_window_cursor focuses Zoom on the
+ candidate instead of the text cursor. Convert through screen
+ coordinates to handle arbitrary child frame positioning. */
+ if (selected_line >= 0 && parent)
+ {
+ EmacsView *parentView = FRAME_NS_VIEW (parent);
+ if (parentView)
+ {
+ int line_h = FRAME_LINE_HEIGHT (emacsframe);
+ int y_off = selected_line * line_h;
+ NSRect childRect = NSMakeRect (
+ WINDOW_TEXT_TO_FRAME_PIXEL_X (w, 0),
+ WINDOW_TO_FRAME_PIXEL_Y (w, y_off),
+ FRAME_COLUMN_WIDTH (emacsframe),
+ line_h);
+
+ /* Child view → screen → parent view. */
+ NSRect childWinR
+ = [self convertRect:childRect toView:nil];
+ NSRect screenR
+ = [[self window] convertRectToScreen:childWinR];
+ NSRect parentWinR
+ = [[parentView window]
+ convertRectFromScreen:screenR];
+ NSRect parentViewR
+ = [parentView convertRect:parentWinR fromView:nil];
+
+ parentView->overlayZoomRect = parentViewR;
+ parentView->overlayZoomActive = YES;
+ }
+ }
+}
+
- (void)postAccessibilityUpdates
{
NSTRACE ("[EmacsView postAccessibilityUpdates]");
@@ -12309,11 +12526,23 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view,
/* 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;
+ }
+
/* 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