Files
emacs-doom/patches/0004-ns-add-interactive-span-elements-for-Tab-navigation.patch
Daneel 73563be72d patches: fix VoiceOver reads only first word in org-agenda
When AXSelectedTextChanged is posted from the parent EmacsView (NSView)
with UIElementsKey pointing to the EmacsAXBuffer element, VoiceOver calls
accessibilityLineForIndex: on the VIEW rather than on the focused element.
In specialised buffers (org-agenda, org-super-agenda) where line geometry
differs from plain text, the view returns an incorrect range and VoiceOver
reads only the first word at the cursor (e.g. 'La' or 'Liga') instead of
the full line.

Plain text buffers were unaffected because the fallback geometry happened
to be correct for simple line layouts.

Fix: post AXSelectedTextChanged on self (the EmacsAXBuffer element)
instead of on self.emacsView.  This causes VoiceOver to call
accessibilityLineForIndex: on the element that owns the selection, which
returns the correct line range in all buffer types.  Remove UIElementsKey
(unnecessary when posting from the element itself).

This aligns with the pre-review code (51f5944) which always posted
AX notifications directly on the focused element.
2026-03-03 09:43:26 +01:00

325 lines
10 KiB
Diff

From 5d5aca0665a22d3bf3cce54e53ec58fa9defab6e 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