Files
emacs-doom/patches/0002-ns-implement-buffer-mode-line-and-interactive-span-e.patch
Daneel 67b1d25c34 patches: 4-patch VoiceOver series (split + improved docs)
Split VoiceOver accessibility into 4 logical patches:
  0001: Base classes + text extraction (+753)
  0002: Buffer/ModeLine/InteractiveSpan implementations (+1716)
  0003: EmacsView integration + cursor tracking (+395)
  0004: Documentation with known limitations (+75)

Each patch is self-contained: 0001 adds infrastructure that compiles
but doesn't change behavior.  0002 adds protocol implementations.
0003 wires everything into EmacsView.  0004 documents for users.

All patches verified: apply cleanly to current Emacs master,
final state identical to original monolithic patch.
2026-02-28 09:54:51 +01:00

1797 lines
54 KiB
Diff

From a49c6b5a9601fe11a6a03292e8b4d685a0ce50af Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 09:54:28 +0100
Subject: [PATCH 2/4] ns: implement buffer, mode-line, and interactive span
elements
Add the three remaining virtual element classes, completing the
accessibility object model. Combined with the previous patch, this
provides a full NSAccessibility text protocol implementation.
EmacsAccessibilityBuffer <NSAccessibility>: full text protocol for
a single Emacs window.
Text cache: @synchronized caching of buffer text and visible-run
array. Cache invalidated on modiff_count, window start, or
invisible-text configuration change.
Index mapping: binary search O(log n) between buffer positions and
UTF-16 accessibility indices via the visible-run array.
Selection: selectedTextRange from point/mark; insertion point from
point via index mapping.
Geometry: lineForIndex/indexForLine by newline scanning.
frameForRange delegates to ns_ax_frame_for_range.
Notification dispatch (postTextChangedNotification): hybrid
SelectedTextChanged / ValueChanged / AnnouncementRequested,
modeled on WebKit's pattern. Line navigation emits ValueChanged;
character/word motion emits SelectedTextChanged only. Completion
buffer announcements via AnnouncementRequested with High priority.
EmacsAccessibilityModeLine: AXStaticText exposing mode-line content.
EmacsAccessibilityInteractiveSpan: lightweight child of a buffer
element for Tab-navigable interactive text.
ns_ax_scan_interactive_spans: scan visible range with O(n/skip)
property-skip optimization. Priority: widget > button > follow-link
> org-link > completion-candidate > keymap-overlay.
Buffer (InteractiveSpans) category: Tab/Shift-Tab cycling with
wrap-around and VoiceOver focus notification.
ns_ax_completion_text_for_span: extract completion candidate text.
Threading: Lisp-accessing methods use dispatch_sync to main thread;
@synchronized protects text cache.
Tested on macOS 14 with VoiceOver. Verified: buffer reading, line
navigation, word/character announcements, completion announcements,
Tab-cycling interactive spans, mode-line readout.
* src/nsterm.m: EmacsAccessibilityBuffer, EmacsAccessibilityModeLine,
EmacsAccessibilityInteractiveSpan, supporting functions.
---
src/nsterm.m | 1716 ++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 1716 insertions(+)
diff --git a/src/nsterm.m b/src/nsterm.m
index ee27df1..c47912d 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -7387,6 +7387,351 @@ ns_ax_post_notification_with_info (id element,
});
}
+/* 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
+
+
+static NSString *
+ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
+ struct buffer *b,
+ ptrdiff_t start,
+ ptrdiff_t end,
+ NSString *cachedText)
+{
+ if (!elem || !b || !cachedText || end <= start)
+ return nil;
+
+ NSString *text = nil;
+ specpdl_ref count = SPECPDL_INDEX ();
+ record_unwind_current_buffer ();
+ /* Block input to prevent concurrent redisplay from modifying buffer
+ state while we read text properties. Unwind-protected so
+ block_input is always matched by unblock_input on signal. */
+ record_unwind_protect_void (unblock_input);
+ block_input ();
+ if (b != current_buffer)
+ set_buffer_internal_1 (b);
+
+ /* Prefer canonical completion candidate string from text property.
+ Try both completion--string (new API, set by minibuffer.el) and
+ completion (older API used by some modes). */
+ ptrdiff_t probes[2] = { start, end - 1 };
+ for (int i = 0; i < 2 && !text; i++)
+ {
+ ptrdiff_t p = probes[i];
+ Lisp_Object cstr = Fget_char_property (make_fixnum (p),
+ Qns_ax_completion__string,
+ Qnil);
+ if (STRINGP (cstr))
+ text = [NSString stringWithLispString:cstr];
+ if (!text)
+ {
+ /* Fallback: 'completion property used by display-completion-list. */
+ cstr = Fget_char_property (make_fixnum (p),
+ Qns_ax_completion,
+ Qnil);
+ if (STRINGP (cstr))
+ text = [NSString stringWithLispString:cstr];
+ }
+ }
+
+ if (!text)
+ {
+ NSUInteger ax_s = [elem accessibilityIndexForCharpos:start];
+ NSUInteger ax_e = [elem accessibilityIndexForCharpos:end];
+ if (ax_e > ax_s && ax_e <= [cachedText length])
+ text = [cachedText substringWithRange:NSMakeRange (ax_s, ax_e - ax_s)];
+ }
+
+ unbind_to (count, Qnil);
+
+ if (text)
+ {
+ text = [text stringByTrimmingCharactersInSet:
+ [NSCharacterSet whitespaceAndNewlineCharacterSet]];
+ if ([text length] == 0)
+ text = nil;
+ }
+
+ return text;
+}
+
@implementation EmacsAccessibilityElement
@@ -7443,6 +7788,1377 @@ ns_ax_post_notification_with_info (id element,
@end
+
+@implementation EmacsAccessibilityBuffer
+@synthesize cachedText;
+@synthesize cachedTextModiff;
+@synthesize cachedOverlayModiff;
+@synthesize cachedTextStart;
+@synthesize cachedModiff;
+@synthesize cachedPoint;
+@synthesize cachedMarkActive;
+@synthesize cachedCompletionAnnouncement;
+@synthesize cachedCompletionOverlayStart;
+@synthesize cachedCompletionOverlayEnd;
+@synthesize cachedCompletionPoint;
+
+- (void)dealloc
+{
+ [cachedText release];
+ [cachedCompletionAnnouncement release];
+ [cachedInteractiveSpans release];
+ if (visibleRuns)
+ xfree (visibleRuns);
+ [super dealloc];
+}
+
+/* ---- Text cache ---- */
+
+- (void)invalidateTextCache
+{
+ @synchronized (self)
+ {
+ [cachedText release];
+ cachedText = nil;
+ if (visibleRuns)
+ {
+ xfree (visibleRuns);
+ visibleRuns = NULL;
+ }
+ visibleRunCount = 0;
+ }
+ [self invalidateInteractiveSpans];
+}
+
+- (void)ensureTextCache
+{
+ NSTRACE ("EmacsAccessibilityBuffer ensureTextCache");
+ /* This method is only called from the main thread (AX getters
+ dispatch_sync to main first). Reads of cachedText/cachedTextModiff
+ below are therefore safe without @synchronized — only the
+ write section at the end needs synchronization to protect
+ against concurrent reads from AX server thread. */
+ eassert ([NSThread isMainThread]);
+ struct window *w = [self validWindow];
+ if (!w || !WINDOW_LEAF_P (w))
+ return;
+
+ struct buffer *b = XBUFFER (w->contents);
+ if (!b)
+ return;
+
+ ptrdiff_t modiff = BUF_MODIFF (b);
+ ptrdiff_t overlay_modiff = BUF_OVERLAY_MODIFF (b);
+ ptrdiff_t pt = BUF_PT (b);
+ NSUInteger textLen = cachedText ? [cachedText length] : 0;
+ /* Track both BUF_MODIFF and BUF_OVERLAY_MODIFF. Overlay-only
+ changes (e.g., timer-based completion highlight move without
+ text edit) bump overlay_modiff but not modiff. Also detect
+ narrowing/widening which changes BUF_BEGV without bumping
+ either modiff counter. */
+ if (cachedText && cachedTextModiff == modiff
+ && cachedOverlayModiff == overlay_modiff
+ && cachedTextStart == BUF_BEGV (b)
+ && pt >= cachedTextStart
+ && (textLen == 0
+ || [self accessibilityIndexForCharpos:pt] <= textLen))
+ return;
+
+ ptrdiff_t start;
+ ns_ax_visible_run *runs = NULL;
+ NSUInteger nruns = 0;
+ NSString *text = ns_ax_buffer_text (w, &start, &runs, &nruns);
+
+ @synchronized (self)
+ {
+ [cachedText release];
+ cachedText = [text retain];
+ cachedTextModiff = modiff;
+ cachedOverlayModiff = overlay_modiff;
+ cachedTextStart = start;
+
+ if (visibleRuns)
+ xfree (visibleRuns);
+ visibleRuns = runs;
+ visibleRunCount = nruns;
+ }
+}
+
+/* ---- Index mapping ---- */
+
+/* Convert buffer charpos to accessibility string index. */
+- (NSUInteger)accessibilityIndexForCharpos:(ptrdiff_t)charpos
+{
+ /* This method may be called from the AX server thread.
+ Synchronize on self to prevent use-after-free if the main
+ thread invalidates the text cache concurrently. */
+ @synchronized (self)
+ {
+ if (visibleRunCount == 0)
+ return 0;
+
+ /* Binary search: runs are sorted by charpos (ascending). Find the
+ run whose [charpos, charpos+length) range contains the target,
+ or the nearest run after an invisible gap. O(log n) instead of
+ O(n) — matters for org-mode with many folded sections. */
+ NSUInteger lo = 0, hi = visibleRunCount;
+ while (lo < hi)
+ {
+ NSUInteger mid = lo + (hi - lo) / 2;
+ ns_ax_visible_run *r = &visibleRuns[mid];
+ if (charpos < r->charpos)
+ hi = mid;
+ else if (charpos >= r->charpos + r->length)
+ lo = mid + 1;
+ else
+ {
+ /* Found: charpos is inside this run. Compute UTF-16 delta
+ directly from cachedText — no Lisp calls needed. */
+ NSUInteger chars_in = (NSUInteger)(charpos - r->charpos);
+ if (chars_in == 0 || !cachedText)
+ return r->ax_start;
+ NSUInteger run_end_ax = r->ax_start + r->ax_length;
+ NSUInteger scan = r->ax_start;
+ for (NSUInteger c = 0; c < chars_in && scan < run_end_ax; c++)
+ {
+ NSRange seq = [cachedText
+ rangeOfComposedCharacterSequenceAtIndex:scan];
+ scan = NSMaxRange (seq);
+ }
+ return (scan <= run_end_ax) ? scan : run_end_ax;
+ }
+ }
+ /* charpos falls in an invisible gap or past the end. */
+ if (lo < visibleRunCount)
+ return visibleRuns[lo].ax_start;
+ ns_ax_visible_run *last = &visibleRuns[visibleRunCount - 1];
+ return last->ax_start + last->ax_length;
+ } /* @synchronized */
+}
+
+/* Convert accessibility string index to buffer charpos.
+ Safe to call from any thread: uses only cachedText (NSString) and
+ visibleRuns — no Lisp calls. */
+- (ptrdiff_t)charposForAccessibilityIndex:(NSUInteger)ax_idx
+{
+ /* May be called from AX server thread — synchronize. */
+ @synchronized (self)
+ {
+ if (visibleRunCount == 0)
+ return cachedTextStart;
+
+ /* Binary search: runs are sorted by ax_start (ascending). */
+ NSUInteger lo = 0, hi = visibleRunCount;
+ while (lo < hi)
+ {
+ NSUInteger mid = lo + (hi - lo) / 2;
+ ns_ax_visible_run *r = &visibleRuns[mid];
+ if (ax_idx < r->ax_start)
+ hi = mid;
+ else if (ax_idx >= r->ax_start + r->ax_length)
+ lo = mid + 1;
+ else
+ {
+ /* Found: ax_idx is inside this run. Walk composed character
+ sequences to count Emacs characters up to ax_idx. */
+ if (!cachedText)
+ return r->charpos;
+ NSUInteger scan = r->ax_start;
+ ptrdiff_t cp = r->charpos;
+ while (scan < ax_idx)
+ {
+ NSRange seq = [cachedText
+ rangeOfComposedCharacterSequenceAtIndex:scan];
+ scan = NSMaxRange (seq);
+ cp++;
+ }
+ return cp;
+ }
+ }
+ /* Past end — return last charpos. */
+ if (lo > 0)
+ {
+ ns_ax_visible_run *last = &visibleRuns[visibleRunCount - 1];
+ return last->charpos + last->length;
+ }
+ return cachedTextStart;
+ } /* @synchronized */
+}
+
+/* ---- NSAccessibility protocol ---- */
+
+- (NSAccessibilityRole)accessibilityRole
+{
+ if (![NSThread isMainThread])
+ {
+ __block NSAccessibilityRole result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityRole];
+ });
+ return result;
+ }
+ struct window *w = [self validWindow];
+ if (w && MINI_WINDOW_P (w))
+ return NSAccessibilityTextFieldRole;
+ return NSAccessibilityTextAreaRole;
+}
+
+- (NSString *)accessibilityPlaceholderValue
+{
+ if (![NSThread isMainThread])
+ {
+ __block NSString *result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityPlaceholderValue];
+ });
+ return result;
+ }
+ struct window *w = [self validWindow];
+ if (!w || !MINI_WINDOW_P (w))
+ return nil;
+ Lisp_Object prompt = Fminibuffer_prompt ();
+ if (STRINGP (prompt))
+ return [NSString stringWithLispString: prompt];
+ return nil;
+}
+
+- (NSString *)accessibilityRoleDescription
+{
+ if (![NSThread isMainThread])
+ {
+ __block NSString *result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityRoleDescription];
+ });
+ return result;
+ }
+ struct window *w = [self validWindow];
+ if (w && MINI_WINDOW_P (w))
+ return @"minibuffer";
+ return @"editor";
+}
+
+- (NSString *)accessibilityLabel
+{
+ if (![NSThread isMainThread])
+ {
+ __block NSString *result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityLabel];
+ });
+ return result;
+ }
+ struct window *w = [self validWindow];
+ if (w && WINDOW_LEAF_P (w))
+ {
+ if (MINI_WINDOW_P (w))
+ return @"Minibuffer";
+
+ struct buffer *b = XBUFFER (w->contents);
+ if (b)
+ {
+ Lisp_Object name = BVAR (b, name);
+ if (STRINGP (name))
+ return [NSString stringWithLispString:name];
+ }
+ }
+ return @"buffer";
+}
+
+- (BOOL)isAccessibilityFocused
+{
+ if (![NSThread isMainThread])
+ {
+ __block BOOL result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self isAccessibilityFocused];
+ });
+ return result;
+ }
+ struct window *w = [self validWindow];
+ if (!w)
+ return NO;
+ EmacsView *view = self.emacsView;
+ if (!view || !view->emacsframe)
+ return NO;
+ struct frame *f = view->emacsframe;
+ return (w == XWINDOW (f->selected_window));
+}
+
+- (id)accessibilityValue
+{
+ /* AX getters can be called from any thread by the AT subsystem.
+ Dispatch to main thread where Emacs buffer state is consistent. */
+ if (![NSThread isMainThread])
+ {
+ __block id result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityValue];
+ });
+ return result;
+ }
+ [self ensureTextCache];
+ return cachedText ? cachedText : @"";
+}
+
+- (NSInteger)accessibilityNumberOfCharacters
+{
+ if (![NSThread isMainThread])
+ {
+ __block NSInteger result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityNumberOfCharacters];
+ });
+ return result;
+ }
+ [self ensureTextCache];
+ return cachedText ? [cachedText length] : 0;
+}
+
+- (NSString *)accessibilitySelectedText
+{
+ if (![NSThread isMainThread])
+ {
+ __block NSString *result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilitySelectedText];
+ });
+ return result;
+ }
+ struct window *w = [self validWindow];
+ if (!w || !WINDOW_LEAF_P (w))
+ return @"";
+
+ struct buffer *b = XBUFFER (w->contents);
+ if (!b || NILP (BVAR (b, mark_active)))
+ return @"";
+
+ NSRange sel = [self accessibilitySelectedTextRange];
+ [self ensureTextCache];
+ if (!cachedText || sel.location == NSNotFound
+ || sel.location + sel.length > [cachedText length])
+ return @"";
+ return [cachedText substringWithRange:sel];
+}
+
+- (NSRange)accessibilitySelectedTextRange
+{
+ if (![NSThread isMainThread])
+ {
+ __block NSRange result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilitySelectedTextRange];
+ });
+ return result;
+ }
+ struct window *w = [self validWindow];
+ if (!w || !WINDOW_LEAF_P (w))
+ return NSMakeRange (0, 0);
+
+ if (!BUFFERP (w->contents))
+ return NSMakeRange (0, 0);
+ struct buffer *b = XBUFFER (w->contents);
+ if (!b)
+ return NSMakeRange (0, 0);
+
+ [self ensureTextCache];
+ ptrdiff_t pt = BUF_PT (b);
+ NSUInteger point_idx = [self accessibilityIndexForCharpos:pt];
+
+ if (NILP (BVAR (b, mark_active)))
+ return NSMakeRange (point_idx, 0);
+
+ ptrdiff_t mark_pos = marker_position (BVAR (b, mark));
+ NSUInteger mark_idx = [self accessibilityIndexForCharpos:mark_pos];
+ NSUInteger start_idx = MIN (point_idx, mark_idx);
+ NSUInteger end_idx = MAX (point_idx, mark_idx);
+ return NSMakeRange (start_idx, end_idx - start_idx);
+}
+
+- (void)setAccessibilitySelectedTextRange:(NSRange)range
+{
+ if (![NSThread isMainThread])
+ {
+ dispatch_async (dispatch_get_main_queue (), ^{
+ [self setAccessibilitySelectedTextRange:range];
+ });
+ return;
+ }
+ struct window *w = [self validWindow];
+ if (!w || !WINDOW_LEAF_P (w))
+ return;
+
+ struct buffer *b = XBUFFER (w->contents);
+ if (!b)
+ return;
+
+ [self ensureTextCache];
+
+ specpdl_ref count = SPECPDL_INDEX ();
+ record_unwind_current_buffer ();
+ /* Ensure block_input is always matched by unblock_input even if
+ Fset_marker or another Lisp call signals (longjmp). */
+ record_unwind_protect_void (unblock_input);
+ block_input ();
+
+ /* Convert accessibility index to buffer charpos via mapping. */
+ ptrdiff_t charpos = [self charposForAccessibilityIndex:range.location];
+
+ /* Clamp to buffer bounds. */
+ if (charpos < BUF_BEGV (b))
+ charpos = BUF_BEGV (b);
+ if (charpos > BUF_ZV (b))
+ charpos = BUF_ZV (b);
+
+ /* Move point directly in the buffer. */
+ if (b != current_buffer)
+ set_buffer_internal_1 (b);
+
+ SET_PT_BOTH (charpos, CHAR_TO_BYTE (charpos));
+
+ /* Keep mark state aligned with requested selection range. */
+ if (range.length > 0)
+ {
+ ptrdiff_t mark_charpos = [self charposForAccessibilityIndex:
+ range.location + range.length];
+ if (mark_charpos > BUF_ZV (b))
+ mark_charpos = BUF_ZV (b);
+ Fset_marker (BVAR (b, mark), make_fixnum (mark_charpos),
+ Fcurrent_buffer ());
+ bset_mark_active (b, Qt);
+ }
+ else
+ bset_mark_active (b, Qnil);
+
+ unbind_to (count, Qnil);
+
+ /* Update cached state so the next notification cycle doesn't
+ re-announce this movement. */
+ self.cachedPoint = charpos;
+ self.cachedMarkActive = (range.length > 0);
+}
+
+- (void)setAccessibilityFocused:(BOOL)flag
+{
+ if (!flag)
+ return;
+
+ /* VoiceOver may call this from the AX server thread.
+ All Lisp reads, block_input, and AppKit calls require main. */
+ if (![NSThread isMainThread])
+ {
+ dispatch_async (dispatch_get_main_queue (), ^{
+ [self setAccessibilityFocused:flag];
+ });
+ return;
+ }
+
+ struct window *w = [self validWindow];
+ if (!w || !WINDOW_LEAF_P (w))
+ return;
+
+ EmacsView *view = self.emacsView;
+ if (!view || !view->emacsframe)
+ return;
+
+ /* Use specpdl unwind protection for block_input safety. */
+ specpdl_ref count = SPECPDL_INDEX ();
+ record_unwind_protect_void (unblock_input);
+ block_input ();
+
+ /* Select the Emacs window so keyboard focus follows VoiceOver. */
+ struct frame *f = view->emacsframe;
+ if (w != XWINDOW (f->selected_window))
+ Fselect_window (self.lispWindow, Qnil);
+
+ /* Raise the frame's NS window to ensure keyboard focus. */
+ NSWindow *nswin = [view window];
+ if (nswin && ![nswin isKeyWindow])
+ [nswin makeKeyAndOrderFront:nil];
+
+ unbind_to (count, Qnil);
+
+ /* Post SelectedTextChanged so VoiceOver reads the current line
+ upon entering text interaction mode.
+ WebKit AXObjectCacheMac fallback enum: SelectionMove = 2. */
+ NSDictionary *info = @{
+ @"AXTextStateChangeType":
+ @(ns_ax_text_state_change_selection_move),
+ @"AXTextChangeElement": self
+ };
+ ns_ax_post_notification_with_info (
+ self, NSAccessibilitySelectedTextChangedNotification, info);
+}
+
+- (NSInteger)accessibilityInsertionPointLineNumber
+{
+ if (![NSThread isMainThread])
+ {
+ __block NSInteger result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityInsertionPointLineNumber];
+ });
+ return result;
+ }
+ struct window *w = [self validWindow];
+ if (!w || !WINDOW_LEAF_P (w))
+ return 0;
+
+ struct buffer *b = XBUFFER (w->contents);
+ if (!b)
+ return 0;
+
+ [self ensureTextCache];
+ if (!cachedText)
+ return 0;
+
+ ptrdiff_t pt = BUF_PT (b);
+ NSUInteger point_idx = [self accessibilityIndexForCharpos:pt];
+ if (point_idx > [cachedText length])
+ point_idx = [cachedText length];
+
+ /* Count lines by iterating lineRangeForRange from the start.
+ Each call jumps an entire line — O(lines) not O(chars). */
+ NSInteger line = 0;
+ NSUInteger scan = 0;
+ NSUInteger len = [cachedText length];
+ while (scan < point_idx && scan < len)
+ {
+ NSRange lr = [cachedText lineRangeForRange:NSMakeRange (scan, 0)];
+ NSUInteger next = NSMaxRange (lr);
+ if (next <= scan) break; /* safety */
+ if (next > point_idx) break;
+ line++;
+ scan = next;
+ }
+ return line;
+}
+
+- (NSString *)accessibilityStringForRange:(NSRange)range
+{
+ if (![NSThread isMainThread])
+ {
+ __block NSString *result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityStringForRange:range];
+ });
+ return result;
+ }
+ [self ensureTextCache];
+ if (!cachedText || range.location + range.length > [cachedText length])
+ return @"";
+ return [cachedText substringWithRange:range];
+}
+
+- (NSAttributedString *)accessibilityAttributedStringForRange:(NSRange)range
+{
+ NSString *str = [self accessibilityStringForRange:range];
+ return [[[NSAttributedString alloc] initWithString:str] autorelease];
+}
+
+- (NSInteger)accessibilityLineForIndex:(NSInteger)index
+{
+ if (![NSThread isMainThread])
+ {
+ __block NSInteger result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityLineForIndex:index];
+ });
+ return result;
+ }
+ [self ensureTextCache];
+ if (!cachedText || index < 0)
+ return 0;
+
+ NSUInteger idx = (NSUInteger) index;
+ if (idx > [cachedText length])
+ idx = [cachedText length];
+
+ /* Count lines by iterating lineRangeForRange — O(lines). */
+ NSInteger line = 0;
+ NSUInteger scan = 0;
+ NSUInteger len = [cachedText length];
+ while (scan < idx && scan < len)
+ {
+ NSRange lr = [cachedText lineRangeForRange:NSMakeRange (scan, 0)];
+ NSUInteger next = NSMaxRange (lr);
+ if (next <= scan) break;
+ if (next > idx) break;
+ line++;
+ scan = next;
+ }
+ return line;
+}
+
+- (NSRange)accessibilityRangeForLine:(NSInteger)line
+{
+ if (![NSThread isMainThread])
+ {
+ __block NSRange result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityRangeForLine:line];
+ });
+ return result;
+ }
+ [self ensureTextCache];
+ if (!cachedText || line < 0)
+ return NSMakeRange (NSNotFound, 0);
+
+ NSUInteger len = [cachedText length];
+ if (len == 0)
+ return (line == 0) ? NSMakeRange (0, 0)
+ : NSMakeRange (NSNotFound, 0);
+
+ /* Skip to the requested line using lineRangeForRange — O(lines)
+ not O(chars), consistent with accessibilityLineForIndex:. */
+ NSInteger cur_line = 0;
+ NSUInteger scan = 0;
+ while (cur_line < line && scan < len)
+ {
+ NSRange lr = [cachedText lineRangeForRange:NSMakeRange (scan, 0)];
+ NSUInteger next = NSMaxRange (lr);
+ if (next <= scan) break; /* safety */
+ cur_line++;
+ scan = next;
+ }
+ if (cur_line != line)
+ return NSMakeRange (NSNotFound, 0);
+
+ /* Return the range of the target line. */
+ if (scan >= len)
+ return NSMakeRange (len, 0); /* phantom line after final newline */
+ NSRange lr = [cachedText lineRangeForRange:NSMakeRange (scan, 0)];
+ return lr;
+}
+
+- (NSRange)accessibilityRangeForIndex:(NSInteger)index
+{
+ if (![NSThread isMainThread])
+ {
+ __block NSRange result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityRangeForIndex:index];
+ });
+ return result;
+ }
+ [self ensureTextCache];
+ if (!cachedText || index < 0
+ || (NSUInteger) index >= [cachedText length])
+ return NSMakeRange (NSNotFound, 0);
+ return [cachedText rangeOfComposedCharacterSequenceAtIndex:(NSUInteger)index];
+}
+
+- (NSRange)accessibilityStyleRangeForIndex:(NSInteger)index
+{
+ /* Return the range of the current line. A more accurate
+ implementation would return face/font property boundaries,
+ but line granularity is acceptable for VoiceOver. */
+ NSInteger line = [self accessibilityLineForIndex:index];
+ return [self accessibilityRangeForLine:line];
+}
+
+- (NSRect)accessibilityFrameForRange:(NSRange)range
+{
+ if (![NSThread isMainThread])
+ {
+ __block NSRect result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityFrameForRange:range];
+ });
+ return result;
+ }
+ struct window *w = [self validWindow];
+ EmacsView *view = self.emacsView;
+ if (!w || !view)
+ return NSZeroRect;
+ /* Convert ax-index range to charpos range for glyph lookup. */
+ [self ensureTextCache];
+ ptrdiff_t cp_start = [self charposForAccessibilityIndex:range.location];
+ ptrdiff_t cp_end = [self charposForAccessibilityIndex:
+ range.location + range.length];
+ return ns_ax_frame_for_range (w, view, cp_start,
+ cp_end - cp_start);
+}
+
+- (NSRange)accessibilityRangeForPosition:(NSPoint)screenPoint
+{
+ if (![NSThread isMainThread])
+ {
+ __block NSRange result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result
+ = [self accessibilityRangeForPosition:screenPoint];
+ });
+ return result;
+ }
+ /* Hit test: convert screen point to buffer character index. */
+ struct window *w = [self validWindow];
+ EmacsView *view = self.emacsView;
+ if (!w || !view || !w->current_matrix)
+ return NSMakeRange (0, 0);
+
+ /* Convert screen point to EmacsView coordinates. */
+ NSPoint windowPoint = [[view window] convertPointFromScreen:screenPoint];
+ NSPoint viewPoint = [view convertPoint:windowPoint fromView:nil];
+
+ /* Convert to window-relative pixel coordinates. */
+ int x = (int) viewPoint.x - w->pixel_left;
+ int y = (int) viewPoint.y - w->pixel_top;
+
+ if (x < 0 || y < 0 || x >= w->pixel_width || y >= w->pixel_height)
+ return NSMakeRange (0, 0);
+
+ /* Block input to prevent concurrent redisplay from modifying the
+ glyph matrix while we traverse it. Use specpdl unwind protection
+ so block_input is always matched by unblock_input, even if
+ ensureTextCache triggers a Lisp signal (longjmp). */
+ specpdl_ref count = SPECPDL_INDEX ();
+ record_unwind_protect_void (unblock_input);
+ block_input ();
+
+ /* Find the glyph row at this y coordinate. */
+ struct glyph_matrix *matrix = w->current_matrix;
+ struct glyph_row *hit_row = NULL;
+
+ for (int i = 0; i < matrix->nrows; i++)
+ {
+ struct glyph_row *row = matrix->rows + i;
+ if (!row->enabled_p || !row->displays_text_p || row->mode_line_p)
+ continue;
+ int row_top = WINDOW_TO_FRAME_PIXEL_Y (w, MAX (0, row->y));
+ if ((int) viewPoint.y >= row_top
+ && (int) viewPoint.y < row_top + row->visible_height)
+ {
+ hit_row = row;
+ break;
+ }
+ }
+
+ if (!hit_row)
+ {
+ unbind_to (count, Qnil);
+ return NSMakeRange (0, 0);
+ }
+
+ /* Find the glyph at this x coordinate within the row. */
+ struct glyph *glyph = hit_row->glyphs[TEXT_AREA];
+ struct glyph *end = glyph + hit_row->used[TEXT_AREA];
+ int glyph_x = 0;
+ ptrdiff_t best_charpos = MATRIX_ROW_START_CHARPOS (hit_row);
+
+ for (; glyph < end; glyph++)
+ {
+ if (glyph->type == CHAR_GLYPH && glyph->charpos > 0)
+ {
+ if (x >= glyph_x && x < glyph_x + glyph->pixel_width)
+ {
+ best_charpos = glyph->charpos;
+ break;
+ }
+ best_charpos = glyph->charpos;
+ }
+ glyph_x += glyph->pixel_width;
+ }
+
+ /* Convert buffer charpos to accessibility index via mapping. */
+ [self ensureTextCache];
+ NSUInteger ax_idx = [self accessibilityIndexForCharpos:best_charpos];
+ if (cachedText && ax_idx > [cachedText length])
+ ax_idx = [cachedText length];
+
+ unbind_to (count, Qnil);
+ return NSMakeRange (ax_idx, 1);
+}
+
+- (NSRange)accessibilityVisibleCharacterRange
+{
+ if (![NSThread isMainThread])
+ {
+ __block NSRange result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityVisibleCharacterRange];
+ });
+ return result;
+ }
+ /* Return the full cached text range. VoiceOver interprets the
+ visible range boundary as end-of-text, so we must expose the
+ entire buffer to avoid premature "end of text" announcements. */
+ [self ensureTextCache];
+ return NSMakeRange (0, cachedText ? [cachedText length] : 0);
+}
+
+- (NSRect)accessibilityFrame
+{
+ if (![NSThread isMainThread])
+ {
+ __block NSRect result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityFrame];
+ });
+ return result;
+ }
+ struct window *w = [self validWindow];
+ if (!w)
+ return NSZeroRect;
+
+ /* Subtract mode line height so the buffer element does not overlap it. */
+ int text_h = w->pixel_height;
+ if (w->current_matrix)
+ {
+ for (int i = w->current_matrix->nrows - 1; i >= 0; i--)
+ {
+ struct glyph_row *row = w->current_matrix->rows + i;
+ if (row->enabled_p && row->mode_line_p)
+ {
+ text_h -= row->visible_height;
+ break;
+ }
+ }
+ }
+ return [self screenRectFromEmacsX:w->pixel_left
+ y:w->pixel_top
+ width:w->pixel_width
+ height:text_h];
+}
+
+/* ---- Notification dispatch (helper methods) ---- */
+
+/* Post NSAccessibilityValueChangedNotification for a text edit.
+ Called when BUF_MODIFF changes between redisplay cycles. */
+- (void)postTextChangedNotification:(ptrdiff_t)point
+{
+ /* Capture changed char before invalidating cache. */
+ NSString *changedChar = @"";
+ if (point > self.cachedPoint
+ && point - self.cachedPoint == 1)
+ {
+ /* Single char inserted — refresh cache and grab it. */
+ [self invalidateTextCache];
+ [self ensureTextCache];
+ if (cachedText)
+ {
+ NSUInteger idx = [self accessibilityIndexForCharpos:point - 1];
+ if (idx < [cachedText length])
+ changedChar = [cachedText substringWithRange:
+ NSMakeRange (idx, 1)];
+ }
+ }
+ else
+ {
+ [self invalidateTextCache];
+ }
+
+ /* Update cachedPoint here so the selection-move branch does NOT
+ fire for point changes caused by edits. WebKit and Chromium
+ never send both ValueChanged and SelectedTextChanged for the
+ same user action — they are mutually exclusive. */
+ self.cachedPoint = point;
+
+ NSDictionary *change = @{
+ @"AXTextEditType": @(ns_ax_text_edit_type_typing),
+ @"AXTextChangeValue": changedChar,
+ @"AXTextChangeValueLength": @([changedChar length])
+ };
+ NSDictionary *userInfo = @{
+ @"AXTextStateChangeType": @(ns_ax_text_state_change_edit),
+ @"AXTextChangeValues": @[change],
+ @"AXTextChangeElement": self
+ };
+ ns_ax_post_notification_with_info (
+ self, NSAccessibilityValueChangedNotification, userInfo);
+}
+
+/* Post SelectedTextChanged and AnnouncementRequested for the
+ focused buffer element when point or mark changes. */
+- (void)postFocusedCursorNotification:(ptrdiff_t)point
+ direction:(NSInteger)direction
+ granularity:(NSInteger)granularity
+ markActive:(BOOL)markActive
+ oldMarkActive:(BOOL)oldMarkActive
+{
+ BOOL isCharMove
+ = (!markActive && !oldMarkActive
+ && granularity
+ == ns_ax_text_selection_granularity_character);
+
+ /* Always post SelectedTextChanged to interrupt VoiceOver reading
+ and update cursor tracking / braille displays. */
+ NSMutableDictionary *moveInfo = [NSMutableDictionary dictionary];
+ moveInfo[@"AXTextStateChangeType"]
+ = @(ns_ax_text_state_change_selection_move);
+ moveInfo[@"AXTextSelectionDirection"] = @(direction);
+ moveInfo[@"AXTextChangeElement"] = self;
+ /* Omit granularity for character moves so VoiceOver does not
+ derive its own speech (it would read the wrong character
+ for evil block-cursor mode). Include it for word/line/
+ selection so VoiceOver reads the appropriate text. */
+ if (!isCharMove)
+ moveInfo[@"AXTextSelectionGranularity"] = @(granularity);
+
+ ns_ax_post_notification_with_info (
+ self,
+ NSAccessibilitySelectedTextChangedNotification,
+ moveInfo);
+
+ /* For character moves: explicit announcement of char AT point.
+ This is the ONLY speech source for character navigation.
+ Correct for evil block-cursor (cursor ON the character)
+ and harmless for insert-mode. */
+ if (isCharMove && cachedText)
+ {
+ NSUInteger point_idx
+ = [self accessibilityIndexForCharpos:point];
+ NSUInteger tlen = [cachedText length];
+ if (point_idx < tlen)
+ {
+ NSRange charRange = [cachedText
+ rangeOfComposedCharacterSequenceAtIndex: point_idx];
+ if (charRange.location != NSNotFound
+ && charRange.length > 0
+ && NSMaxRange (charRange) <= tlen)
+ {
+ NSString *ch
+ = [cachedText substringWithRange: charRange];
+ if (![ch isEqualToString: @"\n"])
+ {
+ NSDictionary *annInfo = @{
+ NSAccessibilityAnnouncementKey: ch,
+ NSAccessibilityPriorityKey:
+ @(NSAccessibilityPriorityHigh)
+ };
+ ns_ax_post_notification_with_info (
+ NSApp,
+ NSAccessibilityAnnouncementRequestedNotification,
+ annInfo);
+ }
+ }
+ }
+ }
+
+ /* For focused line moves: always announce line text explicitly.
+ SelectedTextChanged with granularity=line works for arrow keys,
+ but C-n/C-p need the explicit announcement (VoiceOver processes
+ these keystrokes differently from arrows).
+ In completion-list-mode, read the completion candidate instead
+ of the whole line. */
+ if (cachedText
+ && granularity == ns_ax_text_selection_granularity_line)
+ {
+ NSString *announceText = nil;
+
+ /* 1. completion--string at point. */
+ Lisp_Object cstr
+ = Fget_char_property (make_fixnum (point),
+ Qns_ax_completion__string, Qnil);
+ announceText = ns_ax_completion_string_from_prop (cstr);
+
+ /* 2. Fallback: full line text. */
+ if (!announceText)
+ {
+ NSUInteger point_idx
+ = [self accessibilityIndexForCharpos:point];
+ if (point_idx <= [cachedText length])
+ {
+ NSInteger lineNum
+ = [self accessibilityLineForIndex:point_idx];
+ NSRange lineRange
+ = [self accessibilityRangeForLine:lineNum];
+ if (lineRange.location != NSNotFound
+ && lineRange.length > 0
+ && NSMaxRange (lineRange) <= [cachedText length])
+ announceText
+ = [cachedText substringWithRange:lineRange];
+ }
+ }
+
+ if (announceText)
+ {
+ announceText = [announceText
+ stringByTrimmingCharactersInSet:
+ [NSCharacterSet whitespaceAndNewlineCharacterSet]];
+ if ([announceText length] > 0)
+ {
+ NSDictionary *annInfo = @{
+ NSAccessibilityAnnouncementKey: announceText,
+ NSAccessibilityPriorityKey:
+ @(NSAccessibilityPriorityHigh)
+ };
+ ns_ax_post_notification_with_info (
+ NSApp,
+ NSAccessibilityAnnouncementRequestedNotification,
+ annInfo);
+ }
+ }
+ }
+}
+
+/* Post AnnouncementRequested for non-focused buffers (typically
+ *Completions* while minibuffer has keyboard focus).
+ VoiceOver does not automatically read changes in non-focused
+ elements, so we announce the selected completion explicitly. */
+- (void)postCompletionAnnouncementForBuffer:(struct buffer *)b
+ point:(ptrdiff_t)point
+{
+ NSString *announceText = nil;
+ ptrdiff_t currentOverlayStart = 0;
+ ptrdiff_t currentOverlayEnd = 0;
+
+ specpdl_ref count2 = SPECPDL_INDEX ();
+ record_unwind_current_buffer ();
+ if (b != current_buffer)
+ set_buffer_internal_1 (b);
+
+ /* 1) Prefer explicit completion candidate property. */
+ Lisp_Object cstr = Fget_char_property (make_fixnum (point),
+ Qns_ax_completion__string,
+ Qnil);
+ announceText = ns_ax_completion_string_from_prop (cstr);
+
+ /* 2) Fallback: mouse-face span at point. */
+ if (!announceText)
+ {
+ Lisp_Object mf = Fget_char_property (make_fixnum (point),
+ Qmouse_face, Qnil);
+ if (!NILP (mf))
+ {
+ ptrdiff_t begv2 = BUF_BEGV (b);
+ ptrdiff_t zv2 = BUF_ZV (b);
+
+ Lisp_Object prev_change
+ = Fprevious_single_char_property_change (
+ make_fixnum (point + 1), Qmouse_face,
+ Qnil, make_fixnum (begv2));
+ ptrdiff_t s2
+ = FIXNUMP (prev_change) ? XFIXNUM (prev_change)
+ : begv2;
+
+ Lisp_Object next_change
+ = Fnext_single_char_property_change (
+ make_fixnum (point), Qmouse_face,
+ Qnil, make_fixnum (zv2));
+ ptrdiff_t e2
+ = FIXNUMP (next_change) ? XFIXNUM (next_change)
+ : zv2;
+
+ if (e2 > s2)
+ {
+ NSUInteger ax_s = [self accessibilityIndexForCharpos:s2];
+ NSUInteger ax_e = [self accessibilityIndexForCharpos:e2];
+ if (ax_e > ax_s && ax_e <= [cachedText length])
+ announceText = [cachedText substringWithRange:
+ NSMakeRange (ax_s, ax_e - ax_s)];
+ }
+ }
+ }
+
+ /* 3) Fallback: completions-highlight overlay at point. */
+ if (!announceText)
+ {
+ Lisp_Object faceSym = Qns_ax_completions_highlight;
+ Lisp_Object overlays = Foverlays_at (make_fixnum (point), Qnil);
+ Lisp_Object tail;
+ for (tail = overlays; CONSP (tail); tail = XCDR (tail))
+ {
+ Lisp_Object ov = XCAR (tail);
+ Lisp_Object face = Foverlay_get (ov, Qface);
+ if (EQ (face, faceSym)
+ || (CONSP (face)
+ && !NILP (Fmemq (faceSym, face))))
+ {
+ ptrdiff_t ov_start = OVERLAY_START (ov);
+ ptrdiff_t ov_end = OVERLAY_END (ov);
+ if (ov_end > ov_start)
+ {
+ announceText = ns_ax_completion_text_for_span (self, b,
+ ov_start,
+ ov_end,
+ cachedText);
+ currentOverlayStart = ov_start;
+ currentOverlayEnd = ov_end;
+ }
+ break;
+ }
+ }
+ }
+
+ /* 4) Fallback: nearest completions-highlight overlay. */
+ if (!announceText)
+ {
+ ptrdiff_t ov_start = 0;
+ ptrdiff_t ov_end = 0;
+ if (ns_ax_find_completion_overlay_range (b, point,
+ &ov_start, &ov_end))
+ {
+ announceText = ns_ax_completion_text_for_span (self, b,
+ ov_start, ov_end,
+ cachedText);
+ currentOverlayStart = ov_start;
+ currentOverlayEnd = ov_end;
+ }
+ }
+
+ unbind_to (count2, Qnil);
+
+ /* Final fallback: read current line at point. */
+ if (!announceText)
+ {
+ NSUInteger point_idx = [self accessibilityIndexForCharpos:point];
+ if (point_idx <= [cachedText length])
+ {
+ NSInteger lineNum = [self accessibilityLineForIndex:
+ point_idx];
+ NSRange lineRange = [self accessibilityRangeForLine:lineNum];
+ if (lineRange.location != NSNotFound
+ && lineRange.length > 0
+ && lineRange.location + lineRange.length
+ <= [cachedText length])
+ announceText = [cachedText substringWithRange:lineRange];
+ }
+ }
+
+ /* Deduplicate: post only when text, overlay, or point changed. */
+ if (announceText)
+ {
+ announceText = [announceText stringByTrimmingCharactersInSet:
+ [NSCharacterSet whitespaceAndNewlineCharacterSet]];
+ if ([announceText length] > 0)
+ {
+ BOOL textChanged = ![announceText isEqualToString:
+ self.cachedCompletionAnnouncement];
+ BOOL overlayChanged =
+ (currentOverlayStart != self.cachedCompletionOverlayStart
+ || currentOverlayEnd != self.cachedCompletionOverlayEnd);
+ BOOL pointChanged = (point != self.cachedCompletionPoint);
+ if (textChanged || overlayChanged || pointChanged)
+ {
+ NSDictionary *annInfo = @{
+ NSAccessibilityAnnouncementKey: announceText,
+ NSAccessibilityPriorityKey:
+ @(NSAccessibilityPriorityHigh)
+ };
+ ns_ax_post_notification_with_info (
+ NSApp,
+ NSAccessibilityAnnouncementRequestedNotification,
+ annInfo);
+ }
+ self.cachedCompletionAnnouncement = announceText;
+ self.cachedCompletionOverlayStart = currentOverlayStart;
+ self.cachedCompletionOverlayEnd = currentOverlayEnd;
+ self.cachedCompletionPoint = point;
+ }
+ else
+ {
+ self.cachedCompletionAnnouncement = nil;
+ self.cachedCompletionOverlayStart = 0;
+ self.cachedCompletionOverlayEnd = 0;
+ self.cachedCompletionPoint = 0;
+ }
+ }
+ else
+ {
+ self.cachedCompletionAnnouncement = nil;
+ self.cachedCompletionOverlayStart = 0;
+ self.cachedCompletionOverlayEnd = 0;
+ self.cachedCompletionPoint = 0;
+ }
+}
+
+/* ---- Notification dispatch (main entry point) ---- */
+
+/* Dispatch accessibility notifications after a redisplay cycle.
+ Detects three mutually exclusive events: text edit, cursor/mark
+ change, or no change. Delegates to helper methods above. */
+- (void)postAccessibilityNotificationsForFrame:(struct frame *)f
+{
+ NSTRACE ("[EmacsView postAccessibilityNotificationsForFrame:]");
+ struct window *w = [self validWindow];
+ if (!w || !WINDOW_LEAF_P (w))
+ return;
+
+ struct buffer *b = XBUFFER (w->contents);
+ if (!b)
+ return;
+
+ ptrdiff_t modiff = BUF_MODIFF (b);
+ ptrdiff_t point = BUF_PT (b);
+ BOOL markActive = !NILP (BVAR (b, mark_active));
+
+ /* --- Text changed (edit) --- */
+ if (modiff != self.cachedModiff)
+ {
+ self.cachedModiff = modiff;
+ [self postTextChangedNotification:point];
+ }
+
+ /* --- Cursor moved or selection changed ---
+ Use 'else if' — edits and selection moves are mutually exclusive
+ per the WebKit/Chromium pattern. */
+ else if (point != self.cachedPoint || markActive != self.cachedMarkActive)
+ {
+ ptrdiff_t oldPoint = self.cachedPoint;
+ BOOL oldMarkActive = self.cachedMarkActive;
+ self.cachedPoint = point;
+ self.cachedMarkActive = markActive;
+
+ /* Compute direction. */
+ NSInteger direction = ns_ax_text_selection_direction_discontiguous;
+ if (point > oldPoint)
+ direction = ns_ax_text_selection_direction_next;
+ else if (point < oldPoint)
+ direction = ns_ax_text_selection_direction_previous;
+
+ int ctrlNP = 0;
+ bool isCtrlNP = ns_ax_event_is_line_nav_key (&ctrlNP);
+
+ /* --- Granularity detection --- */
+ NSInteger granularity = ns_ax_text_selection_granularity_unknown;
+ [self ensureTextCache];
+ if (cachedText && oldPoint > 0)
+ {
+ NSUInteger tlen = [cachedText length];
+ NSUInteger oldIdx = [self accessibilityIndexForCharpos:oldPoint];
+ NSUInteger newIdx = [self accessibilityIndexForCharpos:point];
+ if (oldIdx > tlen) oldIdx = tlen;
+ if (newIdx > tlen) newIdx = tlen;
+
+ NSRange oldLine = [cachedText lineRangeForRange:
+ NSMakeRange (oldIdx, 0)];
+ NSRange newLine = [cachedText lineRangeForRange:
+ NSMakeRange (newIdx, 0)];
+ if (oldLine.location != newLine.location)
+ granularity = ns_ax_text_selection_granularity_line;
+ else
+ {
+ NSUInteger dist = (newIdx > oldIdx
+ ? newIdx - oldIdx
+ : oldIdx - newIdx);
+ if (dist > 1)
+ granularity = ns_ax_text_selection_granularity_word;
+ else if (dist == 1)
+ granularity = ns_ax_text_selection_granularity_character;
+ }
+ }
+
+ /* Force line semantics for explicit C-n/C-p / Tab / backtab. */
+ if (isCtrlNP)
+ {
+ direction = (ctrlNP > 0
+ ? ns_ax_text_selection_direction_next
+ : ns_ax_text_selection_direction_previous);
+ granularity = ns_ax_text_selection_granularity_line;
+ }
+
+ /* Post notifications for focused and non-focused elements. */
+ if ([self isAccessibilityFocused])
+ [self postFocusedCursorNotification:point
+ direction:direction
+ granularity:granularity
+ markActive:markActive
+ oldMarkActive:oldMarkActive];
+
+ if (![self isAccessibilityFocused] && cachedText)
+ [self postCompletionAnnouncementForBuffer:b point:point];
+ }
+ else
+ {
+ /* Nothing changed. Reset completion cache for focused buffer
+ to avoid stale announcements. */
+ if ([self isAccessibilityFocused])
+ {
+ self.cachedCompletionAnnouncement = nil;
+ self.cachedCompletionOverlayStart = 0;
+ self.cachedCompletionOverlayEnd = 0;
+ self.cachedCompletionPoint = 0;
+ }
+ }
+}
+
+@end
+
+
+@implementation EmacsAccessibilityModeLine
+
+- (NSAccessibilityRole)accessibilityRole
+{
+ return NSAccessibilityStaticTextRole;
+}
+
+- (NSString *)accessibilityLabel
+{
+ if (![NSThread isMainThread])
+ {
+ __block NSString *result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityLabel];
+ });
+ return result;
+ }
+ struct window *w = [self validWindow];
+ if (w && WINDOW_LEAF_P (w))
+ {
+ struct buffer *b = XBUFFER (w->contents);
+ if (b)
+ {
+ Lisp_Object name = BVAR (b, name);
+ if (STRINGP (name))
+ {
+ NSString *bufName = [NSString stringWithLispString:name];
+ return [NSString stringWithFormat:@"Mode Line - %@", bufName];
+ }
+ }
+ }
+ return @"Mode Line";
+}
+
+- (id)accessibilityValue
+{
+ if (![NSThread isMainThread])
+ {
+ __block id result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityValue];
+ });
+ return result;
+ }
+ struct window *w = [self validWindow];
+ if (!w)
+ return @"";
+ return ns_ax_mode_line_text (w);
+}
+
+- (NSRect)accessibilityFrame
+{
+ if (![NSThread isMainThread])
+ {
+ __block NSRect result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityFrame];
+ });
+ return result;
+ }
+ struct window *w = [self validWindow];
+ if (!w || !w->current_matrix)
+ return NSZeroRect;
+
+ /* Find the mode line row and return its screen rect. */
+ struct glyph_matrix *matrix = w->current_matrix;
+ for (int i = 0; i < matrix->nrows; i++)
+ {
+ struct glyph_row *row = matrix->rows + i;
+ if (row->enabled_p && row->mode_line_p)
+ {
+ return [self screenRectFromEmacsX:w->pixel_left
+ y:WINDOW_TO_FRAME_PIXEL_Y (w,
+ MAX (0, row->y))
+ width:w->pixel_width
+ height:row->visible_height];
+ }
+ }
+ return NSZeroRect;
+}
+
+@end
+
#endif /* NS_IMPL_COCOA */
--
2.43.0