From 7303dd3913729a454b44fe71219acbd612d624b4 Mon Sep 17 00:00:00 2001 From: Martin Sukany Date: Sat, 28 Feb 2026 12:58:11 +0100 Subject: [PATCH 5/9] ns: add interactive span elements for Tab navigation * src/nsterm.m (ns_ax_scan_interactive_spans): New function. (EmacsAccessibilityInteractiveSpan): Implement AXButton/AXLink elements with AXPress action. (EmacsAccessibilityBuffer(InteractiveSpans)): New category. accessibilityChildrenInNavigationOrder for Tab/Shift-Tab cycling with wrap-around. Tested on macOS 14. Verified: Tab-cycling through org-mode links, *Completions* candidates, widget buttons, customize buffers. --- src/nsterm.m | 286 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) diff --git a/src/nsterm.m b/src/nsterm.m index 7e3d57a..1a21f2e 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -9263,6 +9263,292 @@ - (NSRect)accessibilityFrame @end + + +/* =================================================================== + EmacsAccessibilityInteractiveSpan — helpers and implementation + =================================================================== */ + +/* Scan visible range of window W for interactive spans. + Returns NSArray. + + 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