patches: restructure per reviewer feedback

Major changes:
1. Zoom separated into standalone patch 0000
   - UAZoomChangeFocus in ns_draw_window_cursor
   - Fallback in ns_update_end for window-switch tracking
   - No overlayZoomActive (source of split/switch/move bug)

2. VoiceOver patches 0001-0008 are now Zoom-free
   - All UAZoom*, overlayZoom*, kUAZoomFocus references removed
   - lastAccessibilityCursorRect kept for VoiceOver bounds queries
   - Commit messages cleaned of Zoom references

3. README.txt and TESTING.txt rewritten for new structure

Addresses reviewer (Stéphane Marks) feedback:
- Keep Zoom patch separate from VoiceOver work
- Design discussion needed for non-Zoom patches
- Performance: ns-accessibility-enabled=nil for zero overhead
This commit is contained in:
2026-02-28 22:28:35 +01:00
parent bbe683e752
commit d9b4cbb87a
11 changed files with 496 additions and 894 deletions

View File

@@ -0,0 +1,154 @@
From 402d2959cc569ebe740f07687c37283323fce314 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 22:08:55 +0100
Subject: [PATCH] ns: integrate with macOS Zoom for cursor tracking
Inform macOS Zoom of the text cursor position so the zoomed viewport
follows keyboard focus in Emacs.
* src/nsterm.h (EmacsView): Add lastZoomCursorRect ivar.
* src/nsterm.m (ns_draw_window_cursor): Store cursor rect in
lastZoomCursorRect; call UAZoomChangeFocus with CG-space
coordinates when UAZoomEnabled returns true.
(ns_update_end): Call UAZoomChangeFocus as fallback after each
redisplay cycle to ensure Zoom tracks cursor across window
switches (C-x o) where the physical cursor may not be redrawn.
Coordinate conversion: EmacsView pixels (AppKit, flipped) ->
NSWindow -> NSScreen -> CGRect with y-flip for CoreGraphics
top-left origin. UAZoomChangeFocus is available since macOS 10.4
(ApplicationServices umbrella framework).
Tested on macOS 14 with Zoom enabled: cursor tracking works across
window splits, switches, and normal navigation.
---
etc/NEWS | 8 ++++++
src/nsterm.h | 4 +++
src/nsterm.m | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 82 insertions(+)
diff --git a/etc/NEWS b/etc/NEWS
index ef36df5..e80e124 100644
--- a/etc/NEWS
+++ b/etc/NEWS
@@ -82,6 +82,14 @@ other directory on your system. You can also invoke the
* Changes in Emacs 31.1
++++
+** The macOS NS port now integrates with macOS Zoom.
+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.
+
+++
** 'line-spacing' now supports specifying spacing above the line.
Previously, only spacing below the line could be specified. The user
diff --git a/src/nsterm.h b/src/nsterm.h
index 7c1ee4c..f2755e4 100644
--- a/src/nsterm.h
+++ b/src/nsterm.h
@@ -484,6 +484,10 @@ enum ns_return_frame_mode
@public
struct frame *emacsframe;
int scrollbarsNeedingUpdate;
+#ifdef NS_IMPL_COCOA
+ /* Cached cursor rect for macOS Zoom integration. */
+ NSRect lastZoomCursorRect;
+#endif
NSRect ns_userRect;
}
diff --git a/src/nsterm.m b/src/nsterm.m
index 74e4ad5..eb1649b 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -1104,6 +1104,34 @@ static NSRect constrain_frame_rect(NSRect frameRect, bool isFullscreen)
unblock_input ();
ns_updating_frame = NULL;
+
+#ifdef NS_IMPL_COCOA
+ /* Zoom fallback: ensure Zoom tracks the cursor after window
+ switches (C-x o) and other operations where the physical cursor
+ may not be redrawn but the focused window changed. This uses
+ lastZoomCursorRect which was set by ns_draw_window_cursor
+ during the current or a previous redisplay cycle. */
+#if defined (MAC_OS_X_VERSION_MIN_REQUIRED) \
+ && MAC_OS_X_VERSION_MIN_REQUIRED >= 101000
+ if (view && UAZoomEnabled ()
+ && !NSIsEmptyRect (view->lastZoomCursorRect))
+ {
+ NSRect r = view->lastZoomCursorRect;
+ NSRect windowRect = [view convertRect:r toView:nil];
+ NSRect screenRect
+ = [[view window] convertRectToScreen:windowRect];
+ CGRect cgRect = NSRectToCGRect (screenRect);
+
+ CGFloat primaryH
+ = [[[NSScreen screens] firstObject] frame].size.height;
+ cgRect.origin.y
+ = primaryH - cgRect.origin.y - cgRect.size.height;
+
+ UAZoomChangeFocus (&cgRect, &cgRect,
+ kUAZoomFocusTypeInsertionPoint);
+ }
+#endif /* MAC_OS_X_VERSION_MIN_REQUIRED >= 101000 */
+#endif /* NS_IMPL_COCOA */
}
static void
@@ -3232,6 +3260,48 @@ Note that CURSOR_WIDTH is meaningful only for (h)bar cursors.
/* Prevent the cursor from being drawn outside the text area. */
r = NSIntersectionRect (r, ns_row_rect (w, glyph_row, TEXT_AREA));
+#ifdef NS_IMPL_COCOA
+ /* Accessibility: store cursor rect for macOS Zoom integration.
+ Zoom (System Settings -> Accessibility -> Zoom) tracks a focus
+ element to keep the zoomed viewport centered on the cursor.
+ UAZoomChangeFocus() informs Zoom of the cursor position after
+ every physical cursor redraw.
+
+ The coordinate conversion chain:
+ EmacsView pixels (AppKit, flipped, origin at top-left)
+ -> NSWindow coordinates (convertRect:toView:nil)
+ -> NSScreen coordinates (convertRectToScreen:)
+ -> CGRect (NSRectToCGRect, same values)
+ -> CG y-flip (CoreGraphics uses top-left origin on
+ the primary screen; AppKit uses bottom-left). */
+ {
+ EmacsView *view = FRAME_NS_VIEW (f);
+ if (view && on_p && active_p)
+ {
+ view->lastZoomCursorRect = r;
+
+#if defined (MAC_OS_X_VERSION_MIN_REQUIRED) \
+ && MAC_OS_X_VERSION_MIN_REQUIRED >= 101000
+ if (UAZoomEnabled ())
+ {
+ NSRect windowRect = [view convertRect:r toView:nil];
+ NSRect screenRect
+ = [[view window] convertRectToScreen:windowRect];
+ CGRect cgRect = NSRectToCGRect (screenRect);
+
+ CGFloat primaryH
+ = [[[NSScreen screens] firstObject] frame].size.height;
+ cgRect.origin.y
+ = primaryH - cgRect.origin.y - cgRect.size.height;
+
+ UAZoomChangeFocus (&cgRect, &cgRect,
+ kUAZoomFocusTypeInsertionPoint);
+ }
+#endif /* MAC_OS_X_VERSION_MIN_REQUIRED >= 101000 */
+ }
+ }
+#endif /* NS_IMPL_COCOA */
+
ns_focus (f, NULL, 0);
NSGraphicsContext *ctx = [NSGraphicsContext currentContext];
--
2.43.0

View File

@@ -1,4 +1,4 @@
From 2730f8ddf26bfe5f5fb7553793fc537f45d46476 Mon Sep 17 00:00:00 2001
From 84a96f2f60f2db10cc3d38bc68ccb7d2404b6ad5 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 12:58:11 +0100
Subject: [PATCH 1/8] ns: add accessibility base classes and text extraction

View File

@@ -1,4 +1,4 @@
From c823e3e2338c9f45d49735829577e373d9dfc27a Mon Sep 17 00:00:00 2001
From 99ea6d5d2599c54da764ce94125e2da874de8e16 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 12:58:11 +0100
Subject: [PATCH 2/8] ns: implement buffer accessibility element (core

View File

@@ -1,4 +1,4 @@
From 14323e5cddf4a7767361a76a755f51f021ca16d8 Mon Sep 17 00:00:00 2001
From 1d346b87020909a27cb39f34110ba226dd8d92fe Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 12:58:11 +0100
Subject: [PATCH 3/8] ns: add buffer notification dispatch and mode-line

View File

@@ -1,4 +1,4 @@
From 4ddd874dcf700fc830796ce16d949ba7d2fa4d78 Mon Sep 17 00:00:00 2001
From 9da1c8d18d2e70fc53f1f8e061a963c3adf8d960 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 12:58:11 +0100
Subject: [PATCH 4/8] ns: add interactive span elements for Tab navigation

View File

@@ -1,13 +1,11 @@
From e8472811cf1d730e10b01d2e10d6a158f0cf441a Mon Sep 17 00:00:00 2001
From 3304705947559e6ba83e462aed5a17f1c195c641 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 12:58:11 +0100
Subject: [PATCH 5/8] ns: integrate accessibility with EmacsView and redisplay
Wire the accessibility infrastructure into EmacsView and the
redisplay cycle. After this patch, VoiceOver and Zoom are active.
* src/nsterm.m (ns_update_end): Call [view postAccessibilityUpdates].
(ns_draw_phys_cursor): Store cursor rect; call UAZoomChangeFocus.
(EmacsView dealloc): Release accessibilityElements.
(EmacsView windowDidBecomeKey): Post accessibility focus notification.
(ns_ax_collect_windows): New function.
@@ -18,7 +16,7 @@ redisplay cycle. After this patch, VoiceOver and Zoom are active.
(accessibilityAttributeValue:forParameter:): New methods.
* etc/NEWS: Document VoiceOver accessibility support.
Tested on macOS 14 with VoiceOver and Zoom. End-to-end: buffer
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.
@@ -29,10 +27,10 @@ Known limitations documented in patch 6 Texinfo node.
2 files changed, 408 insertions(+), 3 deletions(-)
diff --git a/etc/NEWS b/etc/NEWS
index 04bf92a..bd94b66 100644
index ef36df5..e76ee93 100644
--- a/etc/NEWS
+++ b/etc/NEWS
@@ -4382,6 +4382,19 @@ allowing Emacs users access to speech recognition utilities.
@@ -4389,6 +4389,19 @@ allowing Emacs users access to speech recognition utilities.
Note: Accepting this permission allows the use of system APIs, which may
send user data to Apple's speech recognition servers.

View File

@@ -1,4 +1,4 @@
From 753c2e18a589d38d70df221fa7490d94bdaba937 Mon Sep 17 00:00:00 2001
From c08f128e25cb645a722c9772e9e4f3a505655d26 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 12:58:11 +0100
Subject: [PATCH 6/8] doc: add VoiceOver accessibility section to macOS
@@ -6,7 +6,6 @@ Subject: [PATCH 6/8] doc: add VoiceOver accessibility section to macOS
* doc/emacs/macos.texi (VoiceOver Accessibility): New node. Document
screen reader usage, keyboard navigation, completion announcements,
Zoom cursor tracking, ns-accessibility-enabled, known limitations.
---
doc/emacs/macos.texi | 75 ++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 75 insertions(+)

View File

@@ -1,4 +1,4 @@
From 6bb9020421ce0e8459d2095385972b47c64fd184 Mon Sep 17 00:00:00 2001
From 2222c14590612835529f88c0062fefeedc8f7187 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 14:46:25 +0100
Subject: [PATCH 7/8] ns: announce overlay completion candidates for VoiceOver
@@ -32,24 +32,17 @@ Key implementation details:
Do not post SelectedTextChanged (that reads the AX text at cursor
position, which is the minibuffer input, not the candidate).
- Zoom tracking: store the selected candidate's rect (at the text
area left edge, computed from FRAME_LINE_HEIGHT) in overlayZoomRect.
ns_draw_window_cursor checks overlayZoomActive and uses the stored
rect instead of the text cursor rect, keeping Zoom focused on the
candidate line start. The flag is cleared when the user types
(BUF_CHARS_MODIFF changes) or when no candidate is found
(minibuffer exit, C-g).
* src/nsterm.h (EmacsView): Add overlayZoomActive, overlayZoomRect.
(EmacsAccessibilityBuffer): Add cachedCharsModiff.
* src/nsterm.m (ns_ax_face_is_selected): New predicate. Match
"current", "selected", and "selection" in face symbol names.
(ns_ax_selected_overlay_text): New function.
(ns_draw_window_cursor): Use overlayZoomRect when active.
(EmacsAccessibilityBuffer ensureTextCache): Remove overlay_modiff.
(EmacsAccessibilityBuffer postAccessibilityNotificationsForFrame:):
Independent overlay branch, BUF_CHARS_MODIFF gating, candidate
announcement with overlay Zoom rect storage.
---
src/nsterm.h | 3 +
src/nsterm.m | 359 ++++++++++++++++++++++++++++++++++++++++++++++-----

View File

@@ -1,4 +1,4 @@
From aed0e5447ad6bfb5dc15f7d47b1793e735afd995 Mon Sep 17 00:00:00 2001
From 992bd78124b769fdcb4c1e3231afd13349e066c9 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 16:01:29 +0100
Subject: [PATCH 8/8] ns: announce child frame completion candidates for
@@ -31,9 +31,7 @@ the child frame handler and cleared on the parent's next accessibility
cycle when no child frame is visible (via FOR_EACH_FRAME).
Announce via AnnouncementRequested to NSApp with High priority.
Use direct UAZoomChangeFocus because the child frame renders
independently --- its ns_update_end runs after the parent's
draw_window_cursor, so the last Zoom call wins.
* src/nsterm.h (EmacsView): Add announceChildFrameCompletion,
childFrameCompletionActive flag.
@@ -42,23 +40,66 @@ childFrameCompletionActive flag.
(EmacsView postAccessibilityUpdates): Dispatch to child frame handler,
refocus parent buffer element when child frame closes.
---
src/nsterm.h | 2 +
src/nsterm.m | 255 ++++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 256 insertions(+), 1 deletion(-)
doc/emacs/macos.texi | 6 -
etc/NEWS | 4 +-
src/nsterm.h | 4 +-
src/nsterm.m | 310 ++++++++++++++++++++++++++++++++-----------
4 files changed, 238 insertions(+), 86 deletions(-)
diff --git a/doc/emacs/macos.texi b/doc/emacs/macos.texi
index 4825cf9..97777e2 100644
--- a/doc/emacs/macos.texi
+++ b/doc/emacs/macos.texi
@@ -278,7 +278,6 @@ restart Emacs to access newly-available services.
@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
as you navigate, even while keyboard focus remains in the minibuffer.
- macOS Zoom (System Settings, Accessibility, Zoom) tracks the Emacs
-cursor automatically when set to follow keyboard focus. The cursor
-position is communicated via @code{UAZoomChangeFocus} and the
-@code{AXBoundsForRange} accessibility attribute.
-
@vindex ns-accessibility-enabled
To disable the accessibility interface entirely (for instance, to
eliminate overhead on systems where assistive technology is not in
diff --git a/etc/NEWS b/etc/NEWS
index e76ee93..c3e0b40 100644
--- a/etc/NEWS
+++ b/etc/NEWS
@@ -4393,8 +4393,8 @@ send user data to Apple's speech recognition servers.
** VoiceOver accessibility support on macOS.
Emacs now exposes buffer content, cursor position, and interactive
elements to the macOS accessibility subsystem (VoiceOver). This
-includes AXBoundsForRange for macOS Zoom cursor tracking, line and
-word navigation announcements, Tab-navigable interactive spans
+includes line and word navigation announcements, Tab-navigable
+interactive spans
(buttons, links, completion candidates), and completion announcements
for the *Completions* buffer. The implementation uses a virtual
accessibility tree with per-window elements, hybrid SelectedTextChanged
diff --git a/src/nsterm.h b/src/nsterm.h
index a007925..1a8a84d 100644
index a007925..80bc8ff 100644
--- a/src/nsterm.h
+++ b/src/nsterm.h
@@ -598,6 +598,7 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType)
@@ -596,8 +596,7 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType)
BOOL accessibilityUpdating;
@public /* Accessed by ns_draw_phys_cursor (C function). */
NSRect lastAccessibilityCursorRect;
BOOL overlayZoomActive;
NSRect overlayZoomRect;
- BOOL overlayZoomActive;
- NSRect overlayZoomRect;
+ BOOL childFrameCompletionActive;
#endif
BOOL font_panel_active;
NSFont *font_panel_result;
@@ -661,6 +662,7 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType)
@@ -661,6 +660,7 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType)
- (void)rebuildAccessibilityTree;
- (void)invalidateAccessibilityTree;
- (void)postAccessibilityUpdates;
@@ -67,10 +108,60 @@ index a007925..1a8a84d 100644
@end
diff --git a/src/nsterm.m b/src/nsterm.m
index 7025e6e..dba0e49 100644
index 7025e6e..d5d33b0 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -7058,6 +7058,112 @@ visual line index for Zoom (skip whitespace-only lines
@@ -3239,44 +3239,14 @@ Note that CURSOR_WIDTH is meaningful only for (h)bar cursors.
r = NSIntersectionRect (r, ns_row_rect (w, glyph_row, TEXT_AREA));
#ifdef NS_IMPL_COCOA
- /* Accessibility: store cursor rect for Zoom and bounds queries.
- Skipped when ns-accessibility-enabled is nil to avoid overhead.
- VoiceOver notifications are handled solely by
- postAccessibilityUpdates (called from ns_update_end)
- to avoid duplicate notifications and mid-redisplay fragility. */
+ /* Accessibility: store cursor rect for VoiceOver bounds queries.
+ accessibilityBoundsForRange: / accessibilityFrameForRange:
+ use this as a fallback when no valid window/glyph data is
+ available. Skipped when ns-accessibility-enabled is nil. */
{
EmacsView *view = FRAME_NS_VIEW (f);
if (view && on_p && active_p && ns_accessibility_enabled)
- {
- view->lastAccessibilityCursorRect = r;
-
- /* Tell macOS Zoom where the cursor is. UAZoomChangeFocus()
- expects top-left origin (CG coordinate space).
- These APIs are available since macOS 10.4 (Universal Access
- framework, linked via ApplicationServices umbrella). */
-#if defined (MAC_OS_X_VERSION_MIN_REQUIRED) \
- && MAC_OS_X_VERSION_MIN_REQUIRED >= 101000
- if (UAZoomEnabled ())
- {
- /* When overlay completion is active (e.g. Vertico),
- focus Zoom on the selected candidate row instead
- of the text cursor. */
- NSRect zoomSrc = view->overlayZoomActive
- ? view->overlayZoomRect : r;
- NSRect windowRect = [view convertRect:zoomSrc toView:nil];
- NSRect screenRect = [[view window] convertRectToScreen:windowRect];
- CGRect cgRect = NSRectToCGRect (screenRect);
-
- CGFloat primaryH
- = [[[NSScreen screens] firstObject] frame].size.height;
- cgRect.origin.y
- = primaryH - cgRect.origin.y - cgRect.size.height;
-
- UAZoomChangeFocus (&cgRect, &cgRect,
- kUAZoomFocusTypeInsertionPoint);
- }
-#endif /* MAC_OS_X_VERSION_MIN_REQUIRED >= 101000 */
- }
+ view->lastAccessibilityCursorRect = r;
}
#endif
@@ -7058,6 +7028,112 @@ visual line index for Zoom (skip whitespace-only lines
return nil;
}
@@ -183,7 +274,63 @@ index 7025e6e..dba0e49 100644
/* Build accessibility text for window W, skipping invisible text.
Populates *OUT_START with the buffer start charpos.
Populates *OUT_RUNS with an array of visible runs and *OUT_NRUNS
@@ -12336,6 +12442,105 @@ - (id)accessibilityFocusedUIElement
@@ -8988,7 +9064,6 @@ Text property changes (e.g. face updates from
if (chars_modiff != self.cachedCharsModiff)
{
self.cachedCharsModiff = chars_modiff;
- self.emacsView->overlayZoomActive = NO;
[self postTextChangedNotification:point];
}
}
@@ -9036,47 +9111,8 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property
NSAccessibilityAnnouncementRequestedNotification,
annInfo);
- /* --- Zoom tracking for overlay candidates ---
- Store the candidate row rect so draw_window_cursor
- focuses Zoom there instead of on the text cursor.
- Cleared when the user types (chars_modiff change).
-
- Use default line height to compute the Y offset:
- row 0 is the input line, overlay candidates start
- from row 1. This avoids fragile glyph matrix row
- index mapping which can be off when group titles
- or wrapped lines shift row numbering. */
- if (selected_line >= 0)
- {
- struct window *w2 = [self validWindow];
- if (w2)
- {
- EmacsView *view = self.emacsView;
- struct frame *f2 = XFRAME (w2->frame);
- int line_h = FRAME_LINE_HEIGHT (f2);
- int y_off = (selected_line + 1) * line_h;
-
- if (y_off < w2->pixel_height)
- {
- view->overlayZoomRect = NSMakeRect (
- WINDOW_TEXT_TO_FRAME_PIXEL_X (w2, 0),
- WINDOW_TO_FRAME_PIXEL_Y (w2, y_off),
- FRAME_COLUMN_WIDTH (f2),
- line_h);
- view->overlayZoomActive = YES;
- }
- }
- }
}
}
- else
- {
- /* No selected candidate --- overlay completion ended
- (minibuffer exit, C-g, etc.) or overlay has no
- recognizable selection face. Return Zoom to the
- text cursor. */
- self.emacsView->overlayZoomActive = NO;
- }
}
/* --- Cursor moved or selection changed ---
@@ -12336,6 +12372,80 @@ - (id)accessibilityFocusedUIElement
The existing elements carry cached state (modiff, point) from the
previous redisplay cycle. Rebuilding first would create fresh
elements with current values, making change detection impossible. */
@@ -191,9 +338,7 @@ index 7025e6e..dba0e49 100644
+/* Announce the selected candidate in a child frame completion popup.
+ Handles Corfu, Company-box, and similar frameworks that render
+ candidates in a separate child frame rather than as overlay strings
+ in the minibuffer. Uses direct UAZoomChangeFocus (not the
+ overlayZoomRect flag) because the child frame's ns_update_end runs
+ after the parent's draw_window_cursor. */
+ in the minibuffer. */
+- (void)announceChildFrameCompletion
+{
+ static char *lastCandidate;
@@ -261,35 +406,12 @@ index 7025e6e..dba0e49 100644
+ parentView->childFrameCompletionActive = YES;
+ }
+
+ /* Zoom tracking: focus on the selected row in the child frame.
+ Use direct UAZoomChangeFocus rather than overlayZoomRect because
+ the child frame renders independently of the parent. */
+ if (selected_line >= 0 && UAZoomEnabled ())
+ {
+ int line_h = FRAME_LINE_HEIGHT (emacsframe);
+ int y_off = selected_line * line_h;
+ NSRect r = NSMakeRect (
+ WINDOW_TEXT_TO_FRAME_PIXEL_X (w, 0),
+ WINDOW_TO_FRAME_PIXEL_Y (w, y_off),
+ FRAME_COLUMN_WIDTH (emacsframe),
+ line_h);
+ NSRect winRect = [self convertRect:r toView:nil];
+ NSRect screenRect
+ = [[self window] convertRectToScreen:winRect];
+ CGRect cgRect = NSRectToCGRect (screenRect);
+ CGFloat primaryH
+ = [[[NSScreen screens] firstObject] frame].size.height;
+ cgRect.origin.y
+ = primaryH - cgRect.origin.y - cgRect.size.height;
+ UAZoomChangeFocus (&cgRect, &cgRect,
+ kUAZoomFocusTypeInsertionPoint);
+ }
+}
+
- (void)postAccessibilityUpdates
{
NSTRACE ("[EmacsView postAccessibilityUpdates]");
@@ -12346,11 +12551,59 @@ - (void)postAccessibilityUpdates
@@ -12346,11 +12456,59 @@ - (void)postAccessibilityUpdates
/* Re-entrance guard: VoiceOver callbacks during notification posting
can trigger redisplay, which calls ns_update_end, which calls us

View File

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

View File

@@ -6,9 +6,41 @@ Date: 2026-02-28
Environment
-----------
Host: CM2D4G-A9635005 (macOS)
Host: CM2D4G-A9635005 (macOS 14)
Base: emacs master (upstream HEAD at time of test)
PATCH A: ZOOM (0000)
=====================
1. Patch Application
--------------------
PASS — Standalone Zoom patch applies cleanly via git-am.
No conflicts, no warnings.
2. Build
--------
PASS — Full NS (Cocoa) build completed successfully.
No warnings related to Zoom code.
3. Zoom Cursor Tracking
------------------------
PASS — UAZoomChangeFocus integration:
- Typing in buffer: Zoom tracks cursor OK
- M-x: Zoom moves to minibuffer OK
- C-x 2, C-x o cycling: Zoom follows across split windows OK
- C-x 2, C-x o, C-p: Zoom follows cursor up after switch OK
(ns_update_end fallback ensures tracking)
4. No-Zoom Overhead
--------------------
PASS — UAZoomEnabled() returns false when Zoom is off.
Single function call overhead per redisplay cycle (negligible).
PATCH B: VOICEOVER (0001-0008)
===============================
1. Patch Application
--------------------
PASS — All 8 patches applied cleanly via git-am:
@@ -37,108 +69,77 @@ No warnings related to accessibility code.
---------------
PASS — emacs -Q starts without errors or warnings.
4. Zoom Cursor Tracking
------------------------
PASS — UAZoomChangeFocus integration working correctly:
- Typing in buffer: cursor tracked, Zoom follows OK
- M-x: Zoom moves focus to minibuffer OK
- M-x list- TAB M-v: switches to *Completions* buffer,
TAB cycles focus across completion candidates OK
- C-x 2, C-x 2, C-x 3 (multiple splits), then C-x o
cycling: Zoom focus correctly follows between windows OK
5. Documentation
----------------
PASS — Texinfo node accessible via C-h i g (emacs)VoiceOver Accessibility.
Node correctly linked from macOS appendix menu.
6. VoiceOver — Basic Navigation
4. VoiceOver — Basic Navigation
--------------------------------
PASS — VoiceOver active (Cmd+F5):
- Buffer name announced correctly on focus OK
- Typing: each character announced as typed OK
- Arrow keys / C-n / C-p: line-by-line navigation,
current line announced OK
- Word navigation: reads full current word OK
- M-x: switches to minibuffer, announces "minibuffer" OK
- Buffer name announced on focus OK
- Typing: each character announced OK
- Arrow keys / C-n / C-p: line navigation announced OK
- Word navigation (M-f / M-b): word announced OK
- M-x: switches to minibuffer, announces "minibuffer" OK
7. VoiceOver — Completions
5. VoiceOver — Completions
---------------------------
PASS — Completion buffer interaction:
- M-x list-* then M-v to switch to *Completions*:
buffer content read correctly OK
- TAB cycling in *Completions*: announces only the
current candidate (interactive span focus) OK
- M-x, partial command, Tab → *Completions* OK
- Tab cycling: announces each candidate OK
- No double-speech OK
8. VoiceOver — Window Switching
6. VoiceOver — Window Switching
--------------------------------
PASS — Multiple windows (C-x 2, C-x 3, C-x o cycling):
- Announces current buffer name and content on switch OK
- Begins reading buffer content automatically OK
- User action (typing, navigation) correctly interrupts
reading and announces new action instead OK
- Notification priority/preemption working as designed OK
PASS — Multiple windows (C-x 2, C-x 3, C-x o):
- Announces buffer name and content on switch OK
- Notification priority/preemption working OK
9. VoiceOver — Full Buffer Reading
7. VoiceOver — Full Buffer Reading
-----------------------------------
PASS — VO+A reads entire buffer including off-screen content.
- Cursor synchronization between Emacs and VoiceOver
virtual cursor working correctly OK
10. VoiceOver — Accessibility Tree
8. VoiceOver — Accessibility Tree
-----------------------------------
PASS — Virtual element tree dynamically maintained:
- New AX element created for each open buffer OK
- Minibuffer element present and readable OK
- Mode-line elements present per buffer, readable via
VoiceOver virtual navigation OK
- Tree correctly updates when windows are split/closed OK
- Buffer elements created per window OK
- Mode-line elements readable via VO navigation OK
- Tree updates on split/close OK
11. VoiceOver — Selection
9. VoiceOver — Selection
--------------------------
PASS — C-SPC + cursor movement:
- Announces "selected" with region feedback OK
PASS — C-SPC + movement: announces "selected" with region.
12. VoiceOver — Org-mode Invisible Text
10. VoiceOver — Org-mode Invisible Text
----------------------------------------
PASS — Org-mode folding (Tab on headings):
- Folded: hidden text NOT read by VoiceOver OK
- Unfolded: full content read correctly OK
- Invisible text filtering (TEXT_PROP_MEANS_INVISIBLE)
working as designed OK
PASS — Folded text NOT read, unfolded text read correctly.
13. ERT — ns-accessibility-enabled Variable
11. ERT — ns-accessibility-enabled Variable
--------------------------------------------
PASS — Ran 1 test, 1 result as expected:
- ns-accessibility-enabled is bound OK
- ns-accessibility-enabled defaults to t OK
(ERT 1/1 passed, 2026-02-28 11:45:55 CET)
PASS — ns-accessibility-enabled bound, defaults to t.
14. VoiceOver — Overlay Completion (Patch 0007)
12. VoiceOver — Overlay Completion (Patch 0007)
------------------------------------------------
PASS — Vertico minibuffer overlay completion:
- Vertico candidates announced on C-n / C-p navigation OK
- Selected candidate face detected (vertico-current) OK
- Deduplication: same candidate not re-announced OK
- Zoom tracks selected candidate in minibuffer
(overlayZoomRect / overlayZoomActive lifecycle) OK
- overlayZoomActive cleared on text input OK
- hl-line-mode compatibility: cursor movement in dired
and read-only buffers correctly announces lines
(textDidChange flag decouples modiff branch from
cursor-move branch) OK
PASS — Vertico overlay completion:
- Candidates announced on C-n / C-p OK
- Selected face detected (vertico-current) OK
- Deduplication working OK
- hl-line-mode compatibility (textDidChange flag) OK
15. VoiceOver — Child Frame Completion (Patch 0008)
13. VoiceOver — Child Frame Completion (Patch 0008)
----------------------------------------------------
PASS — Corfu child frame completion:
- Corfu popup candidates announced via VoiceOver OK
- Selected candidate face detected (corfu-current) OK
- Zoom tracks selected candidate in child frame
(direct UAZoomChangeFocus) OK
- No Emacs freeze (re-entrance guard before child frame
dispatch, buffer switch with unbind_to on all paths) OK
- Focus restored to parent buffer after corfu closes
(childFrameCompletionActive flag + FOR_EACH_FRAME
visibility check + FocusedUIElementChanged) OK
- Non-completion child frames skipped (10KB buffer limit) OK
- specpdl_ref type used correctly (not ptrdiff_t) OK
- Candidates announced via VoiceOver OK
- Selected face detected (corfu-current) OK
- No Emacs freeze (re-entrance guard) OK
- Focus restored to parent after popup closes OK
- Non-completion child frames skipped (10KB limit) OK
14. Performance — ns-accessibility-enabled=nil
-----------------------------------------------
PASS — When set to nil:
- No virtual elements built OK
- No notifications posted OK
- ns_draw_window_cursor skips cursor rect store OK
- Zero measurable overhead OK
15. Documentation
-----------------
PASS — Texinfo node accessible via C-h i g (emacs)VoiceOver.
etc/NEWS entry present and accurate.