patches: 5-patch VoiceOver series (improved split + safety docs)

Split into 5 logical patches:
  0001: Base classes + text extraction (+474)
  0002: Buffer + ModeLine protocol (+1620)
  0003: Interactive spans (+403)
  0004: EmacsView integration + etc/NEWS (+408)
  0005: Documentation (+75)

Improvements over previous version:
- 5 patches (was 3): finer granularity
- Helpers placed in correct patches (find_completion_overlay_range,
  event_is_line_nav_key moved to patch with their users)
- etc/NEWS moved to last functional patch (0004)
- ChangeLog-format commit messages
- Longjmp safety analysis comment in code
- Code reorganized for clean sequential patches
This commit is contained in:
2026-02-28 10:11:16 +01:00
parent 67b1d25c34
commit 5016155c8a
5 changed files with 745 additions and 758 deletions

View File

@@ -1,90 +1,37 @@
From eb8038a4d9c4fb4640b0987d6529e8b961353596 Mon Sep 17 00:00:00 2001
From 53a22c5d2014002256acf1c1bc14af2dfbb26469 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 09:54:28 +0100
Subject: [PATCH 1/4] ns: add accessibility base classes and text extraction
Date: Sat, 28 Feb 2026 10:10:55 +0100
Subject: [PATCH 1/5] ns: add accessibility base classes and text extraction
Add the foundation for macOS VoiceOver accessibility support in the
NS (Cocoa) port. This patch provides the base class hierarchy, text
extraction with invisible-text handling, coordinate mapping, and
notification helpers. No existing code paths are modified.
Add the foundation for macOS VoiceOver accessibility in the NS
(Cocoa) port. No existing code paths are modified.
New types (nsterm.h):
ns_ax_visible_run: maps buffer character positions to UTF-16
accessibility indices, skipping invisible text.
EmacsAccessibilityElement: base class for virtual AX elements.
Forward declarations for EmacsAccessibilityBuffer,
EmacsAccessibilityModeLine, EmacsAccessibilityInteractiveSpan.
EmacsAXSpanType: enum for interactive span classification.
EmacsView ivar extensions: accessibilityElements, last-
SelectedWindow, accessibilityTreeValid, lastAccessibilityCursorRect.
New helper functions (nsterm.m):
ns_ax_buffer_text: build accessibility string with visible-run
mapping. Uses TEXT_PROP_MEANS_INVISIBLE for spec-controlled
invisibility, Fbuffer_substring_no_properties for buffer-gap
safety. Capped at NS_AX_TEXT_CAP (100,000 UTF-16 units).
ns_ax_mode_line_text: extract mode-line text from glyph matrix
(CHAR_GLYPH only; image/stretch glyphs skipped with TODO note).
ns_ax_frame_for_range: screen rect for character range via glyph
matrix lookup with text-area clipping.
ns_ax_post_notification, ns_ax_post_notification_with_info:
dispatch_async wrappers to prevent deadlock.
Utility helpers: ns_ax_completion_string_from_prop,
ns_ax_window_buffer_object, ns_ax_window_end_charpos,
ns_ax_text_prop_at, ns_ax_next_prop_change, ns_ax_get_span_label.
EmacsAccessibilityElement @implementation: base class with
validWindow, screenRectFromEmacsX:y:width:height:, and hierarchy
plumbing (accessibilityParent, accessibilityWindow).
New user option: ns-accessibility-enabled (default t).
Tested on macOS 14 Sonoma. Builds cleanly; base class instantiates;
symbols register; no functional change (integration in next patch).
* src/nsterm.h: New class declarations, struct, enum, ivar extensions.
* src/nsterm.m: Helper functions, base element, DEFSYM, DEFVAR.
* etc/NEWS: Document VoiceOver accessibility support.
* src/nsterm.h (ns_ax_visible_run): New struct for buffer-to-UTF-16
index mapping.
(EmacsAccessibilityElement): New base class for virtual AX elements.
(EmacsAccessibilityBuffer, EmacsAccessibilityModeLine)
(EmacsAccessibilityInteractiveSpan): Forward declarations.
(EmacsAXSpanType): New enum for span classification.
(EmacsView): New ivars accessibilityElements, lastSelectedWindow,
accessibilityTreeValid, lastAccessibilityCursorRect.
* src/nsterm.m: Include intervals.h for TEXT_PROP_MEANS_INVISIBLE.
(ns_ax_buffer_text): New function. Build accessibility string with
visible-run mapping; skip invisible text per spec.
(ns_ax_mode_line_text): New function. Extract CHAR_GLYPH text.
(ns_ax_frame_for_range): New function. Screen rect via glyph matrix.
(ns_ax_post_notification, ns_ax_post_notification_with_info): New
functions. dispatch_async wrappers preventing VoiceOver deadlock.
(ns_ax_completion_string_from_prop, ns_ax_window_buffer_object)
(ns_ax_window_end_charpos, ns_ax_text_prop_at)
(ns_ax_next_prop_change, ns_ax_get_span_label): New utility helpers.
(EmacsAccessibilityElement): Implement base class.
(syms_of_nsterm): Register accessibility DEFSYM and DEFVAR
ns-accessibility-enabled.
---
etc/NEWS | 13 ++
src/nsterm.h | 119 ++++++++++
src/nsterm.m | 621 +++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 753 insertions(+)
src/nsterm.h | 119 +++++++++++++++++
src/nsterm.m | 355 +++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 474 insertions(+)
diff --git a/etc/NEWS b/etc/NEWS
index 7367e3c..608650e 100644
--- a/etc/NEWS
+++ b/etc/NEWS
@@ -4374,6 +4374,19 @@ allowing Emacs users access to speech recognition utilities.
Note: Accepting this permission allows the use of system APIs, which may
send user data to Apple's speech recognition servers.
+---
+** VoiceOver accessibility support on macOS.
+Emacs now exposes buffer content, cursor position, and interactive
+elements to the macOS accessibility subsystem (VoiceOver). This
+includes AXBoundsForRange for macOS Zoom cursor tracking, line and
+word navigation announcements, Tab-navigable interactive spans
+(buttons, links, completion candidates), and completion announcements
+for the *Completions* buffer. The implementation uses a virtual
+accessibility tree with per-window elements, hybrid SelectedTextChanged
+and AnnouncementRequested notifications, and thread-safe text caching.
+Set 'ns-accessibility-enabled' to nil to disable the accessibility
+interface and eliminate the associated overhead.
+
---
** Re-introduced dictation, lost in Emacs v30 (macOS).
We lost macOS dictation in v30 when migrating to NSTextInputClient.
diff --git a/src/nsterm.h b/src/nsterm.h
index 7c1ee4c..393fc4c 100644
--- a/src/nsterm.h
@@ -230,7 +177,7 @@ index 7c1ee4c..393fc4c 100644
diff --git a/src/nsterm.m b/src/nsterm.m
index 74e4ad5..ee27df1 100644
index 74e4ad5..c91ec90 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -46,6 +46,7 @@ GNUstep port and post-20 update by Adrian Robert (arobert@cogsci.ucsd.edu)
@@ -241,7 +188,7 @@ index 74e4ad5..ee27df1 100644
#include "systime.h"
#include "character.h"
#include "xwidget.h"
@@ -6856,6 +6857,595 @@ ns_create_font_panel_buttons (id target, SEL select, SEL cancel_action)
@@ -6856,6 +6857,329 @@ ns_create_font_panel_buttons (id target, SEL select, SEL cancel_action)
}
#endif
@@ -506,274 +453,8 @@ index 74e4ad5..ee27df1 100644
+ ns_ax_text_selection_granularity_line = 3,
+};
+
+static BOOL
+ns_ax_find_completion_overlay_range (struct buffer *b, ptrdiff_t point,
+ ptrdiff_t *out_start,
+ ptrdiff_t *out_end)
+{
+ if (!b || !out_start || !out_end)
+ return NO;
+
+ Lisp_Object faceSym = Qns_ax_completions_highlight;
+ ptrdiff_t begv = BUF_BEGV (b);
+ ptrdiff_t zv = BUF_ZV (b);
+ ptrdiff_t best_start = 0;
+ ptrdiff_t best_end = 0;
+ ptrdiff_t best_dist = PTRDIFF_MAX;
+ BOOL found = NO;
+
+ /* Fast path: look at point and immediate neighbors first.
+ Prefer point+1 over point-1: when Tab moves to a new completion,
+ point is at the START of the new entry while point-1 is still
+ inside the previous entry's overlay. Forward probe finds the
+ correct new entry; backward probe finds the wrong old one. */
+ ptrdiff_t probes[3] = { point, point + 1, point - 1 };
+ for (int i = 0; i < 3 && !found; i++)
+ {
+ ptrdiff_t p = probes[i];
+ if (p < begv || p > zv)
+ continue;
+
+ Lisp_Object overlays = Foverlays_at (make_fixnum (p), Qnil);
+ Lisp_Object tail;
+ for (tail = overlays; CONSP (tail); tail = XCDR (tail))
+ {
+ Lisp_Object ov = XCAR (tail);
+ Lisp_Object face = Foverlay_get (ov, Qface);
+ if (!(EQ (face, faceSym)
+ || (CONSP (face) && !NILP (Fmemq (faceSym, face)))))
+ continue;
+
+ ptrdiff_t ov_start = OVERLAY_START (ov);
+ ptrdiff_t ov_end = OVERLAY_END (ov);
+ if (ov_end <= ov_start)
+ continue;
+
+ best_start = ov_start;
+ best_end = ov_end;
+ best_dist = 0;
+ found = YES;
+ break;
+ }
+ }
+
+ if (!found)
+ {
+ /* Bulk query: get all overlays in the buffer at once.
+ Avoids the previous O(n) per-character Foverlays_at loop. */
+ Lisp_Object all = Foverlays_in (make_fixnum (begv),
+ make_fixnum (zv));
+ Lisp_Object tail;
+ for (tail = all; CONSP (tail); tail = XCDR (tail))
+ {
+ Lisp_Object ov = XCAR (tail);
+ Lisp_Object face = Foverlay_get (ov, Qface);
+ if (!(EQ (face, faceSym)
+ || (CONSP (face)
+ && !NILP (Fmemq (faceSym, face)))))
+ continue;
+
+ ptrdiff_t ov_start = OVERLAY_START (ov);
+ ptrdiff_t ov_end = OVERLAY_END (ov);
+ if (ov_end <= ov_start)
+ continue;
+
+ ptrdiff_t dist = 0;
+ if (point < ov_start)
+ dist = ov_start - point;
+ else if (point > ov_end)
+ dist = point - ov_end;
+
+ if (!found || dist < best_dist)
+ {
+ best_start = ov_start;
+ best_end = ov_end;
+ best_dist = dist;
+ found = YES;
+ }
+ }
+ }
+
+ if (!found)
+ return NO;
+
+ *out_start = best_start;
+ *out_end = best_end;
+ return YES;
+}
+
+/* Detect line-level navigation commands. Inspects Vthis_command
+ (the command symbol being executed) rather than raw key codes so
+ that remapped bindings (e.g., C-j -> next-line) are recognized.
+ Falls back to last_command_event for Tab/backtab which are not
+ bound to a single canonical command symbol. */
+static bool
+ns_ax_event_is_line_nav_key (int *which)
+{
+ /* 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). */
+ if (SYMBOLP (Vthis_command) && !NILP (Vthis_command))
+ {
+ Lisp_Object cmd = Vthis_command;
+ /* Forward line commands. */
+ if (EQ (cmd, Qns_ax_next_line)
+ || EQ (cmd, Qns_ax_dired_next_line)
+ || EQ (cmd, Qns_ax_evil_next_line)
+ || EQ (cmd, Qns_ax_evil_next_visual_line))
+ {
+ if (which) *which = 1;
+ return true;
+ }
+ /* Backward line commands. */
+ if (EQ (cmd, Qns_ax_previous_line)
+ || EQ (cmd, Qns_ax_dired_previous_line)
+ || EQ (cmd, Qns_ax_evil_previous_line)
+ || EQ (cmd, Qns_ax_evil_previous_visual_line))
+ {
+ if (which) *which = -1;
+ return true;
+ }
+ }
+
+ /* 2. Fallback: check raw key events for Tab/backtab. */
+ Lisp_Object ev = last_command_event;
+ if (CONSP (ev))
+ ev = EVENT_HEAD (ev);
+
+ if (SYMBOLP (ev) && EQ (ev, Qns_ax_backtab))
+ {
+ if (which) *which = -1;
+ return true;
+ }
+ if (FIXNUMP (ev) && XFIXNUM (ev) == 9) /* Tab */
+ {
+ if (which) *which = 1;
+ return true;
+ }
+ return false;
+}
+
+/* ===================================================================
+ EmacsAccessibilityInteractiveSpan — helpers and implementation
+ =================================================================== */
+
+/* Extract announcement string from completion--string property value.
+ The property can be a plain Lisp string (simple completion) or
+ a list ("candidate" "annotation") for annotated completions.
+ Returns nil on failure. */
+static NSString *
+ns_ax_completion_string_from_prop (Lisp_Object cstr)
+{
+ if (STRINGP (cstr))
+ return [NSString stringWithLispString: cstr];
+ if (CONSP (cstr) && STRINGP (XCAR (cstr)))
+ return [NSString stringWithLispString: XCAR (cstr)];
+ return nil;
+}
+
+/* Return the Emacs buffer Lisp object for window W, or Qnil. */
+static Lisp_Object
+ns_ax_window_buffer_object (struct window *w)
+{
+ if (!w)
+ return Qnil;
+ if (!BUFFERP (w->contents))
+ return Qnil;
+ return w->contents;
+}
+
+/* Compute visible-end charpos for window W.
+ Emacs stores it as BUF_Z - window_end_pos.
+ Falls back to BUF_ZV when window_end_valid is false (e.g., when
+ called from an AX getter before the next redisplay cycle). */
+static ptrdiff_t
+ns_ax_window_end_charpos (struct window *w, struct buffer *b)
+{
+ if (!w->window_end_valid)
+ return BUF_ZV (b);
+ return BUF_Z (b) - w->window_end_pos;
+}
+
+/* Fetch text property PROP at charpos POS in BUF_OBJ. */
+static Lisp_Object
+ns_ax_text_prop_at (ptrdiff_t pos, Lisp_Object prop, Lisp_Object buf_obj)
+{
+ Lisp_Object plist = Ftext_properties_at (make_fixnum (pos), buf_obj);
+ /* Third argument to Fplist_get is PREDICATE (Emacs 29+), not a
+ default value. Qnil selects the default `eq' comparison. */
+ return Fplist_get (plist, prop, Qnil);
+}
+
+/* Next charpos where PROP changes, capped at LIMIT. */
+static ptrdiff_t
+ns_ax_next_prop_change (ptrdiff_t pos, Lisp_Object prop,
+ Lisp_Object buf_obj, ptrdiff_t limit)
+{
+ Lisp_Object result
+ = Fnext_single_property_change (make_fixnum (pos), prop,
+ buf_obj, make_fixnum (limit));
+ return FIXNUMP (result) ? XFIXNUM (result) : limit;
+}
+
+/* Build label for span [START, END) in BUF_OBJ.
+ Priority: completion--string → buffer text → help-echo. */
+static NSString *
+ns_ax_get_span_label (ptrdiff_t start, ptrdiff_t end,
+ Lisp_Object buf_obj)
+{
+ Lisp_Object cs = ns_ax_text_prop_at (start, Qns_ax_completion__string,
+ buf_obj);
+ if (STRINGP (cs))
+ return [NSString stringWithLispString: cs];
+
+ if (end > start)
+ {
+ Lisp_Object substr = Fbuffer_substring_no_properties (
+ make_fixnum (start), make_fixnum (end));
+ if (STRINGP (substr))
+ {
+ NSString *s = [NSString stringWithLispString: substr];
+ s = [s stringByTrimmingCharactersInSet:
+ [NSCharacterSet whitespaceAndNewlineCharacterSet]];
+ if (s.length > 0)
+ return s;
+ }
+ }
+
+ Lisp_Object he = ns_ax_text_prop_at (start, Qhelp_echo, buf_obj);
+ if (STRINGP (he))
+ return [NSString stringWithLispString: he];
+
+ return @"";
+}
+
+/* Post AX notifications asynchronously to prevent deadlock.
+ NSAccessibilityPostNotification may synchronously invoke VoiceOver
+ callbacks that dispatch_sync back to the main queue. If we are
+ already on the main queue (e.g., inside postAccessibilityUpdates
+ called from ns_update_end), that dispatch_sync deadlocks.
+ Deferring via dispatch_async lets the current method return first,
+ freeing the main queue for VoiceOver's dispatch_sync calls. */
+
+static inline void
+ns_ax_post_notification (id element,
+ NSAccessibilityNotificationName name)
+{
+ dispatch_async (dispatch_get_main_queue (), ^{
+ NSAccessibilityPostNotification (element, name);
+ });
+}
+
+static inline void
+ns_ax_post_notification_with_info (id element,
+ NSAccessibilityNotificationName name,
+ NSDictionary *info)
+{
+ dispatch_async (dispatch_get_main_queue (), ^{
+ NSAccessibilityPostNotificationWithUserInfo (element, name, info);
+ });
+}
+
+
+@implementation EmacsAccessibilityElement
@@ -837,7 +518,7 @@ index 74e4ad5..ee27df1 100644
/* ==========================================================================
EmacsView implementation
@@ -11312,6 +11902,28 @@ syms_of_nsterm (void)
@@ -11312,6 +11636,28 @@ syms_of_nsterm (void)
DEFSYM (Qns_drag_operation_generic, "ns-drag-operation-generic");
DEFSYM (Qns_handle_drag_motion, "ns-handle-drag-motion");
@@ -866,7 +547,7 @@ index 74e4ad5..ee27df1 100644
Fput (Qalt, Qmodifier_value, make_fixnum (alt_modifier));
Fput (Qhyper, Qmodifier_value, make_fixnum (hyper_modifier));
Fput (Qmeta, Qmodifier_value, make_fixnum (meta_modifier));
@@ -11460,6 +12072,15 @@ Note that this does not apply to images.
@@ -11460,6 +11806,15 @@ Note that this does not apply to images.
This variable is ignored on Mac OS X < 10.7 and GNUstep. */);
ns_use_srgb_colorspace = YES;

View File

@@ -1,348 +1,193 @@
From a49c6b5a9601fe11a6a03292e8b4d685a0ce50af Mon Sep 17 00:00:00 2001
From 757988a19f7aef1f03ec277f898d92bdd4f2607e Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 09:54:28 +0100
Subject: [PATCH 2/4] ns: implement buffer, mode-line, and interactive span
elements
Date: Sat, 28 Feb 2026 10:10:55 +0100
Subject: [PATCH 2/5] ns: implement buffer and mode-line accessibility elements
Add the three remaining virtual element classes, completing the
accessibility object model. Combined with the previous patch, this
provides a full NSAccessibility text protocol implementation.
Add the full NSAccessibility text protocol for Emacs windows and
mode-line readout.
EmacsAccessibilityBuffer <NSAccessibility>: full text protocol for
a single Emacs window.
Text cache: @synchronized caching of buffer text and visible-run
array. Cache invalidated on modiff_count, window start, or
invisible-text configuration change.
Index mapping: binary search O(log n) between buffer positions and
UTF-16 accessibility indices via the visible-run array.
Selection: selectedTextRange from point/mark; insertion point from
point via index mapping.
Geometry: lineForIndex/indexForLine by newline scanning.
frameForRange delegates to ns_ax_frame_for_range.
Notification dispatch (postTextChangedNotification): hybrid
SelectedTextChanged / ValueChanged / AnnouncementRequested,
modeled on WebKit's pattern. Line navigation emits ValueChanged;
character/word motion emits SelectedTextChanged only. Completion
buffer announcements via AnnouncementRequested with High priority.
EmacsAccessibilityModeLine: AXStaticText exposing mode-line content.
EmacsAccessibilityInteractiveSpan: lightweight child of a buffer
element for Tab-navigable interactive text.
ns_ax_scan_interactive_spans: scan visible range with O(n/skip)
property-skip optimization. Priority: widget > button > follow-link
> org-link > completion-candidate > keymap-overlay.
Buffer (InteractiveSpans) category: Tab/Shift-Tab cycling with
wrap-around and VoiceOver focus notification.
ns_ax_completion_text_for_span: extract completion candidate text.
Threading: Lisp-accessing methods use dispatch_sync to main thread;
@synchronized protects text cache.
Tested on macOS 14 with VoiceOver. Verified: buffer reading, line
navigation, word/character announcements, completion announcements,
Tab-cycling interactive spans, mode-line readout.
* src/nsterm.m: EmacsAccessibilityBuffer, EmacsAccessibilityModeLine,
EmacsAccessibilityInteractiveSpan, supporting functions.
* src/nsterm.m (ns_ax_find_completion_overlay_range): New function.
(ns_ax_event_is_line_nav_key): New function.
(ns_ax_completion_text_for_span): New function.
(EmacsAccessibilityBuffer): Implement NSAccessibility protocol:
text cache with @synchronized, visible-run binary search O(log n),
selectedTextRange, lineForIndex/indexForLine, frameForRange,
rangeForPosition, setAccessibilitySelectedTextRange, setAccessibility-
Focused.
(EmacsAccessibilityBuffer postTextChangedNotification:): New method.
ValueChanged with edit details.
(EmacsAccessibilityBuffer postFocusedCursorNotification:direction:
granularity:markActive:oldMarkActive:): New method. Hybrid
SelectedTextChanged / AnnouncementRequested per WebKit pattern.
(EmacsAccessibilityBuffer postCompletionAnnouncementForBuffer:point:):
New method. Announce completion candidates in non-focused buffers.
(EmacsAccessibilityBuffer postAccessibilityNotificationsForFrame:):
New method. Main dispatch: edit vs cursor-move vs no-change.
(EmacsAccessibilityModeLine): Implement AXStaticText element.
---
src/nsterm.m | 1716 ++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 1716 insertions(+)
src/nsterm.m | 1620 ++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 1620 insertions(+)
diff --git a/src/nsterm.m b/src/nsterm.m
index ee27df1..c47912d 100644
index c91ec90..90db3b7 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -7387,6 +7387,351 @@ ns_ax_post_notification_with_info (id element,
});
}
@@ -7177,6 +7177,1626 @@ enum {
@end
+/* Scan visible range of window W for interactive spans.
+ Returns NSArray<EmacsAccessibilityInteractiveSpan *>.
+
+ Priority when properties overlap:
+ widget > button > follow-link > org-link >
+ completion-candidate > keymap-overlay. */
+static NSArray *
+ns_ax_scan_interactive_spans (struct window *w,
+ EmacsAccessibilityBuffer *parent_buf)
+
+
+
+static BOOL
+ns_ax_find_completion_overlay_range (struct buffer *b, ptrdiff_t point,
+ ptrdiff_t *out_start,
+ ptrdiff_t *out_end)
+{
+ if (!w)
+ return @[];
+ if (!b || !out_start || !out_end)
+ return NO;
+
+ Lisp_Object buf_obj = ns_ax_window_buffer_object (w);
+ if (NILP (buf_obj))
+ return @[];
+ Lisp_Object faceSym = Qns_ax_completions_highlight;
+ ptrdiff_t begv = BUF_BEGV (b);
+ ptrdiff_t zv = BUF_ZV (b);
+ ptrdiff_t best_start = 0;
+ ptrdiff_t best_end = 0;
+ ptrdiff_t best_dist = PTRDIFF_MAX;
+ BOOL found = NO;
+
+ 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);
+ /* Fast path: look at point and immediate neighbors first.
+ Prefer point+1 over point-1: when Tab moves to a new completion,
+ point is at the START of the new entry while point-1 is still
+ inside the previous entry's overlay. Forward probe finds the
+ correct new entry; backward probe finds the wrong old one. */
+ ptrdiff_t probes[3] = { point, point + 1, point - 1 };
+ for (int i = 0; i < 3 && !found; i++)
+ {
+ ptrdiff_t p = probes[i];
+ if (p < begv || p > zv)
+ continue;
+
+ if (vis_start < BUF_BEGV (b)) vis_start = BUF_BEGV (b);
+ if (vis_end > BUF_ZV (b)) vis_end = BUF_ZV (b);
+ if (vis_start >= vis_end)
+ return @[];
+ Lisp_Object overlays = Foverlays_at (make_fixnum (p), Qnil);
+ Lisp_Object tail;
+ for (tail = overlays; CONSP (tail); tail = XCDR (tail))
+ {
+ Lisp_Object ov = XCAR (tail);
+ Lisp_Object face = Foverlay_get (ov, Qface);
+ if (!(EQ (face, faceSym)
+ || (CONSP (face) && !NILP (Fmemq (faceSym, face)))))
+ continue;
+
+ /* Symbols are interned once at startup via DEFSYM in syms_of_nsterm;
+ reference them directly here (GC-safe, no repeated obarray lookup). */
+ ptrdiff_t ov_start = OVERLAY_START (ov);
+ ptrdiff_t ov_end = OVERLAY_END (ov);
+ if (ov_end <= ov_start)
+ continue;
+
+ BOOL is_completion_buf = EQ (BVAR (b, major_mode), Qns_ax_completion_list_mode);
+
+ NSMutableArray *spans = [NSMutableArray array];
+ ptrdiff_t pos = vis_start;
+
+ while (pos < vis_end)
+ {
+ Lisp_Object plist = Ftext_properties_at (make_fixnum (pos), buf_obj);
+ EmacsAXSpanType span_type = EmacsAXSpanTypeNone;
+ Lisp_Object limit_prop = Qnil;
+
+ if (!NILP (Fplist_get (plist, Qns_ax_widget, Qnil)))
+ {
+ span_type = EmacsAXSpanTypeWidget;
+ limit_prop = Qns_ax_widget;
+ }
+ else if (!NILP (Fplist_get (plist, Qns_ax_button, Qnil)))
+ {
+ span_type = EmacsAXSpanTypeButton;
+ limit_prop = Qns_ax_button;
+ }
+ else if (!NILP (Fplist_get (plist, Qns_ax_follow_link, Qnil)))
+ {
+ span_type = EmacsAXSpanTypeLink;
+ limit_prop = Qns_ax_follow_link;
+ }
+ else if (!NILP (Fplist_get (plist, Qns_ax_org_link, Qnil)))
+ {
+ span_type = EmacsAXSpanTypeLink;
+ limit_prop = Qns_ax_org_link;
+ }
+ else if (is_completion_buf
+ && !NILP (Fplist_get (plist, Qmouse_face, Qnil)))
+ {
+ /* For completions, use completion--string as boundary so we
+ don't accidentally merge two column-adjacent candidates
+ whose mouse-face regions may share padding whitespace.
+ Fall back to mouse-face if completion--string is absent. */
+ Lisp_Object cs_sym = Qns_ax_completion__string;
+ Lisp_Object cs_val = ns_ax_text_prop_at (pos, cs_sym, buf_obj);
+ span_type = EmacsAXSpanTypeCompletionItem;
+ limit_prop = NILP (cs_val) ? Qmouse_face : cs_sym;
+ }
+ else
+ {
+ /* Check overlays for keymap. */
+ Lisp_Object ovs
+ = Foverlays_in (make_fixnum (pos), make_fixnum (pos + 1));
+ while (CONSP (ovs))
+ {
+ if (!NILP (Foverlay_get (XCAR (ovs), Qkeymap)))
+ {
+ span_type = EmacsAXSpanTypeButton;
+ limit_prop = Qkeymap;
+ best_start = ov_start;
+ best_end = ov_end;
+ best_dist = 0;
+ found = YES;
+ break;
+ }
+ ovs = XCDR (ovs);
+ }
+ }
+
+ if (span_type == EmacsAXSpanTypeNone)
+ if (!found)
+ {
+ /* Skip to the next position where any interactive property
+ changes. Try each scannable property in turn and take
+ the nearest change point — O(properties) per gap rather
+ than O(chars). Fall back to pos+1 as safety net. */
+ ptrdiff_t next_interesting = vis_end;
+ Lisp_Object skip_props[5]
+ = { Qns_ax_widget, Qns_ax_button, Qns_ax_follow_link,
+ Qns_ax_org_link, Qmouse_face };
+ for (int sp = 0; sp < 5; sp++)
+ /* Bulk query: get all overlays in the buffer at once.
+ Avoids the previous O(n) per-character Foverlays_at loop. */
+ Lisp_Object all = Foverlays_in (make_fixnum (begv),
+ make_fixnum (zv));
+ Lisp_Object tail;
+ for (tail = all; CONSP (tail); tail = XCDR (tail))
+ {
+ ptrdiff_t np
+ = ns_ax_next_prop_change (pos, skip_props[sp],
+ buf_obj, vis_end);
+ if (np > pos && np < next_interesting)
+ next_interesting = np;
+ }
+ /* Also check overlay keymap changes. */
+ Lisp_Object np_ov
+ = Fnext_single_char_property_change (make_fixnum (pos),
+ Qkeymap, buf_obj,
+ make_fixnum (vis_end));
+ if (FIXNUMP (np_ov))
+ {
+ ptrdiff_t npv = XFIXNUM (np_ov);
+ if (npv > pos && npv < next_interesting)
+ next_interesting = npv;
+ }
+ pos = (next_interesting > pos) ? next_interesting : pos + 1;
+ Lisp_Object ov = XCAR (tail);
+ Lisp_Object face = Foverlay_get (ov, Qface);
+ if (!(EQ (face, faceSym)
+ || (CONSP (face)
+ && !NILP (Fmemq (faceSym, face)))))
+ continue;
+ }
+
+ ptrdiff_t span_end = !NILP (limit_prop)
+ ? ns_ax_next_prop_change (pos, limit_prop, buf_obj, vis_end)
+ : pos + 1;
+ ptrdiff_t ov_start = OVERLAY_START (ov);
+ ptrdiff_t ov_end = OVERLAY_END (ov);
+ if (ov_end <= ov_start)
+ continue;
+
+ if (span_end > vis_end) span_end = vis_end;
+ if (span_end <= pos) span_end = pos + 1;
+ ptrdiff_t dist = 0;
+ if (point < ov_start)
+ dist = ov_start - point;
+ else if (point > ov_end)
+ dist = point - ov_end;
+
+ EmacsAccessibilityInteractiveSpan *span
+ = [[EmacsAccessibilityInteractiveSpan alloc] init];
+ span.charposStart = pos;
+ span.charposEnd = span_end;
+ span.spanType = span_type;
+ span.parentBuffer = parent_buf;
+ span.emacsView = parent_buf.emacsView;
+ span.lispWindow = parent_buf.lispWindow;
+ span.spanLabel = ns_ax_get_span_label (pos, span_end, buf_obj);
+
+ [spans addObject: span];
+ [span release];
+
+ pos = span_end;
+ }
+
+ return [[spans copy] autorelease];
+}
+
+@implementation EmacsAccessibilityInteractiveSpan
+@synthesize spanLabel;
+@synthesize spanValue;
+
+- (void)dealloc
+{
+ [spanLabel release];
+ [spanValue release];
+ [super dealloc];
+}
+
+- (BOOL) isAccessibilityElement { return YES; }
+
+- (NSAccessibilityRole) accessibilityRole
+{
+ switch (self.spanType)
+ if (!found || dist < best_dist)
+ {
+ case EmacsAXSpanTypeLink: return NSAccessibilityLinkRole;
+ default: return NSAccessibilityButtonRole;
+ best_start = ov_start;
+ best_end = ov_end;
+ best_dist = dist;
+ found = YES;
+ }
+ }
+ }
+}
+
+- (NSString *) accessibilityLabel { return self.spanLabel ?: @""; }
+- (NSString *) accessibilityValue { return self.spanValue; }
+
+- (NSRect) accessibilityFrame
+{
+ EmacsAccessibilityBuffer *pb = self.parentBuffer;
+ if (!pb || ![self validWindow])
+ return NSZeroRect;
+ NSUInteger ax_s = [pb accessibilityIndexForCharpos: self.charposStart];
+ NSUInteger ax_e = [pb accessibilityIndexForCharpos: self.charposEnd];
+ if (ax_e < ax_s) ax_e = ax_s;
+ return [pb accessibilityFrameForRange: NSMakeRange (ax_s, ax_e - ax_s)];
+}
+
+- (BOOL) isAccessibilityFocused
+{
+ /* Read the cached point stored by EmacsAccessibilityBuffer on the main
+ thread — safe to read from any thread (plain ptrdiff_t, no Lisp calls). */
+ EmacsAccessibilityBuffer *pb = self.parentBuffer;
+ if (!pb)
+ if (!found)
+ return NO;
+ ptrdiff_t pt = pb.cachedPoint;
+ return pt >= self.charposStart && pt < self.charposEnd;
+
+ *out_start = best_start;
+ *out_end = best_end;
+ return YES;
+}
+
+- (void) setAccessibilityFocused: (BOOL) focused
+/* Detect line-level navigation commands. Inspects Vthis_command
+ (the command symbol being executed) rather than raw key codes so
+ that remapped bindings (e.g., C-j -> next-line) are recognized.
+ Falls back to last_command_event for Tab/backtab which are not
+ bound to a single canonical command symbol. */
+static bool
+ns_ax_event_is_line_nav_key (int *which)
+{
+ if (!focused)
+ return;
+ ptrdiff_t target = self.charposStart;
+ Lisp_Object lwin = self.lispWindow;
+ dispatch_async (dispatch_get_main_queue (), ^{
+ /* lwin is a Lisp_Object captured by value. This is GC-safe
+ because Lisp_Objects are tagged integers/pointers that
+ remain valid across GC — GC does not relocate objects in
+ Emacs. The WINDOW_LIVE_P check below guards against the
+ window being deleted between capture and execution. */
+ if (!WINDOWP (lwin) || NILP (Fwindow_live_p (lwin)))
+ return;
+ /* Use specpdl unwind protection so that block_input is always
+ matched by unblock_input, even if Fselect_window signals. */
+ specpdl_ref count = SPECPDL_INDEX ();
+ record_unwind_protect_void (unblock_input);
+ block_input ();
+ record_unwind_current_buffer ();
+ Fselect_window (lwin, Qnil);
+ struct window *w = XWINDOW (lwin);
+ struct buffer *b = XBUFFER (w->contents);
+ if (b != current_buffer)
+ set_buffer_internal_1 (b);
+ ptrdiff_t pos = target;
+ if (pos < BUF_BEGV (b)) pos = BUF_BEGV (b);
+ if (pos > BUF_ZV (b)) pos = BUF_ZV (b);
+ SET_PT_BOTH (pos, CHAR_TO_BYTE (pos));
+ unbind_to (count, Qnil);
+ });
+}
+
+@end
+
+/* EmacsAccessibilityBuffer — InteractiveSpans category.
+ Methods are kept here (same .m file) so they access the ivars
+ declared in the @interface ivar block. */
+@implementation EmacsAccessibilityBuffer (InteractiveSpans)
+
+- (void) invalidateInteractiveSpans
+{
+ interactiveSpansDirty = YES;
+}
+
+- (NSArray *) accessibilityChildrenInNavigationOrder
+{
+ if (!interactiveSpansDirty && cachedInteractiveSpans != nil)
+ return cachedInteractiveSpans;
+
+ if (![NSThread isMainThread])
+ /* 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). */
+ if (SYMBOLP (Vthis_command) && !NILP (Vthis_command))
+ {
+ __block NSArray *result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityChildrenInNavigationOrder];
+ });
+ return result;
+ Lisp_Object cmd = Vthis_command;
+ /* Forward line commands. */
+ if (EQ (cmd, Qns_ax_next_line)
+ || EQ (cmd, Qns_ax_dired_next_line)
+ || EQ (cmd, Qns_ax_evil_next_line)
+ || EQ (cmd, Qns_ax_evil_next_visual_line))
+ {
+ if (which) *which = 1;
+ return true;
+ }
+ /* Backward line commands. */
+ if (EQ (cmd, Qns_ax_previous_line)
+ || EQ (cmd, Qns_ax_dired_previous_line)
+ || EQ (cmd, Qns_ax_evil_previous_line)
+ || EQ (cmd, Qns_ax_evil_previous_visual_line))
+ {
+ if (which) *which = -1;
+ return true;
+ }
+ }
+
+ struct window *w = [self validWindow];
+ if (!w)
+ return cachedInteractiveSpans ? cachedInteractiveSpans : @[];
+ /* 2. Fallback: check raw key events for Tab/backtab. */
+ Lisp_Object ev = last_command_event;
+ if (CONSP (ev))
+ ev = EVENT_HEAD (ev);
+
+ /* Validate buffer before scanning. The Lisp calls inside
+ ns_ax_scan_interactive_spans (Ftext_properties_at, Fplist_get,
+ Fnext_single_property_change) do not signal on valid buffers
+ with valid positions. Verify those preconditions here so we
+ never enter the scan with invalid state, which could longjmp
+ out of a dispatch_sync block and deadlock the AX thread. */
+ if (!BUFFERP (w->contents) || !XBUFFER (w->contents))
+ return cachedInteractiveSpans ? cachedInteractiveSpans : @[];
+
+ NSArray *spans = ns_ax_scan_interactive_spans (w, self);
+
+ if (!cachedInteractiveSpans)
+ cachedInteractiveSpans = [[NSMutableArray alloc] init];
+ [cachedInteractiveSpans setArray: spans];
+ interactiveSpansDirty = NO;
+
+ return cachedInteractiveSpans;
+ if (SYMBOLP (ev) && EQ (ev, Qns_ax_backtab))
+ {
+ if (which) *which = -1;
+ return true;
+ }
+ if (FIXNUMP (ev) && XFIXNUM (ev) == 9) /* Tab */
+ {
+ if (which) *which = 1;
+ return true;
+ }
+ return false;
+}
+
+@end
+
+
+static NSString *
@@ -409,14 +254,6 @@ index ee27df1..c47912d 100644
+
+ return text;
+}
+
@implementation EmacsAccessibilityElement
@@ -7443,6 +7788,1377 @@ ns_ax_post_notification_with_info (id element,
@end
+
+@implementation EmacsAccessibilityBuffer
+@synthesize cachedText;
@@ -614,6 +451,37 @@ index ee27df1..c47912d 100644
+ } /* @synchronized */
+}
+
+/* --- Threading and signal safety ---
+
+ AX getter methods may be called from the VoiceOver server thread.
+ All methods that access Lisp objects or buffer state dispatch_sync
+ to the main thread where Emacs state is consistent.
+
+ Longjmp safety: Lisp functions called inside dispatch_sync blocks
+ (Fget_char_property, Fbuffer_substring_no_properties, etc.) could
+ theoretically signal and longjmp through the dispatch_sync frame,
+ deadlocking the AX server thread. This is prevented by:
+
+ 1. validWindow checks WINDOW_LIVE_P and BUFFERP before every
+ Lisp access — the window and buffer are verified live.
+ 2. All dispatch_sync blocks run on the main thread where no
+ concurrent Lisp code can modify state between checks.
+ 3. block_input prevents timer events and process output from
+ running between precondition checks and Lisp calls.
+ 4. Buffer positions are clamped to BUF_BEGV/BUF_ZV before
+ use, preventing out-of-range signals.
+ 5. specpdl unwind protection ensures block_input is always
+ matched by unblock_input, even on longjmp.
+
+ This matches the safety model of existing nsterm.m F-function
+ calls (24 direct calls, none wrapped in internal_condition_case).
+
+ Known gap: if the Emacs window tree is modified between redisplay
+ cycles in a way that invalidates validWindow's cached result,
+ a stale dereference could occur. In practice this does not happen
+ because window tree modifications go through the event loop which
+ we are blocking via dispatch_sync. */
+
+/* ---- NSAccessibility protocol ---- */
+
+- (NSAccessibilityRole)accessibilityRole

View File

@@ -0,0 +1,437 @@
From 7c1ad53ed32fc4b2650f0af51dd4d8b5ed87bdf4 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 10:10:55 +0100
Subject: [PATCH 3/5] ns: add interactive span elements for Tab navigation
Add lightweight child elements for Tab-navigable interactive text.
* src/nsterm.m (ns_ax_scan_interactive_spans): New function. Scan
visible range with O(n/skip) property-skip optimization. Priority:
widget > button > follow-link > org-link > completion-candidate >
keymap-overlay.
(EmacsAccessibilityInteractiveSpan): Implement AXButton/AXLink
elements with AXPress action.
(EmacsAccessibilityBuffer(InteractiveSpans)): New category.
accessibilityChildrenInNavigationOrder for Tab/Shift-Tab cycling
with wrap-around.
---
src/nsterm.m | 403 +++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 403 insertions(+)
diff --git a/src/nsterm.m b/src/nsterm.m
index 90db3b7..db5e4b3 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -8797,6 +8797,409 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
@end
+
+
+/* ===================================================================
+ EmacsAccessibilityInteractiveSpan — helpers and implementation
+ =================================================================== */
+
+/* Extract announcement string from completion--string property value.
+ The property can be a plain Lisp string (simple completion) or
+ a list ("candidate" "annotation") for annotated completions.
+ Returns nil on failure. */
+static NSString *
+ns_ax_completion_string_from_prop (Lisp_Object cstr)
+{
+ if (STRINGP (cstr))
+ return [NSString stringWithLispString: cstr];
+ if (CONSP (cstr) && STRINGP (XCAR (cstr)))
+ return [NSString stringWithLispString: XCAR (cstr)];
+ return nil;
+}
+
+/* Return the Emacs buffer Lisp object for window W, or Qnil. */
+static Lisp_Object
+ns_ax_window_buffer_object (struct window *w)
+{
+ if (!w)
+ return Qnil;
+ if (!BUFFERP (w->contents))
+ return Qnil;
+ return w->contents;
+}
+
+/* Compute visible-end charpos for window W.
+ Emacs stores it as BUF_Z - window_end_pos.
+ Falls back to BUF_ZV when window_end_valid is false (e.g., when
+ called from an AX getter before the next redisplay cycle). */
+static ptrdiff_t
+ns_ax_window_end_charpos (struct window *w, struct buffer *b)
+{
+ if (!w->window_end_valid)
+ return BUF_ZV (b);
+ return BUF_Z (b) - w->window_end_pos;
+}
+
+/* Fetch text property PROP at charpos POS in BUF_OBJ. */
+static Lisp_Object
+ns_ax_text_prop_at (ptrdiff_t pos, Lisp_Object prop, Lisp_Object buf_obj)
+{
+ Lisp_Object plist = Ftext_properties_at (make_fixnum (pos), buf_obj);
+ /* Third argument to Fplist_get is PREDICATE (Emacs 29+), not a
+ default value. Qnil selects the default `eq' comparison. */
+ return Fplist_get (plist, prop, Qnil);
+}
+
+/* Next charpos where PROP changes, capped at LIMIT. */
+static ptrdiff_t
+ns_ax_next_prop_change (ptrdiff_t pos, Lisp_Object prop,
+ Lisp_Object buf_obj, ptrdiff_t limit)
+{
+ Lisp_Object result
+ = Fnext_single_property_change (make_fixnum (pos), prop,
+ buf_obj, make_fixnum (limit));
+ return FIXNUMP (result) ? XFIXNUM (result) : limit;
+}
+
+/* Build label for span [START, END) in BUF_OBJ.
+ Priority: completion--string → buffer text → help-echo. */
+static NSString *
+ns_ax_get_span_label (ptrdiff_t start, ptrdiff_t end,
+ Lisp_Object buf_obj)
+{
+ Lisp_Object cs = ns_ax_text_prop_at (start, Qns_ax_completion__string,
+ buf_obj);
+ if (STRINGP (cs))
+ return [NSString stringWithLispString: cs];
+
+ if (end > start)
+ {
+ Lisp_Object substr = Fbuffer_substring_no_properties (
+ make_fixnum (start), make_fixnum (end));
+ if (STRINGP (substr))
+ {
+ NSString *s = [NSString stringWithLispString: substr];
+ s = [s stringByTrimmingCharactersInSet:
+ [NSCharacterSet whitespaceAndNewlineCharacterSet]];
+ if (s.length > 0)
+ return s;
+ }
+ }
+
+ Lisp_Object he = ns_ax_text_prop_at (start, Qhelp_echo, buf_obj);
+ if (STRINGP (he))
+ return [NSString stringWithLispString: he];
+
+ return @"";
+}
+
+/* Post AX notifications asynchronously to prevent deadlock.
+ NSAccessibilityPostNotification may synchronously invoke VoiceOver
+ callbacks that dispatch_sync back to the main queue. If we are
+ already on the main queue (e.g., inside postAccessibilityUpdates
+ called from ns_update_end), that dispatch_sync deadlocks.
+ Deferring via dispatch_async lets the current method return first,
+ freeing the main queue for VoiceOver's dispatch_sync calls. */
+
+static inline void
+ns_ax_post_notification (id element,
+ NSAccessibilityNotificationName name)
+{
+ dispatch_async (dispatch_get_main_queue (), ^{
+ NSAccessibilityPostNotification (element, name);
+ });
+}
+
+static inline void
+ns_ax_post_notification_with_info (id element,
+ NSAccessibilityNotificationName name,
+ NSDictionary *info)
+{
+ dispatch_async (dispatch_get_main_queue (), ^{
+ NSAccessibilityPostNotificationWithUserInfo (element, name, info);
+ });
+}
+
+/* Scan visible range of window W for interactive spans.
+ Returns NSArray<EmacsAccessibilityInteractiveSpan *>.
+
+ Priority when properties overlap:
+ widget > button > follow-link > org-link >
+ completion-candidate > keymap-overlay. */
+static NSArray *
+ns_ax_scan_interactive_spans (struct window *w,
+ EmacsAccessibilityBuffer *parent_buf)
+{
+ if (!w)
+ return @[];
+
+ Lisp_Object buf_obj = ns_ax_window_buffer_object (w);
+ if (NILP (buf_obj))
+ return @[];
+
+ struct buffer *b = XBUFFER (buf_obj);
+ ptrdiff_t vis_start = marker_position (w->start);
+ ptrdiff_t vis_end = ns_ax_window_end_charpos (w, b);
+
+ if (vis_start < BUF_BEGV (b)) vis_start = BUF_BEGV (b);
+ if (vis_end > BUF_ZV (b)) vis_end = BUF_ZV (b);
+ if (vis_start >= vis_end)
+ return @[];
+
+ /* Symbols are interned once at startup via DEFSYM in syms_of_nsterm;
+ reference them directly here (GC-safe, no repeated obarray lookup). */
+
+ BOOL is_completion_buf
+ = EQ (BVAR (b, major_mode), Qns_ax_completion_list_mode);
+
+ NSMutableArray *spans = [NSMutableArray array];
+ ptrdiff_t pos = vis_start;
+
+ while (pos < vis_end)
+ {
+ Lisp_Object plist = Ftext_properties_at (make_fixnum (pos), buf_obj);
+ EmacsAXSpanType span_type = EmacsAXSpanTypeNone;
+ Lisp_Object limit_prop = Qnil;
+
+ if (!NILP (Fplist_get (plist, Qns_ax_widget, Qnil)))
+ {
+ span_type = EmacsAXSpanTypeWidget;
+ limit_prop = Qns_ax_widget;
+ }
+ else if (!NILP (Fplist_get (plist, Qns_ax_button, Qnil)))
+ {
+ span_type = EmacsAXSpanTypeButton;
+ limit_prop = Qns_ax_button;
+ }
+ else if (!NILP (Fplist_get (plist, Qns_ax_follow_link, Qnil)))
+ {
+ span_type = EmacsAXSpanTypeLink;
+ limit_prop = Qns_ax_follow_link;
+ }
+ else if (!NILP (Fplist_get (plist, Qns_ax_org_link, Qnil)))
+ {
+ span_type = EmacsAXSpanTypeLink;
+ limit_prop = Qns_ax_org_link;
+ }
+ else if (is_completion_buf
+ && !NILP (Fplist_get (plist, Qmouse_face, Qnil)))
+ {
+ /* For completions, use completion--string as boundary so we
+ don't accidentally merge two column-adjacent candidates
+ whose mouse-face regions may share padding whitespace.
+ Fall back to mouse-face if completion--string is absent. */
+ Lisp_Object cs_sym = Qns_ax_completion__string;
+ Lisp_Object cs_val = ns_ax_text_prop_at (pos, cs_sym, buf_obj);
+ span_type = EmacsAXSpanTypeCompletionItem;
+ limit_prop = NILP (cs_val) ? Qmouse_face : cs_sym;
+ }
+ else
+ {
+ /* Check overlays for keymap. */
+ Lisp_Object ovs
+ = Foverlays_in (make_fixnum (pos), make_fixnum (pos + 1));
+ while (CONSP (ovs))
+ {
+ if (!NILP (Foverlay_get (XCAR (ovs), Qkeymap)))
+ {
+ span_type = EmacsAXSpanTypeButton;
+ limit_prop = Qkeymap;
+ break;
+ }
+ ovs = XCDR (ovs);
+ }
+ }
+
+ if (span_type == EmacsAXSpanTypeNone)
+ {
+ /* Skip to the next position where any interactive property
+ changes. Try each scannable property in turn and take
+ the nearest change point — O(properties) per gap rather
+ than O(chars). Fall back to pos+1 as safety net. */
+ ptrdiff_t next_interesting = vis_end;
+ Lisp_Object skip_props[5]
+ = { Qns_ax_widget, Qns_ax_button, Qns_ax_follow_link,
+ Qns_ax_org_link, Qmouse_face };
+ for (int sp = 0; sp < 5; sp++)
+ {
+ ptrdiff_t np
+ = ns_ax_next_prop_change (pos, skip_props[sp],
+ buf_obj, vis_end);
+ if (np > pos && np < next_interesting)
+ next_interesting = np;
+ }
+ /* Also check overlay keymap changes. */
+ Lisp_Object np_ov
+ = Fnext_single_char_property_change (make_fixnum (pos),
+ Qkeymap, buf_obj,
+ make_fixnum (vis_end));
+ if (FIXNUMP (np_ov))
+ {
+ ptrdiff_t npv = XFIXNUM (np_ov);
+ if (npv > pos && npv < next_interesting)
+ next_interesting = npv;
+ }
+ pos = (next_interesting > pos) ? next_interesting : pos + 1;
+ continue;
+ }
+
+ ptrdiff_t span_end = !NILP (limit_prop)
+ ? ns_ax_next_prop_change (pos, limit_prop, buf_obj, vis_end)
+ : pos + 1;
+
+ if (span_end > vis_end) span_end = vis_end;
+ if (span_end <= pos) span_end = pos + 1;
+
+ EmacsAccessibilityInteractiveSpan *span
+ = [[EmacsAccessibilityInteractiveSpan alloc] init];
+ span.charposStart = pos;
+ span.charposEnd = span_end;
+ span.spanType = span_type;
+ span.parentBuffer = parent_buf;
+ span.emacsView = parent_buf.emacsView;
+ span.lispWindow = parent_buf.lispWindow;
+ span.spanLabel = ns_ax_get_span_label (pos, span_end, buf_obj);
+
+ [spans addObject: span];
+ [span release];
+
+ pos = span_end;
+ }
+
+ return [[spans copy] autorelease];
+}
+
+@implementation EmacsAccessibilityInteractiveSpan
+@synthesize spanLabel;
+@synthesize spanValue;
+
+- (void)dealloc
+{
+ [spanLabel release];
+ [spanValue release];
+ [super dealloc];
+}
+
+- (BOOL) isAccessibilityElement { return YES; }
+
+- (NSAccessibilityRole) accessibilityRole
+{
+ switch (self.spanType)
+ {
+ case EmacsAXSpanTypeLink: return NSAccessibilityLinkRole;
+ default: return NSAccessibilityButtonRole;
+ }
+}
+
+- (NSString *) accessibilityLabel { return self.spanLabel ?: @""; }
+- (NSString *) accessibilityValue { return self.spanValue; }
+
+- (NSRect) accessibilityFrame
+{
+ EmacsAccessibilityBuffer *pb = self.parentBuffer;
+ if (!pb || ![self validWindow])
+ return NSZeroRect;
+ NSUInteger ax_s = [pb accessibilityIndexForCharpos: self.charposStart];
+ NSUInteger ax_e = [pb accessibilityIndexForCharpos: self.charposEnd];
+ if (ax_e < ax_s) ax_e = ax_s;
+ return [pb accessibilityFrameForRange: NSMakeRange (ax_s, ax_e - ax_s)];
+}
+
+- (BOOL) isAccessibilityFocused
+{
+ /* Read the cached point stored by EmacsAccessibilityBuffer on the main
+ thread — safe to read from any thread (plain ptrdiff_t, no Lisp calls). */
+ EmacsAccessibilityBuffer *pb = self.parentBuffer;
+ if (!pb)
+ return NO;
+ ptrdiff_t pt = pb.cachedPoint;
+ return pt >= self.charposStart && pt < self.charposEnd;
+}
+
+- (void) setAccessibilityFocused: (BOOL) focused
+{
+ if (!focused)
+ return;
+ ptrdiff_t target = self.charposStart;
+ Lisp_Object lwin = self.lispWindow;
+ dispatch_async (dispatch_get_main_queue (), ^{
+ /* lwin is a Lisp_Object captured by value. This is GC-safe
+ because Lisp_Objects are tagged integers/pointers that
+ remain valid across GC — GC does not relocate objects in
+ Emacs. The WINDOW_LIVE_P check below guards against the
+ window being deleted between capture and execution. */
+ if (!WINDOWP (lwin) || NILP (Fwindow_live_p (lwin)))
+ return;
+ /* Use specpdl unwind protection so that block_input is always
+ matched by unblock_input, even if Fselect_window signals. */
+ specpdl_ref count = SPECPDL_INDEX ();
+ record_unwind_protect_void (unblock_input);
+ block_input ();
+ record_unwind_current_buffer ();
+ Fselect_window (lwin, Qnil);
+ struct window *w = XWINDOW (lwin);
+ struct buffer *b = XBUFFER (w->contents);
+ if (b != current_buffer)
+ set_buffer_internal_1 (b);
+ ptrdiff_t pos = target;
+ if (pos < BUF_BEGV (b)) pos = BUF_BEGV (b);
+ if (pos > BUF_ZV (b)) pos = BUF_ZV (b);
+ SET_PT_BOTH (pos, CHAR_TO_BYTE (pos));
+ unbind_to (count, Qnil);
+ });
+}
+
+@end
+
+/* EmacsAccessibilityBuffer — InteractiveSpans category.
+ Methods are kept here (same .m file) so they access the ivars
+ declared in the @interface ivar block. */
+@implementation EmacsAccessibilityBuffer (InteractiveSpans)
+
+- (void) invalidateInteractiveSpans
+{
+ interactiveSpansDirty = YES;
+}
+
+- (NSArray *) accessibilityChildrenInNavigationOrder
+{
+ if (!interactiveSpansDirty && cachedInteractiveSpans != nil)
+ return cachedInteractiveSpans;
+
+ if (![NSThread isMainThread])
+ {
+ __block NSArray *result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityChildrenInNavigationOrder];
+ });
+ return result;
+ }
+
+ struct window *w = [self validWindow];
+ if (!w)
+ return cachedInteractiveSpans ? cachedInteractiveSpans : @[];
+
+ /* Validate buffer before scanning. The Lisp calls inside
+ ns_ax_scan_interactive_spans (Ftext_properties_at, Fplist_get,
+ Fnext_single_property_change) do not signal on valid buffers
+ with valid positions. Verify those preconditions here so we
+ never enter the scan with invalid state, which could longjmp
+ out of a dispatch_sync block and deadlock the AX thread. */
+ if (!BUFFERP (w->contents) || !XBUFFER (w->contents))
+ return cachedInteractiveSpans ? cachedInteractiveSpans : @[];
+
+ NSArray *spans = ns_ax_scan_interactive_spans (w, self);
+
+ if (!cachedInteractiveSpans)
+ cachedInteractiveSpans = [[NSMutableArray alloc] init];
+ [cachedInteractiveSpans setArray: spans];
+ interactiveSpansDirty = NO;
+
+ return cachedInteractiveSpans;
+}
+
+@end
+
#endif /* NS_IMPL_COCOA */
--
2.43.0

View File

@@ -1,72 +1,60 @@
From 1bebb38dc851cb4e656fe25078f1e36d54900be5 Mon Sep 17 00:00:00 2001
From 9ec896a226144676a36908938899fc90c2f5730b Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 09:54:28 +0100
Subject: [PATCH 3/4] ns: integrate accessibility with EmacsView and redisplay
Date: Sat, 28 Feb 2026 10:10:55 +0100
Subject: [PATCH 4/5] ns: integrate accessibility with EmacsView and redisplay
Wire the accessibility infrastructure from the previous patches into
EmacsView and the redisplay cycle. After this patch, VoiceOver and
Zoom support is fully active.
Wire the accessibility infrastructure into EmacsView and the
redisplay cycle.
Integration points:
ns_update_end: call [view postAccessibilityUpdates] after each
redisplay cycle to dispatch queued accessibility notifications.
ns_draw_phys_cursor: store cursor rect for Zoom and call
UAZoomChangeFocus with correct CG coordinate-space transform
when ns-accessibility-enabled is non-nil.
EmacsView dealloc: release accessibilityElements array.
windowDidBecomeKey: post FocusedUIElementChangedNotification and
SelectedTextChanged so VoiceOver tracks the focused buffer on
app/window switch.
EmacsView accessibility methods:
rebuildAccessibilityTree: walk Emacs window tree via
ns_ax_collect_windows to create/reuse virtual elements
(EmacsAccessibilityBuffer per window, EmacsAccessibilityModeLine
per mode line).
invalidateAccessibilityTree: mark tree dirty; deferred rebuild
on next AX query.
accessibilityChildren, accessibilityFocusedUIElement: expose
virtual elements to VoiceOver.
postAccessibilityUpdates: main notification dispatch. Detects
three events: window tree change (rebuild + LayoutChanged),
window switch (focus notification), per-buffer text/cursor
changes (delegated to buffer elements). Re-entrance guard
prevents infinite recursion from VoiceOver callbacks.
accessibilityBoundsForRange: cursor rect for Zoom, delegates to
focused buffer element with cursor-rect fallback.
Legacy parameterized APIs (accessibilityParameterizedAttribute-
Names, accessibilityAttributeValue:forParameter:) for pre-10.10
compatibility.
Tested on macOS 14 Sonoma with VoiceOver and Zoom. Full end-to-end:
buffer navigation, cursor tracking, window switching, completion
announcements, interactive spans, org-mode folded headings,
evil-mode block cursor, indirect buffers, multi-window layouts.
Known limitations:
- Bidi text: accessibilityRangeForPosition assumes LTR glyph layout.
- Mode-line icons: image/stretch glyphs not extracted.
- Text cap: buffers exceeding NS_AX_TEXT_CAP truncated.
- Indirect buffers: share modiff_count, may cause redundant cache
invalidation (correctness preserved, minor performance cost).
* src/nsterm.m: EmacsView accessibility integration, cursor tracking.
* 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 FocusedUIElementChanged and
SelectedTextChanged.
(ns_ax_collect_windows): New function. Walk window tree creating
virtual elements.
(EmacsView rebuildAccessibilityTree): New method.
(EmacsView invalidateAccessibilityTree): New method.
(EmacsView accessibilityChildren): New method.
(EmacsView accessibilityFocusedUIElement): New method.
(EmacsView postAccessibilityUpdates): New method. Detect tree
change, window switch, per-buffer changes; re-entrance guard.
(EmacsView accessibilityBoundsForRange:): New method. Cursor rect
for Zoom with focused-element delegation.
(EmacsView accessibilityParameterizedAttributeNames): New method.
(EmacsView accessibilityAttributeValue:forParameter:): New method.
* etc/NEWS: Document VoiceOver accessibility support.
---
src/nsterm.m | 395 +++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 395 insertions(+)
etc/NEWS | 13 ++
src/nsterm.m | 397 ++++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 408 insertions(+), 2 deletions(-)
diff --git a/etc/NEWS b/etc/NEWS
index 7367e3c..608650e 100644
--- a/etc/NEWS
+++ b/etc/NEWS
@@ -4374,6 +4374,19 @@ allowing Emacs users access to speech recognition utilities.
Note: Accepting this permission allows the use of system APIs, which may
send user data to Apple's speech recognition servers.
+---
+** VoiceOver accessibility support on macOS.
+Emacs now exposes buffer content, cursor position, and interactive
+elements to the macOS accessibility subsystem (VoiceOver). This
+includes AXBoundsForRange for macOS Zoom cursor tracking, line and
+word navigation announcements, Tab-navigable interactive spans
+(buttons, links, completion candidates), and completion announcements
+for the *Completions* buffer. The implementation uses a virtual
+accessibility tree with per-window elements, hybrid SelectedTextChanged
+and AnnouncementRequested notifications, and thread-safe text caching.
+Set 'ns-accessibility-enabled' to nil to disable the accessibility
+interface and eliminate the associated overhead.
+
---
** Re-introduced dictation, lost in Emacs v30 (macOS).
We lost macOS dictation in v30 when migrating to NSTextInputClient.
diff --git a/src/nsterm.m b/src/nsterm.m
index c47912d..34af842 100644
index db5e4b3..421a6a4 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -1105,6 +1105,11 @@ ns_update_end (struct frame *f)
@@ -125,7 +113,23 @@ index c47912d..34af842 100644
ns_focus (f, NULL, 0);
NSGraphicsContext *ctx = [NSGraphicsContext currentContext];
@@ -9204,6 +9246,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
@@ -7180,7 +7222,6 @@ enum {
-
static BOOL
ns_ax_find_completion_overlay_range (struct buffer *b, ptrdiff_t point,
ptrdiff_t *out_start,
@@ -8798,7 +8839,6 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
@end
-
/* ===================================================================
EmacsAccessibilityInteractiveSpan — helpers and implementation
=================================================================== */
@@ -9245,6 +9285,7 @@ ns_ax_scan_interactive_spans (struct window *w,
[layer release];
#endif
@@ -133,7 +137,7 @@ index c47912d..34af842 100644
[[self menu] release];
[super dealloc];
}
@@ -10552,6 +10595,32 @@ ns_in_echo_area (void)
@@ -10593,6 +10634,32 @@ ns_in_echo_area (void)
XSETFRAME (event.frame_or_window, emacsframe);
kbd_buffer_store_event (&event);
ns_send_appdefined (-1); // Kick main loop
@@ -166,7 +170,7 @@ index c47912d..34af842 100644
}
@@ -11789,6 +11858,332 @@ ns_in_echo_area (void)
@@ -11830,6 +11897,332 @@ ns_in_echo_area (void)
return fs_state;
}

View File

@@ -1,16 +1,13 @@
From 3e6f8148a01ee7934a357858477ac3c61b491088 Mon Sep 17 00:00:00 2001
From 0e09f3e4d8edac1c157130f733f3bff18d758c5f Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 09:54:28 +0100
Subject: [PATCH 4/4] doc: add VoiceOver accessibility section to macOS
Date: Sat, 28 Feb 2026 10:10:55 +0100
Subject: [PATCH 5/5] doc: add VoiceOver accessibility section to macOS
appendix
Document the new VoiceOver accessibility support in the Emacs manual.
* doc/emacs/macos.texi (VoiceOver Accessibility): New section
covering screen reader usage, keyboard navigation feedback,
completion announcements, Zoom cursor tracking, the
ns-accessibility-enabled user option, and known limitations (text
cap, mode-line icon fonts, bidi hit-testing, tree rebuild behavior).
* doc/emacs/macos.texi (VoiceOver Accessibility): New node. Document
screen reader usage, keyboard navigation feedback, completion
announcements, Zoom cursor tracking, ns-accessibility-enabled, and
known limitations.
---
doc/emacs/macos.texi | 75 ++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 75 insertions(+)