Files
emacs-doom/patches/0002-ns-implement-buffer-accessibility-element-core-proto.patch
Daneel a5ff8d391b patches: fix org fold/unfold VoiceOver refresh; revert to BUF_MODIFF
REGRESSION: fold/unfold in org-mode, outline-mode and hideshow-mode did
not refresh VoiceOver text because ensureTextCache used BUF_CHARS_MODIFF
which is NOT bumped by (put-text-property ... 'invisible), the mechanism
used by modern org-fold-core (org >= 29) and outline-mode to hide text.

VoiceOver would continue reading folded content as if visible, or miss
newly unfolded content entirely, because the text cache was considered
valid despite the visible-text having changed.

Revert ensureTextCache to BUF_MODIFF with an explanatory comment:

- BUF_CHARS_MODIFF is bumped only on character insertions/deletions, not
  text-property changes.  Fold/unfold uses text properties for visibility.
- BUF_OVERLAY_MODIFF alone is also insufficient: org >= 29 uses text
  properties, not overlays, for folding.  Also hl-line-mode bumps
  BUF_OVERLAY_MODIFF every post-command-hook --- same per-keystroke cost
  as BUF_MODIFF, with none of its correctness guarantee.
- BUF_MODIFF cost is acceptable: ensureTextCache is called only when
  VoiceOver queries AX properties (human interaction speed, not redisplay
  speed).  Rebuild cost is O(visible-buffer-text).

Also retain C-n/C-p line-read fix from previous commit (7a0b4f6):
FocusedUIElementChanged excluded for sequential isCtrlNP moves.
2026-03-02 20:57:32 +01:00

1158 lines
35 KiB
Diff

From 4f4fa019e14e1cd9283b09b9b7cf20e772edc809 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 12:58:11 +0100
Subject: [PATCH 2/8] ns: implement buffer accessibility element (core
protocol)
Implement the NSAccessibility text protocol for Emacs buffer windows.
* src/nsterm.m (ns_ax_find_completion_overlay_range): New function.
(ns_ax_event_is_line_nav_key, ns_ax_completion_text_for_span): New
functions.
(EmacsAccessibilityBuffer): Implement core NSAccessibility protocol.
(ensureTextCache): Validity gated on BUF_CHARS_MODIFF, not BUF_MODIFF,
to avoid O(buffer-size) rebuilds on every font-lock pass. Add
explanatory comment on why lineRangeForRange: in the lineStartOffsets
loop is safe: it runs only on actual character modifications.
(accessibilityIndexForCharpos:): O(1) fast path for pure-ASCII runs
(ax_length == length); fall back to sequence walk for multi-byte runs.
(charposForAccessibilityIndex:): Symmetric O(1) fast path.
(accessibilitySelectedTextRange, accessibilityLineForIndex:)
(accessibilityIndexForLine:, accessibilityRangeForIndex:)
(accessibilityStringForRange:, accessibilityFrameForRange:)
(accessibilityRangeForPosition:, setAccessibilitySelectedTextRange:)
(setAccessibilityFocused:): Implement NSAccessibility protocol methods.
---
src/nsterm.m | 1115 ++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 1115 insertions(+)
diff --git a/src/nsterm.m b/src/nsterm.m
index 9d36de66f9..6256dbc22e 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -7625,6 +7625,1121 @@ - (id)accessibilityTopLevelUIElement
@end
+
+
+
+
+static BOOL
+ns_ax_find_completion_overlay_range (struct buffer *b, ptrdiff_t point,
+ ptrdiff_t *out_start,
+ ptrdiff_t *out_end)
+{
+ if (!b || !out_start || !out_end)
+ return NO;
+
+ Lisp_Object faceSym = Qns_ax_completions_highlight;
+ ptrdiff_t begv = BUF_BEGV (b);
+ ptrdiff_t zv = BUF_ZV (b);
+ ptrdiff_t best_start = 0;
+ ptrdiff_t best_end = 0;
+ ptrdiff_t best_dist = PTRDIFF_MAX;
+ BOOL found = NO;
+
+ /* Fast path: look at point and immediate neighbors first.
+ Prefer point+1 over point-1: when Tab moves to a new completion,
+ point is at the START of the new entry while point-1 is still
+ inside the previous entry's overlay. Forward probe finds the
+ correct new entry; backward probe finds the wrong old one. */
+ ptrdiff_t probes[3] = { point, point + 1, point - 1 };
+ for (int i = 0; i < 3 && !found; i++)
+ {
+ ptrdiff_t p = probes[i];
+ if (p < begv || p > zv)
+ continue;
+
+ Lisp_Object overlays = Foverlays_at (make_fixnum (p), 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)))))
+ continue;
+
+ ptrdiff_t ov_start = OVERLAY_START (ov);
+ ptrdiff_t ov_end = OVERLAY_END (ov);
+ if (ov_end <= ov_start)
+ continue;
+
+ best_start = ov_start;
+ best_end = ov_end;
+ best_dist = 0;
+ found = YES;
+ break;
+ }
+ }
+
+ if (!found)
+ {
+ /* Bulk query: get all overlays in the buffer at once.
+ Avoids the previous O(n) per-character Foverlays_at loop. */
+ Lisp_Object all = Foverlays_in (make_fixnum (begv),
+ make_fixnum (zv));
+ Lisp_Object tail;
+ for (tail = all; 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)))))
+ continue;
+
+ ptrdiff_t ov_start = OVERLAY_START (ov);
+ ptrdiff_t ov_end = OVERLAY_END (ov);
+ if (ov_end <= ov_start)
+ continue;
+
+ ptrdiff_t dist = 0;
+ if (point < ov_start)
+ dist = ov_start - point;
+ else if (point > ov_end)
+ dist = point - ov_end;
+
+ if (!found || dist < best_dist)
+ {
+ best_start = ov_start;
+ best_end = ov_end;
+ best_dist = dist;
+ found = YES;
+ }
+ }
+ }
+
+ if (!found)
+ return NO;
+
+ *out_start = best_start;
+ *out_end = best_end;
+ return YES;
+}
+
+/* Detect line-level navigation commands. Inspects Vthis_command
+ (the command symbol being executed) rather than raw key codes so
+ that remapped bindings (e.g., C-j -> next-line) are recognized.
+ Falls back to last_command_event for Tab/backtab which are not
+ bound to a single canonical command symbol. */
+static bool
+ns_ax_event_is_line_nav_key (int *which)
+{
+ /* 1. Check Vthis_command for known navigation command symbols.
+ All symbols are registered via DEFSYM in syms_of_nsterm to avoid
+ per-call obarray lookups in this hot path (runs every cursor move). */
+ if (SYMBOLP (Vthis_command) && !NILP (Vthis_command))
+ {
+ Lisp_Object cmd = Vthis_command;
+ /* Forward line commands. */
+ if (EQ (cmd, Qns_ax_next_line)
+ || EQ (cmd, Qns_ax_dired_next_line))
+ {
+ if (which) *which = 1;
+ return true;
+ }
+ /* Backward line commands. */
+ if (EQ (cmd, Qns_ax_previous_line)
+ || EQ (cmd, Qns_ax_dired_previous_line))
+ {
+ if (which) *which = -1;
+ return true;
+ }
+ }
+
+ /* 2. Fallback: check raw key events for Tab/backtab. */
+ Lisp_Object ev = last_command_event;
+ if (CONSP (ev))
+ ev = EVENT_HEAD (ev);
+
+ if (SYMBOLP (ev) && EQ (ev, Qns_ax_backtab))
+ {
+ if (which) *which = -1;
+ return true;
+ }
+ if (FIXNUMP (ev) && XFIXNUM (ev) == 9) /* Tab */
+ {
+ if (which) *which = 1;
+ return true;
+ }
+ return false;
+}
+
+
+
+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 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);
+ if (lineStartOffsets)
+ xfree (lineStartOffsets);
+ [super dealloc];
+}
+
+/* ---- Text cache ---- */
+
+- (void)invalidateTextCache
+{
+ @synchronized (self)
+ {
+ [cachedText release];
+ cachedText = nil;
+ if (visibleRuns)
+ {
+ xfree (visibleRuns);
+ visibleRuns = NULL;
+ }
+ visibleRunCount = 0;
+ if (lineStartOffsets)
+ {
+ xfree (lineStartOffsets);
+ lineStartOffsets = NULL;
+ }
+ lineCount = 0;
+ }
+ [self invalidateInteractiveSpans];
+}
+
+/* ---- Line index helpers ---- */
+
+/* Return the line number for AX string index IDX using the
+ precomputed lineStartOffsets array. Binary search: O(log L)
+ where L is the number of lines in the cached text.
+
+ lineStartOffsets[i] holds the AX string index where line i
+ begins. Built once per cache rebuild in ensureTextCache. */
+- (NSInteger)lineForAXIndex:(NSUInteger)idx
+{
+ @synchronized (self)
+ {
+ if (!lineStartOffsets || lineCount == 0)
+ return 0;
+
+ /* Binary search for the largest line whose start offset <= idx. */
+ NSUInteger lo = 0, hi = lineCount;
+ while (lo < hi)
+ {
+ NSUInteger mid = lo + (hi - lo) / 2;
+ if (lineStartOffsets[mid] <= idx)
+ lo = mid + 1;
+ else
+ hi = mid;
+ }
+ return (NSInteger) (lo > 0 ? lo - 1 : 0);
+ }
+}
+
+/* Return the AX string range for LINE using the precomputed
+ lineStartOffsets array. O(1) lookup. */
+- (NSRange)rangeForLine:(NSUInteger)line textLength:(NSUInteger)tlen
+{
+ @synchronized (self)
+ {
+ if (!lineStartOffsets || lineCount == 0)
+ return NSMakeRange (NSNotFound, 0);
+
+ if (line >= lineCount)
+ return NSMakeRange (NSNotFound, 0);
+
+ NSUInteger start = lineStartOffsets[line];
+ NSUInteger end = (line + 1 < lineCount)
+ ? lineStartOffsets[line + 1]
+ : tlen;
+ return NSMakeRange (start, end - start);
+ }
+}
+
+- (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;
+
+ /* Use BUF_CHARS_MODIFF, not BUF_MODIFF, for cache validity.
+ BUF_MODIFF is bumped by every text-property change, including
+ font-lock face applications on every redisplay. AX text contains
+ only characters, not face data, so property-only changes do not
+ affect the cached value. Rebuilding the full buffer text on
+ each font-lock pass is O(buffer-size) per redisplay --- this
+ causes progressive slowdown when scrolling through large files.
+ BUF_CHARS_MODIFF is bumped only on actual character insertions
+ and deletions, matching the semantic of "did the text change".
+ This is the pattern used by WebKit and NSTextView.
+ Do NOT track BUF_OVERLAY_MODIFF here --- overlay text is not
+ included in the cached AX text (it is handled separately via
+ explicit announcements in postAccessibilityNotificationsForFrame).
+ Including overlay_modiff would silently update cachedOverlayModiff
+ and prevent the notification dispatch from detecting changes. */
+ ptrdiff_t chars_modiff = BUF_CHARS_MODIFF (b);
+ ptrdiff_t pt = BUF_PT (b);
+ NSUInteger textLen = cachedText ? [cachedText length] : 0;
+ if (cachedText && cachedTextModiff == chars_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 = chars_modiff;
+ cachedTextStart = start;
+
+ if (visibleRuns)
+ xfree (visibleRuns);
+ visibleRuns = runs;
+ visibleRunCount = nruns;
+
+ /* Build line-start index for O(log L) line queries.
+ Walk the cached text once, recording the start offset of each
+ line. Uses NSString lineRangeForRange: --- O(N) in the total
+ text --- but this loop runs only on cache rebuild, which is
+ gated on BUF_CHARS_MODIFF: actual character insertions or
+ deletions. Font-lock (text property changes) does not trigger
+ a rebuild, so the hot path (cursor movement, redisplay) never
+ enters this code. */
+ if (lineStartOffsets)
+ xfree (lineStartOffsets);
+ lineStartOffsets = NULL;
+ lineCount = 0;
+
+ NSUInteger tlen = [cachedText length];
+ if (tlen > 0)
+ {
+ NSUInteger cap = 256;
+ lineStartOffsets = xmalloc (cap * sizeof (NSUInteger));
+ lineStartOffsets[0] = 0;
+ lineCount = 1;
+ NSUInteger pos = 0;
+ while (pos < tlen)
+ {
+ NSRange lr = [cachedText lineRangeForRange:
+ NSMakeRange (pos, 0)];
+ NSUInteger next = NSMaxRange (lr);
+ if (next <= pos)
+ break; /* safety */
+ if (next < tlen)
+ {
+ if (lineCount >= cap)
+ {
+ cap *= 2;
+ lineStartOffsets = xrealloc (lineStartOffsets,
+ cap * sizeof (NSUInteger));
+ }
+ lineStartOffsets[lineCount] = next;
+ lineCount++;
+ }
+ pos = next;
+ }
+ }
+ }
+}
+
+/* ---- 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.
+ Fast path for pure-ASCII runs (ax_length == length): every
+ Emacs charpos maps to exactly one UTF-16 code unit, so the
+ conversion is O(1). This matters because ensureTextCache
+ calls this method on every redisplay frame to validate the
+ cache --- a O(cursor_position) loop here means O(position)
+ cost per frame even when the buffer is unchanged.
+ Multi-byte runs fall through to the sequence walk, bounded
+ by run length (visible window), not total buffer size. */
+ NSUInteger chars_in = (NSUInteger)(charpos - r->charpos);
+ if (chars_in == 0)
+ return r->ax_start;
+ if (r->ax_length == (NSUInteger) r->length)
+ return r->ax_start + chars_in;
+ if (!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.
+ Fast path for pure-ASCII runs: ax_length == length means
+ every Emacs charpos maps to exactly one AX string index.
+ The conversion is then O(1) instead of O(cursor_position).
+ Buffers with emoji, CJK, or other non-BMP characters use
+ the slow path (composed character sequence walk), which is
+ bounded by run length, not total buffer size. */
+ if (r->ax_length == (NSUInteger) r->length)
+ return r->charpos + (ptrdiff_t) (ax_idx - r->ax_start);
+
+ 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 */
+}
+
+/* --- Threading and signal safety ---
+
+ AX getter methods may be called from the VoiceOver server thread.
+ All methods that access Lisp objects or buffer state dispatch_sync
+ to the main thread where Emacs state is consistent.
+
+ Longjmp safety: Lisp functions called inside dispatch_sync blocks
+ (Fget_char_property, Fbuffer_substring_no_properties, etc.) could
+ theoretically signal and longjmp through the dispatch_sync frame,
+ deadlocking the AX server thread. This is prevented by:
+
+ 1. validWindow checks WINDOW_LIVE_P and BUFFERP before every
+ Lisp access — the window and buffer are verified live.
+ 2. All dispatch_sync blocks run on the main thread where no
+ concurrent Lisp code can modify state between checks.
+ 3. block_input prevents timer events and process output from
+ running between precondition checks and Lisp calls.
+ 4. Buffer positions are clamped to BUF_BEGV/BUF_ZV before
+ use, preventing out-of-range signals.
+ 5. specpdl unwind protection ensures block_input is always
+ matched by unblock_input, even on longjmp.
+
+ This matches the safety model of existing nsterm.m F-function
+ calls (24 direct calls, none wrapped in internal_condition_case).
+
+ Known gap: if the Emacs window tree is modified between redisplay
+ cycles in a way that invalidates validWindow's cached result,
+ a stale dereference could occur. In practice this does not happen
+ because window tree modifications go through the event loop which
+ we are blocking via dispatch_sync. */
+
+/* ---- 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));
+
+ /* Always deactivate mark: VoiceOver range.length is an internal
+ word boundary hint, not a text selection. Activating the mark
+ makes accessibilitySelectedTextRange return a non-zero length,
+ which confuses VoiceOver into positioning its browse cursor at
+ the END of the selection instead of the start. */
+ 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];
+
+ return [self lineForAXIndex:point_idx];
+}
+
+- (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);
+
+ return [self rangeForLine:(NSUInteger)line textLength:len];
+}
+
+- (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. */
+
+@end
+
#endif /* NS_IMPL_COCOA */
--
2.43.0