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 index 3e1ac74..d3015e2 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/src/nsterm.m +++ b/src/nsterm.m
@@ -8758,6 +8758,552 @@ - (NSRect)accessibilityFrame @@ -8758,6 +8758,605 @@ - (NSRect)accessibilityFrame
@end @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. + /* For focused line moves: always announce line text explicitly.
+ SelectedTextChanged with granularity=line works for arrow keys, + SelectedTextChanged with granularity=line works for arrow keys,
+ but C-n/C-p need the explicit announcement (VoiceOver processes + but C-n/C-p need the explicit announcement (VoiceOver processes
@@ -317,7 +371,6 @@ index 3e1ac74..d3015e2 100644
+ } + }
+ +
+ unbind_to (count2, Qnil); + unbind_to (count2, Qnil);
+ unblock_input ();
+ +
+ /* Final fallback: read current line at point. */ + /* Final fallback: read current line at point. */
+ if (!announceText) + if (!announceText)

View File

@@ -435,9 +435,9 @@ index 8d44b5f..29b646d 100644
specpdl_ref count2 = SPECPDL_INDEX (); specpdl_ref count2 = SPECPDL_INDEX ();
+ /* Register unblock_input as an unwind action so that if any Lisp + /* Register unblock_input as an unwind action so that if any Lisp
+ call below signals (triggering a longjmp through unbind_to), + call below signals (triggering a longjmp through unbind_to),
+ block_input is always paired with an unblock_input. The explicit + block_input is always paired with an unblock_input. The
+ unblock_input() at the end of the function is still needed for + unbind_to call at the end of the function unwinds this.
+ the normal (non-signal) path. */ + record_unwind_protect_void plus unbind_to is idempotent. */
+ record_unwind_protect_void (unblock_input); + record_unwind_protect_void (unblock_input);
record_unwind_current_buffer (); record_unwind_current_buffer ();
if (b != current_buffer) if (b != current_buffer)
@@ -518,7 +518,7 @@ index 8d44b5f..29b646d 100644
NSInteger direction = ns_ax_text_selection_direction_discontiguous; NSInteger direction = ns_ax_text_selection_direction_discontiguous;
if (point > oldPoint) if (point > oldPoint)
direction = ns_ax_text_selection_direction_next; 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; granularity = ns_ax_text_selection_granularity_line;
} }
@@ -541,6 +541,21 @@ index 8d44b5f..29b646d 100644
+ handles them correctly. */ + handles them correctly. */
+ if (emacsMovedCursor && !isCtrlNP) + if (emacsMovedCursor && !isCtrlNP)
+ direction = ns_ax_text_selection_direction_discontiguous; + 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. */ /* Post notifications for focused and non-focused elements. */
if ([self isAccessibilityFocused]) if ([self isAccessibilityFocused])
@@ -592,7 +607,7 @@ index 8d44b5f..29b646d 100644
scrollbarsNeedingUpdate = 0; scrollbarsNeedingUpdate = 0;
fs_state = FULLSCREEN_NONE; fs_state = FULLSCREEN_NONE;
fs_before_fs = next_maximized = -1; 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 The existing elements carry cached state (modiff, point) from the
previous redisplay cycle. Rebuilding first would create fresh previous redisplay cycle. Rebuilding first would create fresh
elements with current values, making change detection impossible. */ 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 + set_buffer_internal_1 is preferred over set_buffer_internal in
+ a redisplay context: it skips point-motion hooks that could + a redisplay context: it skips point-motion hooks that could
+ trigger further redisplay or modify buffer state unexpectedly. */ + trigger further redisplay or modify buffer state unexpectedly. */
+ block_input ();
+ specpdl_ref count = SPECPDL_INDEX (); + specpdl_ref count = SPECPDL_INDEX ();
+ record_unwind_protect_void (unblock_input);
+ record_unwind_current_buffer (); + record_unwind_current_buffer ();
+ set_buffer_internal_1 (eb); + set_buffer_internal_1 (eb);
+ Lisp_Object ls = Fbuffer_string (); + Lisp_Object ls = Fbuffer_string ();