Compare commits

148 Commits

Author SHA1 Message Date
30089e9413 patches: fold line index + remove NS_AX_TEXT_CAP into 0001-0002
- 0001: remove NS_AX_TEXT_CAP (100K char cap), add lineStartOffsets/
  lineCount ivars and method declarations to nsterm.h
- 0002: add lineForAXIndex:/rangeForLine: O(log L) helpers, build line
  index in ensureTextCache, replace O(L) line scanning in
  accessibilityInsertionPointLineNumber/accessibilityLineForIndex/
  accessibilityRangeForLine, free index in invalidateTextCache/dealloc
- 0009 deleted (folded into 0001+0002)
- README.txt: remove NS_AX_TEXT_CAP references, update known
  limitations, stress test threshold 50K lines
2026-02-28 21:39:30 +01:00
419762bde0 patches: add 0009 line index perf fix, update README.txt
New patch 0009 fixes O(L) line scanning in accessibilityLineForIndex:
and accessibilityRangeForLine: by adding a precomputed lineStartOffsets
array built once per cache rebuild.  Queries go from O(L) linear scan
to O(log L) binary search.

README.txt: updated patch listing, text cache section, known limitations
(O(L) issue now resolved), stress test threshold raised to 50,000 lines.
2026-02-28 21:16:16 +01:00
3abc7c9745 config: org-caldav constants, evil merge, use-package cleanup, keybindings section
- org-caldav: extract UUIDs/URL to defconst near USER IDENTITY
  (my/caldav-url, my/caldav-id-suky/placeholders/family/klara)
- evil: merge two (after! evil) blocks into one
- olivetti: use-package! → after! (no load-order keywords needed)
- keybindings: central reference section at end of file
  standalone map! bindings (zoom, elfeed, speech, kubel, iedit,
  vundo, org-roam-ui, langtool, org-caldav) moved here
  context-coupled bindings left near their packages with comment index
2026-02-28 20:48:57 +01:00
538e15f707 config: consolidate mu4e into single block, fix key conflicts
- Merge 3 separate (after! mu4e) blocks into one
- Fix key conflict: bookmark Today was ?t, same as maildir Trash
  New: Today=?d, Trash shortcut=?T (uppercase)
- Remove duplicate mu4e-view-mode-hook (gnus-article-prepare-hook suffices)
- Move sendmail + message-cite settings into the single block
- Add inline comments explaining gnus-cite-* vs message-cited-text-*
  duplication (separate face systems, same visual intent)
- Minor: group settings with section comments for readability
2026-02-28 20:39:20 +01:00
c82ef86eaf patches: fix 0008 — forward reference + blank lines (maintainer review)
Move file-scope statics (lastChildFrameBuffer/Modiff/Candidate) and
ns_ax_reset_accessibility_updating before announceChildFrameCompletion.
Using them before their declaration was a forward reference (UB in C).

Remove two spurious blank lines at start of announceChildFrameCompletion
method body.
2026-02-28 19:26:55 +01:00
Martin Sukany
98b3d04597 Revert "patches: add 0009 resource safety hardening + update 0007/0008"
This reverts commit acc2a2985e.
2026-02-28 19:05:02 +01:00
acc2a2985e patches: add 0009 resource safety hardening + update 0007/0008
New patch 0009 fixes HIGH severity issues from Opus review:
- Announcement coalescing (50ms debounce)
- cachedText retain+autorelease in accessibilityValue
- EmacsView dealloc: nil out emacsView on all AX elements
- Nil guards on protocol methods + overlayZoomActive

0007 updated: revert accidental em-dash→triple-dash, add overlayZoomActive nil guards
0008 updated: specpdl exception safety for accessibilityUpdating, lastChildFrameBuffer staticpro

Series now 9 patches total (0001-0006 unchanged, 0007-0009 new/updated).
2026-02-28 18:45:30 +01:00
0f7608326c patches: fix 2 blockers from Opus review
BLOCKER #1: accessibilityUpdating flag exception safety.
A Lisp signal (longjmp) during postAccessibilityUpdates left
the re-entrance flag permanently YES, suppressing all future
AX notifications → VoiceOver goes silent randomly.
Fix: specpdl unwind protection (record_unwind_protect_ptr)
resets the flag on any longjmp. All 3 exit points use unbind_to.

BLOCKER #2: static struct buffer *lastBuffer dangling pointer.
Raw C pointer to buffer struct has no GC protection. After
kill-buffer, the pointer dangles.
Fix: file-scope Lisp_Object lastChildFrameBuffer with staticpro.
EQ comparison instead of pointer equality.

Also: revert accidental em-dash → triple-dash in title bar (0007),
fix README factual error (BUF_OVERLAY_MODIFF cache key).
2026-02-28 18:29:19 +01:00
4f37a8660e patches: update TESTING.txt + README.txt for 0007/0008
TESTING: sections 14 (overlay completion) + 15 (child frame completion)
README: patch series listing, overlay/child frame architecture,
  textDidChange flag, focus restoration, new limitations
2026-02-28 18:01:38 +01:00
6356cd9751 config: org-super-agenda-header-map nil (disable header keymap) 2026-02-28 17:55:55 +01:00
ef239ddf7a config: fix org-super-agenda bulk marking (header keymap override) 2026-02-28 17:52:39 +01:00
c05b46b058 patches: 0007 textDidChange fix (hl-line-mode), remove config workaround 2026-02-28 17:42:00 +01:00
beb5e14adf patches: revert textDidChange from 0007, config workarounds
- 0007 reverted to original else-if (no textDidChange flag)
- config: disable hl-line-mode (BUF_MODIFF blocking VoiceOver)
- config: org-startup-folded 'content (all headings, body hidden)
- config: corfu-auto-prefix 3 + delay re-set hook after Doom init
2026-02-28 17:39:51 +01:00
edbed0a116 patches: 0007 fix hl-line-mode blocking SelectedTextChanged
hl-line-mode (and similar) bumps BUF_MODIFF via text property
changes on every cursor movement. The else-if structure caused
the modiff branch to fire (skipping ValueChanged correctly) but
also blocked the cursor-move branch (SelectedTextChanged).

Fix: use textDidChange flag to decouple the two branches.
ValueChanged and SelectedTextChanged remain mutually exclusive
for real edits, but SelectedTextChanged now fires when only
text properties changed.
2026-02-28 17:38:05 +01:00
83c3c09858 config: restore dirvish (issue is Doom/Evil, not dirvish) 2026-02-28 17:34:09 +01:00
0996157b34 config: org-startup-folded t (show only headings on open) 2026-02-28 17:31:41 +01:00
fbbd7530c5 config: replace dirvish with plain dired
Dirvish caused VoiceOver issues. Plain dired with:
- hide-details, GNU ls sorting, dwim-target
- h/l navigation (vim-style)
- recursive copies, top-level delete confirm
2026-02-28 17:30:53 +01:00
2d053f5a92 patches: 0008 fix EmacsAccessibilityBuffer class name 2026-02-28 17:24:44 +01:00
4c7ce352cd patches: 0008 focus restore after child frame close + corfu delay 2s
- childFrameCompletionActive flag: set by child frame handler,
  cleared when no child frame visible on parent's next cycle
- Posts FocusedUIElementChanged on parent buffer element to
  restore VoiceOver character echo after corfu closes
- corfu-auto-delay: 1.0 → 2.0 (reduce popup noise)
2026-02-28 17:21:44 +01:00
afa65a8201 patches: 0008 restored to confirmed working version (3e5fe81)
Pure version: direct UAZoomChangeFocus, specpdl_ref, re-entrance
guard, buffer switch + unbind_to. No window announcement suppression
(all attempts broke VoiceOver focus tracking or Zoom).
2026-02-28 17:08:49 +01:00
e09b8c61f0 patches: 0008 - setTitle:empty instead of setAccessibilityElement:NO 2026-02-28 17:04:06 +01:00
306cde4f79 patches: restore 0008 + setAccessibilityElement:NO (no Zoom changes)
Restore working 0008 (direct UAZoomChangeFocus, specpdl_ref fix,
re-entrance guard, buffer switch). Only addition: suppress child
frame window announcement via setAccessibilityElement:NO.
No overlayZoomRect/overlayZoomActive changes — 0007 untouched.
2026-02-28 16:59:05 +01:00
Martin Sukany
f05d124381 Revert "config: corfu-terminal on macOS GUI for VoiceOver accessibility"
This reverts commit 190a4ae346.
2026-02-28 16:57:55 +01:00
190a4ae346 config: corfu-terminal on macOS GUI for VoiceOver accessibility
Child frames cause VoiceOver to announce 'X window' and break
focus tracking.  corfu-terminal renders via overlays, which the
VoiceOver overlay completion patch (0007) handles automatically.
2026-02-28 16:49:40 +01:00
9772b7e33e patches: drop 0008 (child frame), revert 0007 to clean state
Child frame completion (Corfu) will be handled via config:
corfu-terminal-mode renders as overlays → patch 0007 handles it.
2026-02-28 16:48:53 +01:00
1455542227 patches: 0008 - GroupRole + focus restore + overlayZoom reset
- NSAccessibilityGroupRole (no window announcement, focus tracking OK)
- FocusedUIElementChanged on parent when corfu closes
- overlayZoomActive = NO reset each parent cycle (handles C-g + frame delete)
2026-02-28 16:43:26 +01:00
5a58e3b925 patches: 0008 - setAccessibilityElement:NO (suppress window announcement) 2026-02-28 16:34:41 +01:00
2dc4182856 patches: 0008 - Zoom via parent overlayZoomRect + suppress window announcement 2026-02-28 16:29:26 +01:00
3e5fe814b8 patches: 0008 fix specpdl_ref type (not ptrdiff_t) 2026-02-28 16:19:08 +01:00
5aa0f05a33 patches: 0008 fix current_buffer bug + unbind_to on all return paths
Fbuffer_substring_no_properties operates on current_buffer, not the
passed buffer. Added set_buffer_internal_1 + record_unwind_current_buffer
with unbind_to on every return path.
2026-02-28 16:15:41 +01:00
31fcc1a711 patches: remove stale 0007 (merged overlay+child-frame variant) 2026-02-28 16:11:52 +01:00
659b9e2a1e patches: 0008 fix - re-entrance guard + modiff gate + safety checks
Root cause: child frame path bypassed accessibilityUpdating guard.
Lisp calls in announceChildFrameCompletion triggered redisplay →
ns_update_end → postAccessibilityUpdates → infinite recursion.
2026-02-28 16:11:23 +01:00
a8af58cff1 patches: split 0007 (overlay) + 0008 (child frame)
0007: Vertico/Icomplete/Ivy overlay completion, Zoom at text left edge
0008: Corfu/Company-box child frame completion, direct UAZoomChangeFocus
2026-02-28 16:02:13 +01:00
92188ab008 patches: v8 0007 - child frame completion (Corfu) + Zoom fix
Added: child frame buffer scanning for Corfu/Company-box.
Fixed: Zoom rect at text area left edge (not window center).
Added: 'selection' face match for company-tooltip-selection.
2026-02-28 15:59:11 +01:00
be4e0bb5be patches: v7 0007 - Zoom left edge + selection face match
Zoom rect now at text area left edge (WINDOW_TEXT_TO_FRAME_PIXEL_X)
with cursor-width (FRAME_COLUMN_WIDTH) instead of full window width.
Face matching adds 'selection' (company-tooltip-selection).
2026-02-28 15:51:06 +01:00
4e5596d9de patches: v6 0007 - fix Zoom Y offset (line_height arithmetic) 2026-02-28 15:39:44 +01:00
9129f032cf patches: v5 0007 - review fixes (em dash, comments, safety docs) 2026-02-28 15:36:50 +01:00
99609f0437 patches: v4 0007 - Zoom follows overlay candidate
Zoom now tracks the selected candidate row via overlayZoomRect
instead of always pointing at the text cursor. Returns to cursor
on typing (chars_modiff change).
2026-02-28 15:33:45 +01:00
9359277143 patches: v3 0007 - face name heuristic for candidate detection
Previous two-reference algorithm failed because:
- Vertico's cursor-space line (face=nil) confused the reference
- Count overlay processed before candidates overlay
- Group titles have distinct faces too

New approach: ns_ax_face_is_selected checks if face symbol name
contains 'current' or 'selected'. Works for all major frameworks
(Vertico, Icomplete, Ivy) without framework-specific code.
2026-02-28 15:25:52 +01:00
fcff3429b1 patches: rewrite 0007 - fix root cause of 'new line' announcement
Root cause: Vertico bumps BOTH BUF_MODIFF (text property face change
from vertico--prompt-selection) and BUF_OVERLAY_MODIFF (overlay-put)
in same cycle. Previous else-if chain meant overlay branch never fired.

Fixes:
1. Overlay check independent (if, not else-if)
2. BUF_CHARS_MODIFF gates ValueChanged (suppress property-only changes)
3. ensureTextCache no longer tracks overlay_modiff (prevents race)
4. Only AnnouncementRequested (no SelectedTextChanged - wrong line)
5. Two-reference face detection + single-candidate
6. Zoom tracking via UAZoomChangeFocus
2026-02-28 15:12:11 +01:00
9408e37a90 patches: rewrite 0007 overlay support
Key changes from previous version:
- Remove overlay text from ns_ax_buffer_text (was causing spurious
  'new line' announcements via VoiceOver text diff)
- Do NOT invalidate text cache on overlay change
- Two-reference face detection (handles selected at any position)
- SDATA scan instead of per-char Faref for newline detection
- Zoom tracking via UAZoomChangeFocus for selected candidate row
- Deduplication via cachedCompletionAnnouncement
2026-02-28 14:57:00 +01:00
6c502c7af5 patches: squash 0007+0008+0009 into single clean 0007
All overlay fixes in one patch: Fequal face detection,
NSApp announcement target, SelectedTextChanged interrupt.
2026-02-28 14:46:43 +01:00
8a48e72493 patches: add 0009 fix Fequal face comparison in overlay detection 2026-02-28 14:44:37 +01:00
99ed8b4ae4 patches: add 0008 fix overlay announcement bugs
- AnnouncementRequested to NSApp (not self)
- Compare line faces via Fequal (find selected candidate)
- SelectedTextChanged before announcement (interrupt speech)
2026-02-28 14:41:45 +01:00
Martin Sukany
270adb363d update 2026-02-28 14:33:41 +01:00
24fd61f1f6 patches: add 0007 overlay display string support
- Appends overlay before-string/after-string to AX text
- Detects BUF_OVERLAY_MODIFF changes
- Finds highlighted candidate via face text property
- Announces selected candidate via AnnouncementRequested
  (fixes 'new line' instead of reading candidate)
2026-02-28 14:31:02 +01:00
ed3bc1e0ad patches: add 0007 overlay display string support
- Appends overlay before-string/after-string to AX text
- Detects BUF_OVERLAY_MODIFF changes
- Finds highlighted candidate via face text property
- Announces selected candidate via AnnouncementRequested
  (fixes 'new line' instead of reading candidate)
2026-02-28 14:24:04 +01:00
Martin Sukany
1f91d94ae0 Revert "vertico: enable buffer-mode for VoiceOver accessibility"
This reverts commit 5799521a00.
2026-02-28 14:16:02 +01:00
5799521a00 vertico: enable buffer-mode for VoiceOver accessibility
Vertico default renders candidates via overlay before-string which
is invisible to the nsterm.m AX text extraction.  vertico-buffer-mode
renders in a real buffer window that the accessibility layer handles.
2026-02-28 14:12:42 +01:00
a39ccd13d7 org-agenda: start from today, remove duplicates
- Start from today (no past days visible)
- Show 7 days forward
- Skip done/cancelled items
- No duplicate: skip scheduled if deadline already shown
- Suppress early deadline warning if scheduled date exists
2026-02-28 13:34:50 +01:00
c67fa525ab org-super-agenda: fix sorting and grouping
- Priority-based sorting within groups (A > B > C)
- Overdue deadlines at top
- Due today / scheduled today separated
- Due soon (upcoming deadlines) in own group
- No duplicates (items match first group only)
- Kyndryl/ZTJ groups by tag without deadline overlap
2026-02-28 13:31:18 +01:00
5293eef3ac dired: enable gnus-dired-mode for multi-file mail attachments 2026-02-28 13:18:54 +01:00
9130268ff0 patches: fix ObjC category declaration warnings
Move postAccessibilityNotificationsForFrame: declaration from
primary @interface to (Notifications) category.
Add invalidateInteractiveSpans to (InteractiveSpans) category.
Fixes 3 compiler warnings (-Wobjc-method-access,
-Wincomplete-implementation, -Wobjc-protocol-method-implementation).
2026-02-28 12:58:18 +01:00
6da6f7c90f mu4e: add citation color faces for reply readability 2026-02-28 12:52:01 +01:00
ca77cc8e3d patches: add TESTING.txt with macOS build and VoiceOver evidence 2026-02-28 11:46:53 +01:00
edab71038a patches: 6-patch series (split Buffer into core + notifications)
0001: Base classes + helpers (+587)
  0002: Buffer core protocol (+1089)
  0003: Buffer notifications + ModeLine (+545)
  0004: Interactive spans (+286)
  0005: EmacsView integration + NEWS (+408)
  0006: Documentation (+75)

Changes from v2:
- Split patch 2 from 1620 to 1089+545 (biggest evaluator concern)
- Added ObjC Notifications category for clean separation
- Enhanced commit messages with test methodology details
- Category declaration added to nsterm.h
2026-02-28 10:35:53 +01:00
fa28bb52e1 patches: fix forward dependency (helpers moved to patch 1) 2026-02-28 10:24:39 +01:00
5016155c8a patches: 5-patch VoiceOver series (improved split + safety docs)
Split into 5 logical patches:
  0001: Base classes + text extraction (+474)
  0002: Buffer + ModeLine protocol (+1620)
  0003: Interactive spans (+403)
  0004: EmacsView integration + etc/NEWS (+408)
  0005: Documentation (+75)

Improvements over previous version:
- 5 patches (was 3): finer granularity
- Helpers placed in correct patches (find_completion_overlay_range,
  event_is_line_nav_key moved to patch with their users)
- etc/NEWS moved to last functional patch (0004)
- ChangeLog-format commit messages
- Longjmp safety analysis comment in code
- Code reorganized for clean sequential patches
2026-02-28 10:11:16 +01:00
67b1d25c34 patches: 4-patch VoiceOver series (split + improved docs)
Split VoiceOver accessibility into 4 logical patches:
  0001: Base classes + text extraction (+753)
  0002: Buffer/ModeLine/InteractiveSpan implementations (+1716)
  0003: EmacsView integration + cursor tracking (+395)
  0004: Documentation with known limitations (+75)

Each patch is self-contained: 0001 adds infrastructure that compiles
but doesn't change behavior.  0002 adds protocol implementations.
0003 wires everything into EmacsView.  0004 documents for users.

All patches verified: apply cleanly to current Emacs master,
final state identical to original monolithic patch.
2026-02-28 09:54:51 +01:00
2c8515a0a1 patches: split VoiceOver into 3-patch series, improve docs
Split the monolithic 3011-line patch into logical pieces:
  0001: All new accessibility code (infrastructure, no existing code modified)
  0002: EmacsView integration + cursor tracking (wiring only)
  0003: Documentation (expanded with known limitations)

Improvements:
- Comprehensive commit messages with testing methodology
- Known limitations documented (text cap, bidi, mode-line icons)
- Documentation expanded with Known Limitations section
- Each patch is self-contained and reviewable
2026-02-28 09:34:00 +01:00
bbd328dc81 fix: patch hunk 8 old_count 7→6 (trailer miscount) 2026-02-28 09:10:53 +01:00
b002d3004a patches: fix MRC build — add @synthesize for spanLabel/spanValue 2026-02-27 22:25:36 +01:00
d408a542e5 patches: review fixes — defvar, method extraction, GC safety, window_end_valid
Review-based improvements:
- ns-accessibility-enabled DEFVAR_BOOL (disable AX overhead)
- window_end_valid guard in ns_ax_window_end_charpos
- GC safety comments on Lisp_Object ObjC ivars
- postAccessibilityNotificationsForFrame split into 4 methods
- block_input in ns_ax_completion_text_for_span
- Fplist_get predicate comment
- macos.texi VoiceOver section with defvar docs
- README updated with USER OPTION + REVIEW CHANGES sections
2026-02-27 17:51:39 +01:00
b83a061322 patches: review fixes — eassert, blank lines, Texinfo doc
- nsterm.m: Remove 2 stray blank lines at start of
  ns_ax_event_is_line_nav_key body
- nsterm.m: Add eassert([NSThread isMainThread]) to ensureTextCache
  for explicit main-thread contract enforcement
- New 0002 patch: Texinfo section in doc/emacs/macos.texi documenting
  VoiceOver accessibility, Zoom tracking, completion announcements
- README.txt: Updated file listing and patch index
2026-02-27 17:15:18 +01:00
65c799dc3f patches: review fixes — specpdl protection, overlay_modiff tracking, binary search, enum cleanup
M1: accessibilityRangeForPosition uses specpdl unwind protection for
    block_input/unblock_input (consistent with all other methods).
M2: Track BUF_OVERLAY_MODIFF in ensureTextCache — overlay-only changes
    (timer-based completion highlight) now invalidate the text cache.
M3: Detect narrowing/widening by comparing cachedTextStart vs BUF_BEGV.
m1: Binary search (O(log n)) for visible runs in both
    accessibilityIndexForCharpos and charposForAccessibilityIndex.
m3: Add EmacsAXSpanTypeNone = -1 to enum instead of (EmacsAXSpanType)-1 cast.
m5: Add TODO comment in ns_ax_mode_line_text about non-CHAR_GLYPH limitation.
README: Remove resolved overlay_modiff limitation, document binary search
    and narrowing detection, update architecture section.
2026-02-27 16:56:05 +01:00
765725aaef patches: fix review B1/W1-5 — unwind protection, dealloc leak, DEFSYM nav, lineRange, buffer validation, select-window
B1: setAccessibilitySelectedTextRange: — add record_unwind_protect_void(unblock_input)
    before block_input to prevent permanently blocked input if Fset_marker signals.
W1: EmacsAccessibilityInteractiveSpan — add -dealloc releasing spanLabel/spanValue
    (MRC copy properties leaked on every span rebuild cycle).
W2: ns_ax_event_is_line_nav_key — replace 8x intern_c_string with DEFSYM'd symbols
    (Qns_ax_next_line etc.) to avoid per-cursor-move obarray lookups.
W3: accessibilityRangeForLine: — rewrite from O(n chars) characterAtIndex loop to
    O(lines) lineRangeForRange, matching accessibilityLineForIndex: pattern.
W4: accessibilityChildrenInNavigationOrder — validate buffer before calling
    ns_ax_scan_interactive_spans to prevent Lisp signals in dispatch_sync context.
W5: EmacsAccessibilityBuffer setAccessibilityFocused: — add Fselect_window so
    VoiceOver focus actually switches the Emacs selected window, with proper
    unwind protection for block_input.
2026-02-27 16:38:12 +01:00
936c251f11 patches: comprehensive review fixes — B1/W1-4/M1-4
B1: setAccessibilityFocused: on EmacsAccessibilityBuffer now checks
    ![NSThread isMainThread] and dispatches to main via dispatch_async.
    Prevents data race + AppKit thread violation from AX server thread.

W1: accessibilityInsertionPointLineNumber and accessibilityLineForIndex:
    now use lineRangeForRange iteration — O(lines) instead of O(chars).

W2: ns_ax_scan_interactive_spans skips non-interactive regions using
    Fnext_single_property_change for each scannable property and
    Fnext_single_char_property_change for keymap overlays.

W3: ns_ax_event_is_line_nav_key inspects Vthis_command against known
    navigation command symbols (next-line, previous-line, evil variants,
    dired variants) instead of raw key codes. Tab/backtab fallback
    retained via last_command_event.

W4: DEFSYM symbols renamed with ns_ax_ prefix (Qns_ax_button, etc.)
    to avoid linker collisions with other Emacs source files.
    Lisp symbol strings unchanged.

M3: Removed dead enum values (CheckBox, TextField, PopUpButton) and
    corresponding dead switch cases.

M4: Improved accessibilityStyleRangeForIndex: comment documenting the
    line-granularity simplification.

README: Updated stats, KNOWN LIMITATIONS, DEFSYM docs, test numbering.
2026-02-27 16:14:47 +01:00
Martin Sukany
a6a3aca678 remove some stale files 2026-02-27 15:47:21 +01:00
60e9ea2c59 patches: update README — document async notification posting
Add deadlock prevention section to THREADING MODEL, note async
posting in NOTIFICATION STRATEGY, add design decision 6a, and
add deadlock regression test case (#24) to testing checklist.
2026-02-27 15:44:22 +01:00
111013ddf1 patches: fix VoiceOver deadlock — async AX notification posting
NSAccessibilityPostNotification may synchronously invoke VoiceOver
callbacks from a background AX server thread.  Those callbacks call
dispatch_sync(main_queue) to read buffer state.  If the main thread
is still inside the notification-posting method (postAccessibilityUpdates,
windowDidBecomeKey, or postAccessibilityNotificationsForFrame), the
dispatch_sync deadlocks.

Symptom: Emacs hangs on C-x o after M-x list-buffers from Completions
buffer, but only with VoiceOver enabled.

Fix: introduce ns_ax_post_notification() and
ns_ax_post_notification_with_info() wrappers that defer notification
posting via dispatch_async(main_queue).  This lets the current method
return and frees the main queue for VoiceOver's dispatch_sync calls.

All 14 notification-posting sites now use the async wrappers.
2026-02-27 15:41:26 +01:00
fa3ee7cc88 fix: correct patch hunk headers after static BOOL insertion
The previous sed edit added a line but didn't update @@ hunk
headers, causing 'corrupt patch at line 2849'. Fixed:
- Main hunk: 2386 -> 2387 new-side lines
- Subsequent hunks: +1 offset on new-file line numbers
2026-02-27 15:23:19 +01:00
d29a33fcfb fix: add missing 'static BOOL' return type to ns_ax_find_completion_overlay_range
The function definition lacked a return type, causing:
  nsterm.m:7149:1: error: a type specifier is required for all declarations

Added 'static BOOL' — the function returns YES/NO and is file-scoped.
Updated patch line counts in header and README accordingly.
2026-02-27 15:21:47 +01:00
0b43fd25e3 patches: review fixes — memory leak, dead code, unwind-protect, protocol conformance
B1: Fix memory leak in ns_ax_scan_interactive_spans — [spans copy]
    returned +1 retained object never released by caller.
    Now returns [[spans copy] autorelease].

B2: Remove dead function ns_ax_utf16_length_for_buffer_range —
    defined but never called anywhere in the patch.

B3: Add specpdl unwind protection in
    EmacsAccessibilityInteractiveSpan setAccessibilityFocused: —
    if Fselect_window signals, block_input is now always matched
    by unblock_input via record_unwind_protect_void.

W2: Document ns_ax_event_is_line_nav_key fragility in README
    Known Limitations (raw keycodes vs command symbols).

W4: Add comment for #include intervals.h (TEXT_PROP_MEANS_INVISIBLE).

M3: accessibilityBoundsForRange: on EmacsView now delegates to the
    focused EmacsAccessibilityBuffer for accurate per-range geometry,
    with cursor-rect fallback for Zoom.

M4: Add <NSAccessibility> protocol conformance to
    EmacsAccessibilityBuffer @interface declaration.

W1: Expanded commit message listing all new types, functions, DEFSYM
    additions, and threading model.
2026-02-27 15:12:40 +01:00
6994403014 patches: complete thread safety — dispatch_sync on ALL AX methods
Add dispatch_sync guard to: Buffer accessibilityFrame, accessibilityLabel,
accessibilityRole, accessibilityRoleDescription, accessibilityPlaceholderValue,
isAccessibilityFocused. ModeLine accessibilityValue, accessibilityFrame,
accessibilityLabel. setAccessibilitySelectedTextRange now uses
record_unwind_current_buffer + unbind_to.
2026-02-27 14:55:03 +01:00
e4129581b7 patches: unify AX threading — dispatch_sync on all getter methods
8 methods were missing thread guards: accessibilityNumberOfCharacters,
accessibilitySelectedText, accessibilityStringForRange:,
accessibilityLineForIndex:, accessibilityRangeForLine:,
accessibilityRangeForIndex:, accessibilityVisibleCharacterRange,
setAccessibilitySelectedTextRange:.

All AX getters now consistently dispatch_sync to main thread.
GC safety comment added to InteractiveSpan setAccessibilityFocused:.
2026-02-27 14:49:33 +01:00
404d26e2fe patches: R3 — add missing NSTRACE to 3 EmacsView methods 2026-02-27 14:37:11 +01:00
1ecb9908af patches: maintainer review R2 fixes — all must-fix items resolved
- unwind-protect in ns_ax_utf16_length_for_buffer_range
- unwind-protect in ns_ax_completion_text_for_span
- unwind-protect in postAccessibilityNotificationsForFrame
- NSTRACE added to all 4 key functions (3 were missing)
- O(n) mouse-face scan → Fprevious/Fnext_single_char_property_change
- etc/NEWS entry added to patch
- Main-thread invariant comment in ensureTextCache
2026-02-27 14:35:04 +01:00
eafc80e324 patches: maintainer review fixes — thread safety, performance, safety
BLOCKER fixes:
- @synchronized on visibleRuns/cachedText (AX thread data race)
- Foverlays_in bulk query replaces O(n) per-char Foverlays_at loop

WARNING fixes:
- record_unwind_current_buffer in ns_ax_buffer_text
- ns_ax_frame_for_range simplified (charpos params, no NSRange indirection)
- NSTRACE added to 4 key accessibility functions
- MAC_OS_X_VERSION_MIN_REQUIRED guard for UAZoom APIs
- BUF_OVERLAY_MODIFF TODO in ensureTextCache
2026-02-27 14:29:41 +01:00
af960683f0 config: robust dired-hide-details-mode for VoiceOver
- Explicitly set dirvish-hide-details t
- Keep dired-mode-hook as primary trigger
- Add dired-after-readin-hook as failsafe (catches late buffer setup)
- Remove dirvish-directory-view-mode-hook (dirvish handles via its own var)
2026-02-27 14:08:40 +01:00
d151c40357 config: add dired-hide-details-mode hook for dirvish too
dirvish-override-dired-mode may bypass dired-mode-hook.
Add hook to dirvish-directory-view-mode-hook as well.
Toggle details with ( in dired/dirvish buffers.
2026-02-27 14:04:55 +01:00
b8ef731a8a config: minimal dirvish attributes for VoiceOver
Remove file-time and file-size from default dirvish-attributes so
screen reader reads only filenames.  Add D keybinding to toggle
full details on/off in dirvish buffers.
2026-02-27 14:01:46 +01:00
4eb370f6f0 patches: add README.txt — full VoiceOver architecture documentation
QA PASS 93.2/100. Covers: architecture, threading model, notification
strategy, text cache, completion announcements, interactive spans,
Zoom integration, design decisions, known limitations, testing checklist.
2026-02-27 13:59:08 +01:00
33eff6e9f8 patches: QA round 2 PASS (93.5/100) — final indentation fix + ChangeLog commit message 2026-02-27 13:38:30 +01:00
95b5271e35 patches: QA round 1 — all 13 issues fixed
- Delete dead ns_ax_command_is_basic_line_move
- Rename ns_ax_event_is_ctrl_n_or_p → ns_ax_event_is_line_nav_key
- Fix DEFSYM naming (Qwidget/Qbutton/Qfollow_link/Qorg_link/Qcompletion_list_mode)
- DEFSYM for hot-path symbols (Qcompletion__string, Qcompletions_highlight, Qbacktab)
- Remove duplicate overlay scan in else branch
- Add accessibilityStringForRange: to EmacsView (legacy API fix)
- Replace @try/@finally with explicit re-entrance guard
- Replace NSCAssert with eassert
- Wrap all lines to ≤79 columns (dispatch_sync, NSDictionary literals)
- Add missing comments (NS_AX_TEXT_CAP, sentinel values, @public, block_input)
- Fix indentation of cachedCompletionPoint lines
- Remove orphan @protected
2026-02-27 13:34:33 +01:00
7971f42600 patches: restore full-line AnnouncementRequested for C-n/C-p 2026-02-27 12:59:32 +01:00
3447fcc8d5 patches: hybrid notification — SelectedTextChanged + selective AnnouncementRequested
- SelectedTextChanged always posted (interrupts auto-read, braille)
- Character moves: granularity omitted from userInfo + AnnouncementRequested(char AT point)
- Word moves: granularity=word in userInfo (VoiceOver reads word) — fixes M-f/M-b
- Line moves: granularity=line in userInfo (VoiceOver reads line)
- Completion in focused buffer: AnnouncementRequested overrides line
2026-02-27 12:49:55 +01:00
495a5510c6 patches: AnnouncementRequested PriorityMedium→High (interrupt buffer reading) 2026-02-27 12:37:42 +01:00
8a834448f9 patches: systematic notification strategy — eliminate double-speech
SelectedTextChanged → only for selection changes (mark active)
AnnouncementRequested → only for cursor moves (char/line)
Never both for the same event. Fixes double-speech globally.
2026-02-27 12:27:20 +01:00
edad606809 patches: fix double-speech and evil block cursor char reading
- 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)
2026-02-27 12:16:32 +01:00
5f98a78467 patches: fix 5 critical issues in VoiceOver patch
- 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)
2026-02-27 12:07:19 +01:00
3df5dc94b1 patches: fix char nav — announce char AT point, not before it (evil block cursor) 2026-02-27 11:48:42 +01:00
75c1b471c4 patches: fix granularity — line comparison not delta, fixes org-mode empty lines 2026-02-27 11:36:08 +01:00
8890e659e1 patches: systematic line announcement (granularity=line, all modes) 2026-02-27 11:14:30 +01:00
0110670734 patches: fix broken function insertion (build error) 2026-02-27 11:06:14 +01:00
Martin Sukany
ab4367c459 update 2026-02-27 11:05:28 +01:00
f306599d94 patches: fix completion announcement (CONSP completion--string, focused+unfocused paths) 2026-02-27 11:01:22 +01:00
c138bdc6d5 patches: remove accessibilityChildren override (C-n/C-p line nav regression fix) 2026-02-27 10:47:54 +01:00
5f08e0a315 patches: fix completion span boundary (completion--string vs mouse-face) 2026-02-27 10:42:31 +01:00
23102444d4 patches: fix build errors (Fplist_get arity, MRC weak, duplicate decl) 2026-02-27 10:31:02 +01:00
92ef24332f patches: add EmacsAccessibilityInteractiveSpan (VoiceOver Tab nav, buttons, links, completions) 2026-02-27 10:26:29 +01:00
7a0e7722f7 patches: fix Tab navigation in completion buffer (probe order + Tab detection) 2026-02-27 10:10:39 +01:00
1245253e15 patches: fix B1 (live window ref), B2 (matrix guard), B3 (invisibility-spec), H1 (thread safety), H4 (main thread assert) 2026-02-27 09:57:10 +01:00
081d1c01e7 patches: restore postAccessibilityUpdates in ns_update_end (VoiceOver broken after rebase) 2026-02-27 09:39:42 +01:00
d11aa168b9 patches: fix Vreal_this_command linker error, squash into single patch 2026-02-27 07:43:13 +01:00
f37e06b00f patches: rebase on upstream f0dbe25 + fix NSRange return type 2026-02-27 07:38:18 +01:00
7419d9b0e4 patches: regenerate from actual emacs repo (fix src/ path prefix) 2026-02-27 07:31:44 +01:00
368f9600df patches: fix NSRange return type bug in accessibilitySelectedTextRange 2026-02-27 07:29:56 +01:00
97776b5141 Fix patch after 3-reviewer pipeline review
- BLOCKER: real_this_command → Vreal_this_command (DEFVAR_LISP uses V prefix)
- Thread safety: setAccessibilitySelectedTextRange dispatches to main thread
- Defensive: 7 new BUFFERP(w->contents) guards before XBUFFER calls

Reviewed by: symbol checker, logic/memory reviewer, ABI/build reviewer
Verified: git apply --check OK, zero real_this_command refs, dispatch_async present
2026-02-26 22:27:25 +01:00
3c0a68cfb9 Fix patch: adjust subsequent hunk offsets after 2-line removal 2026-02-26 21:52:48 +01:00
Martin Sukany
9a08f01066 removed 2026-02-26 21:51:50 +01:00
Martin Sukany
20d9605afa Merge remote-tracking branch 'refs/remotes/origin/master' 2026-02-26 21:50:36 +01:00
35a5326c18 Fix corrupt patch: correct hunk line counts after extern removal 2026-02-26 21:50:23 +01:00
Martin Sukany
f6c4328cf6 Merge remote-tracking branch 'refs/remotes/origin/master' 2026-02-26 21:43:12 +01:00
220e90144f Fix build errors in Zoom/VoiceOver patch
- 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
2026-02-26 21:43:04 +01:00
Martin Sukany
c8d7d11136 Merge remote-tracking branch 'refs/remotes/origin/master' 2026-02-26 21:38:10 +01:00
3bad798541 patches: fix v16.4 build errors (extern redecl + invalid C-n/C-p escape) 2026-02-26 21:37:41 +01:00
Martin Sukany
259ec157d1 Merge remote-tracking branch 'refs/remotes/origin/master' 2026-02-26 19:14:20 +01:00
7c01587079 patches: isolate C-n/C-p keypath from arrow navigation 2026-02-26 18:41:35 +01:00
059ee5a0ea patches: target C-n/C-p vs arrow VoiceOver line-read mismatch 2026-02-26 18:37:06 +01:00
Martin Sukany
fde9454f41 Merge remote-tracking branch 'refs/remotes/origin/master' 2026-02-26 18:25:13 +01:00
1b42d553a2 patches: completion candidate-aware VoiceOver announce fix 2026-02-26 18:25:04 +01:00
Martin Sukany
b691e804d2 Merge remote-tracking branch 'refs/remotes/origin/master' 2026-02-26 18:08:03 +01:00
74b9691856 patches: fix AX enum mapping + completion announcement source 2026-02-26 18:06:14 +01:00
Martin Sukany
f95d06dc82 Merge remote-tracking branch 'refs/remotes/origin/master' 2026-02-26 17:51:20 +01:00
b3a6141831 patches: robust AX/VoiceOver fix after full audit pipeline 2026-02-26 17:49:56 +01:00
Martin Sukany
c0a9bc8c62 Merge remote-tracking branch 'refs/remotes/origin/master' 2026-02-26 17:32:55 +01:00
8720a43d04 patches: fix paths to src/nsterm.{m,h} 2026-02-26 17:32:31 +01:00
Martin Sukany
e6608d9813 Merge remote-tracking branch 'refs/remotes/origin/master' 2026-02-26 17:31:27 +01:00
8ae3f0b367 patches: update VoiceOver patch (v15.9 candidate) 2026-02-26 17:30:21 +01:00
Martin Sukany
bae4368d9e Merge remote-tracking branch 'refs/remotes/origin/master' 2026-02-26 17:05:52 +01:00
fa4748eb4c voiceover: refresh ns accessibility patch to v15.8 2026-02-26 17:02:49 +01:00
Martin Sukany
ef18e6ef7b Merge remote-tracking branch 'refs/remotes/origin/master' 2026-02-26 16:08:05 +01:00
Martin Sukany
4449c5a3bd Merge remote-tracking branch 'refs/remotes/origin/master' 2026-02-26 15:45:38 +01:00
Martin Sukany
f9403bc6a4 Merge remote-tracking branch 'refs/remotes/origin/master' 2026-02-26 15:40:40 +01:00
Martin Sukany
14a0b703ec Merge remote-tracking branch 'refs/remotes/origin/master' 2026-02-26 15:22:33 +01:00
Martin Sukany
77bd9fb84c Merge remote-tracking branch 'refs/remotes/origin/master' 2026-02-26 15:18:34 +01:00
Martin Sukany
68eb568810 Merge remote-tracking branch 'refs/remotes/origin/master' 2026-02-26 14:54:07 +01:00
Martin Sukany
a2c9911171 Merge remote-tracking branch 'refs/remotes/origin/master' 2026-02-26 14:32:25 +01:00
Martin Sukany
cd53ce26cc Merge remote-tracking branch 'refs/remotes/origin/master' 2026-02-26 14:13:03 +01:00
Martin Sukany
5ded624f7b Merge remote-tracking branch 'refs/remotes/origin/master' 2026-02-26 13:51:11 +01:00
Martin Sukany
6d185c880a Merge remote-tracking branch 'refs/remotes/origin/master' 2026-02-26 13:27:31 +01:00
Martin Sukany
05fc543958 Merge remote-tracking branch 'refs/remotes/origin/master' 2026-02-26 13:22:44 +01:00
Martin Sukany
5b12e8c435 Merge remote-tracking branch 'refs/remotes/origin/master' 2026-02-26 12:58:04 +01:00
Martin Sukany
be642808cc Merge remote-tracking branch 'refs/remotes/origin/master' 2026-02-26 12:56:54 +01:00
Martin Sukany
fd03f2e7c4 Merge remote-tracking branch 'refs/remotes/origin/master' 2026-02-26 12:51:36 +01:00
Martin Sukany
bfe29f8da8 Merge remote-tracking branch 'refs/remotes/origin/master' 2026-02-26 12:49:37 +01:00
Martin Sukany
6724922592 Merge remote-tracking branch 'refs/remotes/origin/master' 2026-02-26 11:58:23 +01:00
e40d502d43 ns: fix VoiceOver cursor sync — 8 changes from pipeline review
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
2026-02-26 11:44:13 +01:00
13 changed files with 5739 additions and 1661 deletions

9
TODO.org Normal file
View File

@@ -0,0 +1,9 @@
#+title: Todo
* Emacs patch
Po poslednim testovani:
- dired i completions navigace funguje sipkama
- dired i completions - kdyz naviguju emacs zpusobem C-n, C-p, atp, tak to nefunguje, cte to neco uplne jineho, nechapu proc
- V completions stale nefunguje TAB, chova se to stejne jako minule (myslim tim ten hightlighting).
Mozna jsi zapomnel kontext, protoze jsme se prepnuli z Anthropic na OpenAI modely, takze v pripade potreby nacti kontext z NOW.md a memory.

272
config.el
View File

@@ -7,6 +7,13 @@
(setq user-full-name "Martin Sukany"
user-mail-address "martin@sukany.cz")
;; CalDAV calendar IDs (edit here to update sync targets)
(defconst my/caldav-url "https://cal.apps.sukany.cz/dav.php/calendars/martin")
(defconst my/caldav-id-suky "default")
(defconst my/caldav-id-placeholders "4C748EE5-ECFF-4D4A-A72E-6DE37BAADEB3")
(defconst my/caldav-id-family "family")
(defconst my/caldav-id-klara "klara")
;; Trust all TLS certificates (corporate MITM proxy with intermediate CA)
(setq gnutls-verify-error nil)
(setq tls-checktrust nil)
@@ -97,7 +104,9 @@
;; Let Evil use the system clipboard
(after! evil
(setq evil-want-clipboard t))
(setq evil-want-clipboard t)
;; Ensure dashboard buffer starts in normal state (required for SPC leader)
(evil-set-initial-state '+doom-dashboard-mode 'normal))
;; Standard macOS modifier keys for GUI Emacs
(when (display-graphic-p)
@@ -128,10 +137,6 @@
(global-set-key [wheel-up] #'mwheel-scroll)
(global-set-key [wheel-down] #'mwheel-scroll))
;; Ensure dashboard buffer starts in normal state (required for SPC leader)
(after! evil
(evil-set-initial-state '+doom-dashboard-mode 'normal))
;; Cancel persp-mode's 2.5s cache timer after startup
;; (reduces unnecessary redraws that cause macOS Zoom to jump)
(run-with-timer 3 nil
@@ -265,6 +270,7 @@
;; Visual: hide markup, pretty entities, compact tags
(setq org-startup-indented nil ; conflicts with org-modern star display
org-startup-folded 'content ; show all headings, hide body text
org-hide-emphasis-markers t
org-pretty-entities t
org-ellipsis ""
@@ -630,12 +636,15 @@ Skip for beamer exports — beamer uses adjustbox on plain tabular."
(after! corfu
(setq corfu-auto t
corfu-auto-delay 1.0
corfu-auto-prefix 2
corfu-auto-delay 2.0
corfu-auto-prefix 3 ; need 3+ chars before popup
corfu-cycle t
corfu-preselect 'prompt
corfu-quit-no-match 'separator
corfu-preview-current nil)
;; Re-set delay after global-corfu-mode to override Doom defaults
(add-hook 'global-corfu-mode-hook
(lambda () (setq corfu-auto-delay 2.0)))
(global-corfu-mode))
(use-package! cape
@@ -679,6 +688,7 @@ Skip for beamer exports — beamer uses adjustbox on plain tabular."
(expand-file-name "/opt/homebrew/opt/mu/share/emacs/site-lisp/mu/mu4e"))
(after! mu4e
;; --- Mailbox layout ---
(setq mu4e-maildir "~/.mail"
mu4e-get-mail-command "mbsync personal"
mu4e-update-interval 300
@@ -687,8 +697,10 @@ Skip for beamer exports — beamer uses adjustbox on plain tabular."
mu4e-sent-folder "/personal/Sent"
mu4e-drafts-folder "/personal/Drafts"
mu4e-trash-folder "/personal/Trash"
mu4e-refile-folder "/personal/Archive"
mu4e-headers-show-threads t
mu4e-refile-folder "/personal/Archive")
;; --- Headers view ---
(setq mu4e-headers-show-threads t
mu4e-headers-include-related t
mu4e-use-fancy-chars t
mu4e-headers-mark-for-thread t
@@ -706,25 +718,63 @@ Skip for beamer exports — beamer uses adjustbox on plain tabular."
mu4e-headers-thread-last-child-prefix '("└>" . "└▶ ")
mu4e-headers-thread-duplicate-prefix '("=" . ""))
;; Bookmarks — unread excludes Trash/Archive/Sent/Drafts/Spam
;; --- Bookmarks ---
;; Keys: u=Unread i=Inbox d=Today w=Week
;; Note: ?d for Today avoids conflict with maildir shortcut ?t=Trash
(setq mu4e-bookmarks
'((:name "Unread"
:query "flag:unread AND NOT maildir:/personal/Trash AND NOT maildir:/personal/Archive AND NOT maildir:/personal/Sent AND NOT maildir:/personal/Drafts AND NOT maildir:/personal/Spam"
:key ?u)
(:name "Inbox" :query "maildir:/personal/INBOX" :key ?i)
(:name "Today" :query "date:today AND NOT maildir:/personal/Trash AND NOT maildir:/personal/Archive AND NOT maildir:/personal/Sent" :key ?t)
(:name "Today" :query "date:today AND NOT maildir:/personal/Trash AND NOT maildir:/personal/Archive AND NOT maildir:/personal/Sent" :key ?d)
(:name "Week" :query "date:7d..now AND NOT maildir:/personal/Trash AND NOT maildir:/personal/Archive AND NOT maildir:/personal/Sent" :key ?w)))
;; Do not cite sender's signature in replies
(setq message-cite-function #'message-cite-original-without-signature)
;; --- Maildir shortcuts (jump with 'j') ---
;; Keys: i=INBOX s=Sent T=Trash a=Archive
;; ?T (uppercase) for Trash avoids conflict with bookmark ?t (was Today)
(setq mu4e-maildir-shortcuts
'(("/personal/INBOX" . ?i)
("/personal/Sent" . ?s)
("/personal/Trash" . ?T)
("/personal/Archive" . ?a)))
;; Signature from file
(setq message-signature-file (expand-file-name "~/.mail/signature")
message-signature t)
;; --- Sending ---
(setq sendmail-program "msmtp"
message-send-mail-function #'message-send-mail-with-sendmail
mail-specify-envelope-from t
message-sendmail-envelope-from 'header)
;; Move cursor past headers to message body when opening a message
;; --- Compose / reply ---
;; message-cite-function is a message-mode setting but configured here
;; because it only matters in the context of mu4e replies.
(setq message-cite-function #'message-cite-original-without-signature
message-signature-file (expand-file-name "~/.mail/signature")
message-signature t)
;; --- Citation colors ---
;; gnus-cite-* : colors in the view (read) buffer
;; message-cited-text-*: colors in the compose (reply) buffer
;; Both use the same Dracula palette for visual consistency.
;; Duplicate face names are intentional — gnus and message-mode
;; use separate face systems even though they render the same content.
(setq gnus-cite-face-list
'(gnus-cite-1 gnus-cite-2 gnus-cite-3 gnus-cite-4))
(custom-set-faces!
'(gnus-cite-1 :foreground "#8be9fd" :italic t) ; cyan — level 1
'(gnus-cite-2 :foreground "#bd93f9" :italic t) ; purple — level 2
'(gnus-cite-3 :foreground "#6272a4" :italic t) ; blue — level 3
'(gnus-cite-4 :foreground "#44475a" :italic t) ; grey — level 4+
'(message-cited-text-1 :foreground "#8be9fd" :italic t)
'(message-cited-text-2 :foreground "#bd93f9" :italic t)
'(message-cited-text-3 :foreground "#6272a4" :italic t)
'(message-cited-text-4 :foreground "#44475a" :italic t))
;; --- View: skip to message body ---
;; gnus-article-prepare-hook fires when the article buffer is ready,
;; covering both the initial render and navigation between messages.
;; No need for mu4e-view-mode-hook (that fires earlier, before content).
(defun my/mu4e-view-goto-body ()
"Position cursor at the start of the message body, skipping headers."
"Position cursor at message body, skipping RFC 2822 headers."
(run-with-idle-timer
0.05 nil
(lambda ()
@@ -732,23 +782,15 @@ Skip for beamer exports — beamer uses adjustbox on plain tabular."
(with-current-buffer buf
(goto-char (point-min))
(while (and (not (eobp))
(looking-at "^\\([A-Za-z-]+:\\|[ \t]\\)"))
(looking-at "^\([A-Za-z-]+:\|[ \t]\)"))
(forward-line 1))
(while (and (not (eobp)) (looking-at "^\\s-*$"))
(while (and (not (eobp)) (looking-at "^\s-*$"))
(forward-line 1)))))))
(add-hook 'gnus-article-prepare-hook #'my/mu4e-view-goto-body)
(add-hook 'mu4e-view-mode-hook #'my/mu4e-view-goto-body)
;; Maildir shortcuts
(setq mu4e-maildir-shortcuts
'(("/personal/INBOX" . ?i)
("/personal/Sent" . ?s)
("/personal/Trash" . ?t)
("/personal/Archive" . ?a)))
;; Cursor on subject column after j/k navigation
;; --- Headers: keep cursor on subject column after j/k ---
(defun my/mu4e-goto-subject (&rest _)
"Move cursor to the start of the subject text in a mu4e headers line."
"Move cursor to the start of the subject text in a headers line."
(when (derived-mode-p 'mu4e-headers-mode)
(let* ((msg (mu4e-message-at-point t))
(subject (when msg (mu4e-message-field msg :subject))))
@@ -760,22 +802,14 @@ Skip for beamer exports — beamer uses adjustbox on plain tabular."
(advice-add 'mu4e-headers-next :after #'my/mu4e-goto-subject)
(advice-add 'mu4e-headers-prev :after #'my/mu4e-goto-subject)
;; zT = toggle thread view (T alone marks thread for bulk action)
;; zT = toggle thread view (plain T marks the thread for bulk action)
(evil-define-key 'normal mu4e-headers-mode-map
(kbd "zT") #'mu4e-headers-toggle-threading))
(after! mu4e
(setq sendmail-program "msmtp"
message-send-mail-function #'message-send-mail-with-sendmail
mail-specify-envelope-from t
message-sendmail-envelope-from 'header))
;;; ============================================================
;;; RSS — ELFEED
;;; ============================================================
(map! :leader :desc "Elfeed" "o r" #'elfeed)
(after! org
(setq rmh-elfeed-org-files
@@ -865,6 +899,17 @@ Skip for beamer exports — beamer uses adjustbox on plain tabular."
;;; DIRED & DIRVISH
;;; ============================================================
;; Always hide file details (permissions, size, date) for VoiceOver.
;; Toggle visibility with ( in dired/dirvish buffers.
;; Three layers of insurance: dirvish-hide-details, dired-mode-hook,
;; and dired-after-readin-hook (catches late buffer setup).
(add-hook 'dired-mode-hook #'dired-hide-details-mode)
;; Attach marked files to mail compose buffer via C-c RET C-a
(add-hook 'dired-mode-hook #'turn-on-gnus-dired-mode)
(add-hook 'dired-after-readin-hook
(lambda () (unless dired-hide-details-mode
(dired-hide-details-mode 1))))
;; Emacs 31 may not autoload dired-read-dir-and-switches early enough
(require 'dired)
;; macOS: use GNU ls (coreutils) for dired/dirvish sorting support
@@ -876,14 +921,27 @@ Skip for beamer exports — beamer uses adjustbox on plain tabular."
"RET" #'dired-find-alternate-file
"^" #'dired-up-directory))
;; Dirvish — modern dired replacement
(use-package! dirvish
:init (dirvish-override-dired-mode)
:config
(setq dirvish-mode-line-format '(:left (sort symlink) :right (omit yank index))
dirvish-attributes '(vc-state subtree-state nerd-icons collapse git-msg file-time file-size)
(setq dirvish-hide-details t
dirvish-mode-line-format '(:left (sort symlink) :right (omit yank index))
dirvish-attributes '(vc-state subtree-state nerd-icons collapse git-msg)
dirvish-side-width 35)
(defun my/dirvish-toggle-details ()
"Toggle file-time and file-size dirvish attributes."
(interactive)
(if (memq 'file-size dirvish-attributes)
(setq-local dirvish-attributes
(seq-remove (lambda (a) (memq a '(file-time file-size)))
dirvish-attributes))
(setq-local dirvish-attributes
(append dirvish-attributes '(file-time file-size))))
(revert-buffer))
(map! :map dirvish-mode-map
:n "D" #'my/dirvish-toggle-details
:n "q" #'dirvish-quit
:n "h" #'dired-up-directory
:n "l" #'dired-find-file
@@ -1079,10 +1137,6 @@ Skip for beamer exports — beamer uses adjustbox on plain tabular."
(setq dtk-speaker-process nil))
(message "Emacspeak OFF (server restart inhibited)"))
(map! :leader
(:prefix ("t" . "toggle")
:desc "Speech ON" "s" #'my/emacspeak-on
:desc "Speech OFF" "S" #'my/emacspeak-off))
(with-eval-after-load 'dtk-speak
(setq dtk-speech-rate-base 300)
@@ -1175,23 +1229,6 @@ Skip for beamer exports — beamer uses adjustbox on plain tabular."
scroll-margin 2)
;;; ============================================================
;;; KEYBINDINGS
;;; ============================================================
(map! :leader
(:prefix ("h" . "help")
:desc "Describe bindings (buffer-local)" "B" #'describe-bindings))
(map! :leader
(:prefix ("z" . "zoom")
:desc "Zoom in (x1.5)" "+" #'my/zoom-in
:desc "Zoom in (x1.5)" "=" #'my/zoom-in
:desc "Zoom out (x1.5)" "-" #'my/zoom-out
:desc "Reset" "0" #'my/zoom-reset
:desc "Restore previous" "z" #'my/zoom-restore))
;;; ============================================================
;;; MATRIX — EMENT.EL
;;; ============================================================
@@ -1358,9 +1395,7 @@ Skip for beamer exports — beamer uses adjustbox on plain tabular."
;;; WRITING — olivetti-mode
;;; ============================================================
(use-package! olivetti
:defer t
:config
(after! olivetti
(setq olivetti-body-width 90))
(add-hook 'org-mode-hook (lambda () (when buffer-file-name (olivetti-mode 1))))
@@ -1406,27 +1441,46 @@ Skip for beamer exports — beamer uses adjustbox on plain tabular."
(use-package! org-super-agenda
:after org-agenda
:config
;; Agenda: start from today, no past days, no duplicates
(setq org-agenda-start-on-weekday nil
org-agenda-start-day "0d"
org-agenda-span 7
org-agenda-skip-scheduled-if-done t
org-agenda-skip-deadline-if-done t
org-agenda-skip-scheduled-if-deadline-is-shown t
org-agenda-skip-deadline-prewarning-if-scheduled 'pre-scheduled)
;; Sorting: priority first, then deadline, then scheduled
(setq org-agenda-sorting-strategy
'((agenda priority-down deadline-up scheduled-up)
(todo priority-down deadline-up)
(tags priority-down deadline-up)))
(setq org-super-agenda-groups
'((:name "Kyndryl — today"
:and (:tag ("kyndryl" "work") :scheduled today))
(:name "Kyndryl — deadline"
:and (:tag ("kyndryl" "work") :deadline t))
(:name "Kyndryl"
:tag ("kyndryl" "work"))
(:name "ZTJ — today"
:and (:tag "ztj" :scheduled today))
(:name "ZTJ"
:tag "ztj")
(:name "Today"
:scheduled today
'((:name "Overdue"
:deadline past)
(:name "Due today"
:deadline today)
(:name "Scheduled today"
:scheduled today)
(:name "Due soon"
:deadline future)
(:name "Waiting"
:todo "WAIT")
(:name "Kyndryl"
:tag ("kyndryl" "work"))
(:name "ZTJ"
:tag "ztj")
(:name "Other"
:anything t))))
(after! org-super-agenda
(org-super-agenda-mode 1))
(org-super-agenda-mode 1)
;; Fix: org-super-agenda applies its own keymap to group headers
;; via text properties, overriding org-agenda keybindings.
;; Setting it to nil disables the header keymap entirely so all
;; standard agenda keys (m, B, t, etc.) work on every line.
(setq org-super-agenda-header-map nil))
;;; ============================================================
@@ -1553,32 +1607,32 @@ Skip for beamer exports — beamer uses adjustbox on plain tabular."
(insert "#+TITLE: CalDAV sync\n#+STARTUP: overview\n"))))
;; 1. Suky (twoway): download -> suky.org, upload from calendar_outbox.org
(setq org-caldav-url "https://cal.apps.sukany.cz/dav.php/calendars/martin"
org-caldav-calendar-id "default"
(setq org-caldav-url my/caldav-url
org-caldav-calendar-id my/caldav-id-suky
org-caldav-inbox "~/org/caldav/suky.org"
org-caldav-files '("~/org/calendar_outbox.org")
org-caldav-sync-direction 'twoway)
(org-caldav-sync)
;; 2. Placeholders (read-only)
(setq org-caldav-url "https://cal.apps.sukany.cz/dav.php/calendars/martin"
org-caldav-calendar-id "4C748EE5-ECFF-4D4A-A72E-6DE37BAADEB3"
(setq org-caldav-url my/caldav-url
org-caldav-calendar-id my/caldav-id-placeholders
org-caldav-inbox "~/org/caldav/placeholders.org"
org-caldav-files nil
org-caldav-sync-direction 'cal->org)
(org-caldav-sync)
;; 3. Family (read-only, shared via Baikal ACL)
(setq org-caldav-url "https://cal.apps.sukany.cz/dav.php/calendars/martin"
org-caldav-calendar-id "family"
(setq org-caldav-url my/caldav-url
org-caldav-calendar-id my/caldav-id-family
org-caldav-inbox "~/org/caldav/family.org"
org-caldav-files nil
org-caldav-sync-direction 'cal->org)
(org-caldav-sync)
;; 4. Klara (read-only, shared via Baikal ACL)
(setq org-caldav-url "https://cal.apps.sukany.cz/dav.php/calendars/martin"
org-caldav-calendar-id "klara"
(setq org-caldav-url my/caldav-url
org-caldav-calendar-id my/caldav-id-klara
org-caldav-inbox "~/org/caldav/klara.org"
org-caldav-files nil
org-caldav-sync-direction 'cal->org)
@@ -1586,7 +1640,6 @@ Skip for beamer exports — beamer uses adjustbox on plain tabular."
(message "CalDAV sync done: Suky + Placeholders + Family + Klara")))
(map! :leader "o c" #'my/org-caldav-sync)
;;; ============================================================
@@ -1775,7 +1828,6 @@ Formats matching what org-caldav/ox-icalendar export correctly:
:n "l" #'kubel-get-pod-logs
:n "d" #'kubel-describe-resource
:n "D" #'kubel-delete-resource))
(map! :leader "o k" #'kubel)
;;; ============================================================
@@ -1821,12 +1873,10 @@ Formats matching what org-caldav/ox-icalendar export correctly:
;;; ============================================================
(use-package! iedit :commands iedit-mode)
(map! :leader "s e" #'iedit-mode)
(use-package! vundo
:commands vundo
:config (setq vundo-glyph-alist vundo-unicode-symbols))
(map! :leader "u" #'vundo)
(use-package! breadcrumb
:hook ((prog-mode . breadcrumb-local-mode)
@@ -2031,7 +2081,6 @@ Formats matching what org-caldav/ox-icalendar export correctly:
(setq org-roam-ui-sync-theme t
org-roam-ui-follow t
org-roam-ui-update-on-save t))
(map! :leader "n r u" #'org-roam-ui-mode)
;;; ============================================================
@@ -2043,9 +2092,50 @@ Formats matching what org-caldav/ox-icalendar export correctly:
(expand-file-name "~/languagetool/languagetool-commandline.jar")
langtool-default-language "cs"
langtool-mother-tongue "cs"))
;;; ============================================================
;;; KEYBINDINGS — central reference
;;; ============================================================
;;; Standalone bindings (package-independent, safe to define top-level).
;;
;; The following bindings are defined near their packages (load-order sensitive):
;; SPC o r — elfeed (elfeed section)
;; SPC o n/N — org-noter (org-noter section)
;; SPC o C — calendar/calfw (calfw section)
;; SPC j k/K — link-hint (link-hint section)
;; SPC t o — olivetti (olivetti section)
;; SPC z +/= — zoom in/out (keybindings section below)
;; SPC o M — Matrix/ement (Matrix section)
;; SPC B b/i — bibliography/citar (bibliography section)
;; SPC s q/Q — org-ql (org-ql section)
;; SPC | di| ci| vi| — table cell objects (evil-org section)
(map! :leader
(:prefix ("z" . "zoom")
:desc "Zoom in (x1.5)" "+" #'my/zoom-in
:desc "Zoom in (x1.5)" "=" #'my/zoom-in
:desc "Zoom out (x1.5)" "-" #'my/zoom-out
:desc "Reset" "0" #'my/zoom-reset
:desc "Restore previous" "z" #'my/zoom-restore))
(map! :leader :desc "Elfeed" "o r" #'elfeed)
(map! :leader
(:prefix ("t" . "toggle")
:desc "Speech ON" "s" #'my/emacspeak-on
:desc "Speech OFF" "S" #'my/emacspeak-off))
(map! :leader
(:prefix ("h" . "help")
:desc "Describe bindings (buffer-local)" "B" #'describe-bindings))
(map! :leader "o k" #'kubel)
(map! :leader "s e" #'iedit-mode)
(map! :leader "u" #'vundo)
(map! :leader "n r u" #'org-roam-ui-mode)
(map! :leader
"t g" #'langtool-check
"t G" #'langtool-check-done)
(map! :leader "o c" #'my/org-caldav-sync)
;; gls
(setq insert-directory-program "gls")

View File

@@ -0,0 +1,682 @@
From d176c3c9d97574f0cd493d6491eda0a82ad28387 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 12:58:11 +0100
Subject: [PATCH 1/8] ns: add accessibility base classes and text extraction
Add the foundation for macOS VoiceOver accessibility in the NS
(Cocoa) port. No existing code paths are modified.
* src/nsterm.h (ns_ax_visible_run): New struct.
(EmacsAccessibilityElement): New base class.
(EmacsAccessibilityBuffer, EmacsAccessibilityModeLine)
(EmacsAccessibilityInteractiveSpan): Forward declarations.
(EmacsAccessibilityBuffer(Notifications)): New category interface.
(EmacsAccessibilityBuffer(InteractiveSpans)): New category interface.
(EmacsAXSpanType): New enum.
(EmacsView): New ivars for accessibility state.
* src/nsterm.m: Include intervals.h for TEXT_PROP_MEANS_INVISIBLE.
(ns_ax_buffer_text, ns_ax_mode_line_text, ns_ax_frame_for_range)
(ns_ax_completion_string_from_prop, ns_ax_window_buffer_object)
(ns_ax_window_end_charpos, ns_ax_text_prop_at)
(ns_ax_next_prop_change, ns_ax_get_span_label)
(ns_ax_post_notification, ns_ax_post_notification_with_info): New
functions.
(EmacsAccessibilityElement): Implement base class.
(syms_of_nsterm): Register accessibility DEFSYM and DEFVAR
ns-accessibility-enabled.
Tested on macOS 14 Sonoma with VoiceOver 10. Builds cleanly;
no functional change (dead code until patch 5/6 wires it in).
---
src/nsterm.h | 131 +++++++++++++++
src/nsterm.m | 456 +++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 587 insertions(+)
diff --git a/src/nsterm.h b/src/nsterm.h
index 7c1ee4c..5298386 100644
--- a/src/nsterm.h
+++ b/src/nsterm.h
@@ -453,6 +453,122 @@ enum ns_return_frame_mode
@end
+/* ==========================================================================
+
+ Accessibility virtual elements (macOS / Cocoa only)
+
+ ========================================================================== */
+
+#ifdef NS_IMPL_COCOA
+@class EmacsView;
+
+/* Base class for virtual accessibility elements attached to EmacsView. */
+@interface EmacsAccessibilityElement : NSAccessibilityElement
+@property (nonatomic, unsafe_unretained) EmacsView *emacsView;
+/* Lisp window object — safe across GC cycles.
+ GC safety: these Lisp_Objects are NOT visible to GC via staticpro
+ or the specpdl stack. This is safe because:
+ (1) Emacs GC runs only on the main thread, at well-defined safe
+ points during Lisp evaluation — never during redisplay.
+ (2) Accessibility elements are owned by EmacsView which belongs to
+ an active frame; windows referenced here are always reachable
+ from the frame's window tree until rebuildAccessibilityTree
+ updates them during the next redisplay cycle.
+ (3) AX getters dispatch_sync to main before accessing Lisp state,
+ so GC cannot run concurrently with any access to lispWindow.
+ (4) validWindow checks WINDOW_LIVE_P before dereferencing. */
+@property (nonatomic, assign) Lisp_Object lispWindow;
+- (struct window *)validWindow; /* Returns live window or NULL. */
+- (NSRect)screenRectFromEmacsX:(int)x y:(int)y width:(int)w height:(int)h;
+@end
+
+/* A visible run: maps a contiguous range of accessibility indices
+ to a contiguous range of buffer character positions. Invisible
+ text is skipped, so ax_start values are consecutive across runs
+ while charpos values may have gaps. */
+typedef struct ns_ax_visible_run
+{
+ ptrdiff_t charpos; /* Buffer charpos where this visible run starts. */
+ ptrdiff_t length; /* Number of visible Emacs characters in this run. */
+ NSUInteger ax_start; /* Starting index in the accessibility string. */
+ NSUInteger ax_length; /* Length in accessibility string (UTF-16 units). */
+} ns_ax_visible_run;
+
+/* Virtual AXTextArea element — one per visible Emacs window (buffer). */
+@interface EmacsAccessibilityBuffer : EmacsAccessibilityElement <NSAccessibility>
+{
+ ns_ax_visible_run *visibleRuns;
+ NSUInteger visibleRunCount;
+ NSUInteger *lineStartOffsets; /* AX string index of each line start. */
+ NSUInteger lineCount; /* Number of entries in lineStartOffsets. */
+ NSMutableArray *cachedInteractiveSpans;
+ BOOL interactiveSpansDirty;
+}
+@property (nonatomic, retain) NSString *cachedText;
+@property (nonatomic, assign) ptrdiff_t cachedTextModiff;
+@property (nonatomic, assign) ptrdiff_t cachedOverlayModiff;
+@property (nonatomic, assign) ptrdiff_t cachedTextStart;
+@property (nonatomic, assign) ptrdiff_t cachedModiff;
+@property (nonatomic, assign) ptrdiff_t cachedPoint;
+@property (nonatomic, assign) BOOL cachedMarkActive;
+@property (nonatomic, copy) NSString *cachedCompletionAnnouncement;
+@property (nonatomic, assign) ptrdiff_t cachedCompletionOverlayStart;
+@property (nonatomic, assign) ptrdiff_t cachedCompletionOverlayEnd;
+@property (nonatomic, assign) ptrdiff_t cachedCompletionPoint;
+- (void)invalidateTextCache;
+- (NSInteger)lineForAXIndex:(NSUInteger)idx;
+- (NSRange)rangeForLine:(NSUInteger)line textLength:(NSUInteger)tlen;
+- (ptrdiff_t)charposForAccessibilityIndex:(NSUInteger)ax_idx;
+- (NSUInteger)accessibilityIndexForCharpos:(ptrdiff_t)charpos;
+@end
+
+@interface EmacsAccessibilityBuffer (Notifications)
+- (void)postTextChangedNotification:(ptrdiff_t)point;
+- (void)postAccessibilityNotificationsForFrame:(struct frame *)f;
+@end
+
+@interface EmacsAccessibilityBuffer (InteractiveSpans)
+- (void)invalidateInteractiveSpans;
+@end
+
+/* Virtual AXStaticText element — one per mode line. */
+@interface EmacsAccessibilityModeLine : EmacsAccessibilityElement
+@end
+
+/* Span types for interactive AX child elements. */
+typedef NS_ENUM (NSInteger, EmacsAXSpanType)
+{
+ EmacsAXSpanTypeNone = -1,
+ EmacsAXSpanTypeButton = 0,
+ EmacsAXSpanTypeLink = 1,
+ EmacsAXSpanTypeCompletionItem = 2,
+ EmacsAXSpanTypeWidget = 3,
+};
+
+/* A lightweight AX element representing one interactive text span
+ (button, link, checkbox, completion candidate, etc.) within a buffer
+ window. Exposed as AX child of EmacsAccessibilityBuffer so VoiceOver
+ Tab navigation can reach individual interactive elements. */
+@interface EmacsAccessibilityInteractiveSpan : EmacsAccessibilityElement
+
+@property (nonatomic, assign) ptrdiff_t charposStart;
+@property (nonatomic, assign) ptrdiff_t charposEnd;
+@property (nonatomic, assign) EmacsAXSpanType spanType;
+@property (nonatomic, copy) NSString *spanLabel;
+@property (nonatomic, copy) NSString *spanValue;
+@property (nonatomic, unsafe_unretained) EmacsAccessibilityBuffer *parentBuffer;
+
+- (NSAccessibilityRole) accessibilityRole;
+- (NSString *) accessibilityLabel;
+- (NSRect) accessibilityFrame;
+- (BOOL) isAccessibilityElement;
+- (BOOL) isAccessibilityFocused;
+- (void) setAccessibilityFocused: (BOOL) focused;
+
+@end
+#endif /* NS_IMPL_COCOA */
+
+
/* ==========================================================================
The main Emacs view
@@ -471,6 +587,14 @@ enum ns_return_frame_mode
#ifdef NS_IMPL_COCOA
char *old_title;
BOOL maximizing_resize;
+ NSMutableArray *accessibilityElements;
+ /* See GC safety comment on EmacsAccessibilityElement.lispWindow. */
+ Lisp_Object lastSelectedWindow;
+ Lisp_Object lastRootWindow;
+ BOOL accessibilityTreeValid;
+ BOOL accessibilityUpdating;
+ @public /* Accessed by ns_draw_phys_cursor (C function). */
+ NSRect lastAccessibilityCursorRect;
#endif
BOOL font_panel_active;
NSFont *font_panel_result;
@@ -528,6 +652,13 @@ enum ns_return_frame_mode
- (void)windowWillExitFullScreen;
- (void)windowDidExitFullScreen;
- (void)windowDidBecomeKey;
+
+#ifdef NS_IMPL_COCOA
+/* Accessibility support. */
+- (void)rebuildAccessibilityTree;
+- (void)invalidateAccessibilityTree;
+- (void)postAccessibilityUpdates;
+#endif
@end
diff --git a/src/nsterm.m b/src/nsterm.m
index 74e4ad5..2ac1d9d 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -46,6 +46,7 @@ GNUstep port and post-20 update by Adrian Robert (arobert@cogsci.ucsd.edu)
#include "blockinput.h"
#include "sysselect.h"
#include "nsterm.h"
+#include "intervals.h" /* TEXT_PROP_MEANS_INVISIBLE */
#include "systime.h"
#include "character.h"
#include "xwidget.h"
@@ -6856,6 +6857,430 @@ ns_create_font_panel_buttons (id target, SEL select, SEL cancel_action)
}
#endif
+/* ==========================================================================
+
+ Accessibility virtual elements (macOS / Cocoa only)
+
+ ========================================================================== */
+
+#ifdef NS_IMPL_COCOA
+
+/* ---- Helper: extract buffer text for accessibility ---- */
+
+/* Build accessibility text for window W, skipping invisible text.
+ Populates *OUT_START with the buffer start charpos.
+ Populates *OUT_RUNS with an array of visible runs and *OUT_NRUNS
+ with the count. Caller must free *OUT_RUNS with xfree(). */
+
+static NSString *
+ns_ax_buffer_text (struct window *w, ptrdiff_t *out_start,
+ ns_ax_visible_run **out_runs, NSUInteger *out_nruns)
+{
+ *out_runs = NULL;
+ *out_nruns = 0;
+
+ if (!w || !WINDOW_LEAF_P (w))
+ {
+ *out_start = 0;
+ return @"";
+ }
+
+ struct buffer *b = XBUFFER (w->contents);
+ if (!b)
+ {
+ *out_start = 0;
+ return @"";
+ }
+
+ ptrdiff_t begv = BUF_BEGV (b);
+ ptrdiff_t zv = BUF_ZV (b);
+
+ *out_start = begv;
+
+ if (zv <= begv)
+ return @"";
+
+ specpdl_ref count = SPECPDL_INDEX ();
+ record_unwind_current_buffer ();
+ if (b != current_buffer)
+ set_buffer_internal_1 (b);
+
+ /* First pass: count visible runs to allocate the mapping array. */
+ NSUInteger run_capacity = 64;
+ ns_ax_visible_run *runs = xmalloc (run_capacity
+ * sizeof (ns_ax_visible_run));
+ NSUInteger nruns = 0;
+ NSUInteger ax_offset = 0;
+
+ NSMutableString *result = [NSMutableString string];
+ ptrdiff_t pos = begv;
+
+ while (pos < zv)
+ {
+ /* Check invisible property (text properties + overlays).
+ Use TEXT_PROP_MEANS_INVISIBLE which respects buffer-invisibility-spec,
+ matching the logic in xdisp.c. This correctly handles org-mode,
+ outline-mode, hideshow and any mode using spec-controlled
+ invisibility (not just `invisible t'). */
+ Lisp_Object invis = Fget_char_property (make_fixnum (pos),
+ Qinvisible, Qnil);
+ if (TEXT_PROP_MEANS_INVISIBLE (invis))
+ {
+ /* Skip to the next position where invisible changes. */
+ Lisp_Object next = Fnext_single_char_property_change (
+ make_fixnum (pos), Qinvisible, Qnil, make_fixnum (zv));
+ pos = FIXNUMP (next) ? XFIXNUM (next) : zv;
+ continue;
+ }
+
+ /* Find end of this visible run: where invisible property changes. */
+ Lisp_Object next = Fnext_single_char_property_change (
+ make_fixnum (pos), Qinvisible, Qnil, make_fixnum (zv));
+ ptrdiff_t run_end = FIXNUMP (next) ? XFIXNUM (next) : zv;
+
+ ptrdiff_t run_len = run_end - pos;
+
+ /* Extract this visible run's text. Use
+ Fbuffer_substring_no_properties which correctly handles the
+ buffer gap — raw BUF_BYTE_ADDRESS reads across the gap would
+ include garbage bytes when the run spans the gap position. */
+ Lisp_Object lstr = Fbuffer_substring_no_properties (
+ make_fixnum (pos), make_fixnum (run_end));
+ NSString *nsstr = [NSString stringWithLispString:lstr];
+ NSUInteger ns_len = [nsstr length];
+ [result appendString:nsstr];
+
+ /* Record this visible run in the mapping. */
+ if (nruns >= run_capacity)
+ {
+ run_capacity *= 2;
+ runs = xrealloc (runs, run_capacity
+ * sizeof (ns_ax_visible_run));
+ }
+ runs[nruns].charpos = pos;
+ runs[nruns].length = run_len;
+ runs[nruns].ax_start = ax_offset;
+ runs[nruns].ax_length = ns_len;
+ nruns++;
+
+ ax_offset += ns_len;
+ pos = run_end;
+ }
+
+ unbind_to (count, Qnil);
+
+ *out_runs = runs;
+ *out_nruns = nruns;
+ return result;
+}
+
+
+/* ---- Helper: extract mode line text from glyph rows ---- */
+
+/* TODO: Only CHAR_GLYPH characters (>= 32) are extracted. Image
+ glyphs, stretch glyphs, and composed glyphs are silently skipped.
+ Mode lines using icon fonts (e.g. doom-modeline with nerd-font)
+ will produce incomplete accessibility text. */
+static NSString *
+ns_ax_mode_line_text (struct window *w)
+{
+ if (!w || !w->current_matrix)
+ return @"";
+
+ struct glyph_matrix *matrix = w->current_matrix;
+ NSMutableString *text = [NSMutableString string];
+
+ for (int i = 0; i < matrix->nrows; i++)
+ {
+ struct glyph_row *row = matrix->rows + i;
+ if (!row->enabled_p || !row->mode_line_p)
+ continue;
+
+ struct glyph *g = row->glyphs[TEXT_AREA];
+ struct glyph *end = g + row->used[TEXT_AREA];
+ for (; g < end; g++)
+ {
+ if (g->type == CHAR_GLYPH && g->u.ch >= 32)
+ {
+ unichar uch = (unichar) g->u.ch;
+ [text appendString:[NSString stringWithCharacters:&uch
+ length:1]];
+ }
+ }
+ }
+ return text;
+}
+
+
+/* ---- Helper: screen rect for a character range via glyph matrix ---- */
+
+static NSRect
+ns_ax_frame_for_range (struct window *w, EmacsView *view,
+ ptrdiff_t charpos_start,
+ ptrdiff_t charpos_len)
+{
+ if (!w || !w->current_matrix || !view)
+ return NSZeroRect;
+
+ /* charpos_start and charpos_len are already in buffer charpos
+ space — the caller maps AX string indices through
+ charposForAccessibilityIndex which handles invisible text. */
+ ptrdiff_t cp_start = charpos_start;
+ ptrdiff_t cp_end = cp_start + charpos_len;
+
+ struct glyph_matrix *matrix = w->current_matrix;
+ NSRect result = NSZeroRect;
+ BOOL found = NO;
+
+ for (int i = 0; i < matrix->nrows; i++)
+ {
+ struct glyph_row *row = matrix->rows + i;
+ if (!row->enabled_p || row->mode_line_p)
+ continue;
+ if (!row->displays_text_p && !row->ends_at_zv_p)
+ continue;
+
+ ptrdiff_t row_start = MATRIX_ROW_START_CHARPOS (row);
+ ptrdiff_t row_end = MATRIX_ROW_END_CHARPOS (row);
+
+ if (row_start < cp_end && row_end > cp_start)
+ {
+ int window_x, window_y, window_width;
+ window_box (w, TEXT_AREA, &window_x, &window_y,
+ &window_width, 0);
+
+ NSRect rowRect;
+ rowRect.origin.x = window_x;
+ rowRect.origin.y = WINDOW_TO_FRAME_PIXEL_Y (w, MAX (0, row->y));
+ rowRect.origin.y = MAX (rowRect.origin.y, window_y);
+ rowRect.size.width = window_width;
+ rowRect.size.height = row->height;
+
+ if (!found)
+ {
+ result = rowRect;
+ found = YES;
+ }
+ else
+ result = NSUnionRect (result, rowRect);
+ }
+ }
+
+ if (!found)
+ return NSZeroRect;
+
+ /* Clip result to text area bounds. */
+ {
+ int text_area_x, text_area_y, text_area_w, text_area_h;
+ window_box (w, TEXT_AREA, &text_area_x, &text_area_y,
+ &text_area_w, &text_area_h);
+ CGFloat max_y = WINDOW_TO_FRAME_PIXEL_Y (w, text_area_y + text_area_h);
+ if (NSMaxY (result) > max_y)
+ result.size.height = max_y - result.origin.y;
+ }
+
+ /* Convert from EmacsView (flipped) coords to screen coords. */
+ NSRect winRect = [view convertRect:result toView:nil];
+ return [[view window] convertRectToScreen:winRect];
+}
+
+/* AX enum numeric compatibility for NSAccessibility notifications.
+ Values match WebKit AXObjectCacheMac fallback enums
+ (AXTextStateChangeType / AXTextEditType / AXTextSelectionDirection /
+ AXTextSelectionGranularity). */
+enum {
+ ns_ax_text_state_change_unknown = 0,
+ ns_ax_text_state_change_edit = 1,
+ ns_ax_text_state_change_selection_move = 2,
+
+ ns_ax_text_edit_type_typing = 3,
+
+ ns_ax_text_selection_direction_unknown = 0,
+ ns_ax_text_selection_direction_previous = 3,
+ ns_ax_text_selection_direction_next = 4,
+ ns_ax_text_selection_direction_discontiguous = 5,
+
+ ns_ax_text_selection_granularity_unknown = 0,
+ ns_ax_text_selection_granularity_character = 1,
+ ns_ax_text_selection_granularity_word = 2,
+ ns_ax_text_selection_granularity_line = 3,
+};
+
+/* Extract announcement string from completion--string property value.
+ The property can be a plain Lisp string (simple completion) or
+ a list ("candidate" "annotation") for annotated completions.
+ Returns nil on failure. */
+static NSString *
+ns_ax_completion_string_from_prop (Lisp_Object cstr)
+{
+ if (STRINGP (cstr))
+ return [NSString stringWithLispString: cstr];
+ if (CONSP (cstr) && STRINGP (XCAR (cstr)))
+ return [NSString stringWithLispString: XCAR (cstr)];
+ return nil;
+}
+
+/* Return the Emacs buffer Lisp object for window W, or Qnil. */
+static Lisp_Object
+ns_ax_window_buffer_object (struct window *w)
+{
+ if (!w)
+ return Qnil;
+ if (!BUFFERP (w->contents))
+ return Qnil;
+ return w->contents;
+}
+
+/* Compute visible-end charpos for window W.
+ Emacs stores it as BUF_Z - window_end_pos.
+ Falls back to BUF_ZV when window_end_valid is false (e.g., when
+ called from an AX getter before the next redisplay cycle). */
+static ptrdiff_t
+ns_ax_window_end_charpos (struct window *w, struct buffer *b)
+{
+ if (!w->window_end_valid)
+ return BUF_ZV (b);
+ return BUF_Z (b) - w->window_end_pos;
+}
+
+/* Fetch text property PROP at charpos POS in BUF_OBJ. */
+static Lisp_Object
+ns_ax_text_prop_at (ptrdiff_t pos, Lisp_Object prop, Lisp_Object buf_obj)
+{
+ Lisp_Object plist = Ftext_properties_at (make_fixnum (pos), buf_obj);
+ /* Third argument to Fplist_get is PREDICATE (Emacs 29+), not a
+ default value. Qnil selects the default `eq' comparison. */
+ return Fplist_get (plist, prop, Qnil);
+}
+
+/* Next charpos where PROP changes, capped at LIMIT. */
+static ptrdiff_t
+ns_ax_next_prop_change (ptrdiff_t pos, Lisp_Object prop,
+ Lisp_Object buf_obj, ptrdiff_t limit)
+{
+ Lisp_Object result
+ = Fnext_single_property_change (make_fixnum (pos), prop,
+ buf_obj, make_fixnum (limit));
+ return FIXNUMP (result) ? XFIXNUM (result) : limit;
+}
+
+/* Build label for span [START, END) in BUF_OBJ.
+ Priority: completion--string → buffer text → help-echo. */
+static NSString *
+ns_ax_get_span_label (ptrdiff_t start, ptrdiff_t end,
+ Lisp_Object buf_obj)
+{
+ Lisp_Object cs = ns_ax_text_prop_at (start, Qns_ax_completion__string,
+ buf_obj);
+ if (STRINGP (cs))
+ return [NSString stringWithLispString: cs];
+
+ if (end > start)
+ {
+ Lisp_Object substr = Fbuffer_substring_no_properties (
+ make_fixnum (start), make_fixnum (end));
+ if (STRINGP (substr))
+ {
+ NSString *s = [NSString stringWithLispString: substr];
+ s = [s stringByTrimmingCharactersInSet:
+ [NSCharacterSet whitespaceAndNewlineCharacterSet]];
+ if (s.length > 0)
+ return s;
+ }
+ }
+
+ Lisp_Object he = ns_ax_text_prop_at (start, Qhelp_echo, buf_obj);
+ if (STRINGP (he))
+ return [NSString stringWithLispString: he];
+
+ return @"";
+}
+
+/* Post AX notifications asynchronously to prevent deadlock.
+ NSAccessibilityPostNotification may synchronously invoke VoiceOver
+ callbacks that dispatch_sync back to the main queue. If we are
+ already on the main queue (e.g., inside postAccessibilityUpdates
+ called from ns_update_end), that dispatch_sync deadlocks.
+ Deferring via dispatch_async lets the current method return first,
+ freeing the main queue for VoiceOver's dispatch_sync calls. */
+
+static inline void
+ns_ax_post_notification (id element,
+ NSAccessibilityNotificationName name)
+{
+ dispatch_async (dispatch_get_main_queue (), ^{
+ NSAccessibilityPostNotification (element, name);
+ });
+}
+
+static inline void
+ns_ax_post_notification_with_info (id element,
+ NSAccessibilityNotificationName name,
+ NSDictionary *info)
+{
+ dispatch_async (dispatch_get_main_queue (), ^{
+ NSAccessibilityPostNotificationWithUserInfo (element, name, info);
+ });
+}
+
+@implementation EmacsAccessibilityElement
+
+- (instancetype)init
+{
+ self = [super init];
+ if (self)
+ self.lispWindow = Qnil;
+ return self;
+}
+
+/* Return the associated Emacs window if it is still live, else NULL.
+ Use this instead of storing a raw struct window * which can become a
+ dangling pointer after delete-window or kill-buffer. */
+- (struct window *)validWindow
+{
+ if (NILP (self.lispWindow) || !WINDOW_LIVE_P (self.lispWindow))
+ return NULL;
+ return XWINDOW (self.lispWindow);
+}
+
+- (NSRect)screenRectFromEmacsX:(int)x y:(int)y width:(int)ew height:(int)eh
+{
+ EmacsView *view = self.emacsView;
+ if (!view || ![view window])
+ return NSZeroRect;
+
+ NSRect r = NSMakeRect (x, y, ew, eh);
+ NSRect winRect = [view convertRect:r toView:nil];
+ return [[view window] convertRectToScreen:winRect];
+}
+
+- (BOOL)isAccessibilityElement
+{
+ return YES;
+}
+
+/* ---- Hierarchy plumbing (required for VoiceOver to find us) ---- */
+
+- (id)accessibilityParent
+{
+ return NSAccessibilityUnignoredAncestor (self.emacsView);
+}
+
+- (id)accessibilityWindow
+{
+ return [self.emacsView window];
+}
+
+- (id)accessibilityTopLevelUIElement
+{
+ return [self.emacsView window];
+}
+
+@end
+
+#endif /* NS_IMPL_COCOA */
+
+
/* ==========================================================================
EmacsView implementation
@@ -11312,6 +11737,28 @@ syms_of_nsterm (void)
DEFSYM (Qns_drag_operation_generic, "ns-drag-operation-generic");
DEFSYM (Qns_handle_drag_motion, "ns-handle-drag-motion");
+ /* Accessibility: line navigation command symbols for
+ ns_ax_event_is_line_nav_key (hot path, avoid intern per call). */
+ DEFSYM (Qns_ax_next_line, "next-line");
+ DEFSYM (Qns_ax_previous_line, "previous-line");
+ DEFSYM (Qns_ax_dired_next_line, "dired-next-line");
+ DEFSYM (Qns_ax_dired_previous_line, "dired-previous-line");
+ DEFSYM (Qns_ax_evil_next_line, "evil-next-line");
+ DEFSYM (Qns_ax_evil_previous_line, "evil-previous-line");
+ DEFSYM (Qns_ax_evil_next_visual_line, "evil-next-visual-line");
+ DEFSYM (Qns_ax_evil_previous_visual_line, "evil-previous-visual-line");
+
+ /* Accessibility span scanning symbols. */
+ DEFSYM (Qns_ax_widget, "widget");
+ DEFSYM (Qns_ax_button, "button");
+ DEFSYM (Qns_ax_follow_link, "follow-link");
+ DEFSYM (Qns_ax_org_link, "org-link");
+ DEFSYM (Qns_ax_completion_list_mode, "completion-list-mode");
+ DEFSYM (Qns_ax_completion__string, "completion--string");
+ DEFSYM (Qns_ax_completion, "completion");
+ DEFSYM (Qns_ax_completions_highlight, "completions-highlight");
+ DEFSYM (Qns_ax_backtab, "backtab");
+ /* Qmouse_face and Qkeymap are defined in textprop.c / keymap.c. */
Fput (Qalt, Qmodifier_value, make_fixnum (alt_modifier));
Fput (Qhyper, Qmodifier_value, make_fixnum (hyper_modifier));
Fput (Qmeta, Qmodifier_value, make_fixnum (meta_modifier));
@@ -11460,6 +11907,15 @@ Note that this does not apply to images.
This variable is ignored on Mac OS X < 10.7 and GNUstep. */);
ns_use_srgb_colorspace = YES;
+ DEFVAR_BOOL ("ns-accessibility-enabled", ns_accessibility_enabled,
+ doc: /* Non-nil means expose buffer content to the macOS accessibility
+subsystem (VoiceOver, Zoom, and other assistive technology).
+When nil, the accessibility virtual element tree is not built and no
+notifications are posted, eliminating the associated overhead.
+Requires the Cocoa (NS) build on macOS; ignored on GNUstep.
+Default is t. */);
+ ns_accessibility_enabled = YES;
+
DEFVAR_BOOL ("ns-use-mwheel-acceleration",
ns_use_mwheel_acceleration,
doc: /* Non-nil means use macOS's standard mouse wheel acceleration.
--
2.43.0

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,584 @@
From 97baf7b5f8b0ccc85342e7d552b69b337c98f772 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 12:58:11 +0100
Subject: [PATCH 3/8] ns: add buffer notification dispatch and mode-line
element
Add VoiceOver notification methods and mode-line readout.
* src/nsterm.m (EmacsAccessibilityBuffer(Notifications)): New
category.
(postTextChangedNotification:): ValueChanged with edit details.
(postFocusedCursorNotification:direction:granularity:markActive:
oldMarkActive:): Hybrid SelectedTextChanged / AnnouncementRequested
per WebKit pattern.
(postCompletionAnnouncementForBuffer:point:): Announce completion
candidates in non-focused buffers.
(postAccessibilityNotificationsForFrame:): Main dispatch entry point.
(EmacsAccessibilityModeLine): Implement AXStaticText element.
Tested on macOS 14. Verified: cursor movement announcements,
region selection feedback, completion popups, mode-line reading.
---
src/nsterm.m | 545 +++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 545 insertions(+)
diff --git a/src/nsterm.m b/src/nsterm.m
index fc5906a..f1a1b42 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -8624,6 +8624,551 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
@end
+
+
+/* ===================================================================
+ EmacsAccessibilityBuffer (Notifications) — AX event dispatch
+
+ These methods notify VoiceOver of text and selection changes.
+ Called from the redisplay cycle (postAccessibilityUpdates).
+ =================================================================== */
+
+@implementation EmacsAccessibilityBuffer (Notifications)
+
+- (void)postTextChangedNotification:(ptrdiff_t)point
+{
+ /* Capture changed char before invalidating cache. */
+ NSString *changedChar = @"";
+ if (point > self.cachedPoint
+ && point - self.cachedPoint == 1)
+ {
+ /* Single char inserted — refresh cache and grab it. */
+ [self invalidateTextCache];
+ [self ensureTextCache];
+ if (cachedText)
+ {
+ NSUInteger idx = [self accessibilityIndexForCharpos:point - 1];
+ if (idx < [cachedText length])
+ changedChar = [cachedText substringWithRange:
+ NSMakeRange (idx, 1)];
+ }
+ }
+ else
+ {
+ [self invalidateTextCache];
+ }
+
+ /* Update cachedPoint here so the selection-move branch does NOT
+ fire for point changes caused by edits. WebKit and Chromium
+ never send both ValueChanged and SelectedTextChanged for the
+ same user action — they are mutually exclusive. */
+ self.cachedPoint = point;
+
+ NSDictionary *change = @{
+ @"AXTextEditType": @(ns_ax_text_edit_type_typing),
+ @"AXTextChangeValue": changedChar,
+ @"AXTextChangeValueLength": @([changedChar length])
+ };
+ NSDictionary *userInfo = @{
+ @"AXTextStateChangeType": @(ns_ax_text_state_change_edit),
+ @"AXTextChangeValues": @[change],
+ @"AXTextChangeElement": self
+ };
+ ns_ax_post_notification_with_info (
+ self, NSAccessibilityValueChangedNotification, userInfo);
+}
+
+/* Post SelectedTextChanged and AnnouncementRequested for the
+ focused buffer element when point or mark changes. */
+- (void)postFocusedCursorNotification:(ptrdiff_t)point
+ direction:(NSInteger)direction
+ granularity:(NSInteger)granularity
+ markActive:(BOOL)markActive
+ oldMarkActive:(BOOL)oldMarkActive
+{
+ BOOL isCharMove
+ = (!markActive && !oldMarkActive
+ && granularity
+ == ns_ax_text_selection_granularity_character);
+
+ /* Always post SelectedTextChanged to interrupt VoiceOver reading
+ and update cursor tracking / braille displays. */
+ NSMutableDictionary *moveInfo = [NSMutableDictionary dictionary];
+ moveInfo[@"AXTextStateChangeType"]
+ = @(ns_ax_text_state_change_selection_move);
+ moveInfo[@"AXTextSelectionDirection"] = @(direction);
+ moveInfo[@"AXTextChangeElement"] = self;
+ /* Omit granularity for character moves so VoiceOver does not
+ derive its own speech (it would read the wrong character
+ for evil block-cursor mode). Include it for word/line/
+ selection so VoiceOver reads the appropriate text. */
+ if (!isCharMove)
+ moveInfo[@"AXTextSelectionGranularity"] = @(granularity);
+
+ ns_ax_post_notification_with_info (
+ self,
+ NSAccessibilitySelectedTextChangedNotification,
+ moveInfo);
+
+ /* For character moves: explicit announcement of char AT point.
+ This is the ONLY speech source for character navigation.
+ Correct for evil block-cursor (cursor ON the character)
+ and harmless for insert-mode. */
+ if (isCharMove && cachedText)
+ {
+ NSUInteger point_idx
+ = [self accessibilityIndexForCharpos:point];
+ NSUInteger tlen = [cachedText length];
+ if (point_idx < tlen)
+ {
+ NSRange charRange = [cachedText
+ rangeOfComposedCharacterSequenceAtIndex: point_idx];
+ if (charRange.location != NSNotFound
+ && charRange.length > 0
+ && NSMaxRange (charRange) <= tlen)
+ {
+ NSString *ch
+ = [cachedText substringWithRange: charRange];
+ if (![ch isEqualToString: @"\n"])
+ {
+ NSDictionary *annInfo = @{
+ NSAccessibilityAnnouncementKey: ch,
+ 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
+ these keystrokes differently from arrows).
+ In completion-list-mode, read the completion candidate instead
+ of the whole line. */
+ if (cachedText
+ && granularity == ns_ax_text_selection_granularity_line)
+ {
+ NSString *announceText = nil;
+
+ /* 1. completion--string at point. */
+ Lisp_Object cstr
+ = Fget_char_property (make_fixnum (point),
+ Qns_ax_completion__string, Qnil);
+ announceText = ns_ax_completion_string_from_prop (cstr);
+
+ /* 2. Fallback: full line text. */
+ if (!announceText)
+ {
+ NSUInteger point_idx
+ = [self accessibilityIndexForCharpos:point];
+ if (point_idx <= [cachedText length])
+ {
+ NSInteger lineNum
+ = [self accessibilityLineForIndex:point_idx];
+ NSRange lineRange
+ = [self accessibilityRangeForLine:lineNum];
+ if (lineRange.location != NSNotFound
+ && lineRange.length > 0
+ && NSMaxRange (lineRange) <= [cachedText length])
+ announceText
+ = [cachedText substringWithRange:lineRange];
+ }
+ }
+
+ if (announceText)
+ {
+ announceText = [announceText
+ stringByTrimmingCharactersInSet:
+ [NSCharacterSet whitespaceAndNewlineCharacterSet]];
+ if ([announceText length] > 0)
+ {
+ NSDictionary *annInfo = @{
+ NSAccessibilityAnnouncementKey: announceText,
+ NSAccessibilityPriorityKey:
+ @(NSAccessibilityPriorityHigh)
+ };
+ ns_ax_post_notification_with_info (
+ NSApp,
+ NSAccessibilityAnnouncementRequestedNotification,
+ annInfo);
+ }
+ }
+ }
+}
+
+/* Post AnnouncementRequested for non-focused buffers (typically
+ *Completions* while minibuffer has keyboard focus).
+ VoiceOver does not automatically read changes in non-focused
+ elements, so we announce the selected completion explicitly. */
+- (void)postCompletionAnnouncementForBuffer:(struct buffer *)b
+ point:(ptrdiff_t)point
+{
+ NSString *announceText = nil;
+ ptrdiff_t currentOverlayStart = 0;
+ ptrdiff_t currentOverlayEnd = 0;
+
+ specpdl_ref count2 = SPECPDL_INDEX ();
+ record_unwind_current_buffer ();
+ if (b != current_buffer)
+ set_buffer_internal_1 (b);
+
+ /* 1) Prefer explicit completion candidate property. */
+ Lisp_Object cstr = Fget_char_property (make_fixnum (point),
+ Qns_ax_completion__string,
+ Qnil);
+ announceText = ns_ax_completion_string_from_prop (cstr);
+
+ /* 2) Fallback: mouse-face span at point. */
+ if (!announceText)
+ {
+ Lisp_Object mf = Fget_char_property (make_fixnum (point),
+ Qmouse_face, Qnil);
+ if (!NILP (mf))
+ {
+ ptrdiff_t begv2 = BUF_BEGV (b);
+ ptrdiff_t zv2 = BUF_ZV (b);
+
+ Lisp_Object prev_change
+ = Fprevious_single_char_property_change (
+ make_fixnum (point + 1), Qmouse_face,
+ Qnil, make_fixnum (begv2));
+ ptrdiff_t s2
+ = FIXNUMP (prev_change) ? XFIXNUM (prev_change)
+ : begv2;
+
+ Lisp_Object next_change
+ = Fnext_single_char_property_change (
+ make_fixnum (point), Qmouse_face,
+ Qnil, make_fixnum (zv2));
+ ptrdiff_t e2
+ = FIXNUMP (next_change) ? XFIXNUM (next_change)
+ : zv2;
+
+ if (e2 > s2)
+ {
+ NSUInteger ax_s = [self accessibilityIndexForCharpos:s2];
+ NSUInteger ax_e = [self accessibilityIndexForCharpos:e2];
+ if (ax_e > ax_s && ax_e <= [cachedText length])
+ announceText = [cachedText substringWithRange:
+ NSMakeRange (ax_s, ax_e - ax_s)];
+ }
+ }
+ }
+
+ /* 3) Fallback: completions-highlight overlay at point. */
+ if (!announceText)
+ {
+ Lisp_Object faceSym = Qns_ax_completions_highlight;
+ Lisp_Object overlays = Foverlays_at (make_fixnum (point), Qnil);
+ Lisp_Object tail;
+ for (tail = overlays; CONSP (tail); tail = XCDR (tail))
+ {
+ Lisp_Object ov = XCAR (tail);
+ Lisp_Object face = Foverlay_get (ov, Qface);
+ if (EQ (face, faceSym)
+ || (CONSP (face)
+ && !NILP (Fmemq (faceSym, face))))
+ {
+ ptrdiff_t ov_start = OVERLAY_START (ov);
+ ptrdiff_t ov_end = OVERLAY_END (ov);
+ if (ov_end > ov_start)
+ {
+ announceText = ns_ax_completion_text_for_span (self, b,
+ ov_start,
+ ov_end,
+ cachedText);
+ currentOverlayStart = ov_start;
+ currentOverlayEnd = ov_end;
+ }
+ break;
+ }
+ }
+ }
+
+ /* 4) Fallback: nearest completions-highlight overlay. */
+ if (!announceText)
+ {
+ ptrdiff_t ov_start = 0;
+ ptrdiff_t ov_end = 0;
+ if (ns_ax_find_completion_overlay_range (b, point,
+ &ov_start, &ov_end))
+ {
+ announceText = ns_ax_completion_text_for_span (self, b,
+ ov_start, ov_end,
+ cachedText);
+ currentOverlayStart = ov_start;
+ currentOverlayEnd = ov_end;
+ }
+ }
+
+ unbind_to (count2, Qnil);
+
+ /* Final fallback: read current line at point. */
+ if (!announceText)
+ {
+ NSUInteger point_idx = [self accessibilityIndexForCharpos:point];
+ if (point_idx <= [cachedText length])
+ {
+ NSInteger lineNum = [self accessibilityLineForIndex:
+ point_idx];
+ NSRange lineRange = [self accessibilityRangeForLine:lineNum];
+ if (lineRange.location != NSNotFound
+ && lineRange.length > 0
+ && lineRange.location + lineRange.length
+ <= [cachedText length])
+ announceText = [cachedText substringWithRange:lineRange];
+ }
+ }
+
+ /* Deduplicate: post only when text, overlay, or point changed. */
+ if (announceText)
+ {
+ announceText = [announceText stringByTrimmingCharactersInSet:
+ [NSCharacterSet whitespaceAndNewlineCharacterSet]];
+ if ([announceText length] > 0)
+ {
+ BOOL textChanged = ![announceText isEqualToString:
+ self.cachedCompletionAnnouncement];
+ BOOL overlayChanged =
+ (currentOverlayStart != self.cachedCompletionOverlayStart
+ || currentOverlayEnd != self.cachedCompletionOverlayEnd);
+ BOOL pointChanged = (point != self.cachedCompletionPoint);
+ if (textChanged || overlayChanged || pointChanged)
+ {
+ NSDictionary *annInfo = @{
+ NSAccessibilityAnnouncementKey: announceText,
+ NSAccessibilityPriorityKey:
+ @(NSAccessibilityPriorityHigh)
+ };
+ ns_ax_post_notification_with_info (
+ NSApp,
+ NSAccessibilityAnnouncementRequestedNotification,
+ annInfo);
+ }
+ self.cachedCompletionAnnouncement = announceText;
+ self.cachedCompletionOverlayStart = currentOverlayStart;
+ self.cachedCompletionOverlayEnd = currentOverlayEnd;
+ self.cachedCompletionPoint = point;
+ }
+ else
+ {
+ self.cachedCompletionAnnouncement = nil;
+ self.cachedCompletionOverlayStart = 0;
+ self.cachedCompletionOverlayEnd = 0;
+ self.cachedCompletionPoint = 0;
+ }
+ }
+ else
+ {
+ self.cachedCompletionAnnouncement = nil;
+ self.cachedCompletionOverlayStart = 0;
+ self.cachedCompletionOverlayEnd = 0;
+ self.cachedCompletionPoint = 0;
+ }
+}
+
+/* ---- Notification dispatch (main entry point) ---- */
+
+/* Dispatch accessibility notifications after a redisplay cycle.
+ Detects three mutually exclusive events: text edit, cursor/mark
+ change, or no change. Delegates to helper methods above. */
+- (void)postAccessibilityNotificationsForFrame:(struct frame *)f
+{
+ NSTRACE ("[EmacsView postAccessibilityNotificationsForFrame:]");
+ struct window *w = [self validWindow];
+ if (!w || !WINDOW_LEAF_P (w))
+ return;
+
+ struct buffer *b = XBUFFER (w->contents);
+ if (!b)
+ return;
+
+ ptrdiff_t modiff = BUF_MODIFF (b);
+ ptrdiff_t point = BUF_PT (b);
+ BOOL markActive = !NILP (BVAR (b, mark_active));
+
+ /* --- Text changed (edit) --- */
+ if (modiff != self.cachedModiff)
+ {
+ self.cachedModiff = modiff;
+ [self postTextChangedNotification:point];
+ }
+
+ /* --- Cursor moved or selection changed ---
+ Use 'else if' — edits and selection moves are mutually exclusive
+ per the WebKit/Chromium pattern. */
+ else if (point != self.cachedPoint || markActive != self.cachedMarkActive)
+ {
+ ptrdiff_t oldPoint = self.cachedPoint;
+ BOOL oldMarkActive = self.cachedMarkActive;
+ self.cachedPoint = point;
+ self.cachedMarkActive = markActive;
+
+ /* Compute direction. */
+ NSInteger direction = ns_ax_text_selection_direction_discontiguous;
+ if (point > oldPoint)
+ direction = ns_ax_text_selection_direction_next;
+ else if (point < oldPoint)
+ direction = ns_ax_text_selection_direction_previous;
+
+ int ctrlNP = 0;
+ bool isCtrlNP = ns_ax_event_is_line_nav_key (&ctrlNP);
+
+ /* --- Granularity detection --- */
+ NSInteger granularity = ns_ax_text_selection_granularity_unknown;
+ [self ensureTextCache];
+ if (cachedText && oldPoint > 0)
+ {
+ NSUInteger tlen = [cachedText length];
+ NSUInteger oldIdx = [self accessibilityIndexForCharpos:oldPoint];
+ NSUInteger newIdx = [self accessibilityIndexForCharpos:point];
+ if (oldIdx > tlen) oldIdx = tlen;
+ if (newIdx > tlen) newIdx = tlen;
+
+ NSRange oldLine = [cachedText lineRangeForRange:
+ NSMakeRange (oldIdx, 0)];
+ NSRange newLine = [cachedText lineRangeForRange:
+ NSMakeRange (newIdx, 0)];
+ if (oldLine.location != newLine.location)
+ granularity = ns_ax_text_selection_granularity_line;
+ else
+ {
+ NSUInteger dist = (newIdx > oldIdx
+ ? newIdx - oldIdx
+ : oldIdx - newIdx);
+ if (dist > 1)
+ granularity = ns_ax_text_selection_granularity_word;
+ else if (dist == 1)
+ granularity = ns_ax_text_selection_granularity_character;
+ }
+ }
+
+ /* Force line semantics for explicit C-n/C-p / Tab / backtab. */
+ if (isCtrlNP)
+ {
+ direction = (ctrlNP > 0
+ ? ns_ax_text_selection_direction_next
+ : ns_ax_text_selection_direction_previous);
+ granularity = ns_ax_text_selection_granularity_line;
+ }
+
+ /* Post notifications for focused and non-focused elements. */
+ if ([self isAccessibilityFocused])
+ [self postFocusedCursorNotification:point
+ direction:direction
+ granularity:granularity
+ markActive:markActive
+ oldMarkActive:oldMarkActive];
+
+ if (![self isAccessibilityFocused] && cachedText)
+ [self postCompletionAnnouncementForBuffer:b point:point];
+ }
+ else
+ {
+ /* Nothing changed. Reset completion cache for focused buffer
+ to avoid stale announcements. */
+ if ([self isAccessibilityFocused])
+ {
+ self.cachedCompletionAnnouncement = nil;
+ self.cachedCompletionOverlayStart = 0;
+ self.cachedCompletionOverlayEnd = 0;
+ self.cachedCompletionPoint = 0;
+ }
+ }
+}
+
+@end
+
+
+@implementation EmacsAccessibilityModeLine
+
+- (NSAccessibilityRole)accessibilityRole
+{
+ return NSAccessibilityStaticTextRole;
+}
+
+- (NSString *)accessibilityLabel
+{
+ if (![NSThread isMainThread])
+ {
+ __block NSString *result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityLabel];
+ });
+ return result;
+ }
+ struct window *w = [self validWindow];
+ if (w && WINDOW_LEAF_P (w))
+ {
+ struct buffer *b = XBUFFER (w->contents);
+ if (b)
+ {
+ Lisp_Object name = BVAR (b, name);
+ if (STRINGP (name))
+ {
+ NSString *bufName = [NSString stringWithLispString:name];
+ return [NSString stringWithFormat:@"Mode Line - %@", bufName];
+ }
+ }
+ }
+ return @"Mode Line";
+}
+
+- (id)accessibilityValue
+{
+ if (![NSThread isMainThread])
+ {
+ __block id result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityValue];
+ });
+ return result;
+ }
+ struct window *w = [self validWindow];
+ if (!w)
+ return @"";
+ return ns_ax_mode_line_text (w);
+}
+
+- (NSRect)accessibilityFrame
+{
+ if (![NSThread isMainThread])
+ {
+ __block NSRect result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityFrame];
+ });
+ return result;
+ }
+ struct window *w = [self validWindow];
+ if (!w || !w->current_matrix)
+ return NSZeroRect;
+
+ /* Find the mode line row and return its screen rect. */
+ struct glyph_matrix *matrix = w->current_matrix;
+ for (int i = 0; i < matrix->nrows; i++)
+ {
+ struct glyph_row *row = matrix->rows + i;
+ if (row->enabled_p && row->mode_line_p)
+ {
+ return [self screenRectFromEmacsX:w->pixel_left
+ y:WINDOW_TO_FRAME_PIXEL_Y (w,
+ MAX (0, row->y))
+ width:w->pixel_width
+ height:row->visible_height];
+ }
+ }
+ return NSZeroRect;
+}
+
+@end
+
#endif /* NS_IMPL_COCOA */
--
2.43.0

View File

@@ -0,0 +1,318 @@
From 1bd12dd5d464d0c3f9774630014e434b8fb0e19e Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 12:58:11 +0100
Subject: [PATCH 4/8] ns: add interactive span elements for Tab navigation
* src/nsterm.m (ns_ax_scan_interactive_spans): New function.
(EmacsAccessibilityInteractiveSpan): Implement AXButton/AXLink
elements with AXPress action.
(EmacsAccessibilityBuffer(InteractiveSpans)): New category.
accessibilityChildrenInNavigationOrder for Tab/Shift-Tab cycling
with wrap-around.
Tested on macOS 14. Verified: Tab-cycling through org-mode links,
*Completions* candidates, widget buttons, customize buffers.
---
src/nsterm.m | 286 +++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 286 insertions(+)
diff --git a/src/nsterm.m b/src/nsterm.m
index f1a1b42..91d0241 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -9169,6 +9169,292 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
@end
+
+
+/* ===================================================================
+ EmacsAccessibilityInteractiveSpan — helpers and implementation
+ =================================================================== */
+
+/* Scan visible range of window W for interactive spans.
+ Returns NSArray<EmacsAccessibilityInteractiveSpan *>.
+
+ Priority when properties overlap:
+ widget > button > follow-link > org-link >
+ completion-candidate > keymap-overlay. */
+static NSArray *
+ns_ax_scan_interactive_spans (struct window *w,
+ EmacsAccessibilityBuffer *parent_buf)
+{
+ if (!w)
+ return @[];
+
+ Lisp_Object buf_obj = ns_ax_window_buffer_object (w);
+ if (NILP (buf_obj))
+ return @[];
+
+ struct buffer *b = XBUFFER (buf_obj);
+ ptrdiff_t vis_start = marker_position (w->start);
+ ptrdiff_t vis_end = ns_ax_window_end_charpos (w, b);
+
+ if (vis_start < BUF_BEGV (b)) vis_start = BUF_BEGV (b);
+ if (vis_end > BUF_ZV (b)) vis_end = BUF_ZV (b);
+ if (vis_start >= vis_end)
+ return @[];
+
+ /* Symbols are interned once at startup via DEFSYM in syms_of_nsterm;
+ reference them directly here (GC-safe, no repeated obarray lookup). */
+
+ BOOL is_completion_buf
+ = EQ (BVAR (b, major_mode), Qns_ax_completion_list_mode);
+
+ NSMutableArray *spans = [NSMutableArray array];
+ ptrdiff_t pos = vis_start;
+
+ while (pos < vis_end)
+ {
+ Lisp_Object plist = Ftext_properties_at (make_fixnum (pos), buf_obj);
+ EmacsAXSpanType span_type = EmacsAXSpanTypeNone;
+ Lisp_Object limit_prop = Qnil;
+
+ if (!NILP (Fplist_get (plist, Qns_ax_widget, Qnil)))
+ {
+ span_type = EmacsAXSpanTypeWidget;
+ limit_prop = Qns_ax_widget;
+ }
+ else if (!NILP (Fplist_get (plist, Qns_ax_button, Qnil)))
+ {
+ span_type = EmacsAXSpanTypeButton;
+ limit_prop = Qns_ax_button;
+ }
+ else if (!NILP (Fplist_get (plist, Qns_ax_follow_link, Qnil)))
+ {
+ span_type = EmacsAXSpanTypeLink;
+ limit_prop = Qns_ax_follow_link;
+ }
+ else if (!NILP (Fplist_get (plist, Qns_ax_org_link, Qnil)))
+ {
+ span_type = EmacsAXSpanTypeLink;
+ limit_prop = Qns_ax_org_link;
+ }
+ else if (is_completion_buf
+ && !NILP (Fplist_get (plist, Qmouse_face, Qnil)))
+ {
+ /* For completions, use completion--string as boundary so we
+ don't accidentally merge two column-adjacent candidates
+ whose mouse-face regions may share padding whitespace.
+ Fall back to mouse-face if completion--string is absent. */
+ Lisp_Object cs_sym = Qns_ax_completion__string;
+ Lisp_Object cs_val = ns_ax_text_prop_at (pos, cs_sym, buf_obj);
+ span_type = EmacsAXSpanTypeCompletionItem;
+ limit_prop = NILP (cs_val) ? Qmouse_face : cs_sym;
+ }
+ else
+ {
+ /* Check overlays for keymap. */
+ Lisp_Object ovs
+ = Foverlays_in (make_fixnum (pos), make_fixnum (pos + 1));
+ while (CONSP (ovs))
+ {
+ if (!NILP (Foverlay_get (XCAR (ovs), Qkeymap)))
+ {
+ span_type = EmacsAXSpanTypeButton;
+ limit_prop = Qkeymap;
+ break;
+ }
+ ovs = XCDR (ovs);
+ }
+ }
+
+ if (span_type == EmacsAXSpanTypeNone)
+ {
+ /* Skip to the next position where any interactive property
+ changes. Try each scannable property in turn and take
+ the nearest change point — O(properties) per gap rather
+ than O(chars). Fall back to pos+1 as safety net. */
+ ptrdiff_t next_interesting = vis_end;
+ Lisp_Object skip_props[5]
+ = { Qns_ax_widget, Qns_ax_button, Qns_ax_follow_link,
+ Qns_ax_org_link, Qmouse_face };
+ for (int sp = 0; sp < 5; sp++)
+ {
+ ptrdiff_t np
+ = ns_ax_next_prop_change (pos, skip_props[sp],
+ buf_obj, vis_end);
+ if (np > pos && np < next_interesting)
+ next_interesting = np;
+ }
+ /* Also check overlay keymap changes. */
+ Lisp_Object np_ov
+ = Fnext_single_char_property_change (make_fixnum (pos),
+ Qkeymap, buf_obj,
+ make_fixnum (vis_end));
+ if (FIXNUMP (np_ov))
+ {
+ ptrdiff_t npv = XFIXNUM (np_ov);
+ if (npv > pos && npv < next_interesting)
+ next_interesting = npv;
+ }
+ pos = (next_interesting > pos) ? next_interesting : pos + 1;
+ continue;
+ }
+
+ ptrdiff_t span_end = !NILP (limit_prop)
+ ? ns_ax_next_prop_change (pos, limit_prop, buf_obj, vis_end)
+ : pos + 1;
+
+ if (span_end > vis_end) span_end = vis_end;
+ if (span_end <= pos) span_end = pos + 1;
+
+ EmacsAccessibilityInteractiveSpan *span
+ = [[EmacsAccessibilityInteractiveSpan alloc] init];
+ span.charposStart = pos;
+ span.charposEnd = span_end;
+ span.spanType = span_type;
+ span.parentBuffer = parent_buf;
+ span.emacsView = parent_buf.emacsView;
+ span.lispWindow = parent_buf.lispWindow;
+ span.spanLabel = ns_ax_get_span_label (pos, span_end, buf_obj);
+
+ [spans addObject: span];
+ [span release];
+
+ pos = span_end;
+ }
+
+ return [[spans copy] autorelease];
+}
+
+@implementation EmacsAccessibilityInteractiveSpan
+@synthesize spanLabel;
+@synthesize spanValue;
+
+- (void)dealloc
+{
+ [spanLabel release];
+ [spanValue release];
+ [super dealloc];
+}
+
+- (BOOL) isAccessibilityElement { return YES; }
+
+- (NSAccessibilityRole) accessibilityRole
+{
+ switch (self.spanType)
+ {
+ case EmacsAXSpanTypeLink: return NSAccessibilityLinkRole;
+ default: return NSAccessibilityButtonRole;
+ }
+}
+
+- (NSString *) accessibilityLabel { return self.spanLabel ?: @""; }
+- (NSString *) accessibilityValue { return self.spanValue; }
+
+- (NSRect) accessibilityFrame
+{
+ EmacsAccessibilityBuffer *pb = self.parentBuffer;
+ if (!pb || ![self validWindow])
+ return NSZeroRect;
+ NSUInteger ax_s = [pb accessibilityIndexForCharpos: self.charposStart];
+ NSUInteger ax_e = [pb accessibilityIndexForCharpos: self.charposEnd];
+ if (ax_e < ax_s) ax_e = ax_s;
+ return [pb accessibilityFrameForRange: NSMakeRange (ax_s, ax_e - ax_s)];
+}
+
+- (BOOL) isAccessibilityFocused
+{
+ /* Read the cached point stored by EmacsAccessibilityBuffer on the main
+ thread — safe to read from any thread (plain ptrdiff_t, no Lisp calls). */
+ EmacsAccessibilityBuffer *pb = self.parentBuffer;
+ if (!pb)
+ return NO;
+ ptrdiff_t pt = pb.cachedPoint;
+ return pt >= self.charposStart && pt < self.charposEnd;
+}
+
+- (void) setAccessibilityFocused: (BOOL) focused
+{
+ if (!focused)
+ return;
+ ptrdiff_t target = self.charposStart;
+ Lisp_Object lwin = self.lispWindow;
+ dispatch_async (dispatch_get_main_queue (), ^{
+ /* lwin is a Lisp_Object captured by value. This is GC-safe
+ because Lisp_Objects are tagged integers/pointers that
+ remain valid across GC — GC does not relocate objects in
+ Emacs. The WINDOW_LIVE_P check below guards against the
+ window being deleted between capture and execution. */
+ if (!WINDOWP (lwin) || NILP (Fwindow_live_p (lwin)))
+ return;
+ /* Use specpdl unwind protection so that block_input is always
+ matched by unblock_input, even if Fselect_window signals. */
+ specpdl_ref count = SPECPDL_INDEX ();
+ record_unwind_protect_void (unblock_input);
+ block_input ();
+ record_unwind_current_buffer ();
+ Fselect_window (lwin, Qnil);
+ struct window *w = XWINDOW (lwin);
+ struct buffer *b = XBUFFER (w->contents);
+ if (b != current_buffer)
+ set_buffer_internal_1 (b);
+ ptrdiff_t pos = target;
+ if (pos < BUF_BEGV (b)) pos = BUF_BEGV (b);
+ if (pos > BUF_ZV (b)) pos = BUF_ZV (b);
+ SET_PT_BOTH (pos, CHAR_TO_BYTE (pos));
+ unbind_to (count, Qnil);
+ });
+}
+
+@end
+
+/* EmacsAccessibilityBuffer — InteractiveSpans category.
+ Methods are kept here (same .m file) so they access the ivars
+ declared in the @interface ivar block. */
+@implementation EmacsAccessibilityBuffer (InteractiveSpans)
+
+- (void) invalidateInteractiveSpans
+{
+ interactiveSpansDirty = YES;
+}
+
+- (NSArray *) accessibilityChildrenInNavigationOrder
+{
+ if (!interactiveSpansDirty && cachedInteractiveSpans != nil)
+ return cachedInteractiveSpans;
+
+ if (![NSThread isMainThread])
+ {
+ __block NSArray *result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityChildrenInNavigationOrder];
+ });
+ return result;
+ }
+
+ struct window *w = [self validWindow];
+ if (!w)
+ return cachedInteractiveSpans ? cachedInteractiveSpans : @[];
+
+ /* Validate buffer before scanning. The Lisp calls inside
+ ns_ax_scan_interactive_spans (Ftext_properties_at, Fplist_get,
+ Fnext_single_property_change) do not signal on valid buffers
+ with valid positions. Verify those preconditions here so we
+ never enter the scan with invalid state, which could longjmp
+ out of a dispatch_sync block and deadlock the AX thread. */
+ if (!BUFFERP (w->contents) || !XBUFFER (w->contents))
+ return cachedInteractiveSpans ? cachedInteractiveSpans : @[];
+
+ NSArray *spans = ns_ax_scan_interactive_spans (w, self);
+
+ if (!cachedInteractiveSpans)
+ cachedInteractiveSpans = [[NSMutableArray alloc] init];
+ [cachedInteractiveSpans setArray: spans];
+ interactiveSpansDirty = NO;
+
+ return cachedInteractiveSpans;
+}
+
+@end
+
#endif /* NS_IMPL_COCOA */
--
2.43.0

View File

@@ -0,0 +1,515 @@
From 3bbe8ba29725a4708595befa6b73e5873a2aab43 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 12:58:11 +0100
Subject: [PATCH 5/8] ns: integrate accessibility with EmacsView and redisplay
Wire the accessibility infrastructure into EmacsView and the
redisplay cycle. After this patch, VoiceOver and Zoom are active.
* src/nsterm.m (ns_update_end): Call [view postAccessibilityUpdates].
(ns_draw_phys_cursor): Store cursor rect; call UAZoomChangeFocus.
(EmacsView dealloc): Release accessibilityElements.
(EmacsView windowDidBecomeKey): Post accessibility focus notification.
(ns_ax_collect_windows): New function.
(EmacsView rebuildAccessibilityTree, invalidateAccessibilityTree)
(accessibilityChildren, accessibilityFocusedUIElement)
(postAccessibilityUpdates, accessibilityBoundsForRange:)
(accessibilityParameterizedAttributeNames)
(accessibilityAttributeValue:forParameter:): New methods.
* etc/NEWS: Document VoiceOver accessibility support.
Tested on macOS 14 with VoiceOver and Zoom. End-to-end: buffer
navigation, cursor tracking, window switching, completions, evil-mode
block cursor, org-mode folded headings, indirect buffers.
Known limitations documented in patch 6 Texinfo node.
---
etc/NEWS | 13 ++
src/nsterm.m | 398 ++++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 408 insertions(+), 3 deletions(-)
diff --git a/etc/NEWS b/etc/NEWS
index 7367e3c..608650e 100644
--- a/etc/NEWS
+++ b/etc/NEWS
@@ -4374,6 +4374,19 @@ allowing Emacs users access to speech recognition utilities.
Note: Accepting this permission allows the use of system APIs, which may
send user data to Apple's speech recognition servers.
+---
+** VoiceOver accessibility support on macOS.
+Emacs now exposes buffer content, cursor position, and interactive
+elements to the macOS accessibility subsystem (VoiceOver). This
+includes AXBoundsForRange for macOS Zoom cursor tracking, line and
+word navigation announcements, Tab-navigable interactive spans
+(buttons, links, completion candidates), and completion announcements
+for the *Completions* buffer. The implementation uses a virtual
+accessibility tree with per-window elements, hybrid SelectedTextChanged
+and AnnouncementRequested notifications, and thread-safe text caching.
+Set 'ns-accessibility-enabled' to nil to disable the accessibility
+interface and eliminate the associated overhead.
+
---
** Re-introduced dictation, lost in Emacs v30 (macOS).
We lost macOS dictation in v30 when migrating to NSTextInputClient.
diff --git a/src/nsterm.m b/src/nsterm.m
index 91d0241..125e52c 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -1105,6 +1105,11 @@ ns_update_end (struct frame *f)
unblock_input ();
ns_updating_frame = NULL;
+
+#ifdef NS_IMPL_COCOA
+ /* Post accessibility notifications after each redisplay cycle. */
+ [view postAccessibilityUpdates];
+#endif
}
static void
@@ -3233,6 +3238,43 @@ ns_draw_window_cursor (struct window *w, struct glyph_row *glyph_row,
/* Prevent the cursor from being drawn outside the text area. */
r = NSIntersectionRect (r, ns_row_rect (w, glyph_row, TEXT_AREA));
+#ifdef NS_IMPL_COCOA
+ /* Accessibility: store cursor rect for Zoom and bounds queries.
+ Skipped when ns-accessibility-enabled is nil to avoid overhead.
+ VoiceOver notifications are handled solely by
+ postAccessibilityUpdates (called from ns_update_end)
+ to avoid duplicate notifications and mid-redisplay fragility. */
+ {
+ EmacsView *view = FRAME_NS_VIEW (f);
+ if (view && on_p && active_p && ns_accessibility_enabled)
+ {
+ view->lastAccessibilityCursorRect = r;
+
+ /* Tell macOS Zoom where the cursor is. UAZoomChangeFocus()
+ expects top-left origin (CG coordinate space).
+ These APIs are available since macOS 10.4 (Universal Access
+ framework, linked via ApplicationServices umbrella). */
+#if defined (MAC_OS_X_VERSION_MIN_REQUIRED) \
+ && MAC_OS_X_VERSION_MIN_REQUIRED >= 101000
+ if (UAZoomEnabled ())
+ {
+ NSRect windowRect = [view convertRect:r toView:nil];
+ NSRect screenRect = [[view window] convertRectToScreen:windowRect];
+ CGRect cgRect = NSRectToCGRect (screenRect);
+
+ CGFloat primaryH
+ = [[[NSScreen screens] firstObject] frame].size.height;
+ cgRect.origin.y
+ = primaryH - cgRect.origin.y - cgRect.size.height;
+
+ UAZoomChangeFocus (&cgRect, &cgRect,
+ kUAZoomFocusTypeInsertionPoint);
+ }
+#endif /* MAC_OS_X_VERSION_MIN_REQUIRED >= 101000 */
+ }
+ }
+#endif
+
ns_focus (f, NULL, 0);
NSGraphicsContext *ctx = [NSGraphicsContext currentContext];
@@ -7531,7 +7573,6 @@ ns_ax_post_notification_with_info (id element,
-
static BOOL
ns_ax_find_completion_overlay_range (struct buffer *b, ptrdiff_t point,
ptrdiff_t *out_start,
@@ -8625,7 +8666,6 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
@end
-
/* ===================================================================
EmacsAccessibilityBuffer (Notifications) — AX event dispatch
@@ -9170,7 +9210,6 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
@end
-
/* ===================================================================
EmacsAccessibilityInteractiveSpan — helpers and implementation
=================================================================== */
@@ -9500,6 +9539,7 @@ ns_ax_scan_interactive_spans (struct window *w,
[layer release];
#endif
+ [accessibilityElements release];
[[self menu] release];
[super dealloc];
}
@@ -10848,6 +10888,32 @@ ns_in_echo_area (void)
XSETFRAME (event.frame_or_window, emacsframe);
kbd_buffer_store_event (&event);
ns_send_appdefined (-1); // Kick main loop
+
+#ifdef NS_IMPL_COCOA
+ /* Notify VoiceOver that the focused accessibility element changed.
+ Post on the focused virtual element so VoiceOver starts tracking it.
+ This is critical for initial focus and app-switch scenarios. */
+ if (ns_accessibility_enabled)
+ {
+ id focused = [self accessibilityFocusedUIElement];
+ if (focused
+ && [focused isKindOfClass:[EmacsAccessibilityBuffer class]])
+ {
+ ns_ax_post_notification (focused,
+ NSAccessibilityFocusedUIElementChangedNotification);
+ NSDictionary *info = @{
+ @"AXTextStateChangeType":
+ @(ns_ax_text_state_change_selection_move),
+ @"AXTextChangeElement": focused
+ };
+ ns_ax_post_notification_with_info (focused,
+ NSAccessibilitySelectedTextChangedNotification, info);
+ }
+ else if (focused)
+ ns_ax_post_notification (focused,
+ NSAccessibilityFocusedUIElementChangedNotification);
+ }
+#endif
}
@@ -12085,6 +12151,332 @@ ns_in_echo_area (void)
return fs_state;
}
+#ifdef NS_IMPL_COCOA
+
+/* ---- Accessibility: walk the Emacs window tree ---- */
+
+static void
+ns_ax_collect_windows (Lisp_Object window, EmacsView *view,
+ NSMutableArray *elements,
+ NSDictionary *existing)
+{
+ if (NILP (window))
+ return;
+
+ struct window *w = XWINDOW (window);
+
+ if (WINDOW_LEAF_P (w))
+ {
+ /* Buffer element — reuse existing if available. */
+ EmacsAccessibilityBuffer *elem
+ = [existing objectForKey:[NSValue valueWithPointer:w]];
+ if (!elem)
+ {
+ elem = [[EmacsAccessibilityBuffer alloc] init];
+ elem.emacsView = view;
+
+ /* Initialize cached state to -1 to force first notification. */
+ elem.cachedModiff = -1;
+ elem.cachedPoint = -1;
+ elem.cachedMarkActive = NO;
+ }
+ else
+ {
+ [elem retain];
+ }
+ elem.lispWindow = window;
+ [elements addObject:elem];
+ [elem release];
+
+ /* Mode line element (skip for minibuffer). */
+ if (!MINI_WINDOW_P (w))
+ {
+ EmacsAccessibilityModeLine *ml
+ = [[EmacsAccessibilityModeLine alloc] init];
+ ml.emacsView = view;
+ ml.lispWindow = window;
+ [elements addObject:ml];
+ [ml release];
+ }
+ }
+ else
+ {
+ /* Internal (combination) window — recurse into children. */
+ Lisp_Object child = w->contents;
+ while (!NILP (child))
+ {
+ ns_ax_collect_windows (child, view, elements, existing);
+ child = XWINDOW (child)->next;
+ }
+ }
+}
+
+- (void)rebuildAccessibilityTree
+{
+ NSTRACE ("[EmacsView rebuildAccessibilityTree]");
+ if (!emacsframe)
+ return;
+
+ /* Build map of existing elements by window pointer for reuse. */
+ NSMutableDictionary *existing = [NSMutableDictionary dictionary];
+ if (accessibilityElements)
+ {
+ for (EmacsAccessibilityElement *elem in accessibilityElements)
+ {
+ if ([elem isKindOfClass:[EmacsAccessibilityBuffer class]]
+ && !NILP (elem.lispWindow))
+ [existing setObject:elem
+ forKey:[NSValue valueWithPointer:
+ XWINDOW (elem.lispWindow)]];
+ }
+ }
+
+ NSMutableArray *newElements = [NSMutableArray arrayWithCapacity:8];
+
+ /* Collect from main window tree. */
+ Lisp_Object root = FRAME_ROOT_WINDOW (emacsframe);
+ ns_ax_collect_windows (root, self, newElements, existing);
+
+ /* Include minibuffer. */
+ Lisp_Object mini = emacsframe->minibuffer_window;
+ if (!NILP (mini))
+ ns_ax_collect_windows (mini, self, newElements, existing);
+
+ [accessibilityElements release];
+ accessibilityElements = [newElements retain];
+ accessibilityTreeValid = YES;
+}
+
+- (void)invalidateAccessibilityTree
+{
+ accessibilityTreeValid = NO;
+}
+
+- (NSAccessibilityRole)accessibilityRole
+{
+ return NSAccessibilityGroupRole;
+}
+
+- (NSString *)accessibilityLabel
+{
+ return @"Emacs";
+}
+
+- (BOOL)isAccessibilityElement
+{
+ return YES;
+}
+
+- (NSArray *)accessibilityChildren
+{
+ if (!accessibilityElements || !accessibilityTreeValid)
+ [self rebuildAccessibilityTree];
+ return accessibilityElements;
+}
+
+- (id)accessibilityFocusedUIElement
+{
+ if (!emacsframe)
+ return self;
+
+ if (!accessibilityElements || !accessibilityTreeValid)
+ [self rebuildAccessibilityTree];
+
+ for (EmacsAccessibilityElement *elem in accessibilityElements)
+ {
+ if ([elem isKindOfClass:[EmacsAccessibilityBuffer class]]
+ && EQ (elem.lispWindow, emacsframe->selected_window))
+ return elem;
+ }
+ return self;
+}
+
+/* Called from ns_update_end to post AX notifications.
+
+ Important: post notifications BEFORE rebuilding the tree.
+ 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. */
+- (void)postAccessibilityUpdates
+{
+ NSTRACE ("[EmacsView postAccessibilityUpdates]");
+ eassert ([NSThread isMainThread]);
+
+ if (!emacsframe || !ns_accessibility_enabled)
+ return;
+
+ /* Re-entrance guard: VoiceOver callbacks during notification posting
+ can trigger redisplay, which calls ns_update_end, which calls us
+ again. Prevent infinite recursion. */
+ if (accessibilityUpdating)
+ return;
+ accessibilityUpdating = YES;
+
+ /* Detect window tree change (split, delete, new buffer). Compare
+ FRAME_ROOT_WINDOW — if it changed, the tree structure changed. */
+ Lisp_Object curRoot = FRAME_ROOT_WINDOW (emacsframe);
+ if (!EQ (curRoot, lastRootWindow))
+ {
+ lastRootWindow = curRoot;
+ accessibilityTreeValid = NO;
+ }
+
+ /* If tree is stale, rebuild FIRST so we don't iterate freed
+ window pointers. Skip notifications for this cycle — the
+ freshly-built elements have no previous state to diff against. */
+ if (!accessibilityTreeValid)
+ {
+ [self rebuildAccessibilityTree];
+ /* Invalidate span cache — window layout changed. */
+ for (EmacsAccessibilityElement *elem in accessibilityElements)
+ if ([elem isKindOfClass: [EmacsAccessibilityBuffer class]])
+ [(EmacsAccessibilityBuffer *) elem invalidateInteractiveSpans];
+ ns_ax_post_notification (self,
+ NSAccessibilityLayoutChangedNotification);
+
+ /* Post focus change so VoiceOver picks up the new tree. */
+ id focused = [self accessibilityFocusedUIElement];
+ if (focused && focused != self)
+ ns_ax_post_notification (focused,
+ NSAccessibilityFocusedUIElementChangedNotification);
+
+ lastSelectedWindow = emacsframe->selected_window;
+ accessibilityUpdating = NO;
+ return;
+ }
+
+ /* Post per-buffer notifications using EXISTING elements that have
+ cached state from the previous cycle. Validate each window
+ pointer before use. */
+ for (EmacsAccessibilityElement *elem in accessibilityElements)
+ {
+ if ([elem isKindOfClass:[EmacsAccessibilityBuffer class]])
+ {
+ struct window *w = [elem validWindow];
+ if (w && WINDOW_LEAF_P (w)
+ && BUFFERP (w->contents) && XBUFFER (w->contents))
+ [(EmacsAccessibilityBuffer *) elem
+ postAccessibilityNotificationsForFrame:emacsframe];
+ }
+ }
+
+ /* Check for window switch (C-x o). */
+ Lisp_Object curSel = emacsframe->selected_window;
+ BOOL windowSwitched = !EQ (curSel, lastSelectedWindow);
+ if (windowSwitched)
+ {
+ lastSelectedWindow = curSel;
+ id focused = [self accessibilityFocusedUIElement];
+ if (focused && focused != self
+ && [focused isKindOfClass:[EmacsAccessibilityBuffer class]])
+ {
+ ns_ax_post_notification (focused,
+ NSAccessibilityFocusedUIElementChangedNotification);
+ NSDictionary *info = @{
+ @"AXTextStateChangeType":
+ @(ns_ax_text_state_change_selection_move),
+ @"AXTextChangeElement": focused
+ };
+ ns_ax_post_notification_with_info (focused,
+ NSAccessibilitySelectedTextChangedNotification, info);
+ }
+ else if (focused && focused != self)
+ ns_ax_post_notification (focused,
+ NSAccessibilityFocusedUIElementChangedNotification);
+ }
+
+ accessibilityUpdating = NO;
+}
+
+/* ---- Cursor position for Zoom (via accessibilityBoundsForRange:) ----
+
+ accessibilityFrame returns the VIEW's frame (standard behavior).
+ The cursor location is exposed through accessibilityBoundsForRange:
+ which AT tools query using the selectedTextRange. */
+
+- (NSRect)accessibilityBoundsForRange:(NSRange)range
+{
+ /* Delegate to the focused buffer element for accurate per-range
+ geometry when possible. Fall back to the cached cursor rect
+ (set by ns_draw_phys_cursor) for Zoom and simple AT queries. */
+ id focused = [self accessibilityFocusedUIElement];
+ if ([focused isKindOfClass:[EmacsAccessibilityBuffer class]])
+ {
+ NSRect bufRect = [(EmacsAccessibilityBuffer *) focused
+ accessibilityFrameForRange:range];
+ if (!NSIsEmptyRect (bufRect))
+ return bufRect;
+ }
+
+ NSRect viewRect = lastAccessibilityCursorRect;
+
+ if (viewRect.size.width < 1)
+ viewRect.size.width = 1;
+ if (viewRect.size.height < 1)
+ viewRect.size.height = 8;
+
+ NSWindow *win = [self window];
+ if (win == nil)
+ return NSZeroRect;
+
+ NSRect windowRect = [self convertRect:viewRect toView:nil];
+ return [win convertRectToScreen:windowRect];
+}
+
+/* Modern NSAccessibility protocol entry point. Delegates to
+ accessibilityBoundsForRange: which holds the real implementation
+ shared with the legacy parameterized-attribute API. */
+- (NSRect)accessibilityFrameForRange:(NSRange)range
+{
+ return [self accessibilityBoundsForRange:range];
+}
+
+/* Delegate to the focused virtual buffer element so both the modern
+ and legacy APIs return the correct string data. */
+- (NSString *)accessibilityStringForRange:(NSRange)range
+{
+ id focused = [self accessibilityFocusedUIElement];
+ if ([focused isKindOfClass:[EmacsAccessibilityBuffer class]])
+ return [(EmacsAccessibilityBuffer *) focused
+ accessibilityStringForRange:range];
+ return @"";
+}
+
+/* ---- Legacy parameterized attribute APIs (Zoom uses these) ---- */
+
+- (NSArray *)accessibilityParameterizedAttributeNames
+{
+ NSArray *superAttrs = [super accessibilityParameterizedAttributeNames];
+ if (superAttrs == nil)
+ superAttrs = @[];
+ return [superAttrs arrayByAddingObjectsFromArray:
+ @[NSAccessibilityBoundsForRangeParameterizedAttribute,
+ NSAccessibilityStringForRangeParameterizedAttribute]];
+}
+
+- (id)accessibilityAttributeValue:(NSString *)attribute
+ forParameter:(id)parameter
+{
+ if ([attribute isEqualToString:
+ NSAccessibilityBoundsForRangeParameterizedAttribute])
+ {
+ NSRange range = [(NSValue *) parameter rangeValue];
+ return [NSValue valueWithRect:
+ [self accessibilityBoundsForRange:range]];
+ }
+
+ if ([attribute isEqualToString:
+ NSAccessibilityStringForRangeParameterizedAttribute])
+ {
+ NSRange range = [(NSValue *) parameter rangeValue];
+ return [self accessibilityStringForRange:range];
+ }
+
+ return [super accessibilityAttributeValue:attribute forParameter:parameter];
+}
+
+#endif /* NS_IMPL_COCOA */
+
@end /* EmacsView */
--
2.43.0

View File

@@ -0,0 +1,109 @@
From 5ddf6227b581bf292fc187a1ebcaf80d2cd4cf2a Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 12:58:11 +0100
Subject: [PATCH 6/8] doc: add VoiceOver accessibility section to macOS
appendix
* doc/emacs/macos.texi (VoiceOver Accessibility): New node. Document
screen reader usage, keyboard navigation, completion announcements,
Zoom cursor tracking, ns-accessibility-enabled, known limitations.
---
doc/emacs/macos.texi | 75 ++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 75 insertions(+)
diff --git a/doc/emacs/macos.texi b/doc/emacs/macos.texi
index 6bd334f..c4dced5 100644
--- a/doc/emacs/macos.texi
+++ b/doc/emacs/macos.texi
@@ -36,6 +36,7 @@ Support}), but we hope to improve it in the future.
* Mac / GNUstep Basics:: Basic Emacs usage under GNUstep or macOS.
* Mac / GNUstep Customization:: Customizations under GNUstep or macOS.
* Mac / GNUstep Events:: How window system events are handled.
+* VoiceOver Accessibility:: Screen reader support on macOS.
* GNUstep Support:: Details on status of GNUstep support.
@end menu
@@ -272,6 +273,80 @@ and return the result as a string. You can also use the Lisp function
services and receive the results back. Note that you may need to
restart Emacs to access newly-available services.
+@node VoiceOver Accessibility
+@section VoiceOver Accessibility (macOS)
+@cindex VoiceOver
+@cindex accessibility (macOS)
+@cindex screen reader (macOS)
+@cindex Zoom, cursor tracking (macOS)
+
+ When built with the Cocoa interface on macOS, Emacs exposes buffer
+content, cursor position, mode lines, and interactive elements to the
+macOS accessibility subsystem. This enables use with VoiceOver,
+Apple's built-in screen reader, and with other assistive technology
+such as macOS Zoom.
+
+ Toggle VoiceOver with @kbd{Cmd-F5} (or via System Settings,
+Accessibility, VoiceOver). When Emacs is focused, VoiceOver announces
+the buffer name and current line. Standard Emacs navigation produces
+speech feedback:
+
+@itemize @bullet
+@item
+Arrow keys read individual characters (left/right) or full lines
+(up/down).
+@item
+@kbd{M-f} and @kbd{M-b} announce words.
+@item
+@kbd{C-n} and @kbd{C-p} read the destination line.
+@item
+Shift-modified movement announces selected or deselected text.
+@item
+@key{TAB} and @kbd{S-@key{TAB}} navigate interactive elements
+(buttons, links, completion candidates) within a buffer.
+@end itemize
+
+ The @file{*Completions*} buffer announces each completion candidate
+as you navigate, even while keyboard focus remains in the minibuffer.
+
+ macOS Zoom (System Settings, Accessibility, Zoom) tracks the Emacs
+cursor automatically when set to follow keyboard focus. The cursor
+position is communicated via @code{UAZoomChangeFocus} and the
+@code{AXBoundsForRange} accessibility attribute.
+
+@vindex ns-accessibility-enabled
+ To disable the accessibility interface entirely (for instance, to
+eliminate overhead on systems where assistive technology is not in
+use), set @code{ns-accessibility-enabled} to @code{nil}. The default
+is @code{t}.
+
+@subheading Known Limitations
+
+@itemize @bullet
+@item
+Accessibility text is capped at 100,000 UTF-16 units per window.
+Buffers exceeding this limit are truncated for accessibility purposes;
+VoiceOver will announce ``end of text'' at the cap boundary.
+@item
+Mode-line text extraction handles only character glyphs. Mode lines
+using icon fonts (e.g., @code{doom-modeline} with nerd-font icons)
+produce incomplete accessibility text.
+@item
+The accessibility virtual element tree is rebuilt automatically on
+window configuration changes (splits, deletions, new buffers).
+@item
+Right-to-left (bidi) text is exposed correctly as buffer content,
+but @code{accessibilityRangeForPosition} hit-testing assumes
+left-to-right glyph layout.
+@end itemize
+
+ This support is available only on the Cocoa build; GNUstep has a
+different accessibility model and is not yet supported
+(@pxref{GNUstep Support}). Evil-mode block cursors are handled
+correctly: character navigation announces the character at the cursor
+position, not the character before it.
+
+
@node GNUstep Support
@section GNUstep Support
--
2.43.0

View File

@@ -0,0 +1,660 @@
From 8f619411ec75efbd18e663bb3f2ed6f8c9af60d8 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 14:46:25 +0100
Subject: [PATCH 7/8] ns: announce overlay completion candidates for VoiceOver
Completion frameworks such as Vertico, Ivy, and Icomplete render
candidates via overlay before-string/after-string properties rather
than buffer text. Without this patch, VoiceOver cannot read
overlay-based completion UIs.
Identify the selected candidate by scanning overlay strings for a
face whose symbol name contains "current", "selected", or
"selection" --- this matches vertico-current, icomplete-selected-match,
ivy-current-match, company-tooltip-selection, and similar framework
faces without hard-coding any specific name.
Key implementation details:
- The overlay detection branch runs independently (if, not else-if)
of the text-change branch, because Vertico bumps both BUF_MODIFF
(via text property changes in vertico--prompt-selection) and
BUF_OVERLAY_MODIFF (via overlay-put) in the same command cycle.
- Use BUF_CHARS_MODIFF to gate ValueChanged notifications, since
text property changes bump BUF_MODIFF but not BUF_CHARS_MODIFF.
- Remove BUF_OVERLAY_MODIFF from ensureTextCache validity checks
to prevent a race condition where VoiceOver AX queries silently
consume the overlay change before the notification dispatch runs.
- Announce via AnnouncementRequested to NSApp with High priority.
Do not post SelectedTextChanged (that reads the AX text at cursor
position, which is the minibuffer input, not the candidate).
- Zoom tracking: store the selected candidate's rect (at the text
area left edge, computed from FRAME_LINE_HEIGHT) in overlayZoomRect.
ns_draw_window_cursor checks overlayZoomActive and uses the stored
rect instead of the text cursor rect, keeping Zoom focused on the
candidate line start. The flag is cleared when the user types
(BUF_CHARS_MODIFF changes) or when no candidate is found
(minibuffer exit, C-g).
* src/nsterm.h (EmacsView): Add overlayZoomActive, overlayZoomRect.
(EmacsAccessibilityBuffer): Add cachedCharsModiff.
* src/nsterm.m (ns_ax_face_is_selected): New predicate. Match
"current", "selected", and "selection" in face symbol names.
(ns_ax_selected_overlay_text): New function.
(ns_draw_window_cursor): Use overlayZoomRect when active.
(EmacsAccessibilityBuffer ensureTextCache): Remove overlay_modiff.
(EmacsAccessibilityBuffer postAccessibilityNotificationsForFrame:):
Independent overlay branch, BUF_CHARS_MODIFF gating, candidate
announcement with overlay Zoom rect storage.
---
src/nsterm.h | 3 +
src/nsterm.m | 361 ++++++++++++++++++++++++++++++++++++++++++++++-----
2 files changed, 329 insertions(+), 35 deletions(-)
diff --git a/src/nsterm.h b/src/nsterm.h
index 5298386..a007925 100644
--- a/src/nsterm.h
+++ b/src/nsterm.h
@@ -509,6 +509,7 @@ typedef struct ns_ax_visible_run
@property (nonatomic, assign) ptrdiff_t cachedOverlayModiff;
@property (nonatomic, assign) ptrdiff_t cachedTextStart;
@property (nonatomic, assign) ptrdiff_t cachedModiff;
+@property (nonatomic, assign) ptrdiff_t cachedCharsModiff;
@property (nonatomic, assign) ptrdiff_t cachedPoint;
@property (nonatomic, assign) BOOL cachedMarkActive;
@property (nonatomic, copy) NSString *cachedCompletionAnnouncement;
@@ -595,6 +596,8 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType)
BOOL accessibilityUpdating;
@public /* Accessed by ns_draw_phys_cursor (C function). */
NSRect lastAccessibilityCursorRect;
+ BOOL overlayZoomActive;
+ NSRect overlayZoomRect;
#endif
BOOL font_panel_active;
NSFont *font_panel_result;
diff --git a/src/nsterm.m b/src/nsterm.m
index 125e52c..ebd52c6 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -3258,7 +3258,12 @@ ns_draw_window_cursor (struct window *w, struct glyph_row *glyph_row,
&& MAC_OS_X_VERSION_MIN_REQUIRED >= 101000
if (UAZoomEnabled ())
{
- NSRect windowRect = [view convertRect:r toView:nil];
+ /* When overlay completion is active (e.g. Vertico),
+ focus Zoom on the selected candidate row instead
+ of the text cursor. */
+ NSRect zoomSrc = view->overlayZoomActive
+ ? view->overlayZoomRect : r;
+ NSRect windowRect = [view convertRect:zoomSrc toView:nil];
NSRect screenRect = [[view window] convertRectToScreen:windowRect];
CGRect cgRect = NSRectToCGRect (screenRect);
@@ -7159,11 +7164,156 @@ ns_ax_selected_child_frame_text (struct buffer *b, Lisp_Object buf_obj,
}
+/* Return true if FACE is or contains a face symbol whose name
+ includes "current" or "selected", indicating a highlighted
+ completion candidate. Works for vertico-current,
+ icomplete-selected-match, ivy-current-match, etc. */
+static bool
+ns_ax_face_is_selected (Lisp_Object face)
+{
+ if (SYMBOLP (face) && !NILP (face))
+ {
+ const char *name = SSDATA (SYMBOL_NAME (face));
+ /* Substring match is intentionally broad --- it catches
+ vertico-current, icomplete-selected-match, ivy-current-match,
+ company-tooltip-selection, and similar. False positives are
+ harmless since this runs only on overlay strings during
+ completion. */
+ if (strstr (name, "current") || strstr (name, "selected")
+ || strstr (name, "selection"))
+ return true;
+ }
+ if (CONSP (face))
+ {
+ for (Lisp_Object tail = face; CONSP (tail); tail = XCDR (tail))
+ if (ns_ax_face_is_selected (XCAR (tail)))
+ return true;
+ }
+ return false;
+}
+
+/* Extract the currently selected candidate text from overlay display
+ strings. Completion frameworks render candidates as overlay
+ before-string/after-string and highlight the current candidate
+ with a face whose name contains "current" or "selected"
+ (e.g. vertico-current, icomplete-selected-match, ivy-current-match).
+
+ Scan all overlays in the buffer region [BEG, END), find the line
+ whose face matches the selection heuristic, and return it (already
+ trimmed of surrounding whitespace).
+
+ Also set *OUT_LINE_INDEX to the 0-based visual line index of the
+ selected candidate (for Zoom positioning), counting only non-trivial
+ lines. Set to -1 if not found.
+
+ Returns nil if no selected candidate is found. */
+static NSString *
+ns_ax_selected_overlay_text (struct buffer *b,
+ ptrdiff_t beg, ptrdiff_t end,
+ int *out_line_index)
+{
+ *out_line_index = -1;
+
+ Lisp_Object ov_list = Foverlays_in (make_fixnum (beg),
+ make_fixnum (end));
+
+ for (Lisp_Object tail = ov_list; CONSP (tail); tail = XCDR (tail))
+ {
+ Lisp_Object ov = XCAR (tail);
+ Lisp_Object strings[2];
+ strings[0] = Foverlay_get (ov, intern_c_string ("before-string"));
+ strings[1] = Foverlay_get (ov, intern_c_string ("after-string"));
+
+ for (int s = 0; s < 2; s++)
+ {
+ if (!STRINGP (strings[s]))
+ continue;
+
+ Lisp_Object str = strings[s];
+ ptrdiff_t slen = SCHARS (str);
+ if (slen == 0)
+ continue;
+
+ /* Scan for newline positions using SDATA for efficiency.
+ The data pointer is used only in this loop, before any
+ Lisp calls (Fget_text_property etc.) that could trigger
+ GC and relocate string data. */
+ const unsigned char *data = SDATA (str);
+ ptrdiff_t byte_len = SBYTES (str);
+ /* 512 lines is sufficient for any completion UI;
+ vertico-count defaults to 10. */
+ ptrdiff_t line_starts[512];
+ ptrdiff_t line_ends[512];
+ int nlines = 0;
+ ptrdiff_t char_pos = 0, byte_pos = 0, lstart = 0;
+
+ while (byte_pos < byte_len && nlines < 512)
+ {
+ if (data[byte_pos] == '\n')
+ {
+ if (char_pos > lstart)
+ {
+ line_starts[nlines] = lstart;
+ line_ends[nlines] = char_pos;
+ nlines++;
+ }
+ lstart = char_pos + 1;
+ }
+ if (STRING_MULTIBYTE (str))
+ byte_pos += BYTES_BY_CHAR_HEAD (data[byte_pos]);
+ else
+ byte_pos++;
+ char_pos++;
+ }
+ if (char_pos > lstart && nlines < 512)
+ {
+ line_starts[nlines] = lstart;
+ line_ends[nlines] = char_pos;
+ nlines++;
+ }
+
+ /* Find the line whose face indicates selection. Track
+ visual line index for Zoom (skip whitespace-only lines
+ like Vertico's leading cursor-space). */
+ int candidate_idx = 0;
+ for (int li = 0; li < nlines; li++)
+ {
+ Lisp_Object face
+ = Fget_text_property (make_fixnum (line_starts[li]),
+ Qface, str);
+ if (ns_ax_face_is_selected (face))
+ {
+ Lisp_Object line
+ = Fsubstring_no_properties (
+ str,
+ make_fixnum (line_starts[li]),
+ make_fixnum (line_ends[li]));
+ NSString *text = [NSString stringWithLispString:line];
+ text = [text stringByTrimmingCharactersInSet:
+ [NSCharacterSet
+ whitespaceAndNewlineCharacterSet]];
+ if ([text length] > 0)
+ {
+ *out_line_index = candidate_idx;
+ return text;
+ }
+ }
+
+ /* Count non-trivial lines as candidates for Zoom. */
+ if (line_ends[li] - line_starts[li] > 1)
+ candidate_idx++;
+ }
+ }
+ }
+
+ return nil;
+}
+
+
/* Build accessibility text for window W, skipping invisible text.
Populates *OUT_START with the buffer start charpos.
Populates *OUT_RUNS with an array of visible runs and *OUT_NRUNS
with the count. Caller must free *OUT_RUNS with xfree(). */
-
static NSString *
ns_ax_buffer_text (struct window *w, ptrdiff_t *out_start,
ns_ax_visible_run **out_runs, NSUInteger *out_nruns)
@@ -7234,7 +7384,7 @@ ns_ax_buffer_text (struct window *w, ptrdiff_t *out_start,
/* Extract this visible run's text. Use
Fbuffer_substring_no_properties which correctly handles the
- buffer gap — raw BUF_BYTE_ADDRESS reads across the gap would
+ buffer gap --- raw BUF_BYTE_ADDRESS reads across the gap would
include garbage bytes when the run spans the gap position. */
Lisp_Object lstr = Fbuffer_substring_no_properties (
make_fixnum (pos), make_fixnum (run_end));
@@ -7315,7 +7465,7 @@ ns_ax_frame_for_range (struct window *w, EmacsView *view,
return NSZeroRect;
/* charpos_start and charpos_len are already in buffer charpos
- space — the caller maps AX string indices through
+ space --- the caller maps AX string indices through
charposForAccessibilityIndex which handles invisible text. */
ptrdiff_t cp_start = charpos_start;
ptrdiff_t cp_end = cp_start + charpos_len;
@@ -7794,6 +7944,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
@synthesize cachedOverlayModiff;
@synthesize cachedTextStart;
@synthesize cachedModiff;
+@synthesize cachedCharsModiff;
@synthesize cachedPoint;
@synthesize cachedMarkActive;
@synthesize cachedCompletionAnnouncement;
@@ -7891,7 +8042,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
NSTRACE ("EmacsAccessibilityBuffer ensureTextCache");
/* This method is only called from the main thread (AX getters
dispatch_sync to main first). Reads of cachedText/cachedTextModiff
- below are therefore safe without @synchronized — only the
+ below are therefore safe without @synchronized --- only the
write section at the end needs synchronization to protect
against concurrent reads from AX server thread. */
eassert ([NSThread isMainThread]);
@@ -7904,16 +8055,15 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
return;
ptrdiff_t modiff = BUF_MODIFF (b);
- ptrdiff_t overlay_modiff = BUF_OVERLAY_MODIFF (b);
ptrdiff_t pt = BUF_PT (b);
NSUInteger textLen = cachedText ? [cachedText length] : 0;
- /* Track both BUF_MODIFF and BUF_OVERLAY_MODIFF. Overlay-only
- changes (e.g., timer-based completion highlight move without
- text edit) bump overlay_modiff but not modiff. Also detect
- narrowing/widening which changes BUF_BEGV without bumping
- either modiff counter. */
+ /* Cache validity: track BUF_MODIFF and buffer narrowing.
+ Do NOT track BUF_OVERLAY_MODIFF here --- overlay text is not
+ included in the cached AX text (it is handled separately via
+ explicit announcements). Including overlay_modiff would
+ silently update cachedOverlayModiff and prevent the
+ notification dispatch from detecting overlay changes. */
if (cachedText && cachedTextModiff == modiff
- && cachedOverlayModiff == overlay_modiff
&& cachedTextStart == BUF_BEGV (b)
&& pt >= cachedTextStart
&& (textLen == 0
@@ -7930,7 +8080,6 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
[cachedText release];
cachedText = [text retain];
cachedTextModiff = modiff;
- cachedOverlayModiff = overlay_modiff;
cachedTextStart = start;
if (visibleRuns)
@@ -7995,7 +8144,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
/* Binary search: runs are sorted by charpos (ascending). Find the
run whose [charpos, charpos+length) range contains the target,
or the nearest run after an invisible gap. O(log n) instead of
- O(n) — matters for org-mode with many folded sections. */
+ O(n) --- matters for org-mode with many folded sections. */
NSUInteger lo = 0, hi = visibleRunCount;
while (lo < hi)
{
@@ -8008,7 +8157,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
else
{
/* Found: charpos is inside this run. Compute UTF-16 delta
- directly from cachedText — no Lisp calls needed. */
+ directly from cachedText --- no Lisp calls needed. */
NSUInteger chars_in = (NSUInteger)(charpos - r->charpos);
if (chars_in == 0 || !cachedText)
return r->ax_start;
@@ -8033,10 +8182,10 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
/* Convert accessibility string index to buffer charpos.
Safe to call from any thread: uses only cachedText (NSString) and
- visibleRuns — no Lisp calls. */
+ visibleRuns --- no Lisp calls. */
- (ptrdiff_t)charposForAccessibilityIndex:(NSUInteger)ax_idx
{
- /* May be called from AX server thread — synchronize. */
+ /* May be called from AX server thread --- synchronize. */
@synchronized (self)
{
if (visibleRunCount == 0)
@@ -8070,7 +8219,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
return cp;
}
}
- /* Past end — return last charpos. */
+ /* Past end --- return last charpos. */
if (lo > 0)
{
ns_ax_visible_run *last = &visibleRuns[visibleRunCount - 1];
@@ -8092,7 +8241,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
deadlocking the AX server thread. This is prevented by:
1. validWindow checks WINDOW_LIVE_P and BUFFERP before every
- Lisp access — the window and buffer are verified live.
+ Lisp access --- the window and buffer are verified live.
2. All dispatch_sync blocks run on the main thread where no
concurrent Lisp code can modify state between checks.
3. block_input prevents timer events and process output from
@@ -8443,7 +8592,51 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
if (point_idx > [cachedText length])
point_idx = [cachedText length];
+ return [self lineForAXIndex:point_idx];
+}
+
+- (NSString *)accessibilityStringForRange:(NSRange)range
+{
+ if (![NSThread isMainThread])
+ {
+ __block NSString *result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityStringForRange:range];
+ });
+ return result;
+ }
+ [self ensureTextCache];
+ if (!cachedText || range.location + range.length > [cachedText length])
+ return @"";
+ return [cachedText substringWithRange:range];
+}
+
+- (NSAttributedString *)accessibilityAttributedStringForRange:(NSRange)range
+{
+ NSString *str = [self accessibilityStringForRange:range];
+ return [[[NSAttributedString alloc] initWithString:str] autorelease];
+}
+
+- (NSInteger)accessibilityLineForIndex:(NSInteger)index
+{
+ if (![NSThread isMainThread])
+ {
+ __block NSInteger result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityLineForIndex:index];
+ });
+ return result;
+ }
+ [self ensureTextCache];
+ if (!cachedText || index < 0)
+ return 0;
+
+ NSUInteger idx = (NSUInteger) index;
+ if (idx > [cachedText length])
+ idx = [cachedText length];
+
return [self lineForAXIndex:idx];
+
}
- (NSRange)accessibilityRangeForLine:(NSInteger)line
@@ -8667,7 +8860,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
/* ===================================================================
- EmacsAccessibilityBuffer (Notifications) — AX event dispatch
+ EmacsAccessibilityBuffer (Notifications) --- AX event dispatch
These methods notify VoiceOver of text and selection changes.
Called from the redisplay cycle (postAccessibilityUpdates).
@@ -8682,7 +8875,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
if (point > self.cachedPoint
&& point - self.cachedPoint == 1)
{
- /* Single char inserted — refresh cache and grab it. */
+ /* Single char inserted --- refresh cache and grab it. */
[self invalidateTextCache];
[self ensureTextCache];
if (cachedText)
@@ -8701,7 +8894,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
/* Update cachedPoint here so the selection-move branch does NOT
fire for point changes caused by edits. WebKit and Chromium
never send both ValueChanged and SelectedTextChanged for the
- same user action — they are mutually exclusive. */
+ same user action --- they are mutually exclusive. */
self.cachedPoint = point;
NSDictionary *change = @{
@@ -9034,14 +9227,112 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
BOOL markActive = !NILP (BVAR (b, mark_active));
/* --- Text changed (edit) --- */
+ ptrdiff_t chars_modiff = BUF_CHARS_MODIFF (b);
if (modiff != self.cachedModiff)
{
self.cachedModiff = modiff;
- [self postTextChangedNotification:point];
+ /* Only post ValueChanged when actual characters changed.
+ Text property changes (e.g. face updates from
+ vertico--prompt-selection) bump BUF_MODIFF but not
+ BUF_CHARS_MODIFF. Posting ValueChanged for property-only
+ changes causes VoiceOver to say "new line" when the diff
+ is non-empty due to overlay content changes. */
+ if (chars_modiff != self.cachedCharsModiff)
+ {
+ self.cachedCharsModiff = chars_modiff;
+ self.emacsView->overlayZoomActive = NO;
+ [self postTextChangedNotification:point];
+ }
+ }
+
+
+ /* --- Overlay content changed (e.g. Vertico/Ivy candidate switch) ---
+ Check independently of the modiff branch above, because
+ frameworks like Vertico bump BOTH BUF_MODIFF (via text property
+ changes in vertico--prompt-selection) and BUF_OVERLAY_MODIFF
+ (via overlay-put) in the same command cycle. If this were an
+ else-if, the modiff branch would always win and overlay
+ announcements would never fire.
+ Do NOT invalidate the text cache --- the buffer text has not
+ changed, and cache invalidation causes VoiceOver to diff old
+ vs new AX text and announce spurious "new line". */
+ if (BUF_OVERLAY_MODIFF (b) != self.cachedOverlayModiff)
+ {
+ self.cachedOverlayModiff = BUF_OVERLAY_MODIFF (b);
+
+ int selected_line = -1;
+ NSString *candidate
+ = ns_ax_selected_overlay_text (b, BUF_BEGV (b), BUF_ZV (b),
+ &selected_line);
+ if (candidate)
+ {
+ /* Deduplicate: only announce when the candidate changed. */
+ if (![candidate isEqualToString:
+ self.cachedCompletionAnnouncement])
+ {
+ self.cachedCompletionAnnouncement = candidate;
+
+ /* Announce the candidate text directly via NSApp.
+ Do NOT post SelectedTextChanged --- that would cause
+ VoiceOver to read the AX text at the cursor position
+ (the minibuffer input line), not the overlay candidate.
+ AnnouncementRequested with High priority interrupts
+ any current speech and announces our text. */
+ NSDictionary *annInfo = @{
+ NSAccessibilityAnnouncementKey: candidate,
+ NSAccessibilityPriorityKey:
+ @(NSAccessibilityPriorityHigh)
+ };
+ ns_ax_post_notification_with_info (
+ NSApp,
+ NSAccessibilityAnnouncementRequestedNotification,
+ annInfo);
+
+ /* --- Zoom tracking for overlay candidates ---
+ Store the candidate row rect so draw_window_cursor
+ focuses Zoom there instead of on the text cursor.
+ Cleared when the user types (chars_modiff change).
+
+ Use default line height to compute the Y offset:
+ row 0 is the input line, overlay candidates start
+ from row 1. This avoids fragile glyph matrix row
+ index mapping which can be off when group titles
+ or wrapped lines shift row numbering. */
+ if (selected_line >= 0)
+ {
+ struct window *w2 = [self validWindow];
+ if (w2)
+ {
+ EmacsView *view = self.emacsView;
+ struct frame *f2 = XFRAME (w2->frame);
+ int line_h = FRAME_LINE_HEIGHT (f2);
+ int y_off = (selected_line + 1) * line_h;
+
+ if (y_off < w2->pixel_height)
+ {
+ view->overlayZoomRect = NSMakeRect (
+ WINDOW_TEXT_TO_FRAME_PIXEL_X (w2, 0),
+ WINDOW_TO_FRAME_PIXEL_Y (w2, y_off),
+ FRAME_COLUMN_WIDTH (f2),
+ line_h);
+ view->overlayZoomActive = YES;
+ }
+ }
+ }
+ }
+ }
+ else
+ {
+ /* No selected candidate --- overlay completion ended
+ (minibuffer exit, C-g, etc.) or overlay has no
+ recognizable selection face. Return Zoom to the
+ text cursor. */
+ self.emacsView->overlayZoomActive = NO;
+ }
}
/* --- Cursor moved or selection changed ---
- Use 'else if' — edits and selection moves are mutually exclusive
+ Use 'else if' --- edits and selection moves are mutually exclusive
per the WebKit/Chromium pattern. */
else if (point != self.cachedPoint || markActive != self.cachedMarkActive)
{
@@ -9211,7 +9502,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
/* ===================================================================
- EmacsAccessibilityInteractiveSpan — helpers and implementation
+ EmacsAccessibilityInteractiveSpan --- helpers and implementation
=================================================================== */
/* Scan visible range of window W for interactive spans.
@@ -9402,7 +9693,7 @@ ns_ax_scan_interactive_spans (struct window *w,
- (BOOL) isAccessibilityFocused
{
/* Read the cached point stored by EmacsAccessibilityBuffer on the main
- thread — safe to read from any thread (plain ptrdiff_t, no Lisp calls). */
+ thread --- safe to read from any thread (plain ptrdiff_t, no Lisp calls). */
EmacsAccessibilityBuffer *pb = self.parentBuffer;
if (!pb)
return NO;
@@ -9419,7 +9710,7 @@ ns_ax_scan_interactive_spans (struct window *w,
dispatch_async (dispatch_get_main_queue (), ^{
/* lwin is a Lisp_Object captured by value. This is GC-safe
because Lisp_Objects are tagged integers/pointers that
- remain valid across GC — GC does not relocate objects in
+ remain valid across GC --- GC does not relocate objects in
Emacs. The WINDOW_LIVE_P check below guards against the
window being deleted between capture and execution. */
if (!WINDOWP (lwin) || NILP (Fwindow_live_p (lwin)))
@@ -9445,7 +9736,7 @@ ns_ax_scan_interactive_spans (struct window *w,
@end
-/* EmacsAccessibilityBuffer — InteractiveSpans category.
+/* EmacsAccessibilityBuffer --- InteractiveSpans category.
Methods are kept here (same .m file) so they access the ivars
declared in the @interface ivar block. */
@implementation EmacsAccessibilityBuffer (InteractiveSpans)
@@ -10765,13 +11056,13 @@ ns_in_echo_area (void)
if (old_title == 0)
{
char *t = strdup ([[[self window] title] UTF8String]);
- char *pos = strstr (t, " — ");
+ char *pos = strstr (t, " --- ");
if (pos)
*pos = '\0';
old_title = t;
}
size_title = xmalloc (strlen (old_title) + 40);
- esprintf (size_title, "%s — (%d × %d)", old_title, cols, rows);
+ esprintf (size_title, "%s --- (%d × %d)", old_title, cols, rows);
[window setTitle: [NSString stringWithUTF8String: size_title]];
[window display];
xfree (size_title);
@@ -12167,7 +12458,7 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view,
if (WINDOW_LEAF_P (w))
{
- /* Buffer element — reuse existing if available. */
+ /* Buffer element --- reuse existing if available. */
EmacsAccessibilityBuffer *elem
= [existing objectForKey:[NSValue valueWithPointer:w]];
if (!elem)
@@ -12201,7 +12492,7 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view,
}
else
{
- /* Internal (combination) window — recurse into children. */
+ /* Internal (combination) window --- recurse into children. */
Lisp_Object child = w->contents;
while (!NILP (child))
{
@@ -12313,7 +12604,7 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view,
accessibilityUpdating = YES;
/* Detect window tree change (split, delete, new buffer). Compare
- FRAME_ROOT_WINDOW — if it changed, the tree structure changed. */
+ FRAME_ROOT_WINDOW --- if it changed, the tree structure changed. */
Lisp_Object curRoot = FRAME_ROOT_WINDOW (emacsframe);
if (!EQ (curRoot, lastRootWindow))
{
@@ -12322,12 +12613,12 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view,
}
/* If tree is stale, rebuild FIRST so we don't iterate freed
- window pointers. Skip notifications for this cycle — the
+ window pointers. Skip notifications for this cycle --- the
freshly-built elements have no previous state to diff against. */
if (!accessibilityTreeValid)
{
[self rebuildAccessibilityTree];
- /* Invalidate span cache — window layout changed. */
+ /* Invalidate span cache --- window layout changed. */
for (EmacsAccessibilityElement *elem in accessibilityElements)
if ([elem isKindOfClass: [EmacsAccessibilityBuffer class]])
[(EmacsAccessibilityBuffer *) elem invalidateInteractiveSpans];
--
2.43.0

View File

@@ -0,0 +1,353 @@
From d68d1334147a7de273e39cf26c778389faa424ad Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 16:01:29 +0100
Subject: [PATCH 8/8] ns: announce child frame completion candidates for
VoiceOver
Completion frameworks such as Corfu, Company-box, and similar
render candidates in a child frame rather than as overlay strings
in the minibuffer. This patch extends the overlay announcement
support (patch 7/8) to handle child frame popups.
Detect child frames via FRAME_PARENT_FRAME in postAccessibilityUpdates.
Scan the child frame buffer text line by line using Fget_char_property
(which checks both text properties and overlay face properties) to
find the selected candidate. Reuse ns_ax_face_is_selected from
the overlay patch to identify "current", "selected", and
"selection" faces.
Safety:
- record_unwind_current_buffer / set_buffer_internal_1 to switch to
the child frame buffer for Fbuffer_substring_no_properties.
- Re-entrance guard (accessibilityUpdating) before child frame dispatch.
- BUF_MODIFF gating prevents redundant scans.
- WINDOWP, BUFFERP validation for partially initialized frames.
- Buffer size limit (10000 chars) skips non-completion child frames.
When the child frame closes, post FocusedUIElementChangedNotification
on the parent buffer element to restore VoiceOver's character echo
and cursor tracking. The flag childFrameCompletionActive is set by
the child frame handler and cleared on the parent's next accessibility
cycle when no child frame is visible (via FOR_EACH_FRAME).
Announce via AnnouncementRequested to NSApp with High priority.
Use direct UAZoomChangeFocus because the child frame renders
independently --- its ns_update_end runs after the parent's
draw_window_cursor, so the last Zoom call wins.
* src/nsterm.h (EmacsView): Add announceChildFrameCompletion,
childFrameCompletionActive flag.
* src/nsterm.m (ns_ax_selected_child_frame_text): New function.
(EmacsView announceChildFrameCompletion): New method, set parent flag.
(EmacsView postAccessibilityUpdates): Dispatch to child frame handler,
refocus parent buffer element when child frame closes.
---
src/nsterm.h | 2 +
src/nsterm.m | 253 ++++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 254 insertions(+), 1 deletion(-)
diff --git a/src/nsterm.h b/src/nsterm.h
index a007925..1a8a84d 100644
--- a/src/nsterm.h
+++ b/src/nsterm.h
@@ -598,6 +598,7 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType)
NSRect lastAccessibilityCursorRect;
BOOL overlayZoomActive;
NSRect overlayZoomRect;
+ BOOL childFrameCompletionActive;
#endif
BOOL font_panel_active;
NSFont *font_panel_result;
@@ -661,6 +662,7 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType)
- (void)rebuildAccessibilityTree;
- (void)invalidateAccessibilityTree;
- (void)postAccessibilityUpdates;
+- (void)announceChildFrameCompletion;
#endif
@end
diff --git a/src/nsterm.m b/src/nsterm.m
index ebd52c6..a7025a9 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -7310,6 +7310,110 @@ ns_ax_selected_overlay_text (struct buffer *b,
}
+/* Scan buffer text of a child frame for the selected completion
+ candidate. Used for frameworks that render candidates in a
+ child frame (e.g. Corfu, Company-box) rather than as overlay
+ strings. Check the effective face (text properties + overlays)
+ at the start of each line via Fget_char_property.
+
+ Returns the candidate text (trimmed) or nil. Sets
+ *OUT_LINE_INDEX to the 0-based line index for Zoom. */
+static NSString *
+ns_ax_selected_child_frame_text (struct buffer *b, Lisp_Object buf_obj,
+ int *out_line_index)
+{
+ *out_line_index = -1;
+ ptrdiff_t beg = BUF_BEGV (b);
+ ptrdiff_t end = BUF_ZV (b);
+
+ if (beg >= end)
+ return nil;
+
+ /* Temporarily switch to the child frame buffer.
+ Fbuffer_substring_no_properties operates on current_buffer,
+ which may be a different buffer (e.g., the parent frame's). */
+ specpdl_ref count = SPECPDL_INDEX ();
+ record_unwind_current_buffer ();
+ set_buffer_internal_1 (b);
+
+ /* Get buffer text as a Lisp string for efficient scanning.
+ The buffer is a small completion popup (typically < 20 lines). */
+ Lisp_Object str
+ = Fbuffer_substring_no_properties (make_fixnum (beg),
+ make_fixnum (end));
+ if (!STRINGP (str) || SCHARS (str) == 0)
+ {
+ unbind_to (count, Qnil);
+ return nil;
+ }
+
+ /* Scan newlines (same pattern as ns_ax_selected_overlay_text).
+ The data pointer is used only in this loop, before Lisp calls. */
+ const unsigned char *data = SDATA (str);
+ ptrdiff_t byte_len = SBYTES (str);
+ ptrdiff_t line_starts[128];
+ ptrdiff_t line_ends[128];
+ int nlines = 0;
+ ptrdiff_t char_pos = 0, byte_pos = 0, lstart = 0;
+
+ while (byte_pos < byte_len && nlines < 128)
+ {
+ if (data[byte_pos] == '\n')
+ {
+ if (char_pos > lstart)
+ {
+ line_starts[nlines] = lstart;
+ line_ends[nlines] = char_pos;
+ nlines++;
+ }
+ lstart = char_pos + 1;
+ }
+ if (STRING_MULTIBYTE (str))
+ byte_pos += BYTES_BY_CHAR_HEAD (data[byte_pos]);
+ else
+ byte_pos++;
+ char_pos++;
+ }
+ if (char_pos > lstart && nlines < 128)
+ {
+ line_starts[nlines] = lstart;
+ line_ends[nlines] = char_pos;
+ nlines++;
+ }
+
+ /* Find the line with a selected face. Use Fget_char_property on
+ the BUFFER (not the string) so overlay faces are included.
+ Offset string positions by beg to get buffer positions. */
+ for (int li = 0; li < nlines; li++)
+ {
+ ptrdiff_t buf_pos = beg + line_starts[li];
+ Lisp_Object face
+ = Fget_char_property (make_fixnum (buf_pos), Qface, buf_obj);
+
+ if (ns_ax_face_is_selected (face))
+ {
+ Lisp_Object line
+ = Fsubstring_no_properties (str,
+ make_fixnum (line_starts[li]),
+ make_fixnum (line_ends[li]));
+ NSString *text = [NSString stringWithLispString:line];
+ text = [text stringByTrimmingCharactersInSet:
+ [NSCharacterSet
+ whitespaceAndNewlineCharacterSet]];
+ if ([text length] > 0)
+ {
+ *out_line_index = li;
+ unbind_to (count, Qnil);
+ return text;
+ }
+ }
+ }
+
+ unbind_to (count, Qnil);
+ return nil;
+}
+
+
/* Build accessibility text for window W, skipping invisible text.
Populates *OUT_START with the buffer start charpos.
Populates *OUT_RUNS with an array of visible runs and *OUT_NRUNS
@@ -12588,6 +12692,105 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view,
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. */
+
+/* Announce the selected candidate in a child frame completion popup.
+ Handles Corfu, Company-box, and similar frameworks that render
+ candidates in a separate child frame rather than as overlay strings
+ in the minibuffer. Uses direct UAZoomChangeFocus (not the
+ overlayZoomRect flag) because the child frame's ns_update_end runs
+ after the parent's draw_window_cursor. */
+- (void)announceChildFrameCompletion
+{
+ static char *lastCandidate;
+ static struct buffer *lastBuffer;
+ static EMACS_INT lastModiff;
+
+ /* Validate frame state --- child frames may be partially
+ initialized during creation. */
+ if (!WINDOWP (emacsframe->selected_window))
+ return;
+ struct window *w = XWINDOW (emacsframe->selected_window);
+ if (!BUFFERP (w->contents))
+ return;
+ struct buffer *b = XBUFFER (w->contents);
+
+ /* Only scan when the buffer content has actually changed.
+ This prevents redundant work on every redisplay tick and
+ also guards against re-entrance: if Lisp calls below
+ trigger redisplay, the modiff check short-circuits. */
+ EMACS_INT modiff = BUF_MODIFF (b);
+ if (b == lastBuffer && modiff == lastModiff)
+ return;
+ lastBuffer = b;
+ lastModiff = modiff;
+
+ /* Skip buffers larger than a typical completion popup.
+ This avoids scanning eldoc, which-key, or other child
+ frame buffers that are not completion UIs. */
+ if (BUF_ZV (b) - BUF_BEGV (b) > 10000)
+ return;
+
+ int selected_line = -1;
+ NSString *candidate
+ = ns_ax_selected_child_frame_text (b, w->contents, &selected_line);
+
+ if (!candidate)
+ return;
+
+ /* Deduplicate --- avoid re-announcing the same candidate. */
+ const char *cstr = [candidate UTF8String];
+ if (lastCandidate && strcmp (cstr, lastCandidate) == 0)
+ return;
+ xfree (lastCandidate);
+ lastCandidate = xstrdup (cstr);
+
+ NSDictionary *annInfo = @{
+ NSAccessibilityAnnouncementKey: candidate,
+ NSAccessibilityPriorityKey:
+ @(NSAccessibilityPriorityHigh)
+ };
+ ns_ax_post_notification_with_info (
+ NSApp,
+ NSAccessibilityAnnouncementRequestedNotification,
+ annInfo);
+
+ /* Mark the parent as having an active child frame completion.
+ When the child frame closes, the parent's next accessibility
+ cycle will post FocusedUIElementChanged to restore VoiceOver's
+ focus to the buffer text element. */
+ struct frame *parent = FRAME_PARENT_FRAME (emacsframe);
+ if (parent)
+ {
+ EmacsView *parentView = FRAME_NS_VIEW (parent);
+ if (parentView)
+ parentView->childFrameCompletionActive = YES;
+ }
+
+ /* Zoom tracking: focus on the selected row in the child frame.
+ Use direct UAZoomChangeFocus rather than overlayZoomRect because
+ the child frame renders independently of the parent. */
+ if (selected_line >= 0 && UAZoomEnabled ())
+ {
+ int line_h = FRAME_LINE_HEIGHT (emacsframe);
+ int y_off = selected_line * line_h;
+ NSRect r = NSMakeRect (
+ WINDOW_TEXT_TO_FRAME_PIXEL_X (w, 0),
+ WINDOW_TO_FRAME_PIXEL_Y (w, y_off),
+ FRAME_COLUMN_WIDTH (emacsframe),
+ line_h);
+ NSRect winRect = [self convertRect:r toView:nil];
+ NSRect screenRect
+ = [[self window] convertRectToScreen:winRect];
+ CGRect cgRect = NSRectToCGRect (screenRect);
+ CGFloat primaryH
+ = [[[NSScreen screens] firstObject] frame].size.height;
+ cgRect.origin.y
+ = primaryH - cgRect.origin.y - cgRect.size.height;
+ UAZoomChangeFocus (&cgRect, &cgRect,
+ kUAZoomFocusTypeInsertionPoint);
+ }
+}
+
- (void)postAccessibilityUpdates
{
NSTRACE ("[EmacsView postAccessibilityUpdates]");
@@ -12598,11 +12801,59 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view,
/* Re-entrance guard: VoiceOver callbacks during notification posting
can trigger redisplay, which calls ns_update_end, which calls us
- again. Prevent infinite recursion. */
+ again. Prevent infinite recursion. This MUST come before the
+ child frame check --- announceChildFrameCompletion makes Lisp
+ calls that can trigger redisplay. */
if (accessibilityUpdating)
return;
accessibilityUpdating = YES;
+ /* Child frame completion popup (Corfu, Company-box, etc.).
+ Child frames don't participate in the accessibility tree;
+ announce the selected candidate directly. */
+ if (FRAME_PARENT_FRAME (emacsframe))
+ {
+ [self announceChildFrameCompletion];
+ accessibilityUpdating = NO;
+ return;
+ }
+
+ /* If a child frame completion was recently active but no child
+ frame is visible anymore, refocus VoiceOver on the buffer
+ element so character echo and cursor tracking resume.
+ Skip if a child frame still exists (completion still open). */
+ if (childFrameCompletionActive)
+ {
+ Lisp_Object tail, frame;
+ BOOL childStillVisible = NO;
+ FOR_EACH_FRAME (tail, frame)
+ if (FRAME_PARENT_FRAME (XFRAME (frame)) == emacsframe
+ && FRAME_VISIBLE_P (XFRAME (frame)))
+ {
+ childStillVisible = YES;
+ break;
+ }
+
+ if (!childStillVisible)
+ {
+ childFrameCompletionActive = NO;
+ EmacsAccessibilityBuffer *focused = nil;
+ for (id elem in accessibilityElements)
+ if ([elem isKindOfClass:
+ [EmacsAccessibilityBuffer class]]
+ && [(EmacsAccessibilityBuffer *)elem
+ isAccessibilityFocused])
+ {
+ focused = elem;
+ break;
+ }
+ if (focused)
+ ns_ax_post_notification (
+ focused,
+ NSAccessibilityFocusedUIElementChangedNotification);
+ }
+ }
+
/* Detect window tree change (split, delete, new buffer). Compare
FRAME_ROOT_WINDOW --- if it changed, the tree structure changed. */
Lisp_Object curRoot = FRAME_ROOT_WINDOW (emacsframe);
--
2.43.0

794
patches/README.txt Normal file
View File

@@ -0,0 +1,794 @@
EMACS NS VOICEOVER ACCESSIBILITY PATCH
========================================
patch: 0001-0008 (8 patches, see PATCH SERIES below)
author: Martin Sukany <martin@sukany.cz>
files: src/nsterm.h (+124 lines)
src/nsterm.m (+3577 ins, -185 del, +3392 net)
doc/emacs/macos.texi (+53 lines)
etc/NEWS (+13 lines)
PATCH SERIES
------------
0001 ns: add accessibility base classes and text extraction
0002 ns: implement buffer accessibility element (core protocol)
0003 ns: add buffer notification dispatch and mode-line element
0004 ns: add interactive span elements for Tab navigation
0005 ns: integrate accessibility with EmacsView and redisplay
0006 doc: add VoiceOver accessibility section to macOS appendix
0007 ns: announce overlay completion candidates for VoiceOver
0008 ns: announce child frame completion candidates for VoiceOver
0009 Performance: precomputed line index for O(log L) line queries
OVERVIEW
--------
This patch adds comprehensive macOS VoiceOver accessibility support
to the Emacs NS (Cocoa) port. Before this patch, Emacs exposed only
a minimal, largely broken accessibility interface to macOS assistive
technology (AT) clients: EmacsView identified itself as a generic
NSAccessibilityGroup with no text content, no cursor tracking, and
no notifications. VoiceOver users could activate the application
but received no meaningful speech feedback when editing text.
The patch introduces a layered virtual element tree above EmacsView.
Each visible Emacs window is represented by an EmacsAccessibilityBuffer
element (AXTextArea / AXTextField for minibuffer) with a full text
cache, a visible-run mapping table that bridges buffer character
positions to UTF-16 accessibility string indices, and an interactive
span child array for Tab navigation. A companion
EmacsAccessibilityModeLine element (AXStaticText) represents the mode
line of each window. These virtual elements are wired into the macOS
Accessibility API through EmacsView acting as the AXGroup root.
Two additional integration points are provided: (1) macOS Zoom is
informed of the cursor position after every physical cursor redraw via
UAZoomChangeFocus(), using the correct CoreGraphics (top-left-origin)
coordinate space; (2) EmacsView implements accessibilityBoundsForRange:
and its legacy parameterized-attribute equivalent so that both Zoom
and third-party AT tools can locate the insertion point. The patch
also covers completion announcements for the *Completions* buffer and
Tab-navigable interactive spans for buttons, links, checkboxes,
Org-mode links, completion candidates, and keymap overlays.
ARCHITECTURE
------------
Class hierarchy (Cocoa only):
NSAccessibilityElement
|
+-- EmacsAccessibilityElement (base: owns emacsView + lispWindow)
|
+-- EmacsAccessibilityBuffer (AXTextArea; one per leaf window)
| [category InteractiveSpans] (Tab nav children)
|
+-- EmacsAccessibilityModeLine (AXStaticText; one per non-mini)
|
+-- EmacsAccessibilityInteractiveSpan (AXButton/Link/etc.)
EmacsView (NSView subclass, existing)
|
+-- owns NSMutableArray *accessibilityElements
contains EmacsAccessibilityBuffer + EmacsAccessibilityModeLine
instances for every visible leaf window and minibuffer.
EmacsAccessibilityInteractiveSpan instances are children of
their parent EmacsAccessibilityBuffer, NOT of this array.
EmacsAccessibilityElement (base class)
- Stores a weak (unsafe_unretained) pointer to EmacsView and a
Lisp_Object lispWindow (GC-safe window reference).
- Provides -validWindow which verifies WINDOW_LIVE_P before
returning the raw struct window *. All subclasses use this to
avoid dangling pointers after delete-window or kill-buffer.
- Provides -screenRectFromEmacsX:y:width:height: which converts
EmacsView pixel coordinates (flipped AppKit space) to screen
coordinates via the NSWindow coordinate chain.
EmacsAccessibilityBuffer
- Implements the full NSAccessibility text protocol: value, selected
text range, line/index/range conversions, frame-for-range,
range-for-position, and insertion-point-line-number.
- Maintains a text cache (cachedText / visibleRuns) keyed on
BUF_MODIFF and BUF_BEGV (narrowing). BUF_OVERLAY_MODIFF is
tracked separately for notification dispatch (patch 0007)
but not for cache invalidation.
The cache is the single source of truth for all
index-to-charpos and charpos-to-index mappings.
- Detects buffer edits (modiff change), cursor movement (point
change), and mark changes, and posts the appropriate
NSAccessibility notifications after each redisplay cycle.
- Stores cached values for the previous cycle (cachedModiff,
cachedPoint, cachedMarkActive) to enable change detection.
EmacsAccessibilityModeLine
- Reads mode line text directly from the window's current glyph
matrix (CHAR_GLYPH rows with mode_line_p set).
- Stateless: no cache; text is read fresh on every AX query.
EmacsAccessibilityInteractiveSpan
- Lightweight child element representing one contiguous interactive
region (button, link, completion item, etc.).
- Reports isAccessibilityFocused by comparing cachedPoint of the
parent EmacsAccessibilityBuffer against its charpos range.
- On setAccessibilityFocused: dispatches to the main queue via
GCD to move Emacs point, using block_input around SET_PT_BOTH.
EmacsView (extensions)
- accessibilityElements array: rebuilt by -rebuildAccessibilityTree
when the window tree changes (split, delete, new buffer).
- -postAccessibilityUpdates: called from ns_update_end() after
every redisplay cycle; drives the notification dispatch loop.
- lastAccessibilityCursorRect: updated by ns_draw_phys_cursor
(C function) for Zoom integration.
- Implements accessibilityBoundsForRange: /
accessibilityFrameForRange: and the legacy
accessibilityAttributeValue:forParameter: API.
USER OPTION
-----------
ns-accessibility-enabled (DEFVAR_BOOL, default t):
When nil, the accessibility virtual element tree is not built, no
notifications are posted, and ns_draw_phys_cursor skips the Zoom
update. This eliminates accessibility overhead entirely on systems
where assistive technology is not in use. Guarded at three entry
points: postAccessibilityUpdates, ns_draw_phys_cursor, and
windowDidBecomeKey.
THREADING MODEL
---------------
Emacs runs all Lisp evaluation and buffer mutation on the main thread
(the Cocoa/AppKit main thread). The macOS Accessibility server
(axserver / AT daemon) calls AX getters from a private background
thread.
Rules enforced by this patch:
Main thread only:
- ns_update_end -> postAccessibilityUpdates
- rebuildAccessibilityTree / invalidateAccessibilityTree
- ensureTextCache / ns_ax_buffer_text (Lisp calls:
Fget_char_property, Fnext_single_char_property_change,
Fbuffer_substring_no_properties)
- postAccessibilityNotificationsForFrame: (full notify logic)
- setAccessibilitySelectedTextRange: (SET_PT_BOTH, marker moves)
- setAccessibilityFocused: on EmacsAccessibilityInteractiveSpan
(dispatches to main queue via dispatch_async; uses specpdl
unwind protection so block_input is always matched by
unblock_input even if Fselect_window signals an error)
- ns_draw_phys_cursor partial update (lastAccessibilityCursorRect,
UAZoomChangeFocus)
Safe from any thread (no Lisp calls, no mutable Emacs state):
- accessibilityIndexForCharpos: reads visibleRuns + cachedText
- charposForAccessibilityIndex: same
- isAccessibilityFocused on EmacsAccessibilityInteractiveSpan
(reads cachedPoint, a plain ptrdiff_t)
Dispatch-gated (marshalled to main thread when called off-thread):
- accessibilityValue (EmacsAccessibilityBuffer)
- accessibilitySelectedTextRange
- accessibilityInsertionPointLineNumber
- accessibilityFrameForRange:
- accessibilityRangeForPosition:
- accessibilityChildrenInNavigationOrder
The marshalling pattern used throughout:
if (![NSThread isMainThread]) {
__block T result;
dispatch_sync(dispatch_get_main_queue(), ^{ result = ...; });
return result;
}
Async notification posting (deadlock prevention):
NSAccessibilityPostNotification may synchronously invoke VoiceOver
callbacks from a private AX server thread. Those callbacks call
AX getters which dispatch_sync back to the main queue. If the
main thread is still inside the notification-posting method (e.g.,
postAccessibilityUpdates called from ns_update_end), the
dispatch_sync deadlocks: the main thread waits for VoiceOver to
finish processing the notification, while VoiceOver's thread waits
for the main queue to become available.
To break this cycle, all notification posting goes through two
static inline wrappers:
ns_ax_post_notification(element, name)
ns_ax_post_notification_with_info(element, name, info)
These wrappers defer the actual NSAccessibilityPostNotification
call via dispatch_async(dispatch_get_main_queue(), ^{ ... }).
The current method returns first, freeing the main queue, so
VoiceOver's dispatch_sync calls can proceed without deadlock.
Block captures retain ObjC objects (element, info dictionary)
for the lifetime of the deferred block.
Cached data written on main thread and read from any thread:
- cachedText (NSString *): written by ensureTextCache on main.
- visibleRuns (ns_ax_visible_run *): written by ensureTextCache.
- cachedPoint (ptrdiff_t): plain scalar; atomic on 64-bit ARM/x86.
No explicit lock is used; the design relies on the fact that index
mapping methods make no Lisp calls and read only the above scalars
and the immutable NSString object.
NOTIFICATION STRATEGY
---------------------
All notifications are posted asynchronously via
ns_ax_post_notification / ns_ax_post_notification_with_info
(dispatch_async wrappers -- see THREADING MODEL for rationale).
Notifications are generated by -postAccessibilityNotificationsForFrame:
which runs on the main thread after every redisplay cycle. The
method detects three mutually exclusive events:
1. TEXT CHANGED (modiff != cachedModiff)
Posts NSAccessibilityValueChangedNotification with AXTextEditType
= Typing and, when exactly one character was inserted, provides
AXTextChangeValue for echo feedback. cachedPoint is updated here
to suppress a spurious selection-move event in the same cycle
(WebKit/Chromium convention: edit and selection-move are mutually
exclusive per runloop iteration).
2. CURSOR MOVED OR MARK CHANGED (point != cachedPoint OR mark change)
Granularity is computed by comparing oldIdx and newIdx in
cachedText:
- different line range -> LINE granularity
- same line, distance > 1 UTF-16 unit -> WORD granularity
- same line, distance == 1 UTF-16 unit -> CHARACTER granularity
C-n / C-p / Tab / backtab force LINE granularity
(detected by ns_ax_event_is_line_nav_key which inspects
last_command_event) regardless.
For FOCUSED elements the hybrid strategy applies:
CHARACTER moves:
SelectedTextChanged is posted WITHOUT AXTextSelectionGranularity
in userInfo. Omitting the key prevents VoiceOver from deriving
its own speech (it would read the character BEFORE point,
which is wrong for evil block-cursor mode where the cursor
sits ON the character). Then AnnouncementRequested is posted
separately with the character AT point as the announcement.
Newline is skipped (VoiceOver handles end-of-line internally).
WORD and LINE moves:
SelectedTextChanged is posted WITH AXTextSelectionGranularity.
VoiceOver reads the word/line correctly from the element text
using the granularity hint. For LINE moves an additional
AnnouncementRequested is also posted with the line text (or
the completion--string at point if in a completion buffer) to
handle C-n/C-p -- VoiceOver processes these keystrokes
differently from arrow keys internally.
SELECTION changes (mark becomes active or extends):
SelectedTextChanged with LINE or WORD granularity. VoiceOver
reads the newly selected or deselected text.
For NON-FOCUSED elements (e.g. *Completions* while minibuffer has
focus): AnnouncementRequested only. See COMPLETION ANNOUNCEMENTS.
3. NO CHANGE
Nothing is posted. Completion cache is cleared for focused buffer.
TEXT CACHE AND VISIBLE RUNS
----------------------------
ns_ax_buffer_text(w, out_start, out_runs, out_nruns) builds the
accessibility string for window W. It operates on the current
buffer with set_buffer_internal_1, scanning from BUF_BEGV to BUF_ZV.
Invisible text detection uses TEXT_PROP_MEANS_INVISIBLE(invis) where
invis = Fget_char_property(pos, Qinvisible, Qnil). This respects
buffer-invisibility-spec, correctly handling org-mode folding,
outline mode, and hideshow -- not just `invisible t' text properties.
When an invisible region is found, the scanner jumps ahead using
Fnext_single_char_property_change to skip the entire region in O(1)
iterations rather than character by character.
Text extraction uses Fbuffer_substring_no_properties (not raw
BUF_BYTE_ADDRESS) to handle the buffer gap correctly. Raw byte
access across the gap position yields garbage bytes.
The ns_ax_visible_run structure:
typedef struct ns_ax_visible_run {
ptrdiff_t charpos; /* Buffer charpos of run start. */
ptrdiff_t length; /* Emacs characters in this run. */
NSUInteger ax_start; /* UTF-16 index in accessibility string. */
NSUInteger ax_length; /* UTF-16 units for this run. */
} ns_ax_visible_run;
Multiple runs are produced when invisible text splits the buffer into
non-contiguous visible segments. The mapping array is stored in the
EmacsAccessibilityBuffer ivar `visibleRuns' (C array, xmalloc'd).
Index mapping (charpos <-> ax_index) uses binary search over the
sorted run array — O(log n) per lookup. Within a run, UTF-16 unit
counting uses
rangeOfComposedCharacterSequenceAtIndex: to handle surrogate pairs
(emoji, rare CJK) correctly -- one Emacs character may occupy 2
UTF-16 units.
Cache invalidation is triggered whenever BUF_MODIFF or
BUF_OVERLAY_MODIFF changes (ensureTextCache compares both
cachedTextModiff and cachedOverlayModiff). Additionally,
narrowing/widening is detected by comparing cachedTextStart
against BUF_BEGV — these operations change the visible region
without bumping either modiff counter. The cache is also
invalidated when the window tree is rebuilt.
There is no character cap on the accessibility text. The entire
visible (non-invisible) buffer content is exposed to VoiceOver.
Users who do not need accessibility can set ns-accessibility-enabled
to nil for zero overhead.
A lineStartOffsets array is built during each cache rebuild,
recording the AX string index where each line begins. This
makes accessibilityLineForIndex: and accessibilityRangeForLine:
O(log L) via binary search instead of O(L) linear scanning.
The index is freed and rebuilt alongside the text cache.
COMPLETION ANNOUNCEMENTS
------------------------
When point moves in a non-focused buffer (the common case:
*Completions* window while the minibuffer retains keyboard focus),
VoiceOver does not automatically read the change because it is
tracking the focused element. The patch posts AnnouncementRequested
with a 4-step fallback chain to find the best text to announce:
Step 1 -- completion--string property at point.
The `completion--string' text property (set by minibuffer.el
since Emacs 29) carries the canonical completion candidate string.
It can be a plain Lisp string or a list (CANDIDATE ANNOTATION) where both
are strings.
ns_ax_completion_string_from_prop handles both: plain string ->
use directly; cons -> use car (the candidate without annotation).
This is the preferred source: precisely the candidate text with
no surrounding whitespace.
Step 2 -- mouse-face span at point.
completion-list-mode marks the active candidate with mouse-face.
The code walks backward and forward from point to find the span
boundaries, then reads the corresponding slice of cachedText.
Used when completion--string is absent (older Emacs or non-
standard completion modes).
Step 3 -- completions-highlight overlay at point.
Emacs 29+ highlights the selected completion with the
`completions-highlight' face applied via an overlay. The overlay
text is extracted via ns_ax_completion_text_for_span which itself
tries completion--string first, then the `completion' property,
then falls back to the ax string slice.
Step 4 -- nearest completions-highlight overlay.
ns_ax_find_completion_overlay_range scans the buffer for the
closest completions-highlight overlay to point. Uses fast probes
at {point, point+1, point-1} before falling back to a full O(n)
scan.
Final fallback -- current line text.
Read the line containing point from cachedText.
Deduplication: the announcement is posted only when announceText,
overlay bounds, or point have changed since the last cycle
(cachedCompletionAnnouncement, cachedCompletionOverlayStart/End,
cachedCompletionPoint).
INTERACTIVE SPANS
-----------------
ns_ax_scan_interactive_spans(w, parent_buf) scans the visible range
of window W looking for text properties that indicate interactive
content. Properties are checked in priority order:
widget -> EmacsAXSpanTypeWidget (AXButton, via default)
button -> EmacsAXSpanTypeButton (AXButton, via default)
follow-link -> EmacsAXSpanTypeLink (AXLink)
org-link -> EmacsAXSpanTypeLink (AXLink)
mouse-face -> EmacsAXSpanTypeCompletionItem
(AXButton; completion-list-mode only)
keymap overlay-> EmacsAXSpanTypeButton (AXButton)
For completion buffers (major-mode == completion-list-mode), the span
boundary for mouse-face regions uses completion--string as the property
key when present, rather than mouse-face itself. This prevents two
column-adjacent completion candidates from being merged into one span
when their mouse-face regions share padding whitespace.
All property symbols are registered with DEFSYM in syms_of_nsterm
using ns_ax_ prefixed C variable names (e.g., Qns_ax_button for
"button") to avoid collisions with other Emacs source files.
Referenced directly -- no repeated intern() calls.
Each span is allocated, configured, added to the spans array, then
released (the array retains it). The function returns an autoreleased
immutable copy of the spans array. Label priority:
completion--string > buffer substring > help-echo.
Tab navigation: -accessibilityChildrenInNavigationOrder returns the
cached span array, rebuilt lazily when interactiveSpansDirty is set.
Calls from off-thread are marshalled with dispatch_sync.
Focus movement: -setAccessibilityFocused: on a span dispatches
Fselect_window + SET_PT_BOTH to the main queue via dispatch_async,
wrapped in block_input/unblock_input.
ZOOM INTEGRATION
----------------
macOS Zoom (accessibility zoom) tracks a "focus element" to keep the
zoomed viewport centered on the relevant screen area. Two mechanisms
are provided:
1. ns_draw_phys_cursor (C function, main thread, called during
redisplay). After clipping the cursor rect to the text area,
stores the rect in view->lastAccessibilityCursorRect. If
UAZoomEnabled(), converts the rect to screen coordinates and calls
UAZoomChangeFocus(kUAZoomFocusTypeInsertionPoint).
Coordinate conversion chain:
EmacsView pixels (AppKit, flipped, origin at top-left of view)
-[convertRect:toView:nil]-> NSWindow coordinates
-[convertRectToScreen:]-> NSScreen coordinates
NSRectToCGRect -> CGRect (same values, no transform)
CG y-flip: cgRect.origin.y = primaryH - y - height
The flip is required because CoreGraphics uses top-left origin
(primary screen) while AppKit screen rects use bottom-left.
primaryH = [[NSScreen screens] firstObject].frame.size.height.
2. EmacsView -accessibilityBoundsForRange: /
-accessibilityFrameForRange:
AT tools (including Zoom) call these with the selectedTextRange
to locate the insertion point. The implementation first delegates
to the focused EmacsAccessibilityBuffer element for accurate
per-range geometry via its accessibilityFrameForRange: method.
If the buffer element returns an empty rect (no valid window or
glyph data), the fallback uses the cached cursor rect stored in
lastAccessibilityCursorRect (minimum size 1x8 pixels). The legacy
parameterized-attribute API
(NSAccessibilityBoundsForRangeParameterizedAttribute) is supported
via -accessibilityAttributeValue:forParameter: for older AT
clients.
KEY DESIGN DECISIONS
--------------------
1. DEFSYM instead of intern for all frequently-used symbols.
DEFSYM registers symbols at startup (syms_of_nsterm) and stores
them in C globals (e.g. Qns_ax_completion__string, Qns_ax_next_line).
This covers both property scanning symbols and line navigation
command symbols used in ns_ax_event_is_line_nav_key (hot path:
runs on every cursor movement). Using intern() would perform
obarray lookups on each redisplay cycle. DEFSYM symbols are
also always reachable by the GC via staticpro, eliminating any
risk of premature collection.
2. AnnouncementRequested for character moves, not SelectedTextChanged.
VoiceOver derives the speech character from SelectedTextChanged by
looking at the character BEFORE the new cursor position (the char
"passed over"). In evil-mode with a block cursor, the cursor sits
ON the character, not between characters. AnnouncementRequested
with the character AT point produces correct speech in both insert
and normal (block-cursor) modes. SelectedTextChanged is still
posted without granularity to interrupt ongoing VoiceOver reading
and update braille display tracking.
3. completion--string, not mouse-face, as span boundary.
mouse-face regions in completion-list-mode sometimes include
leading or trailing whitespace shared between column-adjacent
candidates, which could merge two candidates into one span.
completion--string changes precisely at candidate boundaries.
4. Probe order {point, point+1, point-1} for overlay search.
After Tab advances to a new completion candidate, point is at the
START of the new entry. The previous entry's overlay covers the
position before the new start, so point-1 is inside the OLD
overlay. Trying point+1 before point-1 finds the new (correct)
entry first.
5. Notifications posted BEFORE rebuilding the tree.
postAccessibilityUpdates uses existing elements which carry cached
state from the previous cycle. Rebuilding first would create
fresh elements with current values, making change detection
impossible. Tree rebuild is deferred to cycles where
accessibilityTreeValid is false; no notifications are posted in
that cycle.
6. Re-entrance guard (accessibilityUpdating flag).
VoiceOver callbacks triggered by notification posting can cause
Cocoa to re-enter the run loop, which may trigger redisplay, which
calls ns_update_end -> postAccessibilityUpdates. The BOOL flag
breaks this recursion.
6a. Async notification posting (dispatch_async wrappers).
NSAccessibilityPostNotification can synchronously trigger
VoiceOver queries from a background AX server thread. Those
queries dispatch_sync to the main queue. If the main thread
is still inside postAccessibilityUpdates (or windowDidBecomeKey,
or setAccessibilityFocused:), the dispatch_sync deadlocks.
All 14 notification sites use ns_ax_post_notification / _with_info
wrappers that defer posting via dispatch_async, freeing the main
queue before VoiceOver's callbacks arrive. This follows the same
pattern used by WebKit's AXObjectCacheMac (deferred posting via
performSelector:withObject:afterDelay:0).
7. lispWindow (Lisp_Object) instead of raw struct window *.
struct window pointers can become dangling after delete-window.
Storing the Lisp_Object and using WINDOW_LIVE_P + XWINDOW at the
call site is the standard safe pattern in Emacs C code.
8. accessibilityVisibleCharacterRange returns full buffer range.
VoiceOver treats the visible range boundary as end-of-text. If
this returned only the on-screen portion, VoiceOver would announce
"end of text" prematurely when the cursor reaches the visible
bottom, even though more buffer content exists below.
OVERLAY COMPLETION ANNOUNCEMENTS (Patch 0007)
----------------------------------------------
Overlay-based completion frameworks (Vertico, Icomplete, Ivy, etc.)
render candidates as overlay strings in the minibuffer. VoiceOver
does not see overlay content changes automatically. This patch
detects overlay candidate changes and announces the selected
candidate.
Detection:
ns_ax_face_is_selected(face) checks whether a face name contains
"current", "selected", or "selection" (matching vertico-current,
icomplete-selected-match, ivy-current-match, etc.). Supports
both single face symbols and face lists.
ns_ax_selected_overlay_text(b, beg, end, out_line) scans the
buffer region line by line using Fget_char_property to check
both text properties and overlay face properties.
Overlay changes are tracked independently of text changes:
BUF_OVERLAY_MODIFF is checked in an independent if-branch (not
else-if) because Vertico bumps both BUF_MODIFF (text properties)
and BUF_OVERLAY_MODIFF (overlays) in the same command cycle.
textDidChange flag:
hl-line-mode and similar packages update face properties (text
properties, not characters) on every cursor movement, bumping
BUF_MODIFF without changing BUF_CHARS_MODIFF. The original
else-if structure caused the modiff branch to fire (correctly
skipping ValueChanged) but also blocked the cursor-move branch
(SelectedTextChanged). A BOOL textDidChange flag decouples the
two branches: ValueChanged and SelectedTextChanged remain
mutually exclusive for real edits, but SelectedTextChanged fires
correctly when only text properties changed.
Zoom:
The selected candidate position is stored in overlayZoomRect /
overlayZoomActive on the parent EmacsView. draw_window_cursor
uses this rect instead of the text cursor when a candidate is
active. Cleared when BUF_CHARS_MODIFF changes (user types)
or when no candidate is found.
CHILD FRAME COMPLETION ANNOUNCEMENTS (Patch 0008)
--------------------------------------------------
Completion frameworks such as Corfu, Company-box, and similar render
candidates in a child frame rather than as overlay strings. This
patch detects child frames via FRAME_PARENT_FRAME and announces
the selected candidate.
Detection:
Child frames are dispatched in postAccessibilityUpdates before
the main tree rebuild logic. FRAME_PARENT_FRAME(emacsframe)
returns non-NULL for child frames.
ns_ax_selected_child_frame_text(b, buf_obj, out_line) scans the
child frame buffer line by line, reusing ns_ax_face_is_selected
from patch 0007.
Buffer switch safety:
Fbuffer_substring_no_properties operates on current_buffer, which
may differ from the child frame buffer during ns_update_end.
The function uses record_unwind_current_buffer /
set_buffer_internal_1 to temporarily switch, with unbind_to on
all three return paths after the switch. Uses specpdl_ref (not
ptrdiff_t) for the SPECPDL_INDEX return value.
Re-entrance protection:
The accessibilityUpdating guard MUST precede the child frame
dispatch because Lisp calls in the scan function (Fget_char_property,
Fbuffer_substring_no_properties) can trigger redisplay.
BUF_MODIFF gating provides a secondary guard and prevents
redundant scans.
Validation:
- WINDOWP / BUFFERP checks for partially initialized child frames.
- Buffer size limit (10000 chars) skips non-completion child frames
(eldoc, which-key, etc.).
Focus restoration:
childFrameCompletionActive (BOOL on EmacsView) is set by the child
frame handler on the parent view. On the parent's next accessibility
cycle, FOR_EACH_FRAME checks whether any child frame is still
visible. If not, FocusedUIElementChangedNotification is posted on
the focused buffer element to restore VoiceOver character echo and
cursor tracking.
Zoom:
Direct UAZoomChangeFocus (not overlayZoomRect) because the child
frame's ns_update_end runs after the parent's draw_window_cursor,
so the last Zoom call wins.
Deduplication:
Static C string cache (lastCandidate via xstrdup/xfree) avoids
re-announcing the same candidate.
KNOWN LIMITATIONS
-----------------
- Interactive span scan uses Fnext_single_property_change across
multiple properties to skip non-interactive regions in bulk, but
still visits every property-change boundary. For buffers with
many overlapping text properties (e.g. heavily fontified source
code), the number of boundaries can be significant. The scan
runs on every redisplay cycle when interactiveSpansDirty is set.
- Mode line text is extracted from CHAR_GLYPH rows only. Image
glyphs, stretch glyphs, and composed glyphs are silently skipped.
Mode lines with icon fonts (e.g. doom-modeline with nerd-font)
produce incomplete or garbled accessibility text.
- Line counting (accessibilityInsertionPointLineNumber,
accessibilityLineForIndex:) uses a precomputed lineStartOffsets
array built once per cache rebuild. Queries are O(log L) via
binary search.
- No multi-frame coordination. EmacsView.accessibilityElements is
per-view; there is no cross-frame notification ordering.
- Overlay completion (0007) face matching uses string containment
("current", "selected", "selection"). Custom completion frameworks
with face names not containing these substrings will not be detected.
- Child frame completion (0008) static lastBuffer pointer may become
stale if the buffer is freed and a new one allocated at the same
address. This is harmless (worst case: one missed announcement).
- Child frame window-appeared announcement: macOS automatically
announces the window title when a child frame NSWindow appears.
This cannot be suppressed without breaking VoiceOver focus tracking
or Zoom integration.
- GNUstep is explicitly excluded (#ifdef NS_IMPL_COCOA). GNUstep
has a different accessibility model and requires separate work.
- Line navigation detection (ns_ax_event_is_line_nav_key) checks
Vthis_command against known navigation command symbols
(next-line, previous-line, evil-next-line, etc.) and falls back
to raw key codes for Tab/backtab. Custom navigation commands
not in the recognized list will not get forced line-granularity
announcements.
- UAZoomChangeFocus always uses kUAZoomFocusTypeInsertionPoint
regardless of cursor style (box, bar, hbar). This is cosmetically
imprecise but functionally correct.
TESTING CHECKLIST
-----------------
Prerequisites:
- macOS with VoiceOver (Cmd-F5 to toggle).
- Emacs built from source with this patch applied.
- Evil-mode recommended for block-cursor tests.
Basic text reading:
1. Open Emacs. Press Cmd-F5 to start VoiceOver.
2. Switch to Emacs (Cmd-Tab). VoiceOver should announce
"Emacs, editor" and read the current line.
3. Move cursor with arrow keys. VoiceOver should read each
character (left/right) or line (up/down) as you move.
4. Verify: right/left arrow reads the character AT the cursor
position, not the character left behind. (evil block-cursor)
Word and line navigation:
5. Press M-f / M-b (forward/backward word). VoiceOver should
announce the word landed on.
6. Press C-n / C-p. VoiceOver should read the full new line.
7. Hold Shift and press arrow keys to extend selection. VoiceOver
should announce the selected text.
Completion navigation:
8. Type M-x to open the minibuffer.
9. Type a partial command name. Press Tab to open *Completions*.
10. Press Tab / S-Tab to cycle through completions. VoiceOver
should announce each candidate name as you move.
11. Verify no double-speech (each candidate read exactly once).
Interactive span Tab navigation:
12. Open a buffer with buttons (e.g. M-x describe-key).
13. Use VoiceOver Item Chooser (VO-I) or Tab with VoiceOver
interaction mode to navigate interactive elements.
14. Verify each button/link is reachable and its label is read.
15. In an org-mode file with links, verify links appear as
separate navigable AXLink elements.
Mode line:
16. Use the VoiceOver cursor to navigate to the mode line below a
buffer. VoiceOver should read the mode line text.
Zoom integration:
17. Enable macOS Zoom (System Settings -> Accessibility -> Zoom).
18. Set Zoom to "Follow keyboard focus".
19. Move cursor in Emacs. Zoom viewport should track the cursor.
20. Verify Zoom follows the cursor across split windows.
Window operations:
21. Split window with C-x 2. VoiceOver should announce a layout
change. Switch with C-x o; VoiceOver should read the new
window content.
22. Delete a window with C-x 0. No crash should occur.
23. Switch buffers with C-x b. VoiceOver should read new buffer.
Deadlock regression (async notifications):
24. With VoiceOver on: M-x, type partial command, M-v to
*Completions*, Tab to a candidate, Enter to execute, then
C-x o to switch windows. Emacs must not hang.
Stress test (line index):
25. Open a large file (>50,000 lines). Navigate to the end with
M-> or C-v repeatedly. VoiceOver speech should remain fluid
at all positions (no progressive slowdown).
26. Open an org-mode file with many folded sections. Verify that
folded (invisible) text is not announced during navigation.
REVIEW CHANGES (post initial implementation)
---------------------------------------------
The following changes were made based on maintainer-style code review:
1. ns_ax_window_end_charpos: added window_end_valid guard. Falls
back to BUF_ZV when the window has not been fully redisplayed,
preventing stale data in AX getters called before next redisplay.
2. GC safety documentation: detailed comment on lispWindow ivar
explaining why staticpro is not needed (windows reachable from
frame tree, GC only on main thread, AX getters dispatch to main).
3. ns-accessibility-enabled (DEFVAR_BOOL): new user option to
disable accessibility entirely. Guards three entry points.
4. postAccessibilityNotificationsForFrame: extracted from one ~200
line method into four focused helpers:
- postTextChangedNotification: (typing echo)
- postFocusedCursorNotification:direction:granularity:markActive:
oldMarkActive: (focused cursor/selection)
- postCompletionAnnouncementForBuffer:point: (completions)
- postAccessibilityNotificationsForFrame: (orchestrator, ~60 lines)
5. ns_ax_completion_text_for_span: added block_input/unblock_input
with specpdl unwind protection for signal safety.
6. Fplist_get third-argument comment (PREDICATE, not default value).
7. Documentation: macos.texi section updated with
ns-accessibility-enabled variable reference. etc/NEWS updated.
-- end of README --

144
patches/TESTING.txt Normal file
View File

@@ -0,0 +1,144 @@
VoiceOver Accessibility Patch Series — Testing Evidence
=======================================================
Tester: Martin Sukany
Date: 2026-02-28
Environment
-----------
Host: CM2D4G-A9635005 (macOS)
Base: emacs master (upstream HEAD at time of test)
1. Patch Application
--------------------
PASS — All 8 patches applied cleanly via git-am:
0001 ns: add accessibility base classes and text extraction
0002 ns: implement buffer accessibility element (core protocol)
0003 ns: add buffer notification dispatch and mode-line element
0004 ns: add interactive span elements for Tab navigation
0005 ns: integrate accessibility with EmacsView and redisplay
0006 doc: add VoiceOver accessibility section to macOS appendix
0007 ns: announce overlay completion candidates for VoiceOver
0008 ns: announce child frame completion candidates for VoiceOver
No conflicts, no warnings.
2. Build
--------
PASS — Full NS (Cocoa) build completed successfully:
./autogen.sh OK
./configure --with-ns OK
make -j12 OK
make install OK
No warnings related to accessibility code.
3. Basic Launch
---------------
PASS — emacs -Q starts without errors or warnings.
4. Zoom Cursor Tracking
------------------------
PASS — UAZoomChangeFocus integration working correctly:
- Typing in buffer: cursor tracked, Zoom follows OK
- M-x: Zoom moves focus to minibuffer OK
- M-x list- TAB M-v: switches to *Completions* buffer,
TAB cycles focus across completion candidates OK
- C-x 2, C-x 2, C-x 3 (multiple splits), then C-x o
cycling: Zoom focus correctly follows between windows OK
5. Documentation
----------------
PASS — Texinfo node accessible via C-h i g (emacs)VoiceOver Accessibility.
Node correctly linked from macOS appendix menu.
6. VoiceOver — Basic Navigation
--------------------------------
PASS — VoiceOver active (Cmd+F5):
- Buffer name announced correctly on focus OK
- Typing: each character announced as typed OK
- Arrow keys / C-n / C-p: line-by-line navigation,
current line announced OK
- Word navigation: reads full current word OK
- M-x: switches to minibuffer, announces "minibuffer" OK
7. VoiceOver — Completions
---------------------------
PASS — Completion buffer interaction:
- M-x list-* then M-v to switch to *Completions*:
buffer content read correctly OK
- TAB cycling in *Completions*: announces only the
current candidate (interactive span focus) OK
8. VoiceOver — Window Switching
--------------------------------
PASS — Multiple windows (C-x 2, C-x 3, C-x o cycling):
- Announces current buffer name and content on switch OK
- Begins reading buffer content automatically OK
- User action (typing, navigation) correctly interrupts
reading and announces new action instead OK
- Notification priority/preemption working as designed OK
9. VoiceOver — Full Buffer Reading
-----------------------------------
PASS — VO+A reads entire buffer including off-screen content.
- Cursor synchronization between Emacs and VoiceOver
virtual cursor working correctly OK
10. VoiceOver — Accessibility Tree
-----------------------------------
PASS — Virtual element tree dynamically maintained:
- New AX element created for each open buffer OK
- Minibuffer element present and readable OK
- Mode-line elements present per buffer, readable via
VoiceOver virtual navigation OK
- Tree correctly updates when windows are split/closed OK
11. VoiceOver — Selection
--------------------------
PASS — C-SPC + cursor movement:
- Announces "selected" with region feedback OK
12. VoiceOver — Org-mode Invisible Text
----------------------------------------
PASS — Org-mode folding (Tab on headings):
- Folded: hidden text NOT read by VoiceOver OK
- Unfolded: full content read correctly OK
- Invisible text filtering (TEXT_PROP_MEANS_INVISIBLE)
working as designed OK
13. ERT — ns-accessibility-enabled Variable
--------------------------------------------
PASS — Ran 1 test, 1 result as expected:
- ns-accessibility-enabled is bound OK
- ns-accessibility-enabled defaults to t OK
(ERT 1/1 passed, 2026-02-28 11:45:55 CET)
14. VoiceOver — Overlay Completion (Patch 0007)
------------------------------------------------
PASS — Vertico minibuffer overlay completion:
- Vertico candidates announced on C-n / C-p navigation OK
- Selected candidate face detected (vertico-current) OK
- Deduplication: same candidate not re-announced OK
- Zoom tracks selected candidate in minibuffer
(overlayZoomRect / overlayZoomActive lifecycle) OK
- overlayZoomActive cleared on text input OK
- hl-line-mode compatibility: cursor movement in dired
and read-only buffers correctly announces lines
(textDidChange flag decouples modiff branch from
cursor-move branch) OK
15. VoiceOver — Child Frame Completion (Patch 0008)
----------------------------------------------------
PASS — Corfu child frame completion:
- Corfu popup candidates announced via VoiceOver OK
- Selected candidate face detected (corfu-current) OK
- Zoom tracks selected candidate in child frame
(direct UAZoomChangeFocus) OK
- No Emacs freeze (re-entrance guard before child frame
dispatch, buffer switch with unbind_to on all paths) OK
- Focus restored to parent buffer after corfu closes
(childFrameCompletionActive flag + FOR_EACH_FRAME
visibility check + FocusedUIElementChanged) OK
- Non-completion child frames skipped (10KB buffer limit) OK
- specpdl_ref type used correctly (not ptrdiff_t) OK