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
Complete rewrite from glyph-based (v14) to buffer-relative design:
- ns_ax_buffer_text() with BUF_BEGV/ZV/PT, 100KB cap
- EmacsAccessibilityBuffer (AXTextArea per window)
- EmacsAccessibilityModeLine (AXStaticText per mode line)
- Enum fix: Edit=0, SelectionMove=1
- Text cache by BUF_MODIFF
- Tree rebuild on window config change only
- Minibuffer included
- Zoom code preserved (UAZoomChangeFocus)
- 1142 lines (nsterm.h +47, nsterm.m +1017)
Changes applied (from vo-cursor pipeline review, 7 workers):
1. (Change 8) Add ns_ax_index_for_charpos helper, refactor
ns_ax_index_for_point as thin wrapper — shared coordinate
mapping for all accessibility attribute methods.
2. (Change 1) Remove Site A notifications from ns_draw_window_cursor.
Eliminates duplicate VoiceOver notifications (Site A + Site B both
fired for same events). Zoom cursor tracking (UAZoomChangeFocus)
preserved.
3. (Change 7) Remove redundant bare ValueChanged before rich
userInfo version in postAccessibilityUpdatesForWindow:.
4. (Change 2) Fix typing echo character extraction to use glyph-based
index (ns_ax_index_for_point) instead of buffer-relative
(pt - BUF_BEGV - 1).
5. (Change 3) Add AXTextStateChangeType:@2 (SelectionMove) userInfo
to SelectedTextChanged notification for cursor movement —
enables VoiceOver line-by-line reading on arrow keys.
6. (Change 4) Fix accessibilityRangeForPosition: to return
glyph-based index via ns_ax_index_for_charpos instead of
buffer-relative (charpos - BUF_BEGV).
7. (Change 5) Fix accessibilitySelectedTextRange mark branch to use
ns_ax_index_for_charpos for both endpoints instead of mixing
glyph-based point with buffer-relative mark.
8. Remove 10 redundant text methods from EmacsView (Group role
should not expose text attributes — eliminates coordinate
system divergence with EmacsAccessibilityBuffer).
9. Fix MRC leak: release EmacsAccessibilityBuffer after addObject:
in ns_ax_collect_windows.
10. Remove dead lastAccessibilityModiff ivar (was only used by
removed Site A).
Enum values verified from WebKit AXTextStateChangeIntent.h:
AXTextStateChangeTypeEdit = 1
AXTextStateChangeTypeSelectionMove = 2
AXTextEditTypeTyping = 3
Changes applied (from vo-cursor pipeline review, 7 workers):
1. (Change 8) Add ns_ax_index_for_charpos helper, refactor
ns_ax_index_for_point as thin wrapper — shared coordinate
mapping for all accessibility attribute methods.
2. (Change 1) Remove Site A notifications from ns_draw_window_cursor.
Eliminates duplicate VoiceOver notifications (Site A + Site B both
fired for same events). Zoom cursor tracking (UAZoomChangeFocus)
preserved.
3. (Change 7) Remove redundant bare ValueChanged before rich
userInfo version in postAccessibilityUpdatesForWindow:.
4. (Change 2) Fix typing echo character extraction to use glyph-based
index (ns_ax_index_for_point) instead of buffer-relative
(pt - BUF_BEGV - 1).
5. (Change 3) Add AXTextStateChangeType:@2 (SelectionMove) userInfo
to SelectedTextChanged notification for cursor movement —
enables VoiceOver line-by-line reading on arrow keys.
6. (Change 4) Fix accessibilityRangeForPosition: to return
glyph-based index via ns_ax_index_for_charpos instead of
buffer-relative (charpos - BUF_BEGV).
7. (Change 5) Fix accessibilitySelectedTextRange mark branch to use
ns_ax_index_for_charpos for both endpoints instead of mixing
glyph-based point with buffer-relative mark.
8. Remove 10 redundant text methods from EmacsView (Group role
should not expose text attributes — eliminates coordinate
system divergence with EmacsAccessibilityBuffer).
9. Fix MRC leak: release EmacsAccessibilityBuffer after addObject:
in ns_ax_collect_windows.
10. Remove dead lastAccessibilityModiff ivar (was only used by
removed Site A).
Enum values verified from WebKit AXTextStateChangeIntent.h:
AXTextStateChangeTypeEdit = 1
AXTextStateChangeTypeSelectionMove = 2
AXTextEditTypeTyping = 3
Root cause of typing echo + cursor movement failure:
VoiceOver tracks the focused element (EmacsAccessibilityBuffer, AXTextArea).
Notifications from EmacsView (AXGroup) were ignored because VoiceOver
doesn't monitor non-focused elements for text changes.
Fix: ns_draw_window_cursor now calls [view accessibilityFocusedUIElement]
to find the right EmacsAccessibilityBuffer, and posts ValueChanged +
SelectedTextChanged on IT instead of on the view.
Also: postAccessibilityUpdatesForWindow now uses [self accessibilityStringForRange:]
instead of [view accessibilityStringForRange:] for typing echo text.
Root cause: VoiceOver ignored our virtual elements because they were
missing critical hierarchy metadata. Chromium's AXPlatformNodeCocoa
(also an NSAccessibilityElement subclass) works because it provides
all of these. Fixes based on VS Code/Chromium analysis:
1. Add accessibilityWindow + accessibilityTopLevelUIElement on base class
(EmacsAccessibilityElement) — VoiceOver needs these to associate
notifications with a window context
2. Add accessibilityParent on base class — unbroken chain to EmacsView
3. Add isAccessibilityFocused on EmacsAccessibilityBuffer — returns YES
for active buffer
4. Post FocusedUIElementChangedNotification in windowDidBecomeKey —
tells VoiceOver to start tracking the virtual element on app focus
5. Remove duplicate isAccessibilityFocused, deduplicate accessibilityParent
6. Keep all v13.3 features: Zoom, typing echo, full text protocol
v13 virtual element tree (EmacsAccessibilityBuffer) broke:
- typing echo (VoiceOver ignores notifications from virtual elements)
- buffer reading (AXGroup role on EmacsView confused VoiceOver)
- text navigation (dual text protocol = conflicting responses)
Reverting to v9 which has confirmed working:
- Zoom cursor tracking (UAZoomChangeFocus)
- typing echo (rich ValueChanged on EmacsView)
- buffer reading (accessibilityValue on AXTextArea)
Window detection (the only v13 improvement) not worth the regression.
Key changes:
- Typing echo notifications back in ns_draw_window_cursor (on EmacsView,
synchronous with cursor draw — this is how v9 did it and it worked)
- Rich userInfo: AXTextEditType=3, AXTextChangeValues with actual char
- SelectedTextChangedNotification on every cursor draw
- Full text protocol on EmacsView: accessibilityNumberOfCharacters,
accessibilitySelectedText, accessibilityInsertionPointLineNumber,
accessibilityVisibleCharacterRange, accessibilityLineForIndex:,
accessibilityRangeForLine:, accessibilityAttributedStringForRange:
- accessibilityAttributedStringForRange: on EmacsAccessibilityBuffer too
- Virtual tree kept for VoiceOver window/buffer detection
- arrayWithCapacity: returns autorelease object, must retain for ivar
- Release old array before reassigning in rebuildAccessibilityTree
- Release in EmacsView dealloc
- Fixes crash: _NSPasteboardTypeCache countByEnumeratingWithState (dangling pointer)
Complete rewrite of accessibility support. Instead of flat AXTextArea
on EmacsView, implements proper tree with virtual elements:
EmacsWindow (AXWindow)
└── EmacsView (AXGroup)
├── EmacsAccessibilityBuffer (AXTextArea) — window 1
├── EmacsAccessibilityBuffer (AXTextArea) — window 2
└── ...
Each EmacsAccessibilityBuffer maps to one Emacs window and exposes:
- Buffer text via glyph row iteration (accurate, handles overlays)
- Visual line navigation (glyph_row vpos)
- Cursor tracking with glyph charpos mapping
- Hit testing (accessibilityRangeForPosition:)
- Selection range with active mark
- Screen geometry via pixel coordinate conversion
- Rich typing echo (kAXTextEditTypeTyping userInfo)
- UAZoomChangeFocus for Zoom
Dynamic tree rebuilt on each redisplay cycle from FRAME_ROOT_WINDOW.
Notifications on correct virtual elements (not container view).
3 review cycles (scores: 62 → 68 → 82), plus manual fixes for
hit testing and selection range.
Root cause: all accessibility line methods used count_lines (logical/
newline-counting), but VoiceOver compares line numbers before/after
cursor movement. Wrapped lines had same logical line number, so VO
fell back to reading single characters instead of full lines.
Fix: replaced count_lines/find_newline_no_quit with compute_motion/
vmotion (visual screen lines) in all three methods. Matches iTerm2's
approach of returning screen rows.
Changes from v11:
- accessibilityInsertionPointLineNumber: compute_motion visual lines
- accessibilityLineForIndex: compute_motion visual lines
- accessibilityRangeForLine: vmotion for line start/end
- SelectedRowsChanged only on visual line change (new ivar tracking)
- accessibilityLabel returns buffer name for window switch
- No double ValueChanged on edits (content_changed flag)
- Trailing newline stripped from line ranges
- compute_motion width=-1 for consistency with vmotion
3 pipeline iterations, scores: 78 → 92 → 95
Key insight from iTerm2 analysis: VoiceOver does NOT need rich userInfo
on SelectedTextChanged for cursor movement. Bare notifications + correct
protocol methods (accessibilityStringForRange:, accessibilityLineForIndex:,
accessibilityRangeForLine:) are sufficient. Rich userInfo with wrong values
was likely causing VoiceOver to silently discard notifications.
Changes from v10:
- SelectedTextChanged: bare notification (removed rich userInfo)
- Added SelectedRowsChanged + SelectedColumnsChanged (iTerm2 triple pattern)
- Added bare ValueChanged on every cursor draw (not just edits)
- Fixed current_buffer safety: BUF_BYTE_ADDRESS, set_buffer_internal_1
- Added accessibilityRangeForIndex:, accessibilitySelectedTextRanges,
isAccessibilityFocused, accessibilityLabel
Key changes from v9:
- SelectedTextChanged now includes rich userInfo:
direction (Next/Previous/Discontiguous) and
granularity (Character/Word/Line) inferred from
old vs new cursor position comparison
- Track lastAccessibilityCursorPos + lastAccessibilityCursorLine
ivars for position delta detection
- accessibilityInsertionPointLineNumber: buffer lines (count_lines)
instead of visual vpos
- accessibilityLineForIndex: buffer lines via count_lines
- accessibilityRangeForLine: buffer lines via find_newline_no_quit
- Skip notification on unchanged position (avoids VO repeating)
- Word granularity heuristic: same line, >1 char delta