diff --git a/patches/0001-ns-add-accessibility-base-classes-and-text-extractio.patch b/patches/0001-ns-add-accessibility-base-classes-and-text-extractio.patch new file mode 100644 index 0000000..0851805 --- /dev/null +++ b/patches/0001-ns-add-accessibility-base-classes-and-text-extractio.patch @@ -0,0 +1,887 @@ +From eb8038a4d9c4fb4640b0987d6529e8b961353596 Mon Sep 17 00:00:00 2001 +From: Martin Sukany +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 ++{ ++ 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 + diff --git a/patches/0001-ns-add-accessibility-infrastructure-for-macOS-VoiceO.patch b/patches/0002-ns-implement-buffer-mode-line-and-interactive-span-e.patch similarity index 62% rename from patches/0001-ns-add-accessibility-infrastructure-for-macOS-VoiceO.patch rename to patches/0002-ns-implement-buffer-mode-line-and-interactive-span-e.patch index 65cb493..efbaadf 100644 --- a/patches/0001-ns-add-accessibility-infrastructure-for-macOS-VoiceO.patch +++ b/patches/0002-ns-implement-buffer-mode-line-and-interactive-span-e.patch @@ -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 -Date: Sat, 28 Feb 2026 09:31:55 +0100 -Subject: [PATCH 1/3] ns: add accessibility infrastructure for macOS VoiceOver +Date: Sat, 28 Feb 2026 09:54:28 +0100 +Subject: [PATCH 2/4] ns: implement buffer, mode-line, and interactive span + elements -Add the core accessibility implementation for the NS (Cocoa) port, -providing VoiceOver and Zoom support. This patch adds all new types, -classes, and functions without modifying existing code paths; the -integration with EmacsView and the redisplay cycle follows in a -subsequent patch. +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. -New types: +EmacsAccessibilityBuffer : full text protocol for +a single Emacs window. - ns_ax_visible_run: maps buffer character positions to UTF-16 - accessibility string indices, skipping invisible text. Used by - the index-mapping binary search in EmacsAccessibilityBuffer. + Text cache: @synchronized caching of buffer text and visible-run + array. Cache invalidated on modiff_count, window start, or + invisible-text configuration change. - EmacsAccessibilityElement: base class for virtual AX elements, - stores Lisp_Object lispWindow (GC-safe; see comment in nsterm.h) - and EmacsView reference. + Index mapping: binary search O(log n) between buffer positions and + UTF-16 accessibility indices via the visible-run array. - EmacsAccessibilityBuffer : AXTextArea element per - visible Emacs window. Full NSAccessibility text protocol including - 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. + Selection: selectedTextRange from point/mark; insertion point from + point via index mapping. - EmacsAccessibilityModeLine: AXStaticText per mode line. + Geometry: lineForIndex/indexForLine by newline scanning. + frameForRange delegates to ns_ax_frame_for_range. - EmacsAccessibilityInteractiveSpan: lightweight AX child elements - for Tab-navigable interactive spans (buttons, links, checkboxes, - completion candidates, Org-mode links, keymap overlays). + 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. -New helper functions: +EmacsAccessibilityModeLine: AXStaticText exposing mode-line content. - ns_ax_buffer_text: build accessibility string with visible-run - mapping. Uses TEXT_PROP_MEANS_INVISIBLE for spec-controlled - invisibility and Fbuffer_substring_no_properties for gap safety. +EmacsAccessibilityInteractiveSpan: lightweight child of a buffer +element for Tab-navigable interactive text. - ns_ax_frame_for_range: screen rect for a character range via - glyph matrix lookup with text-area clipping. +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. - ns_ax_event_is_line_nav_key: detect line navigation commands - via Vthis_command with Tab/backtab fallback. +Buffer (InteractiveSpans) category: Tab/Shift-Tab cycling with +wrap-around and VoiceOver focus notification. - ns_ax_scan_interactive_spans: scan visible range for interactive - text properties with property-skip optimization. +ns_ax_completion_text_for_span: extract completion candidate text. -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 -dispatch_sync to main; index mapping methods are thread-safe. -Notifications posted via dispatch_async to prevent deadlock with -VoiceOver's synchronous callbacks. +Tested on macOS 14 with VoiceOver. Verified: buffer reading, line +navigation, word/character announcements, completion announcements, +Tab-cycling interactive spans, mode-line readout. -Tested on macOS 14 Sonoma with VoiceOver and Zoom. Verified: -buffer navigation (char/word/line), completion announcements, -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. +* src/nsterm.m: EmacsAccessibilityBuffer, EmacsAccessibilityModeLine, +EmacsAccessibilityInteractiveSpan, supporting functions. --- - etc/NEWS | 13 + - src/nsterm.h | 119 +++ - src/nsterm.m | 2337 ++++++++++++++++++++++++++++++++++++++++++++++++++ - 3 files changed, 2469 insertions(+) + src/nsterm.m | 1716 ++++++++++++++++++++++++++++++++++++++++++++++++++ + 1 file changed, 1716 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 -+{ -+ 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..c47912d 100644 +index ee27df1..c47912d 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,2311 @@ ns_create_font_panel_buttons (id target, SEL select, SEL cancel_action) +@@ -7387,6 +7387,351 @@ ns_ax_post_notification_with_info (id element, + }); } - #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. + Returns NSArray. + @@ -1133,62 +410,13 @@ index 74e4ad5..c47912d 100644 + return text; +} + -+ -+@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 -+ + + @implementation EmacsAccessibilityElement + +@@ -7443,6 +7788,1377 @@ ns_ax_post_notification_with_info (id element, + + @end + + +@implementation EmacsAccessibilityBuffer +@synthesize cachedText; @@ -2560,57 +1788,9 @@ index 74e4ad5..c47912d 100644 + +@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 diff --git a/patches/0002-ns-integrate-accessibility-with-EmacsView-and-cursor.patch b/patches/0003-ns-integrate-accessibility-with-EmacsView-and-redisp.patch similarity index 86% rename from patches/0002-ns-integrate-accessibility-with-EmacsView-and-cursor.patch rename to patches/0003-ns-integrate-accessibility-with-EmacsView-and-redisp.patch index 621211d..21426aa 100644 --- a/patches/0002-ns-integrate-accessibility-with-EmacsView-and-cursor.patch +++ b/patches/0003-ns-integrate-accessibility-with-EmacsView-and-redisp.patch @@ -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 -Date: Sat, 28 Feb 2026 09:32:52 +0100 -Subject: [PATCH 2/3] ns: integrate accessibility with EmacsView and cursor - tracking +Date: Sat, 28 Feb 2026 09:54:28 +0100 +Subject: [PATCH 3/4] ns: integrate accessibility with EmacsView and redisplay -Wire the accessibility infrastructure from the previous patch into -the existing EmacsView class and the redisplay cycle. After this -patch, VoiceOver and Zoom support is fully active. +Wire the accessibility infrastructure from the previous patches into +EmacsView and the redisplay cycle. After this patch, VoiceOver and +Zoom support is fully active. Integration points: 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 UAZoomChangeFocus with correct CG coordinate-space transform @@ -19,25 +18,49 @@ Integration points: EmacsView dealloc: release accessibilityElements array. - EmacsView windowDidBecomeKey: post - FocusedUIElementChangedNotification and SelectedTextChanged - so VoiceOver tracks the focused buffer on app/window switch. + windowDidBecomeKey: post FocusedUIElementChangedNotification and + SelectedTextChanged so VoiceOver tracks the focused buffer on + app/window switch. - EmacsView accessibility methods: rebuildAccessibilityTree walks - 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. + EmacsView accessibility methods: - postAccessibilityUpdates detects three events: window tree change - (rebuild + layout notification), window switch (focus notification), - and per-buffer changes (delegated to each buffer element). A - re-entrance guard prevents infinite recursion from VoiceOver - callbacks that trigger redisplay. + rebuildAccessibilityTree: walk Emacs window tree via + ns_ax_collect_windows to create/reuse virtual elements + (EmacsAccessibilityBuffer per window, EmacsAccessibilityModeLine + per mode line). -* 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 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 395 insertions(+) diff --git a/patches/0003-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch b/patches/0004-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch similarity index 88% rename from patches/0003-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch rename to patches/0004-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch index 7c9d398..44be4e2 100644 --- a/patches/0003-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch +++ b/patches/0004-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch @@ -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 -Date: Sat, 28 Feb 2026 09:33:23 +0100 -Subject: [PATCH 3/3] doc: add VoiceOver accessibility section to macOS +Date: Sat, 28 Feb 2026 09:54:28 +0100 +Subject: [PATCH 4/4] doc: add VoiceOver accessibility section to macOS appendix Document the new VoiceOver accessibility support in the Emacs manual. -Add a new section to the macOS appendix covering screen reader usage, -keyboard navigation feedback, completion announcements, 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 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+)