When Emacs moves the cursor (emacsMovedCursor=YES), we post
FocusedUIElementChanged on the NSWindow to re-anchor VoiceOver's
browse cursor. For C-n/C-p this notification races with
AXSelectedTextChanged(granularity=line) and causes VoiceOver to
drop the line-read speech.
Arrow key movement works because VoiceOver intercepts those as AX
selection changes (setAccessibilitySelectedTextRange:), making
voiceoverSetPoint=YES and emacsMovedCursor=NO, so no
FocusedUIElementChanged is posted.
Fix: skip FocusedUIElementChanged for sequential C-n/C-p moves
(isCtrlNP). AXSelectedTextChanged with direction=next/previous +
granularity=line is sufficient for VoiceOver to read the new line.
FocusedUIElementChanged is only needed for discontiguous jumps
(]], M-<, isearch, xref etc.) where VoiceOver must re-anchor.
Also merge duplicate comment blocks and fix two compile errors
from a64d24c that Martin caught during testing.
325 lines
10 KiB
Diff
325 lines
10 KiB
Diff
From 3b8838647b39912753157d76b2aa4d8d0da0c55c 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
|
|
|