patches: add 0009 line index perf fix, update README.txt

New patch 0009 fixes O(L) line scanning in accessibilityLineForIndex:
and accessibilityRangeForLine: by adding a precomputed lineStartOffsets
array built once per cache rebuild.  Queries go from O(L) linear scan
to O(log L) binary search.

README.txt: updated patch listing, text cache section, known limitations
(O(L) issue now resolved), stress test threshold raised to 50,000 lines.
This commit is contained in:
2026-02-28 21:16:16 +01:00
parent 3abc7c9745
commit 419762bde0
2 changed files with 282 additions and 9 deletions

View File

@@ -0,0 +1,266 @@
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

@@ -19,6 +19,7 @@ PATCH SERIES
0006 doc: add VoiceOver accessibility section to macOS appendix 0006 doc: add VoiceOver accessibility section to macOS appendix
0007 ns: announce overlay completion candidates for VoiceOver 0007 ns: announce overlay completion candidates for VoiceOver
0008 ns: announce child frame completion candidates for VoiceOver 0008 ns: announce child frame completion candidates for VoiceOver
0009 Performance: precomputed line index for O(log L) line queries
OVERVIEW OVERVIEW
@@ -327,8 +328,13 @@ TEXT CACHE AND VISIBLE RUNS
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. NS_AX_TEXT_CAP = 100,000
UTF-16 units (~200 KB) caps total exposure; buffers larger than UTF-16 units (~200 KB) caps total exposure; buffers larger than
~50,000 lines are truncated for accessibility purposes. VoiceOver ~50,000 lines are truncated for accessibility purposes.
performance degrades noticeably beyond this threshold.
A lineStartOffsets array is built during each cache rebuild,
recording the AX string index where each line begins. This
makes accessibilityLineForIndex: and accessibilityRangeForLine:
O(log L) via binary search instead of O(L) linear scanning.
The index is freed and rebuilt alongside the text cache.
COMPLETION ANNOUNCEMENTS COMPLETION ANNOUNCEMENTS
@@ -645,10 +651,10 @@ KNOWN LIMITATIONS
produce incomplete or garbled accessibility text. produce incomplete or garbled accessibility text.
- Line counting (accessibilityInsertionPointLineNumber, - Line counting (accessibilityInsertionPointLineNumber,
accessibilityLineForIndex:) uses O(lines) iteration via accessibilityLineForIndex:) was O(lines) in patches 1-5.
lineRangeForRange. For buffers with tens of thousands of visible Patch 0009 adds a precomputed lineStartOffsets array built
lines this is acceptable but not optimal. A line-number cache once per cache rebuild; queries are now O(log L) via binary
keyed on cachedTextModiff could reduce this to O(1). search.
- Buffers larger than NS_AX_TEXT_CAP (100,000 UTF-16 units) are - Buffers larger than NS_AX_TEXT_CAP (100,000 UTF-16 units) are
truncated. The truncation is silent; AT tools navigating past the truncated. The truncation is silent; AT tools navigating past the
@@ -746,9 +752,10 @@ 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: Stress test (patch 0009 line index):
25. Open a large file (>5000 lines). Navigate with C-v / M-v. 25. Open a large file (>50,000 lines). Navigate to the end with
Verify no significant lag in VoiceOver speech response. M-> or C-v repeatedly. VoiceOver speech should remain fluid
at all positions (no progressive slowdown).
26. Open an org-mode file with many folded sections. Verify that 26. Open an org-mode file with many folded sections. Verify that
folded (invisible) text is not announced during navigation. folded (invisible) text is not announced during navigation.