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

Split VoiceOver accessibility into 4 logical patches:
  0001: Base classes + text extraction (+753)
  0002: Buffer/ModeLine/InteractiveSpan implementations (+1716)
  0003: EmacsView integration + cursor tracking (+395)
  0004: Documentation with known limitations (+75)

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

All patches verified: apply cleanly to current Emacs master,
final state identical to original monolithic patch.
This commit is contained in:
2026-02-28 09:54:51 +01:00
parent 2c8515a0a1
commit 67b1d25c34
4 changed files with 994 additions and 904 deletions

View File

@@ -0,0 +1,887 @@
From eb8038a4d9c4fb4640b0987d6529e8b961353596 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
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.
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.
---
etc/NEWS | 13 ++
src/nsterm.h | 119 ++++++++++
src/nsterm.m | 621 +++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 753 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
+++ b/src/nsterm.h
@@ -453,6 +453,110 @@ enum ns_return_frame_mode
@end
+/* ==========================================================================
+
+ Accessibility virtual elements (macOS / Cocoa only)
+
+ ========================================================================== */
+
+#ifdef NS_IMPL_COCOA
+@class EmacsView;
+
+/* Base class for virtual accessibility elements attached to EmacsView. */
+@interface EmacsAccessibilityElement : NSAccessibilityElement
+@property (nonatomic, unsafe_unretained) EmacsView *emacsView;
+/* Lisp window object — safe across GC cycles.
+ GC safety: these Lisp_Objects are NOT visible to GC via staticpro
+ or the specpdl stack. This is safe because:
+ (1) Emacs GC runs only on the main thread, at well-defined safe
+ points during Lisp evaluation — never during redisplay.
+ (2) Accessibility elements are owned by EmacsView which belongs to
+ an active frame; windows referenced here are always reachable
+ from the frame's window tree until rebuildAccessibilityTree
+ updates them during the next redisplay cycle.
+ (3) AX getters dispatch_sync to main before accessing Lisp state,
+ so GC cannot run concurrently with any access to lispWindow.
+ (4) validWindow checks WINDOW_LIVE_P before dereferencing. */
+@property (nonatomic, assign) Lisp_Object lispWindow;
+- (struct window *)validWindow; /* Returns live window or NULL. */
+- (NSRect)screenRectFromEmacsX:(int)x y:(int)y width:(int)w height:(int)h;
+@end
+
+/* A visible run: maps a contiguous range of accessibility indices
+ to a contiguous range of buffer character positions. Invisible
+ text is skipped, so ax_start values are consecutive across runs
+ while charpos values may have gaps. */
+typedef struct ns_ax_visible_run
+{
+ ptrdiff_t charpos; /* Buffer charpos where this visible run starts. */
+ ptrdiff_t length; /* Number of visible Emacs characters in this run. */
+ NSUInteger ax_start; /* Starting index in the accessibility string. */
+ NSUInteger ax_length; /* Length in accessibility string (UTF-16 units). */
+} ns_ax_visible_run;
+
+/* Virtual AXTextArea element — one per visible Emacs window (buffer). */
+@interface EmacsAccessibilityBuffer : EmacsAccessibilityElement <NSAccessibility>
+{
+ ns_ax_visible_run *visibleRuns;
+ NSUInteger visibleRunCount;
+ NSMutableArray *cachedInteractiveSpans;
+ BOOL interactiveSpansDirty;
+}
+@property (nonatomic, retain) NSString *cachedText;
+@property (nonatomic, assign) ptrdiff_t cachedTextModiff;
+@property (nonatomic, assign) ptrdiff_t cachedOverlayModiff;
+@property (nonatomic, assign) ptrdiff_t cachedTextStart;
+@property (nonatomic, assign) ptrdiff_t cachedModiff;
+@property (nonatomic, assign) ptrdiff_t cachedPoint;
+@property (nonatomic, assign) BOOL cachedMarkActive;
+@property (nonatomic, copy) NSString *cachedCompletionAnnouncement;
+@property (nonatomic, assign) ptrdiff_t cachedCompletionOverlayStart;
+@property (nonatomic, assign) ptrdiff_t cachedCompletionOverlayEnd;
+@property (nonatomic, assign) ptrdiff_t cachedCompletionPoint;
+- (void)invalidateTextCache;
+- (void)postAccessibilityNotificationsForFrame:(struct frame *)f;
+- (ptrdiff_t)charposForAccessibilityIndex:(NSUInteger)ax_idx;
+- (NSUInteger)accessibilityIndexForCharpos:(ptrdiff_t)charpos;
+@end
+
+/* Virtual AXStaticText element — one per mode line. */
+@interface EmacsAccessibilityModeLine : EmacsAccessibilityElement
+@end
+
+/* Span types for interactive AX child elements. */
+typedef NS_ENUM (NSInteger, EmacsAXSpanType)
+{
+ EmacsAXSpanTypeNone = -1,
+ EmacsAXSpanTypeButton = 0,
+ EmacsAXSpanTypeLink = 1,
+ EmacsAXSpanTypeCompletionItem = 2,
+ EmacsAXSpanTypeWidget = 3,
+};
+
+/* A lightweight AX element representing one interactive text span
+ (button, link, checkbox, completion candidate, etc.) within a buffer
+ window. Exposed as AX child of EmacsAccessibilityBuffer so VoiceOver
+ Tab navigation can reach individual interactive elements. */
+@interface EmacsAccessibilityInteractiveSpan : EmacsAccessibilityElement
+
+@property (nonatomic, assign) ptrdiff_t charposStart;
+@property (nonatomic, assign) ptrdiff_t charposEnd;
+@property (nonatomic, assign) EmacsAXSpanType spanType;
+@property (nonatomic, copy) NSString *spanLabel;
+@property (nonatomic, copy) NSString *spanValue;
+@property (nonatomic, unsafe_unretained) EmacsAccessibilityBuffer *parentBuffer;
+
+- (NSAccessibilityRole) accessibilityRole;
+- (NSString *) accessibilityLabel;
+- (NSRect) accessibilityFrame;
+- (BOOL) isAccessibilityElement;
+- (BOOL) isAccessibilityFocused;
+- (void) setAccessibilityFocused: (BOOL) focused;
+
+@end
+#endif /* NS_IMPL_COCOA */
+
+
/* ==========================================================================
The main Emacs view
@@ -471,6 +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;
+ BOOL accessibilityUpdating;
+ @public /* Accessed by ns_draw_phys_cursor (C function). */
+ NSRect lastAccessibilityCursorRect;
#endif
BOOL font_panel_active;
NSFont *font_panel_result;
@@ -528,6 +640,13 @@ enum ns_return_frame_mode
- (void)windowWillExitFullScreen;
- (void)windowDidExitFullScreen;
- (void)windowDidBecomeKey;
+
+#ifdef NS_IMPL_COCOA
+/* Accessibility support. */
+- (void)rebuildAccessibilityTree;
+- (void)invalidateAccessibilityTree;
+- (void)postAccessibilityUpdates;
+#endif
@end
diff --git a/src/nsterm.m b/src/nsterm.m
index 74e4ad5..ee27df1 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -46,6 +46,7 @@ GNUstep port and post-20 update by Adrian Robert (arobert@cogsci.ucsd.edu)
#include "blockinput.h"
#include "sysselect.h"
#include "nsterm.h"
+#include "intervals.h" /* TEXT_PROP_MEANS_INVISIBLE */
#include "systime.h"
#include "character.h"
#include "xwidget.h"
@@ -6856,6 +6857,595 @@ ns_create_font_panel_buttons (id target, SEL select, SEL cancel_action)
}
#endif
+/* ==========================================================================
+
+ Accessibility virtual elements (macOS / Cocoa only)
+
+ ========================================================================== */
+
+#ifdef NS_IMPL_COCOA
+
+/* ---- Helper: extract buffer text for accessibility ---- */
+
+/* Maximum characters exposed via accessibilityValue. */
+/* Cap accessibility text at 100,000 UTF-16 units (~200 KB). VoiceOver
+ performance degrades beyond this; buffers larger than ~50,000 lines
+ are truncated for accessibility purposes. */
+#define NS_AX_TEXT_CAP 100000
+
+/* Build accessibility text for window W, skipping invisible text.
+ Populates *OUT_START with the buffer start charpos.
+ Populates *OUT_RUNS with an array of visible runs and *OUT_NRUNS
+ with the count. Caller must free *OUT_RUNS with xfree(). */
+
+static NSString *
+ns_ax_buffer_text (struct window *w, ptrdiff_t *out_start,
+ ns_ax_visible_run **out_runs, NSUInteger *out_nruns)
+{
+ *out_runs = NULL;
+ *out_nruns = 0;
+
+ if (!w || !WINDOW_LEAF_P (w))
+ {
+ *out_start = 0;
+ return @"";
+ }
+
+ struct buffer *b = XBUFFER (w->contents);
+ if (!b)
+ {
+ *out_start = 0;
+ return @"";
+ }
+
+ ptrdiff_t begv = BUF_BEGV (b);
+ ptrdiff_t zv = BUF_ZV (b);
+
+ *out_start = begv;
+
+ if (zv <= begv)
+ return @"";
+
+ specpdl_ref count = SPECPDL_INDEX ();
+ record_unwind_current_buffer ();
+ if (b != current_buffer)
+ set_buffer_internal_1 (b);
+
+ /* First pass: count visible runs to allocate the mapping array. */
+ NSUInteger run_capacity = 64;
+ ns_ax_visible_run *runs = xmalloc (run_capacity
+ * sizeof (ns_ax_visible_run));
+ NSUInteger nruns = 0;
+ NSUInteger ax_offset = 0;
+
+ NSMutableString *result = [NSMutableString string];
+ ptrdiff_t pos = begv;
+
+ while (pos < zv)
+ {
+ /* Check invisible property (text properties + overlays).
+ Use TEXT_PROP_MEANS_INVISIBLE which respects buffer-invisibility-spec,
+ matching the logic in xdisp.c. This correctly handles org-mode,
+ outline-mode, hideshow and any mode using spec-controlled
+ invisibility (not just `invisible t'). */
+ Lisp_Object invis = Fget_char_property (make_fixnum (pos),
+ Qinvisible, Qnil);
+ if (TEXT_PROP_MEANS_INVISIBLE (invis))
+ {
+ /* Skip to the next position where invisible changes. */
+ Lisp_Object next = Fnext_single_char_property_change (
+ make_fixnum (pos), Qinvisible, Qnil, make_fixnum (zv));
+ pos = FIXNUMP (next) ? XFIXNUM (next) : zv;
+ continue;
+ }
+
+ /* Find end of this visible run: where invisible property changes. */
+ Lisp_Object next = Fnext_single_char_property_change (
+ make_fixnum (pos), Qinvisible, Qnil, make_fixnum (zv));
+ ptrdiff_t run_end = FIXNUMP (next) ? XFIXNUM (next) : zv;
+
+ /* Cap total text at NS_AX_TEXT_CAP. */
+ ptrdiff_t run_len = run_end - pos;
+ if (ax_offset + (NSUInteger) run_len > NS_AX_TEXT_CAP)
+ run_len = (ptrdiff_t) (NS_AX_TEXT_CAP - ax_offset);
+ if (run_len <= 0)
+ break;
+ run_end = pos + run_len;
+
+ /* Extract this visible run's text. Use
+ Fbuffer_substring_no_properties which correctly handles the
+ buffer gap — raw BUF_BYTE_ADDRESS reads across the gap would
+ include garbage bytes when the run spans the gap position. */
+ Lisp_Object lstr = Fbuffer_substring_no_properties (
+ make_fixnum (pos), make_fixnum (run_end));
+ NSString *nsstr = [NSString stringWithLispString:lstr];
+ NSUInteger ns_len = [nsstr length];
+ [result appendString:nsstr];
+
+ /* Record this visible run in the mapping. */
+ if (nruns >= run_capacity)
+ {
+ run_capacity *= 2;
+ runs = xrealloc (runs, run_capacity
+ * sizeof (ns_ax_visible_run));
+ }
+ runs[nruns].charpos = pos;
+ runs[nruns].length = run_len;
+ runs[nruns].ax_start = ax_offset;
+ runs[nruns].ax_length = ns_len;
+ nruns++;
+
+ ax_offset += ns_len;
+ pos = run_end;
+ }
+
+ unbind_to (count, Qnil);
+
+ *out_runs = runs;
+ *out_nruns = nruns;
+ return result;
+}
+
+
+/* ---- Helper: extract mode line text from glyph rows ---- */
+
+/* TODO: Only CHAR_GLYPH characters (>= 32) are extracted. Image
+ glyphs, stretch glyphs, and composed glyphs are silently skipped.
+ Mode lines using icon fonts (e.g. doom-modeline with nerd-font)
+ will produce incomplete accessibility text. */
+static NSString *
+ns_ax_mode_line_text (struct window *w)
+{
+ if (!w || !w->current_matrix)
+ return @"";
+
+ struct glyph_matrix *matrix = w->current_matrix;
+ NSMutableString *text = [NSMutableString string];
+
+ for (int i = 0; i < matrix->nrows; i++)
+ {
+ struct glyph_row *row = matrix->rows + i;
+ if (!row->enabled_p || !row->mode_line_p)
+ continue;
+
+ struct glyph *g = row->glyphs[TEXT_AREA];
+ struct glyph *end = g + row->used[TEXT_AREA];
+ for (; g < end; g++)
+ {
+ if (g->type == CHAR_GLYPH && g->u.ch >= 32)
+ {
+ unichar uch = (unichar) g->u.ch;
+ [text appendString:[NSString stringWithCharacters:&uch
+ length:1]];
+ }
+ }
+ }
+ return text;
+}
+
+
+/* ---- Helper: screen rect for a character range via glyph matrix ---- */
+
+static NSRect
+ns_ax_frame_for_range (struct window *w, EmacsView *view,
+ ptrdiff_t charpos_start,
+ ptrdiff_t charpos_len)
+{
+ if (!w || !w->current_matrix || !view)
+ return NSZeroRect;
+
+ /* charpos_start and charpos_len are already in buffer charpos
+ space — the caller maps AX string indices through
+ charposForAccessibilityIndex which handles invisible text. */
+ ptrdiff_t cp_start = charpos_start;
+ ptrdiff_t cp_end = cp_start + charpos_len;
+
+ struct glyph_matrix *matrix = w->current_matrix;
+ NSRect result = NSZeroRect;
+ BOOL found = NO;
+
+ for (int i = 0; i < matrix->nrows; i++)
+ {
+ struct glyph_row *row = matrix->rows + i;
+ if (!row->enabled_p || row->mode_line_p)
+ continue;
+ if (!row->displays_text_p && !row->ends_at_zv_p)
+ continue;
+
+ ptrdiff_t row_start = MATRIX_ROW_START_CHARPOS (row);
+ ptrdiff_t row_end = MATRIX_ROW_END_CHARPOS (row);
+
+ if (row_start < cp_end && row_end > cp_start)
+ {
+ int window_x, window_y, window_width;
+ window_box (w, TEXT_AREA, &window_x, &window_y,
+ &window_width, 0);
+
+ NSRect rowRect;
+ rowRect.origin.x = window_x;
+ rowRect.origin.y = WINDOW_TO_FRAME_PIXEL_Y (w, MAX (0, row->y));
+ rowRect.origin.y = MAX (rowRect.origin.y, window_y);
+ rowRect.size.width = window_width;
+ rowRect.size.height = row->height;
+
+ if (!found)
+ {
+ result = rowRect;
+ found = YES;
+ }
+ else
+ result = NSUnionRect (result, rowRect);
+ }
+ }
+
+ if (!found)
+ return NSZeroRect;
+
+ /* Clip result to text area bounds. */
+ {
+ int text_area_x, text_area_y, text_area_w, text_area_h;
+ window_box (w, TEXT_AREA, &text_area_x, &text_area_y,
+ &text_area_w, &text_area_h);
+ CGFloat max_y = WINDOW_TO_FRAME_PIXEL_Y (w, text_area_y + text_area_h);
+ if (NSMaxY (result) > max_y)
+ result.size.height = max_y - result.origin.y;
+ }
+
+ /* Convert from EmacsView (flipped) coords to screen coords. */
+ NSRect winRect = [view convertRect:result toView:nil];
+ return [[view window] convertRectToScreen:winRect];
+}
+
+/* AX enum numeric compatibility for NSAccessibility notifications.
+ Values match WebKit AXObjectCacheMac fallback enums
+ (AXTextStateChangeType / AXTextEditType / AXTextSelectionDirection /
+ AXTextSelectionGranularity). */
+enum {
+ ns_ax_text_state_change_unknown = 0,
+ ns_ax_text_state_change_edit = 1,
+ ns_ax_text_state_change_selection_move = 2,
+
+ ns_ax_text_edit_type_typing = 3,
+
+ ns_ax_text_selection_direction_unknown = 0,
+ ns_ax_text_selection_direction_previous = 3,
+ ns_ax_text_selection_direction_next = 4,
+ ns_ax_text_selection_direction_discontiguous = 5,
+
+ ns_ax_text_selection_granularity_unknown = 0,
+ ns_ax_text_selection_granularity_character = 1,
+ ns_ax_text_selection_granularity_word = 2,
+ ns_ax_text_selection_granularity_line = 3,
+};
+
+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
+
+- (instancetype)init
+{
+ self = [super init];
+ if (self)
+ self.lispWindow = Qnil;
+ return self;
+}
+
+/* Return the associated Emacs window if it is still live, else NULL.
+ Use this instead of storing a raw struct window * which can become a
+ dangling pointer after delete-window or kill-buffer. */
+- (struct window *)validWindow
+{
+ if (NILP (self.lispWindow) || !WINDOW_LIVE_P (self.lispWindow))
+ return NULL;
+ return XWINDOW (self.lispWindow);
+}
+
+- (NSRect)screenRectFromEmacsX:(int)x y:(int)y width:(int)ew height:(int)eh
+{
+ EmacsView *view = self.emacsView;
+ if (!view || ![view window])
+ return NSZeroRect;
+
+ NSRect r = NSMakeRect (x, y, ew, eh);
+ NSRect winRect = [view convertRect:r toView:nil];
+ return [[view window] convertRectToScreen:winRect];
+}
+
+- (BOOL)isAccessibilityElement
+{
+ return YES;
+}
+
+/* ---- Hierarchy plumbing (required for VoiceOver to find us) ---- */
+
+- (id)accessibilityParent
+{
+ return NSAccessibilityUnignoredAncestor (self.emacsView);
+}
+
+- (id)accessibilityWindow
+{
+ return [self.emacsView window];
+}
+
+- (id)accessibilityTopLevelUIElement
+{
+ return [self.emacsView window];
+}
+
+@end
+
+#endif /* NS_IMPL_COCOA */
+
+
/* ==========================================================================
EmacsView implementation
@@ -11312,6 +11902,28 @@ syms_of_nsterm (void)
DEFSYM (Qns_drag_operation_generic, "ns-drag-operation-generic");
DEFSYM (Qns_handle_drag_motion, "ns-handle-drag-motion");
+ /* Accessibility: line navigation command symbols for
+ ns_ax_event_is_line_nav_key (hot path, avoid intern per call). */
+ DEFSYM (Qns_ax_next_line, "next-line");
+ DEFSYM (Qns_ax_previous_line, "previous-line");
+ DEFSYM (Qns_ax_dired_next_line, "dired-next-line");
+ DEFSYM (Qns_ax_dired_previous_line, "dired-previous-line");
+ DEFSYM (Qns_ax_evil_next_line, "evil-next-line");
+ DEFSYM (Qns_ax_evil_previous_line, "evil-previous-line");
+ DEFSYM (Qns_ax_evil_next_visual_line, "evil-next-visual-line");
+ DEFSYM (Qns_ax_evil_previous_visual_line, "evil-previous-visual-line");
+
+ /* Accessibility span scanning symbols. */
+ DEFSYM (Qns_ax_widget, "widget");
+ DEFSYM (Qns_ax_button, "button");
+ DEFSYM (Qns_ax_follow_link, "follow-link");
+ DEFSYM (Qns_ax_org_link, "org-link");
+ DEFSYM (Qns_ax_completion_list_mode, "completion-list-mode");
+ DEFSYM (Qns_ax_completion__string, "completion--string");
+ DEFSYM (Qns_ax_completion, "completion");
+ DEFSYM (Qns_ax_completions_highlight, "completions-highlight");
+ DEFSYM (Qns_ax_backtab, "backtab");
+ /* Qmouse_face and Qkeymap are defined in textprop.c / keymap.c. */
Fput (Qalt, Qmodifier_value, make_fixnum (alt_modifier));
Fput (Qhyper, Qmodifier_value, make_fixnum (hyper_modifier));
Fput (Qmeta, Qmodifier_value, make_fixnum (meta_modifier));
@@ -11460,6 +12072,15 @@ Note that this does not apply to images.
This variable is ignored on Mac OS X < 10.7 and GNUstep. */);
ns_use_srgb_colorspace = YES;
+ DEFVAR_BOOL ("ns-accessibility-enabled", ns_accessibility_enabled,
+ doc: /* Non-nil means expose buffer content to the macOS accessibility
+subsystem (VoiceOver, Zoom, and other assistive technology).
+When nil, the accessibility virtual element tree is not built and no
+notifications are posted, eliminating the associated overhead.
+Requires the Cocoa (NS) build on macOS; ignored on GNUstep.
+Default is t. */);
+ ns_accessibility_enabled = YES;
+
DEFVAR_BOOL ("ns-use-mwheel-acceleration",
ns_use_mwheel_acceleration,
doc: /* Non-nil means use macOS's standard mouse wheel acceleration.
--
2.43.0

View File

@@ -1,793 +1,70 @@
From 663011cd807430d689569fc6b15fb3e3220928ce Mon Sep 17 00:00:00 2001 From a49c6b5a9601fe11a6a03292e8b4d685a0ce50af Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 09:31:55 +0100 Date: Sat, 28 Feb 2026 09:54:28 +0100
Subject: [PATCH 1/3] ns: add accessibility infrastructure for macOS VoiceOver Subject: [PATCH 2/4] ns: implement buffer, mode-line, and interactive span
elements
Add the core accessibility implementation for the NS (Cocoa) port, Add the three remaining virtual element classes, completing the
providing VoiceOver and Zoom support. This patch adds all new types, accessibility object model. Combined with the previous patch, this
classes, and functions without modifying existing code paths; the provides a full NSAccessibility text protocol implementation.
integration with EmacsView and the redisplay cycle follows in a
subsequent patch.
New types: EmacsAccessibilityBuffer <NSAccessibility>: full text protocol for
a single Emacs window.
ns_ax_visible_run: maps buffer character positions to UTF-16 Text cache: @synchronized caching of buffer text and visible-run
accessibility string indices, skipping invisible text. Used by array. Cache invalidated on modiff_count, window start, or
the index-mapping binary search in EmacsAccessibilityBuffer. invisible-text configuration change.
EmacsAccessibilityElement: base class for virtual AX elements, Index mapping: binary search O(log n) between buffer positions and
stores Lisp_Object lispWindow (GC-safe; see comment in nsterm.h) UTF-16 accessibility indices via the visible-run array.
and EmacsView reference.
EmacsAccessibilityBuffer <NSAccessibility>: AXTextArea element per Selection: selectedTextRange from point/mark; insertion point from
visible Emacs window. Full NSAccessibility text protocol including point via index mapping.
value, selectedTextRange, line/index conversions, frameForRange,
rangeForPosition. Text cache with visible-run mapping handles
invisible text (org-mode folds, outline-mode). Hybrid
SelectedTextChanged/AnnouncementRequested notification dispatch.
Completion announcements for *Completions* buffer.
EmacsAccessibilityModeLine: AXStaticText per mode line. Geometry: lineForIndex/indexForLine by newline scanning.
frameForRange delegates to ns_ax_frame_for_range.
EmacsAccessibilityInteractiveSpan: lightweight AX child elements Notification dispatch (postTextChangedNotification): hybrid
for Tab-navigable interactive spans (buttons, links, checkboxes, SelectedTextChanged / ValueChanged / AnnouncementRequested,
completion candidates, Org-mode links, keymap overlays). modeled on WebKit's pattern. Line navigation emits ValueChanged;
character/word motion emits SelectedTextChanged only. Completion
buffer announcements via AnnouncementRequested with High priority.
New helper functions: EmacsAccessibilityModeLine: AXStaticText exposing mode-line content.
ns_ax_buffer_text: build accessibility string with visible-run EmacsAccessibilityInteractiveSpan: lightweight child of a buffer
mapping. Uses TEXT_PROP_MEANS_INVISIBLE for spec-controlled element for Tab-navigable interactive text.
invisibility and Fbuffer_substring_no_properties for gap safety.
ns_ax_frame_for_range: screen rect for a character range via ns_ax_scan_interactive_spans: scan visible range with O(n/skip)
glyph matrix lookup with text-area clipping. property-skip optimization. Priority: widget > button > follow-link
> org-link > completion-candidate > keymap-overlay.
ns_ax_event_is_line_nav_key: detect line navigation commands Buffer (InteractiveSpans) category: Tab/Shift-Tab cycling with
via Vthis_command with Tab/backtab fallback. wrap-around and VoiceOver focus notification.
ns_ax_scan_interactive_spans: scan visible range for interactive ns_ax_completion_text_for_span: extract completion candidate text.
text properties with property-skip optimization.
New user option: ns-accessibility-enabled (default t). Threading: Lisp-accessing methods use dispatch_sync to main thread;
@synchronized protects text cache.
Threading model: all Lisp calls on main thread; AX getters use Tested on macOS 14 with VoiceOver. Verified: buffer reading, line
dispatch_sync to main; index mapping methods are thread-safe. navigation, word/character announcements, completion announcements,
Notifications posted via dispatch_async to prevent deadlock with Tab-cycling interactive spans, mode-line readout.
VoiceOver's synchronous callbacks.
Tested on macOS 14 Sonoma with VoiceOver and Zoom. Verified: * src/nsterm.m: EmacsAccessibilityBuffer, EmacsAccessibilityModeLine,
buffer navigation (char/word/line), completion announcements, EmacsAccessibilityInteractiveSpan, supporting functions.
interactive span Tab navigation, org-mode with folded headings,
evil-mode block cursor, multi-window layouts, indirect buffers.
Known limitations: bidi text layout not fully tested for
accessibilityRangeForPosition; mode-line text extraction skips
image and stretch glyphs (CHAR_GLYPH only); accessibility text
capped at 100,000 UTF-16 units (NS_AX_TEXT_CAP).
* src/nsterm.h: New class declarations, ivar extensions.
* src/nsterm.m: New accessibility implementation, DEFSYM, DEFVAR.
* etc/NEWS: Document VoiceOver accessibility support.
--- ---
etc/NEWS | 13 + src/nsterm.m | 1716 ++++++++++++++++++++++++++++++++++++++++++++++++++
src/nsterm.h | 119 +++ 1 file changed, 1716 insertions(+)
src/nsterm.m | 2337 ++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 2469 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
+++ b/src/nsterm.h
@@ -453,6 +453,110 @@ enum ns_return_frame_mode
@end
+/* ==========================================================================
+
+ Accessibility virtual elements (macOS / Cocoa only)
+
+ ========================================================================== */
+
+#ifdef NS_IMPL_COCOA
+@class EmacsView;
+
+/* Base class for virtual accessibility elements attached to EmacsView. */
+@interface EmacsAccessibilityElement : NSAccessibilityElement
+@property (nonatomic, unsafe_unretained) EmacsView *emacsView;
+/* Lisp window object — safe across GC cycles.
+ GC safety: these Lisp_Objects are NOT visible to GC via staticpro
+ or the specpdl stack. This is safe because:
+ (1) Emacs GC runs only on the main thread, at well-defined safe
+ points during Lisp evaluation — never during redisplay.
+ (2) Accessibility elements are owned by EmacsView which belongs to
+ an active frame; windows referenced here are always reachable
+ from the frame's window tree until rebuildAccessibilityTree
+ updates them during the next redisplay cycle.
+ (3) AX getters dispatch_sync to main before accessing Lisp state,
+ so GC cannot run concurrently with any access to lispWindow.
+ (4) validWindow checks WINDOW_LIVE_P before dereferencing. */
+@property (nonatomic, assign) Lisp_Object lispWindow;
+- (struct window *)validWindow; /* Returns live window or NULL. */
+- (NSRect)screenRectFromEmacsX:(int)x y:(int)y width:(int)w height:(int)h;
+@end
+
+/* A visible run: maps a contiguous range of accessibility indices
+ to a contiguous range of buffer character positions. Invisible
+ text is skipped, so ax_start values are consecutive across runs
+ while charpos values may have gaps. */
+typedef struct ns_ax_visible_run
+{
+ ptrdiff_t charpos; /* Buffer charpos where this visible run starts. */
+ ptrdiff_t length; /* Number of visible Emacs characters in this run. */
+ NSUInteger ax_start; /* Starting index in the accessibility string. */
+ NSUInteger ax_length; /* Length in accessibility string (UTF-16 units). */
+} ns_ax_visible_run;
+
+/* Virtual AXTextArea element — one per visible Emacs window (buffer). */
+@interface EmacsAccessibilityBuffer : EmacsAccessibilityElement <NSAccessibility>
+{
+ ns_ax_visible_run *visibleRuns;
+ NSUInteger visibleRunCount;
+ NSMutableArray *cachedInteractiveSpans;
+ BOOL interactiveSpansDirty;
+}
+@property (nonatomic, retain) NSString *cachedText;
+@property (nonatomic, assign) ptrdiff_t cachedTextModiff;
+@property (nonatomic, assign) ptrdiff_t cachedOverlayModiff;
+@property (nonatomic, assign) ptrdiff_t cachedTextStart;
+@property (nonatomic, assign) ptrdiff_t cachedModiff;
+@property (nonatomic, assign) ptrdiff_t cachedPoint;
+@property (nonatomic, assign) BOOL cachedMarkActive;
+@property (nonatomic, copy) NSString *cachedCompletionAnnouncement;
+@property (nonatomic, assign) ptrdiff_t cachedCompletionOverlayStart;
+@property (nonatomic, assign) ptrdiff_t cachedCompletionOverlayEnd;
+@property (nonatomic, assign) ptrdiff_t cachedCompletionPoint;
+- (void)invalidateTextCache;
+- (void)postAccessibilityNotificationsForFrame:(struct frame *)f;
+- (ptrdiff_t)charposForAccessibilityIndex:(NSUInteger)ax_idx;
+- (NSUInteger)accessibilityIndexForCharpos:(ptrdiff_t)charpos;
+@end
+
+/* Virtual AXStaticText element — one per mode line. */
+@interface EmacsAccessibilityModeLine : EmacsAccessibilityElement
+@end
+
+/* Span types for interactive AX child elements. */
+typedef NS_ENUM (NSInteger, EmacsAXSpanType)
+{
+ EmacsAXSpanTypeNone = -1,
+ EmacsAXSpanTypeButton = 0,
+ EmacsAXSpanTypeLink = 1,
+ EmacsAXSpanTypeCompletionItem = 2,
+ EmacsAXSpanTypeWidget = 3,
+};
+
+/* A lightweight AX element representing one interactive text span
+ (button, link, checkbox, completion candidate, etc.) within a buffer
+ window. Exposed as AX child of EmacsAccessibilityBuffer so VoiceOver
+ Tab navigation can reach individual interactive elements. */
+@interface EmacsAccessibilityInteractiveSpan : EmacsAccessibilityElement
+
+@property (nonatomic, assign) ptrdiff_t charposStart;
+@property (nonatomic, assign) ptrdiff_t charposEnd;
+@property (nonatomic, assign) EmacsAXSpanType spanType;
+@property (nonatomic, copy) NSString *spanLabel;
+@property (nonatomic, copy) NSString *spanValue;
+@property (nonatomic, unsafe_unretained) EmacsAccessibilityBuffer *parentBuffer;
+
+- (NSAccessibilityRole) accessibilityRole;
+- (NSString *) accessibilityLabel;
+- (NSRect) accessibilityFrame;
+- (BOOL) isAccessibilityElement;
+- (BOOL) isAccessibilityFocused;
+- (void) setAccessibilityFocused: (BOOL) focused;
+
+@end
+#endif /* NS_IMPL_COCOA */
+
+
/* ==========================================================================
The main Emacs view
@@ -471,6 +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;
+ BOOL accessibilityUpdating;
+ @public /* Accessed by ns_draw_phys_cursor (C function). */
+ NSRect lastAccessibilityCursorRect;
#endif
BOOL font_panel_active;
NSFont *font_panel_result;
@@ -528,6 +640,13 @@ enum ns_return_frame_mode
- (void)windowWillExitFullScreen;
- (void)windowDidExitFullScreen;
- (void)windowDidBecomeKey;
+
+#ifdef NS_IMPL_COCOA
+/* Accessibility support. */
+- (void)rebuildAccessibilityTree;
+- (void)invalidateAccessibilityTree;
+- (void)postAccessibilityUpdates;
+#endif
@end
diff --git a/src/nsterm.m b/src/nsterm.m diff --git a/src/nsterm.m b/src/nsterm.m
index 74e4ad5..c47912d 100644 index ee27df1..c47912d 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/src/nsterm.m +++ b/src/nsterm.m
@@ -46,6 +46,7 @@ GNUstep port and post-20 update by Adrian Robert (arobert@cogsci.ucsd.edu) @@ -7387,6 +7387,351 @@ ns_ax_post_notification_with_info (id element,
#include "blockinput.h" });
#include "sysselect.h"
#include "nsterm.h"
+#include "intervals.h" /* TEXT_PROP_MEANS_INVISIBLE */
#include "systime.h"
#include "character.h"
#include "xwidget.h"
@@ -6856,6 +6857,2311 @@ ns_create_font_panel_buttons (id target, SEL select, SEL cancel_action)
} }
#endif
+/* ==========================================================================
+
+ Accessibility virtual elements (macOS / Cocoa only)
+
+ ========================================================================== */
+
+#ifdef NS_IMPL_COCOA
+
+/* ---- Helper: extract buffer text for accessibility ---- */
+
+/* Maximum characters exposed via accessibilityValue. */
+/* Cap accessibility text at 100,000 UTF-16 units (~200 KB). VoiceOver
+ performance degrades beyond this; buffers larger than ~50,000 lines
+ are truncated for accessibility purposes. */
+#define NS_AX_TEXT_CAP 100000
+
+/* Build accessibility text for window W, skipping invisible text.
+ Populates *OUT_START with the buffer start charpos.
+ Populates *OUT_RUNS with an array of visible runs and *OUT_NRUNS
+ with the count. Caller must free *OUT_RUNS with xfree(). */
+
+static NSString *
+ns_ax_buffer_text (struct window *w, ptrdiff_t *out_start,
+ ns_ax_visible_run **out_runs, NSUInteger *out_nruns)
+{
+ *out_runs = NULL;
+ *out_nruns = 0;
+
+ if (!w || !WINDOW_LEAF_P (w))
+ {
+ *out_start = 0;
+ return @"";
+ }
+
+ struct buffer *b = XBUFFER (w->contents);
+ if (!b)
+ {
+ *out_start = 0;
+ return @"";
+ }
+
+ ptrdiff_t begv = BUF_BEGV (b);
+ ptrdiff_t zv = BUF_ZV (b);
+
+ *out_start = begv;
+
+ if (zv <= begv)
+ return @"";
+
+ specpdl_ref count = SPECPDL_INDEX ();
+ record_unwind_current_buffer ();
+ if (b != current_buffer)
+ set_buffer_internal_1 (b);
+
+ /* First pass: count visible runs to allocate the mapping array. */
+ NSUInteger run_capacity = 64;
+ ns_ax_visible_run *runs = xmalloc (run_capacity
+ * sizeof (ns_ax_visible_run));
+ NSUInteger nruns = 0;
+ NSUInteger ax_offset = 0;
+
+ NSMutableString *result = [NSMutableString string];
+ ptrdiff_t pos = begv;
+
+ while (pos < zv)
+ {
+ /* Check invisible property (text properties + overlays).
+ Use TEXT_PROP_MEANS_INVISIBLE which respects buffer-invisibility-spec,
+ matching the logic in xdisp.c. This correctly handles org-mode,
+ outline-mode, hideshow and any mode using spec-controlled
+ invisibility (not just `invisible t'). */
+ Lisp_Object invis = Fget_char_property (make_fixnum (pos),
+ Qinvisible, Qnil);
+ if (TEXT_PROP_MEANS_INVISIBLE (invis))
+ {
+ /* Skip to the next position where invisible changes. */
+ Lisp_Object next = Fnext_single_char_property_change (
+ make_fixnum (pos), Qinvisible, Qnil, make_fixnum (zv));
+ pos = FIXNUMP (next) ? XFIXNUM (next) : zv;
+ continue;
+ }
+
+ /* Find end of this visible run: where invisible property changes. */
+ Lisp_Object next = Fnext_single_char_property_change (
+ make_fixnum (pos), Qinvisible, Qnil, make_fixnum (zv));
+ ptrdiff_t run_end = FIXNUMP (next) ? XFIXNUM (next) : zv;
+
+ /* Cap total text at NS_AX_TEXT_CAP. */
+ ptrdiff_t run_len = run_end - pos;
+ if (ax_offset + (NSUInteger) run_len > NS_AX_TEXT_CAP)
+ run_len = (ptrdiff_t) (NS_AX_TEXT_CAP - ax_offset);
+ if (run_len <= 0)
+ break;
+ run_end = pos + run_len;
+
+ /* Extract this visible run's text. Use
+ Fbuffer_substring_no_properties which correctly handles the
+ buffer gap — raw BUF_BYTE_ADDRESS reads across the gap would
+ include garbage bytes when the run spans the gap position. */
+ Lisp_Object lstr = Fbuffer_substring_no_properties (
+ make_fixnum (pos), make_fixnum (run_end));
+ NSString *nsstr = [NSString stringWithLispString:lstr];
+ NSUInteger ns_len = [nsstr length];
+ [result appendString:nsstr];
+
+ /* Record this visible run in the mapping. */
+ if (nruns >= run_capacity)
+ {
+ run_capacity *= 2;
+ runs = xrealloc (runs, run_capacity
+ * sizeof (ns_ax_visible_run));
+ }
+ runs[nruns].charpos = pos;
+ runs[nruns].length = run_len;
+ runs[nruns].ax_start = ax_offset;
+ runs[nruns].ax_length = ns_len;
+ nruns++;
+
+ ax_offset += ns_len;
+ pos = run_end;
+ }
+
+ unbind_to (count, Qnil);
+
+ *out_runs = runs;
+ *out_nruns = nruns;
+ return result;
+}
+
+
+/* ---- Helper: extract mode line text from glyph rows ---- */
+
+/* TODO: Only CHAR_GLYPH characters (>= 32) are extracted. Image
+ glyphs, stretch glyphs, and composed glyphs are silently skipped.
+ Mode lines using icon fonts (e.g. doom-modeline with nerd-font)
+ will produce incomplete accessibility text. */
+static NSString *
+ns_ax_mode_line_text (struct window *w)
+{
+ if (!w || !w->current_matrix)
+ return @"";
+
+ struct glyph_matrix *matrix = w->current_matrix;
+ NSMutableString *text = [NSMutableString string];
+
+ for (int i = 0; i < matrix->nrows; i++)
+ {
+ struct glyph_row *row = matrix->rows + i;
+ if (!row->enabled_p || !row->mode_line_p)
+ continue;
+
+ struct glyph *g = row->glyphs[TEXT_AREA];
+ struct glyph *end = g + row->used[TEXT_AREA];
+ for (; g < end; g++)
+ {
+ if (g->type == CHAR_GLYPH && g->u.ch >= 32)
+ {
+ unichar uch = (unichar) g->u.ch;
+ [text appendString:[NSString stringWithCharacters:&uch
+ length:1]];
+ }
+ }
+ }
+ return text;
+}
+
+
+/* ---- Helper: screen rect for a character range via glyph matrix ---- */
+
+static NSRect
+ns_ax_frame_for_range (struct window *w, EmacsView *view,
+ ptrdiff_t charpos_start,
+ ptrdiff_t charpos_len)
+{
+ if (!w || !w->current_matrix || !view)
+ return NSZeroRect;
+
+ /* charpos_start and charpos_len are already in buffer charpos
+ space — the caller maps AX string indices through
+ charposForAccessibilityIndex which handles invisible text. */
+ ptrdiff_t cp_start = charpos_start;
+ ptrdiff_t cp_end = cp_start + charpos_len;
+
+ struct glyph_matrix *matrix = w->current_matrix;
+ NSRect result = NSZeroRect;
+ BOOL found = NO;
+
+ for (int i = 0; i < matrix->nrows; i++)
+ {
+ struct glyph_row *row = matrix->rows + i;
+ if (!row->enabled_p || row->mode_line_p)
+ continue;
+ if (!row->displays_text_p && !row->ends_at_zv_p)
+ continue;
+
+ ptrdiff_t row_start = MATRIX_ROW_START_CHARPOS (row);
+ ptrdiff_t row_end = MATRIX_ROW_END_CHARPOS (row);
+
+ if (row_start < cp_end && row_end > cp_start)
+ {
+ int window_x, window_y, window_width;
+ window_box (w, TEXT_AREA, &window_x, &window_y,
+ &window_width, 0);
+
+ NSRect rowRect;
+ rowRect.origin.x = window_x;
+ rowRect.origin.y = WINDOW_TO_FRAME_PIXEL_Y (w, MAX (0, row->y));
+ rowRect.origin.y = MAX (rowRect.origin.y, window_y);
+ rowRect.size.width = window_width;
+ rowRect.size.height = row->height;
+
+ if (!found)
+ {
+ result = rowRect;
+ found = YES;
+ }
+ else
+ result = NSUnionRect (result, rowRect);
+ }
+ }
+
+ if (!found)
+ return NSZeroRect;
+
+ /* Clip result to text area bounds. */
+ {
+ int text_area_x, text_area_y, text_area_w, text_area_h;
+ window_box (w, TEXT_AREA, &text_area_x, &text_area_y,
+ &text_area_w, &text_area_h);
+ CGFloat max_y = WINDOW_TO_FRAME_PIXEL_Y (w, text_area_y + text_area_h);
+ if (NSMaxY (result) > max_y)
+ result.size.height = max_y - result.origin.y;
+ }
+
+ /* Convert from EmacsView (flipped) coords to screen coords. */
+ NSRect winRect = [view convertRect:result toView:nil];
+ return [[view window] convertRectToScreen:winRect];
+}
+
+/* AX enum numeric compatibility for NSAccessibility notifications.
+ Values match WebKit AXObjectCacheMac fallback enums
+ (AXTextStateChangeType / AXTextEditType / AXTextSelectionDirection /
+ AXTextSelectionGranularity). */
+enum {
+ ns_ax_text_state_change_unknown = 0,
+ ns_ax_text_state_change_edit = 1,
+ ns_ax_text_state_change_selection_move = 2,
+
+ ns_ax_text_edit_type_typing = 3,
+
+ ns_ax_text_selection_direction_unknown = 0,
+ ns_ax_text_selection_direction_previous = 3,
+ ns_ax_text_selection_direction_next = 4,
+ ns_ax_text_selection_direction_discontiguous = 5,
+
+ ns_ax_text_selection_granularity_unknown = 0,
+ ns_ax_text_selection_granularity_character = 1,
+ ns_ax_text_selection_granularity_word = 2,
+ ns_ax_text_selection_granularity_line = 3,
+};
+
+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);
+ });
+}
+
+/* Scan visible range of window W for interactive spans. +/* Scan visible range of window W for interactive spans.
+ Returns NSArray<EmacsAccessibilityInteractiveSpan *>. + Returns NSArray<EmacsAccessibilityInteractiveSpan *>.
+ +
@@ -1133,62 +410,13 @@ index 74e4ad5..c47912d 100644
+ return text; + return text;
+} +}
+ +
+
+@implementation EmacsAccessibilityElement @implementation EmacsAccessibilityElement
+
+- (instancetype)init @@ -7443,6 +7788,1377 @@ ns_ax_post_notification_with_info (id element,
+{
+ self = [super init]; @end
+ if (self)
+ self.lispWindow = Qnil;
+ return self;
+}
+
+/* Return the associated Emacs window if it is still live, else NULL.
+ Use this instead of storing a raw struct window * which can become a
+ dangling pointer after delete-window or kill-buffer. */
+- (struct window *)validWindow
+{
+ if (NILP (self.lispWindow) || !WINDOW_LIVE_P (self.lispWindow))
+ return NULL;
+ return XWINDOW (self.lispWindow);
+}
+
+- (NSRect)screenRectFromEmacsX:(int)x y:(int)y width:(int)ew height:(int)eh
+{
+ EmacsView *view = self.emacsView;
+ if (!view || ![view window])
+ return NSZeroRect;
+
+ NSRect r = NSMakeRect (x, y, ew, eh);
+ NSRect winRect = [view convertRect:r toView:nil];
+ return [[view window] convertRectToScreen:winRect];
+}
+
+- (BOOL)isAccessibilityElement
+{
+ return YES;
+}
+
+/* ---- Hierarchy plumbing (required for VoiceOver to find us) ---- */
+
+- (id)accessibilityParent
+{
+ return NSAccessibilityUnignoredAncestor (self.emacsView);
+}
+
+- (id)accessibilityWindow
+{
+ return [self.emacsView window];
+}
+
+- (id)accessibilityTopLevelUIElement
+{
+ return [self.emacsView window];
+}
+
+@end
+
+ +
+@implementation EmacsAccessibilityBuffer +@implementation EmacsAccessibilityBuffer
+@synthesize cachedText; +@synthesize cachedText;
@@ -2560,57 +1788,9 @@ index 74e4ad5..c47912d 100644
+ +
+@end +@end
+ +
+#endif /* NS_IMPL_COCOA */ #endif /* NS_IMPL_COCOA */
+
+
/* ==========================================================================
EmacsView implementation
@@ -11312,6 +13618,28 @@ syms_of_nsterm (void)
DEFSYM (Qns_drag_operation_generic, "ns-drag-operation-generic");
DEFSYM (Qns_handle_drag_motion, "ns-handle-drag-motion");
+ /* Accessibility: line navigation command symbols for
+ ns_ax_event_is_line_nav_key (hot path, avoid intern per call). */
+ DEFSYM (Qns_ax_next_line, "next-line");
+ DEFSYM (Qns_ax_previous_line, "previous-line");
+ DEFSYM (Qns_ax_dired_next_line, "dired-next-line");
+ DEFSYM (Qns_ax_dired_previous_line, "dired-previous-line");
+ DEFSYM (Qns_ax_evil_next_line, "evil-next-line");
+ DEFSYM (Qns_ax_evil_previous_line, "evil-previous-line");
+ DEFSYM (Qns_ax_evil_next_visual_line, "evil-next-visual-line");
+ DEFSYM (Qns_ax_evil_previous_visual_line, "evil-previous-visual-line");
+
+ /* Accessibility span scanning symbols. */
+ DEFSYM (Qns_ax_widget, "widget");
+ DEFSYM (Qns_ax_button, "button");
+ DEFSYM (Qns_ax_follow_link, "follow-link");
+ DEFSYM (Qns_ax_org_link, "org-link");
+ DEFSYM (Qns_ax_completion_list_mode, "completion-list-mode");
+ DEFSYM (Qns_ax_completion__string, "completion--string");
+ DEFSYM (Qns_ax_completion, "completion");
+ DEFSYM (Qns_ax_completions_highlight, "completions-highlight");
+ DEFSYM (Qns_ax_backtab, "backtab");
+ /* Qmouse_face and Qkeymap are defined in textprop.c / keymap.c. */
Fput (Qalt, Qmodifier_value, make_fixnum (alt_modifier));
Fput (Qhyper, Qmodifier_value, make_fixnum (hyper_modifier));
Fput (Qmeta, Qmodifier_value, make_fixnum (meta_modifier));
@@ -11460,6 +13788,15 @@ Note that this does not apply to images.
This variable is ignored on Mac OS X < 10.7 and GNUstep. */);
ns_use_srgb_colorspace = YES;
+ DEFVAR_BOOL ("ns-accessibility-enabled", ns_accessibility_enabled,
+ doc: /* Non-nil means expose buffer content to the macOS accessibility
+subsystem (VoiceOver, Zoom, and other assistive technology).
+When nil, the accessibility virtual element tree is not built and no
+notifications are posted, eliminating the associated overhead.
+Requires the Cocoa (NS) build on macOS; ignored on GNUstep.
+Default is t. */);
+ ns_accessibility_enabled = YES;
+
DEFVAR_BOOL ("ns-use-mwheel-acceleration",
ns_use_mwheel_acceleration,
doc: /* Non-nil means use macOS's standard mouse wheel acceleration.
-- --
2.43.0 2.43.0

View File

@@ -1,17 +1,16 @@
From 92b00024559ff35a61d34f85e2c048a1845fca99 Mon Sep 17 00:00:00 2001 From 1bebb38dc851cb4e656fe25078f1e36d54900be5 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 09:32:52 +0100 Date: Sat, 28 Feb 2026 09:54:28 +0100
Subject: [PATCH 2/3] ns: integrate accessibility with EmacsView and cursor Subject: [PATCH 3/4] ns: integrate accessibility with EmacsView and redisplay
tracking
Wire the accessibility infrastructure from the previous patch into Wire the accessibility infrastructure from the previous patches into
the existing EmacsView class and the redisplay cycle. After this EmacsView and the redisplay cycle. After this patch, VoiceOver and
patch, VoiceOver and Zoom support is fully active. Zoom support is fully active.
Integration points: Integration points:
ns_update_end: call [view postAccessibilityUpdates] after each ns_update_end: call [view postAccessibilityUpdates] after each
redisplay cycle to dispatch accessibility notifications. redisplay cycle to dispatch queued accessibility notifications.
ns_draw_phys_cursor: store cursor rect for Zoom and call ns_draw_phys_cursor: store cursor rect for Zoom and call
UAZoomChangeFocus with correct CG coordinate-space transform UAZoomChangeFocus with correct CG coordinate-space transform
@@ -19,25 +18,49 @@ Integration points:
EmacsView dealloc: release accessibilityElements array. EmacsView dealloc: release accessibilityElements array.
EmacsView windowDidBecomeKey: post windowDidBecomeKey: post FocusedUIElementChangedNotification and
FocusedUIElementChangedNotification and SelectedTextChanged SelectedTextChanged so VoiceOver tracks the focused buffer on
so VoiceOver tracks the focused buffer on app/window switch. app/window switch.
EmacsView accessibility methods: rebuildAccessibilityTree walks EmacsView accessibility methods:
the Emacs window tree (ns_ax_collect_windows) to create/reuse
virtual elements. accessibilityChildren, accessibilityFocusedUI-
Element, postAccessibilityUpdates (the main notification dispatch
loop), accessibilityBoundsForRange (delegates to focused buffer
element with cursor-rect fallback for Zoom), and legacy
parameterized attribute APIs.
postAccessibilityUpdates detects three events: window tree change rebuildAccessibilityTree: walk Emacs window tree via
(rebuild + layout notification), window switch (focus notification), ns_ax_collect_windows to create/reuse virtual elements
and per-buffer changes (delegated to each buffer element). A (EmacsAccessibilityBuffer per window, EmacsAccessibilityModeLine
re-entrance guard prevents infinite recursion from VoiceOver per mode line).
callbacks that trigger redisplay.
* src/nsterm.m: EmacsView accessibility integration. 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 | 395 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/nsterm.m | 395 +++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 395 insertions(+) 1 file changed, 395 insertions(+)

View File

@@ -1,16 +1,16 @@
From 12440652eb52520da0714f1762e037836bda7b5b Mon Sep 17 00:00:00 2001 From 3e6f8148a01ee7934a357858477ac3c61b491088 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 09:33:23 +0100 Date: Sat, 28 Feb 2026 09:54:28 +0100
Subject: [PATCH 3/3] doc: add VoiceOver accessibility section to macOS Subject: [PATCH 4/4] doc: add VoiceOver accessibility section to macOS
appendix appendix
Document the new VoiceOver accessibility support in the Emacs manual. 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, Zoom cursor
tracking, the ns-accessibility-enabled user option, and known
limitations (text cap, mode-line icon fonts, bidi hit-testing).
* doc/emacs/macos.texi (VoiceOver Accessibility): New section. * 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 | 75 ++++++++++++++++++++++++++++++++++++++++++++ doc/emacs/macos.texi | 75 ++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 75 insertions(+) 1 file changed, 75 insertions(+)