Files
emacs-doom/patches/0004-ns-add-interactive-span-elements-for-Tab-navigation.patch
Daneel 71c81abcae patches: address all maintainer review issues
- Issue 1: Add explicit ApplicationServices import for UAZoomEnabled/
  UAZoomChangeFocus (was implicit via Carbon.h, now explicit)
- Issue 2: Rename FOR_EACH_FRAME variable 'frames' -> 'frame' (plural
  was misleading; matches Emacs convention)
- Issue 3: Move unblock_input before ObjC calls in
  postCompletionAnnouncementForBuffer: to avoid holding block_input
  during @synchronized operations
- Issue 4: Fix DEFVAR_BOOL doc and Texinfo: initial value is nil,
  not t; auto-detection sets it at startup
- Issue 5: Replace magic 10000 with NS_AX_MAX_COMPLETION_BUFFER_CHARS
  constant with explanatory comment
- Issue 6: Add comment to lineStartOffsets loop explaining it is gated
  on BUF_CHARS_MODIFF and never runs on the hot path
- Issue 8: Rewrite all 9 commit messages to GNU ChangeLog format with
  '* file (symbol): description' entries
- Issue 9: Break 81-char @interface line in nsterm.h
- Issue 10: Add WINDOWP/BUFFERP guards before dereferencing
  cf->selected_window and cw->contents in ns_zoom_find_child_frame_candidate
- Issue 11: Fix @pxref -> @xref at sentence start in macos.texi
2026-03-01 09:44:47 +01:00

320 lines
10 KiB
Diff

From 9c7e408085f52f1e44b6cb71e64448162e5c3e68 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 | 286 +++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 286 insertions(+)
diff --git a/src/nsterm.m b/src/nsterm.m
index 350111a..992a5ce 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -9304,6 +9304,292 @@ - (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 @[];
+
+ /* 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