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
438 lines
14 KiB
Diff
438 lines
14 KiB
Diff
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
|
|
|