diff --git a/patches/0007-ns-announce-overlay-completion-candidates-for-VoiceO.patch b/patches/0007-ns-announce-overlay-completion-candidates-for-VoiceO.patch new file mode 100644 index 0000000..01cd6ba --- /dev/null +++ b/patches/0007-ns-announce-overlay-completion-candidates-for-VoiceO.patch @@ -0,0 +1,302 @@ +From 313b4e4489a617fdd074f577ba024dec88eda87e Mon Sep 17 00:00:00 2001 +From: Martin Sukany +Date: Sat, 28 Feb 2026 14:46:25 +0100 +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 +than buffer text. Without this, VoiceOver cannot read overlay-based +completion UIs. + +Add ns_ax_selected_overlay_text to extract the currently highlighted +candidate from overlay strings. The function determines the normal +(non-selected) face by comparing the first and last lines via Fequal, +then returns the line with a different face (the selected candidate). + +In the notification dispatch, detect overlay-only changes via +BUF_OVERLAY_MODIFF (which is bumped by overlay property changes but +not buffer text edits). Crucially, do NOT invalidate the text cache +on overlay changes --- the buffer text is unchanged and cache +invalidation causes VoiceOver to diff old vs new text, announcing +spurious newlines. Instead, post SelectedTextChanged to interrupt +current speech, then AnnouncementRequested to NSApp with the +candidate text. + +Add Zoom tracking for overlay candidates: find the glyph row +corresponding to the selected candidate and call UAZoomChangeFocus +so the Zoom lens follows the selection. + +* src/nsterm.m (ns_ax_selected_overlay_text): New function. +Returns the selected overlay candidate text and its line index. +(EmacsAccessibilityBuffer postAccessibilityNotificationsForFrame:): +Handle BUF_OVERLAY_MODIFF changes with candidate announcement and +Zoom focus tracking. +--- + src/nsterm.m | 240 ++++++++++++++++++++++++++++++++++++++++++++++++++- + 1 file changed, 239 insertions(+), 1 deletion(-) + +diff --git a/src/nsterm.m b/src/nsterm.m +index 1780194..4cf7b0c 100644 +--- a/src/nsterm.m ++++ b/src/nsterm.m +@@ -6915,11 +6915,146 @@ ns_create_font_panel_buttons (id target, SEL select, SEL cancel_action) + are truncated for accessibility purposes. */ + #define NS_AX_TEXT_CAP 100000 + ++/* Extract the currently selected candidate text from overlay display ++ strings. Completion frameworks (Vertico, Ivy, Icomplete) render ++ candidates as overlay before-string/after-string and highlight the ++ current candidate with a distinct face (e.g. vertico-current). ++ ++ Strategy: collect line boundaries in the overlay string, determine ++ the "normal" (non-selected) face by comparing the first and last ++ lines, then find the outlier line whose face differs. ++ ++ Also return the 0-based line index of the selected candidate in ++ *OUT_LINE_INDEX (or -1 if not found) for Zoom positioning. ++ ++ Returns nil if no distinctly-faced line is found. */ ++static NSString * ++ns_ax_selected_overlay_text (struct buffer *b, ++ ptrdiff_t beg, ptrdiff_t end, ++ int *out_line_index) ++{ ++ *out_line_index = -1; ++ ++ Lisp_Object ov_list = Foverlays_in (make_fixnum (beg), ++ make_fixnum (end)); ++ ++ for (Lisp_Object tail = ov_list; CONSP (tail); tail = XCDR (tail)) ++ { ++ Lisp_Object ov = XCAR (tail); ++ Lisp_Object strings[2]; ++ strings[0] = Foverlay_get (ov, intern_c_string ("before-string")); ++ strings[1] = Foverlay_get (ov, intern_c_string ("after-string")); ++ ++ for (int s = 0; s < 2; s++) ++ { ++ if (!STRINGP (strings[s])) ++ continue; ++ ++ Lisp_Object str = strings[s]; ++ ptrdiff_t slen = SCHARS (str); ++ if (slen == 0) ++ continue; ++ ++ /* Scan for newline positions using SDATA for efficiency ++ (avoids per-character Faref Lisp calls). */ ++ const unsigned char *data = SDATA (str); ++ ptrdiff_t byte_len = SBYTES (str); ++ ptrdiff_t line_starts[512]; ++ ptrdiff_t line_ends[512]; ++ int nlines = 0; ++ ptrdiff_t char_pos = 0, byte_pos = 0, lstart = 0; ++ ++ while (byte_pos < byte_len && nlines < 512) ++ { ++ 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++; ++ } ++ /* Last line (no trailing newline). */ ++ if (char_pos > lstart && nlines < 512) ++ { ++ line_starts[nlines] = lstart; ++ line_ends[nlines] = char_pos; ++ nlines++; ++ } ++ ++ if (nlines < 2) ++ continue; ++ ++ /* Determine the "normal" face using two references. ++ If first and last line share the same face, any line ++ that differs is the selected candidate. If they differ, ++ compare against the second line to resolve which end ++ is the outlier. */ ++ Lisp_Object face_first ++ = Fget_text_property (make_fixnum (line_starts[0]), ++ Qface, str); ++ Lisp_Object face_last ++ = Fget_text_property (make_fixnum (line_starts[nlines - 1]), ++ Qface, str); ++ ++ Lisp_Object normal_face; ++ if (!NILP (Fequal (face_first, face_last))) ++ { ++ normal_face = face_first; ++ } ++ else if (nlines >= 3) ++ { ++ Lisp_Object face_second ++ = Fget_text_property (make_fixnum (line_starts[1]), ++ Qface, str); ++ if (!NILP (Fequal (face_second, face_first))) ++ normal_face = face_first; ++ else ++ normal_face = face_last; ++ } ++ else ++ { ++ /* Only 2 lines, different faces --- use second as normal ++ (in most UIs, selected item is shown first). */ ++ normal_face = face_last; ++ } ++ ++ for (int li = 0; li < nlines; li++) ++ { ++ Lisp_Object lf ++ = Fget_text_property (make_fixnum (line_starts[li]), ++ Qface, str); ++ if (NILP (Fequal (lf, normal_face))) ++ { ++ *out_line_index = li; ++ Lisp_Object line ++ = Fsubstring_no_properties ( ++ str, ++ make_fixnum (line_starts[li]), ++ make_fixnum (line_ends[li])); ++ if (SCHARS (line) > 0) ++ return [NSString stringWithLispString:line]; ++ } ++ } ++ } ++ } ++ ++ 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 + with the count. Caller must free *OUT_RUNS with xfree(). */ +- + static NSString * + ns_ax_buffer_text (struct window *w, ptrdiff_t *out_start, + ns_ax_visible_run **out_runs, NSUInteger *out_nruns) +@@ -8795,6 +8930,109 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, + [self postTextChangedNotification:point]; + } + ++ /* --- Overlay content changed (e.g. Vertico/Ivy candidate switch) --- ++ Overlay-only changes (before-string, after-string, display) bump ++ BUF_OVERLAY_MODIFF but not BUF_MODIFF. Do NOT invalidate the ++ text cache here --- the buffer text itself has not changed, and ++ cache invalidation would cause VoiceOver to diff the old vs new ++ AX text and announce spurious "new line" from newlines. ++ Instead, announce the selected candidate explicitly. */ ++ else if (BUF_OVERLAY_MODIFF (b) != self.cachedOverlayModiff) ++ { ++ self.cachedOverlayModiff = BUF_OVERLAY_MODIFF (b); ++ ++ int selected_line = -1; ++ NSString *candidate ++ = ns_ax_selected_overlay_text (b, BUF_BEGV (b), BUF_ZV (b), ++ &selected_line); ++ if (candidate) ++ { ++ candidate = [candidate ++ stringByTrimmingCharactersInSet: ++ [NSCharacterSet whitespaceAndNewlineCharacterSet]]; ++ ++ /* Deduplicate: only announce when the candidate changed. */ ++ if ([candidate length] > 0 ++ && ![candidate isEqualToString: ++ self.cachedCompletionAnnouncement]) ++ { ++ self.cachedCompletionAnnouncement = candidate; ++ ++ /* Post SelectedTextChanged to interrupt VoiceOver's ++ current speech, then announce the candidate text ++ via NSApp (Apple requires app-level element). */ ++ NSDictionary *moveInfo = @{ ++ @"AXTextStateChangeType": ++ @(ns_ax_text_state_change_selection_move), ++ @"AXTextChangeElement": self ++ }; ++ ns_ax_post_notification_with_info ( ++ self, ++ NSAccessibilitySelectedTextChangedNotification, ++ moveInfo); ++ ++ NSDictionary *annInfo = @{ ++ NSAccessibilityAnnouncementKey: candidate, ++ NSAccessibilityPriorityKey: ++ @(NSAccessibilityPriorityHigh) ++ }; ++ ns_ax_post_notification_with_info ( ++ NSApp, ++ NSAccessibilityAnnouncementRequestedNotification, ++ annInfo); ++ ++ /* --- Zoom tracking for overlay candidates --- ++ The overlay candidates appear as visual lines in the ++ minibuffer window. Row 0 is the input line; overlay ++ candidates start from row 1 onward. Find the glyph ++ row for the selected candidate and focus Zoom there. */ ++#if defined (MAC_OS_X_VERSION_MIN_REQUIRED) \ ++ && MAC_OS_X_VERSION_MIN_REQUIRED >= 101000 ++ if (selected_line >= 0 && UAZoomEnabled ()) ++ { ++ struct window *w2 = [self validWindow]; ++ if (w2 && w2->current_matrix) ++ { ++ EmacsView *view = self.emacsView; ++ int target_vrow = selected_line + 1; ++ int nrows = w2->current_matrix->nrows; ++ if (target_vrow < nrows) ++ { ++ struct glyph_row *row ++ = w2->current_matrix->rows + target_vrow; ++ if (row->enabled_p ++ && row->visible_height > 0) ++ { ++ NSRect r = NSMakeRect ( ++ w2->pixel_left, ++ WINDOW_TOP_EDGE_Y (w2) + row->y, ++ w2->pixel_width, ++ row->visible_height); ++ NSRect winRect ++ = [view convertRect:r toView:nil]; ++ NSRect screenRect ++ = [[view window] ++ convertRectToScreen:winRect]; ++ CGRect cgRect ++ = NSRectToCGRect (screenRect); ++ CGFloat primaryH ++ = [[[NSScreen screens] firstObject] ++ frame].size.height; ++ cgRect.origin.y ++ = (primaryH - cgRect.origin.y ++ - cgRect.size.height); ++ UAZoomChangeFocus ( ++ &cgRect, &cgRect, ++ kUAZoomFocusTypeInsertionPoint); ++ } ++ } ++ } ++ } ++#endif ++ } ++ } ++ } ++ + /* --- Cursor moved or selection changed --- + Use 'else if' — edits and selection moves are mutually exclusive + per the WebKit/Chromium pattern. */ +-- +2.43.0 + diff --git a/patches/0007-ns-include-overlay-display-strings-in-accessibility-.patch b/patches/0007-ns-include-overlay-display-strings-in-accessibility-.patch deleted file mode 100644 index 5d20d0a..0000000 --- a/patches/0007-ns-include-overlay-display-strings-in-accessibility-.patch +++ /dev/null @@ -1,267 +0,0 @@ -From e12982f4ac111f9814a198763124a64540f4640b Mon Sep 17 00:00:00 2001 -From: Martin Sukany -Date: Sat, 28 Feb 2026 14:46:25 +0100 -Subject: [PATCH] ns: include overlay display strings in accessibility text - -Completion frameworks such as Vertico, Ivy, and Icomplete render -candidates via overlay before-string/after-string properties rather -than buffer text. Without this patch, VoiceOver cannot read any -overlay-based completion UI. - -This patch adds three pieces: - -1. ns_ax_selected_overlay_text: extract the currently highlighted - candidate from overlay strings. Compare each line's face against - the first line's face via Fequal; the line with a different face - (e.g. vertico-current) is the selected candidate. - -2. ns_ax_buffer_text: append overlay before-string and after-string - content after the buffer text, with virtual visible-run entries - anchored at the overlay's buffer position. - -3. Notification dispatch: detect overlay-only changes via - BUF_OVERLAY_MODIFF. Post SelectedTextChanged to interrupt - VoiceOver, then AnnouncementRequested (to NSApp) with the - candidate text. - -* src/nsterm.m (ns_ax_selected_overlay_text): New function. -(ns_ax_buffer_text): Append overlay display strings. -(EmacsAccessibilityBuffer postAccessibilityNotificationsForFrame:): -Handle BUF_OVERLAY_MODIFF changes with candidate announcement. ---- - src/nsterm.m | 201 ++++++++++++++++++++++++++++++++++++++++++++++++++- - 1 file changed, 200 insertions(+), 1 deletion(-) - -diff --git a/src/nsterm.m b/src/nsterm.m -index 1780194..35edd39 100644 ---- a/src/nsterm.m -+++ b/src/nsterm.m -@@ -6915,11 +6915,99 @@ ns_create_font_panel_buttons (id target, SEL select, SEL cancel_action) - are truncated for accessibility purposes. */ - #define NS_AX_TEXT_CAP 100000 - -+/* Extract the currently selected candidate text from overlay display -+ strings. Completion frameworks (Vertico, Ivy, Icomplete) highlight -+ the current candidate with a distinct face. We find the line whose -+ face DIFFERS from the first line's face (the "normal" candidate -+ face) — that is the selected candidate. -+ -+ Returns nil if no distinctly-faced line is found. */ -+static NSString * -+ns_ax_selected_overlay_text (struct buffer *b, -+ ptrdiff_t beg, ptrdiff_t end) -+{ -+ Lisp_Object ov_list = Foverlays_in (make_fixnum (beg), -+ make_fixnum (end)); -+ -+ for (Lisp_Object tail = ov_list; CONSP (tail); tail = XCDR (tail)) -+ { -+ Lisp_Object ov = XCAR (tail); -+ Lisp_Object strings[2]; -+ strings[0] = Foverlay_get (ov, intern_c_string ("before-string")); -+ strings[1] = Foverlay_get (ov, intern_c_string ("after-string")); -+ -+ for (int s = 0; s < 2; s++) -+ { -+ if (!STRINGP (strings[s])) -+ continue; -+ -+ Lisp_Object str = strings[s]; -+ ptrdiff_t slen = SCHARS (str); -+ if (slen == 0) -+ continue; -+ -+ /* Collect line boundaries. */ -+ ptrdiff_t line_starts[512]; -+ ptrdiff_t line_ends[512]; -+ int nlines = 0; -+ ptrdiff_t lstart = 0; -+ -+ for (ptrdiff_t i = 0; i <= slen && nlines < 512; i++) -+ { -+ bool is_nl = false; -+ if (i < slen) -+ { -+ Lisp_Object ch = Faref (str, make_fixnum (i)); -+ is_nl = (FIXNUMP (ch) && XFIXNUM (ch) == '\n'); -+ } -+ if (is_nl || i == slen) -+ { -+ if (i > lstart) -+ { -+ line_starts[nlines] = lstart; -+ line_ends[nlines] = i; -+ nlines++; -+ } -+ lstart = i + 1; -+ } -+ } -+ -+ if (nlines < 2) -+ continue; -+ -+ /* Get the face of the first line (the "normal" face). */ -+ Lisp_Object normal_face -+ = Fget_text_property (make_fixnum (line_starts[0]), -+ Qface, str); -+ -+ /* Find the first line with a DIFFERENT face. */ -+ for (int li = 0; li < nlines; li++) -+ { -+ Lisp_Object line_face -+ = Fget_text_property (make_fixnum (line_starts[li]), -+ Qface, str); -+ if (NILP (Fequal (line_face, normal_face))) -+ { -+ Lisp_Object line -+ = Fsubstring_no_properties ( -+ str, -+ make_fixnum (line_starts[li]), -+ make_fixnum (line_ends[li])); -+ if (SCHARS (line) > 0) -+ return [NSString stringWithLispString:line]; -+ } -+ } -+ } -+ } -+ -+ 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 - with the count. Caller must free *OUT_RUNS with xfree(). */ -- - static NSString * - ns_ax_buffer_text (struct window *w, ptrdiff_t *out_start, - ns_ax_visible_run **out_runs, NSUInteger *out_nruns) -@@ -7021,6 +7109,68 @@ ns_ax_buffer_text (struct window *w, ptrdiff_t *out_start, - pos = run_end; - } - -+ /* Append overlay display strings (before-string, after-string). -+ Many completion frameworks (Vertico, Ivy, Icomplete) render -+ candidates via overlay properties rather than buffer text. -+ Without this, VoiceOver cannot read overlay-based content. -+ -+ We append overlay strings after the buffer text and give each -+ a virtual run mapped to the overlay's buffer position, so cursor -+ tracking and index mapping remain functional. */ -+ { -+ Lisp_Object ov_list = Foverlays_in (make_fixnum (begv), -+ make_fixnum (zv)); -+ for (Lisp_Object tail = ov_list; CONSP (tail); tail = XCDR (tail)) -+ { -+ Lisp_Object ov = XCAR (tail); -+ Lisp_Object props[2]; -+ ptrdiff_t anchors[2]; -+ -+ props[0] = Foverlay_get (ov, intern_c_string ("before-string")); -+ anchors[0] = XFIXNUM (Foverlay_start (ov)); -+ props[1] = Foverlay_get (ov, intern_c_string ("after-string")); -+ anchors[1] = XFIXNUM (Foverlay_end (ov)); -+ -+ for (int k = 0; k < 2; k++) -+ { -+ if (!STRINGP (props[k])) -+ continue; -+ -+ /* Cap total text. */ -+ if (ax_offset >= NS_AX_TEXT_CAP) -+ break; -+ -+ NSString *nsstr -+ = [NSString stringWithLispString:props[k]]; -+ NSUInteger ns_len = [nsstr length]; -+ if (ns_len == 0) -+ continue; -+ -+ if (ax_offset + ns_len > NS_AX_TEXT_CAP) -+ ns_len = NS_AX_TEXT_CAP - ax_offset; -+ -+ if (ns_len < [nsstr length]) -+ nsstr = [nsstr substringToIndex:ns_len]; -+ -+ [result appendString:nsstr]; -+ -+ if (nruns >= run_capacity) -+ { -+ run_capacity *= 2; -+ runs = xrealloc (runs, run_capacity -+ * sizeof (ns_ax_visible_run)); -+ } -+ runs[nruns].charpos = anchors[k]; -+ runs[nruns].length = 0; /* virtual — no buffer chars */ -+ runs[nruns].ax_start = ax_offset; -+ runs[nruns].ax_length = ns_len; -+ nruns++; -+ -+ ax_offset += ns_len; -+ } -+ } -+ } -+ - unbind_to (count, Qnil); - - *out_runs = runs; -@@ -8795,6 +8945,55 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, - [self postTextChangedNotification:point]; - } - -+ /* --- Overlay content changed (e.g. Vertico/Ivy candidate switch) --- -+ Overlay-only changes (before-string, after-string, display) bump -+ BUF_OVERLAY_MODIFF but not BUF_MODIFF. Instead of a generic -+ ValueChanged (which causes VoiceOver to say "new line"), find the -+ highlighted candidate in the overlay and announce it directly. */ -+ else if (BUF_OVERLAY_MODIFF (b) != self.cachedOverlayModiff) -+ { -+ self.cachedOverlayModiff = BUF_OVERLAY_MODIFF (b); -+ [self invalidateTextCache]; -+ -+ /* Find the selected candidate text from overlay face -+ properties. Completion frameworks highlight the current -+ candidate with a text face (e.g. vertico-current, -+ icomplete-selected-match). */ -+ NSString *candidate -+ = ns_ax_selected_overlay_text (b, BUF_BEGV (b), BUF_ZV (b)); -+ if (candidate) -+ { -+ /* Post SelectedTextChanged first to interrupt VoiceOver, -+ then AnnouncementRequested with candidate text. -+ Target NSApp for announcements (Apple docs require it). */ -+ NSDictionary *moveInfo = @{ -+ @"AXTextStateChangeType": -+ @(ns_ax_text_state_change_selection_move), -+ @"AXTextChangeElement": self -+ }; -+ ns_ax_post_notification_with_info ( -+ self, -+ NSAccessibilitySelectedTextChangedNotification, -+ moveInfo); -+ -+ candidate = [candidate -+ stringByTrimmingCharactersInSet: -+ [NSCharacterSet whitespaceAndNewlineCharacterSet]]; -+ if ([candidate length] > 0) -+ { -+ NSDictionary *annInfo = @{ -+ NSAccessibilityAnnouncementKey: candidate, -+ NSAccessibilityPriorityKey: -+ @(NSAccessibilityPriorityHigh) -+ }; -+ ns_ax_post_notification_with_info ( -+ NSApp, -+ NSAccessibilityAnnouncementRequestedNotification, -+ annInfo); -+ } -+ } -+ } -+ - /* --- Cursor moved or selection changed --- - Use 'else if' — edits and selection moves are mutually exclusive - per the WebKit/Chromium pattern. */ --- -2.43.0 -