For discontiguous moves (teleports, org-agenda items separated by blank
lines, multi-line jumps), AXSelectedTextChanged was sent with
AXTextSelectionDirection=discontiguous. VoiceOver interprets an
explicit discontiguous direction as 're-anchor only' and reads only the
word at the cursor, ignoring VoiceOver's own line-browse mode.
The pre-review code (51f5944) omitted direction/granularity for all
moves and let VoiceOver determine what to read from its navigation state.
This correctly reads the full line when the VoiceOver rotor is in line
mode, which is the typical setting for text navigation.
Fix: omit AXTextSelectionDirection and AXTextSelectionGranularity from
AXSelectedTextChanged when direction=discontiguous. Include them only
for sequential moves (direction=next/previous), where the explicit hint
ensures VoiceOver reads the correct unit without an extra state query.
This fixes:
- org-agenda / org-super-agenda j/k: items separated by blank lines
cause singleLineMove=NO (non-adjacent AX indices), so direction was
discontiguous -> only first word read.
- Any other navigation that crosses blank or invisible lines.
Sequential moves (C-n/C-p, single adjacent j/k) still include
direction + granularity=line for reliable full-line reads.
325 lines
10 KiB
Diff
325 lines
10 KiB
Diff
From a0ea23e5b05e7ab6048d6dd3e9e05aae00dc6939 Mon Sep 17 00:00:00 2001
|
|
From: Martin Sukany <martin@sukany.cz>
|
|
Date: Sat, 28 Feb 2026 12:58:11 +0100
|
|
Subject: [PATCH 4/8] ns: add interactive span elements for Tab navigation
|
|
|
|
* src/nsterm.m (ns_ax_scan_interactive_spans): New function; scans the
|
|
visible portion of a buffer for interactive text properties
|
|
(ns-ax-widget, ns-ax-button, ns-ax-follow-link, ns-ax-org-link,
|
|
mouse-face, overlay keymap) and builds EmacsAccessibilityInteractiveSpan
|
|
elements.
|
|
(EmacsAccessibilityInteractiveSpan): Implement AXButton and AXLink
|
|
elements with an AXPress action that sends a synthetic TAB keystroke.
|
|
(EmacsAccessibilityBuffer(InteractiveSpans)): New category.
|
|
(accessibilityChildrenInNavigationOrder): Return cached span array,
|
|
rebuilding lazily when interactiveSpansDirty is set.
|
|
---
|
|
src/nsterm.m | 291 +++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
1 file changed, 291 insertions(+)
|
|
|
|
diff --git a/src/nsterm.m b/src/nsterm.m
|
|
index 9e0e317237..b460beb00c 100644
|
|
--- a/src/nsterm.m
|
|
+++ b/src/nsterm.m
|
|
@@ -9346,6 +9346,297 @@ - (NSRect)accessibilityFrame
|
|
|
|
@end
|
|
|
|
+
|
|
+
|
|
+/* ===================================================================
|
|
+ EmacsAccessibilityInteractiveSpan --- helpers and implementation
|
|
+ =================================================================== */
|
|
+
|
|
+/* 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 @[];
|
|
+
|
|
+ block_input ();
|
|
+ specpdl_ref blk_count = SPECPDL_INDEX ();
|
|
+ record_unwind_protect_void (unblock_input);
|
|
+
|
|
+ /* 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
|
|
|