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>
|
From: Martin Sukany <martin@sukany.cz>
|
||||||
Date: Sat, 28 Feb 2026 09:54:28 +0100
|
Date: Sat, 28 Feb 2026 10:10:55 +0100
|
||||||
Subject: [PATCH 1/4] ns: add accessibility base classes and text extraction
|
Subject: [PATCH 1/5] ns: add accessibility base classes and text extraction
|
||||||
|
|
||||||
Add the foundation for macOS VoiceOver accessibility support in the
|
Add the foundation for macOS VoiceOver accessibility in the NS
|
||||||
NS (Cocoa) port. This patch provides the base class hierarchy, text
|
(Cocoa) port. No existing code paths are modified.
|
||||||
extraction with invisible-text handling, coordinate mapping, and
|
|
||||||
notification helpers. No existing code paths are modified.
|
|
||||||
|
|
||||||
New types (nsterm.h):
|
* src/nsterm.h (ns_ax_visible_run): New struct for buffer-to-UTF-16
|
||||||
|
index mapping.
|
||||||
ns_ax_visible_run: maps buffer character positions to UTF-16
|
(EmacsAccessibilityElement): New base class for virtual AX elements.
|
||||||
accessibility indices, skipping invisible text.
|
(EmacsAccessibilityBuffer, EmacsAccessibilityModeLine)
|
||||||
|
(EmacsAccessibilityInteractiveSpan): Forward declarations.
|
||||||
EmacsAccessibilityElement: base class for virtual AX elements.
|
(EmacsAXSpanType): New enum for span classification.
|
||||||
|
(EmacsView): New ivars accessibilityElements, lastSelectedWindow,
|
||||||
Forward declarations for EmacsAccessibilityBuffer,
|
accessibilityTreeValid, lastAccessibilityCursorRect.
|
||||||
EmacsAccessibilityModeLine, EmacsAccessibilityInteractiveSpan.
|
* src/nsterm.m: Include intervals.h for TEXT_PROP_MEANS_INVISIBLE.
|
||||||
|
(ns_ax_buffer_text): New function. Build accessibility string with
|
||||||
EmacsAXSpanType: enum for interactive span classification.
|
visible-run mapping; skip invisible text per spec.
|
||||||
|
(ns_ax_mode_line_text): New function. Extract CHAR_GLYPH text.
|
||||||
EmacsView ivar extensions: accessibilityElements, last-
|
(ns_ax_frame_for_range): New function. Screen rect via glyph matrix.
|
||||||
SelectedWindow, accessibilityTreeValid, lastAccessibilityCursorRect.
|
(ns_ax_post_notification, ns_ax_post_notification_with_info): New
|
||||||
|
functions. dispatch_async wrappers preventing VoiceOver deadlock.
|
||||||
New helper functions (nsterm.m):
|
(ns_ax_completion_string_from_prop, ns_ax_window_buffer_object)
|
||||||
|
(ns_ax_window_end_charpos, ns_ax_text_prop_at)
|
||||||
ns_ax_buffer_text: build accessibility string with visible-run
|
(ns_ax_next_prop_change, ns_ax_get_span_label): New utility helpers.
|
||||||
mapping. Uses TEXT_PROP_MEANS_INVISIBLE for spec-controlled
|
(EmacsAccessibilityElement): Implement base class.
|
||||||
invisibility, Fbuffer_substring_no_properties for buffer-gap
|
(syms_of_nsterm): Register accessibility DEFSYM and DEFVAR
|
||||||
safety. Capped at NS_AX_TEXT_CAP (100,000 UTF-16 units).
|
ns-accessibility-enabled.
|
||||||
|
|
||||||
ns_ax_mode_line_text: extract mode-line text from glyph matrix
|
|
||||||
(CHAR_GLYPH only; image/stretch glyphs skipped with TODO note).
|
|
||||||
|
|
||||||
ns_ax_frame_for_range: screen rect for character range via glyph
|
|
||||||
matrix lookup with text-area clipping.
|
|
||||||
|
|
||||||
ns_ax_post_notification, ns_ax_post_notification_with_info:
|
|
||||||
dispatch_async wrappers to prevent deadlock.
|
|
||||||
|
|
||||||
Utility helpers: ns_ax_completion_string_from_prop,
|
|
||||||
ns_ax_window_buffer_object, ns_ax_window_end_charpos,
|
|
||||||
ns_ax_text_prop_at, ns_ax_next_prop_change, ns_ax_get_span_label.
|
|
||||||
|
|
||||||
EmacsAccessibilityElement @implementation: base class with
|
|
||||||
validWindow, screenRectFromEmacsX:y:width:height:, and hierarchy
|
|
||||||
plumbing (accessibilityParent, accessibilityWindow).
|
|
||||||
|
|
||||||
New user option: ns-accessibility-enabled (default t).
|
|
||||||
|
|
||||||
Tested on macOS 14 Sonoma. Builds cleanly; base class instantiates;
|
|
||||||
symbols register; no functional change (integration in next patch).
|
|
||||||
|
|
||||||
* src/nsterm.h: New class declarations, struct, enum, ivar extensions.
|
|
||||||
* src/nsterm.m: Helper functions, base element, DEFSYM, DEFVAR.
|
|
||||||
* etc/NEWS: Document VoiceOver accessibility support.
|
|
||||||
---
|
---
|
||||||
etc/NEWS | 13 ++
|
src/nsterm.h | 119 +++++++++++++++++
|
||||||
src/nsterm.h | 119 ++++++++++
|
src/nsterm.m | 355 +++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
src/nsterm.m | 621 +++++++++++++++++++++++++++++++++++++++++++++++++++
|
2 files changed, 474 insertions(+)
|
||||||
3 files changed, 753 insertions(+)
|
|
||||||
|
|
||||||
diff --git a/etc/NEWS b/etc/NEWS
|
|
||||||
index 7367e3c..608650e 100644
|
|
||||||
--- a/etc/NEWS
|
|
||||||
+++ b/etc/NEWS
|
|
||||||
@@ -4374,6 +4374,19 @@ allowing Emacs users access to speech recognition utilities.
|
|
||||||
Note: Accepting this permission allows the use of system APIs, which may
|
|
||||||
send user data to Apple's speech recognition servers.
|
|
||||||
|
|
||||||
+---
|
|
||||||
+** VoiceOver accessibility support on macOS.
|
|
||||||
+Emacs now exposes buffer content, cursor position, and interactive
|
|
||||||
+elements to the macOS accessibility subsystem (VoiceOver). This
|
|
||||||
+includes AXBoundsForRange for macOS Zoom cursor tracking, line and
|
|
||||||
+word navigation announcements, Tab-navigable interactive spans
|
|
||||||
+(buttons, links, completion candidates), and completion announcements
|
|
||||||
+for the *Completions* buffer. The implementation uses a virtual
|
|
||||||
+accessibility tree with per-window elements, hybrid SelectedTextChanged
|
|
||||||
+and AnnouncementRequested notifications, and thread-safe text caching.
|
|
||||||
+Set 'ns-accessibility-enabled' to nil to disable the accessibility
|
|
||||||
+interface and eliminate the associated overhead.
|
|
||||||
+
|
|
||||||
---
|
|
||||||
** Re-introduced dictation, lost in Emacs v30 (macOS).
|
|
||||||
We lost macOS dictation in v30 when migrating to NSTextInputClient.
|
|
||||||
diff --git a/src/nsterm.h b/src/nsterm.h
|
diff --git a/src/nsterm.h b/src/nsterm.h
|
||||||
index 7c1ee4c..393fc4c 100644
|
index 7c1ee4c..393fc4c 100644
|
||||||
--- a/src/nsterm.h
|
--- a/src/nsterm.h
|
||||||
@@ -230,7 +177,7 @@ index 7c1ee4c..393fc4c 100644
|
|||||||
|
|
||||||
|
|
||||||
diff --git a/src/nsterm.m b/src/nsterm.m
|
diff --git a/src/nsterm.m b/src/nsterm.m
|
||||||
index 74e4ad5..ee27df1 100644
|
index 74e4ad5..c91ec90 100644
|
||||||
--- a/src/nsterm.m
|
--- a/src/nsterm.m
|
||||||
+++ b/src/nsterm.m
|
+++ b/src/nsterm.m
|
||||||
@@ -46,6 +46,7 @@ GNUstep port and post-20 update by Adrian Robert (arobert@cogsci.ucsd.edu)
|
@@ -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 "systime.h"
|
||||||
#include "character.h"
|
#include "character.h"
|
||||||
#include "xwidget.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
|
#endif
|
||||||
|
|
||||||
@@ -506,274 +453,8 @@ index 74e4ad5..ee27df1 100644
|
|||||||
+ ns_ax_text_selection_granularity_line = 3,
|
+ 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
|
+@implementation EmacsAccessibilityElement
|
||||||
@@ -837,7 +518,7 @@ index 74e4ad5..ee27df1 100644
|
|||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
|
|
||||||
EmacsView implementation
|
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_drag_operation_generic, "ns-drag-operation-generic");
|
||||||
DEFSYM (Qns_handle_drag_motion, "ns-handle-drag-motion");
|
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 (Qalt, Qmodifier_value, make_fixnum (alt_modifier));
|
||||||
Fput (Qhyper, Qmodifier_value, make_fixnum (hyper_modifier));
|
Fput (Qhyper, Qmodifier_value, make_fixnum (hyper_modifier));
|
||||||
Fput (Qmeta, Qmodifier_value, make_fixnum (meta_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. */);
|
This variable is ignored on Mac OS X < 10.7 and GNUstep. */);
|
||||||
ns_use_srgb_colorspace = YES;
|
ns_use_srgb_colorspace = YES;
|
||||||
|
|
||||||
|
|||||||
@@ -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 <martin@sukany.cz>
|
From: Martin Sukany <martin@sukany.cz>
|
||||||
Date: Sat, 28 Feb 2026 09:54:28 +0100
|
Date: Sat, 28 Feb 2026 10:10:55 +0100
|
||||||
Subject: [PATCH 2/4] ns: implement buffer, mode-line, and interactive span
|
Subject: [PATCH 2/5] ns: implement buffer and mode-line accessibility elements
|
||||||
elements
|
|
||||||
|
|
||||||
Add the three remaining virtual element classes, completing the
|
Add the full NSAccessibility text protocol for Emacs windows and
|
||||||
accessibility object model. Combined with the previous patch, this
|
mode-line readout.
|
||||||
provides a full NSAccessibility text protocol implementation.
|
|
||||||
|
|
||||||
EmacsAccessibilityBuffer <NSAccessibility>: full text protocol for
|
* src/nsterm.m (ns_ax_find_completion_overlay_range): New function.
|
||||||
a single Emacs window.
|
(ns_ax_event_is_line_nav_key): New function.
|
||||||
|
(ns_ax_completion_text_for_span): New function.
|
||||||
Text cache: @synchronized caching of buffer text and visible-run
|
(EmacsAccessibilityBuffer): Implement NSAccessibility protocol:
|
||||||
array. Cache invalidated on modiff_count, window start, or
|
text cache with @synchronized, visible-run binary search O(log n),
|
||||||
invisible-text configuration change.
|
selectedTextRange, lineForIndex/indexForLine, frameForRange,
|
||||||
|
rangeForPosition, setAccessibilitySelectedTextRange, setAccessibility-
|
||||||
Index mapping: binary search O(log n) between buffer positions and
|
Focused.
|
||||||
UTF-16 accessibility indices via the visible-run array.
|
(EmacsAccessibilityBuffer postTextChangedNotification:): New method.
|
||||||
|
ValueChanged with edit details.
|
||||||
Selection: selectedTextRange from point/mark; insertion point from
|
(EmacsAccessibilityBuffer postFocusedCursorNotification:direction:
|
||||||
point via index mapping.
|
granularity:markActive:oldMarkActive:): New method. Hybrid
|
||||||
|
SelectedTextChanged / AnnouncementRequested per WebKit pattern.
|
||||||
Geometry: lineForIndex/indexForLine by newline scanning.
|
(EmacsAccessibilityBuffer postCompletionAnnouncementForBuffer:point:):
|
||||||
frameForRange delegates to ns_ax_frame_for_range.
|
New method. Announce completion candidates in non-focused buffers.
|
||||||
|
(EmacsAccessibilityBuffer postAccessibilityNotificationsForFrame:):
|
||||||
Notification dispatch (postTextChangedNotification): hybrid
|
New method. Main dispatch: edit vs cursor-move vs no-change.
|
||||||
SelectedTextChanged / ValueChanged / AnnouncementRequested,
|
(EmacsAccessibilityModeLine): Implement AXStaticText element.
|
||||||
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 | 1716 ++++++++++++++++++++++++++++++++++++++++++++++++++
|
src/nsterm.m | 1620 ++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
1 file changed, 1716 insertions(+)
|
1 file changed, 1620 insertions(+)
|
||||||
|
|
||||||
diff --git a/src/nsterm.m b/src/nsterm.m
|
diff --git a/src/nsterm.m b/src/nsterm.m
|
||||||
index ee27df1..c47912d 100644
|
index c91ec90..90db3b7 100644
|
||||||
--- a/src/nsterm.m
|
--- a/src/nsterm.m
|
||||||
+++ b/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<EmacsAccessibilityInteractiveSpan *>.
|
|
||||||
+
|
+
|
||||||
+ 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);
|
+static BOOL
|
||||||
+ if (vis_end > BUF_ZV (b)) vis_end = BUF_ZV (b);
|
+ns_ax_find_completion_overlay_range (struct buffer *b, ptrdiff_t point,
|
||||||
+ if (vis_start >= vis_end)
|
+ ptrdiff_t *out_start,
|
||||||
+ return @[];
|
+ ptrdiff_t *out_end)
|
||||||
|
+{
|
||||||
|
+ if (!b || !out_start || !out_end)
|
||||||
|
+ return NO;
|
||||||
+
|
+
|
||||||
+ /* Symbols are interned once at startup via DEFSYM in syms_of_nsterm;
|
+ Lisp_Object faceSym = Qns_ax_completions_highlight;
|
||||||
+ reference them directly here (GC-safe, no repeated obarray lookup). */
|
+ 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;
|
||||||
+
|
+
|
||||||
+ BOOL is_completion_buf = EQ (BVAR (b, major_mode), Qns_ax_completion_list_mode);
|
+ /* 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;
|
||||||
+
|
+
|
||||||
+ NSMutableArray *spans = [NSMutableArray array];
|
+ Lisp_Object overlays = Foverlays_at (make_fixnum (p), Qnil);
|
||||||
+ ptrdiff_t pos = vis_start;
|
+ 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;
|
||||||
+
|
+
|
||||||
+ while (pos < vis_end)
|
+ ptrdiff_t ov_start = OVERLAY_START (ov);
|
||||||
+ {
|
+ ptrdiff_t ov_end = OVERLAY_END (ov);
|
||||||
+ Lisp_Object plist = Ftext_properties_at (make_fixnum (pos), buf_obj);
|
+ if (ov_end <= ov_start)
|
||||||
+ EmacsAXSpanType span_type = EmacsAXSpanTypeNone;
|
+ continue;
|
||||||
+ Lisp_Object limit_prop = Qnil;
|
|
||||||
+
|
+
|
||||||
+ if (!NILP (Fplist_get (plist, Qns_ax_widget, Qnil)))
|
+ best_start = ov_start;
|
||||||
+ {
|
+ best_end = ov_end;
|
||||||
+ span_type = EmacsAXSpanTypeWidget;
|
+ best_dist = 0;
|
||||||
+ limit_prop = Qns_ax_widget;
|
+ found = YES;
|
||||||
+ }
|
|
||||||
+ 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;
|
+ break;
|
||||||
+ }
|
+ }
|
||||||
+ ovs = XCDR (ovs);
|
|
||||||
+ }
|
|
||||||
+ }
|
+ }
|
||||||
+
|
+
|
||||||
+ if (span_type == EmacsAXSpanTypeNone)
|
+ if (!found)
|
||||||
+ {
|
+ {
|
||||||
+ /* Skip to the next position where any interactive property
|
+ /* Bulk query: get all overlays in the buffer at once.
|
||||||
+ changes. Try each scannable property in turn and take
|
+ Avoids the previous O(n) per-character Foverlays_at loop. */
|
||||||
+ the nearest change point â O(properties) per gap rather
|
+ Lisp_Object all = Foverlays_in (make_fixnum (begv),
|
||||||
+ than O(chars). Fall back to pos+1 as safety net. */
|
+ make_fixnum (zv));
|
||||||
+ ptrdiff_t next_interesting = vis_end;
|
+ Lisp_Object tail;
|
||||||
+ Lisp_Object skip_props[5]
|
+ for (tail = all; CONSP (tail); tail = XCDR (tail))
|
||||||
+ = { 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
|
+ Lisp_Object ov = XCAR (tail);
|
||||||
+ = ns_ax_next_prop_change (pos, skip_props[sp],
|
+ Lisp_Object face = Foverlay_get (ov, Qface);
|
||||||
+ buf_obj, vis_end);
|
+ if (!(EQ (face, faceSym)
|
||||||
+ if (np > pos && np < next_interesting)
|
+ || (CONSP (face)
|
||||||
+ next_interesting = np;
|
+ && !NILP (Fmemq (faceSym, face)))))
|
||||||
+ }
|
|
||||||
+ /* 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;
|
+ continue;
|
||||||
+ }
|
|
||||||
+
|
+
|
||||||
+ ptrdiff_t span_end = !NILP (limit_prop)
|
+ ptrdiff_t ov_start = OVERLAY_START (ov);
|
||||||
+ ? ns_ax_next_prop_change (pos, limit_prop, buf_obj, vis_end)
|
+ ptrdiff_t ov_end = OVERLAY_END (ov);
|
||||||
+ : pos + 1;
|
+ if (ov_end <= ov_start)
|
||||||
|
+ continue;
|
||||||
+
|
+
|
||||||
+ if (span_end > vis_end) span_end = vis_end;
|
+ ptrdiff_t dist = 0;
|
||||||
+ if (span_end <= pos) span_end = pos + 1;
|
+ if (point < ov_start)
|
||||||
|
+ dist = ov_start - point;
|
||||||
|
+ else if (point > ov_end)
|
||||||
|
+ dist = point - ov_end;
|
||||||
+
|
+
|
||||||
+ EmacsAccessibilityInteractiveSpan *span
|
+ if (!found || dist < best_dist)
|
||||||
+ = [[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];
|
+ best_start = ov_start;
|
||||||
+ [spanValue release];
|
+ best_end = ov_end;
|
||||||
+ [super dealloc];
|
+ best_dist = dist;
|
||||||
|
+ found = YES;
|
||||||
+ }
|
+ }
|
||||||
+
|
|
||||||
+- (BOOL) isAccessibilityElement { return YES; }
|
|
||||||
+
|
|
||||||
+- (NSAccessibilityRole) accessibilityRole
|
|
||||||
+{
|
|
||||||
+ switch (self.spanType)
|
|
||||||
+ {
|
|
||||||
+ case EmacsAXSpanTypeLink: return NSAccessibilityLinkRole;
|
|
||||||
+ default: return NSAccessibilityButtonRole;
|
|
||||||
+ }
|
+ }
|
||||||
+ }
|
+ }
|
||||||
+
|
+
|
||||||
+- (NSString *) accessibilityLabel { return self.spanLabel ?: @""; }
|
+ if (!found)
|
||||||
+- (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;
|
+ return NO;
|
||||||
+ ptrdiff_t pt = pb.cachedPoint;
|
+
|
||||||
+ return pt >= self.charposStart && pt < self.charposEnd;
|
+ *out_start = best_start;
|
||||||
|
+ *out_end = best_end;
|
||||||
|
+ return YES;
|
||||||
+}
|
+}
|
||||||
+
|
+
|
||||||
+- (void) setAccessibilityFocused: (BOOL) focused
|
+/* 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)
|
||||||
+{
|
+{
|
||||||
+ if (!focused)
|
+ /* 1. Check Vthis_command for known navigation command symbols.
|
||||||
+ return;
|
+ All symbols are registered via DEFSYM in syms_of_nsterm to avoid
|
||||||
+ ptrdiff_t target = self.charposStart;
|
+ per-call obarray lookups in this hot path (runs every cursor move). */
|
||||||
+ Lisp_Object lwin = self.lispWindow;
|
+ if (SYMBOLP (Vthis_command) && !NILP (Vthis_command))
|
||||||
+ 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;
|
+ Lisp_Object cmd = Vthis_command;
|
||||||
+}
|
+ /* Forward line commands. */
|
||||||
+
|
+ if (EQ (cmd, Qns_ax_next_line)
|
||||||
+- (NSArray *) accessibilityChildrenInNavigationOrder
|
+ || EQ (cmd, Qns_ax_dired_next_line)
|
||||||
|
+ || EQ (cmd, Qns_ax_evil_next_line)
|
||||||
|
+ || EQ (cmd, Qns_ax_evil_next_visual_line))
|
||||||
+ {
|
+ {
|
||||||
+ if (!interactiveSpansDirty && cachedInteractiveSpans != nil)
|
+ if (which) *which = 1;
|
||||||
+ return cachedInteractiveSpans;
|
+ return true;
|
||||||
+
|
+ }
|
||||||
+ if (![NSThread isMainThread])
|
+ /* 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))
|
||||||
+ {
|
+ {
|
||||||
+ __block NSArray *result;
|
+ if (which) *which = -1;
|
||||||
+ dispatch_sync (dispatch_get_main_queue (), ^{
|
+ return true;
|
||||||
+ result = [self accessibilityChildrenInNavigationOrder];
|
+ }
|
||||||
+ });
|
|
||||||
+ return result;
|
|
||||||
+ }
|
+ }
|
||||||
+
|
+
|
||||||
+ struct window *w = [self validWindow];
|
+ /* 2. Fallback: check raw key events for Tab/backtab. */
|
||||||
+ if (!w)
|
+ Lisp_Object ev = last_command_event;
|
||||||
+ return cachedInteractiveSpans ? cachedInteractiveSpans : @[];
|
+ if (CONSP (ev))
|
||||||
|
+ ev = EVENT_HEAD (ev);
|
||||||
+
|
+
|
||||||
+ /* Validate buffer before scanning. The Lisp calls inside
|
+ if (SYMBOLP (ev) && EQ (ev, Qns_ax_backtab))
|
||||||
+ ns_ax_scan_interactive_spans (Ftext_properties_at, Fplist_get,
|
+ {
|
||||||
+ Fnext_single_property_change) do not signal on valid buffers
|
+ if (which) *which = -1;
|
||||||
+ with valid positions. Verify those preconditions here so we
|
+ return true;
|
||||||
+ never enter the scan with invalid state, which could longjmp
|
+ }
|
||||||
+ out of a dispatch_sync block and deadlock the AX thread. */
|
+ if (FIXNUMP (ev) && XFIXNUM (ev) == 9) /* Tab */
|
||||||
+ if (!BUFFERP (w->contents) || !XBUFFER (w->contents))
|
+ {
|
||||||
+ return cachedInteractiveSpans ? cachedInteractiveSpans : @[];
|
+ if (which) *which = 1;
|
||||||
+
|
+ return true;
|
||||||
+ NSArray *spans = ns_ax_scan_interactive_spans (w, self);
|
+ }
|
||||||
+
|
+ return false;
|
||||||
+ if (!cachedInteractiveSpans)
|
|
||||||
+ cachedInteractiveSpans = [[NSMutableArray alloc] init];
|
|
||||||
+ [cachedInteractiveSpans setArray: spans];
|
|
||||||
+ interactiveSpansDirty = NO;
|
|
||||||
+
|
|
||||||
+ return cachedInteractiveSpans;
|
|
||||||
+}
|
+}
|
||||||
+
|
+
|
||||||
+@end
|
|
||||||
+
|
+
|
||||||
+
|
+
|
||||||
+static NSString *
|
+static NSString *
|
||||||
@@ -409,14 +254,6 @@ index ee27df1..c47912d 100644
|
|||||||
+
|
+
|
||||||
+ return text;
|
+ return text;
|
||||||
+}
|
+}
|
||||||
+
|
|
||||||
|
|
||||||
@implementation EmacsAccessibilityElement
|
|
||||||
|
|
||||||
@@ -7443,6 +7788,1377 @@ ns_ax_post_notification_with_info (id element,
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
+
|
+
|
||||||
+@implementation EmacsAccessibilityBuffer
|
+@implementation EmacsAccessibilityBuffer
|
||||||
+@synthesize cachedText;
|
+@synthesize cachedText;
|
||||||
@@ -614,6 +451,37 @@ index ee27df1..c47912d 100644
|
|||||||
+ } /* @synchronized */
|
+ } /* @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 ---- */
|
+/* ---- NSAccessibility protocol ---- */
|
||||||
+
|
+
|
||||||
+- (NSAccessibilityRole)accessibilityRole
|
+- (NSAccessibilityRole)accessibilityRole
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
From 7c1ad53ed32fc4b2650f0af51dd4d8b5ed87bdf4 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Martin Sukany <martin@sukany.cz>
|
||||||
|
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<EmacsAccessibilityInteractiveSpan *>.
|
||||||
|
+
|
||||||
|
+ 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
|
||||||
|
|
||||||
@@ -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 <martin@sukany.cz>
|
From: Martin Sukany <martin@sukany.cz>
|
||||||
Date: Sat, 28 Feb 2026 09:54:28 +0100
|
Date: Sat, 28 Feb 2026 10:10:55 +0100
|
||||||
Subject: [PATCH 3/4] ns: integrate accessibility with EmacsView and redisplay
|
Subject: [PATCH 4/5] ns: integrate accessibility with EmacsView and redisplay
|
||||||
|
|
||||||
Wire the accessibility infrastructure from the previous patches into
|
Wire the accessibility infrastructure into EmacsView and the
|
||||||
EmacsView and the redisplay cycle. After this patch, VoiceOver and
|
redisplay cycle.
|
||||||
Zoom support is fully active.
|
|
||||||
|
|
||||||
Integration points:
|
* src/nsterm.m (ns_update_end): Call [view postAccessibilityUpdates].
|
||||||
|
(ns_draw_phys_cursor): Store cursor rect; call UAZoomChangeFocus.
|
||||||
ns_update_end: call [view postAccessibilityUpdates] after each
|
(EmacsView dealloc): Release accessibilityElements.
|
||||||
redisplay cycle to dispatch queued accessibility notifications.
|
(EmacsView windowDidBecomeKey): Post FocusedUIElementChanged and
|
||||||
|
SelectedTextChanged.
|
||||||
ns_draw_phys_cursor: store cursor rect for Zoom and call
|
(ns_ax_collect_windows): New function. Walk window tree creating
|
||||||
UAZoomChangeFocus with correct CG coordinate-space transform
|
virtual elements.
|
||||||
when ns-accessibility-enabled is non-nil.
|
(EmacsView rebuildAccessibilityTree): New method.
|
||||||
|
(EmacsView invalidateAccessibilityTree): New method.
|
||||||
EmacsView dealloc: release accessibilityElements array.
|
(EmacsView accessibilityChildren): New method.
|
||||||
|
(EmacsView accessibilityFocusedUIElement): New method.
|
||||||
windowDidBecomeKey: post FocusedUIElementChangedNotification and
|
(EmacsView postAccessibilityUpdates): New method. Detect tree
|
||||||
SelectedTextChanged so VoiceOver tracks the focused buffer on
|
change, window switch, per-buffer changes; re-entrance guard.
|
||||||
app/window switch.
|
(EmacsView accessibilityBoundsForRange:): New method. Cursor rect
|
||||||
|
for Zoom with focused-element delegation.
|
||||||
EmacsView accessibility methods:
|
(EmacsView accessibilityParameterizedAttributeNames): New method.
|
||||||
|
(EmacsView accessibilityAttributeValue:forParameter:): New method.
|
||||||
rebuildAccessibilityTree: walk Emacs window tree via
|
* etc/NEWS: Document VoiceOver accessibility support.
|
||||||
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 | 395 +++++++++++++++++++++++++++++++++++++++++++++++++++
|
etc/NEWS | 13 ++
|
||||||
1 file changed, 395 insertions(+)
|
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
|
diff --git a/src/nsterm.m b/src/nsterm.m
|
||||||
index c47912d..34af842 100644
|
index db5e4b3..421a6a4 100644
|
||||||
--- a/src/nsterm.m
|
--- a/src/nsterm.m
|
||||||
+++ b/src/nsterm.m
|
+++ b/src/nsterm.m
|
||||||
@@ -1105,6 +1105,11 @@ ns_update_end (struct frame *f)
|
@@ -1105,6 +1105,11 @@ ns_update_end (struct frame *f)
|
||||||
@@ -125,7 +113,23 @@ index c47912d..34af842 100644
|
|||||||
ns_focus (f, NULL, 0);
|
ns_focus (f, NULL, 0);
|
||||||
|
|
||||||
NSGraphicsContext *ctx = [NSGraphicsContext currentContext];
|
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];
|
[layer release];
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -133,7 +137,7 @@ index c47912d..34af842 100644
|
|||||||
[[self menu] release];
|
[[self menu] release];
|
||||||
[super dealloc];
|
[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);
|
XSETFRAME (event.frame_or_window, emacsframe);
|
||||||
kbd_buffer_store_event (&event);
|
kbd_buffer_store_event (&event);
|
||||||
ns_send_appdefined (-1); // Kick main loop
|
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;
|
return fs_state;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 <martin@sukany.cz>
|
From: Martin Sukany <martin@sukany.cz>
|
||||||
Date: Sat, 28 Feb 2026 09:54:28 +0100
|
Date: Sat, 28 Feb 2026 10:10:55 +0100
|
||||||
Subject: [PATCH 4/4] doc: add VoiceOver accessibility section to macOS
|
Subject: [PATCH 5/5] doc: add VoiceOver accessibility section to macOS
|
||||||
appendix
|
appendix
|
||||||
|
|
||||||
Document the new VoiceOver accessibility support in the Emacs manual.
|
* doc/emacs/macos.texi (VoiceOver Accessibility): New node. Document
|
||||||
|
screen reader usage, keyboard navigation feedback, completion
|
||||||
* doc/emacs/macos.texi (VoiceOver Accessibility): New section
|
announcements, Zoom cursor tracking, ns-accessibility-enabled, and
|
||||||
covering screen reader usage, keyboard navigation feedback,
|
known limitations.
|
||||||
completion announcements, Zoom cursor tracking, the
|
|
||||||
ns-accessibility-enabled user option, and known limitations (text
|
|
||||||
cap, mode-line icon fonts, bidi hit-testing, tree rebuild behavior).
|
|
||||||
---
|
---
|
||||||
doc/emacs/macos.texi | 75 ++++++++++++++++++++++++++++++++++++++++++++
|
doc/emacs/macos.texi | 75 ++++++++++++++++++++++++++++++++++++++++++++
|
||||||
1 file changed, 75 insertions(+)
|
1 file changed, 75 insertions(+)
|
||||||
Reference in New Issue
Block a user