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:
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user