patches: fold line index + remove NS_AX_TEXT_CAP into 0001-0002

- 0001: remove NS_AX_TEXT_CAP (100K char cap), add lineStartOffsets/
  lineCount ivars and method declarations to nsterm.h
- 0002: add lineForAXIndex:/rangeForLine: O(log L) helpers, build line
  index in ensureTextCache, replace O(L) line scanning in
  accessibilityInsertionPointLineNumber/accessibilityLineForIndex/
  accessibilityRangeForLine, free index in invalidateTextCache/dealloc
- 0009 deleted (folded into 0001+0002)
- README.txt: remove NS_AX_TEXT_CAP references, update known
  limitations, stress test threshold 50K lines
This commit is contained in:
2026-02-28 21:39:30 +01:00
parent 419762bde0
commit 30089e9413
10 changed files with 540 additions and 552 deletions

View File

@@ -1,7 +1,7 @@
From 3bb5a0bed12de424e79a24228e6ae2b4a6e0ecf1 Mon Sep 17 00:00:00 2001 From d176c3c9d97574f0cd493d6491eda0a82ad28387 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 12:58:11 +0100 Date: Sat, 28 Feb 2026 12:58:11 +0100
Subject: [PATCH 1/6] ns: add accessibility base classes and text extraction Subject: [PATCH 1/8] ns: add accessibility base classes and text extraction
Add the foundation for macOS VoiceOver accessibility in the NS Add the foundation for macOS VoiceOver accessibility in the NS
(Cocoa) port. No existing code paths are modified. (Cocoa) port. No existing code paths are modified.
@@ -15,7 +15,7 @@ Add the foundation for macOS VoiceOver accessibility in the NS
(EmacsAXSpanType): New enum. (EmacsAXSpanType): New enum.
(EmacsView): New ivars for accessibility state. (EmacsView): New ivars for accessibility state.
* src/nsterm.m: Include intervals.h for TEXT_PROP_MEANS_INVISIBLE. * src/nsterm.m: Include intervals.h for TEXT_PROP_MEANS_INVISIBLE.
(NS_AX_TEXT_CAP): New macro, 100000.
(ns_ax_buffer_text, ns_ax_mode_line_text, ns_ax_frame_for_range) (ns_ax_buffer_text, ns_ax_mode_line_text, ns_ax_frame_for_range)
(ns_ax_completion_string_from_prop, ns_ax_window_buffer_object) (ns_ax_completion_string_from_prop, ns_ax_window_buffer_object)
(ns_ax_window_end_charpos, ns_ax_text_prop_at) (ns_ax_window_end_charpos, ns_ax_text_prop_at)
@@ -29,15 +29,15 @@ ns-accessibility-enabled.
Tested on macOS 14 Sonoma with VoiceOver 10. Builds cleanly; Tested on macOS 14 Sonoma with VoiceOver 10. Builds cleanly;
no functional change (dead code until patch 5/6 wires it in). no functional change (dead code until patch 5/6 wires it in).
--- ---
src/nsterm.h | 127 ++++++++++++++ src/nsterm.h | 131 +++++++++++++++
src/nsterm.m | 468 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/nsterm.m | 456 +++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 595 insertions(+) 2 files changed, 587 insertions(+)
diff --git a/src/nsterm.h b/src/nsterm.h diff --git a/src/nsterm.h b/src/nsterm.h
index 7c1ee4c..51c30ca 100644 index 7c1ee4c..5298386 100644
--- a/src/nsterm.h --- a/src/nsterm.h
+++ b/src/nsterm.h +++ b/src/nsterm.h
@@ -453,6 +453,118 @@ enum ns_return_frame_mode @@ -453,6 +453,122 @@ enum ns_return_frame_mode
@end @end
@@ -87,6 +87,8 @@ index 7c1ee4c..51c30ca 100644
+{ +{
+ ns_ax_visible_run *visibleRuns; + ns_ax_visible_run *visibleRuns;
+ NSUInteger visibleRunCount; + NSUInteger visibleRunCount;
+ NSUInteger *lineStartOffsets; /* AX string index of each line start. */
+ NSUInteger lineCount; /* Number of entries in lineStartOffsets. */
+ NSMutableArray *cachedInteractiveSpans; + NSMutableArray *cachedInteractiveSpans;
+ BOOL interactiveSpansDirty; + BOOL interactiveSpansDirty;
+} +}
@@ -102,6 +104,8 @@ index 7c1ee4c..51c30ca 100644
+@property (nonatomic, assign) ptrdiff_t cachedCompletionOverlayEnd; +@property (nonatomic, assign) ptrdiff_t cachedCompletionOverlayEnd;
+@property (nonatomic, assign) ptrdiff_t cachedCompletionPoint; +@property (nonatomic, assign) ptrdiff_t cachedCompletionPoint;
+- (void)invalidateTextCache; +- (void)invalidateTextCache;
+- (NSInteger)lineForAXIndex:(NSUInteger)idx;
+- (NSRange)rangeForLine:(NSUInteger)line textLength:(NSUInteger)tlen;
+- (ptrdiff_t)charposForAccessibilityIndex:(NSUInteger)ax_idx; +- (ptrdiff_t)charposForAccessibilityIndex:(NSUInteger)ax_idx;
+- (NSUInteger)accessibilityIndexForCharpos:(ptrdiff_t)charpos; +- (NSUInteger)accessibilityIndexForCharpos:(ptrdiff_t)charpos;
+@end +@end
@@ -156,7 +160,7 @@ index 7c1ee4c..51c30ca 100644
/* ========================================================================== /* ==========================================================================
The main Emacs view The main Emacs view
@@ -471,6 +583,14 @@ enum ns_return_frame_mode @@ -471,6 +587,14 @@ enum ns_return_frame_mode
#ifdef NS_IMPL_COCOA #ifdef NS_IMPL_COCOA
char *old_title; char *old_title;
BOOL maximizing_resize; BOOL maximizing_resize;
@@ -171,7 +175,7 @@ index 7c1ee4c..51c30ca 100644
#endif #endif
BOOL font_panel_active; BOOL font_panel_active;
NSFont *font_panel_result; NSFont *font_panel_result;
@@ -528,6 +648,13 @@ enum ns_return_frame_mode @@ -528,6 +652,13 @@ enum ns_return_frame_mode
- (void)windowWillExitFullScreen; - (void)windowWillExitFullScreen;
- (void)windowDidExitFullScreen; - (void)windowDidExitFullScreen;
- (void)windowDidBecomeKey; - (void)windowDidBecomeKey;
@@ -186,7 +190,7 @@ index 7c1ee4c..51c30ca 100644
diff --git a/src/nsterm.m b/src/nsterm.m diff --git a/src/nsterm.m b/src/nsterm.m
index 74e4ad5..935919f 100644 index 74e4ad5..2ac1d9d 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/src/nsterm.m +++ b/src/nsterm.m
@@ -46,6 +46,7 @@ GNUstep port and post-20 update by Adrian Robert (arobert@cogsci.ucsd.edu) @@ -46,6 +46,7 @@ GNUstep port and post-20 update by Adrian Robert (arobert@cogsci.ucsd.edu)
@@ -197,7 +201,7 @@ index 74e4ad5..935919f 100644
#include "systime.h" #include "systime.h"
#include "character.h" #include "character.h"
#include "xwidget.h" #include "xwidget.h"
@@ -6856,6 +6857,442 @@ ns_create_font_panel_buttons (id target, SEL select, SEL cancel_action) @@ -6856,6 +6857,430 @@ ns_create_font_panel_buttons (id target, SEL select, SEL cancel_action)
} }
#endif #endif
@@ -211,12 +215,6 @@ index 74e4ad5..935919f 100644
+ +
+/* ---- Helper: extract buffer text for accessibility ---- */ +/* ---- Helper: extract buffer text for accessibility ---- */
+ +
+/* Maximum characters exposed via accessibilityValue. */
+/* Cap accessibility text at 100,000 UTF-16 units (~200 KB). VoiceOver
+ performance degrades beyond this; buffers larger than ~50,000 lines
+ are truncated for accessibility purposes. */
+#define NS_AX_TEXT_CAP 100000
+
+/* Build accessibility text for window W, skipping invisible text. +/* Build accessibility text for window W, skipping invisible text.
+ Populates *OUT_START with the buffer start charpos. + Populates *OUT_START with the buffer start charpos.
+ Populates *OUT_RUNS with an array of visible runs and *OUT_NRUNS + Populates *OUT_RUNS with an array of visible runs and *OUT_NRUNS
@@ -288,13 +286,7 @@ index 74e4ad5..935919f 100644
+ make_fixnum (pos), Qinvisible, Qnil, make_fixnum (zv)); + make_fixnum (pos), Qinvisible, Qnil, make_fixnum (zv));
+ ptrdiff_t run_end = FIXNUMP (next) ? XFIXNUM (next) : zv; + ptrdiff_t run_end = FIXNUMP (next) ? XFIXNUM (next) : zv;
+ +
+ /* Cap total text at NS_AX_TEXT_CAP. */
+ ptrdiff_t run_len = run_end - pos; + ptrdiff_t run_len = run_end - pos;
+ if (ax_offset + (NSUInteger) run_len > NS_AX_TEXT_CAP)
+ run_len = (ptrdiff_t) (NS_AX_TEXT_CAP - ax_offset);
+ if (run_len <= 0)
+ break;
+ run_end = pos + run_len;
+ +
+ /* Extract this visible run's text. Use + /* Extract this visible run's text. Use
+ Fbuffer_substring_no_properties which correctly handles the + Fbuffer_substring_no_properties which correctly handles the
@@ -640,7 +632,7 @@ index 74e4ad5..935919f 100644
/* ========================================================================== /* ==========================================================================
EmacsView implementation EmacsView implementation
@@ -11312,6 +11749,28 @@ syms_of_nsterm (void) @@ -11312,6 +11737,28 @@ syms_of_nsterm (void)
DEFSYM (Qns_drag_operation_generic, "ns-drag-operation-generic"); DEFSYM (Qns_drag_operation_generic, "ns-drag-operation-generic");
DEFSYM (Qns_handle_drag_motion, "ns-handle-drag-motion"); DEFSYM (Qns_handle_drag_motion, "ns-handle-drag-motion");
@@ -669,7 +661,7 @@ index 74e4ad5..935919f 100644
Fput (Qalt, Qmodifier_value, make_fixnum (alt_modifier)); Fput (Qalt, Qmodifier_value, make_fixnum (alt_modifier));
Fput (Qhyper, Qmodifier_value, make_fixnum (hyper_modifier)); Fput (Qhyper, Qmodifier_value, make_fixnum (hyper_modifier));
Fput (Qmeta, Qmodifier_value, make_fixnum (meta_modifier)); Fput (Qmeta, Qmodifier_value, make_fixnum (meta_modifier));
@@ -11460,6 +11919,15 @@ Note that this does not apply to images. @@ -11460,6 +11907,15 @@ Note that this does not apply to images.
This variable is ignored on Mac OS X < 10.7 and GNUstep. */); This variable is ignored on Mac OS X < 10.7 and GNUstep. */);
ns_use_srgb_colorspace = YES; ns_use_srgb_colorspace = YES;

View File

@@ -1,7 +1,7 @@
From b448ae7e4c129a12a4a5485f26f706614c0da1cd Mon Sep 17 00:00:00 2001 From 6f2e1b097c2ed1d2f45e99cf85792a1b28556202 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 12:58:11 +0100 Date: Sat, 28 Feb 2026 12:58:11 +0100
Subject: [PATCH 2/6] ns: implement buffer accessibility element (core Subject: [PATCH 2/8] ns: implement buffer accessibility element (core
protocol) protocol)
Implement the NSAccessibility text protocol for Emacs buffer windows. Implement the NSAccessibility text protocol for Emacs buffer windows.
@@ -18,14 +18,271 @@ setAccessibilityFocused.
Tested on macOS 14 with VoiceOver. Verified: buffer reading, Tested on macOS 14 with VoiceOver. Verified: buffer reading,
line-by-line navigation, word/character announcements. line-by-line navigation, word/character announcements.
--- ---
src/nsterm.m | 1089 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/nsterm.m | 1346 ++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 1089 insertions(+) 1 file changed, 1346 insertions(+)
diff --git a/src/nsterm.m b/src/nsterm.m diff --git a/src/nsterm.m b/src/nsterm.m
index 935919f..e80c3df 100644 index 2ac1d9d..fc5906a 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/src/nsterm.m +++ b/src/nsterm.m
@@ -7290,6 +7290,1095 @@ ns_ax_post_notification_with_info (id element, @@ -6867,6 +6867,256 @@ ns_create_font_panel_buttons (id target, SEL select, SEL cancel_action)
/* ---- Helper: extract buffer text for accessibility ---- */
+/* Return true if FACE is or contains a face symbol whose name
+ includes "current" or "selected", indicating a highlighted
+ completion candidate. Works for vertico-current,
+ icomplete-selected-match, ivy-current-match, etc. */
+static bool
+ns_ax_face_is_selected (Lisp_Object face)
+{
+ if (SYMBOLP (face) && !NILP (face))
+ {
+ const char *name = SSDATA (SYMBOL_NAME (face));
+ /* Substring match is intentionally broad --- it catches
+ vertico-current, icomplete-selected-match, ivy-current-match,
+ company-tooltip-selection, and similar. False positives are
+ harmless since this runs only on overlay strings during
+ completion. */
+ if (strstr (name, "current") || strstr (name, "selected")
+ || strstr (name, "selection"))
+ return true;
+ }
+ if (CONSP (face))
+ {
+ for (Lisp_Object tail = face; CONSP (tail); tail = XCDR (tail))
+ if (ns_ax_face_is_selected (XCAR (tail)))
+ return true;
+ }
+ return false;
+}
+
+/* Extract the currently selected candidate text from overlay display
+ strings. Completion frameworks render candidates as overlay
+ before-string/after-string and highlight the current candidate
+ with a face whose name contains "current" or "selected"
+ (e.g. vertico-current, icomplete-selected-match, ivy-current-match).
+
+ Scan all overlays in the buffer region [BEG, END), find the line
+ whose face matches the selection heuristic, and return it (already
+ trimmed of surrounding whitespace).
+
+ Also set *OUT_LINE_INDEX to the 0-based visual line index of the
+ selected candidate (for Zoom positioning), counting only non-trivial
+ lines. Set to -1 if not found.
+
+ Returns nil if no selected candidate 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.
+ The data pointer is used only in this loop, before any
+ Lisp calls (Fget_text_property etc.) that could trigger
+ GC and relocate string data. */
+ const unsigned char *data = SDATA (str);
+ ptrdiff_t byte_len = SBYTES (str);
+ /* 512 lines is sufficient for any completion UI;
+ vertico-count defaults to 10. */
+ 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++;
+ }
+ if (char_pos > lstart && nlines < 512)
+ {
+ line_starts[nlines] = lstart;
+ line_ends[nlines] = char_pos;
+ nlines++;
+ }
+
+ /* Find the line whose face indicates selection. Track
+ visual line index for Zoom (skip whitespace-only lines
+ like Vertico's leading cursor-space). */
+ int candidate_idx = 0;
+ for (int li = 0; li < nlines; li++)
+ {
+ Lisp_Object face
+ = Fget_text_property (make_fixnum (line_starts[li]),
+ Qface, str);
+ 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 = candidate_idx;
+ return text;
+ }
+ }
+
+ /* Count non-trivial lines as candidates for Zoom. */
+ if (line_ends[li] - line_starts[li] > 1)
+ candidate_idx++;
+ }
+ }
+ }
+
+ 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
@@ -7278,6 +7528,1102 @@ ns_ax_post_notification_with_info (id element,
@end @end
@@ -268,6 +525,8 @@ index 935919f..e80c3df 100644
+ [cachedInteractiveSpans release]; + [cachedInteractiveSpans release];
+ if (visibleRuns) + if (visibleRuns)
+ xfree (visibleRuns); + xfree (visibleRuns);
+ if (lineStartOffsets)
+ xfree (lineStartOffsets);
+ [super dealloc]; + [super dealloc];
+} +}
+ +
@@ -285,10 +544,65 @@ index 935919f..e80c3df 100644
+ visibleRuns = NULL; + visibleRuns = NULL;
+ } + }
+ visibleRunCount = 0; + visibleRunCount = 0;
+ if (lineStartOffsets)
+ {
+ xfree (lineStartOffsets);
+ lineStartOffsets = NULL;
+ }
+ lineCount = 0;
+ } + }
+ [self invalidateInteractiveSpans]; + [self invalidateInteractiveSpans];
+} +}
+ +
+/* ---- Line index helpers ---- */
+
+/* Return the line number for AX string index IDX using the
+ precomputed lineStartOffsets array. Binary search: O(log L)
+ where L is the number of lines in the cached text.
+
+ lineStartOffsets[i] holds the AX string index where line i
+ begins. Built once per cache rebuild in ensureTextCache. */
+- (NSInteger)lineForAXIndex:(NSUInteger)idx
+{
+ @synchronized (self)
+ {
+ if (!lineStartOffsets || lineCount == 0)
+ return 0;
+
+ /* Binary search for the largest line whose start offset <= idx. */
+ NSUInteger lo = 0, hi = lineCount;
+ while (lo < hi)
+ {
+ NSUInteger mid = lo + (hi - lo) / 2;
+ if (lineStartOffsets[mid] <= idx)
+ lo = mid + 1;
+ else
+ hi = mid;
+ }
+ return (NSInteger) (lo > 0 ? lo - 1 : 0);
+ }
+}
+
+/* Return the AX string range for LINE using the precomputed
+ lineStartOffsets array. O(1) lookup. */
+- (NSRange)rangeForLine:(NSUInteger)line textLength:(NSUInteger)tlen
+{
+ @synchronized (self)
+ {
+ if (!lineStartOffsets || lineCount == 0)
+ return NSMakeRange (NSNotFound, 0);
+
+ if (line >= lineCount)
+ return NSMakeRange (NSNotFound, 0);
+
+ NSUInteger start = lineStartOffsets[line];
+ NSUInteger end = (line + 1 < lineCount)
+ ? lineStartOffsets[line + 1]
+ : tlen;
+ return NSMakeRange (start, end - start);
+ }
+}
+
+- (void)ensureTextCache +- (void)ensureTextCache
+{ +{
+ NSTRACE ("EmacsAccessibilityBuffer ensureTextCache"); + NSTRACE ("EmacsAccessibilityBuffer ensureTextCache");
@@ -340,6 +654,45 @@ index 935919f..e80c3df 100644
+ xfree (visibleRuns); + xfree (visibleRuns);
+ visibleRuns = runs; + visibleRuns = runs;
+ visibleRunCount = nruns; + visibleRunCount = nruns;
+
+ /* Build line-start index for O(log L) line queries.
+ Walk the cached text once, recording the start offset
+ of each line. This runs once per cache rebuild (on text
+ change or narrowing), not per cursor move. */
+ if (lineStartOffsets)
+ xfree (lineStartOffsets);
+ lineStartOffsets = NULL;
+ lineCount = 0;
+
+ NSUInteger tlen = [cachedText length];
+ if (tlen > 0)
+ {
+ NSUInteger cap = 256;
+ lineStartOffsets = xmalloc (cap * sizeof (NSUInteger));
+ lineStartOffsets[0] = 0;
+ lineCount = 1;
+ NSUInteger pos = 0;
+ while (pos < tlen)
+ {
+ NSRange lr = [cachedText lineRangeForRange:
+ NSMakeRange (pos, 0)];
+ NSUInteger next = NSMaxRange (lr);
+ if (next <= pos)
+ break; /* safety */
+ if (next < tlen)
+ {
+ if (lineCount >= cap)
+ {
+ cap *= 2;
+ lineStartOffsets = xrealloc (lineStartOffsets,
+ cap * sizeof (NSUInteger));
+ }
+ lineStartOffsets[lineCount] = next;
+ lineCount++;
+ }
+ pos = next;
+ }
+ }
+ } + }
+} +}
+ +
@@ -807,77 +1160,7 @@ index 935919f..e80c3df 100644
+ if (point_idx > [cachedText length]) + if (point_idx > [cachedText length])
+ point_idx = [cachedText length]; + point_idx = [cachedText length];
+ +
+ /* Count lines by iterating lineRangeForRange from the start. + return [self lineForAXIndex:idx];
+ Each call jumps an entire line — O(lines) not O(chars). */
+ NSInteger line = 0;
+ NSUInteger scan = 0;
+ NSUInteger len = [cachedText length];
+ while (scan < point_idx && scan < len)
+ {
+ NSRange lr = [cachedText lineRangeForRange:NSMakeRange (scan, 0)];
+ NSUInteger next = NSMaxRange (lr);
+ if (next <= scan) break; /* safety */
+ if (next > point_idx) break;
+ line++;
+ scan = next;
+ }
+ return line;
+}
+
+- (NSString *)accessibilityStringForRange:(NSRange)range
+{
+ if (![NSThread isMainThread])
+ {
+ __block NSString *result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityStringForRange:range];
+ });
+ return result;
+ }
+ [self ensureTextCache];
+ if (!cachedText || range.location + range.length > [cachedText length])
+ return @"";
+ return [cachedText substringWithRange:range];
+}
+
+- (NSAttributedString *)accessibilityAttributedStringForRange:(NSRange)range
+{
+ NSString *str = [self accessibilityStringForRange:range];
+ return [[[NSAttributedString alloc] initWithString:str] autorelease];
+}
+
+- (NSInteger)accessibilityLineForIndex:(NSInteger)index
+{
+ if (![NSThread isMainThread])
+ {
+ __block NSInteger result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityLineForIndex:index];
+ });
+ return result;
+ }
+ [self ensureTextCache];
+ if (!cachedText || index < 0)
+ return 0;
+
+ NSUInteger idx = (NSUInteger) index;
+ if (idx > [cachedText length])
+ idx = [cachedText length];
+
+ /* Count lines by iterating lineRangeForRange — O(lines). */
+ NSInteger line = 0;
+ NSUInteger scan = 0;
+ NSUInteger len = [cachedText length];
+ while (scan < idx && scan < len)
+ {
+ NSRange lr = [cachedText lineRangeForRange:NSMakeRange (scan, 0)];
+ NSUInteger next = NSMaxRange (lr);
+ if (next <= scan) break;
+ if (next > idx) break;
+ line++;
+ scan = next;
+ }
+ return line;
+} +}
+ +
+- (NSRange)accessibilityRangeForLine:(NSInteger)line +- (NSRange)accessibilityRangeForLine:(NSInteger)line
@@ -899,26 +1182,7 @@ index 935919f..e80c3df 100644
+ return (line == 0) ? NSMakeRange (0, 0) + return (line == 0) ? NSMakeRange (0, 0)
+ : NSMakeRange (NSNotFound, 0); + : NSMakeRange (NSNotFound, 0);
+ +
+ /* Skip to the requested line using lineRangeForRange — O(lines) + return [self rangeForLine:(NSUInteger)line textLength:len];
+ not O(chars), consistent with accessibilityLineForIndex:. */
+ NSInteger cur_line = 0;
+ NSUInteger scan = 0;
+ while (cur_line < line && scan < len)
+ {
+ NSRange lr = [cachedText lineRangeForRange:NSMakeRange (scan, 0)];
+ NSUInteger next = NSMaxRange (lr);
+ if (next <= scan) break; /* safety */
+ cur_line++;
+ scan = next;
+ }
+ if (cur_line != line)
+ return NSMakeRange (NSNotFound, 0);
+
+ /* Return the range of the target line. */
+ if (scan >= len)
+ return NSMakeRange (len, 0); /* phantom line after final newline */
+ NSRange lr = [cachedText lineRangeForRange:NSMakeRange (scan, 0)];
+ return lr;
+} +}
+ +
+- (NSRange)accessibilityRangeForIndex:(NSInteger)index +- (NSRange)accessibilityRangeForIndex:(NSInteger)index

View File

@@ -1,7 +1,7 @@
From 8163e04dbc948ff24633bed534a8c66b9bc1b187 Mon Sep 17 00:00:00 2001 From 97baf7b5f8b0ccc85342e7d552b69b337c98f772 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 12:58:11 +0100 Date: Sat, 28 Feb 2026 12:58:11 +0100
Subject: [PATCH 3/6] ns: add buffer notification dispatch and mode-line Subject: [PATCH 3/8] ns: add buffer notification dispatch and mode-line
element element
Add VoiceOver notification methods and mode-line readout. Add VoiceOver notification methods and mode-line readout.
@@ -24,10 +24,10 @@ region selection feedback, completion popups, mode-line reading.
1 file changed, 545 insertions(+) 1 file changed, 545 insertions(+)
diff --git a/src/nsterm.m b/src/nsterm.m diff --git a/src/nsterm.m b/src/nsterm.m
index e80c3df..d27e4ed 100644 index fc5906a..f1a1b42 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/src/nsterm.m +++ b/src/nsterm.m
@@ -8379,6 +8379,551 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -8624,6 +8624,551 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
@end @end

View File

@@ -1,7 +1,7 @@
From 6626fa801e94c693d0027c3d7b02e750def95df9 Mon Sep 17 00:00:00 2001 From 1bd12dd5d464d0c3f9774630014e434b8fb0e19e Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 12:58:11 +0100 Date: Sat, 28 Feb 2026 12:58:11 +0100
Subject: [PATCH 4/6] ns: add interactive span elements for Tab navigation Subject: [PATCH 4/8] ns: add interactive span elements for Tab navigation
* src/nsterm.m (ns_ax_scan_interactive_spans): New function. * src/nsterm.m (ns_ax_scan_interactive_spans): New function.
(EmacsAccessibilityInteractiveSpan): Implement AXButton/AXLink (EmacsAccessibilityInteractiveSpan): Implement AXButton/AXLink
@@ -17,10 +17,10 @@ Tested on macOS 14. Verified: Tab-cycling through org-mode links,
1 file changed, 286 insertions(+) 1 file changed, 286 insertions(+)
diff --git a/src/nsterm.m b/src/nsterm.m diff --git a/src/nsterm.m b/src/nsterm.m
index d27e4ed..bfa1b26 100644 index f1a1b42..91d0241 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/src/nsterm.m +++ b/src/nsterm.m
@@ -8924,6 +8924,292 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -9169,6 +9169,292 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
@end @end

View File

@@ -1,7 +1,7 @@
From 72a0b8bad2d200cf093d9e0c60d937032784bd74 Mon Sep 17 00:00:00 2001 From 3bbe8ba29725a4708595befa6b73e5873a2aab43 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 12:58:11 +0100 Date: Sat, 28 Feb 2026 12:58:11 +0100
Subject: [PATCH 5/6] ns: integrate accessibility with EmacsView and redisplay Subject: [PATCH 5/8] ns: integrate accessibility with EmacsView and redisplay
Wire the accessibility infrastructure into EmacsView and the Wire the accessibility infrastructure into EmacsView and the
redisplay cycle. After this patch, VoiceOver and Zoom are active. redisplay cycle. After this patch, VoiceOver and Zoom are active.
@@ -53,7 +53,7 @@ index 7367e3c..608650e 100644
** Re-introduced dictation, lost in Emacs v30 (macOS). ** Re-introduced dictation, lost in Emacs v30 (macOS).
We lost macOS dictation in v30 when migrating to NSTextInputClient. We lost macOS dictation in v30 when migrating to NSTextInputClient.
diff --git a/src/nsterm.m b/src/nsterm.m diff --git a/src/nsterm.m b/src/nsterm.m
index bfa1b26..1780194 100644 index 91d0241..125e52c 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/src/nsterm.m +++ b/src/nsterm.m
@@ -1105,6 +1105,11 @@ ns_update_end (struct frame *f) @@ -1105,6 +1105,11 @@ ns_update_end (struct frame *f)
@@ -112,7 +112,7 @@ index bfa1b26..1780194 100644
ns_focus (f, NULL, 0); ns_focus (f, NULL, 0);
NSGraphicsContext *ctx = [NSGraphicsContext currentContext]; NSGraphicsContext *ctx = [NSGraphicsContext currentContext];
@@ -7293,7 +7335,6 @@ ns_ax_post_notification_with_info (id element, @@ -7531,7 +7573,6 @@ ns_ax_post_notification_with_info (id element,
@@ -120,7 +120,7 @@ index bfa1b26..1780194 100644
static BOOL static BOOL
ns_ax_find_completion_overlay_range (struct buffer *b, ptrdiff_t point, ns_ax_find_completion_overlay_range (struct buffer *b, ptrdiff_t point,
ptrdiff_t *out_start, ptrdiff_t *out_start,
@@ -8380,7 +8421,6 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -8625,7 +8666,6 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
@end @end
@@ -128,7 +128,7 @@ index bfa1b26..1780194 100644
/* =================================================================== /* ===================================================================
EmacsAccessibilityBuffer (Notifications) — AX event dispatch EmacsAccessibilityBuffer (Notifications) — AX event dispatch
@@ -8925,7 +8965,6 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -9170,7 +9210,6 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
@end @end
@@ -136,7 +136,7 @@ index bfa1b26..1780194 100644
/* =================================================================== /* ===================================================================
EmacsAccessibilityInteractiveSpan — helpers and implementation EmacsAccessibilityInteractiveSpan — helpers and implementation
=================================================================== */ =================================================================== */
@@ -9255,6 +9294,7 @@ ns_ax_scan_interactive_spans (struct window *w, @@ -9500,6 +9539,7 @@ ns_ax_scan_interactive_spans (struct window *w,
[layer release]; [layer release];
#endif #endif
@@ -144,7 +144,7 @@ index bfa1b26..1780194 100644
[[self menu] release]; [[self menu] release];
[super dealloc]; [super dealloc];
} }
@@ -10603,6 +10643,32 @@ ns_in_echo_area (void) @@ -10848,6 +10888,32 @@ ns_in_echo_area (void)
XSETFRAME (event.frame_or_window, emacsframe); XSETFRAME (event.frame_or_window, emacsframe);
kbd_buffer_store_event (&event); kbd_buffer_store_event (&event);
ns_send_appdefined (-1); // Kick main loop ns_send_appdefined (-1); // Kick main loop
@@ -177,7 +177,7 @@ index bfa1b26..1780194 100644
} }
@@ -11840,6 +11906,332 @@ ns_in_echo_area (void) @@ -12085,6 +12151,332 @@ ns_in_echo_area (void)
return fs_state; return fs_state;
} }

View File

@@ -1,7 +1,7 @@
From 2865a2fccc26e339a4f649b65ab07cf323acf0d1 Mon Sep 17 00:00:00 2001 From 5ddf6227b581bf292fc187a1ebcaf80d2cd4cf2a Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 12:58:11 +0100 Date: Sat, 28 Feb 2026 12:58:11 +0100
Subject: [PATCH 6/6] doc: add VoiceOver accessibility section to macOS Subject: [PATCH 6/8] doc: add VoiceOver accessibility section to macOS
appendix appendix
* doc/emacs/macos.texi (VoiceOver Accessibility): New node. Document * doc/emacs/macos.texi (VoiceOver Accessibility): New node. Document

View File

@@ -1,7 +1,7 @@
From 6e907a1000a8b138976d6a906e40449fdf1a61c5 Mon Sep 17 00:00:00 2001 From 8f619411ec75efbd18e663bb3f2ed6f8c9af60d8 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 14:46:25 +0100 Date: Sat, 28 Feb 2026 14:46:25 +0100
Subject: [PATCH 1/2] ns: announce overlay completion candidates for VoiceOver Subject: [PATCH 7/8] ns: announce overlay completion candidates for VoiceOver
Completion frameworks such as Vertico, Ivy, and Icomplete render Completion frameworks such as Vertico, Ivy, and Icomplete render
candidates via overlay before-string/after-string properties rather candidates via overlay before-string/after-string properties rather
@@ -52,14 +52,14 @@ Independent overlay branch, BUF_CHARS_MODIFF gating, candidate
announcement with overlay Zoom rect storage. announcement with overlay Zoom rect storage.
--- ---
src/nsterm.h | 3 + src/nsterm.h | 3 +
src/nsterm.m | 331 +++++++++++++++++++++++++++++++++++++++++++++------ src/nsterm.m | 361 ++++++++++++++++++++++++++++++++++++++++++++++-----
2 files changed, 298 insertions(+), 36 deletions(-) 2 files changed, 329 insertions(+), 35 deletions(-)
diff --git a/src/nsterm.h b/src/nsterm.h diff --git a/src/nsterm.h b/src/nsterm.h
index 51c30ca..5c15639 100644 index 5298386..a007925 100644
--- a/src/nsterm.h --- a/src/nsterm.h
+++ b/src/nsterm.h +++ b/src/nsterm.h
@@ -507,6 +507,7 @@ typedef struct ns_ax_visible_run @@ -509,6 +509,7 @@ typedef struct ns_ax_visible_run
@property (nonatomic, assign) ptrdiff_t cachedOverlayModiff; @property (nonatomic, assign) ptrdiff_t cachedOverlayModiff;
@property (nonatomic, assign) ptrdiff_t cachedTextStart; @property (nonatomic, assign) ptrdiff_t cachedTextStart;
@property (nonatomic, assign) ptrdiff_t cachedModiff; @property (nonatomic, assign) ptrdiff_t cachedModiff;
@@ -67,7 +67,7 @@ index 51c30ca..5c15639 100644
@property (nonatomic, assign) ptrdiff_t cachedPoint; @property (nonatomic, assign) ptrdiff_t cachedPoint;
@property (nonatomic, assign) BOOL cachedMarkActive; @property (nonatomic, assign) BOOL cachedMarkActive;
@property (nonatomic, copy) NSString *cachedCompletionAnnouncement; @property (nonatomic, copy) NSString *cachedCompletionAnnouncement;
@@ -591,6 +592,8 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType) @@ -595,6 +596,8 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType)
BOOL accessibilityUpdating; BOOL accessibilityUpdating;
@public /* Accessed by ns_draw_phys_cursor (C function). */ @public /* Accessed by ns_draw_phys_cursor (C function). */
NSRect lastAccessibilityCursorRect; NSRect lastAccessibilityCursorRect;
@@ -77,7 +77,7 @@ index 51c30ca..5c15639 100644
BOOL font_panel_active; BOOL font_panel_active;
NSFont *font_panel_result; NSFont *font_panel_result;
diff --git a/src/nsterm.m b/src/nsterm.m diff --git a/src/nsterm.m b/src/nsterm.m
index 1780194..143e784 100644 index 125e52c..ebd52c6 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/src/nsterm.m +++ b/src/nsterm.m
@@ -3258,7 +3258,12 @@ ns_draw_window_cursor (struct window *w, struct glyph_row *glyph_row, @@ -3258,7 +3258,12 @@ ns_draw_window_cursor (struct window *w, struct glyph_row *glyph_row,
@@ -94,9 +94,9 @@ index 1780194..143e784 100644
NSRect screenRect = [[view window] convertRectToScreen:windowRect]; NSRect screenRect = [[view window] convertRectToScreen:windowRect];
CGRect cgRect = NSRectToCGRect (screenRect); CGRect cgRect = NSRectToCGRect (screenRect);
@@ -6915,11 +6920,156 @@ ns_create_font_panel_buttons (id target, SEL select, SEL cancel_action) @@ -7159,11 +7164,156 @@ ns_ax_selected_child_frame_text (struct buffer *b, Lisp_Object buf_obj,
are truncated for accessibility purposes. */ }
#define NS_AX_TEXT_CAP 100000
+/* Return true if FACE is or contains a face symbol whose name +/* Return true if FACE is or contains a face symbol whose name
+ includes "current" or "selected", indicating a highlighted + includes "current" or "selected", indicating a highlighted
@@ -252,7 +252,7 @@ index 1780194..143e784 100644
static NSString * static NSString *
ns_ax_buffer_text (struct window *w, ptrdiff_t *out_start, ns_ax_buffer_text (struct window *w, ptrdiff_t *out_start,
ns_ax_visible_run **out_runs, NSUInteger *out_nruns) ns_ax_visible_run **out_runs, NSUInteger *out_nruns)
@@ -6996,7 +7146,7 @@ ns_ax_buffer_text (struct window *w, ptrdiff_t *out_start, @@ -7234,7 +7384,7 @@ ns_ax_buffer_text (struct window *w, ptrdiff_t *out_start,
/* Extract this visible run's text. Use /* Extract this visible run's text. Use
Fbuffer_substring_no_properties which correctly handles the Fbuffer_substring_no_properties which correctly handles the
@@ -261,7 +261,7 @@ index 1780194..143e784 100644
include garbage bytes when the run spans the gap position. */ include garbage bytes when the run spans the gap position. */
Lisp_Object lstr = Fbuffer_substring_no_properties ( Lisp_Object lstr = Fbuffer_substring_no_properties (
make_fixnum (pos), make_fixnum (run_end)); make_fixnum (pos), make_fixnum (run_end));
@@ -7077,7 +7227,7 @@ ns_ax_frame_for_range (struct window *w, EmacsView *view, @@ -7315,7 +7465,7 @@ ns_ax_frame_for_range (struct window *w, EmacsView *view,
return NSZeroRect; return NSZeroRect;
/* charpos_start and charpos_len are already in buffer charpos /* charpos_start and charpos_len are already in buffer charpos
@@ -270,7 +270,7 @@ index 1780194..143e784 100644
charposForAccessibilityIndex which handles invisible text. */ charposForAccessibilityIndex which handles invisible text. */
ptrdiff_t cp_start = charpos_start; ptrdiff_t cp_start = charpos_start;
ptrdiff_t cp_end = cp_start + charpos_len; ptrdiff_t cp_end = cp_start + charpos_len;
@@ -7556,6 +7706,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -7794,6 +7944,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
@synthesize cachedOverlayModiff; @synthesize cachedOverlayModiff;
@synthesize cachedTextStart; @synthesize cachedTextStart;
@synthesize cachedModiff; @synthesize cachedModiff;
@@ -278,7 +278,7 @@ index 1780194..143e784 100644
@synthesize cachedPoint; @synthesize cachedPoint;
@synthesize cachedMarkActive; @synthesize cachedMarkActive;
@synthesize cachedCompletionAnnouncement; @synthesize cachedCompletionAnnouncement;
@@ -7596,7 +7747,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -7891,7 +8042,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
NSTRACE ("EmacsAccessibilityBuffer ensureTextCache"); NSTRACE ("EmacsAccessibilityBuffer ensureTextCache");
/* This method is only called from the main thread (AX getters /* This method is only called from the main thread (AX getters
dispatch_sync to main first). Reads of cachedText/cachedTextModiff dispatch_sync to main first). Reads of cachedText/cachedTextModiff
@@ -287,7 +287,7 @@ index 1780194..143e784 100644
write section at the end needs synchronization to protect write section at the end needs synchronization to protect
against concurrent reads from AX server thread. */ against concurrent reads from AX server thread. */
eassert ([NSThread isMainThread]); eassert ([NSThread isMainThread]);
@@ -7609,16 +7760,15 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -7904,16 +8055,15 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
return; return;
ptrdiff_t modiff = BUF_MODIFF (b); ptrdiff_t modiff = BUF_MODIFF (b);
@@ -310,7 +310,7 @@ index 1780194..143e784 100644
&& cachedTextStart == BUF_BEGV (b) && cachedTextStart == BUF_BEGV (b)
&& pt >= cachedTextStart && pt >= cachedTextStart
&& (textLen == 0 && (textLen == 0
@@ -7635,7 +7785,6 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -7930,7 +8080,6 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
[cachedText release]; [cachedText release];
cachedText = [text retain]; cachedText = [text retain];
cachedTextModiff = modiff; cachedTextModiff = modiff;
@@ -318,7 +318,7 @@ index 1780194..143e784 100644
cachedTextStart = start; cachedTextStart = start;
if (visibleRuns) if (visibleRuns)
@@ -7661,7 +7810,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -7995,7 +8144,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
/* Binary search: runs are sorted by charpos (ascending). Find the /* Binary search: runs are sorted by charpos (ascending). Find the
run whose [charpos, charpos+length) range contains the target, run whose [charpos, charpos+length) range contains the target,
or the nearest run after an invisible gap. O(log n) instead of or the nearest run after an invisible gap. O(log n) instead of
@@ -327,7 +327,7 @@ index 1780194..143e784 100644
NSUInteger lo = 0, hi = visibleRunCount; NSUInteger lo = 0, hi = visibleRunCount;
while (lo < hi) while (lo < hi)
{ {
@@ -7674,7 +7823,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -8008,7 +8157,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
else else
{ {
/* Found: charpos is inside this run. Compute UTF-16 delta /* Found: charpos is inside this run. Compute UTF-16 delta
@@ -336,7 +336,7 @@ index 1780194..143e784 100644
NSUInteger chars_in = (NSUInteger)(charpos - r->charpos); NSUInteger chars_in = (NSUInteger)(charpos - r->charpos);
if (chars_in == 0 || !cachedText) if (chars_in == 0 || !cachedText)
return r->ax_start; return r->ax_start;
@@ -7699,10 +7848,10 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -8033,10 +8182,10 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
/* Convert accessibility string index to buffer charpos. /* Convert accessibility string index to buffer charpos.
Safe to call from any thread: uses only cachedText (NSString) and Safe to call from any thread: uses only cachedText (NSString) and
@@ -349,7 +349,7 @@ index 1780194..143e784 100644
@synchronized (self) @synchronized (self)
{ {
if (visibleRunCount == 0) if (visibleRunCount == 0)
@@ -7736,7 +7885,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -8070,7 +8219,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
return cp; return cp;
} }
} }
@@ -358,7 +358,7 @@ index 1780194..143e784 100644
if (lo > 0) if (lo > 0)
{ {
ns_ax_visible_run *last = &visibleRuns[visibleRunCount - 1]; ns_ax_visible_run *last = &visibleRuns[visibleRunCount - 1];
@@ -7758,7 +7907,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -8092,7 +8241,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
deadlocking the AX server thread. This is prevented by: deadlocking the AX server thread. This is prevented by:
1. validWindow checks WINDOW_LIVE_P and BUFFERP before every 1. validWindow checks WINDOW_LIVE_P and BUFFERP before every
@@ -367,16 +367,59 @@ index 1780194..143e784 100644
2. All dispatch_sync blocks run on the main thread where no 2. All dispatch_sync blocks run on the main thread where no
concurrent Lisp code can modify state between checks. concurrent Lisp code can modify state between checks.
3. block_input prevents timer events and process output from 3. block_input prevents timer events and process output from
@@ -8166,7 +8315,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -8443,7 +8592,51 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
if (idx > [cachedText length]) if (point_idx > [cachedText length])
idx = [cachedText length]; point_idx = [cachedText length];
- /* Count lines by iterating lineRangeForRange — O(lines). */ + return [self lineForAXIndex:point_idx];
+ /* Count lines by iterating lineRangeForRange --- O(lines). */ +}
NSInteger line = 0; +
NSUInteger scan = 0; +- (NSString *)accessibilityStringForRange:(NSRange)range
NSUInteger len = [cachedText length]; +{
@@ -8422,7 +8571,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, + if (![NSThread isMainThread])
+ {
+ __block NSString *result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityStringForRange:range];
+ });
+ return result;
+ }
+ [self ensureTextCache];
+ if (!cachedText || range.location + range.length > [cachedText length])
+ return @"";
+ return [cachedText substringWithRange:range];
+}
+
+- (NSAttributedString *)accessibilityAttributedStringForRange:(NSRange)range
+{
+ NSString *str = [self accessibilityStringForRange:range];
+ return [[[NSAttributedString alloc] initWithString:str] autorelease];
+}
+
+- (NSInteger)accessibilityLineForIndex:(NSInteger)index
+{
+ if (![NSThread isMainThread])
+ {
+ __block NSInteger result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityLineForIndex:index];
+ });
+ return result;
+ }
+ [self ensureTextCache];
+ if (!cachedText || index < 0)
+ return 0;
+
+ NSUInteger idx = (NSUInteger) index;
+ if (idx > [cachedText length])
+ idx = [cachedText length];
+
return [self lineForAXIndex:idx];
+
}
- (NSRange)accessibilityRangeForLine:(NSInteger)line
@@ -8667,7 +8860,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
/* =================================================================== /* ===================================================================
@@ -385,7 +428,7 @@ index 1780194..143e784 100644
These methods notify VoiceOver of text and selection changes. These methods notify VoiceOver of text and selection changes.
Called from the redisplay cycle (postAccessibilityUpdates). Called from the redisplay cycle (postAccessibilityUpdates).
@@ -8437,7 +8586,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -8682,7 +8875,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
if (point > self.cachedPoint if (point > self.cachedPoint
&& point - self.cachedPoint == 1) && point - self.cachedPoint == 1)
{ {
@@ -394,7 +437,7 @@ index 1780194..143e784 100644
[self invalidateTextCache]; [self invalidateTextCache];
[self ensureTextCache]; [self ensureTextCache];
if (cachedText) if (cachedText)
@@ -8456,7 +8605,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -8701,7 +8894,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
/* Update cachedPoint here so the selection-move branch does NOT /* Update cachedPoint here so the selection-move branch does NOT
fire for point changes caused by edits. WebKit and Chromium fire for point changes caused by edits. WebKit and Chromium
never send both ValueChanged and SelectedTextChanged for the never send both ValueChanged and SelectedTextChanged for the
@@ -403,34 +446,26 @@ index 1780194..143e784 100644
self.cachedPoint = point; self.cachedPoint = point;
NSDictionary *change = @{ NSDictionary *change = @{
@@ -8789,16 +8938,126 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -9034,14 +9227,112 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
BOOL markActive = !NILP (BVAR (b, mark_active)); BOOL markActive = !NILP (BVAR (b, mark_active));
/* --- Text changed (edit) --- */ /* --- Text changed (edit) --- */
+ ptrdiff_t chars_modiff = BUF_CHARS_MODIFF (b); + ptrdiff_t chars_modiff = BUF_CHARS_MODIFF (b);
+ BOOL textDidChange = NO;
if (modiff != self.cachedModiff) if (modiff != self.cachedModiff)
{ {
self.cachedModiff = modiff; self.cachedModiff = modiff;
- [self postTextChangedNotification:point]; - [self postTextChangedNotification:point];
+ /* Only post ValueChanged when actual characters changed. + /* Only post ValueChanged when actual characters changed.
+ Text property changes (e.g. face updates from hl-line-mode, + Text property changes (e.g. face updates from
+ vertico--prompt-selection) bump BUF_MODIFF but not + vertico--prompt-selection) bump BUF_MODIFF but not
+ BUF_CHARS_MODIFF. Posting ValueChanged for property-only + BUF_CHARS_MODIFF. Posting ValueChanged for property-only
+ changes causes VoiceOver to say "new line" when the diff + changes causes VoiceOver to say "new line" when the diff
+ is non-empty due to overlay content changes. + is non-empty due to overlay content changes. */
+
+ Use textDidChange to avoid blocking the cursor-move branch
+ below: property-only changes must not prevent
+ SelectedTextChanged from firing when point also moved
+ (e.g. hl-line-mode updates face properties on every cursor
+ movement in dired and other read-only buffers). */
+ if (chars_modiff != self.cachedCharsModiff) + if (chars_modiff != self.cachedCharsModiff)
+ { + {
+ self.cachedCharsModiff = chars_modiff; + self.cachedCharsModiff = chars_modiff;
+ self.emacsView->overlayZoomActive = NO; + self.emacsView->overlayZoomActive = NO;
+ [self postTextChangedNotification:point]; + [self postTextChangedNotification:point];
+ textDidChange = YES;
+ } + }
+ } + }
+ +
@@ -522,19 +557,11 @@ index 1780194..143e784 100644
/* --- Cursor moved or selection changed --- /* --- Cursor moved or selection changed ---
- Use 'else if' — edits and selection moves are mutually exclusive - Use 'else if' — edits and selection moves are mutually exclusive
- per the WebKit/Chromium pattern. */ + Use 'else if' --- edits and selection moves are mutually exclusive
- else if (point != self.cachedPoint || markActive != self.cachedMarkActive) per the WebKit/Chromium pattern. */
+ Skip when ValueChanged was already posted (edits and selection else if (point != self.cachedPoint || markActive != self.cachedMarkActive)
+ moves are mutually exclusive per the WebKit/Chromium pattern).
+ But DO fire when only text properties changed (BUF_MODIFF bumped
+ without BUF_CHARS_MODIFF) --- hl-line-mode and similar packages
+ update face properties on every cursor movement. */
+ if (!textDidChange
+ && (point != self.cachedPoint || markActive != self.cachedMarkActive))
{ {
ptrdiff_t oldPoint = self.cachedPoint; @@ -9211,7 +9502,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
BOOL oldMarkActive = self.cachedMarkActive;
@@ -8966,7 +9225,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
/* =================================================================== /* ===================================================================
@@ -543,7 +570,7 @@ index 1780194..143e784 100644
=================================================================== */ =================================================================== */
/* Scan visible range of window W for interactive spans. /* Scan visible range of window W for interactive spans.
@@ -9157,7 +9416,7 @@ ns_ax_scan_interactive_spans (struct window *w, @@ -9402,7 +9693,7 @@ ns_ax_scan_interactive_spans (struct window *w,
- (BOOL) isAccessibilityFocused - (BOOL) isAccessibilityFocused
{ {
/* Read the cached point stored by EmacsAccessibilityBuffer on the main /* Read the cached point stored by EmacsAccessibilityBuffer on the main
@@ -552,7 +579,7 @@ index 1780194..143e784 100644
EmacsAccessibilityBuffer *pb = self.parentBuffer; EmacsAccessibilityBuffer *pb = self.parentBuffer;
if (!pb) if (!pb)
return NO; return NO;
@@ -9174,7 +9433,7 @@ ns_ax_scan_interactive_spans (struct window *w, @@ -9419,7 +9710,7 @@ ns_ax_scan_interactive_spans (struct window *w,
dispatch_async (dispatch_get_main_queue (), ^{ dispatch_async (dispatch_get_main_queue (), ^{
/* lwin is a Lisp_Object captured by value. This is GC-safe /* lwin is a Lisp_Object captured by value. This is GC-safe
because Lisp_Objects are tagged integers/pointers that because Lisp_Objects are tagged integers/pointers that
@@ -561,7 +588,7 @@ index 1780194..143e784 100644
Emacs. The WINDOW_LIVE_P check below guards against the Emacs. The WINDOW_LIVE_P check below guards against the
window being deleted between capture and execution. */ window being deleted between capture and execution. */
if (!WINDOWP (lwin) || NILP (Fwindow_live_p (lwin))) if (!WINDOWP (lwin) || NILP (Fwindow_live_p (lwin)))
@@ -9200,7 +9459,7 @@ ns_ax_scan_interactive_spans (struct window *w, @@ -9445,7 +9736,7 @@ ns_ax_scan_interactive_spans (struct window *w,
@end @end
@@ -570,7 +597,23 @@ index 1780194..143e784 100644
Methods are kept here (same .m file) so they access the ivars Methods are kept here (same .m file) so they access the ivars
declared in the @interface ivar block. */ declared in the @interface ivar block. */
@implementation EmacsAccessibilityBuffer (InteractiveSpans) @implementation EmacsAccessibilityBuffer (InteractiveSpans)
@@ -11922,7 +12181,7 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view, @@ -10765,13 +11056,13 @@ ns_in_echo_area (void)
if (old_title == 0)
{
char *t = strdup ([[[self window] title] UTF8String]);
- char *pos = strstr (t, " — ");
+ char *pos = strstr (t, " --- ");
if (pos)
*pos = '\0';
old_title = t;
}
size_title = xmalloc (strlen (old_title) + 40);
- esprintf (size_title, "%s — (%d × %d)", old_title, cols, rows);
+ esprintf (size_title, "%s --- (%d × %d)", old_title, cols, rows);
[window setTitle: [NSString stringWithUTF8String: size_title]];
[window display];
xfree (size_title);
@@ -12167,7 +12458,7 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view,
if (WINDOW_LEAF_P (w)) if (WINDOW_LEAF_P (w))
{ {
@@ -579,7 +622,7 @@ index 1780194..143e784 100644
EmacsAccessibilityBuffer *elem EmacsAccessibilityBuffer *elem
= [existing objectForKey:[NSValue valueWithPointer:w]]; = [existing objectForKey:[NSValue valueWithPointer:w]];
if (!elem) if (!elem)
@@ -11956,7 +12215,7 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view, @@ -12201,7 +12492,7 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view,
} }
else else
{ {
@@ -588,7 +631,7 @@ index 1780194..143e784 100644
Lisp_Object child = w->contents; Lisp_Object child = w->contents;
while (!NILP (child)) while (!NILP (child))
{ {
@@ -12068,7 +12327,7 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view, @@ -12313,7 +12604,7 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view,
accessibilityUpdating = YES; accessibilityUpdating = YES;
/* Detect window tree change (split, delete, new buffer). Compare /* Detect window tree change (split, delete, new buffer). Compare
@@ -597,7 +640,7 @@ index 1780194..143e784 100644
Lisp_Object curRoot = FRAME_ROOT_WINDOW (emacsframe); Lisp_Object curRoot = FRAME_ROOT_WINDOW (emacsframe);
if (!EQ (curRoot, lastRootWindow)) if (!EQ (curRoot, lastRootWindow))
{ {
@@ -12077,12 +12336,12 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view, @@ -12322,12 +12613,12 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view,
} }
/* If tree is stale, rebuild FIRST so we don't iterate freed /* If tree is stale, rebuild FIRST so we don't iterate freed

View File

@@ -1,7 +1,8 @@
From 846e2fa3f856138127bdc7a475e066003cc76904 Mon Sep 17 00:00:00 2001 From d68d1334147a7de273e39cf26c778389faa424ad Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 16:01:29 +0100 Date: Sat, 28 Feb 2026 16:01:29 +0100
Subject: [PATCH] ns: announce child frame completion candidates for VoiceOver Subject: [PATCH 8/8] ns: announce child frame completion candidates for
VoiceOver
Completion frameworks such as Corfu, Company-box, and similar Completion frameworks such as Corfu, Company-box, and similar
render candidates in a child frame rather than as overlay strings render candidates in a child frame rather than as overlay strings
@@ -42,14 +43,14 @@ childFrameCompletionActive flag.
refocus parent buffer element when child frame closes. refocus parent buffer element when child frame closes.
--- ---
src/nsterm.h | 2 + src/nsterm.h | 2 +
src/nsterm.m | 277 ++++++++++++++++++++++++++++++++++++++++++++++++++- src/nsterm.m | 253 ++++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 276 insertions(+), 3 deletions(-) 2 files changed, 254 insertions(+), 1 deletion(-)
diff --git a/src/nsterm.h b/src/nsterm.h diff --git a/src/nsterm.h b/src/nsterm.h
index 5c15639..8b34300 100644 index a007925..1a8a84d 100644
--- a/src/nsterm.h --- a/src/nsterm.h
+++ b/src/nsterm.h +++ b/src/nsterm.h
@@ -594,6 +594,7 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType) @@ -598,6 +598,7 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType)
NSRect lastAccessibilityCursorRect; NSRect lastAccessibilityCursorRect;
BOOL overlayZoomActive; BOOL overlayZoomActive;
NSRect overlayZoomRect; NSRect overlayZoomRect;
@@ -57,7 +58,7 @@ index 5c15639..8b34300 100644
#endif #endif
BOOL font_panel_active; BOOL font_panel_active;
NSFont *font_panel_result; NSFont *font_panel_result;
@@ -657,6 +658,7 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType) @@ -661,6 +662,7 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType)
- (void)rebuildAccessibilityTree; - (void)rebuildAccessibilityTree;
- (void)invalidateAccessibilityTree; - (void)invalidateAccessibilityTree;
- (void)postAccessibilityUpdates; - (void)postAccessibilityUpdates;
@@ -66,10 +67,10 @@ index 5c15639..8b34300 100644
@end @end
diff --git a/src/nsterm.m b/src/nsterm.m diff --git a/src/nsterm.m b/src/nsterm.m
index 143e784..37918a4 100644 index ebd52c6..a7025a9 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/src/nsterm.m +++ b/src/nsterm.m
@@ -7066,6 +7066,110 @@ ns_ax_selected_overlay_text (struct buffer *b, @@ -7310,6 +7310,110 @@ ns_ax_selected_overlay_text (struct buffer *b,
} }
@@ -180,29 +181,11 @@ index 143e784..37918a4 100644
/* Build accessibility text for window W, skipping invisible text. /* Build accessibility text for window W, skipping invisible text.
Populates *OUT_START with the buffer start charpos. Populates *OUT_START with the buffer start charpos.
Populates *OUT_RUNS with an array of visible runs and *OUT_NRUNS Populates *OUT_RUNS with an array of visible runs and *OUT_NRUNS
@@ -12311,6 +12415,120 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view, @@ -12588,6 +12692,105 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view,
The existing elements carry cached state (modiff, point) from the The existing elements carry cached state (modiff, point) from the
previous redisplay cycle. Rebuilding first would create fresh previous redisplay cycle. Rebuilding first would create fresh
elements with current values, making change detection impossible. */ elements with current values, making change detection impossible. */
+ +
+/* Child frame completion dedup state. File-scope so that
+ lastChildFrameBuffer can be staticpro'd against GC. */
+static Lisp_Object lastChildFrameBuffer;
+static EMACS_INT lastChildFrameModiff;
+static char *lastChildFrameCandidate;
+
+/* Reset the re-entrance guard when unwinding past
+ postAccessibilityUpdates due to a Lisp signal (longjmp).
+ Without this, a signal during Lisp calls (e.g. Fget_char_property
+ in overlay or child frame scanning) would leave
+ accessibilityUpdating = YES permanently, suppressing all future
+ accessibility notifications. */
+static void
+ns_ax_reset_accessibility_updating (void *view)
+{
+ ((EmacsView *)view)->accessibilityUpdating = NO;
+}
+
+/* Announce the selected candidate in a child frame completion popup. +/* Announce the selected candidate in a child frame completion popup.
+ Handles Corfu, Company-box, and similar frameworks that render + Handles Corfu, Company-box, and similar frameworks that render
+ candidates in a separate child frame rather than as overlay strings + candidates in a separate child frame rather than as overlay strings
@@ -211,6 +194,10 @@ index 143e784..37918a4 100644
+ after the parent's draw_window_cursor. */ + after the parent's draw_window_cursor. */
+- (void)announceChildFrameCompletion +- (void)announceChildFrameCompletion
+{ +{
+ static char *lastCandidate;
+ static struct buffer *lastBuffer;
+ static EMACS_INT lastModiff;
+
+ /* Validate frame state --- child frames may be partially + /* Validate frame state --- child frames may be partially
+ initialized during creation. */ + initialized during creation. */
+ if (!WINDOWP (emacsframe->selected_window)) + if (!WINDOWP (emacsframe->selected_window))
@@ -225,11 +212,10 @@ index 143e784..37918a4 100644
+ also guards against re-entrance: if Lisp calls below + also guards against re-entrance: if Lisp calls below
+ trigger redisplay, the modiff check short-circuits. */ + trigger redisplay, the modiff check short-circuits. */
+ EMACS_INT modiff = BUF_MODIFF (b); + EMACS_INT modiff = BUF_MODIFF (b);
+ if (EQ (w->contents, lastChildFrameBuffer) + if (b == lastBuffer && modiff == lastModiff)
+ && modiff == lastChildFrameModiff)
+ return; + return;
+ lastChildFrameBuffer = w->contents; + lastBuffer = b;
+ lastChildFrameModiff = modiff; + lastModiff = modiff;
+ +
+ /* Skip buffers larger than a typical completion popup. + /* Skip buffers larger than a typical completion popup.
+ This avoids scanning eldoc, which-key, or other child + This avoids scanning eldoc, which-key, or other child
@@ -246,10 +232,10 @@ index 143e784..37918a4 100644
+ +
+ /* Deduplicate --- avoid re-announcing the same candidate. */ + /* Deduplicate --- avoid re-announcing the same candidate. */
+ const char *cstr = [candidate UTF8String]; + const char *cstr = [candidate UTF8String];
+ if (lastChildFrameCandidate && strcmp (cstr, lastChildFrameCandidate) == 0) + if (lastCandidate && strcmp (cstr, lastCandidate) == 0)
+ return; + return;
+ xfree (lastChildFrameCandidate); + xfree (lastCandidate);
+ lastChildFrameCandidate = xstrdup (cstr); + lastCandidate = xstrdup (cstr);
+ +
+ NSDictionary *annInfo = @{ + NSDictionary *annInfo = @{
+ NSAccessibilityAnnouncementKey: candidate, + NSAccessibilityAnnouncementKey: candidate,
@@ -301,7 +287,7 @@ index 143e784..37918a4 100644
- (void)postAccessibilityUpdates - (void)postAccessibilityUpdates
{ {
NSTRACE ("[EmacsView postAccessibilityUpdates]"); NSTRACE ("[EmacsView postAccessibilityUpdates]");
@@ -12321,10 +12539,60 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view, @@ -12598,11 +12801,59 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view,
/* Re-entrance guard: VoiceOver callbacks during notification posting /* Re-entrance guard: VoiceOver callbacks during notification posting
can trigger redisplay, which calls ns_update_end, which calls us can trigger redisplay, which calls ns_update_end, which calls us
@@ -312,16 +298,14 @@ index 143e784..37918a4 100644
if (accessibilityUpdating) if (accessibilityUpdating)
return; return;
accessibilityUpdating = YES; accessibilityUpdating = YES;
+ specpdl_ref axCount = SPECPDL_INDEX ();
+ record_unwind_protect_ptr (ns_ax_reset_accessibility_updating, self);
+
+ /* Child frame completion popup (Corfu, Company-box, etc.). + /* Child frame completion popup (Corfu, Company-box, etc.).
+ Child frames don't participate in the accessibility tree; + Child frames don't participate in the accessibility tree;
+ announce the selected candidate directly. */ + announce the selected candidate directly. */
+ if (FRAME_PARENT_FRAME (emacsframe)) + if (FRAME_PARENT_FRAME (emacsframe))
+ { + {
+ [self announceChildFrameCompletion]; + [self announceChildFrameCompletion];
+ unbind_to (axCount, Qnil); + accessibilityUpdating = NO;
+ return; + return;
+ } + }
+ +
@@ -360,37 +344,10 @@ index 143e784..37918a4 100644
+ NSAccessibilityFocusedUIElementChangedNotification); + NSAccessibilityFocusedUIElementChangedNotification);
+ } + }
+ } + }
+
/* Detect window tree change (split, delete, new buffer). Compare /* Detect window tree change (split, delete, new buffer). Compare
FRAME_ROOT_WINDOW --- if it changed, the tree structure changed. */ FRAME_ROOT_WINDOW --- if it changed, the tree structure changed. */
@@ -12355,7 +12623,7 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view, Lisp_Object curRoot = FRAME_ROOT_WINDOW (emacsframe);
NSAccessibilityFocusedUIElementChangedNotification);
lastSelectedWindow = emacsframe->selected_window;
- accessibilityUpdating = NO;
+ unbind_to (axCount, Qnil);
return;
}
@@ -12399,7 +12667,7 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view,
NSAccessibilityFocusedUIElementChangedNotification);
}
- accessibilityUpdating = NO;
+ unbind_to (axCount, Qnil);
}
/* ---- Cursor position for Zoom (via accessibilityBoundsForRange:) ----
@@ -14341,6 +14609,9 @@ syms_of_nsterm (void)
DEFSYM (Qns_ax_completion, "completion");
DEFSYM (Qns_ax_completions_highlight, "completions-highlight");
DEFSYM (Qns_ax_backtab, "backtab");
+
+ lastChildFrameBuffer = Qnil;
+ staticpro (&lastChildFrameBuffer);
/* Qmouse_face and Qkeymap are defined in textprop.c / keymap.c. */
Fput (Qalt, Qmodifier_value, make_fixnum (alt_modifier));
Fput (Qhyper, Qmodifier_value, make_fixnum (hyper_modifier));
-- --
2.43.0 2.43.0

View File

@@ -1,266 +0,0 @@
From 8dc935c6d88c129dcf4394c0ef4dc070ffc9aac8 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 21:15:04 +0100
Subject: [PATCH] =?UTF-8?q?ns:=20fix=20O(n)=20line=20scanning=20in=20acces?=
=?UTF-8?q?sibility=20=E2=80=94=20use=20precomputed=20line=20index?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
accessibilityLineForIndex: and accessibilityRangeForLine: scanned
from position 0 using lineRangeForRange: in a loop, making them
O(L) where L is the line number. At line 50,000 this caused
VoiceOver to stall (~150,000 iterations per cursor move via
postFocusedCursorNotification: which calls these 3 times).
Build a precomputed lineStartOffsets array in ensureTextCache,
populated once per cache rebuild (O(L) amortized). Line queries
now use binary search: O(log L).
* src/nsterm.h (EmacsAccessibilityBuffer): Add lineStartOffsets
and lineCount ivars. Add lineForAXIndex: and
rangeForLine:textLength: method declarations.
* src/nsterm.m (lineForAXIndex:): New method. Binary search
over lineStartOffsets for O(log L) line lookup.
(rangeForLine:textLength:): New method. O(1) range lookup
using lineStartOffsets.
(ensureTextCache): Build lineStartOffsets after setting
cachedText.
(invalidateTextCache): Free lineStartOffsets.
(dealloc): Free lineStartOffsets.
(accessibilityInsertionPointLineNumber): Delegate to
lineForAXIndex: instead of linear scan.
(accessibilityLineForIndex:): Likewise.
(accessibilityRangeForLine:): Delegate to rangeForLine:textLength:
instead of linear scan.
Performance: cursor movement at line 50,000 goes from ~150,000
iterations to ~51 (3 × log2(50000) ≈ 3 × 17).
---
src/nsterm.h | 4 ++
src/nsterm.m | 148 ++++++++++++++++++++++++++++++++++-----------------
2 files changed, 103 insertions(+), 49 deletions(-)
diff --git a/src/nsterm.h b/src/nsterm.h
index 8b34300..1a8a84d 100644
--- a/src/nsterm.h
+++ b/src/nsterm.h
@@ -499,6 +499,8 @@ typedef struct ns_ax_visible_run
{
ns_ax_visible_run *visibleRuns;
NSUInteger visibleRunCount;
+ NSUInteger *lineStartOffsets; /* AX string index of each line start. */
+ NSUInteger lineCount; /* Number of entries in lineStartOffsets. */
NSMutableArray *cachedInteractiveSpans;
BOOL interactiveSpansDirty;
}
@@ -515,6 +517,8 @@ typedef struct ns_ax_visible_run
@property (nonatomic, assign) ptrdiff_t cachedCompletionOverlayEnd;
@property (nonatomic, assign) ptrdiff_t cachedCompletionPoint;
- (void)invalidateTextCache;
+- (NSInteger)lineForAXIndex:(NSUInteger)idx;
+- (NSRange)rangeForLine:(NSUInteger)line textLength:(NSUInteger)tlen;
- (ptrdiff_t)charposForAccessibilityIndex:(NSUInteger)ax_idx;
- (NSUInteger)accessibilityIndexForCharpos:(ptrdiff_t)charpos;
@end
diff --git a/src/nsterm.m b/src/nsterm.m
index 2e7d776..057ebe7 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -7825,6 +7825,8 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
[cachedInteractiveSpans release];
if (visibleRuns)
xfree (visibleRuns);
+ if (lineStartOffsets)
+ xfree (lineStartOffsets);
[super dealloc];
}
@@ -7842,10 +7844,65 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
visibleRuns = NULL;
}
visibleRunCount = 0;
+ if (lineStartOffsets)
+ {
+ xfree (lineStartOffsets);
+ lineStartOffsets = NULL;
+ }
+ lineCount = 0;
}
[self invalidateInteractiveSpans];
}
+/* ---- Line index helpers ---- */
+
+/* Return the line number for AX string index IDX using the
+ precomputed lineStartOffsets array. Binary search: O(log L)
+ where L is the number of lines in the cached text.
+
+ lineStartOffsets[i] holds the AX string index where line i
+ begins. Built once per cache rebuild in ensureTextCache. */
+- (NSInteger)lineForAXIndex:(NSUInteger)idx
+{
+ @synchronized (self)
+ {
+ if (!lineStartOffsets || lineCount == 0)
+ return 0;
+
+ /* Binary search for the largest line whose start offset <= idx. */
+ NSUInteger lo = 0, hi = lineCount;
+ while (lo < hi)
+ {
+ NSUInteger mid = lo + (hi - lo) / 2;
+ if (lineStartOffsets[mid] <= idx)
+ lo = mid + 1;
+ else
+ hi = mid;
+ }
+ return (NSInteger) (lo > 0 ? lo - 1 : 0);
+ }
+}
+
+/* Return the AX string range for LINE using the precomputed
+ lineStartOffsets array. O(1) lookup. */
+- (NSRange)rangeForLine:(NSUInteger)line textLength:(NSUInteger)tlen
+{
+ @synchronized (self)
+ {
+ if (!lineStartOffsets || lineCount == 0)
+ return NSMakeRange (NSNotFound, 0);
+
+ if (line >= lineCount)
+ return NSMakeRange (NSNotFound, 0);
+
+ NSUInteger start = lineStartOffsets[line];
+ NSUInteger end = (line + 1 < lineCount)
+ ? lineStartOffsets[line + 1]
+ : tlen;
+ return NSMakeRange (start, end - start);
+ }
+}
+
- (void)ensureTextCache
{
NSTRACE ("EmacsAccessibilityBuffer ensureTextCache");
@@ -7895,6 +7952,45 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
xfree (visibleRuns);
visibleRuns = runs;
visibleRunCount = nruns;
+
+ /* Build line-start index for O(log L) line queries.
+ Walk the cached text once, recording the start offset
+ of each line. This runs once per cache rebuild (on text
+ change or narrowing), not per cursor move. */
+ if (lineStartOffsets)
+ xfree (lineStartOffsets);
+ lineStartOffsets = NULL;
+ lineCount = 0;
+
+ NSUInteger tlen = [cachedText length];
+ if (tlen > 0)
+ {
+ NSUInteger cap = 256;
+ lineStartOffsets = xmalloc (cap * sizeof (NSUInteger));
+ lineStartOffsets[0] = 0;
+ lineCount = 1;
+ NSUInteger pos = 0;
+ while (pos < tlen)
+ {
+ NSRange lr = [cachedText lineRangeForRange:
+ NSMakeRange (pos, 0)];
+ NSUInteger next = NSMaxRange (lr);
+ if (next <= pos)
+ break; /* safety */
+ if (next < tlen)
+ {
+ if (lineCount >= cap)
+ {
+ cap *= 2;
+ lineStartOffsets = xrealloc (lineStartOffsets,
+ cap * sizeof (NSUInteger));
+ }
+ lineStartOffsets[lineCount] = next;
+ lineCount++;
+ }
+ pos = next;
+ }
+ }
}
}
@@ -8362,21 +8458,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
if (point_idx > [cachedText length])
point_idx = [cachedText length];
- /* Count lines by iterating lineRangeForRange from the start.
- Each call jumps an entire line — O(lines) not O(chars). */
- NSInteger line = 0;
- NSUInteger scan = 0;
- NSUInteger len = [cachedText length];
- while (scan < point_idx && scan < len)
- {
- NSRange lr = [cachedText lineRangeForRange:NSMakeRange (scan, 0)];
- NSUInteger next = NSMaxRange (lr);
- if (next <= scan) break; /* safety */
- if (next > point_idx) break;
- line++;
- scan = next;
- }
- return line;
+ return [self lineForAXIndex:point_idx];
}
- (NSString *)accessibilityStringForRange:(NSRange)range
@@ -8419,20 +8501,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
if (idx > [cachedText length])
idx = [cachedText length];
- /* Count lines by iterating lineRangeForRange --- O(lines). */
- NSInteger line = 0;
- NSUInteger scan = 0;
- NSUInteger len = [cachedText length];
- while (scan < idx && scan < len)
- {
- NSRange lr = [cachedText lineRangeForRange:NSMakeRange (scan, 0)];
- NSUInteger next = NSMaxRange (lr);
- if (next <= scan) break;
- if (next > idx) break;
- line++;
- scan = next;
- }
- return line;
+ return [self lineForAXIndex:idx];
}
- (NSRange)accessibilityRangeForLine:(NSInteger)line
@@ -8454,26 +8523,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
return (line == 0) ? NSMakeRange (0, 0)
: NSMakeRange (NSNotFound, 0);
- /* Skip to the requested line using lineRangeForRange — O(lines)
- not O(chars), consistent with accessibilityLineForIndex:. */
- NSInteger cur_line = 0;
- NSUInteger scan = 0;
- while (cur_line < line && scan < len)
- {
- NSRange lr = [cachedText lineRangeForRange:NSMakeRange (scan, 0)];
- NSUInteger next = NSMaxRange (lr);
- if (next <= scan) break; /* safety */
- cur_line++;
- scan = next;
- }
- if (cur_line != line)
- return NSMakeRange (NSNotFound, 0);
-
- /* Return the range of the target line. */
- if (scan >= len)
- return NSMakeRange (len, 0); /* phantom line after final newline */
- NSRange lr = [cachedText lineRangeForRange:NSMakeRange (scan, 0)];
- return lr;
+ return [self rangeForLine:(NSUInteger)line textLength:len];
}
- (NSRange)accessibilityRangeForIndex:(NSInteger)index
--
2.43.0

View File

@@ -326,9 +326,12 @@ TEXT CACHE AND VISIBLE RUNS
narrowing/widening is detected by comparing cachedTextStart narrowing/widening is detected by comparing cachedTextStart
against BUF_BEGV — these operations change the visible region against BUF_BEGV — these operations change the visible region
without bumping either modiff counter. The cache is also without bumping either modiff counter. The cache is also
invalidated when the window tree is rebuilt. NS_AX_TEXT_CAP = 100,000 invalidated when the window tree is rebuilt.
UTF-16 units (~200 KB) caps total exposure; buffers larger than
~50,000 lines are truncated for accessibility purposes. There is no character cap on the accessibility text. The entire
visible (non-invisible) buffer content is exposed to VoiceOver.
Users who do not need accessibility can set ns-accessibility-enabled
to nil for zero overhead.
A lineStartOffsets array is built during each cache rebuild, A lineStartOffsets array is built during each cache rebuild,
recording the AX string index where each line begins. This recording the AX string index where each line begins. This
@@ -651,14 +654,9 @@ KNOWN LIMITATIONS
produce incomplete or garbled accessibility text. produce incomplete or garbled accessibility text.
- Line counting (accessibilityInsertionPointLineNumber, - Line counting (accessibilityInsertionPointLineNumber,
accessibilityLineForIndex:) was O(lines) in patches 1-5. accessibilityLineForIndex:) uses a precomputed lineStartOffsets
Patch 0009 adds a precomputed lineStartOffsets array built array built once per cache rebuild. Queries are O(log L) via
once per cache rebuild; queries are now O(log L) via binary binary search.
search.
- Buffers larger than NS_AX_TEXT_CAP (100,000 UTF-16 units) are
truncated. The truncation is silent; AT tools navigating past the
truncation boundary may behave unexpectedly.
- No multi-frame coordination. EmacsView.accessibilityElements is - No multi-frame coordination. EmacsView.accessibilityElements is
per-view; there is no cross-frame notification ordering. per-view; there is no cross-frame notification ordering.
@@ -752,7 +750,7 @@ TESTING CHECKLIST
*Completions*, Tab to a candidate, Enter to execute, then *Completions*, Tab to a candidate, Enter to execute, then
C-x o to switch windows. Emacs must not hang. C-x o to switch windows. Emacs must not hang.
Stress test (patch 0009 line index): Stress test (line index):
25. Open a large file (>50,000 lines). Navigate to the end with 25. Open a large file (>50,000 lines). Navigate to the end with
M-> or C-v repeatedly. VoiceOver speech should remain fluid M-> or C-v repeatedly. VoiceOver speech should remain fluid
at all positions (no progressive slowdown). at all positions (no progressive slowdown).