patches: fix Blocker #2 - remove ensureTextCache from notification path

BUF_MODIFF was needed for fold/unfold correctness (org-mode), but
ensureTextCache was called from postAccessibilityNotificationsForFrame:
on every cursor move, causing O(buffer-size) rebuild on every font-lock
pass.

Fix: remove [self ensureTextCache] from the granularity-detection branch
of the notification path.  The granularity detection now uses cachedText
directly, falling back to granularity_unknown when absent (safe: VoiceOver
makes its own determination).  ensureTextCache is called exclusively from
AX getters (human interaction speed).  Font-lock passes no longer trigger
any cache rebuild.

The BUF_MODIFF validity in ensureTextCache is retained (correct for
fold/unfold). Comment updated to accurately describe the calling pattern.

All 9 patches apply cleanly on fresh base (git apply verified).
This commit is contained in:
2026-03-03 18:26:17 +01:00
parent 70f0cb9a86
commit f42e799991
2 changed files with 79 additions and 57 deletions

View File

@@ -1,4 +1,4 @@
From 7474a4e1ddbf37286842e3beda1810c40f2a3ef7 Mon Sep 17 00:00:00 2001
From 217177caefc709c37ae04732ec595ace903e4cc4 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Mon, 2 Mar 2026 18:39:46 +0100
Subject: [PATCH 8/9] ns: announce overlay completion candidates for VoiceOver
@@ -11,15 +11,24 @@ this change VoiceOver cannot read overlay-based completion UIs.
'current', 'selected', 'selection' in face symbol names.
(ns_ax_selected_overlay_text): New function; scan overlay strings in
the window for a line with a selected face; return its text.
(EmacsAccessibilityBuffer(Notifications)
postAccessibilityNotificationsForFrame:): Handle BUF_OVERLAY_MODIFF
(ensureTextCache): Switch cache-validity counter from BUF_CHARS_MODIFF
to BUF_MODIFF. Fold/unfold commands (org-mode, outline-mode,
hideshow-mode) change the 'invisible text property via
`put-text-property', which bumps BUF_MODIFF but not BUF_CHARS_MODIFF.
Using BUF_CHARS_MODIFF would serve stale AX text across fold/unfold.
The rebuild is O(visible-buffer-text) but ensureTextCache is called
exclusively from AX getters at human interaction speed, never from the
redisplay notification path; font-lock passes cause zero rebuild cost.
(postAccessibilityNotificationsForFrame:): Handle BUF_OVERLAY_MODIFF
changes independently of text changes. Use BUF_CHARS_MODIFF to gate
ValueChanged; keep overlay_modiff out of ensureTextCache to prevent a
race where an AX query consumes the change before notification.
ValueChanged. Do not call ensureTextCache from the cursor-moved branch:
the granularity detection uses cachedText directly (falling back to
granularity_unknown when the cache is absent), so font-lock passes
cannot trigger O(buffer-size) rebuilds via the notification path.
---
src/nsterm.h | 1 +
src/nsterm.m | 345 ++++++++++++++++++++++++++++++++++++++++++++-------
2 files changed, 304 insertions(+), 42 deletions(-)
src/nsterm.m | 358 ++++++++++++++++++++++++++++++++++++++++++++-------
2 files changed, 316 insertions(+), 43 deletions(-)
diff --git a/src/nsterm.h b/src/nsterm.h
index f245675513..a210ceba14 100644
@@ -34,7 +43,7 @@ index f245675513..a210ceba14 100644
@property (nonatomic, assign) BOOL cachedMarkActive;
@property (nonatomic, copy) NSString *cachedCompletionAnnouncement;
diff --git a/src/nsterm.m b/src/nsterm.m
index a0419bb5df..54cee74401 100644
index a0419bb5df..b9d3a0eb53 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -7263,11 +7263,154 @@ Accessibility virtual elements (macOS / Cocoa only)
@@ -228,7 +237,7 @@ index a0419bb5df..54cee74401 100644
write section at the end needs synchronization to protect
against concurrent reads from AX server thread. */
eassert ([NSThread isMainThread]);
@@ -8005,25 +8149,34 @@ - (void)ensureTextCache
@@ -8005,25 +8149,38 @@ - (void)ensureTextCache
if (!b)
return;
@@ -259,12 +268,16 @@ index a0419bb5df..54cee74401 100644
+ as if it were visible, or miss newly revealed content entirely.
+
+ BUF_MODIFF is bumped by all buffer modifications including
+ text-property changes (e.g. font-lock face assignments), causing a
+ full text-cache rebuild on each redisplay cycle. This is acceptable
+ because `ensureTextCache' is only called when VoiceOver queries
+ accessibilityValue or related AX properties --- which happens at
+ human interaction speed, not at redisplay speed. The per-rebuild
+ cost is O(visible-buffer-text).
+ text-property changes (e.g. font-lock face assignments). The
+ per-rebuild cost is O(visible-buffer-text), but `ensureTextCache'
+ is called exclusively from AX getters (accessibilityValue,
+ accessibilitySelectedTextRange, etc.) which run at human interaction
+ speed --- not from the redisplay notification path. Font-lock
+ passes do not call this method, so the rebuild cost per font-lock
+ cycle is zero. The redisplay notification path (postAccessibility-
+ NotificationsForFrame:) uses cachedText directly without calling
+ ensureTextCache; granularity detection falls back gracefully when
+ the cache is absent.
+
+ Do NOT use BUF_OVERLAY_MODIFF alone: org-mode >= 29 (org-fold-core)
+ uses text properties, not overlays, for folding, so
@@ -280,7 +293,7 @@ index a0419bb5df..54cee74401 100644
&& cachedTextStart == BUF_BEGV (b)
&& pt >= cachedTextStart
&& (textLen == 0
@@ -8039,7 +8192,7 @@ included in the cached AX text (it is handled separately via
@@ -8039,7 +8196,7 @@ included in the cached AX text (it is handled separately via
{
[cachedText release];
cachedText = [text retain];
@@ -289,7 +302,7 @@ index a0419bb5df..54cee74401 100644
cachedTextStart = start;
if (visibleRuns)
@@ -8051,9 +8204,9 @@ included in the cached AX text (it is handled separately via
@@ -8051,9 +8208,9 @@ included in the cached AX text (it is handled separately via
Walk the cached text once, recording the start offset of each
line. Uses NSString lineRangeForRange: --- O(N) in the total
text --- but this loop runs only on cache rebuild, which is
@@ -302,7 +315,7 @@ index a0419bb5df..54cee74401 100644
enters this code. */
if (lineStartOffsets)
xfree (lineStartOffsets);
@@ -8108,7 +8261,7 @@ - (NSUInteger)accessibilityIndexForCharpos:(ptrdiff_t)charpos
@@ -8108,7 +8265,7 @@ - (NSUInteger)accessibilityIndexForCharpos:(ptrdiff_t)charpos
/* Binary search: runs are sorted by charpos (ascending). Find the
run whose [charpos, charpos+length) range contains the target,
or the nearest run after an invisible gap. O(log n) instead of
@@ -311,7 +324,7 @@ index a0419bb5df..54cee74401 100644
NSUInteger lo = 0, hi = visibleRunCount;
while (lo < hi)
{
@@ -8157,10 +8310,10 @@ by run length (visible window), not total buffer size. */
@@ -8157,10 +8314,10 @@ by run length (visible window), not total buffer size. */
/* Convert accessibility string index to buffer charpos.
Safe to call from any thread: uses only cachedText (NSString) and
@@ -324,7 +337,7 @@ index a0419bb5df..54cee74401 100644
@synchronized (self)
{
if (visibleRunCount == 0)
@@ -8202,7 +8355,7 @@ the slow path (composed character sequence walk), which is
@@ -8202,7 +8359,7 @@ the slow path (composed character sequence walk), which is
return cp;
}
}
@@ -333,7 +346,7 @@ index a0419bb5df..54cee74401 100644
if (lo > 0)
{
ns_ax_visible_run *last = &visibleRuns[visibleRunCount - 1];
@@ -8224,7 +8377,7 @@ the slow path (composed character sequence walk), which is
@@ -8224,7 +8381,7 @@ the slow path (composed character sequence walk), which is
deadlocking the AX server thread. This is prevented by:
1. validWindow checks WINDOW_LIVE_P and BUFFERP before every
@@ -342,7 +355,7 @@ index a0419bb5df..54cee74401 100644
2. All dispatch_sync blocks run on the main thread where no
concurrent Lisp code can modify state between checks.
3. block_input prevents timer events and process output from
@@ -8570,6 +8723,50 @@ - (NSInteger)accessibilityInsertionPointLineNumber
@@ -8570,6 +8727,50 @@ - (NSInteger)accessibilityInsertionPointLineNumber
return [self lineForAXIndex:point_idx];
}
@@ -393,7 +406,7 @@ index a0419bb5df..54cee74401 100644
- (NSRange)accessibilityRangeForLine:(NSInteger)line
{
if (![NSThread isMainThread])
@@ -8792,7 +8989,7 @@ - (NSRect)accessibilityFrame
@@ -8792,7 +8993,7 @@ - (NSRect)accessibilityFrame
/* ===================================================================
@@ -402,7 +415,7 @@ index a0419bb5df..54cee74401 100644
These methods notify VoiceOver of text and selection changes.
Called from the redisplay cycle (postAccessibilityUpdates).
@@ -8807,7 +9004,7 @@ - (void)postTextChangedNotification:(ptrdiff_t)point
@@ -8807,7 +9008,7 @@ - (void)postTextChangedNotification:(ptrdiff_t)point
if (point > self.cachedPoint
&& point - self.cachedPoint == 1)
{
@@ -411,7 +424,7 @@ index a0419bb5df..54cee74401 100644
[self invalidateTextCache];
[self ensureTextCache];
if (cachedText)
@@ -8826,7 +9023,7 @@ - (void)postTextChangedNotification:(ptrdiff_t)point
@@ -8826,7 +9027,7 @@ - (void)postTextChangedNotification:(ptrdiff_t)point
/* Update cachedPoint here so the selection-move branch does NOT
fire for point changes caused by edits. WebKit and Chromium
never send both ValueChanged and SelectedTextChanged for the
@@ -420,7 +433,7 @@ index a0419bb5df..54cee74401 100644
self.cachedPoint = point;
NSDictionary *change = @{
@@ -9220,16 +9417,80 @@ - (void)postAccessibilityNotificationsForFrame:(struct frame *)f
@@ -9220,16 +9421,80 @@ - (void)postAccessibilityNotificationsForFrame:(struct frame *)f
BOOL markActive = !NILP (BVAR (b, mark_active));
/* --- Text changed (edit) --- */
@@ -505,7 +518,24 @@ index a0419bb5df..54cee74401 100644
{
ptrdiff_t oldPoint = self.cachedPoint;
BOOL oldMarkActive = self.cachedMarkActive;
@@ -12408,7 +12669,7 @@ - (int) fullscreenState
@@ -9247,8 +9512,15 @@ - (void)postAccessibilityNotificationsForFrame:(struct frame *)f
bool isCtrlNP = ns_ax_event_is_line_nav_key (&ctrlNP);
/* --- Granularity detection --- */
+ /* Use cached text as-is; do NOT call ensureTextCache here.
+ ensureTextCache is O(visible-buffer-text) and must not run on
+ every redisplay cycle. Using stale cached text for granularity
+ classification is safe: the worst case is an incorrect
+ granularity hint (defaulting to unknown), which causes VoiceOver
+ to make its own determination. Fresh text is always available
+ to VoiceOver via the AX getter path (accessibilityValue etc.). */
NSInteger granularity = ns_ax_text_selection_granularity_unknown;
- [self ensureTextCache];
+ BOOL singleLineMove = NO;
if (cachedText && oldPoint > 0)
{
NSUInteger tlen = [cachedText length];
@@ -12408,7 +12680,7 @@ - (int) fullscreenState
if (WINDOW_LEAF_P (w))
{
@@ -514,7 +544,7 @@ index a0419bb5df..54cee74401 100644
EmacsAccessibilityBuffer *elem
= [existing objectForKey:[NSValue valueWithPointer:w]];
if (!elem)
@@ -12442,7 +12703,7 @@ - (int) fullscreenState
@@ -12442,7 +12714,7 @@ - (int) fullscreenState
}
else
{
@@ -523,7 +553,7 @@ index a0419bb5df..54cee74401 100644
Lisp_Object child = w->contents;
while (!NILP (child))
{
@@ -12554,7 +12815,7 @@ - (void)postAccessibilityUpdates
@@ -12554,7 +12826,7 @@ - (void)postAccessibilityUpdates
accessibilityUpdating = YES;
/* Detect window tree change (split, delete, new buffer). Compare
@@ -532,7 +562,7 @@ index a0419bb5df..54cee74401 100644
Lisp_Object curRoot = FRAME_ROOT_WINDOW (emacsframe);
if (!EQ (curRoot, lastRootWindow))
{
@@ -12563,12 +12824,12 @@ - (void)postAccessibilityUpdates
@@ -12563,12 +12835,12 @@ - (void)postAccessibilityUpdates
}
/* If tree is stale, rebuild FIRST so we don't iterate freed

View File

@@ -1,4 +1,4 @@
From 137cb30bb546a9599983c25a9873d1518ad8edee Mon Sep 17 00:00:00 2001
From b54ed57b93cb47250695106021d6e96030ffdd59 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Mon, 2 Mar 2026 18:49:13 +0100
Subject: [PATCH 9/9] ns: announce child frame completion candidates for
@@ -37,8 +37,8 @@ Remove Zoom section (covered by patch 0000). Fix dangling paragraph.
doc/emacs/macos.texi | 13 +-
etc/NEWS | 25 +-
src/nsterm.h | 21 ++
src/nsterm.m | 561 +++++++++++++++++++++++++++++++++++++------
4 files changed, 529 insertions(+), 91 deletions(-)
src/nsterm.m | 560 +++++++++++++++++++++++++++++++++++++------
4 files changed, 528 insertions(+), 91 deletions(-)
diff --git a/doc/emacs/macos.texi b/doc/emacs/macos.texi
index 72ac3a9aa9..cf5ed0ff28 100644
@@ -160,7 +160,7 @@ index a210ceba14..2edd7cd6e0 100644
@end
diff --git a/src/nsterm.m b/src/nsterm.m
index 54cee74401..6ba2229639 100644
index b9d3a0eb53..5e48710930 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -1275,6 +1275,12 @@ If a completion candidate is selected (overlay or child frame),
@@ -309,7 +309,7 @@ index 54cee74401..6ba2229639 100644
if (b != current_buffer)
set_buffer_internal_1 (b);
@@ -8605,6 +8726,11 @@ - (void)setAccessibilitySelectedTextRange:(NSRange)range
@@ -8609,6 +8730,11 @@ - (void)setAccessibilitySelectedTextRange:(NSRange)range
[self ensureTextCache];
@@ -321,7 +321,7 @@ index 54cee74401..6ba2229639 100644
specpdl_ref count = SPECPDL_INDEX ();
record_unwind_current_buffer ();
/* Ensure block_input is always matched by unblock_input even if
@@ -9053,20 +9179,38 @@ - (void)postFocusedCursorNotification:(ptrdiff_t)point
@@ -9057,20 +9183,38 @@ - (void)postFocusedCursorNotification:(ptrdiff_t)point
&& granularity
== ns_ax_text_selection_granularity_character);
@@ -370,7 +370,7 @@ index 54cee74401..6ba2229639 100644
ns_ax_post_notification_with_info (
self,
NSAccessibilitySelectedTextChangedNotification,
@@ -9166,12 +9310,17 @@ user expectation ("w" jumps to next word and reads it). */
@@ -9170,12 +9314,17 @@ user expectation ("w" jumps to next word and reads it). */
}
}
@@ -393,7 +393,7 @@ index 54cee74401..6ba2229639 100644
if (cachedText
&& granularity == ns_ax_text_selection_granularity_line)
{
@@ -9236,6 +9385,11 @@ - (void)postCompletionAnnouncementForBuffer:(struct buffer *)b
@@ -9240,6 +9389,11 @@ - (void)postCompletionAnnouncementForBuffer:(struct buffer *)b
block_input ();
specpdl_ref count2 = SPECPDL_INDEX ();
@@ -405,7 +405,7 @@ index 54cee74401..6ba2229639 100644
record_unwind_protect_void (unblock_input);
record_unwind_current_buffer ();
if (b != current_buffer)
@@ -9412,12 +9566,29 @@ - (void)postAccessibilityNotificationsForFrame:(struct frame *)f
@@ -9416,12 +9570,29 @@ - (void)postAccessibilityNotificationsForFrame:(struct frame *)f
if (!b)
return;
@@ -435,7 +435,7 @@ index 54cee74401..6ba2229639 100644
if (modiff != self.cachedModiff)
{
self.cachedModiff = modiff;
@@ -9431,6 +9602,7 @@ Text property changes (e.g. face updates from
@@ -9435,6 +9606,7 @@ Text property changes (e.g. face updates from
{
self.cachedCharsModiff = chars_modiff;
[self postTextChangedNotification:point];
@@ -443,7 +443,7 @@ index 54cee74401..6ba2229639 100644
}
}
@@ -9453,37 +9625,44 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property
@@ -9457,37 +9629,44 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property
displayed in the minibuffer. In normal editing buffers,
font-lock and other modes change BUF_OVERLAY_MODIFF on
every redisplay, triggering O(overlays) work per keystroke.
@@ -517,7 +517,7 @@ index 54cee74401..6ba2229639 100644
}
}
}
@@ -9497,7 +9676,18 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property
@@ -9501,7 +9680,18 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property
self.cachedPoint = point;
self.cachedMarkActive = markActive;
@@ -537,15 +537,7 @@ index 54cee74401..6ba2229639 100644
NSInteger direction = ns_ax_text_selection_direction_discontiguous;
if (point > oldPoint)
direction = ns_ax_text_selection_direction_next;
@@ -9509,6 +9699,7 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property
/* --- Granularity detection --- */
NSInteger granularity = ns_ax_text_selection_granularity_unknown;
+ BOOL singleLineMove = NO;
[self ensureTextCache];
if (cachedText && oldPoint > 0)
{
@@ -9523,7 +9714,18 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property
@@ -9534,7 +9724,18 @@ to VoiceOver via the AX getter path (accessibilityValue etc.). */
NSRange newLine = [cachedText lineRangeForRange:
NSMakeRange (newIdx, 0)];
if (oldLine.location != newLine.location)
@@ -565,7 +557,7 @@ index 54cee74401..6ba2229639 100644
else
{
NSUInteger dist = (newIdx > oldIdx
@@ -9545,38 +9747,23 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property
@@ -9556,38 +9757,23 @@ to VoiceOver via the AX getter path (accessibilityValue etc.). */
granularity = ns_ax_text_selection_granularity_line;
}
@@ -617,7 +609,7 @@ index 54cee74401..6ba2229639 100644
{
NSWindow *win = [self.emacsView window];
if (win)
@@ -9735,6 +9922,13 @@ - (NSRect)accessibilityFrame
@@ -9746,6 +9932,13 @@ - (NSRect)accessibilityFrame
if (vis_start >= vis_end)
return @[];
@@ -631,7 +623,7 @@ index 54cee74401..6ba2229639 100644
block_input ();
specpdl_ref blk_count = SPECPDL_INDEX ();
record_unwind_protect_void (unblock_input);
@@ -10042,6 +10236,10 @@ - (void)dealloc
@@ -10053,6 +10246,10 @@ - (void)dealloc
#endif
[accessibilityElements release];
@@ -642,7 +634,7 @@ index 54cee74401..6ba2229639 100644
[[self menu] release];
[super dealloc];
}
@@ -11491,6 +11689,9 @@ - (instancetype) initFrameFromEmacs: (struct frame *)f
@@ -11502,6 +11699,9 @@ - (instancetype) initFrameFromEmacs: (struct frame *)f
windowClosing = NO;
processingCompose = NO;
@@ -652,7 +644,7 @@ index 54cee74401..6ba2229639 100644
scrollbarsNeedingUpdate = 0;
fs_state = FULLSCREEN_NONE;
fs_before_fs = next_maximized = -1;
@@ -12799,6 +13000,156 @@ - (id)accessibilityFocusedUIElement
@@ -12810,6 +13010,156 @@ - (id)accessibilityFocusedUIElement
The existing elements carry cached state (modiff, point) from the
previous redisplay cycle. Rebuilding first would create fresh
elements with current values, making change detection impossible. */
@@ -809,7 +801,7 @@ index 54cee74401..6ba2229639 100644
- (void)postAccessibilityUpdates
{
NSTRACE ("[EmacsView postAccessibilityUpdates]");
@@ -12809,11 +13160,69 @@ - (void)postAccessibilityUpdates
@@ -12820,11 +13170,69 @@ - (void)postAccessibilityUpdates
/* Re-entrance guard: VoiceOver callbacks during notification posting
can trigger redisplay, which calls ns_update_end, which calls us