SelectedTextChanged → only for selection changes (mark active)
AnnouncementRequested → only for cursor moves (char/line)
Never both for the same event. Fixes double-speech globally.
- SelectedTextChanged posted only for focused element: prevents completion
buffer from triggering double-speech (old-candidate + new-candidate)
- AnnouncementRequested for char navigation restored (evil block cursor fix):
posted AFTER SelectedTextChanged so VoiceOver cancels its own reading
and uses our explicit char-at-point announcement
- Priority: Medium (was High)
- Remove static Lisp_Object locals; use DEFSYM in syms_of_nsterm (GC-safe)
- Replace Lisp calls in accessibilityIndexForCharpos / charposForAccessibilityIndex
with NSString composed-character traversal (thread-safe, no Lisp needed)
- isAccessibilityFocused reads cachedPoint instead of marker_position off-thread
- Remove double-announcement: character nav uses only SelectedTextChanged
- Line announcement priority: High → Medium (avoid suppressing VO feedback)
- Remove 'extern Lisp_Object last_command_event' - last_command_event
is a macro in globals.h (expands to globals.f_last_command_event),
so an extern declaration conflicts with the existing
'extern struct emacs_globals globals'
- Replace invalid C escape sequences '\C-n' and '\C-p' with
('n' & 0x1f) and ('p' & 0x1f) respectively
Root cause: ns_ax_buffer_text used BUF_BYTE_ADDRESS + raw pointer
read which crosses the buffer gap when visible runs span it. The gap
follows point, so completion cycling and dired navigation reliably
trigger corruption — VoiceOver reads wrong text.
Fix: Replace raw pointer extraction with Fbuffer_substring_no_properties
which handles the gap internally. Add ax_length field to visible run
struct for accurate UTF-16 length tracking (fixes supplementary
Unicode character offset drift).
Secondary: ax_offset accumulation now uses NSString length (UTF-16
units) instead of Emacs char count, preventing progressive drift in
index mapping for subsequent visible runs.
Major architectural change: ns_ax_buffer_text now skips text with
the 'invisible' property using TEXT_PROP_MEANS_INVISIBLE. Accessibility
text matches what the user sees on screen.
New ns_ax_visible_run struct tracks charpos↔ax-index mapping for each
visible text run. All 8 index conversion sites updated. Fixes wrong
line reading in dired, completions, and any buffer with invisible text.
Completions announcement now detects completions-highlight overlay
and reads the full highlighted candidate text instead of partial line.
2000-line patch, 54 references to new mapping infrastructure.
1. setAccessibilitySelectedTextRange: — enables bidirectional cursor
sync. Converts AX index to buffer charpos, moves point via
SET_PT_BOTH. VoiceOver can now place cursor in text interaction mode.
2. setAccessibilityFocused: — handles VO+Shift+Down interaction entry.
Ensures NS window focus, posts SelectedTextChanged so VoiceOver
reads current line.
3. Completions announcement — when point changes in a non-focused
buffer (e.g. *Completions* during Tab), posts
AnnouncementRequestedNotification with the current line text.
VoiceOver speaks the selected completion while minibuffer keeps focus.
P0 fixes (critical):
- accessibilityRangeForLine: include trailing newline — fixes
zero-length ranges for empty lines causing 'end of text'
- accessibilityVisibleCharacterRange: return full buffer range —
VoiceOver was treating visible window boundary as end of text
- ensureTextCache: invalidate when point moves outside cached region
(stale cachedTextStart after scrolling without editing)
- ns_ax_frame_for_range: use row->height not visible_height, clip
to text area — fixes VoiceOver cursor bleeding into adjacent lines
P1 fixes (important):
- Post SelectedTextChanged after FocusedUIElementChanged on focus
acquisition (windowDidBecomeKey) and window switch (C-x o)
- Post LayoutChangedNotification after tree rebuild
- accessibilityRangeForIndex: rangeOfComposedCharacterSequenceAtIndex
for emoji/combining marks
- Buffer accessibilityFrame excludes mode line height
- New elements init cachedPoint/cachedModiff=-1 to force first
notification
Reviewed by: architect + 3 reviewers (text, notifications, frames)
Root cause (confirmed via WebKit/Chromium source): ValueChanged (edit)
and SelectedTextChanged (cursor move) are MUTUALLY EXCLUSIVE — apps
must never send both for the same user action. VoiceOver enters
'typing mode' on Edit notifications and suppresses/ignores concurrent
SelectionMove notifications, causing the cursor to appear stuck.
Fix: (1) Update cachedPoint inside the modiff branch so the
selection-move check doesn't trigger for edit-caused point changes.
(2) Change 'if' to 'else if' for explicit mutual exclusion.
Source: WebKit AXObjectCacheMac.mm — postTextStateChangePlatformNotification
vs postTextSelectionChangePlatformNotification are separate code paths
that never fire for the same event.
- Re-entrance guard (accessibilityUpdating) prevents infinite recursion
when VoiceOver callbacks trigger redisplay during notification posting
- Detect window tree change via FRAME_ROOT_WINDOW comparison; rebuild
tree BEFORE iterating elements (prevents accessing freed windows)
- Validate window+buffer pointers before posting notifications
- Dynamic direction (Previous/Next/Discontiguous) and granularity
(Character/Line) in SelectedTextChanged notifications based on
actual point movement — fixes C-n/C-p not moving VoiceOver cursor
- New windows (completions, splits) detected via lastRootWindow change