patches: fix discontiguous moves reading only first word

For discontiguous moves (teleports, org-agenda items separated by blank
lines, multi-line jumps), AXSelectedTextChanged was sent with
AXTextSelectionDirection=discontiguous.  VoiceOver interprets an
explicit discontiguous direction as 're-anchor only' and reads only the
word at the cursor, ignoring VoiceOver's own line-browse mode.

The pre-review code (51f5944) omitted direction/granularity for all
moves and let VoiceOver determine what to read from its navigation state.
This correctly reads the full line when the VoiceOver rotor is in line
mode, which is the typical setting for text navigation.

Fix: omit AXTextSelectionDirection and AXTextSelectionGranularity from
AXSelectedTextChanged when direction=discontiguous.  Include them only
for sequential moves (direction=next/previous), where the explicit hint
ensures VoiceOver reads the correct unit without an extra state query.

This fixes:
- org-agenda / org-super-agenda j/k: items separated by blank lines
  cause singleLineMove=NO (non-adjacent AX indices), so direction was
  discontiguous -> only first word read.
- Any other navigation that crosses blank or invisible lines.

Sequential moves (C-n/C-p, single adjacent j/k) still include
direction + granularity=line for reliable full-line reads.
This commit is contained in:
2026-03-02 21:30:42 +01:00
parent ce34c44c2f
commit b6a576a312
9 changed files with 56 additions and 42 deletions

View File

@@ -1,4 +1,4 @@
From 089ff332d52b9595e774b654a9259c65450cdaa2 Mon Sep 17 00:00:00 2001
From 235fb607dfe06a242044218a2ed0ea82fed4f82f Mon Sep 17 00:00:00 2001
From: Daneel <daneel@sukany.cz>
Date: Mon, 2 Mar 2026 18:49:13 +0100
Subject: [PATCH 8/8] ns: announce child frame completion candidates for
@@ -33,8 +33,8 @@ area announcements.
doc/emacs/macos.texi | 14 +-
etc/NEWS | 18 +-
src/nsterm.h | 20 ++
src/nsterm.m | 493 ++++++++++++++++++++++++++++++++++++++-----
4 files changed, 475 insertions(+), 70 deletions(-)
src/nsterm.m | 504 +++++++++++++++++++++++++++++++++++++------
4 files changed, 482 insertions(+), 74 deletions(-)
diff --git a/doc/emacs/macos.texi b/doc/emacs/macos.texi
index 8d4a7825d8..03a657f970 100644
@@ -149,7 +149,7 @@ index 21a93bc799..bdd40b8eb7 100644
@end
diff --git a/src/nsterm.m b/src/nsterm.m
index 8f744d1bf3..20a50281db 100644
index 8f744d1bf3..1f3b2ad78a 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -1126,24 +1126,19 @@ Uses CFAbsoluteTimeGetCurrent() (~5 ns, a VDSO read) for timing. */
@@ -339,29 +339,43 @@ index 8f744d1bf3..20a50281db 100644
specpdl_ref count = SPECPDL_INDEX ();
record_unwind_current_buffer ();
/* Ensure block_input is always matched by unblock_input even if
@@ -9060,15 +9166,23 @@ - (void)postFocusedCursorNotification:(ptrdiff_t)point
@@ -9053,22 +9159,33 @@ - (void)postFocusedCursorNotification:(ptrdiff_t)point
&& granularity
== ns_ax_text_selection_granularity_character);
- /* Always post SelectedTextChanged to interrupt VoiceOver reading
- and update cursor tracking / braille displays. */
+ /* Post SelectedTextChanged to interrupt VoiceOver reading and
+ update cursor tracking / braille displays.
+ For sequential moves (direction = next/previous): include
+ direction + granularity so VoiceOver reads the destination line
+ or word without additional state queries.
+ For discontiguous jumps (teleports, multi-line leaps): omit
+ direction and granularity and let VoiceOver determine what to read
+ from its own navigation state. This matches the pre-review
+ behaviour and ensures VoiceOver reads the full destination line
+ even when the jump skips blank or invisible lines (e.g. org-agenda
+ items separated by blank lines, where adjacency detection cannot
+ classify the move as singleLineMove). */
NSMutableDictionary *moveInfo = [NSMutableDictionary dictionary];
moveInfo[@"AXTextStateChangeType"]
= @(ns_ax_text_state_change_selection_move);
moveInfo[@"AXTextSelectionDirection"] = @(direction);
- moveInfo[@"AXTextSelectionDirection"] = @(direction);
moveInfo[@"AXTextChangeElement"] = self;
- /* Omit granularity for character moves so VoiceOver does not
- derive its own speech (it would read the wrong character
- for block-cursor mode). Include it for word/line/
- selection so VoiceOver reads the appropriate text. */
- if (!isCharMove)
+ /* Include granularity for sequential moves so VoiceOver reads the
+ appropriate unit. Omit for character moves (announced explicitly
+ below) and for discontiguous jumps (destination line announced
+ explicitly; omitting granularity lets VoiceOver use its default
+ behaviour and re-anchor its browse cursor). */
+ if (!isCharMove
+ && direction != ns_ax_text_selection_direction_discontiguous)
moveInfo[@"AXTextSelectionGranularity"] = @(granularity);
- moveInfo[@"AXTextSelectionGranularity"] = @(granularity);
+ BOOL isDiscontiguous
+ = (direction == ns_ax_text_selection_direction_discontiguous);
+ if (!isDiscontiguous && !isCharMove)
+ {
+ moveInfo[@"AXTextSelectionDirection"] = @(direction);
+ moveInfo[@"AXTextSelectionGranularity"] = @(granularity);
+ }
+ /* Post SelectedTextChanged from the parent EmacsView (an NSView)
+ rather than from self (a custom NSObject element). VoiceOver
+ processes text-change notifications more reliably from view-based
+ elements. Include UIElementsKey so VoiceOver knows which child
+ element's selectedTextRange to re-query. */
+ moveInfo[NSAccessibilityUIElementsKey] = @[self];
ns_ax_post_notification_with_info (
- self,
@@ -369,7 +383,7 @@ index 8f744d1bf3..20a50281db 100644
NSAccessibilitySelectedTextChangedNotification,
moveInfo);
@@ -9166,12 +9280,17 @@ user expectation ("w" jumps to next word and reads it). */
@@ -9166,12 +9283,17 @@ user expectation ("w" jumps to next word and reads it). */
}
}
@@ -392,7 +406,7 @@ index 8f744d1bf3..20a50281db 100644
if (cachedText
&& granularity == ns_ax_text_selection_granularity_line)
{
@@ -9236,6 +9355,11 @@ - (void)postCompletionAnnouncementForBuffer:(struct buffer *)b
@@ -9236,6 +9358,11 @@ - (void)postCompletionAnnouncementForBuffer:(struct buffer *)b
block_input ();
specpdl_ref count2 = SPECPDL_INDEX ();
@@ -404,7 +418,7 @@ index 8f744d1bf3..20a50281db 100644
record_unwind_protect_void (unblock_input);
record_unwind_current_buffer ();
if (b != current_buffer)
@@ -9412,12 +9536,29 @@ - (void)postAccessibilityNotificationsForFrame:(struct frame *)f
@@ -9412,12 +9539,29 @@ - (void)postAccessibilityNotificationsForFrame:(struct frame *)f
if (!b)
return;
@@ -434,7 +448,7 @@ index 8f744d1bf3..20a50281db 100644
if (modiff != self.cachedModiff)
{
self.cachedModiff = modiff;
@@ -9431,6 +9572,7 @@ Text property changes (e.g. face updates from
@@ -9431,6 +9575,7 @@ Text property changes (e.g. face updates from
{
self.cachedCharsModiff = chars_modiff;
[self postTextChangedNotification:point];
@@ -442,7 +456,7 @@ index 8f744d1bf3..20a50281db 100644
}
}
@@ -9453,8 +9595,15 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property
@@ -9453,8 +9598,15 @@ 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.
@@ -460,7 +474,7 @@ index 8f744d1bf3..20a50281db 100644
goto skip_overlay_scan;
int selected_line = -1;
@@ -9500,7 +9649,18 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property
@@ -9500,7 +9652,18 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property
self.cachedPoint = point;
self.cachedMarkActive = markActive;
@@ -480,7 +494,7 @@ index 8f744d1bf3..20a50281db 100644
NSInteger direction = ns_ax_text_selection_direction_discontiguous;
if (point > oldPoint)
direction = ns_ax_text_selection_direction_next;
@@ -9512,6 +9672,7 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property
@@ -9512,6 +9675,7 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property
/* --- Granularity detection --- */
NSInteger granularity = ns_ax_text_selection_granularity_unknown;
@@ -488,7 +502,7 @@ index 8f744d1bf3..20a50281db 100644
[self ensureTextCache];
if (cachedText && oldPoint > 0)
{
@@ -9526,7 +9687,18 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property
@@ -9526,7 +9690,18 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property
NSRange newLine = [cachedText lineRangeForRange:
NSMakeRange (newIdx, 0)];
if (oldLine.location != newLine.location)
@@ -508,7 +522,7 @@ index 8f744d1bf3..20a50281db 100644
else
{
NSUInteger dist = (newIdx > oldIdx
@@ -9548,34 +9720,23 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property
@@ -9548,34 +9723,23 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property
granularity = ns_ax_text_selection_granularity_line;
}
@@ -556,7 +570,7 @@ index 8f744d1bf3..20a50281db 100644
{
NSWindow *win = [self.emacsView window];
if (win)
@@ -9734,6 +9895,13 @@ - (NSRect)accessibilityFrame
@@ -9734,6 +9898,13 @@ - (NSRect)accessibilityFrame
if (vis_start >= vis_end)
return @[];
@@ -570,7 +584,7 @@ index 8f744d1bf3..20a50281db 100644
block_input ();
specpdl_ref blk_count = SPECPDL_INDEX ();
record_unwind_protect_void (unblock_input);
@@ -9858,6 +10026,7 @@ than O(chars). Fall back to pos+1 as safety net. */
@@ -9858,6 +10029,7 @@ than O(chars). Fall back to pos+1 as safety net. */
pos = span_end;
}
@@ -578,7 +592,7 @@ index 8f744d1bf3..20a50281db 100644
return [[spans copy] autorelease];
}
@@ -10039,6 +10208,10 @@ - (void)dealloc
@@ -10039,6 +10211,10 @@ - (void)dealloc
#endif
[accessibilityElements release];
@@ -589,7 +603,7 @@ index 8f744d1bf3..20a50281db 100644
[[self menu] release];
[super dealloc];
}
@@ -11488,6 +11661,9 @@ - (instancetype) initFrameFromEmacs: (struct frame *)f
@@ -11488,6 +11664,9 @@ - (instancetype) initFrameFromEmacs: (struct frame *)f
windowClosing = NO;
processingCompose = NO;
@@ -599,7 +613,7 @@ index 8f744d1bf3..20a50281db 100644
scrollbarsNeedingUpdate = 0;
fs_state = FULLSCREEN_NONE;
fs_before_fs = next_maximized = -1;
@@ -12796,6 +12972,154 @@ - (id)accessibilityFocusedUIElement
@@ -12796,6 +12975,154 @@ - (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. */
@@ -754,7 +768,7 @@ index 8f744d1bf3..20a50281db 100644
- (void)postAccessibilityUpdates
{
NSTRACE ("[EmacsView postAccessibilityUpdates]");
@@ -12806,11 +13130,64 @@ - (void)postAccessibilityUpdates
@@ -12806,11 +13133,64 @@ - (void)postAccessibilityUpdates
/* Re-entrance guard: VoiceOver callbacks during notification posting
can trigger redisplay, which calls ns_update_end, which calls us