From a49c6b5a9601fe11a6a03292e8b4d685a0ce50af Mon Sep 17 00:00:00 2001 From: Martin Sukany 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 : 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. + + 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