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.
267 lines
8.5 KiB
Diff
267 lines
8.5 KiB
Diff
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
|
||
|