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

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

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

View File

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