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