From 5016155c8aae81ea19c0f7f57802ddfef734e146 Mon Sep 17 00:00:00 2001 From: Daneel Date: Sat, 28 Feb 2026 10:11:16 +0100 Subject: [PATCH] patches: 5-patch VoiceOver series (improved split + safety docs) Split into 5 logical patches: 0001: Base classes + text extraction (+474) 0002: Buffer + ModeLine protocol (+1620) 0003: Interactive spans (+403) 0004: EmacsView integration + etc/NEWS (+408) 0005: Documentation (+75) Improvements over previous version: - 5 patches (was 3): finer granularity - Helpers placed in correct patches (find_completion_overlay_range, event_is_line_nav_key moved to patch with their users) - etc/NEWS moved to last functional patch (0004) - ChangeLog-format commit messages - Longjmp safety analysis comment in code - Code reorganized for clean sequential patches --- ...lity-base-classes-and-text-extractio.patch | 385 ++----------- ...er-and-mode-line-accessibility-elem.patch} | 528 +++++++----------- ...ive-span-elements-for-Tab-navigation.patch | 437 +++++++++++++++ ...ssibility-with-EmacsView-and-redisp.patch} | 136 ++--- ...-accessibility-section-to-macOS-app.patch} | 17 +- 5 files changed, 745 insertions(+), 758 deletions(-) rename patches/{0002-ns-implement-buffer-mode-line-and-interactive-span-e.patch => 0002-ns-implement-buffer-and-mode-line-accessibility-elem.patch} (79%) create mode 100644 patches/0003-ns-add-interactive-span-elements-for-Tab-navigation.patch rename patches/{0003-ns-integrate-accessibility-with-EmacsView-and-redisp.patch => 0004-ns-integrate-accessibility-with-EmacsView-and-redisp.patch} (80%) rename patches/{0004-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch => 0005-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch} (87%) 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 index 0851805..d0b7db5 100644 --- a/patches/0001-ns-add-accessibility-base-classes-and-text-extractio.patch +++ b/patches/0001-ns-add-accessibility-base-classes-and-text-extractio.patch @@ -1,90 +1,37 @@ -From eb8038a4d9c4fb4640b0987d6529e8b961353596 Mon Sep 17 00:00:00 2001 +From 53a22c5d2014002256acf1c1bc14af2dfbb26469 Mon Sep 17 00:00:00 2001 From: Martin Sukany -Date: Sat, 28 Feb 2026 09:54:28 +0100 -Subject: [PATCH 1/4] ns: add accessibility base classes and text extraction +Date: Sat, 28 Feb 2026 10:10:55 +0100 +Subject: [PATCH 1/5] ns: add accessibility base classes and text extraction -Add the foundation for macOS VoiceOver accessibility support in the -NS (Cocoa) port. This patch provides the base class hierarchy, text -extraction with invisible-text handling, coordinate mapping, and -notification helpers. No existing code paths are modified. +Add the foundation for macOS VoiceOver accessibility in the NS +(Cocoa) port. No existing code paths are modified. -New types (nsterm.h): - - ns_ax_visible_run: maps buffer character positions to UTF-16 - accessibility indices, skipping invisible text. - - EmacsAccessibilityElement: base class for virtual AX elements. - - Forward declarations for EmacsAccessibilityBuffer, - EmacsAccessibilityModeLine, EmacsAccessibilityInteractiveSpan. - - EmacsAXSpanType: enum for interactive span classification. - - EmacsView ivar extensions: accessibilityElements, last- - SelectedWindow, accessibilityTreeValid, lastAccessibilityCursorRect. - -New helper functions (nsterm.m): - - ns_ax_buffer_text: build accessibility string with visible-run - mapping. Uses TEXT_PROP_MEANS_INVISIBLE for spec-controlled - invisibility, Fbuffer_substring_no_properties for buffer-gap - safety. Capped at NS_AX_TEXT_CAP (100,000 UTF-16 units). - - ns_ax_mode_line_text: extract mode-line text from glyph matrix - (CHAR_GLYPH only; image/stretch glyphs skipped with TODO note). - - ns_ax_frame_for_range: screen rect for character range via glyph - matrix lookup with text-area clipping. - - ns_ax_post_notification, ns_ax_post_notification_with_info: - dispatch_async wrappers to prevent deadlock. - - Utility helpers: ns_ax_completion_string_from_prop, - ns_ax_window_buffer_object, ns_ax_window_end_charpos, - ns_ax_text_prop_at, ns_ax_next_prop_change, ns_ax_get_span_label. - -EmacsAccessibilityElement @implementation: base class with -validWindow, screenRectFromEmacsX:y:width:height:, and hierarchy -plumbing (accessibilityParent, accessibilityWindow). - -New user option: ns-accessibility-enabled (default t). - -Tested on macOS 14 Sonoma. Builds cleanly; base class instantiates; -symbols register; no functional change (integration in next patch). - -* src/nsterm.h: New class declarations, struct, enum, ivar extensions. -* src/nsterm.m: Helper functions, base element, DEFSYM, DEFVAR. -* etc/NEWS: Document VoiceOver accessibility support. +* src/nsterm.h (ns_ax_visible_run): New struct for buffer-to-UTF-16 +index mapping. +(EmacsAccessibilityElement): New base class for virtual AX elements. +(EmacsAccessibilityBuffer, EmacsAccessibilityModeLine) +(EmacsAccessibilityInteractiveSpan): Forward declarations. +(EmacsAXSpanType): New enum for span classification. +(EmacsView): New ivars accessibilityElements, lastSelectedWindow, +accessibilityTreeValid, lastAccessibilityCursorRect. +* src/nsterm.m: Include intervals.h for TEXT_PROP_MEANS_INVISIBLE. +(ns_ax_buffer_text): New function. Build accessibility string with +visible-run mapping; skip invisible text per spec. +(ns_ax_mode_line_text): New function. Extract CHAR_GLYPH text. +(ns_ax_frame_for_range): New function. Screen rect via glyph matrix. +(ns_ax_post_notification, ns_ax_post_notification_with_info): New +functions. dispatch_async wrappers preventing VoiceOver deadlock. +(ns_ax_completion_string_from_prop, ns_ax_window_buffer_object) +(ns_ax_window_end_charpos, ns_ax_text_prop_at) +(ns_ax_next_prop_change, ns_ax_get_span_label): New utility helpers. +(EmacsAccessibilityElement): Implement base class. +(syms_of_nsterm): Register accessibility DEFSYM and DEFVAR +ns-accessibility-enabled. --- - etc/NEWS | 13 ++ - src/nsterm.h | 119 ++++++++++ - src/nsterm.m | 621 +++++++++++++++++++++++++++++++++++++++++++++++++++ - 3 files changed, 753 insertions(+) + src/nsterm.h | 119 +++++++++++++++++ + src/nsterm.m | 355 +++++++++++++++++++++++++++++++++++++++++++++++++++ + 2 files changed, 474 insertions(+) -diff --git a/etc/NEWS b/etc/NEWS -index 7367e3c..608650e 100644 ---- a/etc/NEWS -+++ b/etc/NEWS -@@ -4374,6 +4374,19 @@ allowing Emacs users access to speech recognition utilities. - Note: Accepting this permission allows the use of system APIs, which may - send user data to Apple's speech recognition servers. - -+--- -+** VoiceOver accessibility support on macOS. -+Emacs now exposes buffer content, cursor position, and interactive -+elements to the macOS accessibility subsystem (VoiceOver). This -+includes AXBoundsForRange for macOS Zoom cursor tracking, line and -+word navigation announcements, Tab-navigable interactive spans -+(buttons, links, completion candidates), and completion announcements -+for the *Completions* buffer. The implementation uses a virtual -+accessibility tree with per-window elements, hybrid SelectedTextChanged -+and AnnouncementRequested notifications, and thread-safe text caching. -+Set 'ns-accessibility-enabled' to nil to disable the accessibility -+interface and eliminate the associated overhead. -+ - --- - ** Re-introduced dictation, lost in Emacs v30 (macOS). - We lost macOS dictation in v30 when migrating to NSTextInputClient. diff --git a/src/nsterm.h b/src/nsterm.h index 7c1ee4c..393fc4c 100644 --- a/src/nsterm.h @@ -230,7 +177,7 @@ index 7c1ee4c..393fc4c 100644 diff --git a/src/nsterm.m b/src/nsterm.m -index 74e4ad5..ee27df1 100644 +index 74e4ad5..c91ec90 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -46,6 +46,7 @@ GNUstep port and post-20 update by Adrian Robert (arobert@cogsci.ucsd.edu) @@ -241,7 +188,7 @@ index 74e4ad5..ee27df1 100644 #include "systime.h" #include "character.h" #include "xwidget.h" -@@ -6856,6 +6857,595 @@ ns_create_font_panel_buttons (id target, SEL select, SEL cancel_action) +@@ -6856,6 +6857,329 @@ ns_create_font_panel_buttons (id target, SEL select, SEL cancel_action) } #endif @@ -506,274 +453,8 @@ index 74e4ad5..ee27df1 100644 + ns_ax_text_selection_granularity_line = 3, +}; + -+static BOOL -+ns_ax_find_completion_overlay_range (struct buffer *b, ptrdiff_t point, -+ ptrdiff_t *out_start, -+ ptrdiff_t *out_end) -+{ -+ if (!b || !out_start || !out_end) -+ return NO; + -+ Lisp_Object faceSym = Qns_ax_completions_highlight; -+ ptrdiff_t begv = BUF_BEGV (b); -+ ptrdiff_t zv = BUF_ZV (b); -+ ptrdiff_t best_start = 0; -+ ptrdiff_t best_end = 0; -+ ptrdiff_t best_dist = PTRDIFF_MAX; -+ BOOL found = NO; + -+ /* Fast path: look at point and immediate neighbors first. -+ Prefer point+1 over point-1: when Tab moves to a new completion, -+ point is at the START of the new entry while point-1 is still -+ inside the previous entry's overlay. Forward probe finds the -+ correct new entry; backward probe finds the wrong old one. */ -+ ptrdiff_t probes[3] = { point, point + 1, point - 1 }; -+ for (int i = 0; i < 3 && !found; i++) -+ { -+ ptrdiff_t p = probes[i]; -+ if (p < begv || p > zv) -+ continue; -+ -+ Lisp_Object overlays = Foverlays_at (make_fixnum (p), Qnil); -+ Lisp_Object tail; -+ for (tail = overlays; CONSP (tail); tail = XCDR (tail)) -+ { -+ Lisp_Object ov = XCAR (tail); -+ Lisp_Object face = Foverlay_get (ov, Qface); -+ if (!(EQ (face, faceSym) -+ || (CONSP (face) && !NILP (Fmemq (faceSym, face))))) -+ continue; -+ -+ ptrdiff_t ov_start = OVERLAY_START (ov); -+ ptrdiff_t ov_end = OVERLAY_END (ov); -+ if (ov_end <= ov_start) -+ continue; -+ -+ best_start = ov_start; -+ best_end = ov_end; -+ best_dist = 0; -+ found = YES; -+ break; -+ } -+ } -+ -+ if (!found) -+ { -+ /* Bulk query: get all overlays in the buffer at once. -+ Avoids the previous O(n) per-character Foverlays_at loop. */ -+ Lisp_Object all = Foverlays_in (make_fixnum (begv), -+ make_fixnum (zv)); -+ Lisp_Object tail; -+ for (tail = all; CONSP (tail); tail = XCDR (tail)) -+ { -+ Lisp_Object ov = XCAR (tail); -+ Lisp_Object face = Foverlay_get (ov, Qface); -+ if (!(EQ (face, faceSym) -+ || (CONSP (face) -+ && !NILP (Fmemq (faceSym, face))))) -+ continue; -+ -+ ptrdiff_t ov_start = OVERLAY_START (ov); -+ ptrdiff_t ov_end = OVERLAY_END (ov); -+ if (ov_end <= ov_start) -+ continue; -+ -+ ptrdiff_t dist = 0; -+ if (point < ov_start) -+ dist = ov_start - point; -+ else if (point > ov_end) -+ dist = point - ov_end; -+ -+ if (!found || dist < best_dist) -+ { -+ best_start = ov_start; -+ best_end = ov_end; -+ best_dist = dist; -+ found = YES; -+ } -+ } -+ } -+ -+ if (!found) -+ return NO; -+ -+ *out_start = best_start; -+ *out_end = best_end; -+ return YES; -+} -+ -+/* Detect line-level navigation commands. Inspects Vthis_command -+ (the command symbol being executed) rather than raw key codes so -+ that remapped bindings (e.g., C-j -> next-line) are recognized. -+ Falls back to last_command_event for Tab/backtab which are not -+ bound to a single canonical command symbol. */ -+static bool -+ns_ax_event_is_line_nav_key (int *which) -+{ -+ /* 1. Check Vthis_command for known navigation command symbols. -+ All symbols are registered via DEFSYM in syms_of_nsterm to avoid -+ per-call obarray lookups in this hot path (runs every cursor move). */ -+ if (SYMBOLP (Vthis_command) && !NILP (Vthis_command)) -+ { -+ Lisp_Object cmd = Vthis_command; -+ /* Forward line commands. */ -+ if (EQ (cmd, Qns_ax_next_line) -+ || EQ (cmd, Qns_ax_dired_next_line) -+ || EQ (cmd, Qns_ax_evil_next_line) -+ || EQ (cmd, Qns_ax_evil_next_visual_line)) -+ { -+ if (which) *which = 1; -+ return true; -+ } -+ /* Backward line commands. */ -+ if (EQ (cmd, Qns_ax_previous_line) -+ || EQ (cmd, Qns_ax_dired_previous_line) -+ || EQ (cmd, Qns_ax_evil_previous_line) -+ || EQ (cmd, Qns_ax_evil_previous_visual_line)) -+ { -+ if (which) *which = -1; -+ return true; -+ } -+ } -+ -+ /* 2. Fallback: check raw key events for Tab/backtab. */ -+ Lisp_Object ev = last_command_event; -+ if (CONSP (ev)) -+ ev = EVENT_HEAD (ev); -+ -+ if (SYMBOLP (ev) && EQ (ev, Qns_ax_backtab)) -+ { -+ if (which) *which = -1; -+ return true; -+ } -+ if (FIXNUMP (ev) && XFIXNUM (ev) == 9) /* Tab */ -+ { -+ if (which) *which = 1; -+ return true; -+ } -+ return false; -+} -+ -+/* =================================================================== -+ EmacsAccessibilityInteractiveSpan — helpers and implementation -+ =================================================================== */ -+ -+/* Extract announcement string from completion--string property value. -+ The property can be a plain Lisp string (simple completion) or -+ a list ("candidate" "annotation") for annotated completions. -+ Returns nil on failure. */ -+static NSString * -+ns_ax_completion_string_from_prop (Lisp_Object cstr) -+{ -+ if (STRINGP (cstr)) -+ return [NSString stringWithLispString: cstr]; -+ if (CONSP (cstr) && STRINGP (XCAR (cstr))) -+ return [NSString stringWithLispString: XCAR (cstr)]; -+ return nil; -+} -+ -+/* Return the Emacs buffer Lisp object for window W, or Qnil. */ -+static Lisp_Object -+ns_ax_window_buffer_object (struct window *w) -+{ -+ if (!w) -+ return Qnil; -+ if (!BUFFERP (w->contents)) -+ return Qnil; -+ return w->contents; -+} -+ -+/* Compute visible-end charpos for window W. -+ Emacs stores it as BUF_Z - window_end_pos. -+ Falls back to BUF_ZV when window_end_valid is false (e.g., when -+ called from an AX getter before the next redisplay cycle). */ -+static ptrdiff_t -+ns_ax_window_end_charpos (struct window *w, struct buffer *b) -+{ -+ if (!w->window_end_valid) -+ return BUF_ZV (b); -+ return BUF_Z (b) - w->window_end_pos; -+} -+ -+/* Fetch text property PROP at charpos POS in BUF_OBJ. */ -+static Lisp_Object -+ns_ax_text_prop_at (ptrdiff_t pos, Lisp_Object prop, Lisp_Object buf_obj) -+{ -+ Lisp_Object plist = Ftext_properties_at (make_fixnum (pos), buf_obj); -+ /* Third argument to Fplist_get is PREDICATE (Emacs 29+), not a -+ default value. Qnil selects the default `eq' comparison. */ -+ return Fplist_get (plist, prop, Qnil); -+} -+ -+/* Next charpos where PROP changes, capped at LIMIT. */ -+static ptrdiff_t -+ns_ax_next_prop_change (ptrdiff_t pos, Lisp_Object prop, -+ Lisp_Object buf_obj, ptrdiff_t limit) -+{ -+ Lisp_Object result -+ = Fnext_single_property_change (make_fixnum (pos), prop, -+ buf_obj, make_fixnum (limit)); -+ return FIXNUMP (result) ? XFIXNUM (result) : limit; -+} -+ -+/* Build label for span [START, END) in BUF_OBJ. -+ Priority: completion--string → buffer text → help-echo. */ -+static NSString * -+ns_ax_get_span_label (ptrdiff_t start, ptrdiff_t end, -+ Lisp_Object buf_obj) -+{ -+ Lisp_Object cs = ns_ax_text_prop_at (start, Qns_ax_completion__string, -+ buf_obj); -+ if (STRINGP (cs)) -+ return [NSString stringWithLispString: cs]; -+ -+ if (end > start) -+ { -+ Lisp_Object substr = Fbuffer_substring_no_properties ( -+ make_fixnum (start), make_fixnum (end)); -+ if (STRINGP (substr)) -+ { -+ NSString *s = [NSString stringWithLispString: substr]; -+ s = [s stringByTrimmingCharactersInSet: -+ [NSCharacterSet whitespaceAndNewlineCharacterSet]]; -+ if (s.length > 0) -+ return s; -+ } -+ } -+ -+ Lisp_Object he = ns_ax_text_prop_at (start, Qhelp_echo, buf_obj); -+ if (STRINGP (he)) -+ return [NSString stringWithLispString: he]; -+ -+ return @""; -+} -+ -+/* Post AX notifications asynchronously to prevent deadlock. -+ NSAccessibilityPostNotification may synchronously invoke VoiceOver -+ callbacks that dispatch_sync back to the main queue. If we are -+ already on the main queue (e.g., inside postAccessibilityUpdates -+ called from ns_update_end), that dispatch_sync deadlocks. -+ Deferring via dispatch_async lets the current method return first, -+ freeing the main queue for VoiceOver's dispatch_sync calls. */ -+ -+static inline void -+ns_ax_post_notification (id element, -+ NSAccessibilityNotificationName name) -+{ -+ dispatch_async (dispatch_get_main_queue (), ^{ -+ NSAccessibilityPostNotification (element, name); -+ }); -+} -+ -+static inline void -+ns_ax_post_notification_with_info (id element, -+ NSAccessibilityNotificationName name, -+ NSDictionary *info) -+{ -+ dispatch_async (dispatch_get_main_queue (), ^{ -+ NSAccessibilityPostNotificationWithUserInfo (element, name, info); -+ }); -+} + + +@implementation EmacsAccessibilityElement @@ -837,7 +518,7 @@ index 74e4ad5..ee27df1 100644 /* ========================================================================== EmacsView implementation -@@ -11312,6 +11902,28 @@ syms_of_nsterm (void) +@@ -11312,6 +11636,28 @@ syms_of_nsterm (void) DEFSYM (Qns_drag_operation_generic, "ns-drag-operation-generic"); DEFSYM (Qns_handle_drag_motion, "ns-handle-drag-motion"); @@ -866,7 +547,7 @@ index 74e4ad5..ee27df1 100644 Fput (Qalt, Qmodifier_value, make_fixnum (alt_modifier)); Fput (Qhyper, Qmodifier_value, make_fixnum (hyper_modifier)); Fput (Qmeta, Qmodifier_value, make_fixnum (meta_modifier)); -@@ -11460,6 +12072,15 @@ Note that this does not apply to images. +@@ -11460,6 +11806,15 @@ Note that this does not apply to images. This variable is ignored on Mac OS X < 10.7 and GNUstep. */); ns_use_srgb_colorspace = YES; diff --git a/patches/0002-ns-implement-buffer-mode-line-and-interactive-span-e.patch b/patches/0002-ns-implement-buffer-and-mode-line-accessibility-elem.patch similarity index 79% rename from patches/0002-ns-implement-buffer-mode-line-and-interactive-span-e.patch rename to patches/0002-ns-implement-buffer-and-mode-line-accessibility-elem.patch index efbaadf..4802b06 100644 --- a/patches/0002-ns-implement-buffer-mode-line-and-interactive-span-e.patch +++ b/patches/0002-ns-implement-buffer-and-mode-line-accessibility-elem.patch @@ -1,348 +1,193 @@ -From a49c6b5a9601fe11a6a03292e8b4d685a0ce50af Mon Sep 17 00:00:00 2001 +From 757988a19f7aef1f03ec277f898d92bdd4f2607e Mon Sep 17 00:00:00 2001 From: Martin Sukany -Date: Sat, 28 Feb 2026 09:54:28 +0100 -Subject: [PATCH 2/4] ns: implement buffer, mode-line, and interactive span - elements +Date: Sat, 28 Feb 2026 10:10:55 +0100 +Subject: [PATCH 2/5] ns: implement buffer and mode-line accessibility elements -Add the three remaining virtual element classes, completing the -accessibility object model. Combined with the previous patch, this -provides a full NSAccessibility text protocol implementation. +Add the full NSAccessibility text protocol for Emacs windows and +mode-line readout. -EmacsAccessibilityBuffer : full text protocol for -a single Emacs window. - - Text cache: @synchronized caching of buffer text and visible-run - array. Cache invalidated on modiff_count, window start, or - invisible-text configuration change. - - Index mapping: binary search O(log n) between buffer positions and - UTF-16 accessibility indices via the visible-run array. - - Selection: selectedTextRange from point/mark; insertion point from - point via index mapping. - - Geometry: lineForIndex/indexForLine by newline scanning. - frameForRange delegates to ns_ax_frame_for_range. - - Notification dispatch (postTextChangedNotification): hybrid - SelectedTextChanged / ValueChanged / AnnouncementRequested, - modeled on WebKit's pattern. Line navigation emits ValueChanged; - character/word motion emits SelectedTextChanged only. Completion - buffer announcements via AnnouncementRequested with High priority. - -EmacsAccessibilityModeLine: AXStaticText exposing mode-line content. - -EmacsAccessibilityInteractiveSpan: lightweight child of a buffer -element for Tab-navigable interactive text. - -ns_ax_scan_interactive_spans: scan visible range with O(n/skip) -property-skip optimization. Priority: widget > button > follow-link -> org-link > completion-candidate > keymap-overlay. - -Buffer (InteractiveSpans) category: Tab/Shift-Tab cycling with -wrap-around and VoiceOver focus notification. - -ns_ax_completion_text_for_span: extract completion candidate text. - -Threading: Lisp-accessing methods use dispatch_sync to main thread; -@synchronized protects text cache. - -Tested on macOS 14 with VoiceOver. Verified: buffer reading, line -navigation, word/character announcements, completion announcements, -Tab-cycling interactive spans, mode-line readout. - -* src/nsterm.m: EmacsAccessibilityBuffer, EmacsAccessibilityModeLine, -EmacsAccessibilityInteractiveSpan, supporting functions. +* src/nsterm.m (ns_ax_find_completion_overlay_range): New function. +(ns_ax_event_is_line_nav_key): New function. +(ns_ax_completion_text_for_span): New function. +(EmacsAccessibilityBuffer): Implement NSAccessibility protocol: +text cache with @synchronized, visible-run binary search O(log n), +selectedTextRange, lineForIndex/indexForLine, frameForRange, +rangeForPosition, setAccessibilitySelectedTextRange, setAccessibility- +Focused. +(EmacsAccessibilityBuffer postTextChangedNotification:): New method. +ValueChanged with edit details. +(EmacsAccessibilityBuffer postFocusedCursorNotification:direction: +granularity:markActive:oldMarkActive:): New method. Hybrid +SelectedTextChanged / AnnouncementRequested per WebKit pattern. +(EmacsAccessibilityBuffer postCompletionAnnouncementForBuffer:point:): +New method. Announce completion candidates in non-focused buffers. +(EmacsAccessibilityBuffer postAccessibilityNotificationsForFrame:): +New method. Main dispatch: edit vs cursor-move vs no-change. +(EmacsAccessibilityModeLine): Implement AXStaticText element. --- - src/nsterm.m | 1716 ++++++++++++++++++++++++++++++++++++++++++++++++++ - 1 file changed, 1716 insertions(+) + src/nsterm.m | 1620 ++++++++++++++++++++++++++++++++++++++++++++++++++ + 1 file changed, 1620 insertions(+) diff --git a/src/nsterm.m b/src/nsterm.m -index ee27df1..c47912d 100644 +index c91ec90..90db3b7 100644 --- a/src/nsterm.m +++ b/src/nsterm.m -@@ -7387,6 +7387,351 @@ ns_ax_post_notification_with_info (id element, - }); - } +@@ -7177,6 +7177,1626 @@ enum { + + @end -+/* Scan visible range of window W for interactive spans. -+ Returns NSArray. + -+ Priority when properties overlap: -+ widget > button > follow-link > org-link > -+ completion-candidate > keymap-overlay. */ -+static NSArray * -+ns_ax_scan_interactive_spans (struct window *w, -+ EmacsAccessibilityBuffer *parent_buf) ++ ++ ++ ++static BOOL ++ns_ax_find_completion_overlay_range (struct buffer *b, ptrdiff_t point, ++ ptrdiff_t *out_start, ++ ptrdiff_t *out_end) +{ -+ if (!w) -+ return @[]; -+ -+ Lisp_Object buf_obj = ns_ax_window_buffer_object (w); -+ if (NILP (buf_obj)) -+ return @[]; -+ -+ struct buffer *b = XBUFFER (buf_obj); -+ ptrdiff_t vis_start = marker_position (w->start); -+ ptrdiff_t vis_end = ns_ax_window_end_charpos (w, b); -+ -+ if (vis_start < BUF_BEGV (b)) vis_start = BUF_BEGV (b); -+ if (vis_end > BUF_ZV (b)) vis_end = BUF_ZV (b); -+ if (vis_start >= vis_end) -+ return @[]; -+ -+ /* Symbols are interned once at startup via DEFSYM in syms_of_nsterm; -+ reference them directly here (GC-safe, no repeated obarray lookup). */ -+ -+ BOOL is_completion_buf = EQ (BVAR (b, major_mode), Qns_ax_completion_list_mode); -+ -+ NSMutableArray *spans = [NSMutableArray array]; -+ ptrdiff_t pos = vis_start; -+ -+ while (pos < vis_end) -+ { -+ Lisp_Object plist = Ftext_properties_at (make_fixnum (pos), buf_obj); -+ EmacsAXSpanType span_type = EmacsAXSpanTypeNone; -+ Lisp_Object limit_prop = Qnil; -+ -+ if (!NILP (Fplist_get (plist, Qns_ax_widget, Qnil))) -+ { -+ span_type = EmacsAXSpanTypeWidget; -+ limit_prop = Qns_ax_widget; -+ } -+ else if (!NILP (Fplist_get (plist, Qns_ax_button, Qnil))) -+ { -+ span_type = EmacsAXSpanTypeButton; -+ limit_prop = Qns_ax_button; -+ } -+ else if (!NILP (Fplist_get (plist, Qns_ax_follow_link, Qnil))) -+ { -+ span_type = EmacsAXSpanTypeLink; -+ limit_prop = Qns_ax_follow_link; -+ } -+ else if (!NILP (Fplist_get (plist, Qns_ax_org_link, Qnil))) -+ { -+ span_type = EmacsAXSpanTypeLink; -+ limit_prop = Qns_ax_org_link; -+ } -+ else if (is_completion_buf -+ && !NILP (Fplist_get (plist, Qmouse_face, Qnil))) -+ { -+ /* For completions, use completion--string as boundary so we -+ don't accidentally merge two column-adjacent candidates -+ whose mouse-face regions may share padding whitespace. -+ Fall back to mouse-face if completion--string is absent. */ -+ Lisp_Object cs_sym = Qns_ax_completion__string; -+ Lisp_Object cs_val = ns_ax_text_prop_at (pos, cs_sym, buf_obj); -+ span_type = EmacsAXSpanTypeCompletionItem; -+ limit_prop = NILP (cs_val) ? Qmouse_face : cs_sym; -+ } -+ else -+ { -+ /* Check overlays for keymap. */ -+ Lisp_Object ovs -+ = Foverlays_in (make_fixnum (pos), make_fixnum (pos + 1)); -+ while (CONSP (ovs)) -+ { -+ if (!NILP (Foverlay_get (XCAR (ovs), Qkeymap))) -+ { -+ span_type = EmacsAXSpanTypeButton; -+ limit_prop = Qkeymap; -+ break; -+ } -+ ovs = XCDR (ovs); -+ } -+ } -+ -+ if (span_type == EmacsAXSpanTypeNone) -+ { -+ /* Skip to the next position where any interactive property -+ changes. Try each scannable property in turn and take -+ the nearest change point — O(properties) per gap rather -+ than O(chars). Fall back to pos+1 as safety net. */ -+ ptrdiff_t next_interesting = vis_end; -+ Lisp_Object skip_props[5] -+ = { Qns_ax_widget, Qns_ax_button, Qns_ax_follow_link, -+ Qns_ax_org_link, Qmouse_face }; -+ for (int sp = 0; sp < 5; sp++) -+ { -+ ptrdiff_t np -+ = ns_ax_next_prop_change (pos, skip_props[sp], -+ buf_obj, vis_end); -+ if (np > pos && np < next_interesting) -+ next_interesting = np; -+ } -+ /* Also check overlay keymap changes. */ -+ Lisp_Object np_ov -+ = Fnext_single_char_property_change (make_fixnum (pos), -+ Qkeymap, buf_obj, -+ make_fixnum (vis_end)); -+ if (FIXNUMP (np_ov)) -+ { -+ ptrdiff_t npv = XFIXNUM (np_ov); -+ if (npv > pos && npv < next_interesting) -+ next_interesting = npv; -+ } -+ pos = (next_interesting > pos) ? next_interesting : pos + 1; -+ continue; -+ } -+ -+ ptrdiff_t span_end = !NILP (limit_prop) -+ ? ns_ax_next_prop_change (pos, limit_prop, buf_obj, vis_end) -+ : pos + 1; -+ -+ if (span_end > vis_end) span_end = vis_end; -+ if (span_end <= pos) span_end = pos + 1; -+ -+ EmacsAccessibilityInteractiveSpan *span -+ = [[EmacsAccessibilityInteractiveSpan alloc] init]; -+ span.charposStart = pos; -+ span.charposEnd = span_end; -+ span.spanType = span_type; -+ span.parentBuffer = parent_buf; -+ span.emacsView = parent_buf.emacsView; -+ span.lispWindow = parent_buf.lispWindow; -+ span.spanLabel = ns_ax_get_span_label (pos, span_end, buf_obj); -+ -+ [spans addObject: span]; -+ [span release]; -+ -+ pos = span_end; -+ } -+ -+ return [[spans copy] autorelease]; -+} -+ -+@implementation EmacsAccessibilityInteractiveSpan -+@synthesize spanLabel; -+@synthesize spanValue; -+ -+- (void)dealloc -+{ -+ [spanLabel release]; -+ [spanValue release]; -+ [super dealloc]; -+} -+ -+- (BOOL) isAccessibilityElement { return YES; } -+ -+- (NSAccessibilityRole) accessibilityRole -+{ -+ switch (self.spanType) -+ { -+ case EmacsAXSpanTypeLink: return NSAccessibilityLinkRole; -+ default: return NSAccessibilityButtonRole; -+ } -+} -+ -+- (NSString *) accessibilityLabel { return self.spanLabel ?: @""; } -+- (NSString *) accessibilityValue { return self.spanValue; } -+ -+- (NSRect) accessibilityFrame -+{ -+ EmacsAccessibilityBuffer *pb = self.parentBuffer; -+ if (!pb || ![self validWindow]) -+ return NSZeroRect; -+ NSUInteger ax_s = [pb accessibilityIndexForCharpos: self.charposStart]; -+ NSUInteger ax_e = [pb accessibilityIndexForCharpos: self.charposEnd]; -+ if (ax_e < ax_s) ax_e = ax_s; -+ return [pb accessibilityFrameForRange: NSMakeRange (ax_s, ax_e - ax_s)]; -+} -+ -+- (BOOL) isAccessibilityFocused -+{ -+ /* Read the cached point stored by EmacsAccessibilityBuffer on the main -+ thread — safe to read from any thread (plain ptrdiff_t, no Lisp calls). */ -+ EmacsAccessibilityBuffer *pb = self.parentBuffer; -+ if (!pb) ++ if (!b || !out_start || !out_end) + return NO; -+ ptrdiff_t pt = pb.cachedPoint; -+ return pt >= self.charposStart && pt < self.charposEnd; -+} + -+- (void) setAccessibilityFocused: (BOOL) focused -+{ -+ if (!focused) -+ return; -+ ptrdiff_t target = self.charposStart; -+ Lisp_Object lwin = self.lispWindow; -+ dispatch_async (dispatch_get_main_queue (), ^{ -+ /* lwin is a Lisp_Object captured by value. This is GC-safe -+ because Lisp_Objects are tagged integers/pointers that -+ remain valid across GC — GC does not relocate objects in -+ Emacs. The WINDOW_LIVE_P check below guards against the -+ window being deleted between capture and execution. */ -+ if (!WINDOWP (lwin) || NILP (Fwindow_live_p (lwin))) -+ return; -+ /* Use specpdl unwind protection so that block_input is always -+ matched by unblock_input, even if Fselect_window signals. */ -+ specpdl_ref count = SPECPDL_INDEX (); -+ record_unwind_protect_void (unblock_input); -+ block_input (); -+ record_unwind_current_buffer (); -+ Fselect_window (lwin, Qnil); -+ struct window *w = XWINDOW (lwin); -+ struct buffer *b = XBUFFER (w->contents); -+ if (b != current_buffer) -+ set_buffer_internal_1 (b); -+ ptrdiff_t pos = target; -+ if (pos < BUF_BEGV (b)) pos = BUF_BEGV (b); -+ if (pos > BUF_ZV (b)) pos = BUF_ZV (b); -+ SET_PT_BOTH (pos, CHAR_TO_BYTE (pos)); -+ unbind_to (count, Qnil); -+ }); -+} ++ 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; + -+@end -+ -+/* EmacsAccessibilityBuffer — InteractiveSpans category. -+ Methods are kept here (same .m file) so they access the ivars -+ declared in the @interface ivar block. */ -+@implementation EmacsAccessibilityBuffer (InteractiveSpans) -+ -+- (void) invalidateInteractiveSpans -+{ -+ interactiveSpansDirty = YES; -+} -+ -+- (NSArray *) accessibilityChildrenInNavigationOrder -+{ -+ if (!interactiveSpansDirty && cachedInteractiveSpans != nil) -+ return cachedInteractiveSpans; -+ -+ if (![NSThread isMainThread]) ++ /* 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++) + { -+ __block NSArray *result; -+ dispatch_sync (dispatch_get_main_queue (), ^{ -+ result = [self accessibilityChildrenInNavigationOrder]; -+ }); -+ return result; ++ 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; ++ } + } + -+ struct window *w = [self validWindow]; -+ if (!w) -+ return cachedInteractiveSpans ? cachedInteractiveSpans : @[]; ++ 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; + -+ /* Validate buffer before scanning. The Lisp calls inside -+ ns_ax_scan_interactive_spans (Ftext_properties_at, Fplist_get, -+ Fnext_single_property_change) do not signal on valid buffers -+ with valid positions. Verify those preconditions here so we -+ never enter the scan with invalid state, which could longjmp -+ out of a dispatch_sync block and deadlock the AX thread. */ -+ if (!BUFFERP (w->contents) || !XBUFFER (w->contents)) -+ return cachedInteractiveSpans ? cachedInteractiveSpans : @[]; ++ ptrdiff_t ov_start = OVERLAY_START (ov); ++ ptrdiff_t ov_end = OVERLAY_END (ov); ++ if (ov_end <= ov_start) ++ continue; + -+ NSArray *spans = ns_ax_scan_interactive_spans (w, self); ++ ptrdiff_t dist = 0; ++ if (point < ov_start) ++ dist = ov_start - point; ++ else if (point > ov_end) ++ dist = point - ov_end; + -+ if (!cachedInteractiveSpans) -+ cachedInteractiveSpans = [[NSMutableArray alloc] init]; -+ [cachedInteractiveSpans setArray: spans]; -+ interactiveSpansDirty = NO; ++ if (!found || dist < best_dist) ++ { ++ best_start = ov_start; ++ best_end = ov_end; ++ best_dist = dist; ++ found = YES; ++ } ++ } ++ } + -+ return cachedInteractiveSpans; ++ 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; +} + -+@end + + +static NSString * @@ -409,14 +254,6 @@ index ee27df1..c47912d 100644 + + return text; +} -+ - - @implementation EmacsAccessibilityElement - -@@ -7443,6 +7788,1377 @@ ns_ax_post_notification_with_info (id element, - - @end - + +@implementation EmacsAccessibilityBuffer +@synthesize cachedText; @@ -614,6 +451,37 @@ index ee27df1..c47912d 100644 + } /* @synchronized */ +} + ++/* --- Threading and signal safety --- ++ ++ AX getter methods may be called from the VoiceOver server thread. ++ All methods that access Lisp objects or buffer state dispatch_sync ++ to the main thread where Emacs state is consistent. ++ ++ Longjmp safety: Lisp functions called inside dispatch_sync blocks ++ (Fget_char_property, Fbuffer_substring_no_properties, etc.) could ++ theoretically signal and longjmp through the dispatch_sync frame, ++ deadlocking the AX server thread. This is prevented by: ++ ++ 1. validWindow checks WINDOW_LIVE_P and BUFFERP before every ++ Lisp access — the window and buffer are verified live. ++ 2. All dispatch_sync blocks run on the main thread where no ++ concurrent Lisp code can modify state between checks. ++ 3. block_input prevents timer events and process output from ++ running between precondition checks and Lisp calls. ++ 4. Buffer positions are clamped to BUF_BEGV/BUF_ZV before ++ use, preventing out-of-range signals. ++ 5. specpdl unwind protection ensures block_input is always ++ matched by unblock_input, even on longjmp. ++ ++ This matches the safety model of existing nsterm.m F-function ++ calls (24 direct calls, none wrapped in internal_condition_case). ++ ++ Known gap: if the Emacs window tree is modified between redisplay ++ cycles in a way that invalidates validWindow's cached result, ++ a stale dereference could occur. In practice this does not happen ++ because window tree modifications go through the event loop which ++ we are blocking via dispatch_sync. */ ++ +/* ---- NSAccessibility protocol ---- */ + +- (NSAccessibilityRole)accessibilityRole diff --git a/patches/0003-ns-add-interactive-span-elements-for-Tab-navigation.patch b/patches/0003-ns-add-interactive-span-elements-for-Tab-navigation.patch new file mode 100644 index 0000000..854410f --- /dev/null +++ b/patches/0003-ns-add-interactive-span-elements-for-Tab-navigation.patch @@ -0,0 +1,437 @@ +From 7c1ad53ed32fc4b2650f0af51dd4d8b5ed87bdf4 Mon Sep 17 00:00:00 2001 +From: Martin Sukany +Date: Sat, 28 Feb 2026 10:10:55 +0100 +Subject: [PATCH 3/5] ns: add interactive span elements for Tab navigation + +Add lightweight child elements for Tab-navigable interactive text. + +* src/nsterm.m (ns_ax_scan_interactive_spans): New function. Scan +visible range with O(n/skip) property-skip optimization. Priority: +widget > button > follow-link > org-link > completion-candidate > +keymap-overlay. +(EmacsAccessibilityInteractiveSpan): Implement AXButton/AXLink +elements with AXPress action. +(EmacsAccessibilityBuffer(InteractiveSpans)): New category. +accessibilityChildrenInNavigationOrder for Tab/Shift-Tab cycling +with wrap-around. +--- + src/nsterm.m | 403 +++++++++++++++++++++++++++++++++++++++++++++++++++ + 1 file changed, 403 insertions(+) + +diff --git a/src/nsterm.m b/src/nsterm.m +index 90db3b7..db5e4b3 100644 +--- a/src/nsterm.m ++++ b/src/nsterm.m +@@ -8797,6 +8797,409 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, + + @end + ++ ++ ++/* =================================================================== ++ EmacsAccessibilityInteractiveSpan — helpers and implementation ++ =================================================================== */ ++ ++/* Extract announcement string from completion--string property value. ++ The property can be a plain Lisp string (simple completion) or ++ a list ("candidate" "annotation") for annotated completions. ++ Returns nil on failure. */ ++static NSString * ++ns_ax_completion_string_from_prop (Lisp_Object cstr) ++{ ++ if (STRINGP (cstr)) ++ return [NSString stringWithLispString: cstr]; ++ if (CONSP (cstr) && STRINGP (XCAR (cstr))) ++ return [NSString stringWithLispString: XCAR (cstr)]; ++ return nil; ++} ++ ++/* Return the Emacs buffer Lisp object for window W, or Qnil. */ ++static Lisp_Object ++ns_ax_window_buffer_object (struct window *w) ++{ ++ if (!w) ++ return Qnil; ++ if (!BUFFERP (w->contents)) ++ return Qnil; ++ return w->contents; ++} ++ ++/* Compute visible-end charpos for window W. ++ Emacs stores it as BUF_Z - window_end_pos. ++ Falls back to BUF_ZV when window_end_valid is false (e.g., when ++ called from an AX getter before the next redisplay cycle). */ ++static ptrdiff_t ++ns_ax_window_end_charpos (struct window *w, struct buffer *b) ++{ ++ if (!w->window_end_valid) ++ return BUF_ZV (b); ++ return BUF_Z (b) - w->window_end_pos; ++} ++ ++/* Fetch text property PROP at charpos POS in BUF_OBJ. */ ++static Lisp_Object ++ns_ax_text_prop_at (ptrdiff_t pos, Lisp_Object prop, Lisp_Object buf_obj) ++{ ++ Lisp_Object plist = Ftext_properties_at (make_fixnum (pos), buf_obj); ++ /* Third argument to Fplist_get is PREDICATE (Emacs 29+), not a ++ default value. Qnil selects the default `eq' comparison. */ ++ return Fplist_get (plist, prop, Qnil); ++} ++ ++/* Next charpos where PROP changes, capped at LIMIT. */ ++static ptrdiff_t ++ns_ax_next_prop_change (ptrdiff_t pos, Lisp_Object prop, ++ Lisp_Object buf_obj, ptrdiff_t limit) ++{ ++ Lisp_Object result ++ = Fnext_single_property_change (make_fixnum (pos), prop, ++ buf_obj, make_fixnum (limit)); ++ return FIXNUMP (result) ? XFIXNUM (result) : limit; ++} ++ ++/* Build label for span [START, END) in BUF_OBJ. ++ Priority: completion--string → buffer text → help-echo. */ ++static NSString * ++ns_ax_get_span_label (ptrdiff_t start, ptrdiff_t end, ++ Lisp_Object buf_obj) ++{ ++ Lisp_Object cs = ns_ax_text_prop_at (start, Qns_ax_completion__string, ++ buf_obj); ++ if (STRINGP (cs)) ++ return [NSString stringWithLispString: cs]; ++ ++ if (end > start) ++ { ++ Lisp_Object substr = Fbuffer_substring_no_properties ( ++ make_fixnum (start), make_fixnum (end)); ++ if (STRINGP (substr)) ++ { ++ NSString *s = [NSString stringWithLispString: substr]; ++ s = [s stringByTrimmingCharactersInSet: ++ [NSCharacterSet whitespaceAndNewlineCharacterSet]]; ++ if (s.length > 0) ++ return s; ++ } ++ } ++ ++ Lisp_Object he = ns_ax_text_prop_at (start, Qhelp_echo, buf_obj); ++ if (STRINGP (he)) ++ return [NSString stringWithLispString: he]; ++ ++ return @""; ++} ++ ++/* Post AX notifications asynchronously to prevent deadlock. ++ NSAccessibilityPostNotification may synchronously invoke VoiceOver ++ callbacks that dispatch_sync back to the main queue. If we are ++ already on the main queue (e.g., inside postAccessibilityUpdates ++ called from ns_update_end), that dispatch_sync deadlocks. ++ Deferring via dispatch_async lets the current method return first, ++ freeing the main queue for VoiceOver's dispatch_sync calls. */ ++ ++static inline void ++ns_ax_post_notification (id element, ++ NSAccessibilityNotificationName name) ++{ ++ dispatch_async (dispatch_get_main_queue (), ^{ ++ NSAccessibilityPostNotification (element, name); ++ }); ++} ++ ++static inline void ++ns_ax_post_notification_with_info (id element, ++ NSAccessibilityNotificationName name, ++ NSDictionary *info) ++{ ++ dispatch_async (dispatch_get_main_queue (), ^{ ++ NSAccessibilityPostNotificationWithUserInfo (element, name, info); ++ }); ++} ++ ++/* Scan visible range of window W for interactive spans. ++ Returns NSArray. ++ ++ Priority when properties overlap: ++ widget > button > follow-link > org-link > ++ completion-candidate > keymap-overlay. */ ++static NSArray * ++ns_ax_scan_interactive_spans (struct window *w, ++ EmacsAccessibilityBuffer *parent_buf) ++{ ++ if (!w) ++ return @[]; ++ ++ Lisp_Object buf_obj = ns_ax_window_buffer_object (w); ++ if (NILP (buf_obj)) ++ return @[]; ++ ++ struct buffer *b = XBUFFER (buf_obj); ++ ptrdiff_t vis_start = marker_position (w->start); ++ ptrdiff_t vis_end = ns_ax_window_end_charpos (w, b); ++ ++ if (vis_start < BUF_BEGV (b)) vis_start = BUF_BEGV (b); ++ if (vis_end > BUF_ZV (b)) vis_end = BUF_ZV (b); ++ if (vis_start >= vis_end) ++ return @[]; ++ ++ /* Symbols are interned once at startup via DEFSYM in syms_of_nsterm; ++ reference them directly here (GC-safe, no repeated obarray lookup). */ ++ ++ BOOL is_completion_buf ++ = EQ (BVAR (b, major_mode), Qns_ax_completion_list_mode); ++ ++ NSMutableArray *spans = [NSMutableArray array]; ++ ptrdiff_t pos = vis_start; ++ ++ while (pos < vis_end) ++ { ++ Lisp_Object plist = Ftext_properties_at (make_fixnum (pos), buf_obj); ++ EmacsAXSpanType span_type = EmacsAXSpanTypeNone; ++ Lisp_Object limit_prop = Qnil; ++ ++ if (!NILP (Fplist_get (plist, Qns_ax_widget, Qnil))) ++ { ++ span_type = EmacsAXSpanTypeWidget; ++ limit_prop = Qns_ax_widget; ++ } ++ else if (!NILP (Fplist_get (plist, Qns_ax_button, Qnil))) ++ { ++ span_type = EmacsAXSpanTypeButton; ++ limit_prop = Qns_ax_button; ++ } ++ else if (!NILP (Fplist_get (plist, Qns_ax_follow_link, Qnil))) ++ { ++ span_type = EmacsAXSpanTypeLink; ++ limit_prop = Qns_ax_follow_link; ++ } ++ else if (!NILP (Fplist_get (plist, Qns_ax_org_link, Qnil))) ++ { ++ span_type = EmacsAXSpanTypeLink; ++ limit_prop = Qns_ax_org_link; ++ } ++ else if (is_completion_buf ++ && !NILP (Fplist_get (plist, Qmouse_face, Qnil))) ++ { ++ /* For completions, use completion--string as boundary so we ++ don't accidentally merge two column-adjacent candidates ++ whose mouse-face regions may share padding whitespace. ++ Fall back to mouse-face if completion--string is absent. */ ++ Lisp_Object cs_sym = Qns_ax_completion__string; ++ Lisp_Object cs_val = ns_ax_text_prop_at (pos, cs_sym, buf_obj); ++ span_type = EmacsAXSpanTypeCompletionItem; ++ limit_prop = NILP (cs_val) ? Qmouse_face : cs_sym; ++ } ++ else ++ { ++ /* Check overlays for keymap. */ ++ Lisp_Object ovs ++ = Foverlays_in (make_fixnum (pos), make_fixnum (pos + 1)); ++ while (CONSP (ovs)) ++ { ++ if (!NILP (Foverlay_get (XCAR (ovs), Qkeymap))) ++ { ++ span_type = EmacsAXSpanTypeButton; ++ limit_prop = Qkeymap; ++ break; ++ } ++ ovs = XCDR (ovs); ++ } ++ } ++ ++ if (span_type == EmacsAXSpanTypeNone) ++ { ++ /* Skip to the next position where any interactive property ++ changes. Try each scannable property in turn and take ++ the nearest change point — O(properties) per gap rather ++ than O(chars). Fall back to pos+1 as safety net. */ ++ ptrdiff_t next_interesting = vis_end; ++ Lisp_Object skip_props[5] ++ = { Qns_ax_widget, Qns_ax_button, Qns_ax_follow_link, ++ Qns_ax_org_link, Qmouse_face }; ++ for (int sp = 0; sp < 5; sp++) ++ { ++ ptrdiff_t np ++ = ns_ax_next_prop_change (pos, skip_props[sp], ++ buf_obj, vis_end); ++ if (np > pos && np < next_interesting) ++ next_interesting = np; ++ } ++ /* Also check overlay keymap changes. */ ++ Lisp_Object np_ov ++ = Fnext_single_char_property_change (make_fixnum (pos), ++ Qkeymap, buf_obj, ++ make_fixnum (vis_end)); ++ if (FIXNUMP (np_ov)) ++ { ++ ptrdiff_t npv = XFIXNUM (np_ov); ++ if (npv > pos && npv < next_interesting) ++ next_interesting = npv; ++ } ++ pos = (next_interesting > pos) ? next_interesting : pos + 1; ++ continue; ++ } ++ ++ ptrdiff_t span_end = !NILP (limit_prop) ++ ? ns_ax_next_prop_change (pos, limit_prop, buf_obj, vis_end) ++ : pos + 1; ++ ++ if (span_end > vis_end) span_end = vis_end; ++ if (span_end <= pos) span_end = pos + 1; ++ ++ EmacsAccessibilityInteractiveSpan *span ++ = [[EmacsAccessibilityInteractiveSpan alloc] init]; ++ span.charposStart = pos; ++ span.charposEnd = span_end; ++ span.spanType = span_type; ++ span.parentBuffer = parent_buf; ++ span.emacsView = parent_buf.emacsView; ++ span.lispWindow = parent_buf.lispWindow; ++ span.spanLabel = ns_ax_get_span_label (pos, span_end, buf_obj); ++ ++ [spans addObject: span]; ++ [span release]; ++ ++ pos = span_end; ++ } ++ ++ return [[spans copy] autorelease]; ++} ++ ++@implementation EmacsAccessibilityInteractiveSpan ++@synthesize spanLabel; ++@synthesize spanValue; ++ ++- (void)dealloc ++{ ++ [spanLabel release]; ++ [spanValue release]; ++ [super dealloc]; ++} ++ ++- (BOOL) isAccessibilityElement { return YES; } ++ ++- (NSAccessibilityRole) accessibilityRole ++{ ++ switch (self.spanType) ++ { ++ case EmacsAXSpanTypeLink: return NSAccessibilityLinkRole; ++ default: return NSAccessibilityButtonRole; ++ } ++} ++ ++- (NSString *) accessibilityLabel { return self.spanLabel ?: @""; } ++- (NSString *) accessibilityValue { return self.spanValue; } ++ ++- (NSRect) accessibilityFrame ++{ ++ EmacsAccessibilityBuffer *pb = self.parentBuffer; ++ if (!pb || ![self validWindow]) ++ return NSZeroRect; ++ NSUInteger ax_s = [pb accessibilityIndexForCharpos: self.charposStart]; ++ NSUInteger ax_e = [pb accessibilityIndexForCharpos: self.charposEnd]; ++ if (ax_e < ax_s) ax_e = ax_s; ++ return [pb accessibilityFrameForRange: NSMakeRange (ax_s, ax_e - ax_s)]; ++} ++ ++- (BOOL) isAccessibilityFocused ++{ ++ /* Read the cached point stored by EmacsAccessibilityBuffer on the main ++ thread — safe to read from any thread (plain ptrdiff_t, no Lisp calls). */ ++ EmacsAccessibilityBuffer *pb = self.parentBuffer; ++ if (!pb) ++ return NO; ++ ptrdiff_t pt = pb.cachedPoint; ++ return pt >= self.charposStart && pt < self.charposEnd; ++} ++ ++- (void) setAccessibilityFocused: (BOOL) focused ++{ ++ if (!focused) ++ return; ++ ptrdiff_t target = self.charposStart; ++ Lisp_Object lwin = self.lispWindow; ++ dispatch_async (dispatch_get_main_queue (), ^{ ++ /* lwin is a Lisp_Object captured by value. This is GC-safe ++ because Lisp_Objects are tagged integers/pointers that ++ remain valid across GC — GC does not relocate objects in ++ Emacs. The WINDOW_LIVE_P check below guards against the ++ window being deleted between capture and execution. */ ++ if (!WINDOWP (lwin) || NILP (Fwindow_live_p (lwin))) ++ return; ++ /* Use specpdl unwind protection so that block_input is always ++ matched by unblock_input, even if Fselect_window signals. */ ++ specpdl_ref count = SPECPDL_INDEX (); ++ record_unwind_protect_void (unblock_input); ++ block_input (); ++ record_unwind_current_buffer (); ++ Fselect_window (lwin, Qnil); ++ struct window *w = XWINDOW (lwin); ++ struct buffer *b = XBUFFER (w->contents); ++ if (b != current_buffer) ++ set_buffer_internal_1 (b); ++ ptrdiff_t pos = target; ++ if (pos < BUF_BEGV (b)) pos = BUF_BEGV (b); ++ if (pos > BUF_ZV (b)) pos = BUF_ZV (b); ++ SET_PT_BOTH (pos, CHAR_TO_BYTE (pos)); ++ unbind_to (count, Qnil); ++ }); ++} ++ ++@end ++ ++/* EmacsAccessibilityBuffer — InteractiveSpans category. ++ Methods are kept here (same .m file) so they access the ivars ++ declared in the @interface ivar block. */ ++@implementation EmacsAccessibilityBuffer (InteractiveSpans) ++ ++- (void) invalidateInteractiveSpans ++{ ++ interactiveSpansDirty = YES; ++} ++ ++- (NSArray *) accessibilityChildrenInNavigationOrder ++{ ++ if (!interactiveSpansDirty && cachedInteractiveSpans != nil) ++ return cachedInteractiveSpans; ++ ++ if (![NSThread isMainThread]) ++ { ++ __block NSArray *result; ++ dispatch_sync (dispatch_get_main_queue (), ^{ ++ result = [self accessibilityChildrenInNavigationOrder]; ++ }); ++ return result; ++ } ++ ++ struct window *w = [self validWindow]; ++ if (!w) ++ return cachedInteractiveSpans ? cachedInteractiveSpans : @[]; ++ ++ /* Validate buffer before scanning. The Lisp calls inside ++ ns_ax_scan_interactive_spans (Ftext_properties_at, Fplist_get, ++ Fnext_single_property_change) do not signal on valid buffers ++ with valid positions. Verify those preconditions here so we ++ never enter the scan with invalid state, which could longjmp ++ out of a dispatch_sync block and deadlock the AX thread. */ ++ if (!BUFFERP (w->contents) || !XBUFFER (w->contents)) ++ return cachedInteractiveSpans ? cachedInteractiveSpans : @[]; ++ ++ NSArray *spans = ns_ax_scan_interactive_spans (w, self); ++ ++ if (!cachedInteractiveSpans) ++ cachedInteractiveSpans = [[NSMutableArray alloc] init]; ++ [cachedInteractiveSpans setArray: spans]; ++ interactiveSpansDirty = NO; ++ ++ return cachedInteractiveSpans; ++} ++ ++@end ++ + #endif /* NS_IMPL_COCOA */ + + +-- +2.43.0 + diff --git a/patches/0003-ns-integrate-accessibility-with-EmacsView-and-redisp.patch b/patches/0004-ns-integrate-accessibility-with-EmacsView-and-redisp.patch similarity index 80% rename from patches/0003-ns-integrate-accessibility-with-EmacsView-and-redisp.patch rename to patches/0004-ns-integrate-accessibility-with-EmacsView-and-redisp.patch index 21426aa..5f7f5fc 100644 --- a/patches/0003-ns-integrate-accessibility-with-EmacsView-and-redisp.patch +++ b/patches/0004-ns-integrate-accessibility-with-EmacsView-and-redisp.patch @@ -1,72 +1,60 @@ -From 1bebb38dc851cb4e656fe25078f1e36d54900be5 Mon Sep 17 00:00:00 2001 +From 9ec896a226144676a36908938899fc90c2f5730b Mon Sep 17 00:00:00 2001 From: Martin Sukany -Date: Sat, 28 Feb 2026 09:54:28 +0100 -Subject: [PATCH 3/4] ns: integrate accessibility with EmacsView and redisplay +Date: Sat, 28 Feb 2026 10:10:55 +0100 +Subject: [PATCH 4/5] ns: integrate accessibility with EmacsView and redisplay -Wire the accessibility infrastructure from the previous patches into -EmacsView and the redisplay cycle. After this patch, VoiceOver and -Zoom support is fully active. +Wire the accessibility infrastructure into EmacsView and the +redisplay cycle. -Integration points: - - ns_update_end: call [view postAccessibilityUpdates] after each - redisplay cycle to dispatch queued accessibility notifications. - - ns_draw_phys_cursor: store cursor rect for Zoom and call - UAZoomChangeFocus with correct CG coordinate-space transform - when ns-accessibility-enabled is non-nil. - - EmacsView dealloc: release accessibilityElements array. - - windowDidBecomeKey: post FocusedUIElementChangedNotification and - SelectedTextChanged so VoiceOver tracks the focused buffer on - app/window switch. - - EmacsView accessibility methods: - - rebuildAccessibilityTree: walk Emacs window tree via - ns_ax_collect_windows to create/reuse virtual elements - (EmacsAccessibilityBuffer per window, EmacsAccessibilityModeLine - per mode line). - - invalidateAccessibilityTree: mark tree dirty; deferred rebuild - on next AX query. - - accessibilityChildren, accessibilityFocusedUIElement: expose - virtual elements to VoiceOver. - - postAccessibilityUpdates: main notification dispatch. Detects - three events: window tree change (rebuild + LayoutChanged), - window switch (focus notification), per-buffer text/cursor - changes (delegated to buffer elements). Re-entrance guard - prevents infinite recursion from VoiceOver callbacks. - - accessibilityBoundsForRange: cursor rect for Zoom, delegates to - focused buffer element with cursor-rect fallback. - - Legacy parameterized APIs (accessibilityParameterizedAttribute- - Names, accessibilityAttributeValue:forParameter:) for pre-10.10 - compatibility. - -Tested on macOS 14 Sonoma with VoiceOver and Zoom. Full end-to-end: -buffer navigation, cursor tracking, window switching, completion -announcements, interactive spans, org-mode folded headings, -evil-mode block cursor, indirect buffers, multi-window layouts. - -Known limitations: -- Bidi text: accessibilityRangeForPosition assumes LTR glyph layout. -- Mode-line icons: image/stretch glyphs not extracted. -- Text cap: buffers exceeding NS_AX_TEXT_CAP truncated. -- Indirect buffers: share modiff_count, may cause redundant cache - invalidation (correctness preserved, minor performance cost). - -* src/nsterm.m: EmacsView accessibility integration, cursor tracking. +* src/nsterm.m (ns_update_end): Call [view postAccessibilityUpdates]. +(ns_draw_phys_cursor): Store cursor rect; call UAZoomChangeFocus. +(EmacsView dealloc): Release accessibilityElements. +(EmacsView windowDidBecomeKey): Post FocusedUIElementChanged and +SelectedTextChanged. +(ns_ax_collect_windows): New function. Walk window tree creating +virtual elements. +(EmacsView rebuildAccessibilityTree): New method. +(EmacsView invalidateAccessibilityTree): New method. +(EmacsView accessibilityChildren): New method. +(EmacsView accessibilityFocusedUIElement): New method. +(EmacsView postAccessibilityUpdates): New method. Detect tree +change, window switch, per-buffer changes; re-entrance guard. +(EmacsView accessibilityBoundsForRange:): New method. Cursor rect +for Zoom with focused-element delegation. +(EmacsView accessibilityParameterizedAttributeNames): New method. +(EmacsView accessibilityAttributeValue:forParameter:): New method. +* etc/NEWS: Document VoiceOver accessibility support. --- - src/nsterm.m | 395 +++++++++++++++++++++++++++++++++++++++++++++++++++ - 1 file changed, 395 insertions(+) + etc/NEWS | 13 ++ + src/nsterm.m | 397 ++++++++++++++++++++++++++++++++++++++++++++++++++- + 2 files changed, 408 insertions(+), 2 deletions(-) +diff --git a/etc/NEWS b/etc/NEWS +index 7367e3c..608650e 100644 +--- a/etc/NEWS ++++ b/etc/NEWS +@@ -4374,6 +4374,19 @@ allowing Emacs users access to speech recognition utilities. + Note: Accepting this permission allows the use of system APIs, which may + send user data to Apple's speech recognition servers. + ++--- ++** VoiceOver accessibility support on macOS. ++Emacs now exposes buffer content, cursor position, and interactive ++elements to the macOS accessibility subsystem (VoiceOver). This ++includes AXBoundsForRange for macOS Zoom cursor tracking, line and ++word navigation announcements, Tab-navigable interactive spans ++(buttons, links, completion candidates), and completion announcements ++for the *Completions* buffer. The implementation uses a virtual ++accessibility tree with per-window elements, hybrid SelectedTextChanged ++and AnnouncementRequested notifications, and thread-safe text caching. ++Set 'ns-accessibility-enabled' to nil to disable the accessibility ++interface and eliminate the associated overhead. ++ + --- + ** Re-introduced dictation, lost in Emacs v30 (macOS). + We lost macOS dictation in v30 when migrating to NSTextInputClient. diff --git a/src/nsterm.m b/src/nsterm.m -index c47912d..34af842 100644 +index db5e4b3..421a6a4 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -1105,6 +1105,11 @@ ns_update_end (struct frame *f) @@ -125,7 +113,23 @@ index c47912d..34af842 100644 ns_focus (f, NULL, 0); NSGraphicsContext *ctx = [NSGraphicsContext currentContext]; -@@ -9204,6 +9246,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, +@@ -7180,7 +7222,6 @@ enum { + + + +- + static BOOL + ns_ax_find_completion_overlay_range (struct buffer *b, ptrdiff_t point, + ptrdiff_t *out_start, +@@ -8798,7 +8839,6 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, + @end + + +- + /* =================================================================== + EmacsAccessibilityInteractiveSpan — helpers and implementation + =================================================================== */ +@@ -9245,6 +9285,7 @@ ns_ax_scan_interactive_spans (struct window *w, [layer release]; #endif @@ -133,7 +137,7 @@ index c47912d..34af842 100644 [[self menu] release]; [super dealloc]; } -@@ -10552,6 +10595,32 @@ ns_in_echo_area (void) +@@ -10593,6 +10634,32 @@ ns_in_echo_area (void) XSETFRAME (event.frame_or_window, emacsframe); kbd_buffer_store_event (&event); ns_send_appdefined (-1); // Kick main loop @@ -166,7 +170,7 @@ index c47912d..34af842 100644 } -@@ -11789,6 +11858,332 @@ ns_in_echo_area (void) +@@ -11830,6 +11897,332 @@ ns_in_echo_area (void) return fs_state; } diff --git a/patches/0004-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch b/patches/0005-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch similarity index 87% rename from patches/0004-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch rename to patches/0005-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch index 44be4e2..08494c3 100644 --- a/patches/0004-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch +++ b/patches/0005-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch @@ -1,16 +1,13 @@ -From 3e6f8148a01ee7934a357858477ac3c61b491088 Mon Sep 17 00:00:00 2001 +From 0e09f3e4d8edac1c157130f733f3bff18d758c5f Mon Sep 17 00:00:00 2001 From: Martin Sukany -Date: Sat, 28 Feb 2026 09:54:28 +0100 -Subject: [PATCH 4/4] doc: add VoiceOver accessibility section to macOS +Date: Sat, 28 Feb 2026 10:10:55 +0100 +Subject: [PATCH 5/5] doc: add VoiceOver accessibility section to macOS appendix -Document the new VoiceOver accessibility support in the Emacs manual. - -* doc/emacs/macos.texi (VoiceOver Accessibility): New section -covering screen reader usage, keyboard navigation feedback, -completion announcements, Zoom cursor tracking, the -ns-accessibility-enabled user option, and known limitations (text -cap, mode-line icon fonts, bidi hit-testing, tree rebuild behavior). +* doc/emacs/macos.texi (VoiceOver Accessibility): New node. Document +screen reader usage, keyboard navigation feedback, completion +announcements, Zoom cursor tracking, ns-accessibility-enabled, and +known limitations. --- doc/emacs/macos.texi | 75 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+)