diff --git a/patches/0007-ns-announce-overlay-completion-candidates-for-VoiceO.patch b/patches/0007-ns-announce-overlay-completion-candidates-for-VoiceO.patch index d698d82..d42d504 100644 --- a/patches/0007-ns-announce-overlay-completion-candidates-for-VoiceO.patch +++ b/patches/0007-ns-announce-overlay-completion-candidates-for-VoiceO.patch @@ -1,7 +1,7 @@ From 8157451dedda9b43de47f82d1deb85c9d2853a35 Mon Sep 17 00:00:00 2001 From: Martin Sukany Date: Sat, 28 Feb 2026 14:46:25 +0100 -Subject: [PATCH 1/2] ns: announce overlay completion candidates for VoiceOver +Subject: [PATCH] ns: announce overlay completion candidates for VoiceOver Completion frameworks such as Vertico, Ivy, and Icomplete render candidates via overlay before-string/after-string properties rather diff --git a/patches/0008-ns-announce-child-frame-completion-candidates-for-Vo.patch b/patches/0008-ns-announce-child-frame-completion-candidates-for-Vo.patch deleted file mode 100644 index a355007..0000000 --- a/patches/0008-ns-announce-child-frame-completion-candidates-for-Vo.patch +++ /dev/null @@ -1,345 +0,0 @@ -From 95ae88dbcddc8d1af54777bcf95bb9c545383445 Mon Sep 17 00:00:00 2001 -From: Martin Sukany -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: -- record_unwind_current_buffer / set_buffer_internal_1 to switch to - the child frame buffer for Fbuffer_substring_no_properties. -- Re-entrance guard (accessibilityUpdating) before child frame dispatch. -- BUF_MODIFF gating prevents redundant scans. -- WINDOWP, BUFFERP validation for partially initialized frames. -- Buffer size limit (10000 chars) skips non-completion child frames. - -Accessibility: -- Set the child frame window's accessibilityRole to Group. Groups - don't trigger VoiceOver's window-appeared announcement but still - participate in focus tracking (unlike setAccessibilityElement:NO - which breaks focus return). -- Post FocusedUIElementChangedNotification on the parent frame when - no candidate is found, to restore VoiceOver focus after close. -- Reset overlayZoomActive at the start of each parent frame - accessibility cycle. The overlay or child frame handler re-sets - it if a candidate is active; otherwise Zoom returns to the text - cursor (handles C-g and frame destruction gracefully). - -Zoom tracking stores the candidate rect in the PARENT frame's -overlayZoomRect (via child view -> screen -> parent view coordinate -conversion), so that the parent's draw_window_cursor focuses Zoom -on the candidate instead of the text cursor. - -* 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, reset overlayZoomActive each cycle. ---- - src/nsterm.h | 1 + - src/nsterm.m | 248 ++++++++++++++++++++++++++++++++++++++++++++++++++- - 2 files changed, 248 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..2b7a386 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 -+ and restore VoiceOver focus to the parent frame. */ -+ if (parent) -+ { -+ EmacsView *parentView = FRAME_NS_VIEW (parent); -+ if (parentView) -+ { -+ parentView->overlayZoomActive = NO; -+ ns_ax_post_notification ( -+ parentView, -+ NSAccessibilityFocusedUIElementChangedNotification); -+ } -+ } -+ 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); -+ -+ 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,40 @@ 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. -+ -+ Suppress VoiceOver's "X window" announcement by setting -+ the window's accessibility role to Group. Groups don't -+ trigger window-appeared announcements but still participate -+ in focus tracking. Do this BEFORE the scan so VoiceOver -+ processes the role change before any window announcement. */ -+ if (FRAME_PARENT_FRAME (emacsframe)) -+ { -+ /* Set role to Group instead of Window. Groups don't -+ trigger VoiceOver's window-appeared announcement but -+ still participate in focus tracking. */ -+ [[self window] -+ setAccessibilityRole:NSAccessibilityGroupRole]; -+ [self announceChildFrameCompletion]; -+ accessibilityUpdating = NO; -+ return; -+ } -+ -+ /* Reset overlay Zoom each cycle. The overlay branch or child -+ frame handler will set it again if a candidate is active. -+ This ensures Zoom returns to the text cursor when completion -+ ends (including C-g with no buffer text change). */ -+ overlayZoomActive = NO; -+ - /* 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 -