From 419762bde0e3dadb98d8932b26691c73311d386c Mon Sep 17 00:00:00 2001 From: Daneel Date: Sat, 28 Feb 2026 21:16:16 +0100 Subject: [PATCH] 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. --- ...x-O-n-line-scanning-in-accessibility.patch | 266 ++++++++++++++++++ patches/README.txt | 25 +- 2 files changed, 282 insertions(+), 9 deletions(-) create mode 100644 patches/0009-ns-fix-O-n-line-scanning-in-accessibility.patch diff --git a/patches/0009-ns-fix-O-n-line-scanning-in-accessibility.patch b/patches/0009-ns-fix-O-n-line-scanning-in-accessibility.patch new file mode 100644 index 0000000..a6ccf24 --- /dev/null +++ b/patches/0009-ns-fix-O-n-line-scanning-in-accessibility.patch @@ -0,0 +1,266 @@ +From 8dc935c6d88c129dcf4394c0ef4dc070ffc9aac8 Mon Sep 17 00:00:00 2001 +From: Martin Sukany +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 + diff --git a/patches/README.txt b/patches/README.txt index 85484ef..3466f66 100644 --- a/patches/README.txt +++ b/patches/README.txt @@ -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.