Compare commits

86 Commits

Author SHA1 Message Date
fe0a0181d3 patches: fix if(candidate) block indentation in 0008 (I1)
The if(candidate) block in postAccessibilityNotificationsForFrame:
had its opening brace at col 8 (tab) while the if itself is at
col 10 (tab+2), violating GNU coding style.  Two consecutive
closing braces at col 8 made nesting ambiguous on inspection.

Correct indentation (all +4 columns throughout the block):
  - if(candidate) brace: tab+4 = col 12
  - Body (/* Deduplicate */, if(![...]): tab+6 = col 14
  - Inner if(![...]) brace: 2 tabs = col 16
  - Inner if body: tab+tab+2 = col 18
  - Inner closing brace: 2 tabs = col 16
  - if(candidate) closing brace: tab+4 = col 12

New indentation exactly matches the original deleted code (same
method, before our refactor), confirming it follows the pre-existing
style in this file.
2026-03-04 13:53:05 +01:00
6dd3c9bfe4 patches: fix if(candidate) block indentation in 0008
The if(candidate) block's braces and body were not consistently
indented relative to the enclosing if(MINI_WINDOW_P) block.
Change context lines to deletion+addition pairs to correct:
- Opening brace: tab -> tab+4 (col 8 -> col 12)
- Body comment and inner if: tab+2 -> tab+6 (col 10 -> col 14)
- Inner if brace: tab+4 -> tab+6 (col 12 -> col 14)
- Inner and outer closing braces: tab+4->tab+6, tab->tab+4

This is a pre-existing indentation inconsistency that violates
GNU coding style; the if(candidate) check was at col 10 but its
brace was at col 8.
2026-03-04 13:50:45 +01:00
5f18d727e1 patches: fix em-dash and SPECPDL_INDEX ordering
Replace all Unicode em-dashes (---) with ASCII triple-dash (---)
across 0001-0007 (39 occurrences). GNU Emacs source files must be
ASCII; em-dash in comments would cause encoding issues on some systems.

Fix SPECPDL_INDEX() call order in postCompletionAnnouncementForBuffer:
(0003): specpdl_ref must be taken BEFORE block_input() so the snapshot
captures the pre-block_input specpdl depth. This matches the pattern
established in patches 0001 and 0002.
2026-03-04 13:38:54 +01:00
2b7c7abb48 patches: fix if-block indentation in 0008 (B5 fix cleanup)
The B5 goto-replacement introduced indentation inconsistency:
opening/closing braces were at tab+2, body at 6 spaces. The original
code (ns_ax_face_is_selected deletion) used tab for braces and tab+2
for body. Align the new if-block to match existing style:
- Opening brace: tab (was tab+2)
- Body lines (int selected_line, NSString *candidate, if(candidate)):
  tab+2 (was 6 spaces after partial fix)
- Continuation of NSString declaration: tab+4 (was tab)
- Closing brace: tab (was tab+2)
2026-03-04 13:37:39 +01:00
f51da9f8d4 patches: fix I2 body indent and I3 childFrameLastCandidate type
I2: Re-indent body of MINI_WINDOW_P if-block in 0008 from 6 spaces to
8 spaces, consistent with GNU style (if at 6 spaces, body at 8).

I3: Change childFrameLastCandidate from char* to NSString*.
Replaces xstrdup/xfree/strcmp with ObjC retain/release/isEqualToString:,
which is consistent with surrounding ObjC code style and avoids
heap allocation with xmalloc.
2026-03-04 13:35:54 +01:00
9ad01c03fd patches: re-fix B6 nlines < 128 -> 512 (lost in revert)
Fix was accidentally reverted when reverting the broken B5 goto fix.
Both line_starts[512]/line_ends[512] arrays and the main while loop
guard use 512; the trailing-line check must also use 512 to avoid
silently dropping the last line in buffers with 128-511 lines.
2026-03-04 13:32:24 +01:00
c418db05dc patches: fix all round-1 blockers (B1 B3 B5 B6)
B1 (0007): Fix dangling comment - change context line to deletion so
the opening comment of the removed ns_ax_face_is_selected function
is properly deleted, not left as an unclosed comment fragment that
would cause a compilation failure.

B3 (0002): Fix block_input/record_unwind_protect_void ordering in
two places in accessibilityRangeForPosition: and related method.
Correct order: block_input() BEFORE record_unwind_protect_void(),
so the unwind handler cannot call unblock_input without a matching
block_input even if specpdl_push fails.

B5 (0008): Replace goto skip_overlay_scan with proper if-block.
The goto skipped over ObjC variable declarations which is poor style
and would be flagged by Emacs maintainers.  Restructure as
if (MINI_WINDOW_P (w) && !didTextChange) { ... }.

B6 (0008): Fix nlines < 128 to nlines < 512 in ns_ax_selected_child_
frame_text.  Arrays line_starts[] and line_ends[] are declared with
size 512; the trailing-line guard used 128, silently dropping the
last line for buffers with 128-511 lines.
2026-03-04 13:29:55 +01:00
ea9d231177 Revert "patches: fix B5 - replace goto skip_overlay_scan with if-block"
This reverts commit a69aec96d8.
2026-03-04 13:26:20 +01:00
a69aec96d8 patches: fix B5 - replace goto skip_overlay_scan with if-block 2026-03-04 13:21:49 +01:00
97c14a3bd9 patches: fix all review issues from Opus core maintainer review
BLOCKERS fixed:
- #1: add missing skip_overlay_scan: label (goto without target)
- #2: move accessibilityLineForIndex: to patch 0002 (used in 0002/0003,
  was defined in 0007 — forward reference crash risk)
- #3: move BOOL singleLineMove to patch 0008 (declared in 0007, unused
  until 0008 — -Werror build failure)

WARNINGS fixed:
- #1: block_input ordering at 5 sites (record_unwind_protect_void must
  come AFTER block_input to avoid unmatched unblock_input on error)
- #2: deduplicate ns_ax_face_is_selected (move to patch 0001, remove
  from patch 0007 where it was duplicated)
- #4: improve childFrameLastBuffer comment (document rename edge case)
- #5: complete ChangeLog for patch 0007 (add 3 missing method entries)
- #6: document lastSelectedWindow/lastRootWindow GC safety in nsterm.h

MINOR fixed:
- #2: wrap ns_zoom_track_completion call in MAC_OS_X_VERSION >= 101000
  guard (consistent with the function definition)
- #3: @cindex Zoom verified present in VoiceOver section (already OK)
- #4: raise line_starts/line_ends bound from 128 to 512 (consistent
  with ns_ax_selected_overlay_text)
- #5: add echo_area_buffer[] lifetime comment referencing xdisp.c

All 9 patches apply cleanly (git apply verified sequentially).
2026-03-03 20:43:56 +01:00
f42e799991 patches: fix Blocker #2 - remove ensureTextCache from notification path
BUF_MODIFF was needed for fold/unfold correctness (org-mode), but
ensureTextCache was called from postAccessibilityNotificationsForFrame:
on every cursor move, causing O(buffer-size) rebuild on every font-lock
pass.

Fix: remove [self ensureTextCache] from the granularity-detection branch
of the notification path.  The granularity detection now uses cachedText
directly, falling back to granularity_unknown when absent (safe: VoiceOver
makes its own determination).  ensureTextCache is called exclusively from
AX getters (human interaction speed).  Font-lock passes no longer trigger
any cache rebuild.

The BUF_MODIFF validity in ensureTextCache is retained (correct for
fold/unfold). Comment updated to accurately describe the calling pattern.

All 9 patches apply cleanly on fresh base (git apply verified).
2026-03-03 18:26:17 +01:00
70f0cb9a86 patches: fix all safe pre-submission issues
- Blocker #1: add BOOL emacsMovedCursor = YES in patch 0005 so series
  compiles standalone; patch 0008 replaces it with !voiceoverSetPoint
- Blocker #3: change all Daneel authorship to Martin Sukany
- Zoom gate: remove ns_accessibility_enabled guards from Zoom code
  paths (0005 no longer adds them; 0008 retains the clarifying comment)
- eassert: remove redundant BUFFER_LIVE_P eassert with contradictory
  comment in patch 0008
- macos.texi: integrate orphan 'Block-style cursors' paragraph as
  @item in the Known Limitations list
- cindex: restore @cindex Zoom, cursor tracking (macOS) removed in 0008
- ChangeLog 0002: list only functions actually added in that patch
- ChangeLog 0008: accurate description (remove wrong BUF_CHARS_MODIFF
  claim for ensureTextCache; ns_ax_buffer_text block_input was in 0001)
- git apply --check: all 9 patches apply cleanly on fresh base

Remaining open issue: BUF_MODIFF regression in patch 0007 (ensureTextCache
O(N) rebuild per font-lock pass) requires design decision before
submission.
2026-03-03 17:50:18 +01:00
3a3fbbac19 patches: move block_input fix to 0001 where pattern is introduced
The block_input/record_unwind_protect_void ordering fix and its
explanatory comment belong in 0001 (ns_ax_buffer_text), which is
where the pattern is first introduced.  Having the fragile pattern
visible across patches 0001-0007 invites review comments.

Remove the now-redundant block_input reordering hunk from 0008;
0001 already establishes the correct order with the comment.
2026-03-03 13:31:00 +01:00
2dc23e64df patches: revert block_input fix in 0001 (0008 does this correctly)
0008 was already fixing the block_input/record_unwind_protect_void
ordering with a detailed comment.  My earlier change to 0001 broke
0008's context match.  Let the series fix it in the right place.
2026-03-03 13:26:49 +01:00
bbeb4d87ac patches: fix commit msg line lengths, add remaining fixes
- 0007/0008: rewrap long commit message lines (>78 chars created by
  package name generalization); Emacs commit-msg hook enforces 78 char
  limit
- 0006: add paragraph break before 'Block-style cursors' sentence in
  macos.texi VoiceOver section; update hunk count accordingly
- 0000/0007: add comment documenting intentional ns_zoom_face_is_selected
  / ns_ax_face_is_selected duplication (independent series design)
2026-03-03 13:17:54 +01:00
aa0388d485 patches: fix all non-ASCII, PATCH numbering, block_input order, pkg names
- All 9 patches: convert em-dash, arrow, ellipsis, mu to ASCII equivalents
  (required for strict git am on Linux; GNU coding standards)
- 0000: Subject [PATCH 0/8] -> [PATCH] (standalone Zoom series)
- 0001: fix block_input() ordering: call block_input before
  record_unwind_protect_void(unblock_input) per standard pattern
- 0001/0005: shorten decorator separator comment lines to <80 chars
- 0007: genericize overlay completion framework name in commit message
- 0008: genericize child-frame completion framework name in commit message
- README.txt: fix stale 'default t' to 'default nil' with auto-detection note
- TESTING.txt: fix stale 'defaults to t' to 'defaults to nil'
2026-03-03 13:12:25 +01:00
b6b2fe34e1 patches: remove accidental temp script 2026-03-03 12:48:39 +01:00
61c23aed2c patches: address all non-blocking review findings
Fix remaining issues identified in core maintainer review:
- 0000: correct [PATCH 0/8] to [PATCH] (standalone Zoom series)
- 0006: add paragraph break in macos.texi VoiceOver section
- 0001/0005: shorten separator comment lines to 79 chars
- 0007/0008: genericize third-party package names in commit messages
- 0001: fix block_input ordering (block before registering unwind)
- 0000/0007: document intentional face_is_selected duplication
2026-03-03 12:48:31 +01:00
9bee2a1987 patches: address remaining medium/low review findings
M-REM-1 - Stack array bound comment:
ns_ax_selected_child_frame_text uses line_starts/line_ends[128].
Added comment documenting the 128-line bound and the 10,000-character
upstream guard that makes silent truncation safe in practice.

M-REM-2 - Remove dead BUFFER_LIVE_P check:
The second BUFFER_LIVE_P check in announceChildFrameCompletion (after
updating childFrameLastBuffer/Modiff) is unreachable: no code between
the entry check and this point can kill the buffer.  Replaced with
eassert so DEBUG builds catch violations while releasing dead code.

M-REM-3 - @interface declaration for postEchoAreaAnnouncementIfNeeded:
Added declaration to the #ifdef NS_IMPL_COCOA accessibility block in
nsterm.h alongside existing postAccessibilityUpdates and
announceChildFrameCompletion declarations.  postAccessibilityUpdates
is called from ns_update_end; all three methods should be declared.

L-REM-4 - Commit message class name:
Changed (EmacsAXBuffer) to (EmacsAccessibilityBuffer) in the subject
line.  EmacsAXBuffer does not exist; EmacsAccessibilityBuffer is the
actual class that received the voiceoverSetPoint ivar.

NEWS - Remove third-party package names:
Replaced 'Vertico, Icomplete, Ivy for overlay candidates; Corfu,
Company-box for child frame popups' with generic descriptions per GNU
Emacs NEWS convention (third-party package names avoided in NEWS).
2026-03-03 10:21:41 +01:00
3bb6c989c9 patches: address maintainer review findings (C1/C2/H1/H2/M5/M6)
C1 - block_input ordering in ns_ax_buffer_text:
block_input() now called before record_unwind_protect_void(unblock_input).
Previously the unwind handler could have been called without a matching
block_input, corrupting the input-blocking reference count.

C2 - unbind_to missing in patch 0004:
unbind_to(blk_count, Qnil) moved from patch 0008 to patch 0004 so that
ns_ax_scan_interactive_spans has a complete block_input/unbind_to pair
when patches 0000-0004 are applied independently.

H1 - Zoom patch forward dependency on VoiceOver:
Removed forward declaration 'static bool ns_ax_face_is_selected' and
the delegation from ns_zoom_face_is_selected.  Restored standalone
implementation of ns_zoom_face_is_selected in the Zoom patch so patch
0000 compiles and links independently of the VoiceOver patches.

H2 - ns_accessibility_enabled removal undocumented:
Added comment to ns_zoom_track_completion explaining that Zoom cursor
tracking is gated only on ns_zoom_enabled_p(), not ns_accessibility_enabled.
Users running Zoom without VoiceOver must still get completion tracking.

M5 - childFrameLastBuffer GC safety undocumented:
Added comment at the assignment site explaining why BVAR(b, name) (an
interned symbol reachable from obarray) is GC-safe without staticpro.

M6 - FOR_EACH_FRAME without block_input:
Added block_input/unblock_input around the FOR_EACH_FRAME loop in
postAccessibilityUpdates that checks for visible child frames.
Vframe_list must not be modified by timers or process sentinels
during iteration.
2026-03-03 10:11:39 +01:00
73563be72d patches: fix VoiceOver reads only first word in org-agenda
When AXSelectedTextChanged is posted from the parent EmacsView (NSView)
with UIElementsKey pointing to the EmacsAXBuffer element, VoiceOver calls
accessibilityLineForIndex: on the VIEW rather than on the focused element.
In specialised buffers (org-agenda, org-super-agenda) where line geometry
differs from plain text, the view returns an incorrect range and VoiceOver
reads only the first word at the cursor (e.g. 'La' or 'Liga') instead of
the full line.

Plain text buffers were unaffected because the fallback geometry happened
to be correct for simple line layouts.

Fix: post AXSelectedTextChanged on self (the EmacsAXBuffer element)
instead of on self.emacsView.  This causes VoiceOver to call
accessibilityLineForIndex: on the element that owns the selection, which
returns the correct line range in all buffer types.  Remove UIElementsKey
(unnecessary when posting from the element itself).

This aligns with the pre-review code (51f5944) which always posted
AX notifications directly on the focused element.
2026-03-03 09:43:26 +01:00
b6a576a312 patches: fix discontiguous moves reading only first word
For discontiguous moves (teleports, org-agenda items separated by blank
lines, multi-line jumps), AXSelectedTextChanged was sent with
AXTextSelectionDirection=discontiguous.  VoiceOver interprets an
explicit discontiguous direction as 're-anchor only' and reads only the
word at the cursor, ignoring VoiceOver's own line-browse mode.

The pre-review code (51f5944) omitted direction/granularity for all
moves and let VoiceOver determine what to read from its navigation state.
This correctly reads the full line when the VoiceOver rotor is in line
mode, which is the typical setting for text navigation.

Fix: omit AXTextSelectionDirection and AXTextSelectionGranularity from
AXSelectedTextChanged when direction=discontiguous.  Include them only
for sequential moves (direction=next/previous), where the explicit hint
ensures VoiceOver reads the correct unit without an extra state query.

This fixes:
- org-agenda / org-super-agenda j/k: items separated by blank lines
  cause singleLineMove=NO (non-adjacent AX indices), so direction was
  discontiguous -> only first word read.
- Any other navigation that crosses blank or invisible lines.

Sequential moves (C-n/C-p, single adjacent j/k) still include
direction + granularity=line for reliable full-line reads.
2026-03-02 21:30:42 +01:00
ce34c44c2f patches: fix singleLineMove compile error - out-of-scope variables
oldLine and newLine were declared inside 'if (cachedText && oldPoint > 0)'
block but singleLineMove block referenced them from outside that scope.

Fix: declare singleLineMove = NO before the granularity detection block;
compute adjFwd/adjBwd inside the 'if (oldLine.location != newLine.location)'
branch where both variables are in scope.
2026-03-02 21:13:20 +01:00
d6fc21f975 patches: fix Zoom, j/k line-read, fold/unfold, C-n/C-p (regression fixes)
Four regressions introduced during review-based refactoring:

1. ZOOM FOCUS JUMPING (P0008 fix in P0000 scope):
   ns_accessibility_enabled guard was added to ns_zoom_track_completion,
   ns_update_end fallback, and ns_draw_window_cursor.  Zoom works
   independently of VoiceOver; ns_accessibility_enabled is only set when
   a screen reader (VoiceOver) activates the AT layer.  Users who use
   Zoom without VoiceOver got no cursor tracking at all.
   Fix: remove ns_accessibility_enabled from all three Zoom call sites;
   guard only with ns_zoom_enabled_p() as in the original.

2. j/k (ANY SINGLE-STEP LINE COMMAND) READS ONLY FIRST WORD:
   The code only treated C-n/C-p (isCtrlNP) as sequential line moves.
   All other line-movement commands (evil j/k, outline-next-heading,
   org-next-visible-heading, etc.) were classified as 'discontiguous'
   jumps, causing VoiceOver to re-anchor and read only a word.
   Fix: detect single-step moves structurally via NSString line-range
   adjacency (NSMaxRange(oldLine) == newLine.location for forward,
   NSMaxRange(newLine) == oldLine.location for backward).  Any command
   that moves exactly one line is sequential --- no command-name
   whitelisting needed, no package-specific code.

3. ORG FOLD/UNFOLD NOT REFRESHING VOICEOVER (P0007):
   BUF_CHARS_MODIFF misses text-property changes such as 'invisible
   used by org-fold-core (org >= 29), outline-mode, hideshow-mode.
   Fix: use BUF_MODIFF; cost is acceptable (rebuild only on VoiceOver
   queries at human interaction speed, not at redisplay speed).

4. C-n/C-p DROPPED LINE-READ (P0005):
   FocusedUIElementChanged posted for ALL emacsMovedCursor moves raced
   with AXSelectedTextChanged(granularity=line) and caused VoiceOver
   to drop the line-read.  Fix: skip FocusedUIElementChanged for
   sequential moves (isCtrlNP or singleLineMove).
2026-03-02 21:10:12 +01:00
a5ff8d391b patches: fix org fold/unfold VoiceOver refresh; revert to BUF_MODIFF
REGRESSION: fold/unfold in org-mode, outline-mode and hideshow-mode did
not refresh VoiceOver text because ensureTextCache used BUF_CHARS_MODIFF
which is NOT bumped by (put-text-property ... 'invisible), the mechanism
used by modern org-fold-core (org >= 29) and outline-mode to hide text.

VoiceOver would continue reading folded content as if visible, or miss
newly unfolded content entirely, because the text cache was considered
valid despite the visible-text having changed.

Revert ensureTextCache to BUF_MODIFF with an explanatory comment:

- BUF_CHARS_MODIFF is bumped only on character insertions/deletions, not
  text-property changes.  Fold/unfold uses text properties for visibility.
- BUF_OVERLAY_MODIFF alone is also insufficient: org >= 29 uses text
  properties, not overlays, for folding.  Also hl-line-mode bumps
  BUF_OVERLAY_MODIFF every post-command-hook --- same per-keystroke cost
  as BUF_MODIFF, with none of its correctness guarantee.
- BUF_MODIFF cost is acceptable: ensureTextCache is called only when
  VoiceOver queries AX properties (human interaction speed, not redisplay
  speed).  Rebuild cost is O(visible-buffer-text).

Also retain C-n/C-p line-read fix from previous commit (7a0b4f6):
FocusedUIElementChanged excluded for sequential isCtrlNP moves.
2026-03-02 20:57:32 +01:00
7a0b4f6cf2 patches: fix C-n/C-p VoiceOver regression - exclude isCtrlNP from re-anchor
When Emacs moves the cursor (emacsMovedCursor=YES), we post
FocusedUIElementChanged on the NSWindow to re-anchor VoiceOver's
browse cursor.  For C-n/C-p this notification races with
AXSelectedTextChanged(granularity=line) and causes VoiceOver to
drop the line-read speech.

Arrow key movement works because VoiceOver intercepts those as AX
selection changes (setAccessibilitySelectedTextRange:), making
voiceoverSetPoint=YES and emacsMovedCursor=NO, so no
FocusedUIElementChanged is posted.

Fix: skip FocusedUIElementChanged for sequential C-n/C-p moves
(isCtrlNP).  AXSelectedTextChanged with direction=next/previous +
granularity=line is sufficient for VoiceOver to read the new line.
FocusedUIElementChanged is only needed for discontiguous jumps
(]], M-<, isearch, xref etc.) where VoiceOver must re-anchor.

Also merge duplicate comment blocks and fix two compile errors
from a64d24c that Martin caught during testing.
2026-03-02 20:48:57 +01:00
a64d24cbd9 patches: fix two compile errors (stray unbind_to, voiceoverSetPoint scope)
Two bugs introduced during rebase/amend:

1. Stray 'unbind_to (count, Qnil)' in ns_focus (P0000):
   A hunk was misplaced into ns_focus where 'count' is not declared.
   The comment and unbind_to belonged at the end of ns_zoom_track_completion,
   which already has a correct unbind_to.  Remove the duplicate from ns_focus.

2. 'voiceoverSetPoint = NO' in EmacsView::initFrameFromEmacs: (P0008):
   voiceoverSetPoint is a BOOL ivar of EmacsAXBuffer, not EmacsView.
   Setting it in EmacsView's init method causes 'undeclared identifier'.
   ObjC BOOL ivars zero-initialize to NO automatically.  Remove the line.
   voiceoverSetPoint is consumed/set in EmacsAXBuffer methods only.
2026-03-02 20:36:17 +01:00
010630f33d patches: review pass 4 - fix BUF_MODIFF->BUF_CHARS_MODIFF in P0007
P0007 (announce overlay candidates) incorrectly changed ensureTextCache
to use BUF_MODIFF, causing O(buffer-size) AX text rebuilds on every
font-lock pass.  Reverted to BUF_CHARS_MODIFF throughout.

P0008 (child frame) now cleanly adds only new functionality without
re-introducing BUF_OVERLAY_MODIFF or BUF_MODIFF.

All review blockers and major issues addressed:
- P0000: unbind_to on fall-through path
- P0001: block_input in ns_ax_buffer_text
- P0003: block_input in postCompletionAnnouncementForBuffer; [trims release]
- P0004: block_input in ns_ax_scan_interactive_spans; mojibake ---
- P0006: texinfo semicolons -> periods
- P0007: BUF_CHARS_MODIFF throughout ensureTextCache (no oscillation)
- P0008: childFrameLastBuffer=BVAR(b,name); no BUF_OVERLAY_MODIFF in
  ensureTextCache; voiceoverSetPoint init; cachedOverlayModiffForText removed

git am passes all 9 patches on Linux git 2.43.0.
2026-03-02 19:36:23 +01:00
6fd28e19a8 patches: review pass 3 - move block_input to origin patches
block_input protection moved from P0008 to their respective origin
patches for independent compilability (GNU Emacs requirement):
- P0001 (ns_ax_buffer_text): now has block_input + record_unwind
- P0003 (postCompletionAnnouncementForBuffer): now has block_input
- P0004 (ns_ax_scan_interactive_spans): now has block_input

P0008 now only adds its own new functionality (child frame completion
announcements, echo area announcements) without duplicating block_input
from earlier patches.

All 9 patches apply cleanly with git am on Linux git 2.43.0.
2026-03-02 18:55:33 +01:00
6176087cfb patches: apply all maintainer review fixes (review pass 2)
All 9 patches now apply cleanly with git am on Linux (git 2.43.0).
Root cause of previous failures: hunk offsets were systematically wrong
by 7-40 lines; macOS git fuzzy-matched them, Linux did not.
Patches regenerated via git format-patch after applying all changes.

Fixes applied:
- P0000: unbind_to on no-candidate fall-through path; hunk regenerated
- P0001: block_input + record_unwind_protect_void in ns_ax_buffer_text
- P0003: [trims release] MRC memory leak; block_input already present
- P0004: mojibake comment (--- not UTF-8 em-dash)
- P0006: texinfo dangling semicolons -> periods in GNUstep paragraph
- P0007: em-dash fixes removed (content was already --- from P0004/P0005)
- P0008: childFrameLastBuffer -> BVAR(b,name) for GC safety;
  BUF_OVERLAY_MODIFF removed from ensureTextCache (hl-line-mode O(N)
  rebuild regression); block_input in ns_ax_buffer_text (P0001 scope);
  voiceoverSetPoint and childFrameLastBuffer explicit init in
  initFrameFromEmacs:; cachedOverlayModiffForText ivar removed
2026-03-02 18:50:45 +01:00
51f59441c1 fix: VO browse cursor sync - post FocusedUIElementChanged on window
VoiceOver's browse cursor was not following Emacs cursor moves because
FocusedUIElementChangedNotification was posted on the buffer element
(a custom NSObject subclass), which VoiceOver ignores for browse cursor
re-anchoring.

Three changes:
1. Post FocusedUIElementChanged on the NSWindow instead of self, causing
   VoiceOver to call accessibilityFocusedUIElement on the window and
   re-anchor its browse cursor at the returned buffer element.
2. Post NSAccessibilityLayoutChangedNotification on self.emacsView with
   NSAccessibilityUIElementsKey pointing to the buffer element, causing
   VoiceOver to re-examine the element's properties including
   accessibilitySelectedTextRange.
3. Post SelectedTextChangedNotification from self.emacsView (an NSView)
   instead of self (NSObject), with NSAccessibilityUIElementsKey, so
   VoiceOver processes text-change notifications from a view-based
   element in the hierarchy.
2026-03-02 14:59:05 +01:00
3093053131 Fix VO cursor sync: post FocusedUIElementChanged on self not emacsView
The notification was posted on self.emacsView (the NSView container).
VoiceOver called accessibilityFocusedUIElement on the view, got back
the same buffer element, and did not re-anchor its browse cursor.

Fix: post on self (the buffer element itself).  When VoiceOver receives
NSAccessibilityFocusedUIElementChangedNotification from an element that
is already focused, it silently re-queries accessibilitySelectedTextRange
and re-anchors its browse cursor without re-announcing the element name.

Covers all emacsMovedCursor granularities (arrow keys, M-f/M-b, C-n/C-p
via the separate isCtrlNP path).
2026-03-02 14:40:49 +01:00
8196205c3d Revert "ax: fix VoiceOver cursor sync and word double-read"
This reverts commit bc5714b7b7.
2026-03-02 14:28:09 +01:00
bc5714b7b7 ax: fix VoiceOver cursor sync and word double-read
Bug 1 (VO cursor not following Emacs cursor):
- Remove FocusedUIElementChangedNotification on emacsView (was a no-op:
  VO re-queried the same element)
- For Emacs-initiated char/word moves, keep natural next/previous
  direction instead of forcing discontiguous; SelectedTextChanged with
  direction=next advances VO browse cursor sequentially
- Only force discontiguous for line-boundary crossings and large jumps

Bug 2 (word double-read with punctuation):
- Root cause was FocusedUIElementChanged causing VO re-anchor speech
  on top of the explicit word announcement
- Removing FocusedUIElementChanged eliminates the duplicate speech
- Add emacsInitiated parameter to postFocusedCursorNotification;
  omit AXTextSelectionGranularity for Emacs-initiated moves so VO
  does not auto-speak (only explicit announcements provide speech)
- isWordMove now triggers on emacsInitiated flag (Emacs-initiated
  word moves always get explicit announcement)
2026-03-02 12:38:40 +01:00
53ea58725e Fix patch 0002: restore closing ')' after evil-line EQ removal
Removing the evil-mode EQ conditions left the if() without its
closing paren.  Fix:

  if (EQ (cmd, Qns_ax_next_line)
      || EQ (cmd, Qns_ax_dired_next_line))   <- add ')'

Same for previous_line.  Verified: all 9 patches apply and build.
2026-03-02 12:07:11 +01:00
bbdd0143e3 Fix patch 0008: update '-' line to match patch 0003 without 'evil'
Commit 7609922 changed patch 0003 comment from
'for evil block-cursor mode' to 'for block-cursor mode'.
Patch 0008 had a '-' (removal) line still referencing 'for evil
block-cursor mode', which git am could not find in the file ->
'patch does not apply'.

Verified locally: git am now applies all 9 patches cleanly on f0dbe25.
2026-03-02 12:04:29 +01:00
9700d0643d Fix patch 0008: shift nsterm.m hunk line numbers by +4
Adding 4 lines in patch 0003 (punctuation trim fix) shifted all
subsequent nsterm.m positions by 4.  Update the @@ -NNNN offsets
for all 14 nsterm.m hunks at line >= 9060 in patch 0008.
2026-03-02 11:37:09 +01:00
6ea5ae8a90 Fix patch 0008 corrupt hunk header: -4400,16 -> -4400,15
Old-count in macos.texi hunk was off by one; git am reported
'corrupt patch at line 83'.
2026-03-02 11:31:14 +01:00
91981bf77e Fix VO cursor sync, word double-read, and VO->Emacs positioning
patch 0002: Do not activate mark in setAccessibilitySelectedTextRange.
VoiceOver range.length is an internal word-boundary hint, not a text
selection.  Activating the mark made accessibilitySelectedTextRange
return a non-zero length, causing VoiceOver to position its browse
cursor at the END of the selection instead of the START.

patch 0003: Fix word announcement double-read and punctuation.
- Explicit word announcement only for Emacs-initiated (discontiguous)
  moves; VO-initiated (sequential) word navigation relies on VO
  auto-speech from the granularity=word notification, preventing
  double-read.
- Strip trailing/leading punctuation from word announcements using
  punctuationCharacterSet so 'Ahoj,' is announced as 'Ahoj'.

patch 0008: Post FocusedUIElementChangedNotification on the EmacsView
(not just on line-granularity moves) for all Emacs-initiated cursor
movements.  Posting on the view causes VoiceOver to re-query
accessibilityFocusedUIElement and re-anchor its browse cursor at the
current accessibilitySelectedTextRange.  Also fixes a pre-existing
off-by-one in the macos.texi hunk header.
2026-03-02 11:12:48 +01:00
760992224c Remove evil-mode and doom-emacs references from patches
These patches target GNU Emacs core; third-party package references
are unacceptable for upstream submission.

patch 0006: doom-modeline -> icon-based mode-lines; Evil-mode -> generic
patch 0007: fix hunk context hint (doom-modeline -> nerd-font icons)
patch 0008: Evil-mode block cursors -> Block-style cursors

The evil DEFSYM (Qns_ax_evil_next_line etc.) were already removed
by cd288e8.
2026-03-02 11:05:57 +01:00
cd288e8c76 Fix three VoiceOver bugs: crash, cursor sync, word announcement
Bug 1 (crash): postEchoAreaAnnouncementIfNeeded called Fbuffer_string()
without block_input, allowing timer events to interleave with Lisp
calls and corrupt buffer state.  Added block_input/unblock_input via
record_unwind_protect_void.  Also removed unpaired unblock_input()
in postCompletionAnnouncementForBuffer (patch 0003) that became a
double-unblock after patch 0008 added block_input + unwind protect.

Bug 2 (cursor sync): VoiceOver browse cursor did not follow Emacs
keyboard cursor because SelectedTextChanged with discontiguous
direction alone is not sufficient for VoiceOver to re-anchor.  Added
NSAccessibilityFocusedUIElementChangedNotification post when Emacs
moves the cursor across a line boundary, forcing VoiceOver to re-query
accessibilitySelectedTextRange.

Bug 3 (word off-by-one): Evil w (next-word) read the previous word
instead of the destination word.  VO auto-speech from
SelectedTextChanged with direction=next+granularity=word reads the
traversed word, not the arrived-at word.  Added explicit word
announcement (like char moves) that reads the word AT the new cursor
position using whitespace-delimited word boundary scan.
2026-03-02 10:42:54 +01:00
7ab55a7fb3 Fix crash in announceChildFrameCompletion: BUFFER_LIVE_P before BUF_MODIFF
BUF_MODIFF(b) dereferences the struct buffer pointer unconditionally.
If the buffer was killed, this accesses freed memory and crashes.
Check BUFFER_LIVE_P first.

Use precise Python line-index swap instead of Edit tool to avoid
accidentally replacing other patch content.
2026-03-02 10:22:36 +01:00
9f5e5b6e83 Revert patch 0003 granularity change -- patch 0008 already has this fix
Patch 0008 modifies postFocusedCursorNotification to add
'&& direction != discontiguous' to the isCharMove guard, which IS
the cursor sync fix.  Changing patch 0003 independently broke
patch 0008's context match.  Restore patch 0003 to original.
2026-03-02 10:17:53 +01:00
164a194da5 Fix patch 0003 hunk header count and non-ASCII character
The edit adding 2 comment lines broke the +8758,552 count (now 554).
Also replace UTF-8 arrow -> ASCII to avoid git am corruption.
2026-03-02 10:13:49 +01:00
083b4a4acd Fix crash and cursor sync in accessibility patches
Patch 0008: Move BUFFER_LIVE_P check before BUF_MODIFF dereference
in announceChildFrameCompletion.  Accessing BUF_MODIFF on a killed
buffer is a null/garbage dereference.

Patch 0003: Always include AXTextSelectionGranularity in
postFocusedCursorNotification, including for character-granularity
moves.  Without granularity, VoiceOver leaves its browse cursor at
the previous position on C-f/C-b/arrow moves.  The explicit
AnnouncementRequested (High priority) still overrides VO speech
for evil block-cursor correctness.
2026-03-02 10:10:36 +01:00
a1d028f334 patches: remove stale 0010 (content already in 0000) 2026-03-01 21:21:42 +01:00
93ea536942 patches: fix VO browse cursor sync — Emacs→VoiceOver direction 2026-03-01 21:18:34 +01:00
57086b88ef patches: full maintainer review pass — all blocking issues fixed 2026-03-01 21:10:35 +01:00
1dcc7f8352 patches: fix minibuffer — typing priority, echo area, C-g 2026-03-01 21:05:10 +01:00
371d90bd4f patches: review fixes — comments, echo area, doc updates 2026-03-01 20:59:37 +01:00
46930281db patches: fix echo area — read echo_area_buffer[0] directly 2026-03-01 20:51:02 +01:00
6d2d702aa7 patches: fix encoding + cursor sync + echo area 2026-03-01 15:02:20 +01:00
b7d0188cbb patches: fix VoiceOver cursor sync + echo area (base f8d9ecb restored) 2026-03-01 14:51:58 +01:00
7ddebc2579 patches: rebase onto upstream a755d7f (2026-03-01) 2026-03-01 14:44:39 +01:00
3502cfaf25 patches: fix VoiceOver rotor cursor sync + echo area announcements 2026-03-01 14:41:40 +01:00
bcb25088dd patches: announce echo area messages to VoiceOver 2026-03-01 14:25:20 +01:00
0d4d874607 patches: fix VoiceOver rotor cursor sync on large jumps (LayoutChanged) 2026-03-01 14:20:48 +01:00
2402c50117 patches: fix fold/unfold comment — outline-mode uses overlays in Emacs 28+ 2026-03-01 13:44:21 +01:00
6703914305 patches: fix fold/unfold AX cache regression (BUF_OVERLAY_MODIFF) 2026-03-01 13:43:25 +01:00
e969011b87 config: always enable macOS accessibility on darwin 2026-03-01 10:25:11 +01:00
1954d3ec29 config: replace obsolete defadvice with advice-add (Emacs 30.1) 2026-03-01 10:24:01 +01:00
f108952af1 patches: fix remaining line-length violations and doc string corruption
- Correct three pre-existing DEFVAR_BOOL doc strings (ns-use-native-
  fullscreen, ns-use-mwheel-acceleration, ns-use-mwheel-momentum) that
  were accidentally modified by an earlier rebase; restore original text
- Break @property line in nsterm.h to 79 chars
- Replace em-dash with --- and break 80-char comment line in patch 0004
2026-03-01 10:05:49 +01:00
71c81abcae patches: address all maintainer review issues
- Issue 1: Add explicit ApplicationServices import for UAZoomEnabled/
  UAZoomChangeFocus (was implicit via Carbon.h, now explicit)
- Issue 2: Rename FOR_EACH_FRAME variable 'frames' -> 'frame' (plural
  was misleading; matches Emacs convention)
- Issue 3: Move unblock_input before ObjC calls in
  postCompletionAnnouncementForBuffer: to avoid holding block_input
  during @synchronized operations
- Issue 4: Fix DEFVAR_BOOL doc and Texinfo: initial value is nil,
  not t; auto-detection sets it at startup
- Issue 5: Replace magic 10000 with NS_AX_MAX_COMPLETION_BUFFER_CHARS
  constant with explanatory comment
- Issue 6: Add comment to lineStartOffsets loop explaining it is gated
  on BUF_CHARS_MODIFF and never runs on the hot path
- Issue 8: Rewrite all 9 commit messages to GNU ChangeLog format with
  '* file (symbol): description' entries
- Issue 9: Break 81-char @interface line in nsterm.h
- Issue 10: Add WINDOWP/BUFFERP guards before dereferencing
  cf->selected_window and cw->contents in ns_zoom_find_child_frame_candidate
- Issue 11: Fix @pxref -> @xref at sentence start in macos.texi
2026-03-01 09:44:47 +01:00
e0343db56c patches: fix leftover conflict markers in patch 0002 2026-03-01 09:17:45 +01:00
0c13f5d6a3 patches: fix O(position) lag — O(1) fast path in accessibilityIndexForCharpos:
accessibilityIndexForCharpos: walked composed character sequences
from run.ax_start up to the target charpos offset.  For a run
covering an entire ASCII buffer, chars_in = pt - BUF_BEGV, making
each call O(cursor_position).

This method is called from ensureTextCache on EVERY redisplay frame
(as part of the cache validity check), making each frame O(position)
even when the buffer is completely unchanged.  At line 34,000 of a
large file this is ~1,000,000 iterations per frame.

Fix: when ax_length == length for a run (all single-unit characters),
the ax_index is simply ax_start + chars_in.  O(1) instead of O(N).

This is the symmetric counterpart to the charposForAccessibilityIndex:
fast path added in the previous commit.  Both conversion directions
now run in O(1) for pure-ASCII buffers.
2026-03-01 09:14:52 +01:00
fb68dd50ea patches: fix O(position) lag — O(1) fast path in charposForAccessibilityIndex:
charposForAccessibilityIndex: walked composed character sequences
from the start of a visible run to the target AX index.  For a run
covering an entire ASCII buffer, this is O(cursor_position): moving
to line 10,000 requires ~500,000 iterations per call.

The method is called on every SelectedTextChanged notification
response (accessibilityBoundsForRange: from the AX server for cursor
tracking), making cursor movement O(position) in large files.

Fix: when ax_length == length for a run (all characters are single
AX index units — true for all ASCII/Latin text), the charpos offset
is simply ax_idx - run.ax_start.  O(1) instead of O(position).

Multi-byte runs (emoji, CJK, non-BMP) fall back to the sequence walk,
bounded by run length (visible window size), not total buffer size.
2026-03-01 09:03:01 +01:00
31ad038360 patches: fix O(position) lag — use lineForAXIndex: instead of lineRangeForRange:
postAccessibilityNotificationsForFrame: was calling NSString
lineRangeForRange: to detect line-crossing cursor moves.  That
method scans from the start of the string, making it O(cursor_offset).
In large buffers with the cursor near the end, this executes on every
redisplay cycle — causing progressive slowdown proportional to cursor
position.

patch 0002 already builds lineStartOffsets in ensureTextCache (O(N)
once per text change) and exposes lineForAXIndex: (O(log L) binary
search).  Use it instead.

Also remove the now-redundant tlen clamping that existed solely
to prevent lineRangeForRange: from receiving an out-of-range index.
lineForAXIndex: handles out-of-range inputs safely.
2026-03-01 08:29:58 +01:00
c4975c3fe4 patches: fix Corfu Zoom tracking — replace rate-limit with parent-frame guard
Root cause: the 50ms rate limit broke child-frame (Corfu) tracking.
When the Corfu child frame redraws, its ns_update_end fires first and
resets the rate-limit timer.  When the parent frame's ns_update_end
fires immediately after, the timer has not expired, so
ns_zoom_track_completion returns early without scanning child frames.
Zoom focus stays on the first candidate.

Fix: remove the rate limit; add a FRAME_PARENT_FRAME(f) guard instead.
Child frames have no completion children to scan; their parent's
ns_update_end does the scan via FOR_EACH_FRAME.  Returning early on
child-frame calls avoids the redundant scan and leaves the timer
problem moot.  Overhead without the rate limit is ~40 Lisp evaluations
per redisplay (~5-20 µs), acceptable given ns_zoom_enabled_p() already
caches the UAZoomEnabled() IPC call.
2026-03-01 07:11:00 +01:00
19cc43dbbb Revert "patches: fix Corfu completion tracking in Zoom"
This reverts commit d15fe43bf0.
2026-03-01 07:05:33 +01:00
d15fe43bf0 patches: fix Corfu completion tracking in Zoom
Fget_char_property with a buffer as OBJECT checks text properties
only.  Corfu highlights the selected candidate (corfu-current) via
an overlay, not a text property, so the scan always returned -1 and
Zoom focus stayed on the first line.

Pass cf->selected_window instead of cw->contents so that overlays
are included in the property lookup.  Vertico uses text properties
and is unaffected; child-frame completion frameworks that use overlays
(Corfu, Company-box) now track correctly.
2026-03-01 06:50:46 +01:00
636545c2a5 patches: auto-detect Zoom/VoiceOver; single variable gates both
Changes:
- EmacsApp gets ns_update_accessibility_state and
  ns_accessibility_did_change: methods (patch 0005)
- At startup: UAZoomEnabled() + AXIsProcessTrustedWithOptions()
  determine initial ns_accessibility_enabled state
- com.apple.accessibility.api distributed notification updates it
  whenever any AT connects or disconnects
- All Zoom call sites (UAZoomChangeFocus) now gated by
  ns_accessibility_enabled in addition to ns_zoom_enabled_p()
- ns-accessibility-enabled docstring updated to describe auto-detect

Result: zero config needed; zero overhead when no AT is active;
single variable overrides auto-detection when needed.
2026-03-01 06:39:37 +01:00
cc7b288e99 patches: reduce completion tracking rate-limit from 500ms to 50ms
500ms (2 Hz) was too aggressive — Zoom focus stopped updating during
keyboard navigation in Vertico/Corfu lists.  50ms (20 Hz) tracks
fast arrow-key navigation while still avoiding per-frame overhead.
UAZoomEnabled() is already cached so the main cost is the overlay
scan, which is cheap.
2026-03-01 06:30:01 +01:00
63f0e899ce patches: fix childFrameLastBuffer ivar init order
The Qnil initialization was in patch 0000 (Zoom) but the ivar
declaration is in patch 0008 (child frame tracking).  Moved the
init to patch 0008 so each patch compiles independently.
2026-03-01 06:04:22 +01:00
07826b61a0 patches: squash perf fixes into respective patches, clean 9-patch series
Performance fixes folded back:
- BUF_CHARS_MODIFF → patch 0002 (implement buffer accessibility element)
- UAZoomEnabled cache + rate-limit → patch 0000 (Zoom integration)

Also in patch 0000: ns_zoom_face_is_selected (standalone compilation).
Also in patch 0001: ns_accessibility_enabled defaults to nil.
2026-03-01 05:58:42 +01:00
1bf05f1e22 patches: remove duplicate old perf patch 2026-03-01 05:51:08 +01:00
256263343d patches: fix standalone compilation + accessibility default + perf
Three fixes:
1. Patch 0000 now compiles standalone: replaced forward declaration
   of ns_ax_face_is_selected (defined in VoiceOver patches) with
   self-contained ns_zoom_face_is_selected in the Zoom patch.

2. ns_accessibility_enabled defaults to nil: eliminates ALL VoiceOver
   overhead (text cache rebuild, AX notifications, Mach IPC to AX
   server) when VoiceOver is not in use. Zero per-redisplay cost.
   Enable with (setq ns-accessibility-enabled t).

3. UAZoomEnabled() cached for 1s + ns_zoom_track_completion rate-
   limited to 2Hz: eliminates 150-600µs/frame of IPC overhead.
2026-03-01 05:51:03 +01:00
6b3843e0c6 patches: fix O(position) performance via UAZoomEnabled caching
Root cause (per Opus analysis): UAZoomEnabled() is a synchronous
Mach IPC roundtrip to macOS Accessibility server, called 3x per
redisplay cycle. At 60fps = 180 IPC roundtrips/second blocking the
main thread. Combined with Emacs's inherent O(position) redisplay
cost, this compounded into progressive choppy behavior.

Fix 1: ns_zoom_enabled_p() caches UAZoomEnabled() for 1 second.
Fix 2: ns_zoom_track_completion() rate-limited to 2 Hz.

Also includes BUF_CHARS_MODIFF fix (patch 0009) for VoiceOver cache.
2026-03-01 05:23:59 +01:00
cd16d45584 patches: fix O(buffer) cache invalidation caused by font-lock
BUF_CHARS_MODIFF fix — the core performance regression:
ensureTextCache checked BUF_MODIFF which font-lock bumps on every
redisplay. Each cursor movement in a large file triggered full buffer
rebuild. Now uses BUF_CHARS_MODIFF (changes only on char insert/delete).
2026-03-01 04:56:37 +01:00
bc71e58123 patches: fix two more compile errors
- Remove f->child_frame_list (field does not exist in struct frame)
- Fix dangling else-if after goto label (skip_overlay_scan)
2026-03-01 04:33:35 +01:00
3d2fa7a54e patches: fix O(overlays) performance regression
Performance issue: editing large files (>~10KB, >2000 lines) caused
progressive slowdown regardless of VoiceOver status.

Root causes:
1. ns_zoom_find_overlay_candidate_line: called Foverlays_in on the
   entire visible buffer range on every redisplay when UAZoomEnabled().
   In files with many overlays (font-lock, hl-line, show-paren etc.)
   this was O(overlays) Lisp work per keystroke.

2. postAccessibilityNotificationsForFrame: when ns-accessibility-enabled
   is non-nil, checked BUF_OVERLAY_MODIFF every redisplay. font-lock
   bumps this on every redraw, triggering ns_ax_selected_overlay_text
   (another O(overlays) scan) for non-minibuffer windows.

Fix: Both scans now guard with MINI_WINDOW_P check. Overlay completion
frameworks (Vertico, Icomplete, Ivy) only display candidates in
minibuffer windows --- no completion framework puts selected-face
overlays in normal editing buffers. For non-minibuffer windows both
functions return immediately with zero Lisp calls.

Additionally: ns_zoom_find_child_frame_candidate is skipped when
f->child_frame_list is nil (no child frames = no Corfu popup).
2026-03-01 04:26:12 +01:00
84eb777065 patches: fix all compile errors and review issues
- ZV_S -> BUF_ZV (undefined macro)
- cf->current_buffer -> XWINDOW(cf->selected_window)->contents
  (current_buffer is a thread macro, can't use as field name)
- find_newline: add record_unwind_current_buffer + set_buffer_internal_1
- ns_zoom_track_completion: add specpdl unwind protection
- ns_zoom_face_is_selected: replace with forward decl of ns_ax_face_is_selected
  (eliminates duplicate)
- childFrameLastBuffer: struct buffer * -> Lisp_Object (safe vs kill-buffer)
- EmacsView dealloc: xfree childFrameLastCandidate (memory leak)
- postCompletionAnnouncementForBuffer: add block_input/unblock_input
2026-03-01 03:58:04 +01:00
b283068f82 patches: add Zoom completion tracking (overlay + child frame)
Zoom patch 0000 now tracks completion candidates:
- Overlay: Vertico, Icomplete, Ivy (face heuristic on before-string)
- Child frame: Corfu, Company-box (scan buffer text for selected face)
Also fixes duplicate lastCursorRect ivar when applied with VoiceOver.
2026-03-01 03:38:58 +01:00
9110eee881 patches: fix duplicate lastCursorRect ivar (build error)
Zoom (0000) declares lastCursorRect @public in EmacsView.
VoiceOver (0005) was re-declaring it, causing 'duplicate member'
compiler error when both applied together. Removed the duplicate.
2026-03-01 03:20:23 +01:00
74fcee0820 patches: regenerate for combined application (Zoom + VoiceOver)
VoiceOver patches 0001-0008 now apply cleanly on top of Zoom patch
0000.  The full set (git am patches/000*.patch) works without
conflicts.  Patch 0005 (integration) merges Zoom fallback and
VoiceOver postAccessibilityUpdates in ns_update_end.
2026-03-01 03:02:46 +01:00
132e32795f patches: review iteration 1 fixes
Shortened ivar comments (line length), broke long ObjC method call,
changed '---' to em-dash in overlay patch comment.
2026-03-01 02:42:49 +01:00
98ca6a378d patches: move lastCursorRect ivar from patch 1 to patch 5
The ivar was declared in patch 0001 but first used in patch 0005,
creating dead code in intermediate commits 0001-0004.  Now each
commit only introduces declarations that are immediately used.
2026-02-28 22:55:31 +01:00
10 changed files with 1808 additions and 562 deletions

View File

@@ -888,8 +888,10 @@ Skip for beamer exports — beamer uses adjustbox on plain tabular."
(setq vc-ignore-dir-regexp (setq vc-ignore-dir-regexp
(format "%s\\|%s" vc-ignore-dir-regexp tramp-file-name-regexp)) (format "%s\\|%s" vc-ignore-dir-regexp tramp-file-name-regexp))
(defadvice projectile-project-root (around ignore-remote first activate) (advice-add 'projectile-project-root :around
(unless (file-remote-p default-directory) ad-do-it)) (lambda (orig-fn &rest args)
(unless (file-remote-p default-directory)
(apply orig-fn args))))
(setq remote-file-name-inhibit-cache nil (setq remote-file-name-inhibit-cache nil
tramp-verbose 1) tramp-verbose 1)
@@ -1584,13 +1586,14 @@ Skip for beamer exports — beamer uses adjustbox on plain tabular."
;; Error handler: catch errors during cal->org event update ;; Error handler: catch errors during cal->org event update
;; so sync state is saved even if individual events fail ;; so sync state is saved even if individual events fail
(defadvice org-caldav-update-events-in-org (around skip-failed-events activate) (advice-add 'org-caldav-update-events-in-org :around
"Catch errors during cal->org sync; log and return so sync state is saved." (lambda (orig-fn &rest args)
(condition-case err "Catch errors during cal->org sync; log and return so sync state is saved."
ad-do-it (condition-case err
(error (apply orig-fn args)
(message "org-caldav: update-events-in-org error (sync continues): %S" err) (error
(org-caldav-debug-print 1 (format "update-events-in-org error: %S" err))))) (message "org-caldav: update-events-in-org error (sync continues): %S" err)
(org-caldav-debug-print 1 (format "update-events-in-org error: %S" err))))))
(defun my/org-caldav-sync () (defun my/org-caldav-sync ()
"Sync 4 CalDAV calendars: Suky (twoway), Placeholders, Family, Klara (read-only)." "Sync 4 CalDAV calendars: Suky (twoway), Placeholders, Family, Klara (read-only)."
@@ -2139,3 +2142,9 @@ Formats matching what org-caldav/ox-icalendar export correctly:
;; gls ;; gls
(setq insert-directory-program "gls") (setq insert-directory-program "gls")
;; Always enable macOS accessibility (VoiceOver + Zoom cursor tracking).
;; Override auto-detection so the AX tree is available from startup
;; regardless of whether an AT is currently active.
(when (eq system-type 'darwin)
(setq ns-accessibility-enabled t))

View File

@@ -1,37 +1,41 @@
From 085a2c40d1335819b7a0d43b67581cc7b547088f Mon Sep 17 00:00:00 2001 From fcc1826baee5b424d5fdc176239c5675aee6159b Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 22:39:35 +0100 Date: Sat, 28 Feb 2026 22:39:35 +0100
Subject: [PATCH] ns: integrate with macOS Zoom for cursor tracking Subject: [PATCH 1/9] ns: integrate with macOS Zoom for cursor tracking
Inform macOS Zoom of the text cursor position so the zoomed viewport Inform macOS Zoom of the text cursor position so the zoomed viewport
follows keyboard focus in Emacs. follows keyboard focus in Emacs. Also track completion candidates so
Zoom follows the selected item (Vertico, Corfu, etc.) during completion.
* etc/NEWS: Document Zoom integration.
* src/nsterm.h (EmacsView): Add lastCursorRect, zoomCursorUpdated. * src/nsterm.h (EmacsView): Add lastCursorRect, zoomCursorUpdated.
* src/nsterm.m (ns_draw_window_cursor): Store cursor rect in * src/nsterm.m: Include ApplicationServices for UAZoomEnabled and
lastCursorRect; call UAZoomChangeFocus with CG-space coordinates UAZoomChangeFocus (UniversalAccess sub-framework).
when UAZoomEnabled returns true. Set zoomCursorUpdated flag. [NS_IMPL_COCOA]: Define NS_AX_MAX_COMPLETION_BUFFER_CHARS.
(ns_update_end): Call UAZoomChangeFocus as fallback when cursor (ns_zoom_enabled_p): New static function; caches UAZoomEnabled with
was not physically redrawn in this cycle (e.g., after C-x o window 1-second TTL to avoid per-frame Mach IPC overhead.
switch). Gated by zoomCursorUpdated to avoid double calls. (ns_zoom_face_is_selected): New static predicate; matches 'current',
'selected', 'selection' in face symbol names.
Coordinate conversion: EmacsView pixels (AppKit, flipped) -> (ns_zoom_find_overlay_candidate_line): New static function; scans
NSWindow -> NSScreen -> CGRect with y-flip for CoreGraphics minibuffer overlays for the selected completion candidate line.
top-left origin. UAZoomEnabled returns false when Zoom is inactive, (ns_zoom_find_child_frame_candidate): New static function; scans
so overhead is a single function call per redisplay cycle. child frame buffers for a selected candidate; guards against partially
initialized frames with WINDOWP and BUFFERP checks.
Tested on macOS 14 with Zoom enabled: cursor tracking works across (ns_zoom_track_completion): New static function; overrides Zoom focus
window splits, switches (C-x o), and normal navigation. to the selected completion candidate after normal cursor tracking.
(ns_update_end): Call ns_zoom_track_completion.
(ns_draw_window_cursor): Store cursor rect; call UAZoomChangeFocus.
--- ---
etc/NEWS | 8 +++++++ etc/NEWS | 11 ++
src/nsterm.h | 6 +++++ src/nsterm.h | 6 +
src/nsterm.m | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/nsterm.m | 354 +++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 82 insertions(+) 3 files changed, 371 insertions(+)
diff --git a/etc/NEWS b/etc/NEWS diff --git a/etc/NEWS b/etc/NEWS
index ef36df5..f10d17e 100644 index 7367e3ccbd..4c149e41d6 100644
--- a/etc/NEWS --- a/etc/NEWS
+++ b/etc/NEWS +++ b/etc/NEWS
@@ -82,6 +82,14 @@ other directory on your system. You can also invoke the @@ -82,6 +82,17 @@ other directory on your system. You can also invoke the
* Changes in Emacs 31.1 * Changes in Emacs 31.1
@@ -41,13 +45,16 @@ index ef36df5..f10d17e 100644
+Follow keyboard focus), Emacs informs Zoom of the text cursor position +Follow keyboard focus), Emacs informs Zoom of the text cursor position
+after every cursor redraw via 'UAZoomChangeFocus'. The zoomed viewport +after every cursor redraw via 'UAZoomChangeFocus'. The zoomed viewport
+automatically tracks the insertion point across window splits and +automatically tracks the insertion point across window splits and
+switches. +switches. Completion frameworks (Vertico, Icomplete, Ivy for overlay
+candidates; Corfu, Company-box for child frame popups) are also
+tracked: Zoom follows the selected candidate rather than the text
+cursor during completion.
+ +
+++ +++
** 'line-spacing' now supports specifying spacing above the line. ** 'line-spacing' now supports specifying spacing above the line.
Previously, only spacing below the line could be specified. The user Previously, only spacing below the line could be specified. The user
diff --git a/src/nsterm.h b/src/nsterm.h diff --git a/src/nsterm.h b/src/nsterm.h
index 7c1ee4c..ea6e7ba 100644 index 7c1ee4cf53..ea6e7ba4f5 100644
--- a/src/nsterm.h --- a/src/nsterm.h
+++ b/src/nsterm.h +++ b/src/nsterm.h
@@ -484,6 +484,12 @@ enum ns_return_frame_mode @@ -484,6 +484,12 @@ enum ns_return_frame_mode
@@ -64,10 +71,304 @@ index 7c1ee4c..ea6e7ba 100644
} }
diff --git a/src/nsterm.m b/src/nsterm.m diff --git a/src/nsterm.m b/src/nsterm.m
index 74e4ad5..cd721c8 100644 index 932d209f56..88c9251c18 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/src/nsterm.m +++ b/src/nsterm.m
@@ -1104,6 +1104,35 @@ static NSRect constrain_frame_rect(NSRect frameRect, bool isFullscreen) @@ -71,6 +71,11 @@ Updated by Christian Limpach (chris@nice.ch)
#include "macfont.h"
#include <Carbon/Carbon.h>
#include <IOSurface/IOSurface.h>
+/* ApplicationServices provides UAZoomEnabled and UAZoomChangeFocus
+ (UniversalAccess sub-framework). Carbon.h already pulls in
+ ApplicationServices on most SDK versions, but the explicit import
+ makes the dependency visible and guards against SDK changes. */
+#import <ApplicationServices/ApplicationServices.h>
#endif
static EmacsMenu *dockMenu;
@@ -1081,6 +1086,281 @@ static NSRect constrain_frame_rect(NSRect frameRect, bool isFullscreen)
}
+
+#ifdef NS_IMPL_COCOA
+#if defined (MAC_OS_X_VERSION_MIN_REQUIRED) \
+ && MAC_OS_X_VERSION_MIN_REQUIRED >= 101000
+
+/* Maximum buffer size (in characters) for a window that we consider
+ a candidate for a completion popup. Completion popups are small;
+ if the buffer is larger than this, it is not a popup and we skip it
+ to avoid O(buffer-size) work per redisplay cycle. */
+#define NS_AX_MAX_COMPLETION_BUFFER_CHARS 10000
+
+/* Cached wrapper around ns_zoom_enabled_p ().
+ ns_zoom_enabled_p () performs a synchronous Mach IPC roundtrip to the
+ macOS Accessibility server (~50-200 µs per call). With call sites
+ in ns_draw_window_cursor, ns_update_end, and ns_zoom_track_completion,
+ the overhead accumulates to ~150-600 µs per redisplay cycle. Zoom
+ state changes only on explicit user action in System Settings, so a
+ 1-second TTL is safe and indistinguishable from querying every frame.
+ Uses CFAbsoluteTimeGetCurrent() (~5 ns, a VDSO read) for timing. */
+static BOOL ns_zoom_cached_enabled;
+static CFAbsoluteTime ns_zoom_cache_time;
+
+static BOOL
+ns_zoom_enabled_p (void)
+{
+ CFAbsoluteTime now = CFAbsoluteTimeGetCurrent ();
+ if (now - ns_zoom_cache_time > 1.0)
+ {
+ ns_zoom_cached_enabled = UAZoomEnabled ();
+ ns_zoom_cache_time = now;
+ }
+ return ns_zoom_cached_enabled;
+}
+
+/* Identify faces that mark a selected completion candidate.
+ Matches vertico-current, corfu-current, icomplete-selected-match,
+ ivy-current-match, etc. by checking the face symbol name.
+ Defined here so the Zoom patch compiles independently of the
+ VoiceOver patches. */
+static bool
+ns_zoom_face_is_selected (Lisp_Object face)
+{
+ if (SYMBOLP (face))
+ {
+ const char *name = SSDATA (SYMBOL_NAME (face));
+ return (strstr (name, "current") != NULL
+ || strstr (name, "selected") != NULL
+ || strstr (name, "selection") != NULL);
+ }
+ if (CONSP (face))
+ {
+ Lisp_Object tail;
+ for (tail = face; CONSP (tail); tail = XCDR (tail))
+ if (ns_zoom_face_is_selected (XCAR (tail)))
+ return true;
+ }
+ return false;
+}
+
+/* Scan overlay before-string / after-string properties in the
+ selected window for a completion candidate with a "selected"
+ face. Return the 0-based visual line index of the selected
+ candidate, or -1 if none found. */
+static int
+ns_zoom_find_overlay_candidate_line (struct window *w)
+{
+ /* Overlay completion frameworks (Vertico, Icomplete, Ivy) place
+ candidates as overlay strings in the minibuffer only. Scanning
+ overlays in large normal buffers causes O(overlays) work per
+ redisplay --- return immediately for non-minibuffer windows. */
+ if (!MINI_WINDOW_P (w))
+ return -1;
+
+ struct buffer *b = XBUFFER (w->contents);
+ ptrdiff_t beg = marker_position (w->start);
+ ptrdiff_t end = BUF_ZV (b);
+ Lisp_Object overlays = Foverlays_in (make_fixnum (beg),
+ make_fixnum (end));
+ Lisp_Object tail;
+
+ for (tail = overlays; CONSP (tail); tail = XCDR (tail))
+ {
+ Lisp_Object ov = XCAR (tail);
+ Lisp_Object str = Foverlay_get (ov, Qbefore_string);
+
+ if (NILP (str))
+ str = Foverlay_get (ov, Qafter_string);
+ if (!STRINGP (str) || SCHARS (str) < 2)
+ continue;
+
+ /* Walk the string line by line, checking faces. */
+ ptrdiff_t len = SCHARS (str);
+ int line = 0;
+ ptrdiff_t line_start = 0;
+
+ for (ptrdiff_t i = 0; i <= len; i++)
+ {
+ bool at_newline = (i == len
+ || SREF (str, i) == '\n');
+ if (at_newline && i > line_start)
+ {
+ /* Check the face at line_start. */
+ Lisp_Object face
+ = Fget_text_property (make_fixnum (line_start),
+ Qface, str);
+ if (ns_zoom_face_is_selected (face))
+ return line;
+ line++;
+ line_start = i + 1;
+ }
+ else if (at_newline)
+ {
+ line++;
+ line_start = i + 1;
+ }
+ }
+ }
+ return -1;
+}
+
+/* Scan child frames for a completion popup with a selected
+ candidate. Return the 0-based line index, or -1 if none.
+ Set *CHILD_FRAME to the child frame if found. */
+static int
+ns_zoom_find_child_frame_candidate (struct frame *f,
+ struct frame **child_frame)
+{
+ Lisp_Object frame, tail;
+
+ FOR_EACH_FRAME (tail, frame)
+ {
+ struct frame *cf = XFRAME (frame);
+ if (!FRAME_NS_P (cf) || !FRAME_LIVE_P (cf))
+ continue;
+ if (FRAME_PARENT_FRAME (cf) != f)
+ continue;
+ /* Small buffer = likely completion popup. Guard against
+ partially initialized frames where selected_window or its
+ buffer may not yet be live. */
+ if (!WINDOWP (cf->selected_window))
+ continue;
+ struct window *cw = XWINDOW (cf->selected_window);
+ if (!BUFFERP (cw->contents))
+ continue;
+ struct buffer *b = XBUFFER (cw->contents);
+ if (BUF_ZV (b) - BUF_BEGV (b) > NS_AX_MAX_COMPLETION_BUFFER_CHARS)
+ continue;
+
+ ptrdiff_t beg = BUF_BEGV (b);
+ ptrdiff_t zv = BUF_ZV (b);
+ int line = 0;
+
+ specpdl_ref count = SPECPDL_INDEX ();
+ record_unwind_current_buffer ();
+ set_buffer_internal_1 (b);
+
+ ptrdiff_t pos = beg;
+ while (pos < zv)
+ {
+ Lisp_Object face
+ = Fget_char_property (make_fixnum (pos), Qface,
+ cw->contents);
+ if (ns_zoom_face_is_selected (face))
+ {
+ unbind_to (count, Qnil);
+ *child_frame = cf;
+ return line;
+ }
+ /* Advance to next line. */
+ ptrdiff_t next = find_newline (pos, -1, zv, -1,
+ 1, NULL, NULL, false);
+ if (next <= pos)
+ break;
+ pos = next;
+ line++;
+ }
+ unbind_to (count, Qnil);
+ }
+ return -1;
+}
+
+/* Update Zoom focus based on completion candidates.
+ Called from ns_update_end after normal cursor tracking.
+ If a completion candidate is selected (overlay or child frame),
+ move Zoom to that candidate instead of the text cursor. */
+static void
+ns_zoom_track_completion (struct frame *f, EmacsView *view)
+{
+ if (!ns_zoom_enabled_p ())
+ return;
+ if (!WINDOWP (f->selected_window))
+ return;
+ /* Child frames (e.g. the Corfu popup itself) have no children to
+ scan for completion candidates; their parent frame's ns_update_end
+ will scan them via FOR_EACH_FRAME. Return early to avoid a
+ redundant O(frames) scan on every child-frame redisplay cycle.
+ Note: the rate limit that was here caused corfu tracking to fail:
+ the child frame's ns_update_end reset the timer, so the parent
+ frame's subsequent ns_update_end returned early without scanning. */
+ if (FRAME_PARENT_FRAME (f))
+ return;
+
+ specpdl_ref count = SPECPDL_INDEX ();
+ record_unwind_current_buffer ();
+
+ struct window *w = XWINDOW (f->selected_window);
+ int line_h = FRAME_LINE_HEIGHT (f);
+
+ /* 1. Check overlay completions (Vertico, Icomplete, Ivy). */
+ int ov_line = ns_zoom_find_overlay_candidate_line (w);
+ if (ov_line >= 0)
+ {
+ /* Overlay candidates typically start after the input line,
+ so the visual offset is (ov_line + 1) * line_h from
+ the window top. */
+ int y_off = (ov_line + 1) * line_h;
+ if (y_off < w->pixel_height)
+ {
+ NSRect r = NSMakeRect (
+ WINDOW_TEXT_TO_FRAME_PIXEL_X (w, 0),
+ WINDOW_TO_FRAME_PIXEL_Y (w, y_off),
+ FRAME_COLUMN_WIDTH (f),
+ line_h);
+
+ 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);
+ unbind_to (count, Qnil);
+ return;
+ }
+ }
+
+ /* 2. Check child frame completions (Corfu, Company-box). */
+ struct frame *cf = NULL;
+ int cf_line = ns_zoom_find_child_frame_candidate (f, &cf);
+ if (cf_line >= 0 && cf)
+ {
+ EmacsView *cv = FRAME_NS_VIEW (cf);
+ struct window *cw
+ = XWINDOW (cf->selected_window);
+ int cf_line_h = FRAME_LINE_HEIGHT (cf);
+ int y_off = cf_line * cf_line_h;
+
+ NSRect r = NSMakeRect (
+ WINDOW_TEXT_TO_FRAME_PIXEL_X (cw, 0),
+ WINDOW_TO_FRAME_PIXEL_Y (cw, y_off),
+ FRAME_COLUMN_WIDTH (cf),
+ cf_line_h);
+
+ NSRect windowRect = [cv convertRect:r toView:nil];
+ NSRect screenRect
+ = [[cv 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);
+ }
+ unbind_to (count, Qnil);
+}
+
+#endif /* MAC_OS_X_VERSION_MIN_REQUIRED >= 101000 */
+#endif /* NS_IMPL_COCOA */
+
static void
ns_update_end (struct frame *f)
/* --------------------------------------------------------------------------
@@ -1104,6 +1384,41 @@ static NSRect constrain_frame_rect(NSRect frameRect, bool isFullscreen)
unblock_input (); unblock_input ();
ns_updating_frame = NULL; ns_updating_frame = NULL;
@@ -79,7 +380,7 @@ index 74e4ad5..cd721c8 100644
+ (zoomCursorUpdated is NO). */ + (zoomCursorUpdated is NO). */
+#if defined (MAC_OS_X_VERSION_MIN_REQUIRED) \ +#if defined (MAC_OS_X_VERSION_MIN_REQUIRED) \
+ && MAC_OS_X_VERSION_MIN_REQUIRED >= 101000 + && MAC_OS_X_VERSION_MIN_REQUIRED >= 101000
+ if (view && !view->zoomCursorUpdated && UAZoomEnabled () + if (view && !view->zoomCursorUpdated && ns_zoom_enabled_p ()
+ && !NSIsEmptyRect (view->lastCursorRect)) + && !NSIsEmptyRect (view->lastCursorRect))
+ { + {
+ NSRect r = view->lastCursorRect; + NSRect r = view->lastCursorRect;
@@ -99,11 +400,17 @@ index 74e4ad5..cd721c8 100644
+ if (view) + if (view)
+ view->zoomCursorUpdated = NO; + view->zoomCursorUpdated = NO;
+#endif +#endif
+
+ /* Track completion candidates for Zoom (overlay and child frame).
+ Runs after cursor tracking so the selected candidate overrides
+ the default cursor position. */
+ if (view)
+ ns_zoom_track_completion (f, view);
+#endif /* NS_IMPL_COCOA */ +#endif /* NS_IMPL_COCOA */
} }
static void static void
@@ -3232,6 +3261,45 @@ Note that CURSOR_WIDTH is meaningful only for (h)bar cursors. @@ -3232,6 +3547,45 @@ Note that CURSOR_WIDTH is meaningful only for (h)bar cursors.
/* Prevent the cursor from being drawn outside the text area. */ /* Prevent the cursor from being drawn outside the text area. */
r = NSIntersectionRect (r, ns_row_rect (w, glyph_row, TEXT_AREA)); r = NSIntersectionRect (r, ns_row_rect (w, glyph_row, TEXT_AREA));
@@ -126,7 +433,7 @@ index 74e4ad5..cd721c8 100644
+ +
+#if defined (MAC_OS_X_VERSION_MIN_REQUIRED) \ +#if defined (MAC_OS_X_VERSION_MIN_REQUIRED) \
+ && MAC_OS_X_VERSION_MIN_REQUIRED >= 101000 + && MAC_OS_X_VERSION_MIN_REQUIRED >= 101000
+ if (UAZoomEnabled ()) + if (ns_zoom_enabled_p ())
+ { + {
+ NSRect windowRect = [view convertRect:r toView:nil]; + NSRect windowRect = [view convertRect:r toView:nil];
+ NSRect screenRect + NSRect screenRect

View File

@@ -1,43 +1,42 @@
From cd6ad89e786fc79f68bc0843b8122e088e8766ba Mon Sep 17 00:00:00 2001 From 29546d323559dbbefd846f7b2720285ff90368c8 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 12:58:11 +0100 Date: Sat, 28 Feb 2026 12:58:11 +0100
Subject: [PATCH 1/8] ns: add accessibility base classes and text extraction Subject: [PATCH 2/9] ns: add accessibility base classes and text extraction
Add the foundation for macOS VoiceOver accessibility in the NS Add the foundation for macOS VoiceOver accessibility in the NS (Cocoa)
(Cocoa) port. No existing code paths are modified. port. No existing code paths are modified.
* src/nsterm.h (ns_ax_visible_run): New struct. * src/nsterm.h (ns_ax_visible_run): New struct.
(EmacsAccessibilityElement): New base class. (EmacsAccessibilityElement): New base Objective-C class.
(EmacsAccessibilityBuffer, EmacsAccessibilityModeLine) (EmacsAccessibilityBuffer, EmacsAccessibilityModeLine)
(EmacsAccessibilityInteractiveSpan): Forward declarations. (EmacsAccessibilityInteractiveSpan): Forward-declare new classes.
(EmacsAccessibilityBuffer(Notifications)): New category interface. (EmacsAXSpanType): New enum for interactive span types.
(EmacsAccessibilityBuffer(InteractiveSpans)): New category interface. (EmacsView): New ivars for accessibility element tree.
(EmacsAXSpanType): New enum.
(EmacsView): New ivars for accessibility state.
* src/nsterm.m: Include intervals.h for TEXT_PROP_MEANS_INVISIBLE. * src/nsterm.m: Include intervals.h for TEXT_PROP_MEANS_INVISIBLE.
(ns_ax_buffer_text): New function; build visible-text string and
(ns_ax_buffer_text, ns_ax_mode_line_text, ns_ax_frame_for_range) run array for a window, skipping invisible character regions.
(ns_ax_completion_string_from_prop, ns_ax_window_buffer_object) (ns_ax_mode_line_text): New function; extract mode-line text.
(ns_ax_window_end_charpos, ns_ax_text_prop_at) (ns_ax_frame_for_range): New function; map charpos range to screen
(ns_ax_next_prop_change, ns_ax_get_span_label) rect via glyph matrix.
(ns_ax_post_notification, ns_ax_post_notification_with_info): New (ns_ax_completion_string_from_prop)
functions. (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 helper functions.
(EmacsAccessibilityElement): Implement base class. (EmacsAccessibilityElement): Implement base class.
(syms_of_nsterm): Register accessibility DEFSYM and DEFVAR (syms_of_nsterm): Register accessibility DEFSYMs. Add DEFVAR_BOOL
ns-accessibility-enabled. ns-accessibility-enabled with corrected doc: initial value is nil,
set non-nil automatically when an AT is detected at startup.
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.h | 131 ++++++++++++++
src/nsterm.m | 456 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/nsterm.m | 482 +++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 587 insertions(+) 2 files changed, 613 insertions(+)
diff --git a/src/nsterm.h b/src/nsterm.h diff --git a/src/nsterm.h b/src/nsterm.h
index 7c1ee4c..5298386 100644 index ea6e7ba4f5..f245675513 100644
--- a/src/nsterm.h --- a/src/nsterm.h
+++ b/src/nsterm.h +++ b/src/nsterm.h
@@ -453,6 +453,122 @@ enum ns_return_frame_mode @@ -453,6 +453,124 @@ enum ns_return_frame_mode
@end @end
@@ -53,11 +52,11 @@ index 7c1ee4c..5298386 100644
+/* Base class for virtual accessibility elements attached to EmacsView. */ +/* Base class for virtual accessibility elements attached to EmacsView. */
+@interface EmacsAccessibilityElement : NSAccessibilityElement +@interface EmacsAccessibilityElement : NSAccessibilityElement
+@property (nonatomic, unsafe_unretained) EmacsView *emacsView; +@property (nonatomic, unsafe_unretained) EmacsView *emacsView;
+/* Lisp window object safe across GC cycles. +/* Lisp window object --- safe across GC cycles.
+ GC safety: these Lisp_Objects are NOT visible to GC via staticpro + GC safety: these Lisp_Objects are NOT visible to GC via staticpro
+ or the specpdl stack. This is safe because: + or the specpdl stack. This is safe because:
+ (1) Emacs GC runs only on the main thread, at well-defined safe + (1) Emacs GC runs only on the main thread, at well-defined safe
+ points during Lisp evaluation never during redisplay. + points during Lisp evaluation --- never during redisplay.
+ (2) Accessibility elements are owned by EmacsView which belongs to + (2) Accessibility elements are owned by EmacsView which belongs to
+ an active frame; windows referenced here are always reachable + an active frame; windows referenced here are always reachable
+ from the frame's window tree until rebuildAccessibilityTree + from the frame's window tree until rebuildAccessibilityTree
@@ -82,13 +81,14 @@ index 7c1ee4c..5298386 100644
+ NSUInteger ax_length; /* Length in accessibility string (UTF-16 units). */ + NSUInteger ax_length; /* Length in accessibility string (UTF-16 units). */
+} ns_ax_visible_run; +} ns_ax_visible_run;
+ +
+/* Virtual AXTextArea element one per visible Emacs window (buffer). */ +/* Virtual AXTextArea element --- one per visible Emacs window (buffer). */
+@interface EmacsAccessibilityBuffer : EmacsAccessibilityElement <NSAccessibility> +@interface EmacsAccessibilityBuffer
+ : EmacsAccessibilityElement <NSAccessibility>
+{ +{
+ ns_ax_visible_run *visibleRuns; + ns_ax_visible_run *visibleRuns;
+ NSUInteger visibleRunCount; + NSUInteger visibleRunCount;
+ NSUInteger *lineStartOffsets; /* AX string index of each line start. */ + NSUInteger *lineStartOffsets; /* AX index for each line. */
+ NSUInteger lineCount; /* Number of entries in lineStartOffsets. */ + NSUInteger lineCount; /* Entries in lineStartOffsets. */
+ NSMutableArray *cachedInteractiveSpans; + NSMutableArray *cachedInteractiveSpans;
+ BOOL interactiveSpansDirty; + BOOL interactiveSpansDirty;
+} +}
@@ -119,12 +119,12 @@ index 7c1ee4c..5298386 100644
+- (void)invalidateInteractiveSpans; +- (void)invalidateInteractiveSpans;
+@end +@end
+ +
+/* Virtual AXStaticText element one per mode line. */ +/* Virtual AXStaticText element --- one per mode line. */
+@interface EmacsAccessibilityModeLine : EmacsAccessibilityElement +@interface EmacsAccessibilityModeLine : EmacsAccessibilityElement
+@end +@end
+ +
+/* Span types for interactive AX child elements. */ +/* Span types for interactive AX child elements. */
+typedef NS_ENUM (NSInteger, EmacsAXSpanType) +typedef NS_ENUM(NSInteger, EmacsAXSpanType)
+{ +{
+ EmacsAXSpanTypeNone = -1, + EmacsAXSpanTypeNone = -1,
+ EmacsAXSpanTypeButton = 0, + EmacsAXSpanTypeButton = 0,
@@ -144,7 +144,8 @@ index 7c1ee4c..5298386 100644
+@property (nonatomic, assign) EmacsAXSpanType spanType; +@property (nonatomic, assign) EmacsAXSpanType spanType;
+@property (nonatomic, copy) NSString *spanLabel; +@property (nonatomic, copy) NSString *spanLabel;
+@property (nonatomic, copy) NSString *spanValue; +@property (nonatomic, copy) NSString *spanValue;
+@property (nonatomic, unsafe_unretained) EmacsAccessibilityBuffer *parentBuffer; +@property (nonatomic, unsafe_unretained)
+ EmacsAccessibilityBuffer *parentBuffer;
+ +
+- (NSAccessibilityRole) accessibilityRole; +- (NSAccessibilityRole) accessibilityRole;
+- (NSString *) accessibilityLabel; +- (NSString *) accessibilityLabel;
@@ -160,7 +161,7 @@ index 7c1ee4c..5298386 100644
/* ========================================================================== /* ==========================================================================
The main Emacs view The main Emacs view
@@ -471,6 +587,14 @@ enum ns_return_frame_mode @@ -471,6 +589,12 @@ enum ns_return_frame_mode
#ifdef NS_IMPL_COCOA #ifdef NS_IMPL_COCOA
char *old_title; char *old_title;
BOOL maximizing_resize; BOOL maximizing_resize;
@@ -170,12 +171,10 @@ index 7c1ee4c..5298386 100644
+ Lisp_Object lastRootWindow; + Lisp_Object lastRootWindow;
+ BOOL accessibilityTreeValid; + BOOL accessibilityTreeValid;
+ BOOL accessibilityUpdating; + BOOL accessibilityUpdating;
+ @public /* Accessed by ns_draw_phys_cursor (C function). */
+ NSRect lastAccessibilityCursorRect;
#endif #endif
BOOL font_panel_active; BOOL font_panel_active;
NSFont *font_panel_result; NSFont *font_panel_result;
@@ -528,6 +652,13 @@ enum ns_return_frame_mode @@ -534,6 +658,13 @@ enum ns_return_frame_mode
- (void)windowWillExitFullScreen; - (void)windowWillExitFullScreen;
- (void)windowDidExitFullScreen; - (void)windowDidExitFullScreen;
- (void)windowDidBecomeKey; - (void)windowDidBecomeKey;
@@ -190,7 +189,7 @@ index 7c1ee4c..5298386 100644
diff --git a/src/nsterm.m b/src/nsterm.m diff --git a/src/nsterm.m b/src/nsterm.m
index 74e4ad5..2ac1d9d 100644 index 88c9251c18..3b923ee5fa 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/src/nsterm.m +++ b/src/nsterm.m
@@ -46,6 +46,7 @@ Updated by Christian Limpach (chris@nice.ch) @@ -46,6 +46,7 @@ Updated by Christian Limpach (chris@nice.ch)
@@ -201,7 +200,7 @@ index 74e4ad5..2ac1d9d 100644
#include "systime.h" #include "systime.h"
#include "character.h" #include "character.h"
#include "xwidget.h" #include "xwidget.h"
@@ -6856,6 +6857,430 @@ - (BOOL)fulfillService: (NSString *)name withArg: (NSString *)arg @@ -7201,6 +7202,460 @@ - (BOOL)fulfillService: (NSString *)name withArg: (NSString *)arg
} }
#endif #endif
@@ -250,6 +249,11 @@ index 74e4ad5..2ac1d9d 100644
+ +
+ specpdl_ref count = SPECPDL_INDEX (); + specpdl_ref count = SPECPDL_INDEX ();
+ record_unwind_current_buffer (); + record_unwind_current_buffer ();
+ /* block_input must come before record_unwind_protect_void (unblock_input):
+ if specpdl_push were to fail after registration, the unwind handler
+ would call unblock_input without a matching block_input. */
+ block_input ();
+ record_unwind_protect_void (unblock_input);
+ if (b != current_buffer) + if (b != current_buffer)
+ set_buffer_internal_1 (b); + set_buffer_internal_1 (b);
+ +
@@ -290,7 +294,7 @@ index 74e4ad5..2ac1d9d 100644
+ +
+ /* Extract this visible run's text. Use + /* Extract this visible run's text. Use
+ Fbuffer_substring_no_properties which correctly handles the + 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. */ + include garbage bytes when the run spans the gap position. */
+ Lisp_Object lstr = Fbuffer_substring_no_properties ( + Lisp_Object lstr = Fbuffer_substring_no_properties (
+ make_fixnum (pos), make_fixnum (run_end)); + make_fixnum (pos), make_fixnum (run_end));
@@ -327,7 +331,7 @@ index 74e4ad5..2ac1d9d 100644
+ +
+/* TODO: Only CHAR_GLYPH characters (>= 32) are extracted. Image +/* TODO: Only CHAR_GLYPH characters (>= 32) are extracted. Image
+ glyphs, stretch glyphs, and composed glyphs are silently skipped. + glyphs, stretch glyphs, and composed glyphs are silently skipped.
+ Mode lines using icon fonts (e.g. doom-modeline with nerd-font) + Mode lines using icon fonts (e.g. nerd-font icons)
+ will produce incomplete accessibility text. */ + will produce incomplete accessibility text. */
+static NSString * +static NSString *
+ns_ax_mode_line_text (struct window *w) +ns_ax_mode_line_text (struct window *w)
@@ -371,7 +375,7 @@ index 74e4ad5..2ac1d9d 100644
+ return NSZeroRect; + return NSZeroRect;
+ +
+ /* charpos_start and charpos_len are already in buffer charpos + /* 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. */ + charposForAccessibilityIndex which handles invisible text. */
+ ptrdiff_t cp_start = charpos_start; + ptrdiff_t cp_start = charpos_start;
+ ptrdiff_t cp_end = cp_start + charpos_len; + ptrdiff_t cp_end = cp_start + charpos_len;
@@ -552,6 +556,31 @@ index 74e4ad5..2ac1d9d 100644
+ Deferring via dispatch_async lets the current method return first, + Deferring via dispatch_async lets the current method return first,
+ freeing the main queue for VoiceOver's dispatch_sync calls. */ + freeing the main queue for VoiceOver's dispatch_sync calls. */
+ +
+/* Return true if FACE (a symbol or list of symbols) looks like a
+ "selected item" 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: this runs only on overlay/child-frame
+ strings during completion, never in a hot redisplay path. */
+static bool
+ns_ax_face_is_selected (Lisp_Object face)
+{
+ if (SYMBOLP (face) && !NILP (face))
+ {
+ const char *name = SSDATA (SYMBOL_NAME (face));
+ 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;
+}
+
+static inline void +static inline void
+ns_ax_post_notification (id element, +ns_ax_post_notification (id element,
+ NSAccessibilityNotificationName name) + NSAccessibilityNotificationName name)
@@ -632,7 +661,7 @@ index 74e4ad5..2ac1d9d 100644
/* ========================================================================== /* ==========================================================================
EmacsView implementation EmacsView implementation
@@ -11312,6 +11737,28 @@ Convert an X font name (XLFD) to an NS font name. @@ -11657,6 +12112,24 @@ Convert an X font name (XLFD) to an NS font name.
DEFSYM (Qns_drag_operation_generic, "ns-drag-operation-generic"); DEFSYM (Qns_drag_operation_generic, "ns-drag-operation-generic");
DEFSYM (Qns_handle_drag_motion, "ns-handle-drag-motion"); DEFSYM (Qns_handle_drag_motion, "ns-handle-drag-motion");
@@ -642,10 +671,6 @@ index 74e4ad5..2ac1d9d 100644
+ DEFSYM (Qns_ax_previous_line, "previous-line"); + DEFSYM (Qns_ax_previous_line, "previous-line");
+ DEFSYM (Qns_ax_dired_next_line, "dired-next-line"); + DEFSYM (Qns_ax_dired_next_line, "dired-next-line");
+ DEFSYM (Qns_ax_dired_previous_line, "dired-previous-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. */ + /* Accessibility span scanning symbols. */
+ DEFSYM (Qns_ax_widget, "widget"); + DEFSYM (Qns_ax_widget, "widget");
@@ -661,7 +686,7 @@ index 74e4ad5..2ac1d9d 100644
Fput (Qalt, Qmodifier_value, make_fixnum (alt_modifier)); Fput (Qalt, Qmodifier_value, make_fixnum (alt_modifier));
Fput (Qhyper, Qmodifier_value, make_fixnum (hyper_modifier)); Fput (Qhyper, Qmodifier_value, make_fixnum (hyper_modifier));
Fput (Qmeta, Qmodifier_value, make_fixnum (meta_modifier)); Fput (Qmeta, Qmodifier_value, make_fixnum (meta_modifier));
@@ -11460,6 +11907,15 @@ Nil means use fullscreen the old (< 10.7) way. The old way works better with @@ -11805,6 +12278,15 @@ Nil means use fullscreen the old (< 10.7) way. The old way works better with
This variable is ignored on Mac OS X < 10.7 and GNUstep. */); This variable is ignored on Mac OS X < 10.7 and GNUstep. */);
ns_use_srgb_colorspace = YES; ns_use_srgb_colorspace = YES;
@@ -671,8 +696,8 @@ index 74e4ad5..2ac1d9d 100644
+When nil, the accessibility virtual element tree is not built and no +When nil, the accessibility virtual element tree is not built and no
+notifications are posted, eliminating the associated overhead. +notifications are posted, eliminating the associated overhead.
+Requires the Cocoa (NS) build on macOS; ignored on GNUstep. +Requires the Cocoa (NS) build on macOS; ignored on GNUstep.
+Default is t. */); +Default is nil. Set to t to enable VoiceOver support. */);
+ ns_accessibility_enabled = YES; + ns_accessibility_enabled = NO;
+ +
DEFVAR_BOOL ("ns-use-mwheel-acceleration", DEFVAR_BOOL ("ns-use-mwheel-acceleration",
ns_use_mwheel_acceleration, ns_use_mwheel_acceleration,

View File

@@ -1,31 +1,42 @@
From 68ce438269f04570f21e92bd2c49f2ff83244cb8 Mon Sep 17 00:00:00 2001 From f587654717e7a3d3121e4871f04ffbf4e0d5e9be Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 12:58:11 +0100 Date: Sat, 28 Feb 2026 12:58:11 +0100
Subject: [PATCH 2/8] ns: implement buffer accessibility element (core Subject: [PATCH 3/9] ns: implement buffer accessibility element (core
protocol) protocol)
Implement the NSAccessibility text protocol for Emacs buffer windows. Implement the NSAccessibility text protocol for Emacs buffer windows.
* src/nsterm.m (ns_ax_find_completion_overlay_range): New function. * src/nsterm.m (ns_ax_find_completion_overlay_range): New function.
(ns_ax_event_is_line_nav_key): New function. (ns_ax_event_is_line_nav_key, ns_ax_completion_text_for_span): New
(ns_ax_completion_text_for_span): New function. functions.
(EmacsAccessibilityBuffer): Implement core NSAccessibility protocol: (EmacsAccessibilityBuffer): Implement core NSAccessibility protocol.
text cache with @synchronized, visible-run binary search O(log n), (ensureTextCache): Validity gated on BUF_CHARS_MODIFF, not BUF_MODIFF,
selectedTextRange, lineForIndex/indexForLine, frameForRange, to avoid O(buffer-size) rebuilds on every font-lock pass. Add
rangeForPosition, setAccessibilitySelectedTextRange, explanatory comment on why lineRangeForRange: in the lineStartOffsets
setAccessibilityFocused. loop is safe: it runs only on actual character modifications.
(accessibilityIndexForCharpos:): O(1) fast path for pure-ASCII runs
Tested on macOS 14 with VoiceOver. Verified: buffer reading, (ax_length == length); fall back to sequence walk for multi-byte runs.
line-by-line navigation, word/character announcements. (charposForAccessibilityIndex:): Symmetric O(1) fast path.
(accessibilityRole, accessibilityLabel, accessibilityValue)
(accessibilityNumberOfCharacters, accessibilitySelectedText)
(accessibilitySelectedTextRange, accessibilityInsertionPointLineNumber)
(accessibilityLineForIndex:): New method; return the line number for an
AX character index; defined here so patches 0003+ can call it without
forward reference.
(accessibilityRangeForLine:, accessibilityRangeForIndex:)
(accessibilityStyleRangeForIndex:, accessibilityFrameForRange:)
(accessibilityRangeForPosition:, accessibilityVisibleCharacterRange)
(accessibilityFrame, setAccessibilitySelectedTextRange:)
(setAccessibilityFocused:): Implement NSAccessibility protocol methods.
--- ---
src/nsterm.m | 1096 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/nsterm.m | 1135 ++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 1096 insertions(+) 1 file changed, 1135 insertions(+)
diff --git a/src/nsterm.m b/src/nsterm.m diff --git a/src/nsterm.m b/src/nsterm.m
index 2ac1d9d..1bcc84d 100644 index 3b923ee5fa..41c6b8dc14 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/src/nsterm.m +++ b/src/nsterm.m
@@ -7278,6 +7278,1102 @@ - (id)accessibilityTopLevelUIElement @@ -7653,6 +7653,1141 @@ - (id)accessibilityTopLevelUIElement
@end @end
@@ -145,18 +156,14 @@ index 2ac1d9d..1bcc84d 100644
+ Lisp_Object cmd = Vthis_command; + Lisp_Object cmd = Vthis_command;
+ /* Forward line commands. */ + /* Forward line commands. */
+ if (EQ (cmd, Qns_ax_next_line) + if (EQ (cmd, Qns_ax_next_line)
+ || EQ (cmd, Qns_ax_dired_next_line) + || EQ (cmd, Qns_ax_dired_next_line))
+ || EQ (cmd, Qns_ax_evil_next_line)
+ || EQ (cmd, Qns_ax_evil_next_visual_line))
+ { + {
+ if (which) *which = 1; + if (which) *which = 1;
+ return true; + return true;
+ } + }
+ /* Backward line commands. */ + /* Backward line commands. */
+ if (EQ (cmd, Qns_ax_previous_line) + if (EQ (cmd, Qns_ax_previous_line)
+ || EQ (cmd, Qns_ax_dired_previous_line) + || EQ (cmd, Qns_ax_dired_previous_line))
+ || EQ (cmd, Qns_ax_evil_previous_line)
+ || EQ (cmd, Qns_ax_evil_previous_visual_line))
+ { + {
+ if (which) *which = -1; + if (which) *which = -1;
+ return true; + return true;
@@ -199,8 +206,8 @@ index 2ac1d9d..1bcc84d 100644
+ /* Block input to prevent concurrent redisplay from modifying buffer + /* Block input to prevent concurrent redisplay from modifying buffer
+ state while we read text properties. Unwind-protected so + state while we read text properties. Unwind-protected so
+ block_input is always matched by unblock_input on signal. */ + block_input is always matched by unblock_input on signal. */
+ record_unwind_protect_void (unblock_input);
+ block_input (); + block_input ();
+ record_unwind_protect_void (unblock_input);
+ if (b != current_buffer) + if (b != current_buffer)
+ set_buffer_internal_1 (b); + set_buffer_internal_1 (b);
+ +
@@ -351,7 +358,7 @@ index 2ac1d9d..1bcc84d 100644
+ NSTRACE ("EmacsAccessibilityBuffer ensureTextCache"); + NSTRACE ("EmacsAccessibilityBuffer ensureTextCache");
+ /* This method is only called from the main thread (AX getters + /* This method is only called from the main thread (AX getters
+ dispatch_sync to main first). Reads of cachedText/cachedTextModiff + 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 + write section at the end needs synchronization to protect
+ against concurrent reads from AX server thread. */ + against concurrent reads from AX server thread. */
+ eassert ([NSThread isMainThread]); + eassert ([NSThread isMainThread]);
@@ -363,17 +370,25 @@ index 2ac1d9d..1bcc84d 100644
+ if (!b) + if (!b)
+ return; + return;
+ +
+ ptrdiff_t modiff = BUF_MODIFF (b); + /* Use BUF_CHARS_MODIFF, not BUF_MODIFF, for cache validity.
+ ptrdiff_t overlay_modiff = BUF_OVERLAY_MODIFF (b); + BUF_MODIFF is bumped by every text-property change, including
+ font-lock face applications on every redisplay. AX text contains
+ only characters, not face data, so property-only changes do not
+ affect the cached value. Rebuilding the full buffer text on
+ each font-lock pass is O(buffer-size) per redisplay --- this
+ causes progressive slowdown when scrolling through large files.
+ BUF_CHARS_MODIFF is bumped only on actual character insertions
+ and deletions, matching the semantic of "did the text change".
+ This is the pattern used by WebKit and NSTextView.
+ Do NOT track BUF_OVERLAY_MODIFF here --- overlay text is not
+ included in the cached AX text (it is handled separately via
+ explicit announcements in postAccessibilityNotificationsForFrame).
+ Including overlay_modiff would silently update cachedOverlayModiff
+ and prevent the notification dispatch from detecting changes. */
+ ptrdiff_t chars_modiff = BUF_CHARS_MODIFF (b);
+ ptrdiff_t pt = BUF_PT (b); + ptrdiff_t pt = BUF_PT (b);
+ NSUInteger textLen = cachedText ? [cachedText length] : 0; + NSUInteger textLen = cachedText ? [cachedText length] : 0;
+ /* Track both BUF_MODIFF and BUF_OVERLAY_MODIFF. Overlay-only + if (cachedText && cachedTextModiff == chars_modiff
+ 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. */
+ if (cachedText && cachedTextModiff == modiff
+ && cachedOverlayModiff == overlay_modiff
+ && cachedTextStart == BUF_BEGV (b) + && cachedTextStart == BUF_BEGV (b)
+ && pt >= cachedTextStart + && pt >= cachedTextStart
+ && (textLen == 0 + && (textLen == 0
@@ -389,8 +404,7 @@ index 2ac1d9d..1bcc84d 100644
+ { + {
+ [cachedText release]; + [cachedText release];
+ cachedText = [text retain]; + cachedText = [text retain];
+ cachedTextModiff = modiff; + cachedTextModiff = chars_modiff;
+ cachedOverlayModiff = overlay_modiff;
+ cachedTextStart = start; + cachedTextStart = start;
+ +
+ if (visibleRuns) + if (visibleRuns)
@@ -399,9 +413,13 @@ index 2ac1d9d..1bcc84d 100644
+ visibleRunCount = nruns; + visibleRunCount = nruns;
+ +
+ /* Build line-start index for O(log L) line queries. + /* Build line-start index for O(log L) line queries.
+ Walk the cached text once, recording the start offset + Walk the cached text once, recording the start offset of each
+ of each line. This runs once per cache rebuild (on text + line. Uses NSString lineRangeForRange: --- O(N) in the total
+ change or narrowing), not per cursor move. */ + text --- but this loop runs only on cache rebuild, which is
+ gated on BUF_CHARS_MODIFF: actual character insertions or
+ deletions. Font-lock (text property changes) does not trigger
+ a rebuild, so the hot path (cursor movement, redisplay) never
+ enters this code. */
+ if (lineStartOffsets) + if (lineStartOffsets)
+ xfree (lineStartOffsets); + xfree (lineStartOffsets);
+ lineStartOffsets = NULL; + lineStartOffsets = NULL;
@@ -455,7 +473,7 @@ index 2ac1d9d..1bcc84d 100644
+ /* Binary search: runs are sorted by charpos (ascending). Find the + /* Binary search: runs are sorted by charpos (ascending). Find the
+ run whose [charpos, charpos+length) range contains the target, + run whose [charpos, charpos+length) range contains the target,
+ or the nearest run after an invisible gap. O(log n) instead of + 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; + NSUInteger lo = 0, hi = visibleRunCount;
+ while (lo < hi) + while (lo < hi)
+ { + {
@@ -467,10 +485,21 @@ index 2ac1d9d..1bcc84d 100644
+ lo = mid + 1; + lo = mid + 1;
+ else + else
+ { + {
+ /* Found: charpos is inside this run. Compute UTF-16 delta + /* Found: charpos is inside this run. Compute UTF-16 delta.
+ directly from cachedText — no Lisp calls needed. */ + Fast path for pure-ASCII runs (ax_length == length): every
+ Emacs charpos maps to exactly one UTF-16 code unit, so the
+ conversion is O(1). This matters because ensureTextCache
+ calls this method on every redisplay frame to validate the
+ cache --- a O(cursor_position) loop here means O(position)
+ cost per frame even when the buffer is unchanged.
+ Multi-byte runs fall through to the sequence walk, bounded
+ by run length (visible window), not total buffer size. */
+ NSUInteger chars_in = (NSUInteger)(charpos - r->charpos); + NSUInteger chars_in = (NSUInteger)(charpos - r->charpos);
+ if (chars_in == 0 || !cachedText) + if (chars_in == 0)
+ return r->ax_start;
+ if (r->ax_length == (NSUInteger) r->length)
+ return r->ax_start + chars_in;
+ if (!cachedText)
+ return r->ax_start; + return r->ax_start;
+ NSUInteger run_end_ax = r->ax_start + r->ax_length; + NSUInteger run_end_ax = r->ax_start + r->ax_length;
+ NSUInteger scan = r->ax_start; + NSUInteger scan = r->ax_start;
@@ -493,10 +522,10 @@ index 2ac1d9d..1bcc84d 100644
+ +
+/* Convert accessibility string index to buffer charpos. +/* Convert accessibility string index to buffer charpos.
+ Safe to call from any thread: uses only cachedText (NSString) and + 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 +- (ptrdiff_t)charposForAccessibilityIndex:(NSUInteger)ax_idx
+{ +{
+ /* May be called from AX server thread synchronize. */ + /* May be called from AX server thread --- synchronize. */
+ @synchronized (self) + @synchronized (self)
+ { + {
+ if (visibleRunCount == 0) + if (visibleRunCount == 0)
@@ -514,8 +543,16 @@ index 2ac1d9d..1bcc84d 100644
+ lo = mid + 1; + lo = mid + 1;
+ else + else
+ { + {
+ /* Found: ax_idx is inside this run. Walk composed character + /* Found: ax_idx is inside this run.
+ sequences to count Emacs characters up to ax_idx. */ + Fast path for pure-ASCII runs: ax_length == length means
+ every Emacs charpos maps to exactly one AX string index.
+ The conversion is then O(1) instead of O(cursor_position).
+ Buffers with emoji, CJK, or other non-BMP characters use
+ the slow path (composed character sequence walk), which is
+ bounded by run length, not total buffer size. */
+ if (r->ax_length == (NSUInteger) r->length)
+ return r->charpos + (ptrdiff_t) (ax_idx - r->ax_start);
+
+ if (!cachedText) + if (!cachedText)
+ return r->charpos; + return r->charpos;
+ NSUInteger scan = r->ax_start; + NSUInteger scan = r->ax_start;
@@ -530,7 +567,7 @@ index 2ac1d9d..1bcc84d 100644
+ return cp; + return cp;
+ } + }
+ } + }
+ /* Past end return last charpos. */ + /* Past end --- return last charpos. */
+ if (lo > 0) + if (lo > 0)
+ { + {
+ ns_ax_visible_run *last = &visibleRuns[visibleRunCount - 1]; + ns_ax_visible_run *last = &visibleRuns[visibleRunCount - 1];
@@ -552,7 +589,7 @@ index 2ac1d9d..1bcc84d 100644
+ deadlocking the AX server thread. This is prevented by: + deadlocking the AX server thread. This is prevented by:
+ +
+ 1. validWindow checks WINDOW_LIVE_P and BUFFERP before every + 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 + 2. All dispatch_sync blocks run on the main thread where no
+ concurrent Lisp code can modify state between checks. + concurrent Lisp code can modify state between checks.
+ 3. block_input prevents timer events and process output from + 3. block_input prevents timer events and process output from
@@ -782,10 +819,9 @@ index 2ac1d9d..1bcc84d 100644
+ +
+ specpdl_ref count = SPECPDL_INDEX (); + specpdl_ref count = SPECPDL_INDEX ();
+ record_unwind_current_buffer (); + record_unwind_current_buffer ();
+ /* Ensure block_input is always matched by unblock_input even if + /* block_input must come before record_unwind_protect_void (unblock_input). */
+ Fset_marker or another Lisp call signals (longjmp). */
+ record_unwind_protect_void (unblock_input);
+ block_input (); + block_input ();
+ record_unwind_protect_void (unblock_input);
+ +
+ /* Convert accessibility index to buffer charpos via mapping. */ + /* Convert accessibility index to buffer charpos via mapping. */
+ ptrdiff_t charpos = [self charposForAccessibilityIndex:range.location]; + ptrdiff_t charpos = [self charposForAccessibilityIndex:range.location];
@@ -802,20 +838,12 @@ index 2ac1d9d..1bcc84d 100644
+ +
+ SET_PT_BOTH (charpos, CHAR_TO_BYTE (charpos)); + SET_PT_BOTH (charpos, CHAR_TO_BYTE (charpos));
+ +
+ /* Keep mark state aligned with requested selection range. */ + /* Always deactivate mark: VoiceOver range.length is an internal
+ if (range.length > 0) + word boundary hint, not a text selection. Activating the mark
+ { + makes accessibilitySelectedTextRange return a non-zero length,
+ ptrdiff_t mark_charpos = [self charposForAccessibilityIndex: + which confuses VoiceOver into positioning its browse cursor at
+ range.location + range.length]; + the END of the selection instead of the start. */
+ if (mark_charpos > BUF_ZV (b)) + bset_mark_active (b, Qnil);
+ mark_charpos = BUF_ZV (b);
+ Fset_marker (BVAR (b, mark), make_fixnum (mark_charpos),
+ Fcurrent_buffer ());
+ bset_mark_active (b, Qt);
+ }
+ else
+ bset_mark_active (b, Qnil);
+
+ unbind_to (count, Qnil); + unbind_to (count, Qnil);
+ +
+ /* Update cached state so the next notification cycle doesn't + /* Update cached state so the next notification cycle doesn't
@@ -847,10 +875,10 @@ index 2ac1d9d..1bcc84d 100644
+ if (!view || !view->emacsframe) + if (!view || !view->emacsframe)
+ return; + return;
+ +
+ /* Use specpdl unwind protection for block_input safety. */ + /* block_input must come before record_unwind_protect_void (unblock_input). */
+ specpdl_ref count = SPECPDL_INDEX (); + specpdl_ref count = SPECPDL_INDEX ();
+ record_unwind_protect_void (unblock_input);
+ block_input (); + block_input ();
+ record_unwind_protect_void (unblock_input);
+ +
+ /* Select the Emacs window so keyboard focus follows VoiceOver. */ + /* Select the Emacs window so keyboard focus follows VoiceOver. */
+ struct frame *f = view->emacsframe; + struct frame *f = view->emacsframe;
@@ -928,6 +956,27 @@ index 2ac1d9d..1bcc84d 100644
+ return [self rangeForLine:(NSUInteger)line textLength:len]; + return [self rangeForLine:(NSUInteger)line textLength:len];
+} +}
+ +
+- (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)accessibilityRangeForIndex:(NSInteger)index +- (NSRange)accessibilityRangeForIndex:(NSInteger)index
+{ +{
+ if (![NSThread isMainThread]) + if (![NSThread isMainThread])
@@ -942,7 +991,8 @@ index 2ac1d9d..1bcc84d 100644
+ if (!cachedText || index < 0 + if (!cachedText || index < 0
+ || (NSUInteger) index >= [cachedText length]) + || (NSUInteger) index >= [cachedText length])
+ return NSMakeRange (NSNotFound, 0); + return NSMakeRange (NSNotFound, 0);
+ return [cachedText rangeOfComposedCharacterSequenceAtIndex:(NSUInteger)index]; + return [cachedText
+ rangeOfComposedCharacterSequenceAtIndex:(NSUInteger)index];
+} +}
+ +
+- (NSRange)accessibilityStyleRangeForIndex:(NSInteger)index +- (NSRange)accessibilityStyleRangeForIndex:(NSInteger)index
@@ -1010,8 +1060,8 @@ index 2ac1d9d..1bcc84d 100644
+ so block_input is always matched by unblock_input, even if + so block_input is always matched by unblock_input, even if
+ ensureTextCache triggers a Lisp signal (longjmp). */ + ensureTextCache triggers a Lisp signal (longjmp). */
+ specpdl_ref count = SPECPDL_INDEX (); + specpdl_ref count = SPECPDL_INDEX ();
+ record_unwind_protect_void (unblock_input);
+ block_input (); + block_input ();
+ record_unwind_protect_void (unblock_input);
+ +
+ /* Find the glyph row at this y coordinate. */ + /* Find the glyph row at this y coordinate. */
+ struct glyph_matrix *matrix = w->current_matrix; + struct glyph_matrix *matrix = w->current_matrix;

View File

@@ -1,40 +1,42 @@
From f5ce42e931a3ed1668e6fb8260ef736442d8d2c9 Mon Sep 17 00:00:00 2001 From d8a98fc40d8285c19e0a73a7e8a53778926b9836 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 12:58:11 +0100 Date: Sat, 28 Feb 2026 12:58:11 +0100
Subject: [PATCH 3/8] ns: add buffer notification dispatch and mode-line Subject: [PATCH 4/9] ns: add buffer notification dispatch and mode-line
element element
Add VoiceOver notification methods and mode-line readout. Add VoiceOver notification dispatch and mode-line readout.
* src/nsterm.m (EmacsAccessibilityBuffer(Notifications)): New * src/nsterm.m (EmacsAccessibilityBuffer(Notifications)): New category.
category. (postTextChangedNotification:): Post NSAccessibilityValueChangedNotification
(postTextChangedNotification:): ValueChanged with edit details. with AXTextEditType/AXTextChangeValue details.
(postFocusedCursorNotification:direction:granularity:markActive: (postFocusedCursorNotification:direction:granularity:markActive:
oldMarkActive:): Hybrid SelectedTextChanged / AnnouncementRequested oldMarkActive:): Post NSAccessibilitySelectedTextChangedNotification
per WebKit pattern. following the WebKit hybrid pattern; announce character at point for
character moves.
(postCompletionAnnouncementForBuffer:point:): Announce completion (postCompletionAnnouncementForBuffer:point:): Announce completion
candidates in non-focused buffers. candidates in non-focused (completion) buffers. Lisp/buffer
(postAccessibilityNotificationsForFrame:): Main dispatch entry point. access is performed inside block_input; ObjC AX calls are made after
(EmacsAccessibilityModeLine): Implement AXStaticText element. unblock_input to avoid holding block_input during @synchronized.
(postAccessibilityNotificationsForFrame:): Main dispatch entry point;
Tested on macOS 14. Verified: cursor movement announcements, detects text edit, cursor/mark change, or overlay change.
region selection feedback, completion popups, mode-line reading. (EmacsAccessibilityModeLine): Implement AXStaticText element for the
mode line.
--- ---
src/nsterm.m | 545 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/nsterm.m | 606 +++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 545 insertions(+) 1 file changed, 606 insertions(+)
diff --git a/src/nsterm.m b/src/nsterm.m diff --git a/src/nsterm.m b/src/nsterm.m
index 1bcc84d..dfb84ca 100644 index 41c6b8dc14..16343f978a 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/src/nsterm.m +++ b/src/nsterm.m
@@ -8374,6 +8374,551 @@ - (NSRect)accessibilityFrame @@ -8788,6 +8788,612 @@ - (NSRect)accessibilityFrame
@end @end
+ +
+ +
+/* =================================================================== +/* ===================================================================
+ EmacsAccessibilityBuffer (Notifications) AX event dispatch + EmacsAccessibilityBuffer (Notifications) --- AX event dispatch
+ +
+ These methods notify VoiceOver of text and selection changes. + These methods notify VoiceOver of text and selection changes.
+ Called from the redisplay cycle (postAccessibilityUpdates). + Called from the redisplay cycle (postAccessibilityUpdates).
@@ -49,7 +51,7 @@ index 1bcc84d..dfb84ca 100644
+ if (point > self.cachedPoint + if (point > self.cachedPoint
+ && point - self.cachedPoint == 1) + && point - self.cachedPoint == 1)
+ { + {
+ /* Single char inserted refresh cache and grab it. */ + /* Single char inserted --- refresh cache and grab it. */
+ [self invalidateTextCache]; + [self invalidateTextCache];
+ [self ensureTextCache]; + [self ensureTextCache];
+ if (cachedText) + if (cachedText)
@@ -68,7 +70,7 @@ index 1bcc84d..dfb84ca 100644
+ /* Update cachedPoint here so the selection-move branch does NOT + /* Update cachedPoint here so the selection-move branch does NOT
+ fire for point changes caused by edits. WebKit and Chromium + fire for point changes caused by edits. WebKit and Chromium
+ never send both ValueChanged and SelectedTextChanged for the + 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; + self.cachedPoint = point;
+ +
+ NSDictionary *change = @{ + NSDictionary *change = @{
@@ -107,7 +109,7 @@ index 1bcc84d..dfb84ca 100644
+ moveInfo[@"AXTextChangeElement"] = self; + moveInfo[@"AXTextChangeElement"] = self;
+ /* Omit granularity for character moves so VoiceOver does not + /* Omit granularity for character moves so VoiceOver does not
+ derive its own speech (it would read the wrong character + derive its own speech (it would read the wrong character
+ for evil block-cursor mode). Include it for word/line/ + for block-cursor mode). Include it for word/line/
+ selection so VoiceOver reads the appropriate text. */ + selection so VoiceOver reads the appropriate text. */
+ if (!isCharMove) + if (!isCharMove)
+ moveInfo[@"AXTextSelectionGranularity"] = @(granularity); + moveInfo[@"AXTextSelectionGranularity"] = @(granularity);
@@ -119,7 +121,7 @@ index 1bcc84d..dfb84ca 100644
+ +
+ /* For character moves: explicit announcement of char AT point. + /* For character moves: explicit announcement of char AT point.
+ This is the ONLY speech source for character navigation. + This is the ONLY speech source for character navigation.
+ Correct for evil block-cursor (cursor ON the character) + Correct for block-cursor (cursor ON the character)
+ and harmless for insert-mode. */ + and harmless for insert-mode. */
+ if (isCharMove && cachedText) + if (isCharMove && cachedText)
+ { + {
@@ -152,6 +154,65 @@ index 1bcc84d..dfb84ca 100644
+ } + }
+ } + }
+ +
+ /* For word moves: explicit announcement of word AT new point.
+ VO auto-speech from SelectedTextChanged with direction=next
+ and granularity=word reads the word that was traversed (the
+ source word), not the word arrived at. This explicit
+ announcement reads the destination word instead, matching
+ user expectation ("w" jumps to next word and reads it). */
+ BOOL isWordMove
+ = (!markActive && !oldMarkActive
+ && granularity == ns_ax_text_selection_granularity_word
+ && direction == ns_ax_text_selection_direction_discontiguous);
+ if (isWordMove && cachedText)
+ {
+ NSCharacterSet *ws
+ = [NSCharacterSet whitespaceAndNewlineCharacterSet];
+ NSUInteger point_idx
+ = [self accessibilityIndexForCharpos:point];
+ NSUInteger tlen = [cachedText length];
+ if (point_idx < tlen
+ && ![ws characterIsMember:
+ [cachedText characterAtIndex:point_idx]])
+ {
+ /* Find word boundaries around point. */
+ NSUInteger wstart = point_idx;
+ while (wstart > 0
+ && ![ws characterIsMember:
+ [cachedText characterAtIndex:wstart - 1]])
+ wstart--;
+ NSUInteger wend = point_idx;
+ while (wend < tlen
+ && ![ws characterIsMember:
+ [cachedText characterAtIndex:wend]])
+ wend++;
+ if (wend > wstart)
+ {
+ NSString *word
+ = [cachedText substringWithRange:
+ NSMakeRange (wstart, wend - wstart)];
+ NSMutableCharacterSet *trims
+ = [ws mutableCopy];
+ [trims formUnionWithCharacterSet:
+ [NSCharacterSet punctuationCharacterSet]];
+ word = [word stringByTrimmingCharactersInSet:trims];
+ [trims release];
+ if ([word length] > 0)
+ {
+ NSDictionary *annInfo = @{
+ NSAccessibilityAnnouncementKey: word,
+ NSAccessibilityPriorityKey:
+ @(NSAccessibilityPriorityHigh)
+ };
+ ns_ax_post_notification_with_info (
+ NSApp,
+ NSAccessibilityAnnouncementRequestedNotification,
+ annInfo);
+ }
+ }
+ }
+ }
+
+ /* For focused line moves: always announce line text explicitly. + /* For focused line moves: always announce line text explicitly.
+ SelectedTextChanged with granularity=line works for arrow keys, + SelectedTextChanged with granularity=line works for arrow keys,
+ but C-n/C-p need the explicit announcement (VoiceOver processes + but C-n/C-p need the explicit announcement (VoiceOver processes
@@ -221,6 +282,8 @@ index 1bcc84d..dfb84ca 100644
+ ptrdiff_t currentOverlayEnd = 0; + ptrdiff_t currentOverlayEnd = 0;
+ +
+ specpdl_ref count2 = SPECPDL_INDEX (); + specpdl_ref count2 = SPECPDL_INDEX ();
+ block_input ();
+ record_unwind_protect_void (unblock_input);
+ record_unwind_current_buffer (); + record_unwind_current_buffer ();
+ if (b != current_buffer) + if (b != current_buffer)
+ set_buffer_internal_1 (b); + set_buffer_internal_1 (b);
@@ -408,7 +471,7 @@ index 1bcc84d..dfb84ca 100644
+ } + }
+ +
+ /* --- Cursor moved or selection changed --- + /* --- 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. */ + per the WebKit/Chromium pattern. */
+ else if (point != self.cachedPoint || markActive != self.cachedMarkActive) + else if (point != self.cachedPoint || markActive != self.cachedMarkActive)
+ { + {

View File

@@ -1,33 +1,51 @@
From 8675f0f75a33e4a3621e0b1e15aab7eff2c81369 Mon Sep 17 00:00:00 2001 From 9c233aa400c2769e1621ec37f326d1e24c0da2df Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 12:58:11 +0100 Date: Sat, 28 Feb 2026 12:58:11 +0100
Subject: [PATCH 4/8] ns: add interactive span elements for Tab navigation Subject: [PATCH 5/9] ns: add interactive span elements for Tab navigation
* src/nsterm.m (ns_ax_scan_interactive_spans): New function. * src/nsterm.m (ns_ax_scan_interactive_spans): New function; scans the
(EmacsAccessibilityInteractiveSpan): Implement AXButton/AXLink visible portion of a buffer for interactive text properties
elements with AXPress action. (ns-ax-widget, ns-ax-button, ns-ax-follow-link, ns-ax-org-link,
mouse-face, overlay keymap) and builds EmacsAccessibilityInteractiveSpan
elements.
(EmacsAccessibilityInteractiveSpan): Implement AXButton and AXLink
elements with an AXPress action that sends a synthetic TAB keystroke.
(EmacsAccessibilityBuffer(InteractiveSpans)): New category. (EmacsAccessibilityBuffer(InteractiveSpans)): New category.
accessibilityChildrenInNavigationOrder for Tab/Shift-Tab cycling (accessibilityChildrenInNavigationOrder): Return cached span array,
with wrap-around. rebuilding lazily when interactiveSpansDirty is set.
Tested on macOS 14. Verified: Tab-cycling through org-mode links,
*Completions* candidates, widget buttons, customize buffers.
--- ---
src/nsterm.m | 286 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/nsterm.m | 302 ++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 286 insertions(+) 1 file changed, 298 insertions(+), 4 deletions(-)
diff --git a/src/nsterm.m b/src/nsterm.m diff --git a/src/nsterm.m b/src/nsterm.m
index dfb84ca..c852929 100644 index 16343f978a..f5e5cea074 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/src/nsterm.m +++ b/src/nsterm.m
@@ -8919,6 +8919,292 @@ - (NSRect)accessibilityFrame @@ -8669,12 +8669,12 @@ - (NSRange)accessibilityRangeForPosition:(NSPoint)screenPoint
return NSMakeRange (0, 0);
/* Block input to prevent concurrent redisplay from modifying the
- glyph matrix while we traverse it. Use specpdl unwind protection
- so block_input is always matched by unblock_input, even if
- ensureTextCache triggers a Lisp signal (longjmp). */
+ glyph matrix while we traverse it. block_input must come before
+ record_unwind_protect_void (unblock_input) so that the unwind
+ handler is never called without a matching block_input. */
specpdl_ref count = SPECPDL_INDEX ();
- record_unwind_protect_void (unblock_input);
block_input ();
+ record_unwind_protect_void (unblock_input);
/* Find the glyph row at this y coordinate. */
struct glyph_matrix *matrix = w->current_matrix;
@@ -9394,6 +9394,300 @@ - (NSRect)accessibilityFrame
@end @end
+ +
+ +
+/* =================================================================== +/* ===================================================================
+ EmacsAccessibilityInteractiveSpan helpers and implementation + EmacsAccessibilityInteractiveSpan --- helpers and implementation
+ =================================================================== */ + =================================================================== */
+ +
+/* Scan visible range of window W for interactive spans. +/* Scan visible range of window W for interactive spans.
@@ -56,6 +74,10 @@ index dfb84ca..c852929 100644
+ if (vis_start >= vis_end) + if (vis_start >= vis_end)
+ return @[]; + return @[];
+ +
+ block_input ();
+ specpdl_ref blk_count = SPECPDL_INDEX ();
+ record_unwind_protect_void (unblock_input);
+
+ /* Symbols are interned once at startup via DEFSYM in syms_of_nsterm; + /* Symbols are interned once at startup via DEFSYM in syms_of_nsterm;
+ reference them directly here (GC-safe, no repeated obarray lookup). */ + reference them directly here (GC-safe, no repeated obarray lookup). */
+ +
@@ -71,6 +93,7 @@ index dfb84ca..c852929 100644
+ EmacsAXSpanType span_type = EmacsAXSpanTypeNone; + EmacsAXSpanType span_type = EmacsAXSpanTypeNone;
+ Lisp_Object limit_prop = Qnil; + Lisp_Object limit_prop = Qnil;
+ +
+ /* Fplist_get third arg Qnil: use `eq' predicate (the default). */
+ if (!NILP (Fplist_get (plist, Qns_ax_widget, Qnil))) + if (!NILP (Fplist_get (plist, Qns_ax_widget, Qnil)))
+ { + {
+ span_type = EmacsAXSpanTypeWidget; + span_type = EmacsAXSpanTypeWidget;
@@ -124,7 +147,7 @@ index dfb84ca..c852929 100644
+ { + {
+ /* Skip to the next position where any interactive property + /* Skip to the next position where any interactive property
+ changes. Try each scannable property in turn and take + changes. Try each scannable property in turn and take
+ the nearest change point — O(properties) per gap rather + the nearest change point --- O(properties) per gap rather
+ than O(chars). Fall back to pos+1 as safety net. */ + than O(chars). Fall back to pos+1 as safety net. */
+ ptrdiff_t next_interesting = vis_end; + ptrdiff_t next_interesting = vis_end;
+ Lisp_Object skip_props[5] + Lisp_Object skip_props[5]
@@ -176,6 +199,7 @@ index dfb84ca..c852929 100644
+ pos = span_end; + pos = span_end;
+ } + }
+ +
+ unbind_to (blk_count, Qnil);
+ return [[spans copy] autorelease]; + return [[spans copy] autorelease];
+} +}
+ +
@@ -218,7 +242,8 @@ index dfb84ca..c852929 100644
+- (BOOL) isAccessibilityFocused +- (BOOL) isAccessibilityFocused
+{ +{
+ /* Read the cached point stored by EmacsAccessibilityBuffer on the main + /* 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; + EmacsAccessibilityBuffer *pb = self.parentBuffer;
+ if (!pb) + if (!pb)
+ return NO; + return NO;
@@ -235,16 +260,17 @@ index dfb84ca..c852929 100644
+ dispatch_async (dispatch_get_main_queue (), ^{ + dispatch_async (dispatch_get_main_queue (), ^{
+ /* lwin is a Lisp_Object captured by value. This is GC-safe + /* lwin is a Lisp_Object captured by value. This is GC-safe
+ because Lisp_Objects are tagged integers/pointers that + 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 + Emacs. The WINDOW_LIVE_P check below guards against the
+ window being deleted between capture and execution. */ + window being deleted between capture and execution. */
+ if (!WINDOWP (lwin) || NILP (Fwindow_live_p (lwin))) + if (!WINDOWP (lwin) || NILP (Fwindow_live_p (lwin)))
+ return; + return;
+ /* Use specpdl unwind protection so that block_input is always + /* block_input must come before record_unwind_protect_void (unblock_input)
+ matched by unblock_input, even if Fselect_window signals. */ + so the unwind handler is never invoked without a matching block_input,
+ even if Fselect_window signals (longjmp). */
+ specpdl_ref count = SPECPDL_INDEX (); + specpdl_ref count = SPECPDL_INDEX ();
+ record_unwind_protect_void (unblock_input);
+ block_input (); + block_input ();
+ record_unwind_protect_void (unblock_input);
+ record_unwind_current_buffer (); + record_unwind_current_buffer ();
+ Fselect_window (lwin, Qnil); + Fselect_window (lwin, Qnil);
+ struct window *w = XWINDOW (lwin); + struct window *w = XWINDOW (lwin);
@@ -261,7 +287,7 @@ index dfb84ca..c852929 100644
+ +
+@end +@end
+ +
+/* EmacsAccessibilityBuffer InteractiveSpans category. +/* EmacsAccessibilityBuffer --- InteractiveSpans category.
+ Methods are kept here (same .m file) so they access the ivars + Methods are kept here (same .m file) so they access the ivars
+ declared in the @interface ivar block. */ + declared in the @interface ivar block. */
+@implementation EmacsAccessibilityBuffer (InteractiveSpans) +@implementation EmacsAccessibilityBuffer (InteractiveSpans)

View File

@@ -1,37 +1,37 @@
From 9e7fa018ef779610b2fb54c1ff951d0bf6bf7652 Mon Sep 17 00:00:00 2001 From 411c0c3f06ad4c2d5aae2b17b809e8899ea892ba Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 12:58:11 +0100 Date: Sat, 28 Feb 2026 12:58:11 +0100
Subject: [PATCH 5/8] ns: integrate accessibility with EmacsView and redisplay Subject: [PATCH 6/9] ns: integrate accessibility with EmacsView and redisplay
Wire the accessibility infrastructure into EmacsView and the Wire the accessibility element tree into EmacsView and hook it into
the redisplay cycle.
* src/nsterm.m (ns_update_end): Call [view postAccessibilityUpdates]. * etc/NEWS: Document VoiceOver accessibility support.
* src/nsterm.m (ns_update_end): Call -[EmacsView postAccessibilityUpdates].
(EmacsApp ns_update_accessibility_state): New method; query
AXIsProcessTrustedWithOptions and UAZoomEnabled to set
ns_accessibility_enabled automatically.
(EmacsApp ns_accessibility_did_change:): New method; handle
com.apple.accessibility.api distributed notification.
(EmacsView dealloc): Release accessibilityElements. (EmacsView dealloc): Release accessibilityElements.
(EmacsView windowDidBecomeKey): Post accessibility focus notification. (EmacsView windowDidBecomeKey:): Post accessibility focus notification.
(ns_ax_collect_windows): New function. (ns_ax_collect_windows): New function.
(EmacsView rebuildAccessibilityTree, invalidateAccessibilityTree) (EmacsView rebuildAccessibilityTree, invalidateAccessibilityTree)
(accessibilityChildren, accessibilityFocusedUIElement) (accessibilityChildren, accessibilityFocusedUIElement)
(postAccessibilityUpdates, accessibilityBoundsForRange:) (postAccessibilityUpdates, accessibilityBoundsForRange:)
(accessibilityParameterizedAttributeNames) (accessibilityParameterizedAttributeNames)
(accessibilityAttributeValue:forParameter:): New methods. (accessibilityAttributeValue:forParameter:): New methods.
* etc/NEWS: Document VoiceOver accessibility support.
Tested on macOS 14 with VoiceOver. 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 ++ etc/NEWS | 13 ++
src/nsterm.h | 2 +- src/nsterm.h | 7 +-
src/nsterm.m | 373 ++++++++++++++++++++++++++++++++++++++++++++++++++- src/nsterm.m | 474 +++++++++++++++++++++++++++++++++++++++++++++++++--
3 files changed, 384 insertions(+), 4 deletions(-) 3 files changed, 483 insertions(+), 11 deletions(-)
diff --git a/etc/NEWS b/etc/NEWS diff --git a/etc/NEWS b/etc/NEWS
index ef36df5..e76ee93 100644 index 4c149e41d6..7f917f93b2 100644
--- a/etc/NEWS --- a/etc/NEWS
+++ b/etc/NEWS +++ b/etc/NEWS
@@ -4389,6 +4389,19 @@ allowing Emacs users access to speech recognition utilities. @@ -4385,6 +4385,19 @@ allowing Emacs users access to speech recognition utilities.
Note: Accepting this permission allows the use of system APIs, which may Note: Accepting this permission allows the use of system APIs, which may
send user data to Apple's speech recognition servers. send user data to Apple's speech recognition servers.
@@ -52,54 +52,105 @@ index ef36df5..e76ee93 100644
** Re-introduced dictation, lost in Emacs v30 (macOS). ** Re-introduced dictation, lost in Emacs v30 (macOS).
We lost macOS dictation in v30 when migrating to NSTextInputClient. We lost macOS dictation in v30 when migrating to NSTextInputClient.
diff --git a/src/nsterm.h b/src/nsterm.h diff --git a/src/nsterm.h b/src/nsterm.h
index 5298386..ec7b587 100644 index f245675513..4bf79a9adb 100644
--- a/src/nsterm.h --- a/src/nsterm.h
+++ b/src/nsterm.h +++ b/src/nsterm.h
@@ -594,7 +594,7 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType) @@ -590,7 +590,12 @@ typedef NS_ENUM(NSInteger, EmacsAXSpanType)
char *old_title;
BOOL maximizing_resize;
NSMutableArray *accessibilityElements;
- /* See GC safety comment on EmacsAccessibilityElement.lispWindow. */
+ /* Lisp_Object ivars not visible to GC. Both objects are always
+ reachable via the frame's live window tree, so GC cannot collect
+ them. After a window-tree rebuild (delete-window, split-window)
+ a stale EQ match would merely skip a focus notification --- the
+ worst case is one spurious VoiceOver focus event per rebuild.
+ No staticpro() needed: the window tree holds a strong reference. */
Lisp_Object lastSelectedWindow;
Lisp_Object lastRootWindow;
BOOL accessibilityTreeValid; BOOL accessibilityTreeValid;
BOOL accessibilityUpdating;
@public /* Accessed by ns_draw_phys_cursor (C function). */
- NSRect lastAccessibilityCursorRect;
+ NSRect lastCursorRect;
#endif
BOOL font_panel_active;
NSFont *font_panel_result;
diff --git a/src/nsterm.m b/src/nsterm.m diff --git a/src/nsterm.m b/src/nsterm.m
index c852929..f0e8751 100644 index f5e5cea074..c3cd83b774 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/src/nsterm.m +++ b/src/nsterm.m
@@ -1105,6 +1105,11 @@ static NSRect constrain_frame_rect(NSRect frameRect, bool isFullscreen) @@ -1393,7 +1393,8 @@ so the visual offset is (ov_line + 1) * line_h from
(zoomCursorUpdated is NO). */
unblock_input (); #if defined (MAC_OS_X_VERSION_MIN_REQUIRED) \
ns_updating_frame = NULL; && MAC_OS_X_VERSION_MIN_REQUIRED >= 101000
- if (view && !view->zoomCursorUpdated && ns_zoom_enabled_p ()
+ if (view && !view->zoomCursorUpdated
+ && ns_zoom_enabled_p ()
&& !NSIsEmptyRect (view->lastCursorRect))
{
NSRect r = view->lastCursorRect;
@@ -1420,6 +1421,9 @@ so the visual offset is (ov_line + 1) * line_h from
if (view)
ns_zoom_track_completion (f, view);
#endif /* NS_IMPL_COCOA */
+ +
+#ifdef NS_IMPL_COCOA
+ /* Post accessibility notifications after each redisplay cycle. */ + /* Post accessibility notifications after each redisplay cycle. */
+ [view postAccessibilityUpdates]; + [view postAccessibilityUpdates];
+#endif
} }
static void static void
@@ -3233,6 +3238,18 @@ Note that CURSOR_WIDTH is meaningful only for (h)bar cursors. @@ -6723,9 +6727,56 @@ - (void)applicationDidFinishLaunching: (NSNotification *)notification
/* Prevent the cursor from being drawn outside the text area. */ }
r = NSIntersectionRect (r, ns_row_rect (w, glyph_row, TEXT_AREA)); #endif
+#ifdef NS_IMPL_COCOA +#ifdef NS_IMPL_COCOA
+ /* Accessibility: store cursor rect for VoiceOver bounds queries. + /* Auto-detect Zoom and VoiceOver at startup and whenever their state
+ accessibilityBoundsForRange: / accessibilityFrameForRange: + changes. The "com.apple.accessibility.api" distributed notification
+ use this as a fallback when no valid window/glyph data is + fires when any assistive technology connects or disconnects.
+ available. Skipped when ns-accessibility-enabled is nil. */ + Both code paths set ns_accessibility_enabled so that one variable
+ { + gates all our accessibility overhead. */
+ EmacsView *view = FRAME_NS_VIEW (f); + [self ns_update_accessibility_state];
+ if (view && on_p && active_p && ns_accessibility_enabled) + [[NSDistributedNotificationCenter defaultCenter]
+ view->lastCursorRect = r; + addObserver: self
+ } + selector: @selector(ns_accessibility_did_change:)
+ name: @"com.apple.accessibility.api"
+ object: nil
+suspensionBehavior: NSNotificationSuspensionBehaviorDeliverImmediately];
+#endif +#endif
+ +
ns_focus (f, NULL, 0); ns_send_appdefined (-2);
}
NSGraphicsContext *ctx = [NSGraphicsContext currentContext]; +#ifdef NS_IMPL_COCOA
@@ -7281,7 +7298,6 @@ - (id)accessibilityTopLevelUIElement +/* Set ns_accessibility_enabled based on current AT state.
+ Called at startup and from the "com.apple.accessibility.api"
+ distributed notification handler. Checks both UAZoomEnabled()
+ (Zoom) and AXIsProcessTrustedWithOptions() (VoiceOver and other
+ ATs that have connected to this process). */
+- (void) ns_update_accessibility_state
+{
+ NSTRACE ("[EmacsApp ns_update_accessibility_state]");
+ BOOL zoom_on = UAZoomEnabled ();
+ NSDictionary *opts = @{(__bridge id) kAXTrustedCheckOptionPrompt: @NO};
+ BOOL at_on = AXIsProcessTrustedWithOptions ((__bridge CFDictionaryRef) opts);
+ BOOL new_state = zoom_on || at_on;
+ if ((BOOL) ns_accessibility_enabled != new_state)
+ {
+ ns_accessibility_enabled = new_state;
+ /* Reset the UAZoomEnabled cache so ns_zoom_enabled_p() reflects
+ the new Zoom state on its next call. */
+ ns_zoom_cache_time = 0;
+ }
+}
+
+/* Handler for the "com.apple.accessibility.api" distributed notification,
+ posted by macOS when any AT (VoiceOver, Switch Control, etc.) starts
+ or stops. */
+- (void) ns_accessibility_did_change: (NSNotification *) notification
+{
+ NSTRACE ("[EmacsApp ns_accessibility_did_change:]");
+ [self ns_update_accessibility_state];
+}
+#endif
+
- (void)antialiasThresholdDidChange:(NSNotification *)notification
{
#ifdef NS_IMPL_COCOA
@@ -7656,7 +7707,6 @@ - (id)accessibilityTopLevelUIElement
@@ -107,23 +158,78 @@ index c852929..f0e8751 100644
static BOOL static BOOL
ns_ax_find_completion_overlay_range (struct buffer *b, ptrdiff_t point, ns_ax_find_completion_overlay_range (struct buffer *b, ptrdiff_t point,
ptrdiff_t *out_start, ptrdiff_t *out_start,
@@ -8375,7 +8391,6 @@ - (NSRect)accessibilityFrame @@ -8789,7 +8839,6 @@ - (NSRect)accessibilityFrame
@end @end
- -
/* =================================================================== /* ===================================================================
EmacsAccessibilityBuffer (Notifications) AX event dispatch EmacsAccessibilityBuffer (Notifications) --- AX event dispatch
@@ -8920,7 +8935,6 @@ - (NSRect)accessibilityFrame @@ -9283,6 +9332,54 @@ - (void)postAccessibilityNotificationsForFrame:(struct frame *)f
granularity = ns_ax_text_selection_granularity_line;
}
+ /* Treat all moves as Emacs-initiated until voiceoverSetPoint
+ tracking is introduced (subsequent patch). */
+ BOOL emacsMovedCursor = YES;
+
+ /* Programmatic jumps that cross a line boundary (]], [[, M-<,
+ xref, imenu, …) are discontiguous: the cursor teleported to an
+ arbitrary position, not one sequential step forward/backward.
+ Reporting AXTextSelectionDirectionDiscontiguous causes VoiceOver
+ to re-anchor its rotor browse cursor at the new
+ accessibilitySelectedTextRange rather than advancing linearly
+ from its previous internal position. */
+ if (!isCtrlNP && granularity == ns_ax_text_selection_granularity_line)
+ direction = ns_ax_text_selection_direction_discontiguous;
+
+ /* If Emacs moved the cursor (not VoiceOver), force discontiguous
+ so VoiceOver re-anchors its browse cursor to the current
+ accessibilitySelectedTextRange. This covers all Emacs-initiated
+ moves: editing commands, ELisp, isearch, etc.
+ Exception: C-n/C-p (isCtrlNP) already uses next/previous with
+ line granularity; those are already sequential and VoiceOver
+ handles them correctly. */
+ if (emacsMovedCursor && !isCtrlNP)
+ direction = ns_ax_text_selection_direction_discontiguous;
+
+ /* Re-anchor VoiceOver's browse cursor for discontiguous (teleport)
+ moves only. For sequential C-n/C-p (isCtrlNP), posting
+ FocusedUIElementChanged on the window races with the
+ AXSelectedTextChanged(granularity=line) notification and
+ causes VoiceOver to drop the line-read speech. Sequential
+ moves are already handled correctly by AXSelectedTextChanged
+ with direction=next/previous + granularity=line. */
+ if (emacsMovedCursor && !isCtrlNP && [self isAccessibilityFocused])
+ {
+ NSWindow *win = [self.emacsView window];
+ if (win)
+ ns_ax_post_notification (
+ win,
+ NSAccessibilityFocusedUIElementChangedNotification);
+
+ NSDictionary *layoutInfo = @{
+ NSAccessibilityUIElementsKey: @[self]
+ };
+ ns_ax_post_notification_with_info (
+ self.emacsView,
+ NSAccessibilityLayoutChangedNotification,
+ layoutInfo);
+ }
+
/* Post notifications for focused and non-focused elements. */
if ([self isAccessibilityFocused])
[self postFocusedCursorNotification:point
@@ -9395,7 +9492,6 @@ - (NSRect)accessibilityFrame
@end @end
- -
/* =================================================================== /* ===================================================================
EmacsAccessibilityInteractiveSpan helpers and implementation EmacsAccessibilityInteractiveSpan --- helpers and implementation
=================================================================== */ =================================================================== */
@@ -9250,6 +9264,7 @@ - (void)dealloc @@ -9733,6 +9829,7 @@ - (void)dealloc
[layer release]; [layer release];
#endif #endif
@@ -131,7 +237,7 @@ index c852929..f0e8751 100644
[[self menu] release]; [[self menu] release];
[super dealloc]; [super dealloc];
} }
@@ -10598,6 +10613,32 @@ - (void)windowDidBecomeKey /* for direct calls */ @@ -11081,6 +11178,32 @@ - (void)windowDidBecomeKey /* for direct calls */
XSETFRAME (event.frame_or_window, emacsframe); XSETFRAME (event.frame_or_window, emacsframe);
kbd_buffer_store_event (&event); kbd_buffer_store_event (&event);
ns_send_appdefined (-1); // Kick main loop ns_send_appdefined (-1); // Kick main loop
@@ -164,7 +270,7 @@ index c852929..f0e8751 100644
} }
@@ -11835,6 +11876,332 @@ - (int) fullscreenState @@ -12318,6 +12441,332 @@ - (int) fullscreenState
return fs_state; return fs_state;
} }
@@ -184,7 +290,7 @@ index c852929..f0e8751 100644
+ +
+ if (WINDOW_LEAF_P (w)) + if (WINDOW_LEAF_P (w))
+ { + {
+ /* Buffer element reuse existing if available. */ + /* Buffer element --- reuse existing if available. */
+ EmacsAccessibilityBuffer *elem + EmacsAccessibilityBuffer *elem
+ = [existing objectForKey:[NSValue valueWithPointer:w]]; + = [existing objectForKey:[NSValue valueWithPointer:w]];
+ if (!elem) + if (!elem)
@@ -218,7 +324,7 @@ index c852929..f0e8751 100644
+ } + }
+ else + else
+ { + {
+ /* Internal (combination) window recurse into children. */ + /* Internal (combination) window --- recurse into children. */
+ Lisp_Object child = w->contents; + Lisp_Object child = w->contents;
+ while (!NILP (child)) + while (!NILP (child))
+ { + {
@@ -330,7 +436,7 @@ index c852929..f0e8751 100644
+ accessibilityUpdating = YES; + accessibilityUpdating = YES;
+ +
+ /* Detect window tree change (split, delete, new buffer). Compare + /* 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); + Lisp_Object curRoot = FRAME_ROOT_WINDOW (emacsframe);
+ if (!EQ (curRoot, lastRootWindow)) + if (!EQ (curRoot, lastRootWindow))
+ { + {
@@ -339,12 +445,12 @@ index c852929..f0e8751 100644
+ } + }
+ +
+ /* If tree is stale, rebuild FIRST so we don't iterate freed + /* 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. */ + freshly-built elements have no previous state to diff against. */
+ if (!accessibilityTreeValid) + if (!accessibilityTreeValid)
+ { + {
+ [self rebuildAccessibilityTree]; + [self rebuildAccessibilityTree];
+ /* Invalidate span cache window layout changed. */ + /* Invalidate span cache --- window layout changed. */
+ for (EmacsAccessibilityElement *elem in accessibilityElements) + for (EmacsAccessibilityElement *elem in accessibilityElements)
+ if ([elem isKindOfClass: [EmacsAccessibilityBuffer class]]) + if ([elem isKindOfClass: [EmacsAccessibilityBuffer class]])
+ [(EmacsAccessibilityBuffer *) elem invalidateInteractiveSpans]; + [(EmacsAccessibilityBuffer *) elem invalidateInteractiveSpans];
@@ -497,6 +603,30 @@ index c852929..f0e8751 100644
@end /* EmacsView */ @end /* EmacsView */
@@ -14314,12 +14763,17 @@ Nil means use fullscreen the old (< 10.7) way. The old way works better with
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 nil. Set to t to enable VoiceOver support. */);
+ doc: /* Non-nil enables Zoom cursor tracking and VoiceOver support.
+Emacs sets this automatically at startup when macOS Zoom is active or
+any assistive technology (VoiceOver, Switch Control, etc.) is connected,
+and updates it whenever that state changes. You can override manually:
+
+ (setq ns-accessibility-enabled t) ; always on
+ (setq ns-accessibility-enabled nil) ; always off
+
+When nil, no AX tree is built and no notifications are posted,
+giving zero per-redisplay overhead.
+Requires the Cocoa (NS) build on macOS; ignored on GNUstep. */);
ns_accessibility_enabled = NO;
DEFVAR_BOOL ("ns-use-mwheel-acceleration",
-- --
2.43.0 2.43.0

View File

@@ -1,17 +1,22 @@
From 683d7497cc3414a231b44363dd28d2748780c38a Mon Sep 17 00:00:00 2001 From 274c545be1a3af3c7e6f416ac3a22e3b98626b0b Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 12:58:11 +0100 Date: Sat, 28 Feb 2026 12:58:11 +0100
Subject: [PATCH 6/8] doc: add VoiceOver accessibility section to macOS Subject: [PATCH 7/9] doc: add VoiceOver accessibility section to macOS
appendix appendix
* doc/emacs/macos.texi (VoiceOver Accessibility): New node. Document * doc/emacs/macos.texi (VoiceOver Accessibility): New node between
screen reader usage, keyboard navigation, completion announcements, 'Mac / GNUstep Events' and 'GNUstep Support'. Document screen reader
usage, keyboard navigation, completion announcements, ns-accessibility-
enabled, and known limitations. Use @xref for cross-reference at
sentence start. Correct description of ns-accessibility-enabled
default: initial value is nil, set automatically at startup.
--- ---
doc/emacs/macos.texi | 75 ++++++++++++++++++++++++++++++++++++++++++++ doc/emacs/macos.texi | 77 ++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 75 insertions(+) src/nsterm.m | 10 ++++--
2 files changed, 84 insertions(+), 3 deletions(-)
diff --git a/doc/emacs/macos.texi b/doc/emacs/macos.texi diff --git a/doc/emacs/macos.texi b/doc/emacs/macos.texi
index 6bd334f..4825cf9 100644 index 6bd334f48e..72ac3a9aa9 100644
--- a/doc/emacs/macos.texi --- a/doc/emacs/macos.texi
+++ b/doc/emacs/macos.texi +++ b/doc/emacs/macos.texi
@@ -36,6 +36,7 @@ Support}), but we hope to improve it in the future. @@ -36,6 +36,7 @@ Support}), but we hope to improve it in the future.
@@ -22,7 +27,7 @@ index 6bd334f..4825cf9 100644
* GNUstep Support:: Details on status of GNUstep support. * GNUstep Support:: Details on status of GNUstep support.
@end menu @end menu
@@ -272,6 +273,80 @@ and return the result as a string. You can also use the Lisp function @@ -272,6 +273,82 @@ 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 services and receive the results back. Note that you may need to
restart Emacs to access newly-available services. restart Emacs to access newly-available services.
@@ -70,8 +75,9 @@ index 6bd334f..4825cf9 100644
+@vindex ns-accessibility-enabled +@vindex ns-accessibility-enabled
+ To disable the accessibility interface entirely (for instance, to + To disable the accessibility interface entirely (for instance, to
+eliminate overhead on systems where assistive technology is not in +eliminate overhead on systems where assistive technology is not in
+use), set @code{ns-accessibility-enabled} to @code{nil}. The default +use), set @code{ns-accessibility-enabled} to @code{nil}. Emacs
+is @code{t}. +detects the presence of assistive technology at startup and sets this
+variable automatically; the initial value is @code{nil}.
+ +
+@subheading Known Limitations +@subheading Known Limitations
+ +
@@ -82,7 +88,7 @@ index 6bd334f..4825cf9 100644
+are fast. +are fast.
+@item +@item
+Mode-line text extraction handles only character glyphs. Mode lines +Mode-line text extraction handles only character glyphs. Mode lines
+using icon fonts (e.g., @code{doom-modeline} with nerd-font icons) +using icon fonts (e.g., icon-based mode-lines)
+produce incomplete accessibility text. +produce incomplete accessibility text.
+@item +@item
+The accessibility virtual element tree is rebuilt automatically on +The accessibility virtual element tree is rebuilt automatically on
@@ -91,18 +97,40 @@ index 6bd334f..4825cf9 100644
+Right-to-left (bidi) text is exposed correctly as buffer content, +Right-to-left (bidi) text is exposed correctly as buffer content,
+but @code{accessibilityRangeForPosition} hit-testing assumes +but @code{accessibilityRangeForPosition} hit-testing assumes
+left-to-right glyph layout. +left-to-right glyph layout.
+@item
+Block-style cursors are handled correctly: character navigation
+announces the character at the cursor position, not the character
+before it.
+@end itemize +@end itemize
+ +
+ This support is available only on the Cocoa build; GNUstep has a + This support is available only on the Cocoa build. GNUstep has a
+different accessibility model and is not yet supported +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 @node GNUstep Support
@section GNUstep Support @section GNUstep Support
diff --git a/src/nsterm.m b/src/nsterm.m
index c3cd83b774..e4e43dd7a3 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -14764,9 +14764,13 @@ Nil means use fullscreen the old (< 10.7) way. The old way works better with
DEFVAR_BOOL ("ns-accessibility-enabled", ns_accessibility_enabled,
doc: /* Non-nil enables Zoom cursor tracking and VoiceOver support.
-Emacs sets this automatically at startup when macOS Zoom is active or
-any assistive technology (VoiceOver, Switch Control, etc.) is connected,
-and updates it whenever that state changes. You can override manually:
+Emacs detects at startup whether macOS Zoom is active or an assistive
+technology (VoiceOver, Switch Control, etc.) is connected, and sets
+this variable accordingly. It updates automatically when accessibility
+state changes. The initial value is nil; it becomes non-nil only when
+an AT is detected.
+
+You can override the auto-detection:
(setq ns-accessibility-enabled t) ; always on
(setq ns-accessibility-enabled nil) ; always off
-- --
2.43.0 2.43.0

View File

@@ -1,58 +1,43 @@
From 8561d7b8a00b9a7772c718f86fdde770e1c73d41 Mon Sep 17 00:00:00 2001 From b87fb2b1824761fe3d91a27afe966eada39c1c45 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 14:46:25 +0100 Date: Mon, 2 Mar 2026 18:39:46 +0100
Subject: [PATCH 7/8] ns: announce overlay completion candidates for VoiceOver Subject: [PATCH 8/9] ns: announce overlay completion candidates for VoiceOver
Completion frameworks such as Vertico, Ivy, and Icomplete render Completion frameworks such as Vertico, Ivy, and Icomplete render
candidates via overlay before-string/after-string properties rather candidates via overlay before-string/after-string properties. Without
than buffer text. Without this patch, VoiceOver cannot read this change VoiceOver cannot read overlay-based completion UIs.
overlay-based completion UIs.
Identify the selected candidate by scanning overlay strings for a * src/nsterm.m (ns_ax_selected_overlay_text): New function; scan
face whose symbol name contains "current", "selected", or overlay strings in the window for a line with a selected face; return
"selection" --- this matches vertico-current, icomplete-selected-match, its text.
ivy-current-match, company-tooltip-selection, and similar framework (accessibilityStringForRange:, accessibilityAttributedStringForRange:)
faces without hard-coding any specific name. (accessibilityRangeForLine:): New NSAccessibility protocol methods.
Moved here from planned patch 0008 to keep the AX protocol interface
Key implementation details: complete before notification logic uses it.
(ensureTextCache): Switch cache-validity counter from BUF_CHARS_MODIFF
- The overlay detection branch runs independently (if, not else-if) to BUF_MODIFF. Fold/unfold commands (org-mode, outline-mode,
of the text-change branch, because Vertico bumps both BUF_MODIFF hideshow-mode) change the 'invisible text property via
(via text property changes in vertico--prompt-selection) and `put-text-property', which bumps BUF_MODIFF but not BUF_CHARS_MODIFF.
BUF_OVERLAY_MODIFF (via overlay-put) in the same command cycle. Using BUF_CHARS_MODIFF would serve stale AX text across fold/unfold.
The rebuild is O(visible-buffer-text) but ensureTextCache is called
- Use BUF_CHARS_MODIFF to gate ValueChanged notifications, since exclusively from AX getters at human interaction speed, never from the
text property changes bump BUF_MODIFF but not BUF_CHARS_MODIFF. redisplay notification path; font-lock passes cause zero rebuild cost.
(postAccessibilityNotificationsForFrame:): Handle BUF_OVERLAY_MODIFF
- Remove BUF_OVERLAY_MODIFF from ensureTextCache validity checks changes independently of text changes. Use BUF_CHARS_MODIFF to gate
to prevent a race condition where VoiceOver AX queries silently ValueChanged. Do not call ensureTextCache from the cursor-moved branch:
consume the overlay change before the notification dispatch runs. the granularity detection uses cachedText directly (falling back to
granularity_unknown when the cache is absent), so font-lock passes
- Announce via AnnouncementRequested to NSApp with High priority. cannot trigger O(buffer-size) rebuilds via the notification path.
Do not post SelectedTextChanged (that reads the AX text at cursor
position, which is the minibuffer input, not the candidate).
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).
(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.
(EmacsAccessibilityBuffer ensureTextCache): Remove overlay_modiff.
(EmacsAccessibilityBuffer postAccessibilityNotificationsForFrame:):
Independent overlay branch, BUF_CHARS_MODIFF gating, candidate
--- ---
src/nsterm.h | 1 + src/nsterm.h | 1 +
src/nsterm.m | 308 +++++++++++++++++++++++++++++++++++++++++++++------ src/nsterm.m | 384 ++++++++++++++++++++++++++++++++++++++++-----------
2 files changed, 277 insertions(+), 32 deletions(-) 2 files changed, 306 insertions(+), 79 deletions(-)
diff --git a/src/nsterm.h b/src/nsterm.h diff --git a/src/nsterm.h b/src/nsterm.h
index ec7b587..19a7e7a 100644 index 4bf79a9adb..72ca210bb0 100644
--- a/src/nsterm.h --- a/src/nsterm.h
+++ b/src/nsterm.h +++ b/src/nsterm.h
@@ -509,6 +509,7 @@ typedef struct ns_ax_visible_run @@ -510,6 +510,7 @@ typedef struct ns_ax_visible_run
@property (nonatomic, assign) ptrdiff_t cachedOverlayModiff; @property (nonatomic, assign) ptrdiff_t cachedOverlayModiff;
@property (nonatomic, assign) ptrdiff_t cachedTextStart; @property (nonatomic, assign) ptrdiff_t cachedTextStart;
@property (nonatomic, assign) ptrdiff_t cachedModiff; @property (nonatomic, assign) ptrdiff_t cachedModiff;
@@ -61,10 +46,10 @@ index ec7b587..19a7e7a 100644
@property (nonatomic, assign) BOOL cachedMarkActive; @property (nonatomic, assign) BOOL cachedMarkActive;
@property (nonatomic, copy) NSString *cachedCompletionAnnouncement; @property (nonatomic, copy) NSString *cachedCompletionAnnouncement;
diff --git a/src/nsterm.m b/src/nsterm.m diff --git a/src/nsterm.m b/src/nsterm.m
index f0e8751..72478e0 100644 index e4e43dd7a3..c9fe93a57b 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/src/nsterm.m +++ b/src/nsterm.m
@@ -6884,11 +6884,154 @@ Accessibility virtual elements (macOS / Cocoa only) @@ -7263,11 +7263,154 @@ Accessibility virtual elements (macOS / Cocoa only)
/* ---- Helper: extract buffer text for accessibility ---- */ /* ---- Helper: extract buffer text for accessibility ---- */
@@ -220,25 +205,57 @@ index f0e8751..72478e0 100644
static NSString * static NSString *
ns_ax_buffer_text (struct window *w, ptrdiff_t *out_start, ns_ax_buffer_text (struct window *w, ptrdiff_t *out_start,
ns_ax_visible_run **out_runs, NSUInteger *out_nruns) ns_ax_visible_run **out_runs, NSUInteger *out_nruns)
@@ -6959,7 +7102,7 @@ Accessibility virtual elements (macOS / Cocoa only) @@ -7343,7 +7486,7 @@ Accessibility virtual elements (macOS / Cocoa only)
/* Extract this visible run's text. Use /* Extract this visible run's text. Use
Fbuffer_substring_no_properties which correctly handles the 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
+ 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. */ include garbage bytes when the run spans the gap position. */
Lisp_Object lstr = Fbuffer_substring_no_properties ( Lisp_Object lstr = Fbuffer_substring_no_properties (
make_fixnum (pos), make_fixnum (run_end)); make_fixnum (pos), make_fixnum (run_end));
@@ -7040,7 +7183,7 @@ Mode lines using icon fonts (e.g. doom-modeline with nerd-font) @@ -7424,7 +7567,7 @@ Mode lines using icon fonts (e.g. nerd-font icons)
return NSZeroRect; return NSZeroRect;
/* charpos_start and charpos_len are already in buffer charpos /* 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
+ space --- the caller maps AX string indices through + space --- the caller maps AX string indices through
charposForAccessibilityIndex which handles invisible text. */ charposForAccessibilityIndex which handles invisible text. */
ptrdiff_t cp_start = charpos_start; ptrdiff_t cp_start = charpos_start;
ptrdiff_t cp_end = cp_start + charpos_len; ptrdiff_t cp_end = cp_start + charpos_len;
@@ -7519,6 +7662,7 @@ @implementation EmacsAccessibilityBuffer @@ -7606,31 +7749,6 @@ already on the main queue (e.g., inside postAccessibilityUpdates
freeing the main queue for VoiceOver's dispatch_sync calls. */
-/* Return true if FACE (a symbol or list of symbols) looks like a
- "selected item" 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: this runs only on overlay/child-frame
- strings during completion, never in a hot redisplay path. */
-static bool
-ns_ax_face_is_selected (Lisp_Object face)
-{
- if (SYMBOLP (face) && !NILP (face))
- {
- const char *name = SSDATA (SYMBOL_NAME (face));
- 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;
-}
-
static inline void
ns_ax_post_notification (id element,
NSAccessibilityNotificationName name)
{
@@ -7924,6 +8043,7 @@ @implementation EmacsAccessibilityBuffer
@synthesize cachedOverlayModiff; @synthesize cachedOverlayModiff;
@synthesize cachedTextStart; @synthesize cachedTextStart;
@synthesize cachedModiff; @synthesize cachedModiff;
@@ -246,174 +263,229 @@ index f0e8751..72478e0 100644
@synthesize cachedPoint; @synthesize cachedPoint;
@synthesize cachedMarkActive; @synthesize cachedMarkActive;
@synthesize cachedCompletionAnnouncement; @synthesize cachedCompletionAnnouncement;
@@ -7616,7 +7760,7 @@ - (void)ensureTextCache @@ -8021,7 +8141,7 @@ - (void)ensureTextCache
NSTRACE ("EmacsAccessibilityBuffer ensureTextCache"); NSTRACE ("EmacsAccessibilityBuffer ensureTextCache");
/* This method is only called from the main thread (AX getters /* This method is only called from the main thread (AX getters
dispatch_sync to main first). Reads of cachedText/cachedTextModiff 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
+ 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 write section at the end needs synchronization to protect
against concurrent reads from AX server thread. */ against concurrent reads from AX server thread. */
eassert ([NSThread isMainThread]); eassert ([NSThread isMainThread]);
@@ -7629,16 +7773,15 @@ - (void)ensureTextCache @@ -8033,25 +8153,38 @@ - (void)ensureTextCache
if (!b)
return; return;
ptrdiff_t modiff = BUF_MODIFF (b); - /* Use BUF_CHARS_MODIFF, not BUF_MODIFF, for cache validity.
- ptrdiff_t overlay_modiff = BUF_OVERLAY_MODIFF (b); - BUF_MODIFF is bumped by every text-property change, including
- font-lock face applications on every redisplay. AX text contains
- only characters, not face data, so property-only changes do not
- affect the cached value. Rebuilding the full buffer text on
- each font-lock pass is O(buffer-size) per redisplay --- this
- causes progressive slowdown when scrolling through large files.
- BUF_CHARS_MODIFF is bumped only on actual character insertions
- and deletions, matching the semantic of "did the text change".
- This is the pattern used by WebKit and NSTextView.
- Do NOT track BUF_OVERLAY_MODIFF here --- overlay text is not
- included in the cached AX text (it is handled separately via
- explicit announcements in postAccessibilityNotificationsForFrame).
- Including overlay_modiff would silently update cachedOverlayModiff
- and prevent the notification dispatch from detecting changes. */
- ptrdiff_t chars_modiff = BUF_CHARS_MODIFF (b);
+ /* Use BUF_MODIFF, not BUF_CHARS_MODIFF, for cache validity.
+
+ Fold/unfold commands (org-mode, outline-mode, hideshow-mode) change
+ text visibility by modifying the 'invisible text property via
+ `put-text-property' or `add-text-properties'. These bump BUF_MODIFF
+ but NOT BUF_CHARS_MODIFF, because no characters are inserted or
+ deleted. Using only BUF_CHARS_MODIFF would serve stale AX text
+ across fold/unfold: VoiceOver would continue reading hidden content
+ as if it were visible, or miss newly revealed content entirely.
+
+ BUF_MODIFF is bumped by all buffer modifications including
+ text-property changes (e.g. font-lock face assignments). The
+ per-rebuild cost is O(visible-buffer-text), but `ensureTextCache'
+ is called exclusively from AX getters (accessibilityValue,
+ accessibilitySelectedTextRange, etc.) which run at human interaction
+ speed --- not from the redisplay notification path. Font-lock
+ passes do not call this method, so the rebuild cost per font-lock
+ cycle is zero. The redisplay notification path (postAccessibility-
+ NotificationsForFrame:) uses cachedText directly without calling
+ ensureTextCache; granularity detection falls back gracefully when
+ the cache is absent.
+
+ Do NOT use BUF_OVERLAY_MODIFF alone: org-mode >= 29 (org-fold-core)
+ uses text properties, not overlays, for folding, so
+ BUF_OVERLAY_MODIFF would miss those changes. Additionally, modes
+ like hl-line-mode bump BUF_OVERLAY_MODIFF on every
+ post-command-hook, yielding the same per-keystroke rebuild cost as
+ BUF_MODIFF, with none of its correctness guarantee. */
+ ptrdiff_t modiff = BUF_MODIFF (b);
ptrdiff_t pt = BUF_PT (b); ptrdiff_t pt = BUF_PT (b);
NSUInteger textLen = cachedText ? [cachedText length] : 0; NSUInteger textLen = cachedText ? [cachedText length] : 0;
- /* Track both BUF_MODIFF and BUF_OVERLAY_MODIFF. Overlay-only - if (cachedText && cachedTextModiff == chars_modiff
- changes (e.g., timer-based completion highlight move without + if (cachedText && cachedTextModiff == modiff
- 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) && cachedTextStart == BUF_BEGV (b)
&& pt >= cachedTextStart && pt >= cachedTextStart
&& (textLen == 0 && (textLen == 0
@@ -7655,7 +7798,6 @@ - (void)ensureTextCache @@ -8067,7 +8200,7 @@ included in the cached AX text (it is handled separately via
{
[cachedText release]; [cachedText release];
cachedText = [text retain]; cachedText = [text retain];
cachedTextModiff = modiff; - cachedTextModiff = chars_modiff;
- cachedOverlayModiff = overlay_modiff; + cachedTextModiff = modiff;
cachedTextStart = start; cachedTextStart = start;
if (visibleRuns) if (visibleRuns)
@@ -7720,7 +7862,7 @@ - (NSUInteger)accessibilityIndexForCharpos:(ptrdiff_t)charpos @@ -8079,9 +8212,9 @@ included in the cached AX text (it is handled separately via
Walk the cached text once, recording the start offset of each
line. Uses NSString lineRangeForRange: --- O(N) in the total
text --- but this loop runs only on cache rebuild, which is
- gated on BUF_CHARS_MODIFF: actual character insertions or
- deletions. Font-lock (text property changes) does not trigger
- a rebuild, so the hot path (cursor movement, redisplay) never
+ gated on BUF_MODIFF changes. Rebuilds happen when any buffer
+ modification occurs (including fold/unfold), ensuring the line
+ index always matches the currently visible text.
enters this code. */
if (lineStartOffsets)
xfree (lineStartOffsets);
@@ -8136,7 +8269,7 @@ - (NSUInteger)accessibilityIndexForCharpos:(ptrdiff_t)charpos
/* Binary search: runs are sorted by charpos (ascending). Find the /* Binary search: runs are sorted by charpos (ascending). Find the
run whose [charpos, charpos+length) range contains the target, run whose [charpos, charpos+length) range contains the target,
or the nearest run after an invisible gap. O(log n) instead of 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. */
+ 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; NSUInteger lo = 0, hi = visibleRunCount;
while (lo < hi) while (lo < hi)
{ {
@@ -7733,7 +7875,7 @@ - (NSUInteger)accessibilityIndexForCharpos:(ptrdiff_t)charpos @@ -8185,10 +8318,10 @@ by run length (visible window), not total buffer size. */
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;
@@ -7758,10 +7900,10 @@ - (NSUInteger)accessibilityIndexForCharpos:(ptrdiff_t)charpos
/* Convert accessibility string index to buffer charpos. /* Convert accessibility string index to buffer charpos.
Safe to call from any thread: uses only cachedText (NSString) and Safe to call from any thread: uses only cachedText (NSString) and
- visibleRuns no Lisp calls. */ - visibleRuns --- no Lisp calls. */
+ visibleRuns --- no Lisp calls. */ + visibleRuns --- no Lisp calls. */
- (ptrdiff_t)charposForAccessibilityIndex:(NSUInteger)ax_idx - (ptrdiff_t)charposForAccessibilityIndex:(NSUInteger)ax_idx
{ {
- /* May be called from AX server thread synchronize. */ - /* May be called from AX server thread --- synchronize. */
+ /* May be called from AX server thread --- synchronize. */ + /* May be called from AX server thread --- synchronize. */
@synchronized (self) @synchronized (self)
{ {
if (visibleRunCount == 0) if (visibleRunCount == 0)
@@ -7795,7 +7937,7 @@ - (ptrdiff_t)charposForAccessibilityIndex:(NSUInteger)ax_idx @@ -8230,7 +8363,7 @@ the slow path (composed character sequence walk), which is
return cp; return cp;
} }
} }
- /* Past end return last charpos. */ - /* Past end --- return last charpos. */
+ /* Past end --- return last charpos. */ + /* Past end --- return last charpos. */
if (lo > 0) if (lo > 0)
{ {
ns_ax_visible_run *last = &visibleRuns[visibleRunCount - 1]; ns_ax_visible_run *last = &visibleRuns[visibleRunCount - 1];
@@ -7817,7 +7959,7 @@ - (ptrdiff_t)charposForAccessibilityIndex:(NSUInteger)ax_idx @@ -8252,7 +8385,7 @@ the slow path (composed character sequence walk), which is
deadlocking the AX server thread. This is prevented by: deadlocking the AX server thread. This is prevented by:
1. validWindow checks WINDOW_LIVE_P and BUFFERP before every 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.
+ 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 2. All dispatch_sync blocks run on the main thread where no
concurrent Lisp code can modify state between checks. concurrent Lisp code can modify state between checks.
3. block_input prevents timer events and process output from 3. block_input prevents timer events and process output from
@@ -8171,6 +8313,50 @@ - (NSInteger)accessibilityInsertionPointLineNumber @@ -8597,26 +8730,26 @@ - (NSInteger)accessibilityInsertionPointLineNumber
return [self lineForAXIndex:point_idx]; return [self lineForAXIndex:point_idx];
} }
-- (NSRange)accessibilityRangeForLine:(NSInteger)line
+- (NSString *)accessibilityStringForRange:(NSRange)range +- (NSString *)accessibilityStringForRange:(NSRange)range
+{ {
+ if (![NSThread isMainThread]) if (![NSThread isMainThread])
+ { {
- __block NSRange result;
+ __block NSString *result; + __block NSString *result;
+ dispatch_sync (dispatch_get_main_queue (), ^{ dispatch_sync (dispatch_get_main_queue (), ^{
- result = [self accessibilityRangeForLine:line];
+ result = [self accessibilityStringForRange:range]; + result = [self accessibilityStringForRange:range];
+ }); });
+ return result; return result;
+ } }
+ [self ensureTextCache]; [self ensureTextCache];
- if (!cachedText || line < 0)
- return NSMakeRange (NSNotFound, 0);
-
- NSUInteger len = [cachedText length];
- if (len == 0)
- return (line == 0) ? NSMakeRange (0, 0)
- : NSMakeRange (NSNotFound, 0);
+ if (!cachedText || range.location + range.length > [cachedText length]) + if (!cachedText || range.location + range.length > [cachedText length])
+ return @""; + return @"";
+ return [cachedText substringWithRange:range]; + return [cachedText substringWithRange:range];
+} +}
+
- return [self rangeForLine:(NSUInteger)line textLength:len];
+- (NSAttributedString *)accessibilityAttributedStringForRange:(NSRange)range +- (NSAttributedString *)accessibilityAttributedStringForRange:(NSRange)range
+{ +{
+ NSString *str = [self accessibilityStringForRange:range]; + NSString *str = [self accessibilityStringForRange:range];
+ return [[[NSAttributedString alloc] initWithString:str] autorelease]; + return [[[NSAttributedString alloc] initWithString:str] autorelease];
}
- (NSInteger)accessibilityLineForIndex:(NSInteger)index
@@ -8638,6 +8771,29 @@ - (NSInteger)accessibilityLineForIndex:(NSInteger)index
idx = [cachedText length];
return [self lineForAXIndex:idx];
+
+} +}
+ +
+- (NSInteger)accessibilityLineForIndex:(NSInteger)index +- (NSRange)accessibilityRangeForLine:(NSInteger)line
+{ +{
+ if (![NSThread isMainThread]) + if (![NSThread isMainThread])
+ { + {
+ __block NSInteger result; + __block NSRange result;
+ dispatch_sync (dispatch_get_main_queue (), ^{ + dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityLineForIndex:index]; + result = [self accessibilityRangeForLine:line];
+ }); + });
+ return result; + return result;
+ } + }
+ [self ensureTextCache]; + [self ensureTextCache];
+ if (!cachedText || index < 0) + if (!cachedText || line < 0)
+ return 0; + return NSMakeRange (NSNotFound, 0);
+ +
+ NSUInteger idx = (NSUInteger) index; + NSUInteger len = [cachedText length];
+ if (idx > [cachedText length]) + if (len == 0)
+ idx = [cachedText length]; + return (line == 0) ? NSMakeRange (0, 0)
+ : NSMakeRange (NSNotFound, 0);
+ +
+ return [self lineForAXIndex:idx]; + return [self rangeForLine:(NSUInteger)line textLength:len];
+ }
+}
+ - (NSRange)accessibilityRangeForIndex:(NSInteger)index
- (NSRange)accessibilityRangeForLine:(NSInteger)line @@ -8840,7 +8996,7 @@ - (NSRect)accessibilityFrame
{
if (![NSThread isMainThread])
@@ -8392,7 +8578,7 @@ - (NSRect)accessibilityFrame
/* =================================================================== /* ===================================================================
- EmacsAccessibilityBuffer (Notifications) AX event dispatch - EmacsAccessibilityBuffer (Notifications) --- AX event dispatch
+ EmacsAccessibilityBuffer (Notifications) --- AX event dispatch + EmacsAccessibilityBuffer (Notifications) --- AX event dispatch
These methods notify VoiceOver of text and selection changes. These methods notify VoiceOver of text and selection changes.
Called from the redisplay cycle (postAccessibilityUpdates). Called from the redisplay cycle (postAccessibilityUpdates).
@@ -8407,7 +8593,7 @@ - (void)postTextChangedNotification:(ptrdiff_t)point @@ -8855,7 +9011,7 @@ - (void)postTextChangedNotification:(ptrdiff_t)point
if (point > self.cachedPoint if (point > self.cachedPoint
&& point - self.cachedPoint == 1) && point - self.cachedPoint == 1)
{ {
- /* Single char inserted refresh cache and grab it. */ - /* Single char inserted --- refresh cache and grab it. */
+ /* Single char inserted --- refresh cache and grab it. */ + /* Single char inserted --- refresh cache and grab it. */
[self invalidateTextCache]; [self invalidateTextCache];
[self ensureTextCache]; [self ensureTextCache];
if (cachedText) if (cachedText)
@@ -8426,7 +8612,7 @@ - (void)postTextChangedNotification:(ptrdiff_t)point @@ -8874,7 +9030,7 @@ - (void)postTextChangedNotification:(ptrdiff_t)point
/* Update cachedPoint here so the selection-move branch does NOT /* Update cachedPoint here so the selection-move branch does NOT
fire for point changes caused by edits. WebKit and Chromium fire for point changes caused by edits. WebKit and Chromium
never send both ValueChanged and SelectedTextChanged for the never send both ValueChanged and SelectedTextChanged for the
- same user action they are mutually exclusive. */ - same user action --- they are mutually exclusive. */
+ same user action --- they are mutually exclusive. */ + same user action --- they are mutually exclusive. */
self.cachedPoint = point; self.cachedPoint = point;
NSDictionary *change = @{ NSDictionary *change = @{
@@ -8759,14 +8945,72 @@ - (void)postAccessibilityNotificationsForFrame:(struct frame *)f @@ -9268,16 +9424,80 @@ - (void)postAccessibilityNotificationsForFrame:(struct frame *)f
BOOL markActive = !NILP (BVAR (b, mark_active)); BOOL markActive = !NILP (BVAR (b, mark_active));
/* --- Text changed (edit) --- */ /* --- Text changed (edit) --- */
@@ -450,118 +522,108 @@ index f0e8751..72478e0 100644
+ { + {
+ self.cachedOverlayModiff = BUF_OVERLAY_MODIFF (b); + self.cachedOverlayModiff = BUF_OVERLAY_MODIFF (b);
+ +
+ int selected_line = -1; + /* Overlay completion candidates (Vertico, Icomplete, Ivy) are
+ NSString *candidate + displayed in the minibuffer. In normal editing buffers,
+ = ns_ax_selected_overlay_text (b, BUF_BEGV (b), BUF_ZV (b), + font-lock and other modes change BUF_OVERLAY_MODIFF on
+ &selected_line); + every redisplay, triggering O(overlays) work per keystroke.
+ if (candidate) + Restrict the scan to minibuffer windows. */
+ if (MINI_WINDOW_P (w))
+ { + {
+ /* Deduplicate: only announce when the candidate changed. */ + int selected_line = -1;
+ if (![candidate isEqualToString: + NSString *candidate
+ self.cachedCompletionAnnouncement]) + = ns_ax_selected_overlay_text (b, BUF_BEGV (b), BUF_ZV (b),
+ &selected_line);
+ if (candidate)
+ { + {
+ self.cachedCompletionAnnouncement = candidate; + /* Deduplicate: only announce when the candidate changed. */
+ + if (![candidate isEqualToString:
+ /* Announce the candidate text directly via NSApp. + self.cachedCompletionAnnouncement])
+ Do NOT post SelectedTextChanged --- that would cause + {
+ VoiceOver to read the AX text at the cursor position + self.cachedCompletionAnnouncement = candidate;
+ (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);
+ +
+ /* 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);
+ }
+ } + }
+ } + }
} }
/* --- Cursor moved or selection changed --- /* --- 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
+ Use 'else if' --- edits and selection moves are mutually exclusive - per the WebKit/Chromium pattern. */
per the WebKit/Chromium pattern. */ - else if (point != self.cachedPoint || markActive != self.cachedMarkActive)
else if (point != self.cachedPoint || markActive != self.cachedMarkActive) + Independent check from the overlay branch above. */
+ if (point != self.cachedPoint || markActive != self.cachedMarkActive)
{ {
@@ -8936,7 +9180,7 @@ - (NSRect)accessibilityFrame ptrdiff_t oldPoint = self.cachedPoint;
BOOL oldMarkActive = self.cachedMarkActive;
@@ -9295,8 +9515,14 @@ - (void)postAccessibilityNotificationsForFrame:(struct frame *)f
bool isCtrlNP = ns_ax_event_is_line_nav_key (&ctrlNP);
/* --- Granularity detection --- */
/* =================================================================== + /* Use cached text as-is; do NOT call ensureTextCache here.
- EmacsAccessibilityInteractiveSpan — helpers and implementation + ensureTextCache is O(visible-buffer-text) and must not run on
+ EmacsAccessibilityInteractiveSpan --- helpers and implementation + every redisplay cycle. Using stale cached text for granularity
=================================================================== */ + classification is safe: the worst case is an incorrect
+ granularity hint (defaulting to unknown), which causes VoiceOver
/* Scan visible range of window W for interactive spans. + to make its own determination. Fresh text is always available
@@ -9127,7 +9371,7 @@ - (NSRect) accessibilityFrame + to VoiceOver via the AX getter path (accessibilityValue etc.). */
- (BOOL) isAccessibilityFocused NSInteger granularity = ns_ax_text_selection_granularity_unknown;
{ - [self ensureTextCache];
/* Read the cached point stored by EmacsAccessibilityBuffer on the main if (cachedText && oldPoint > 0)
- 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). */ NSUInteger tlen = [cachedText length];
EmacsAccessibilityBuffer *pb = self.parentBuffer; @@ -12457,7 +12683,7 @@ - (int) fullscreenState
if (!pb)
return NO;
@@ -9144,7 +9388,7 @@ - (void) setAccessibilityFocused: (BOOL) focused
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)))
@@ -9170,7 +9414,7 @@ - (void) setAccessibilityFocused: (BOOL) focused
@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)
@@ -11892,7 +12136,7 @@ - (int) fullscreenState
if (WINDOW_LEAF_P (w)) if (WINDOW_LEAF_P (w))
{ {
- /* Buffer element reuse existing if available. */ - /* Buffer element --- reuse existing if available. */
+ /* Buffer element --- reuse existing if available. */ + /* Buffer element --- reuse existing if available. */
EmacsAccessibilityBuffer *elem EmacsAccessibilityBuffer *elem
= [existing objectForKey:[NSValue valueWithPointer:w]]; = [existing objectForKey:[NSValue valueWithPointer:w]];
if (!elem) if (!elem)
@@ -11926,7 +12170,7 @@ - (int) fullscreenState @@ -12491,7 +12717,7 @@ - (int) fullscreenState
} }
else else
{ {
- /* Internal (combination) window recurse into children. */ - /* Internal (combination) window --- recurse into children. */
+ /* Internal (combination) window --- recurse into children. */ + /* Internal (combination) window --- recurse into children. */
Lisp_Object child = w->contents; Lisp_Object child = w->contents;
while (!NILP (child)) while (!NILP (child))
{ {
@@ -12038,7 +12282,7 @@ - (void)postAccessibilityUpdates @@ -12603,7 +12829,7 @@ - (void)postAccessibilityUpdates
accessibilityUpdating = YES; accessibilityUpdating = YES;
/* Detect window tree change (split, delete, new buffer). Compare /* 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. */
+ 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); Lisp_Object curRoot = FRAME_ROOT_WINDOW (emacsframe);
if (!EQ (curRoot, lastRootWindow)) if (!EQ (curRoot, lastRootWindow))
{ {
@@ -12047,12 +12291,12 @@ - (void)postAccessibilityUpdates @@ -12612,12 +12838,12 @@ - (void)postAccessibilityUpdates
} }
/* If tree is stale, rebuild FIRST so we don't iterate freed /* 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
+ 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. */ freshly-built elements have no previous state to diff against. */
if (!accessibilityTreeValid) if (!accessibilityTreeValid)
{ {
[self rebuildAccessibilityTree]; [self rebuildAccessibilityTree];
- /* Invalidate span cache window layout changed. */ - /* Invalidate span cache --- window layout changed. */
+ /* Invalidate span cache --- window layout changed. */ + /* Invalidate span cache --- window layout changed. */
for (EmacsAccessibilityElement *elem in accessibilityElements) for (EmacsAccessibilityElement *elem in accessibilityElements)
if ([elem isKindOfClass: [EmacsAccessibilityBuffer class]]) if ([elem isKindOfClass: [EmacsAccessibilityBuffer class]])

View File

@@ -1,64 +1,50 @@
From 058cc9aad5c34796206749844df28acc9e09f0eb Mon Sep 17 00:00:00 2001 From 5bef7fa553d0dfd9ab933d341a8115d42e026b42 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 16:01:29 +0100 Date: Mon, 2 Mar 2026 18:49:13 +0100
Subject: [PATCH 8/8] ns: announce child frame completion candidates for Subject: [PATCH 9/9] ns: announce child frame completion candidates for
VoiceOver VoiceOver
Completion frameworks such as Corfu, Company-box, and similar Child frame popups (Corfu, Company-mode child frames) render completion
render candidates in a child frame rather than as overlay strings candidates in a separate frame whose buffer is not accessible via the
in the minibuffer. This patch extends the overlay announcement minibuffer overlay path. This patch scans child frame buffers for
support (patch 7/8) to handle child frame popups. selected candidates and announces them via VoiceOver.
Detect child frames via FRAME_PARENT_FRAME in postAccessibilityUpdates. * src/nsterm.h (EmacsView): Add childFrameLastBuffer, childFrameLastModiff,
Scan the child frame buffer text line by line using Fget_char_property childFrameLastCandidate, childFrameCompletionActive, lastEchoCharsModiff
(which checks both text properties and overlay face properties) to ivars. Initialize childFrameLastBuffer to Qnil in initFrameFromEmacs:.
find the selected candidate. Reuse ns_ax_face_is_selected from (EmacsAccessibilityBuffer): Add voiceoverSetPoint ivar.
the overlay patch to identify "current", "selected", and * src/nsterm.m (ns_ax_selected_child_frame_text): New function; scans
"selection" faces. child frame buffer text for the selected completion candidate.
(announceChildFrameCompletion): New method; scans child frame buffers
Safety: for selected completion candidates. Store childFrameLastBuffer as
- record_unwind_current_buffer / set_buffer_internal_1 to switch to BVAR(b, name) (buffer name symbol, GC-reachable via obarray) rather
the child frame buffer for Fbuffer_substring_no_properties. than a raw buffer pointer to avoid a dangling pointer after buffer kill.
- Re-entrance guard (accessibilityUpdating) before child frame dispatch. (postEchoAreaAnnouncementIfNeeded): New method; announces echo area
- BUF_MODIFF gating prevents redundant scans. changes (e.g., "Wrote file", "Quit") for commands that produce output
- WINDOWP, BUFFERP validation for partially initialized frames. while the minibuffer is inactive.
- Buffer size limit (10000 chars) skips non-completion child frames. (postAccessibilityNotificationsForFrame:): Drive child frame and echo
area announcements. Add voiceoverSetPoint flag and singleLineMove
When the child frame closes, post FocusedUIElementChangedNotification adjacency detection to distinguish VoiceOver-initiated cursor moves
on the parent buffer element to restore VoiceOver's character echo from Emacs-initiated moves; sequential adjacent-line moves use
and cursor tracking. The flag childFrameCompletionActive is set by next/previous direction, teleports use discontiguous. Add didTextChange
the child frame handler and cleared on the parent's next accessibility guard to suppress overlay completion announcements while the user types.
cycle when no child frame is visible (via FOR_EACH_FRAME). (setAccessibilitySelectedTextRange:): Set voiceoverSetPoint so that the
subsequent notification cycle uses sequential direction.
Announce via AnnouncementRequested to NSApp with High priority. * doc/emacs/macos.texi (VoiceOver Accessibility): Update to document
independently --- its ns_update_end runs after the parent's echo area announcements and VoiceOver rotor cursor synchronization.
Remove Zoom section (covered by patch 0000). Fix dangling paragraph.
* 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.
--- ---
doc/emacs/macos.texi | 6 -- doc/emacs/macos.texi | 13 +-
etc/NEWS | 4 +- etc/NEWS | 25 +-
src/nsterm.h | 5 + src/nsterm.h | 21 ++
src/nsterm.m | 227 ++++++++++++++++++++++++++++++++++++++++++- src/nsterm.m | 577 +++++++++++++++++++++++++++++++++++++------
4 files changed, 233 insertions(+), 9 deletions(-) 4 files changed, 541 insertions(+), 95 deletions(-)
diff --git a/doc/emacs/macos.texi b/doc/emacs/macos.texi diff --git a/doc/emacs/macos.texi b/doc/emacs/macos.texi
index 4825cf9..97777e2 100644 index 72ac3a9aa9..cf5ed0ff28 100644
--- a/doc/emacs/macos.texi --- a/doc/emacs/macos.texi
+++ b/doc/emacs/macos.texi +++ b/doc/emacs/macos.texi
@@ -278,7 +278,6 @@ restart Emacs to access newly-available services. @@ -309,10 +309,15 @@ Shift-modified movement announces selected or deselected text.
@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
@@ -309,11 +308,6 @@ Shift-modified movement announces selected or deselected text.
The @file{*Completions*} buffer announces each completion candidate The @file{*Completions*} buffer announces each completion candidate
as you navigate, even while keyboard focus remains in the minibuffer. as you navigate, even while keyboard focus remains in the minibuffer.
@@ -66,53 +52,147 @@ index 4825cf9..97777e2 100644
-cursor automatically when set to follow keyboard focus. The cursor -cursor automatically when set to follow keyboard focus. The cursor
-position is communicated via @code{UAZoomChangeFocus} and the -position is communicated via @code{UAZoomChangeFocus} and the
-@code{AXBoundsForRange} accessibility attribute. -@code{AXBoundsForRange} accessibility attribute.
- + Echo area messages are announced automatically. When a background
+operation completes and displays a message (e.g., @samp{Git finished},
+@samp{Wrote file}), VoiceOver reads it without requiring any action.
+Messages are suppressed while the minibuffer is active (i.e., while
+you are typing a command) to avoid interrupting prompt reading.
+
+ VoiceOver's rotor browse cursor stays synchronized with the Emacs
+cursor after large programmatic jumps (for example, heading navigation
+in Org mode, @code{xref-find-definitions}, or @code{imenu}).
@vindex ns-accessibility-enabled @vindex ns-accessibility-enabled
To disable the accessibility interface entirely (for instance, to To disable the accessibility interface entirely (for instance, to
eliminate overhead on systems where assistive technology is not in
diff --git a/etc/NEWS b/etc/NEWS diff --git a/etc/NEWS b/etc/NEWS
index e76ee93..c3e0b40 100644 index 7f917f93b2..bbec21b635 100644
--- a/etc/NEWS --- a/etc/NEWS
+++ b/etc/NEWS +++ b/etc/NEWS
@@ -4393,8 +4393,8 @@ send user data to Apple's speech recognition servers. @@ -88,10 +88,9 @@ When macOS Zoom is enabled (System Settings, Accessibility, Zoom,
Follow keyboard focus), Emacs informs Zoom of the text cursor position
after every cursor redraw via 'UAZoomChangeFocus'. The zoomed viewport
automatically tracks the insertion point across window splits and
-switches. Completion frameworks (Vertico, Icomplete, Ivy for overlay
-candidates; Corfu, Company-box for child frame popups) are also
-tracked: Zoom follows the selected candidate rather than the text
-cursor during completion.
+switches. Overlay-based completion frameworks and child-frame popup completions
+are also tracked: Zoom follows the selected candidate rather than the
+text cursor during completion.
+++
** 'line-spacing' now supports specifying spacing above the line.
@@ -4385,16 +4384,20 @@ 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. ** VoiceOver accessibility support on macOS.
Emacs now exposes buffer content, cursor position, and interactive Emacs now exposes buffer content, cursor position, and interactive
elements to the macOS accessibility subsystem (VoiceOver). This elements to the macOS accessibility subsystem (VoiceOver). This
-includes AXBoundsForRange for macOS Zoom cursor tracking, line and -includes AXBoundsForRange for macOS Zoom cursor tracking, line and
-word navigation announcements, Tab-navigable interactive spans -word navigation announcements, Tab-navigable interactive spans
+includes line and word navigation announcements, Tab-navigable -(buttons, links, completion candidates), and completion announcements
+interactive spans -for the *Completions* buffer. The implementation uses a virtual
(buttons, links, completion candidates), and completion announcements -accessibility tree with per-window elements, hybrid SelectedTextChanged
for the *Completions* buffer. The implementation uses a virtual -and AnnouncementRequested notifications, and thread-safe text caching.
accessibility tree with per-window elements, hybrid SelectedTextChanged +includes:
+- Line and word navigation announcements via standard movement keys.
+- Echo area messages (e.g., "Wrote file", "Git finished") announced
+ automatically as they appear, without user interaction.
+- VoiceOver rotor cursor synchronization after large programmatic
+ jumps (]], M-<, xref, imenu, etc.).
+- Tab-navigable interactive spans (buttons, links, completion
+ candidates) within a buffer.
+- Completion announcements for the *Completions* buffer, overlay
+ completion UIs, and child-frame completion popup UIs.
Set 'ns-accessibility-enabled' to nil to disable the accessibility
interface and eliminate the associated overhead.
diff --git a/src/nsterm.h b/src/nsterm.h diff --git a/src/nsterm.h b/src/nsterm.h
index 19a7e7a..49e8f00 100644 index 72ca210bb0..1c79c8aced 100644
--- a/src/nsterm.h --- a/src/nsterm.h
+++ b/src/nsterm.h +++ b/src/nsterm.h
@@ -596,6 +596,10 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType) @@ -504,9 +504,20 @@ typedef struct ns_ax_visible_run
NSUInteger lineCount; /* Entries in lineStartOffsets. */
NSMutableArray *cachedInteractiveSpans;
BOOL interactiveSpansDirty;
+ /* Set to YES in setAccessibilitySelectedTextRange: (VoiceOver moved
+ the cursor); reset to NO in postAccessibilityNotificationsForFrame:.
+ When YES, cursor notifications use sequential direction so VoiceOver
+ continues smooth line/character navigation without re-anchoring.
+ When NO, Emacs moved the cursor independently; use discontiguous
+ direction so VoiceOver re-anchors its browse cursor to the new
+ accessibilitySelectedTextRange. */
+ BOOL voiceoverSetPoint;
}
@property (nonatomic, retain) NSString *cachedText;
@property (nonatomic, assign) ptrdiff_t cachedTextModiff;
+/* Overlay modiff at last text cache rebuild. Tracked separately from
+ cachedOverlayModiff (which is used for completion announcements) so
+ that fold/unfold detection is independent of notification dispatch. */
@property (nonatomic, assign) ptrdiff_t cachedOverlayModiff;
@property (nonatomic, assign) ptrdiff_t cachedTextStart;
@property (nonatomic, assign) ptrdiff_t cachedModiff;
@@ -601,6 +612,14 @@ typedef NS_ENUM(NSInteger, EmacsAXSpanType)
Lisp_Object lastRootWindow;
BOOL accessibilityTreeValid;
BOOL accessibilityUpdating; BOOL accessibilityUpdating;
@public /* Accessed by ns_draw_phys_cursor (C function). */
NSRect lastCursorRect;
+ BOOL childFrameCompletionActive; + BOOL childFrameCompletionActive;
+ char *childFrameLastCandidate; + NSString *childFrameLastCandidate;
+ struct buffer *childFrameLastBuffer; + Lisp_Object childFrameLastBuffer;
+ EMACS_INT childFrameLastModiff; + EMACS_INT childFrameLastModiff;
+ /* Last BUF_CHARS_MODIFF seen for echo_area_buffer[0]. Used by
+ postEchoAreaAnnouncementIfNeeded to detect new echo area messages
+ independently of the per-element notification cycle. */
+ ptrdiff_t lastEchoCharsModiff;
#endif #endif
BOOL font_panel_active; BOOL font_panel_active;
NSFont *font_panel_result; NSFont *font_panel_result;
@@ -659,6 +663,7 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType) @@ -670,6 +689,8 @@ typedef NS_ENUM(NSInteger, EmacsAXSpanType)
- (void)rebuildAccessibilityTree; - (void)rebuildAccessibilityTree;
- (void)invalidateAccessibilityTree; - (void)invalidateAccessibilityTree;
- (void)postAccessibilityUpdates; - (void)postAccessibilityUpdates;
+- (void)postEchoAreaAnnouncementIfNeeded;
+- (void)announceChildFrameCompletion; +- (void)announceChildFrameCompletion;
#endif #endif
@end @end
diff --git a/src/nsterm.m b/src/nsterm.m diff --git a/src/nsterm.m b/src/nsterm.m
index 72478e0..daa8f61 100644 index c9fe93a57b..f7574efb39 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/src/nsterm.m +++ b/src/nsterm.m
@@ -7028,6 +7028,112 @@ visual line index for Zoom (skip whitespace-only lines @@ -1275,6 +1275,12 @@ If a completion candidate is selected (overlay or child frame),
static void
ns_zoom_track_completion (struct frame *f, EmacsView *view)
{
+ /* Zoom cursor tracking is controlled exclusively by
+ ns_zoom_enabled_p (). We do NOT gate on ns_accessibility_enabled:
+ users can run Zoom without VoiceOver, and those users should still
+ get completion-candidate tracking. ns_accessibility_enabled is
+ only set when a screen reader (VoiceOver or similar) activates the
+ AX layer; it has no bearing on the Zoom feature. */
if (!ns_zoom_enabled_p ())
return;
if (!WINDOWP (f->selected_window))
@@ -1417,9 +1423,14 @@ so the visual offset is (ov_line + 1) * line_h from
/* Track completion candidates for Zoom (overlay and child frame).
Runs after cursor tracking so the selected candidate overrides
- the default cursor position. */
+ the default cursor position. Guard with the same version check
+ as ns_zoom_track_completion's callee (UAZoomChangeFocus requires
+ macOS 10.10+). */
+#if defined (MAC_OS_X_VERSION_MIN_REQUIRED) \
+ && MAC_OS_X_VERSION_MIN_REQUIRED >= 101000
if (view)
ns_zoom_track_completion (f, view);
+#endif /* MAC_OS_X_VERSION_MIN_REQUIRED >= 101000 */
#endif /* NS_IMPL_COCOA */
/* Post accessibility notifications after each redisplay cycle. */
@@ -7407,6 +7418,119 @@ visual line index for Zoom (skip whitespace-only lines
return nil; return nil;
} }
@@ -159,12 +239,19 @@ index 72478e0..daa8f61 100644
+ The data pointer is used only in this loop, before Lisp calls. */ + The data pointer is used only in this loop, before Lisp calls. */
+ const unsigned char *data = SDATA (str); + const unsigned char *data = SDATA (str);
+ ptrdiff_t byte_len = SBYTES (str); + ptrdiff_t byte_len = SBYTES (str);
+ ptrdiff_t line_starts[128]; + /* 128 lines is a safe upper bound for a completion child frame.
+ ptrdiff_t line_ends[128]; + The caller rejects buffers larger than 10,000 characters
+ (BUF_ZV(b) - BUF_BEGV(b) > 10000 guard in announceChildFrameCompletion),
+ so at most ~10,000 / 1-byte-per-line = 10,000 lines could appear,
+ but completion popups are typically < 512 lines. Use 512 to match
+ the bound in ns_ax_selected_overlay_text; lines beyond 512 are
+ silently skipped. */
+ ptrdiff_t line_starts[512];
+ ptrdiff_t line_ends[512];
+ int nlines = 0; + int nlines = 0;
+ ptrdiff_t char_pos = 0, byte_pos = 0, lstart = 0; + ptrdiff_t char_pos = 0, byte_pos = 0, lstart = 0;
+ +
+ while (byte_pos < byte_len && nlines < 128) + while (byte_pos < byte_len && nlines < 512)
+ { + {
+ if (data[byte_pos] == '\n') + if (data[byte_pos] == '\n')
+ { + {
@@ -182,7 +269,7 @@ index 72478e0..daa8f61 100644
+ byte_pos++; + byte_pos++;
+ char_pos++; + char_pos++;
+ } + }
+ if (char_pos > lstart && nlines < 128) + if (char_pos > lstart && nlines < 512)
+ { + {
+ line_starts[nlines] = lstart; + line_starts[nlines] = lstart;
+ line_ends[nlines] = char_pos; + line_ends[nlines] = char_pos;
@@ -225,11 +312,439 @@ index 72478e0..daa8f61 100644
/* Build accessibility text for window W, skipping invisible text. /* Build accessibility text for window W, skipping invisible text.
Populates *OUT_START with the buffer start charpos. Populates *OUT_START with the buffer start charpos.
Populates *OUT_RUNS with an array of visible runs and *OUT_NRUNS Populates *OUT_RUNS with an array of visible runs and *OUT_NRUNS
@@ -12266,6 +12372,77 @@ - (id)accessibilityFocusedUIElement @@ -7440,11 +7564,12 @@ visual line index for Zoom (skip whitespace-only lines
return @"";
specpdl_ref count = SPECPDL_INDEX ();
- record_unwind_current_buffer ();
- /* block_input must come before record_unwind_protect_void (unblock_input):
- if specpdl_push were to fail after registration, the unwind handler
- would call unblock_input without a matching block_input. */
+ /* block_input must precede record_unwind_protect_void (unblock_input):
+ if anything between SPECPDL_INDEX and block_input were to throw,
+ the unwind handler would call unblock_input without a matching
+ block_input, corrupting the input-blocking reference count. */
block_input ();
+ record_unwind_current_buffer ();
record_unwind_protect_void (unblock_input);
if (b != current_buffer)
set_buffer_internal_1 (b);
@@ -8613,6 +8738,11 @@ - (void)setAccessibilitySelectedTextRange:(NSRange)range
[self ensureTextCache];
+ /* Record that VoiceOver (not Emacs) is moving the cursor so that the
+ subsequent postAccessibilityNotificationsForFrame: call can use the
+ correct sequential direction rather than forcing a re-anchor. */
+ voiceoverSetPoint = YES;
+
specpdl_ref count = SPECPDL_INDEX ();
record_unwind_current_buffer ();
/* block_input must come before record_unwind_protect_void (unblock_input). */
@@ -9060,20 +9190,38 @@ - (void)postFocusedCursorNotification:(ptrdiff_t)point
&& granularity
== ns_ax_text_selection_granularity_character);
- /* Always post SelectedTextChanged to interrupt VoiceOver reading
- and update cursor tracking / braille displays. */
+ /* Post SelectedTextChanged to interrupt VoiceOver reading and
+ update cursor tracking / braille displays.
+ For sequential moves (direction = next/previous): include
+ direction + granularity so VoiceOver reads the destination line
+ or word without additional state queries.
+ For discontiguous jumps (teleports, multi-line leaps): omit
+ direction and granularity and let VoiceOver determine what to read
+ from its own navigation state. This matches the pre-review
+ behaviour and ensures VoiceOver reads the full destination line
+ even when the jump skips blank or invisible lines (e.g. org-agenda
+ items separated by blank lines, where adjacency detection cannot
+ classify the move as singleLineMove). */
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 block-cursor mode). Include it for word/line/
- selection so VoiceOver reads the appropriate text. */
- if (!isCharMove)
- moveInfo[@"AXTextSelectionGranularity"] = @(granularity);
-
+ BOOL isDiscontiguous
+ = (direction == ns_ax_text_selection_direction_discontiguous);
+ if (!isDiscontiguous && !isCharMove)
+ {
+ moveInfo[@"AXTextSelectionDirection"] = @(direction);
+ moveInfo[@"AXTextSelectionGranularity"] = @(granularity);
+ }
+
+ /* Post on self (the EmacsAXBuffer element), not on the parent
+ EmacsView. When the notification originates from the element
+ whose selection changed, VoiceOver calls accessibilityLineForIndex:
+ on that element to determine the line to read. Posting from the
+ parent view with UIElementsKey causes VoiceOver to call
+ accessibilityLineForIndex: on the view instead, which returns an
+ incorrect range in specialised buffers (org-agenda, org-super-agenda)
+ where line geometry differs from plain text. */
ns_ax_post_notification_with_info (
self,
NSAccessibilitySelectedTextChangedNotification,
@@ -9173,12 +9321,17 @@ user expectation ("w" jumps to next word and reads it). */
}
}
- /* 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).
+ /* Announce the destination line text for all line-granularity moves.
+ This covers two cases:
+ - C-n/C-p: SelectedTextChanged carries granularity=line, but
+ VoiceOver processes those keystrokes specially and may not
+ produce speech; the explicit announcement is the reliable path.
+ - Discontiguous jumps (]], M-<, xref, imenu, …): granularity=line
+ in the notification is omitted (see above) so VoiceOver will
+ not announce automatically; this explicit announcement fills
+ the gap.
In completion-list-mode, read the completion candidate instead
- of the whole line. */
+ of the full line. */
if (cachedText
&& granularity == ns_ax_text_selection_granularity_line)
{
@@ -9243,6 +9396,11 @@ - (void)postCompletionAnnouncementForBuffer:(struct buffer *)b
block_input ();
specpdl_ref count2 = SPECPDL_INDEX ();
+ /* Register unblock_input as an unwind action so that if any Lisp
+ call below signals (triggering a longjmp through unbind_to),
+ block_input is always paired with an unblock_input. The
+ unbind_to call at the end of the function unwinds this.
+ record_unwind_protect_void plus unbind_to is idempotent. */
record_unwind_protect_void (unblock_input);
record_unwind_current_buffer ();
if (b != current_buffer)
@@ -9419,12 +9577,29 @@ - (void)postAccessibilityNotificationsForFrame:(struct frame *)f
if (!b)
return;
+ /* Echo area announcements are handled in
+ postEchoAreaAnnouncementIfNeeded (called from postAccessibilityUpdates
+ before this per-element loop) so that they are never lost to a
+ concurrent tree rebuild. For the inactive minibuffer (minibuf_level
+ == 0), skip normal cursor and completion processing --- there is no
+ meaningful cursor to track. */
+ if (MINI_WINDOW_P (w) && minibuf_level == 0)
+ return;
+
ptrdiff_t modiff = BUF_MODIFF (b);
ptrdiff_t point = BUF_PT (b);
BOOL markActive = !NILP (BVAR (b, mark_active));
/* --- Text changed (edit) --- */
ptrdiff_t chars_modiff = BUF_CHARS_MODIFF (b);
+ /* Track whether the user typed a character this redisplay cycle.
+ Used below to suppress overlay completion announcements: when the
+ user types, character echo (via postTextChangedNotification) must
+ take priority over overlay candidate updates. Without this guard,
+ Vertico/Ivy updates its overlay immediately after each keystroke,
+ and the High-priority overlay announcement interrupts the character
+ echo, effectively silencing typed characters. */
+ BOOL didTextChange = NO;
if (modiff != self.cachedModiff)
{
self.cachedModiff = modiff;
@@ -9438,6 +9613,7 @@ Text property changes (e.g. face updates from
{
self.cachedCharsModiff = chars_modiff;
[self postTextChangedNotification:point];
+ didTextChange = YES;
}
}
@@ -9460,41 +9636,49 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property
displayed in the minibuffer. In normal editing buffers,
font-lock and other modes change BUF_OVERLAY_MODIFF on
every redisplay, triggering O(overlays) work per keystroke.
- Restrict the scan to minibuffer windows. */
- if (MINI_WINDOW_P (w))
+ Restrict the scan to minibuffer windows.
+ Skip overlay announcements when the user just typed a character
+ (didTextChange). Completion frameworks update their overlay
+ immediately after each keystroke; without this guard, the
+ overlay High-priority announcement would interrupt the character
+ echo produced by postTextChangedNotification, making typed
+ characters inaudible. VoiceOver should read the overlay
+ candidate only when the user navigates (C-n/C-p), not types. */
+ if (MINI_WINDOW_P (w) && !didTextChange)
+ {
+
+ int selected_line = -1;
+ NSString *candidate
+ = ns_ax_selected_overlay_text (b, BUF_BEGV (b), BUF_ZV (b),
+ &selected_line);
+ if (candidate)
- {
+ {
- 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])
- {
+ {
- /* 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);
- }
+ 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);
- }
+ }
- }
+ }
+ }
}
/* --- Cursor moved or selection changed ---
Independent check from the overlay branch above. */
if (point != self.cachedPoint || markActive != self.cachedMarkActive)
@@ -9504,7 +9688,18 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property
self.cachedPoint = point;
self.cachedMarkActive = markActive;
- /* Compute direction. */
+ /* Compute direction.
+ When VoiceOver moved the cursor via setAccessibilitySelectedTextRange:
+ (voiceoverSetPoint == YES), use sequential next/previous so VoiceOver
+ continues smooth navigation from its current position.
+ When Emacs moved the cursor independently (voiceoverSetPoint == NO),
+ force discontiguous direction so VoiceOver re-anchors its browse
+ cursor to accessibilitySelectedTextRange; without this, VoiceOver's
+ internal browse position diverges from the Emacs insertion point and
+ subsequent VO+arrow navigation starts from the wrong location. */
+ BOOL emacsMovedCursor = !voiceoverSetPoint;
+ voiceoverSetPoint = NO; /* Consume the flag. */
+
NSInteger direction = ns_ax_text_selection_direction_discontiguous;
if (point > oldPoint)
direction = ns_ax_text_selection_direction_next;
@@ -9523,6 +9718,7 @@ granularity hint (defaulting to unknown), which causes VoiceOver
to make its own determination. Fresh text is always available
to VoiceOver via the AX getter path (accessibilityValue etc.). */
NSInteger granularity = ns_ax_text_selection_granularity_unknown;
+ BOOL singleLineMove = NO;
if (cachedText && oldPoint > 0)
{
NSUInteger tlen = [cachedText length];
@@ -9536,7 +9732,18 @@ to VoiceOver via the AX getter path (accessibilityValue etc.). */
NSRange newLine = [cachedText lineRangeForRange:
NSMakeRange (newIdx, 0)];
if (oldLine.location != newLine.location)
- granularity = ns_ax_text_selection_granularity_line;
+ {
+ granularity = ns_ax_text_selection_granularity_line;
+ /* Detect single adjacent-line move while oldLine/newLine
+ are in scope. Any command that steps exactly one line ---
+ C-n/C-p, evil j/k, outline-next-heading, etc. --- is
+ sequential. Multi-line teleports (]], M-<, xref, ...) are
+ not adjacent and will be marked discontiguous below.
+ Detected structurally: no package-specific code needed. */
+ BOOL adjFwd = (newLine.location == NSMaxRange (oldLine));
+ BOOL adjBwd = (NSMaxRange (newLine) == oldLine.location);
+ singleLineMove = adjFwd || adjBwd;
+ }
else
{
NSUInteger dist = (newIdx > oldIdx
@@ -9558,38 +9765,23 @@ to VoiceOver via the AX getter path (accessibilityValue etc.). */
granularity = ns_ax_text_selection_granularity_line;
}
- /* Treat all moves as Emacs-initiated until voiceoverSetPoint
- tracking is introduced (subsequent patch). */
- BOOL emacsMovedCursor = YES;
-
- /* Programmatic jumps that cross a line boundary (]], [[, M-<,
- xref, imenu, …) are discontiguous: the cursor teleported to an
- arbitrary position, not one sequential step forward/backward.
- Reporting AXTextSelectionDirectionDiscontiguous causes VoiceOver
- to re-anchor its rotor browse cursor at the new
- accessibilitySelectedTextRange rather than advancing linearly
- from its previous internal position. */
- if (!isCtrlNP && granularity == ns_ax_text_selection_granularity_line)
+
+ /* Multi-line teleports are discontiguous; single adjacent-line
+ steps stay sequential. */
+ if (!isCtrlNP && !singleLineMove
+ && granularity == ns_ax_text_selection_granularity_line)
direction = ns_ax_text_selection_direction_discontiguous;
- /* If Emacs moved the cursor (not VoiceOver), force discontiguous
- so VoiceOver re-anchors its browse cursor to the current
- accessibilitySelectedTextRange. This covers all Emacs-initiated
- moves: editing commands, ELisp, isearch, etc.
- Exception: C-n/C-p (isCtrlNP) already uses next/previous with
- line granularity; those are already sequential and VoiceOver
- handles them correctly. */
- if (emacsMovedCursor && !isCtrlNP)
+ /* Emacs-initiated teleports need re-anchor; sequential steps
+ (C-n/C-p or any adjacent-line command) do not. */
+ if (emacsMovedCursor && !isCtrlNP && !singleLineMove)
direction = ns_ax_text_selection_direction_discontiguous;
- /* Re-anchor VoiceOver's browse cursor for discontiguous (teleport)
- moves only. For sequential C-n/C-p (isCtrlNP), posting
- FocusedUIElementChanged on the window races with the
- AXSelectedTextChanged(granularity=line) notification and
- causes VoiceOver to drop the line-read speech. Sequential
- moves are already handled correctly by AXSelectedTextChanged
- with direction=next/previous + granularity=line. */
- if (emacsMovedCursor && !isCtrlNP && [self isAccessibilityFocused])
+ /* FocusedUIElementChanged only for teleports: posting it for
+ sequential moves races with AXSelectedTextChanged(granularity=line)
+ and causes VoiceOver to drop the line-read speech. */
+ if (emacsMovedCursor && !isCtrlNP && !singleLineMove
+ && [self isAccessibilityFocused])
{
NSWindow *win = [self.emacsView window];
if (win)
@@ -9748,6 +9940,13 @@ - (NSRect)accessibilityFrame
if (vis_start >= vis_end)
return @[];
+ /* block_input for the duration of the scan: the Lisp calls below
+ (Ftext_properties_at, Fplist_get, Foverlays_in, Foverlay_get,
+ Fnext_single_property_change, Fbuffer_substring_no_properties)
+ must not be interleaved with timer events or process sentinels
+ that could modify buffer state (e.g. invalidate vis_end).
+ record_unwind_protect_void guarantees unblock_input even if
+ a Lisp call signals. */
block_input ();
specpdl_ref blk_count = SPECPDL_INDEX ();
record_unwind_protect_void (unblock_input);
@@ -10056,6 +10255,9 @@ - (void)dealloc
#endif
[accessibilityElements release];
+#ifdef NS_IMPL_COCOA
+ [childFrameLastCandidate release];
+#endif
[[self menu] release];
[super dealloc];
}
@@ -11505,6 +11708,9 @@ - (instancetype) initFrameFromEmacs: (struct frame *)f
windowClosing = NO;
processingCompose = NO;
+#ifdef NS_IMPL_COCOA
+ childFrameLastBuffer = Qnil;
+#endif
scrollbarsNeedingUpdate = 0;
fs_state = FULLSCREEN_NONE;
fs_before_fs = next_maximized = -1;
@@ -12813,6 +13019,158 @@ - (id)accessibilityFocusedUIElement
The existing elements carry cached state (modiff, point) from the The existing elements carry cached state (modiff, point) from the
previous redisplay cycle. Rebuilding first would create fresh previous redisplay cycle. Rebuilding first would create fresh
elements with current values, making change detection impossible. */ elements with current values, making change detection impossible. */
+ +
+/* Announce new echo area messages to VoiceOver.
+
+ This is called at the top of postAccessibilityUpdates, before any
+ tree rebuild. Keeping it here, rather than in the per-element loop
+ in postAccessibilityNotificationsForFrame, guarantees that echo area
+ messages (including "Quit" from C-g) are announced even when the
+ accessibility element tree is in the process of being rebuilt.
+
+ The guard minibuf_level == 0 ensures we only announce passive status
+ messages. While the user is actively typing (minibuf_level > 0),
+ character echo and completion announcements take precedence.
+
+ Reads echo_area_buffer[0] directly because with_echo_area_buffer()
+ sets current_buffer via set_buffer_internal_1() but does NOT call
+ Fset_window_buffer(), so the minibuffer window's contents pointer
+ still points to the inactive " *Minibuf-0*" buffer.
+ echo_area_buffer[] is maintained by setup_echo_area_for_printing()
+ and clear_message() in xdisp.c; its lifetime is the process lifetime
+ and it is valid whenever BUFFERP (echo_area_buffer[0]) is true. */
+- (void)postEchoAreaAnnouncementIfNeeded
+{
+ if (minibuf_level != 0)
+ return;
+ Lisp_Object ea = echo_area_buffer[0];
+ if (!BUFFERP (ea))
+ return;
+ struct buffer *eb = XBUFFER (ea);
+ if (!BUFFER_LIVE_P (eb))
+ return;
+ ptrdiff_t echo_chars = BUF_CHARS_MODIFF (eb);
+ if (echo_chars == lastEchoCharsModiff || BUF_ZV (eb) <= BUF_BEGV (eb))
+ return;
+ lastEchoCharsModiff = echo_chars;
+ /* Use specpdl to restore current_buffer if Fbuffer_string signals.
+ set_buffer_internal_1 is preferred over set_buffer_internal in
+ a redisplay context: it skips point-motion hooks that could
+ trigger further redisplay or modify buffer state unexpectedly. */
+ block_input ();
+ specpdl_ref count = SPECPDL_INDEX ();
+ record_unwind_protect_void (unblock_input);
+ record_unwind_current_buffer ();
+ set_buffer_internal_1 (eb);
+ Lisp_Object ls = Fbuffer_string ();
+ unbind_to (count, Qnil);
+ /* stringWithLispString: converts Emacs's internal multibyte encoding
+ to NSString correctly; a raw SSDATA cast would produce invalid
+ UTF-8 for non-ASCII characters. */
+ NSString *raw = [NSString stringWithLispString: ls];
+ NSString *msg = [raw stringByTrimmingCharactersInSet:
+ [NSCharacterSet whitespaceAndNewlineCharacterSet]];
+ if ([msg length] == 0)
+ return;
+ NSDictionary *info = @{
+ NSAccessibilityAnnouncementKey: msg,
+ NSAccessibilityPriorityKey: @(NSAccessibilityPriorityHigh)
+ };
+ ns_ax_post_notification_with_info (
+ NSApp, NSAccessibilityAnnouncementRequestedNotification, info);
+}
+
+/* Announce the selected candidate in a child frame completion popup. +/* Announce the selected candidate in a child frame completion popup.
+ Handles Corfu, Company-box, and similar frameworks that render + Handles Corfu, Company-box, and similar frameworks that render
+ candidates in a separate child frame rather than as overlay strings + candidates in a separate child frame rather than as overlay strings
@@ -250,10 +765,23 @@ index 72478e0..daa8f61 100644
+ This prevents redundant work on every redisplay tick and + This prevents redundant work on every redisplay tick and
+ also guards against re-entrance: if Lisp calls below + also guards against re-entrance: if Lisp calls below
+ trigger redisplay, the modiff check short-circuits. */ + trigger redisplay, the modiff check short-circuits. */
+ EMACS_INT modiff = BUF_MODIFF (b); + if (!BUFFER_LIVE_P (b))
+ if (b == childFrameLastBuffer && modiff == childFrameLastModiff)
+ return; + return;
+ childFrameLastBuffer = b; + EMACS_INT modiff = BUF_MODIFF (b);
+ /* Compare buffer identity via the buffer name symbol. Interned
+ symbols (obarray) are GC-reachable without staticpro(), avoiding
+ a direct struct buffer pointer in a non-GC-visible ObjC ivar.
+ Caveat: if the buffer is renamed (rename-buffer), the stored
+ symbol no longer matches the new name and the equality check
+ returns nil, causing one redundant re-scan. This is harmless ---
+ completion popups (Corfu, Company) are never renamed during a
+ completion session. Using a sequence number would avoid the
+ rename edge case but would require another ivar; the name symbol
+ is a pragmatic, GC-safe approximation. */
+ if (EQ (childFrameLastBuffer, BVAR (b, name))
+ && modiff == childFrameLastModiff)
+ return;
+ childFrameLastBuffer = BVAR (b, name);
+ childFrameLastModiff = modiff; + childFrameLastModiff = modiff;
+ +
+ /* Skip buffers larger than a typical completion popup. + /* Skip buffers larger than a typical completion popup.
@@ -263,18 +791,26 @@ index 72478e0..daa8f61 100644
+ return; + return;
+ +
+ int selected_line = -1; + int selected_line = -1;
+ /* block_input prevents timer events and process output from
+ interleaving with the Lisp calls inside
+ ns_ax_selected_child_frame_text (Fbuffer_substring_no_properties,
+ Fget_char_property, etc.). record_unwind_protect_void ensures
+ unblock_input is called even if a Lisp call signals. */
+ block_input ();
+ specpdl_ref blk_count = SPECPDL_INDEX ();
+ record_unwind_protect_void (unblock_input);
+ NSString *candidate + NSString *candidate
+ = ns_ax_selected_child_frame_text (b, w->contents, &selected_line); + = ns_ax_selected_child_frame_text (b, w->contents, &selected_line);
+ unbind_to (blk_count, Qnil);
+ +
+ if (!candidate) + if (!candidate)
+ return; + return;
+ +
+ /* Deduplicate --- avoid re-announcing the same candidate. */ + /* Deduplicate --- avoid re-announcing the same candidate. */
+ const char *cstr = [candidate UTF8String]; + if ([candidate isEqualToString:childFrameLastCandidate])
+ if (childFrameLastCandidate && strcmp (cstr, childFrameLastCandidate) == 0)
+ return; + return;
+ xfree (childFrameLastCandidate); + [childFrameLastCandidate release];
+ childFrameLastCandidate = xstrdup (cstr); + childFrameLastCandidate = [candidate copy];
+ +
+ NSDictionary *annInfo = @{ + NSDictionary *annInfo = @{
+ NSAccessibilityAnnouncementKey: candidate, + NSAccessibilityAnnouncementKey: candidate,
@@ -303,7 +839,7 @@ index 72478e0..daa8f61 100644
- (void)postAccessibilityUpdates - (void)postAccessibilityUpdates
{ {
NSTRACE ("[EmacsView postAccessibilityUpdates]"); NSTRACE ("[EmacsView postAccessibilityUpdates]");
@@ -12276,11 +12453,59 @@ - (void)postAccessibilityUpdates @@ -12823,14 +13182,71 @@ - (void)postAccessibilityUpdates
/* Re-entrance guard: VoiceOver callbacks during notification posting /* Re-entrance guard: VoiceOver callbacks during notification posting
can trigger redisplay, which calls ns_update_end, which calls us can trigger redisplay, which calls ns_update_end, which calls us
@@ -315,6 +851,11 @@ index 72478e0..daa8f61 100644
return; return;
accessibilityUpdating = YES; accessibilityUpdating = YES;
+ /* Announce echo area messages (e.g. "Quit", "Wrote file") before
+ any tree-rebuild check. This must run even when the element tree
+ is being rebuilt to avoid missing time-sensitive status messages. */
+ [self postEchoAreaAnnouncementIfNeeded];
+
+ /* Child frame completion popup (Corfu, Company-box, etc.). + /* Child frame completion popup (Corfu, Company-box, etc.).
+ Child frames don't participate in the accessibility tree; + Child frames don't participate in the accessibility tree;
+ announce the selected candidate directly. */ + announce the selected candidate directly. */
@@ -333,6 +874,10 @@ index 72478e0..daa8f61 100644
+ { + {
+ Lisp_Object tail, frame; + Lisp_Object tail, frame;
+ BOOL childStillVisible = NO; + BOOL childStillVisible = NO;
+ /* block_input protects the FOR_EACH_FRAME iteration: the
+ frame list (Vframe_list) is a Lisp_Object chain and must not
+ be modified by a timer or process sentinel mid-iteration. */
+ block_input ();
+ FOR_EACH_FRAME (tail, frame) + FOR_EACH_FRAME (tail, frame)
+ if (FRAME_PARENT_FRAME (XFRAME (frame)) == emacsframe + if (FRAME_PARENT_FRAME (XFRAME (frame)) == emacsframe
+ && FRAME_VISIBLE_P (XFRAME (frame))) + && FRAME_VISIBLE_P (XFRAME (frame)))
@@ -340,6 +885,7 @@ index 72478e0..daa8f61 100644
+ childStillVisible = YES; + childStillVisible = YES;
+ break; + break;
+ } + }
+ unblock_input ();
+ +
+ if (!childStillVisible) + if (!childStillVisible)
+ { + {