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
0007 ns: announce overlay 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
@@ -327,8 +328,13 @@ TEXT CACHE AND VISIBLE RUNS
without bumping either modiff counter. The cache is also
invalidated when the window tree is rebuilt. NS_AX_TEXT_CAP = 100,000
UTF-16 units (~200 KB) caps total exposure; buffers larger than
~50,000 lines are truncated for accessibility purposes. VoiceOver
performance degrades noticeably beyond this threshold.
~50,000 lines are truncated for accessibility purposes.
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
@@ -645,10 +651,10 @@ KNOWN LIMITATIONS
produce incomplete or garbled accessibility text.
- Line counting (accessibilityInsertionPointLineNumber,
accessibilityLineForIndex:) uses O(lines) iteration via
lineRangeForRange. For buffers with tens of thousands of visible
lines this is acceptable but not optimal. A line-number cache
keyed on cachedTextModiff could reduce this to O(1).
accessibilityLineForIndex:) was O(lines) in patches 1-5.
Patch 0009 adds a precomputed lineStartOffsets array built
once per cache rebuild; queries are now O(log L) via binary
search.
- Buffers larger than NS_AX_TEXT_CAP (100,000 UTF-16 units) are
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
C-x o to switch windows. Emacs must not hang.
Stress test:
25. Open a large file (>5000 lines). Navigate with C-v / M-v.
Verify no significant lag in VoiceOver speech response.
Stress test (patch 0009 line index):
25. Open a large file (>50,000 lines). Navigate to the end with
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
folded (invisible) text is not announced during navigation.