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
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
Replace extern NSString* declarations with raw string literals
(@"AXTextStateChangeType" etc.) to avoid redeclaration type conflict
with NSAccessibilityConstants.h on macOS 26 Tahoe SDK where these
symbols were added to public headers.
Guard accessibility notifications with on_p && active_p so they only
fire when drawing the cursor in the selected window. Without this,
ns_draw_window_cursor is called for both old and new windows during
redisplay, and UAZoomChangeFocus fires for the old window last.
UAZoomChangeFocus is Apple's documented API for Zoom focus control.
NSAccessibility serves VoiceOver and other AT tools.
Both are needed - same approach as iTerm2.
References Apple developer documentation URLs in comments.
Based on verified research:
- UAZoomChangeFocus IS officially documented by Apple (UniversalAccess.h)
- iTerm2 uses UAZoomChangeFocus for Zoom tracking (PTYTextView.m)
- NSAccessibility alone insufficient for Zoom custom view tracking
- Added Apple documentation URLs in code comments
UAZoomChangeFocus (documented Apple API) + NSAccessibility.
References to official Apple documentation in comments.
Pattern matches iTerm2 implementation.
Key changes:
- Add accessibilityFrame on EmacsView returning cursor screen rect
(the standard mechanism used by Terminal.app, iTerm2, Firefox)
- Guard UAZoomChangeFocus with bundleIdentifier check (bare binaries
are silently ignored by the window server)
- Extensive English comments explaining both mechanisms
- Deduplicate legacy parameterized attribute via accessibilityBoundsForRange:
Root cause of Stéphane's failure: UAZoomChangeFocus communicates via the
window server which identifies apps by CFBundleIdentifier. Bare binaries
(src/emacs, symlinks) have no bundle ID and are ignored. NSAccessibility
notifications + accessibilityFrame work for ALL launch methods.
Compilation error: EmacsView does not declare NSAccessibility conformance
so accessibilityConvertScreenRect: is not visible to the compiler.
Replaced with manual coordinate conversion:
primaryH = NSScreen.screens.first.frame.height
cgRect.origin.y = primaryH - cgRect.origin.y - cgRect.size.height
Root cause found via research pipeline:
NSAccessibility notifications alone are insufficient for custom NSView.
macOS Zoom 'Follow keyboard focus' requires UAZoomChangeFocus() from
HIServices/UniversalAccess.h — same as iTerm2 and Chromium.
Squashed into single clean patch. 159 insertions, 2 files.
macOS Zoom is event-driven -- it only queries AXBoundsForRange after
receiving NSAccessibilitySelectedTextChangedNotification. Previous
version implemented the bounds methods but never posted notifications,
so Zoom never triggered a cursor position query.
Added:
- NSAccessibilityPostNotification(SelectedTextChanged) in ns_draw_window_cursor
- NSAccessibilityPostNotification(FocusedUIElementChanged) in windowDidBecomeKey
- accessibilityAttributeNames + accessibilityAttributeValue: for
NSAccessibilitySelectedTextRangeAttribute -> returns {0,0}
Old parameterized attribute API alone insufficient — macOS Zoom prefers
the new NSAccessibilityProtocol method accessibilityBoundsForRange:.
Also adds isAccessibilityElement returning YES.
Both APIs now implemented for compatibility across macOS versions.