From f29ed7e4e305a5f8857e72d3762d43c0901be718 Mon Sep 17 00:00:00 2001 From: Martin Sukany 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 | 1127 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1123 insertions(+) diff --git a/src/nsterm.m b/src/nsterm.m index 852e7f9..3e1ac74 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -7631,6 +7631,1129 @@ - (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)); + + /* 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]; + + 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