Fix three VoiceOver bugs: crash, cursor sync, word announcement

Bug 1 (crash): postEchoAreaAnnouncementIfNeeded called Fbuffer_string()
without block_input, allowing timer events to interleave with Lisp
calls and corrupt buffer state.  Added block_input/unblock_input via
record_unwind_protect_void.  Also removed unpaired unblock_input()
in postCompletionAnnouncementForBuffer (patch 0003) that became a
double-unblock after patch 0008 added block_input + unwind protect.

Bug 2 (cursor sync): VoiceOver browse cursor did not follow Emacs
keyboard cursor because SelectedTextChanged with discontiguous
direction alone is not sufficient for VoiceOver to re-anchor.  Added
NSAccessibilityFocusedUIElementChangedNotification post when Emacs
moves the cursor across a line boundary, forcing VoiceOver to re-query
accessibilitySelectedTextRange.

Bug 3 (word off-by-one): Evil w (next-word) read the previous word
instead of the destination word.  VO auto-speech from
SelectedTextChanged with direction=next+granularity=word reads the
traversed word, not the arrived-at word.  Added explicit word
announcement (like char moves) that reads the word AT the new cursor
position using whitespace-delimited word boundary scan.
This commit is contained in:
2026-03-02 10:42:54 +01:00
parent 7ab55a7fb3
commit cd288e8c76
2 changed files with 77 additions and 7 deletions

View File

@@ -29,7 +29,7 @@ diff --git a/src/nsterm.m b/src/nsterm.m
index 3e1ac74..d3015e2 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -8758,6 +8758,552 @@ - (NSRect)accessibilityFrame
@@ -8758,6 +8758,605 @@ - (NSRect)accessibilityFrame
@end
@@ -154,6 +154,60 @@ index 3e1ac74..d3015e2 100644
+ }
+ }
+
+ /* For word moves: explicit announcement of word AT new point.
+ VO auto-speech from SelectedTextChanged with direction=next
+ and granularity=word reads the word that was traversed (the
+ source word), not the word arrived at. This explicit
+ announcement reads the destination word instead, matching
+ user expectation ("w" jumps to next word and reads it). */
+ BOOL isWordMove
+ = (!markActive && !oldMarkActive
+ && granularity
+ == ns_ax_text_selection_granularity_word);
+ if (isWordMove && cachedText)
+ {
+ NSCharacterSet *ws
+ = [NSCharacterSet whitespaceAndNewlineCharacterSet];
+ NSUInteger point_idx
+ = [self accessibilityIndexForCharpos:point];
+ NSUInteger tlen = [cachedText length];
+ if (point_idx < tlen
+ && ![ws characterIsMember:
+ [cachedText characterAtIndex:point_idx]])
+ {
+ /* Find word boundaries around point. */
+ NSUInteger wstart = point_idx;
+ while (wstart > 0
+ && ![ws characterIsMember:
+ [cachedText characterAtIndex:wstart - 1]])
+ wstart--;
+ NSUInteger wend = point_idx;
+ while (wend < tlen
+ && ![ws characterIsMember:
+ [cachedText characterAtIndex:wend]])
+ wend++;
+ if (wend > wstart)
+ {
+ NSString *word
+ = [cachedText substringWithRange:
+ NSMakeRange (wstart, wend - wstart)];
+ word = [word stringByTrimmingCharactersInSet: ws];
+ if ([word length] > 0)
+ {
+ NSDictionary *annInfo = @{
+ NSAccessibilityAnnouncementKey: word,
+ NSAccessibilityPriorityKey:
+ @(NSAccessibilityPriorityHigh)
+ };
+ ns_ax_post_notification_with_info (
+ NSApp,
+ NSAccessibilityAnnouncementRequestedNotification,
+ annInfo);
+ }
+ }
+ }
+ }
+
+ /* For focused line moves: always announce line text explicitly.
+ SelectedTextChanged with granularity=line works for arrow keys,
+ but C-n/C-p need the explicit announcement (VoiceOver processes
@@ -317,7 +371,6 @@ index 3e1ac74..d3015e2 100644
+ }
+
+ unbind_to (count2, Qnil);
+ unblock_input ();
+
+ /* Final fallback: read current line at point. */
+ if (!announceText)

View File

@@ -435,9 +435,9 @@ index 8d44b5f..29b646d 100644
specpdl_ref count2 = SPECPDL_INDEX ();
+ /* Register unblock_input as an unwind action so that if any Lisp
+ call below signals (triggering a longjmp through unbind_to),
+ block_input is always paired with an unblock_input. The explicit
+ unblock_input() at the end of the function is still needed for
+ the normal (non-signal) path. */
+ block_input is always paired with an unblock_input. The
+ unbind_to call at the end of the function unwinds this.
+ record_unwind_protect_void plus unbind_to is idempotent. */
+ record_unwind_protect_void (unblock_input);
record_unwind_current_buffer ();
if (b != current_buffer)
@@ -518,7 +518,7 @@ index 8d44b5f..29b646d 100644
NSInteger direction = ns_ax_text_selection_direction_discontiguous;
if (point > oldPoint)
direction = ns_ax_text_selection_direction_next;
@@ -9488,6 +9670,26 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property
@@ -9488,6 +9670,41 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property
granularity = ns_ax_text_selection_granularity_line;
}
@@ -541,6 +541,21 @@ index 8d44b5f..29b646d 100644
+ handles them correctly. */
+ if (emacsMovedCursor && !isCtrlNP)
+ direction = ns_ax_text_selection_direction_discontiguous;
+
+ /* Post FocusedUIElementChanged when Emacs moved the cursor
+ across a line boundary so VoiceOver re-anchors its browse
+ cursor at the new accessibilitySelectedTextRange.
+ SelectedTextChanged with discontiguous direction alone is
+ not sufficient; VoiceOver requires this notification to
+ re-query the focused element and update its internal
+ browse position. */
+ if (emacsMovedCursor
+ && granularity
+ == ns_ax_text_selection_granularity_line
+ && [self isAccessibilityFocused])
+ ns_ax_post_notification (
+ self,
+ NSAccessibilityFocusedUIElementChangedNotification);
+
/* Post notifications for focused and non-focused elements. */
if ([self isAccessibilityFocused])
@@ -592,7 +607,7 @@ index 8d44b5f..29b646d 100644
scrollbarsNeedingUpdate = 0;
fs_state = FULLSCREEN_NONE;
fs_before_fs = next_maximized = -1;
@@ -12688,6 +12909,152 @@ - (id)accessibilityFocusedUIElement
@@ -12688,6 +12909,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. */
@@ -631,7 +646,9 @@ index 8d44b5f..29b646d 100644
+ set_buffer_internal_1 is preferred over set_buffer_internal in
+ a redisplay context: it skips point-motion hooks that could
+ trigger further redisplay or modify buffer state unexpectedly. */
+ block_input ();
+ specpdl_ref count = SPECPDL_INDEX ();
+ record_unwind_protect_void (unblock_input);
+ record_unwind_current_buffer ();
+ set_buffer_internal_1 (eb);
+ Lisp_Object ls = Fbuffer_string ();