patches: review fixes — defvar, method extraction, GC safety, window_end_valid

Review-based improvements:
- ns-accessibility-enabled DEFVAR_BOOL (disable AX overhead)
- window_end_valid guard in ns_ax_window_end_charpos
- GC safety comments on Lisp_Object ObjC ivars
- postAccessibilityNotificationsForFrame split into 4 methods
- block_input in ns_ax_completion_text_for_span
- Fplist_get predicate comment
- macos.texi VoiceOver section with defvar docs
- README updated with USER OPTION + REVIEW CHANGES sections
This commit is contained in:
2026-02-27 17:51:39 +01:00
parent b83a061322
commit d408a542e5
3 changed files with 524 additions and 439 deletions

View File

@@ -1,7 +1,7 @@
From 17a100d99a31e0fae9b641c7ce163efd9bf5945b Mon Sep 17 00:00:00 2001
From c4c5ae47fd944cc04f7e229c2a66fb44fa9d006e Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Fri, 27 Feb 2026 15:09:15 +0100
Subject: [PATCH] ns: implement VoiceOver accessibility for macOS
Date: Fri, 27 Feb 2026 17:48:49 +0100
Subject: [PATCH 1/2] ns: implement VoiceOver accessibility for macOS
Add comprehensive macOS VoiceOver accessibility support to the NS
(Cocoa) port. Before this patch, Emacs exposed only a minimal,
@@ -29,8 +29,6 @@ New types and classes:
for Tab-navigable interactive spans (buttons, links, checkboxes,
completion candidates, Org-mode links, keymap overlays).
EmacsAXSpanType: enum for span classification.
New functions:
ns_ax_buffer_text: build accessibility string with visible-run
@@ -57,7 +55,7 @@ New functions:
completions-highlight overlay for completion announcements.
ns_ax_completion_text_for_span: extract announcement text for a
completion overlay span.
completion overlay span (with block_input/unblock_input protection).
EmacsView extensions:
@@ -72,10 +70,16 @@ EmacsView extensions:
ns_draw_phys_cursor: stores cursor rect for Zoom, calls
UAZoomChangeFocus with correct CG coordinate-space transform.
DEFSYM additions in syms_of_nsterm (ns_ax_ prefix to avoid
collisions): Qns_ax_widget, Qns_ax_button, Qns_ax_follow_link,
Qns_ax_org_link, Qns_ax_completion_list_mode, Qns_ax_completion__string, Qns_ax_completion,
Qns_ax_completions_highlight, Qns_ax_backtab.
DEFSYM additions in syms_of_nsterm: line navigation command symbols
(Qns_ax_next_line, Qns_ax_previous_line, evil/dired variants) and
span scanning symbols (Qns_ax_widget, Qns_ax_button,
Qns_ax_follow_link, Qns_ax_org_link, Qns_ax_completion_list_mode,
Qns_ax_completion__string, Qns_ax_completion,
Qns_ax_completions_highlight, Qns_ax_backtab).
New user option: ns-accessibility-enabled (default t). When nil,
the accessibility virtual element tree is not built and no
notifications are posted, eliminating overhead.
Threading model: all Lisp calls on main thread; AX getters use
dispatch_sync to main; index mapping methods are thread-safe (no
@@ -85,16 +89,16 @@ Lisp calls, read only immutable NSString and scalar cache).
* src/nsterm.h: New class declarations and EmacsView ivar extensions.
* etc/NEWS: Document VoiceOver accessibility support.
---
etc/NEWS | 11 +
src/nsterm.h | 108 ++
src/nsterm.m | 2870 +++++++++++++++++++++++++++++++++++++++++++++++---
3 files changed, 2987 insertions(+), 149 deletions(-)
etc/NEWS | 13 +
src/nsterm.h | 119 ++
src/nsterm.m | 3024 +++++++++++++++++++++++++++++++++++++++++++++++---
3 files changed, 3009 insertions(+), 147 deletions(-)
diff --git a/etc/NEWS b/etc/NEWS
index 7367e3cc..0e4480ad 100644
index 7367e3c..608650e 100644
--- a/etc/NEWS
+++ b/etc/NEWS
@@ -4374,6 +4374,17 @@ allowing Emacs users access to speech recognition utilities.
@@ -4374,6 +4374,19 @@ allowing Emacs users access to speech recognition utilities.
Note: Accepting this permission allows the use of system APIs, which may
send user data to Apple's speech recognition servers.
@@ -108,15 +112,17 @@ index 7367e3cc..0e4480ad 100644
+for the *Completions* buffer. The implementation uses a virtual
+accessibility tree with per-window elements, hybrid SelectedTextChanged
+and AnnouncementRequested notifications, and thread-safe text caching.
+Set 'ns-accessibility-enabled' to nil to disable the accessibility
+interface and eliminate the associated overhead.
+
---
** Re-introduced dictation, lost in Emacs v30 (macOS).
We lost macOS dictation in v30 when migrating to NSTextInputClient.
diff --git a/src/nsterm.h b/src/nsterm.h
index 7c1ee4cf..542e7d59 100644
index 7c1ee4c..393fc4c 100644
--- a/src/nsterm.h
+++ b/src/nsterm.h
@@ -453,6 +453,97 @@ enum ns_return_frame_mode
@@ -453,6 +453,110 @@ enum ns_return_frame_mode
@end
@@ -132,7 +138,18 @@ index 7c1ee4cf..542e7d59 100644
+/* Base class for virtual accessibility elements attached to EmacsView. */
+@interface EmacsAccessibilityElement : NSAccessibilityElement
+@property (nonatomic, unsafe_unretained) EmacsView *emacsView;
+/* Lisp window object — safe across GC cycles. NULL_LISP when unset. */
+/* Lisp window object — safe across GC cycles.
+ GC safety: these Lisp_Objects are NOT visible to GC via staticpro
+ or the specpdl stack. This is safe because:
+ (1) Emacs GC runs only on the main thread, at well-defined safe
+ points during Lisp evaluation — never during redisplay.
+ (2) Accessibility elements are owned by EmacsView which belongs to
+ an active frame; windows referenced here are always reachable
+ from the frame's window tree until rebuildAccessibilityTree
+ updates them during the next redisplay cycle.
+ (3) AX getters dispatch_sync to main before accessing Lisp state,
+ so GC cannot run concurrently with any access to lispWindow.
+ (4) validWindow checks WINDOW_LIVE_P before dereferencing. */
+@property (nonatomic, assign) Lisp_Object lispWindow;
+- (struct window *)validWindow; /* Returns live window or NULL. */
+- (NSRect)screenRectFromEmacsX:(int)x y:(int)y width:(int)w height:(int)h;
@@ -216,11 +233,12 @@ index 7c1ee4cf..542e7d59 100644
/* ==========================================================================
The main Emacs view
@@ -471,6 +562,13 @@ enum ns_return_frame_mode
@@ -471,6 +575,14 @@ enum ns_return_frame_mode
#ifdef NS_IMPL_COCOA
char *old_title;
BOOL maximizing_resize;
+ NSMutableArray *accessibilityElements;
+ /* See GC safety comment on EmacsAccessibilityElement.lispWindow. */
+ Lisp_Object lastSelectedWindow;
+ Lisp_Object lastRootWindow;
+ BOOL accessibilityTreeValid;
@@ -230,7 +248,7 @@ index 7c1ee4cf..542e7d59 100644
#endif
BOOL font_panel_active;
NSFont *font_panel_result;
@@ -528,6 +626,13 @@ enum ns_return_frame_mode
@@ -528,6 +640,13 @@ enum ns_return_frame_mode
- (void)windowWillExitFullScreen;
- (void)windowDidExitFullScreen;
- (void)windowDidBecomeKey;
@@ -245,7 +263,7 @@ index 7c1ee4cf..542e7d59 100644
diff --git a/src/nsterm.m b/src/nsterm.m
index 932d209f..ea2de6f2 100644
index 932d209..6b27c6c 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -46,6 +46,7 @@ Updated by Christian Limpach (chris@nice.ch)
@@ -268,18 +286,19 @@ index 932d209f..ea2de6f2 100644
}
static void
@@ -3232,6 +3238,42 @@ Note that CURSOR_WIDTH is meaningful only for (h)bar cursors.
@@ -3232,6 +3238,43 @@ 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 Zoom and bounds queries.
+ Skipped when ns-accessibility-enabled is nil to avoid overhead.
+ VoiceOver notifications are handled solely by
+ postAccessibilityUpdates (called from ns_update_end)
+ to avoid duplicate notifications and mid-redisplay fragility. */
+ {
+ EmacsView *view = FRAME_NS_VIEW (f);
+ if (view && on_p && active_p)
+ if (view && on_p && active_p && ns_accessibility_enabled)
+ {
+ view->lastAccessibilityCursorRect = r;
+
@@ -311,7 +330,7 @@ index 932d209f..ea2de6f2 100644
ns_focus (f, NULL, 0);
NSGraphicsContext *ctx = [NSGraphicsContext currentContext];
@@ -6849,218 +6891,2498 @@ - (BOOL)fulfillService: (NSString *)name withArg: (NSString *)arg
@@ -6849,218 +6892,2522 @@ - (BOOL)fulfillService: (NSString *)name withArg: (NSString *)arg
/* ==========================================================================
@@ -786,14 +805,6 @@ index 932d209f..ea2de6f2 100644
+static bool
+ns_ax_event_is_line_nav_key (int *which)
+{
-
-#ifdef NS_IMPL_COCOA
- if (!canceled)
- font_panel_result = nil;
-#endif
-
- result = font_panel_result;
- font_panel_result = nil;
+ /* 1. Check Vthis_command for known navigation command symbols.
+ All symbols are registered via DEFSYM in syms_of_nsterm to avoid
+ per-call obarray lookups in this hot path (runs every cursor move). */
@@ -819,12 +830,18 @@ index 932d209f..ea2de6f2 100644
+ return true;
+ }
+ }
+
-#ifdef NS_IMPL_COCOA
- if (!canceled)
- font_panel_result = nil;
-#endif
+ /* 2. Fallback: check raw key events for Tab/backtab. */
+ Lisp_Object ev = last_command_event;
+ if (CONSP (ev))
+ ev = EVENT_HEAD (ev);
+
- result = font_panel_result;
- font_panel_result = nil;
+ if (SYMBOLP (ev) && EQ (ev, Qns_ax_backtab))
+ {
+ if (which) *which = -1;
@@ -875,12 +892,16 @@ index 932d209f..ea2de6f2 100644
-- (BOOL)acceptsFirstResponder
+/* Compute visible-end charpos for window W.
+ Emacs stores it as BUF_Z - window_end_pos. */
+ Emacs stores it as BUF_Z - window_end_pos.
+ Falls back to BUF_ZV when window_end_valid is false (e.g., when
+ called from an AX getter before the next redisplay cycle). */
+static ptrdiff_t
+ns_ax_window_end_charpos (struct window *w, struct buffer *b)
{
- NSTRACE ("[EmacsView acceptsFirstResponder]");
- return YES;
+ if (!w->window_end_valid)
+ return BUF_ZV (b);
+ return BUF_Z (b) - w->window_end_pos;
}
@@ -894,6 +915,8 @@ index 932d209f..ea2de6f2 100644
- [theEvent type], [theEvent clickCount]);
- return ns_click_through;
+ Lisp_Object plist = Ftext_properties_at (make_fixnum (pos), buf_obj);
+ /* Third argument to Fplist_get is PREDICATE (Emacs 29+), not a
+ default value. Qnil selects the default `eq' comparison. */
+ return Fplist_get (plist, prop, Qnil);
}
-- (void)resetCursorRects
@@ -970,7 +993,7 @@ index 932d209f..ea2de6f2 100644
+ NSAccessibilityPostNotification (element, name);
+ });
+}
+
+static inline void
+ns_ax_post_notification_with_info (id element,
+ NSAccessibilityNotificationName name,
@@ -980,30 +1003,29 @@ index 932d209f..ea2de6f2 100644
+ NSAccessibilityPostNotificationWithUserInfo (element, name, info);
+ });
+}
+
-/*****************************************************************************/
-/* Keyboard handling. */
-#define NS_KEYLOG 0
+/* Scan visible range of window W for interactive spans.
+ Returns NSArray<EmacsAccessibilityInteractiveSpan *>.
-- (void)keyDown: (NSEvent *)theEvent
+ Priority when properties overlap:
+ widget > button > follow-link > org-link >
+ completion-candidate > keymap-overlay. */
+static NSArray *
+ns_ax_scan_interactive_spans (struct window *w,
+ EmacsAccessibilityBuffer *parent_buf)
+{
{
- Mouse_HLInfo *hlinfo = MOUSE_HL_INFO (emacsframe);
+ if (!w)
+ return @[];
-/*****************************************************************************/
-/* Keyboard handling. */
-#define NS_KEYLOG 0
+
+ Lisp_Object buf_obj = ns_ax_window_buffer_object (w);
+ if (NILP (buf_obj))
+ return @[];
-- (void)keyDown: (NSEvent *)theEvent
-{
- Mouse_HLInfo *hlinfo = MOUSE_HL_INFO (emacsframe);
+
+ struct buffer *b = XBUFFER (buf_obj);
+ ptrdiff_t vis_start = marker_position (w->start);
+ ptrdiff_t vis_end = ns_ax_window_end_charpos (w, b);
@@ -1278,6 +1300,11 @@ index 932d209f..ea2de6f2 100644
+ NSString *text = nil;
+ specpdl_ref count = SPECPDL_INDEX ();
+ record_unwind_current_buffer ();
+ /* Block input to prevent concurrent redisplay from modifying buffer
+ state while we read text properties. Unwind-protected so
+ block_input is always matched by unblock_input on signal. */
+ record_unwind_protect_void (unblock_input);
+ block_input ();
+ if (b != current_buffer)
+ set_buffer_internal_1 (b);
+
@@ -2214,26 +2241,11 @@ index 932d209f..ea2de6f2 100644
+ height:text_h];
+}
+
+/* ---- Notification dispatch ---- */
+/* ---- Notification dispatch (helper methods) ---- */
+
+- (void)postAccessibilityNotificationsForFrame:(struct frame *)f
+{
+ NSTRACE ("[EmacsView postAccessibilityNotificationsForFrame:]");
+ struct window *w = [self validWindow];
+ if (!w || !WINDOW_LEAF_P (w))
+ return;
+
+ struct buffer *b = XBUFFER (w->contents);
+ if (!b)
+ return;
+
+ ptrdiff_t modiff = BUF_MODIFF (b);
+ ptrdiff_t point = BUF_PT (b);
+ BOOL markActive = !NILP (BVAR (b, mark_active));
+
+ /* --- Text changed → typing echo ---
+ WebKit AXObjectCacheMac fallback enum: Edit = 1, Typing = 3. */
+ if (modiff != self.cachedModiff)
+/* Post NSAccessibilityValueChangedNotification for a text edit.
+ Called when BUF_MODIFF changes between redisplay cycles. */
+- (void)postTextChangedNotification:(ptrdiff_t)point
+{
+ /* Capture changed char before invalidating cache. */
+ NSString *changedChar = @"";
@@ -2256,11 +2268,10 @@ index 932d209f..ea2de6f2 100644
+ [self invalidateTextCache];
+ }
+
+ self.cachedModiff = modiff;
+ /* Update cachedPoint here so the selection-move branch below
+ does NOT fire for point changes caused by edits. WebKit and
+ Chromium never send both ValueChanged and SelectedTextChanged
+ for the same user action — they are mutually exclusive. */
+ /* Update cachedPoint here so the selection-move branch does NOT
+ fire for point changes caused by edits. WebKit and Chromium
+ never send both ValueChanged and SelectedTextChanged for the
+ same user action — they are mutually exclusive. */
+ self.cachedPoint = point;
+
+ NSDictionary *change = @{
@@ -2277,91 +2288,13 @@ index 932d209f..ea2de6f2 100644
+ self, NSAccessibilityValueChangedNotification, userInfo);
+}
+
+ /* --- Cursor moved or selection changed → line reading ---
+ WebKit AXObjectCacheMac fallback enum: SelectionMove = 2.
+ Use 'else if' — edits and selection moves are mutually exclusive
+ per the WebKit/Chromium pattern. VoiceOver gets confused if
+ both notifications arrive in the same runloop iteration. */
+ else if (point != self.cachedPoint || markActive != self.cachedMarkActive)
+ {
+ ptrdiff_t oldPoint = self.cachedPoint;
+ BOOL oldMarkActive = self.cachedMarkActive;
+ self.cachedPoint = point;
+ self.cachedMarkActive = markActive;
+
+ /* Compute direction. */
+ NSInteger direction = ns_ax_text_selection_direction_discontiguous;
+ if (point > oldPoint)
+ direction = ns_ax_text_selection_direction_next;
+ else if (point < oldPoint)
+ direction = ns_ax_text_selection_direction_previous;
+
+ int ctrlNP = 0;
+ bool isCtrlNP = ns_ax_event_is_line_nav_key (&ctrlNP);
+
+ /* --- Granularity detection ---
+ Compare old and new cursor positions in cachedText to determine
+ what kind of move happened. Three levels:
+ - line: different line (lineRangeForRange)
+ - word: same line, distance > 1 UTF-16 unit
+ - character: same line, distance == 1 UTF-16 unit
+ C-n/C-p force line regardless of detected granularity. */
+ NSInteger granularity = ns_ax_text_selection_granularity_unknown;
+ [self ensureTextCache];
+ NSUInteger oldIdx = 0, newIdx = 0;
+ if (cachedText && oldPoint > 0)
+ {
+ NSUInteger tlen = [cachedText length];
+ oldIdx = [self accessibilityIndexForCharpos:oldPoint];
+ newIdx = [self accessibilityIndexForCharpos:point];
+ if (oldIdx > tlen) oldIdx = tlen;
+ if (newIdx > tlen) newIdx = tlen;
+
+ NSRange oldLine = [cachedText lineRangeForRange:
+ NSMakeRange (oldIdx, 0)];
+ NSRange newLine = [cachedText lineRangeForRange:
+ NSMakeRange (newIdx, 0)];
+ if (oldLine.location != newLine.location)
+ granularity = ns_ax_text_selection_granularity_line;
+ else
+ {
+ NSUInteger dist = (newIdx > oldIdx
+ ? newIdx - oldIdx
+ : oldIdx - newIdx);
+ if (dist > 1)
+ granularity = ns_ax_text_selection_granularity_word;
+ else if (dist == 1)
+ granularity = ns_ax_text_selection_granularity_character;
+ }
+ }
+
+ /* Force line semantics for explicit C-n/C-p / Tab / backtab. */
+ if (isCtrlNP)
+ {
+ direction = (ctrlNP > 0
+ ? ns_ax_text_selection_direction_next
+ : ns_ax_text_selection_direction_previous);
+ granularity = ns_ax_text_selection_granularity_line;
+ }
+
+ /* --- NOTIFICATION STRATEGY ---
+ SelectedTextChanged ALWAYS posted for focused element:
+ - Interrupts VoiceOver auto-read (buffer switch reading)
+ - Provides word/line/selection reading via VoiceOver defaults
+
+ For CHARACTER moves only: omit granularity from userInfo so
+ VoiceOver cannot derive speech from SelectedTextChanged, then
+ post AnnouncementRequested with char AT point. This avoids
+ double-speech while keeping the interrupt behaviour.
+
+ For WORD and LINE moves: include granularity in userInfo —
+ VoiceOver reads the word/line correctly on its own.
+
+ For SELECTION changes: include granularity — VoiceOver reads
+ selected/deselected text.
+
+ Non-focused buffers: AnnouncementRequested only (see below). */
+ if ([self isAccessibilityFocused])
+/* Post SelectedTextChanged and AnnouncementRequested for the
+ focused buffer element when point or mark changes. */
+- (void)postFocusedCursorNotification:(ptrdiff_t)point
+ direction:(NSInteger)direction
+ granularity:(NSInteger)granularity
+ markActive:(BOOL)markActive
+ oldMarkActive:(BOOL)oldMarkActive
+{
+ BOOL isCharMove
+ = (!markActive && !oldMarkActive
@@ -2423,11 +2356,11 @@ index 932d209f..ea2de6f2 100644
+ }
+
+ /* For focused line moves: always announce line text explicitly.
+ SelectedTextChanged with granularity=line works for arrow
+ keys, but C-n/C-p need the explicit announcement (VoiceOver
+ processes these keystrokes differently from arrows).
+ In completion-list-mode, read the completion candidate
+ instead of the whole line. */
+ SelectedTextChanged with granularity=line works for arrow keys,
+ but C-n/C-p need the explicit announcement (VoiceOver processes
+ these keystrokes differently from arrows).
+ In completion-list-mode, read the completion candidate instead
+ of the whole line. */
+ if (cachedText
+ && granularity == ns_ax_text_selection_granularity_line)
+ {
@@ -2479,16 +2412,12 @@ index 932d209f..ea2de6f2 100644
+ }
+}
+
+ /* --- Completions announcement ---
+ When point changes in a non-focused buffer (e.g. *Completions*
+ while the minibuffer has keyboard focus), VoiceOver won't read
+ the change because it's tracking the focused element. Post an
+ announcement so the user hears the selected completion.
+
+ If there is a `completions-highlight` overlay at point (Emacs
+ highlights the selected completion candidate), read its full
+ text instead of just the current line. */
+ if (![self isAccessibilityFocused] && cachedText)
+/* Post AnnouncementRequested for non-focused buffers (typically
+ *Completions* while minibuffer has keyboard focus).
+ VoiceOver does not automatically read changes in non-focused
+ elements, so we announce the selected completion explicitly. */
+- (void)postCompletionAnnouncementForBuffer:(struct buffer *)b
+ point:(ptrdiff_t)point
+{
+ NSString *announceText = nil;
+ ptrdiff_t currentOverlayStart = 0;
@@ -2499,17 +2428,13 @@ index 932d209f..ea2de6f2 100644
+ if (b != current_buffer)
+ set_buffer_internal_1 (b);
+
+ /* 1) Prefer explicit completion candidate property when present.
+ completion--string can be a plain string (simple completion)
+ or a list ("candidate" "annotation") for annotated completions.
+ In the list case, use car (the completion itself). */
+ /* 1) Prefer explicit completion candidate property. */
+ Lisp_Object cstr = Fget_char_property (make_fixnum (point),
+ Qns_ax_completion__string,
+ Qnil);
+ announceText = ns_ax_completion_string_from_prop (cstr);
+
+ /* 2) Fallback: announce the mouse-face span at point.
+ completion-list-mode often marks the active candidate this way. */
+ /* 2) Fallback: mouse-face span at point. */
+ if (!announceText)
+ {
+ Lisp_Object mf = Fget_char_property (make_fixnum (point),
@@ -2519,8 +2444,6 @@ index 932d209f..ea2de6f2 100644
+ ptrdiff_t begv2 = BUF_BEGV (b);
+ ptrdiff_t zv2 = BUF_ZV (b);
+
+ /* Find mouse-face span boundaries using property
+ change functions — O(log n) instead of O(n). */
+ Lisp_Object prev_change
+ = Fprevious_single_char_property_change (
+ make_fixnum (point + 1), Qmouse_face,
@@ -2548,7 +2471,7 @@ index 932d209f..ea2de6f2 100644
+ }
+ }
+
+ /* 3) Fallback: check completions-highlight overlay span at point. */
+ /* 3) Fallback: completions-highlight overlay at point. */
+ if (!announceText)
+ {
+ Lisp_Object faceSym = Qns_ax_completions_highlight;
@@ -2578,17 +2501,16 @@ index 932d209f..ea2de6f2 100644
+ }
+ }
+
+ /* 4) Fallback: select the best completions-highlight overlay.
+ Prefer overlay nearest to point over first-found in buffer. */
+ /* 4) Fallback: nearest completions-highlight overlay. */
+ if (!announceText)
+ {
+ ptrdiff_t ov_start = 0;
+ ptrdiff_t ov_end = 0;
+ if (ns_ax_find_completion_overlay_range (b, point, &ov_start, &ov_end))
+ if (ns_ax_find_completion_overlay_range (b, point,
+ &ov_start, &ov_end))
+ {
+ announceText = ns_ax_completion_text_for_span (self, b,
+ ov_start,
+ ov_end,
+ ov_start, ov_end,
+ cachedText);
+ currentOverlayStart = ov_start;
+ currentOverlayEnd = ov_end;
@@ -2597,7 +2519,7 @@ index 932d209f..ea2de6f2 100644
+
+ unbind_to (count2, Qnil);
+
+ /* Final fallback: read the current line at point. */
+ /* Final fallback: read current line at point. */
+ if (!announceText)
+ {
+ NSUInteger point_idx = [self accessibilityIndexForCharpos:point];
@@ -2614,6 +2536,7 @@ index 932d209f..ea2de6f2 100644
+ }
+ }
+
+ /* Deduplicate: post only when text, overlay, or point changed. */
+ if (announceText)
+ {
+ announceText = [announceText stringByTrimmingCharactersInSet:
@@ -2660,13 +2583,106 @@ index 932d209f..ea2de6f2 100644
+ }
+}
+
+/* ---- Notification dispatch (main entry point) ---- */
+
+/* Dispatch accessibility notifications after a redisplay cycle.
+ Detects three mutually exclusive events: text edit, cursor/mark
+ change, or no change. Delegates to helper methods above. */
+- (void)postAccessibilityNotificationsForFrame:(struct frame *)f
+{
+ NSTRACE ("[EmacsView postAccessibilityNotificationsForFrame:]");
+ struct window *w = [self validWindow];
+ if (!w || !WINDOW_LEAF_P (w))
+ return;
+
+ struct buffer *b = XBUFFER (w->contents);
+ if (!b)
+ return;
+
+ ptrdiff_t modiff = BUF_MODIFF (b);
+ ptrdiff_t point = BUF_PT (b);
+ BOOL markActive = !NILP (BVAR (b, mark_active));
+
+ /* --- Text changed (edit) --- */
+ if (modiff != self.cachedModiff)
+ {
+ self.cachedModiff = modiff;
+ [self postTextChangedNotification:point];
+ }
+
+ /* --- Cursor moved or selection changed ---
+ Use 'else if' — edits and selection moves are mutually exclusive
+ per the WebKit/Chromium pattern. */
+ else if (point != self.cachedPoint || markActive != self.cachedMarkActive)
+ {
+ ptrdiff_t oldPoint = self.cachedPoint;
+ BOOL oldMarkActive = self.cachedMarkActive;
+ self.cachedPoint = point;
+ self.cachedMarkActive = markActive;
+
+ /* Compute direction. */
+ NSInteger direction = ns_ax_text_selection_direction_discontiguous;
+ if (point > oldPoint)
+ direction = ns_ax_text_selection_direction_next;
+ else if (point < oldPoint)
+ direction = ns_ax_text_selection_direction_previous;
+
+ int ctrlNP = 0;
+ bool isCtrlNP = ns_ax_event_is_line_nav_key (&ctrlNP);
+
+ /* --- Granularity detection --- */
+ NSInteger granularity = ns_ax_text_selection_granularity_unknown;
+ [self ensureTextCache];
+ if (cachedText && oldPoint > 0)
+ {
+ NSUInteger tlen = [cachedText length];
+ NSUInteger oldIdx = [self accessibilityIndexForCharpos:oldPoint];
+ NSUInteger newIdx = [self accessibilityIndexForCharpos:point];
+ if (oldIdx > tlen) oldIdx = tlen;
+ if (newIdx > tlen) newIdx = tlen;
+
+ NSRange oldLine = [cachedText lineRangeForRange:
+ NSMakeRange (oldIdx, 0)];
+ NSRange newLine = [cachedText lineRangeForRange:
+ NSMakeRange (newIdx, 0)];
+ if (oldLine.location != newLine.location)
+ granularity = ns_ax_text_selection_granularity_line;
+ else
+ {
+ NSUInteger dist = (newIdx > oldIdx
+ ? newIdx - oldIdx
+ : oldIdx - newIdx);
+ if (dist > 1)
+ granularity = ns_ax_text_selection_granularity_word;
+ else if (dist == 1)
+ granularity = ns_ax_text_selection_granularity_character;
+ }
+ }
+
+ /* Force line semantics for explicit C-n/C-p / Tab / backtab. */
+ if (isCtrlNP)
+ {
+ direction = (ctrlNP > 0
+ ? ns_ax_text_selection_direction_next
+ : ns_ax_text_selection_direction_previous);
+ granularity = ns_ax_text_selection_granularity_line;
+ }
+
+ /* Post notifications for focused and non-focused elements. */
+ if ([self isAccessibilityFocused])
+ [self postFocusedCursorNotification:point
+ direction:direction
+ granularity:granularity
+ markActive:markActive
+ oldMarkActive:oldMarkActive];
+
+ if (![self isAccessibilityFocused] && cachedText)
+ [self postCompletionAnnouncementForBuffer:b point:point];
+ }
+ else
+ {
+ /* Nothing changed (no text edit, no cursor move, no mark change).
+ Overlay state cannot change without a modiff bump, so no scan
+ needed for non-focused buffers. Just reset completion cache
+ for focused buffer to avoid stale announcements. */
+ /* Nothing changed. Reset completion cache for focused buffer
+ to avoid stale announcements. */
+ if ([self isAccessibilityFocused])
+ {
+ self.cachedCompletionAnnouncement = nil;
@@ -2984,7 +3000,7 @@ index 932d209f..ea2de6f2 100644
int code;
unsigned fnKeysym = 0;
static NSMutableArray *nsEvArray;
@@ -8237,6 +10559,31 @@ - (void)windowDidBecomeKey /* for direct calls */
@@ -8237,6 +10584,32 @@ - (void)windowDidBecomeKey /* for direct calls */
XSETFRAME (event.frame_or_window, emacsframe);
kbd_buffer_store_event (&event);
ns_send_appdefined (-1); // Kick main loop
@@ -2993,6 +3009,7 @@ index 932d209f..ea2de6f2 100644
+ /* Notify VoiceOver that the focused accessibility element changed.
+ Post on the focused virtual element so VoiceOver starts tracking it.
+ This is critical for initial focus and app-switch scenarios. */
+ if (ns_accessibility_enabled)
+ {
+ id focused = [self accessibilityFocusedUIElement];
+ if (focused
@@ -3016,7 +3033,7 @@ index 932d209f..ea2de6f2 100644
}
@@ -9474,6 +11821,332 @@ - (int) fullscreenState
@@ -9474,6 +11847,332 @@ - (int) fullscreenState
return fs_state;
}
@@ -3171,7 +3188,7 @@ index 932d209f..ea2de6f2 100644
+ NSTRACE ("[EmacsView postAccessibilityUpdates]");
+ eassert ([NSThread isMainThread]);
+
+ if (!emacsframe)
+ if (!emacsframe || !ns_accessibility_enabled)
+ return;
+
+ /* Re-entrance guard: VoiceOver callbacks during notification posting
@@ -3349,7 +3366,7 @@ index 932d209f..ea2de6f2 100644
@end /* EmacsView */
@@ -11303,7 +13976,29 @@ Convert an X font name (XLFD) to an NS font name.
@@ -11303,6 +14002,28 @@ Convert an X font name (XLFD) to an NS font name.
DEFSYM (Qns_drag_operation_generic, "ns-drag-operation-generic");
DEFSYM (Qns_handle_drag_motion, "ns-handle-drag-motion");
@@ -3375,10 +3392,25 @@ index 932d209f..ea2de6f2 100644
+ DEFSYM (Qns_ax_completions_highlight, "completions-highlight");
+ DEFSYM (Qns_ax_backtab, "backtab");
+ /* Qmouse_face and Qkeymap are defined in textprop.c / keymap.c. */
+
Fput (Qalt, Qmodifier_value, make_fixnum (alt_modifier));
Fput (Qhyper, Qmodifier_value, make_fixnum (hyper_modifier));
Fput (Qmeta, Qmodifier_value, make_fixnum (meta_modifier));
@@ -11451,6 +14172,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. */);
ns_use_srgb_colorspace = YES;
+ DEFVAR_BOOL ("ns-accessibility-enabled", ns_accessibility_enabled,
+ doc: /* Non-nil means expose buffer content to the macOS accessibility
+subsystem (VoiceOver, Zoom, and other assistive technology).
+When nil, the accessibility virtual element tree is not built and no
+notifications are posted, eliminating the associated overhead.
+Requires the Cocoa (NS) build on macOS; ignored on GNUstep.
+Default is t. */);
+ ns_accessibility_enabled = YES;
+
DEFVAR_BOOL ("ns-use-mwheel-acceleration",
ns_use_mwheel_acceleration,
doc: /* Non-nil means use macOS's standard mouse wheel acceleration.
--
2.43.0

View File

@@ -1,24 +1,24 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From ce3b2a8091c99f738ec59acd6f6ebf0d84826e34 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Fri, 27 Feb 2026 17:30:00 +0100
Date: Fri, 27 Feb 2026 17:49:51 +0100
Subject: [PATCH 2/2] doc: add VoiceOver accessibility section to macOS
appendix
Document the new VoiceOver accessibility support in the Emacs manual.
Add a new section to the macOS appendix covering screen reader usage,
keyboard navigation feedback, completion announcements, and Zoom
cursor tracking.
keyboard navigation feedback, completion announcements, Zoom cursor
tracking, and the ns-accessibility-enabled user option.
* doc/emacs/macos.texi (VoiceOver Accessibility): New section.
---
doc/emacs/macos.texi | 46 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 46 insertions(+)
doc/emacs/macos.texi | 53 ++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 53 insertions(+)
diff --git a/doc/emacs/macos.texi b/doc/emacs/macos.texi
index 1234567..abcdefg 100644
index 6bd334f..1d969f9 100644
--- a/doc/emacs/macos.texi
+++ b/doc/emacs/macos.texi
@@ -31,6 +31,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.
* Mac / GNUstep Basics:: Basic Emacs usage under GNUstep or macOS.
* Mac / GNUstep Customization:: Customizations under GNUstep or macOS.
* Mac / GNUstep Events:: How window system events are handled.
@@ -26,10 +26,10 @@ index 1234567..abcdefg 100644
* GNUstep Support:: Details on status of GNUstep support.
@end menu
@@ -272,6 +273,51 @@ services and receive the results back. Note that you may need to
@@ -272,6 +273,58 @@ and return the result as a string. You can also use the Lisp function
services and receive the results back. Note that you may need to
restart Emacs to access newly-available services.
+@node VoiceOver Accessibility
+@section VoiceOver Accessibility (macOS)
+@cindex VoiceOver
@@ -70,6 +70,12 @@ index 1234567..abcdefg 100644
+position is communicated via @code{UAZoomChangeFocus} and the
+@code{AXBoundsForRange} accessibility attribute.
+
+@vindex ns-accessibility-enabled
+ To disable the accessibility interface entirely (for instance, to
+eliminate overhead on systems where assistive technology is not in
+use), set @code{ns-accessibility-enabled} to @code{nil}. The default
+is @code{t}.
+
+ This support is available only on the Cocoa build; GNUstep has a
+different accessibility model and is not yet supported
+(@pxref{GNUstep Support}). Evil-mode block cursors are handled
@@ -81,3 +87,4 @@ index 1234567..abcdefg 100644
--
2.43.0

View File

@@ -3,10 +3,10 @@ EMACS NS VOICEOVER ACCESSIBILITY PATCH
patch: 0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch
0002-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch
author: Martin Sukany <martin@sukany.cz>
files: src/nsterm.h (+105 lines)
src/nsterm.m (+2846 ins, -151 del, +2695 net)
doc/emacs/macos.texi (+46 lines)
etc/NEWS (+11 lines)
files: src/nsterm.h (+119 lines)
src/nsterm.m (+3024 ins, -147 del, +2877 net)
doc/emacs/macos.texi (+53 lines)
etc/NEWS (+13 lines)
OVERVIEW
@@ -114,6 +114,18 @@ ARCHITECTURE
accessibilityAttributeValue:forParameter: API.
USER OPTION
-----------
ns-accessibility-enabled (DEFVAR_BOOL, default t):
When nil, the accessibility virtual element tree is not built, no
notifications are posted, and ns_draw_phys_cursor skips the Zoom
update. This eliminates accessibility overhead entirely on systems
where assistive technology is not in use. Guarded at three entry
points: postAccessibilityUpdates, ns_draw_phys_cursor, and
windowDidBecomeKey.
THREADING MODEL
---------------
@@ -615,4 +627,38 @@ TESTING CHECKLIST
26. Open an org-mode file with many folded sections. Verify that
folded (invisible) text is not announced during navigation.
REVIEW CHANGES (post initial implementation)
---------------------------------------------
The following changes were made based on maintainer-style code review:
1. ns_ax_window_end_charpos: added window_end_valid guard. Falls
back to BUF_ZV when the window has not been fully redisplayed,
preventing stale data in AX getters called before next redisplay.
2. GC safety documentation: detailed comment on lispWindow ivar
explaining why staticpro is not needed (windows reachable from
frame tree, GC only on main thread, AX getters dispatch to main).
3. ns-accessibility-enabled (DEFVAR_BOOL): new user option to
disable accessibility entirely. Guards three entry points.
4. postAccessibilityNotificationsForFrame: extracted from one ~200
line method into four focused helpers:
- postTextChangedNotification: (typing echo)
- postFocusedCursorNotification:direction:granularity:markActive:
oldMarkActive: (focused cursor/selection)
- postCompletionAnnouncementForBuffer:point: (completions)
- postAccessibilityNotificationsForFrame: (orchestrator, ~60 lines)
5. ns_ax_completion_text_for_span: added block_input/unblock_input
with specpdl unwind protection for signal safety.
6. Fplist_get third-argument comment (PREDICATE, not default value).
7. Documentation: macos.texi section updated with
ns-accessibility-enabled variable reference. etc/NEWS updated.
-- end of README --