From 17a100d99a31e0fae9b641c7ce163efd9bf5945b Mon Sep 17 00:00:00 2001 From: Martin Sukany Date: Fri, 27 Feb 2026 15:09:15 +0100 Subject: [PATCH] ns: implement VoiceOver accessibility for macOS Add comprehensive macOS VoiceOver accessibility support to the NS (Cocoa) port. Before this patch, Emacs exposed only a minimal, largely broken accessibility interface to macOS assistive technology clients. New types and classes: ns_ax_visible_run: maps buffer character positions to UTF-16 accessibility string indices, skipping invisible text. EmacsAccessibilityElement: base class for virtual AX elements, stores Lisp_Object lispWindow (GC-safe) and EmacsView reference. EmacsAccessibilityBuffer : AXTextArea element per visible Emacs window; full text protocol (value, selectedTextRange, line/index conversions, frameForRange, rangeForPosition), text cache with visible-run mapping, hybrid SelectedTextChanged and AnnouncementRequested notifications, completion announcements for *Completions* buffer. EmacsAccessibilityModeLine: AXStaticText per mode line. EmacsAccessibilityInteractiveSpan: lightweight AX child elements for Tab-navigable interactive spans (buttons, links, checkboxes, completion candidates, Org-mode links, keymap overlays). EmacsAXSpanType: enum for span classification. New functions: ns_ax_buffer_text: build accessibility string with visible-run mapping, using TEXT_PROP_MEANS_INVISIBLE and Fbuffer_substring_no_properties (handles buffer gap correctly). ns_ax_mode_line_text: extract mode line text from glyph matrix. ns_ax_frame_for_range: screen rect for a character range via glyph matrix lookup. ns_ax_event_is_line_nav_key: detect C-n/C-p/Tab/backtab for forced line-granularity announcements. ns_ax_scan_interactive_spans: scan visible range for interactive text properties (widget, button, follow-link, org-link, completion--string, keymap overlay). ns_ax_completion_string_from_prop: extract completion candidate from completion--string property (handles both string and cons). ns_ax_find_completion_overlay_range: locate nearest completions-highlight overlay for completion announcements. ns_ax_completion_text_for_span: extract announcement text for a completion overlay span. EmacsView extensions: rebuildAccessibilityTree, invalidateAccessibilityTree, postAccessibilityUpdates (called from ns_update_end after every redisplay cycle), accessibilityBoundsForRange (delegates to focused buffer element with cursor-rect fallback for Zoom), accessibilityFrameForRange, accessibilityStringForRange, accessibilityParameterizedAttributeNames, accessibilityAttributeValue:forParameter: (legacy API for Zoom). ns_draw_phys_cursor: stores cursor rect for Zoom, calls UAZoomChangeFocus with correct CG coordinate-space transform. DEFSYM additions in syms_of_nsterm: Qwidget, Qbutton, Qfollow_link, Qorg_link, Qcompletion_list_mode, Qcompletion__string, Qcompletion, Qcompletions_highlight, Qbacktab. Threading model: all Lisp calls on main thread; AX getters use dispatch_sync to main; index mapping methods are thread-safe (no Lisp calls, read only immutable NSString and scalar cache). * src/nsterm.m: New accessibility implementation. * src/nsterm.h: New class declarations and EmacsView ivar extensions. * etc/NEWS: Document VoiceOver accessibility support. --- etc/NEWS | 11 + src/nsterm.h | 108 ++ src/nsterm.m | 2870 +++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 2841 insertions(+), 148 deletions(-) diff --git a/etc/NEWS b/etc/NEWS index 7367e3cc..0e4480ad 100644 --- a/etc/NEWS +++ b/etc/NEWS @@ -4374,6 +4374,17 @@ allowing Emacs users access to speech recognition utilities. Note: Accepting this permission allows the use of system APIs, which may send user data to Apple's speech recognition servers. +--- +** VoiceOver accessibility support on macOS. +Emacs now exposes buffer content, cursor position, and interactive +elements to the macOS accessibility subsystem (VoiceOver). This +includes AXBoundsForRange for macOS Zoom cursor tracking, line and +word navigation announcements, Tab-navigable interactive spans +(buttons, links, completion candidates), and completion announcements +for the *Completions* buffer. The implementation uses a virtual +accessibility tree with per-window elements, hybrid SelectedTextChanged +and AnnouncementRequested notifications, and thread-safe text caching. + --- ** Re-introduced dictation, lost in Emacs v30 (macOS). We lost macOS dictation in v30 when migrating to NSTextInputClient. diff --git a/src/nsterm.h b/src/nsterm.h index 7c1ee4cf..542e7d59 100644 --- a/src/nsterm.h +++ b/src/nsterm.h @@ -453,6 +453,100 @@ enum ns_return_frame_mode @end +/* ========================================================================== + + Accessibility virtual elements (macOS / Cocoa only) + + ========================================================================== */ + +#ifdef NS_IMPL_COCOA +@class EmacsView; + +/* Base class for virtual accessibility elements attached to EmacsView. */ +@interface EmacsAccessibilityElement : NSAccessibilityElement +@property (nonatomic, unsafe_unretained) EmacsView *emacsView; +/* Lisp window object — safe across GC cycles. NULL_LISP when unset. */ +@property (nonatomic, assign) Lisp_Object lispWindow; +- (struct window *)validWindow; /* Returns live window or NULL. */ +- (NSRect)screenRectFromEmacsX:(int)x y:(int)y width:(int)w height:(int)h; +@end + +/* A visible run: maps a contiguous range of accessibility indices + to a contiguous range of buffer character positions. Invisible + text is skipped, so ax_start values are consecutive across runs + while charpos values may have gaps. */ +typedef struct ns_ax_visible_run +{ + ptrdiff_t charpos; /* Buffer charpos where this visible run starts. */ + ptrdiff_t length; /* Number of visible Emacs characters in this run. */ + NSUInteger ax_start; /* Starting index in the accessibility string. */ + NSUInteger ax_length; /* Length in accessibility string (UTF-16 units). */ +} ns_ax_visible_run; + +/* Virtual AXTextArea element — one per visible Emacs window (buffer). */ +@interface EmacsAccessibilityBuffer : EmacsAccessibilityElement +{ + ns_ax_visible_run *visibleRuns; + NSUInteger visibleRunCount; + NSMutableArray *cachedInteractiveSpans; + BOOL interactiveSpansDirty; +} +@property (nonatomic, retain) NSString *cachedText; +@property (nonatomic, assign) ptrdiff_t cachedTextModiff; +@property (nonatomic, assign) ptrdiff_t cachedTextStart; +@property (nonatomic, assign) ptrdiff_t cachedModiff; +@property (nonatomic, assign) ptrdiff_t cachedPoint; +@property (nonatomic, assign) BOOL cachedMarkActive; +@property (nonatomic, copy) NSString *cachedCompletionAnnouncement; +@property (nonatomic, assign) ptrdiff_t cachedCompletionOverlayStart; +@property (nonatomic, assign) ptrdiff_t cachedCompletionOverlayEnd; +@property (nonatomic, assign) ptrdiff_t cachedCompletionPoint; +- (void)invalidateTextCache; +- (void)postAccessibilityNotificationsForFrame:(struct frame *)f; +- (ptrdiff_t)charposForAccessibilityIndex:(NSUInteger)ax_idx; +- (NSUInteger)accessibilityIndexForCharpos:(ptrdiff_t)charpos; +@end + +/* Virtual AXStaticText element — one per mode line. */ +@interface EmacsAccessibilityModeLine : EmacsAccessibilityElement +@end + +/* Span types for interactive AX child elements. */ +typedef NS_ENUM (NSInteger, EmacsAXSpanType) +{ + EmacsAXSpanTypeButton = 0, + EmacsAXSpanTypeLink = 1, + EmacsAXSpanTypeCheckBox = 2, + EmacsAXSpanTypeTextField = 3, + EmacsAXSpanTypePopUpButton = 4, + EmacsAXSpanTypeCompletionItem = 5, + EmacsAXSpanTypeWidget = 6, +}; + +/* A lightweight AX element representing one interactive text span + (button, link, checkbox, completion candidate, etc.) within a buffer + window. Exposed as AX child of EmacsAccessibilityBuffer so VoiceOver + Tab navigation can reach individual interactive elements. */ +@interface EmacsAccessibilityInteractiveSpan : EmacsAccessibilityElement + +@property (nonatomic, assign) ptrdiff_t charposStart; +@property (nonatomic, assign) ptrdiff_t charposEnd; +@property (nonatomic, assign) EmacsAXSpanType spanType; +@property (nonatomic, copy) NSString *spanLabel; +@property (nonatomic, copy) NSString *spanValue; +@property (nonatomic, unsafe_unretained) EmacsAccessibilityBuffer *parentBuffer; + +- (NSAccessibilityRole) accessibilityRole; +- (NSString *) accessibilityLabel; +- (NSRect) accessibilityFrame; +- (BOOL) isAccessibilityElement; +- (BOOL) isAccessibilityFocused; +- (void) setAccessibilityFocused: (BOOL) focused; + +@end +#endif /* NS_IMPL_COCOA */ + + /* ========================================================================== The main Emacs view @@ -471,6 +565,13 @@ enum ns_return_frame_mode #ifdef NS_IMPL_COCOA char *old_title; BOOL maximizing_resize; + NSMutableArray *accessibilityElements; + Lisp_Object lastSelectedWindow; + Lisp_Object lastRootWindow; + BOOL accessibilityTreeValid; + BOOL accessibilityUpdating; + @public /* Accessed by ns_draw_phys_cursor (C function). */ + NSRect lastAccessibilityCursorRect; #endif BOOL font_panel_active; NSFont *font_panel_result; @@ -528,6 +629,13 @@ enum ns_return_frame_mode - (void)windowWillExitFullScreen; - (void)windowDidExitFullScreen; - (void)windowDidBecomeKey; + +#ifdef NS_IMPL_COCOA +/* Accessibility support. */ +- (void)rebuildAccessibilityTree; +- (void)invalidateAccessibilityTree; +- (void)postAccessibilityUpdates; +#endif @end diff --git a/src/nsterm.m b/src/nsterm.m index 932d209f..ea2de6f2 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -46,6 +46,7 @@ Updated by Christian Limpach (chris@nice.ch) #include "blockinput.h" #include "sysselect.h" #include "nsterm.h" +#include "intervals.h" /* TEXT_PROP_MEANS_INVISIBLE */ #include "systime.h" #include "character.h" #include "xwidget.h" @@ -1104,6 +1105,11 @@ static NSRect constrain_frame_rect(NSRect frameRect, bool isFullscreen) unblock_input (); ns_updating_frame = NULL; + +#ifdef NS_IMPL_COCOA + /* Post accessibility notifications after each redisplay cycle. */ + [view postAccessibilityUpdates]; +#endif } static void @@ -3232,6 +3238,42 @@ Note that CURSOR_WIDTH is meaningful only for (h)bar cursors. /* Prevent the cursor from being drawn outside the text area. */ r = NSIntersectionRect (r, ns_row_rect (w, glyph_row, TEXT_AREA)); +#ifdef NS_IMPL_COCOA + /* Accessibility: store cursor rect for Zoom and bounds queries. + VoiceOver notifications are handled solely by + postAccessibilityUpdates (called from ns_update_end) + to avoid duplicate notifications and mid-redisplay fragility. */ + { + EmacsView *view = FRAME_NS_VIEW (f); + if (view && on_p && active_p) + { + view->lastAccessibilityCursorRect = r; + + /* Tell macOS Zoom where the cursor is. UAZoomChangeFocus() + expects top-left origin (CG coordinate space). + These APIs are available since macOS 10.4 (Universal Access + framework, linked via ApplicationServices umbrella). */ +#if defined (MAC_OS_X_VERSION_MIN_REQUIRED) \ + && MAC_OS_X_VERSION_MIN_REQUIRED >= 101000 + if (UAZoomEnabled ()) + { + NSRect windowRect = [view convertRect:r toView:nil]; + NSRect screenRect = [[view window] convertRectToScreen:windowRect]; + CGRect cgRect = NSRectToCGRect (screenRect); + + CGFloat primaryH + = [[[NSScreen screens] firstObject] frame].size.height; + cgRect.origin.y + = primaryH - cgRect.origin.y - cgRect.size.height; + + UAZoomChangeFocus (&cgRect, &cgRect, + kUAZoomFocusTypeInsertionPoint); + } +#endif /* MAC_OS_X_VERSION_MIN_REQUIRED >= 101000 */ + } + } +#endif + ns_focus (f, NULL, 0); NSGraphicsContext *ctx = [NSGraphicsContext currentContext]; @@ -6849,218 +6891,2386 @@ - (BOOL)fulfillService: (NSString *)name withArg: (NSString *)arg /* ========================================================================== - EmacsView implementation + Accessibility virtual elements (macOS / Cocoa only) ========================================================================== */ +#ifdef NS_IMPL_COCOA -@implementation EmacsView +/* ---- Helper: extract buffer text for accessibility ---- */ -- (void)windowDidEndLiveResize:(NSNotification *)notification -{ - [self updateFramePosition]; -} +/* Maximum characters exposed via accessibilityValue. */ +/* Cap accessibility text at 100,000 UTF-16 units (~200 KB). VoiceOver + performance degrades beyond this; buffers larger than ~50,000 lines + are truncated for accessibility purposes. */ +#define NS_AX_TEXT_CAP 100000 -/* Needed to inform when window closed from lisp. */ -- (void) setWindowClosing: (BOOL)closing -{ - NSTRACE ("[EmacsView setWindowClosing:%d]", closing); +/* Build accessibility text for window W, skipping invisible text. + Populates *OUT_START with the buffer start charpos. + Populates *OUT_RUNS with an array of visible runs and *OUT_NRUNS + with the count. Caller must free *OUT_RUNS with xfree(). */ - windowClosing = closing; -} +static NSString * +ns_ax_buffer_text (struct window *w, ptrdiff_t *out_start, + ns_ax_visible_run **out_runs, NSUInteger *out_nruns) +{ + *out_runs = NULL; + *out_nruns = 0; + if (!w || !WINDOW_LEAF_P (w)) + { + *out_start = 0; + return @""; + } -- (void)dealloc -{ - NSTRACE ("[EmacsView dealloc]"); + struct buffer *b = XBUFFER (w->contents); + if (!b) + { + *out_start = 0; + return @""; + } - /* Clear the view resize notification. */ - [[NSNotificationCenter defaultCenter] - removeObserver:self - name:NSViewFrameDidChangeNotification - object:nil]; + ptrdiff_t begv = BUF_BEGV (b); + ptrdiff_t zv = BUF_ZV (b); - if (fs_state == FULLSCREEN_BOTH) - [nonfs_window release]; + *out_start = begv; -#if defined (NS_IMPL_COCOA) && MAC_OS_X_VERSION_MIN_REQUIRED >= 101400 - /* Release layer and menu */ - EmacsLayer *layer = (EmacsLayer *)[self layer]; - [layer release]; -#endif + if (zv <= begv) + return @""; - [[self menu] release]; - [super dealloc]; -} + specpdl_ref count = SPECPDL_INDEX (); + record_unwind_current_buffer (); + if (b != current_buffer) + set_buffer_internal_1 (b); + /* First pass: count visible runs to allocate the mapping array. */ + NSUInteger run_capacity = 64; + ns_ax_visible_run *runs = xmalloc (run_capacity + * sizeof (ns_ax_visible_run)); + NSUInteger nruns = 0; + NSUInteger ax_offset = 0; -/* Called on font panel selection. */ -- (void) changeFont: (id) sender -{ - struct font *font = FRAME_OUTPUT_DATA (emacsframe)->font; - NSFont *nsfont; + NSMutableString *result = [NSMutableString string]; + ptrdiff_t pos = begv; -#ifdef NS_IMPL_GNUSTEP - nsfont = ((struct nsfont_info *) font)->nsfont; -#else - nsfont = (NSFont *) macfont_get_nsctfont (font); -#endif + while (pos < zv) + { + /* Check invisible property (text properties + overlays). + Use TEXT_PROP_MEANS_INVISIBLE which respects buffer-invisibility-spec, + matching the logic in xdisp.c. This correctly handles org-mode, + outline-mode, hideshow and any mode using spec-controlled + invisibility (not just `invisible t'). */ + Lisp_Object invis = Fget_char_property (make_fixnum (pos), + Qinvisible, Qnil); + if (TEXT_PROP_MEANS_INVISIBLE (invis)) + { + /* Skip to the next position where invisible changes. */ + Lisp_Object next = Fnext_single_char_property_change ( + make_fixnum (pos), Qinvisible, Qnil, make_fixnum (zv)); + pos = FIXNUMP (next) ? XFIXNUM (next) : zv; + continue; + } - if (!font_panel_active) - return; + /* Find end of this visible run: where invisible property changes. */ + Lisp_Object next = Fnext_single_char_property_change ( + make_fixnum (pos), Qinvisible, Qnil, make_fixnum (zv)); + ptrdiff_t run_end = FIXNUMP (next) ? XFIXNUM (next) : zv; - if (font_panel_result) - [font_panel_result release]; + /* Cap total text at NS_AX_TEXT_CAP. */ + ptrdiff_t run_len = run_end - pos; + if (ax_offset + (NSUInteger) run_len > NS_AX_TEXT_CAP) + run_len = (ptrdiff_t) (NS_AX_TEXT_CAP - ax_offset); + if (run_len <= 0) + break; + run_end = pos + run_len; + + /* Extract this visible run's text. Use + Fbuffer_substring_no_properties which correctly handles the + buffer gap — raw BUF_BYTE_ADDRESS reads across the gap would + include garbage bytes when the run spans the gap position. */ + Lisp_Object lstr = Fbuffer_substring_no_properties ( + make_fixnum (pos), make_fixnum (run_end)); + NSString *nsstr = [NSString stringWithLispString:lstr]; + NSUInteger ns_len = [nsstr length]; + [result appendString:nsstr]; + + /* Record this visible run in the mapping. */ + if (nruns >= run_capacity) + { + run_capacity *= 2; + runs = xrealloc (runs, run_capacity + * sizeof (ns_ax_visible_run)); + } + runs[nruns].charpos = pos; + runs[nruns].length = run_len; + runs[nruns].ax_start = ax_offset; + runs[nruns].ax_length = ns_len; + nruns++; - font_panel_result = (NSFont *) [sender convertFont: nsfont]; + ax_offset += ns_len; + pos = run_end; + } - if (font_panel_result) - [font_panel_result retain]; + unbind_to (count, Qnil); -#ifndef NS_IMPL_COCOA - font_panel_active = NO; - [NSApp stop: self]; -#endif + *out_runs = runs; + *out_nruns = nruns; + return result; } -#ifdef NS_IMPL_COCOA -- (void) noteUserSelectedFont + +/* ---- Helper: extract mode line text from glyph rows ---- */ + +static NSString * +ns_ax_mode_line_text (struct window *w) { - font_panel_active = NO; + if (!w || !w->current_matrix) + return @""; - /* If no font was previously selected, use the currently selected - font. */ + struct glyph_matrix *matrix = w->current_matrix; + NSMutableString *text = [NSMutableString string]; - if (!font_panel_result && FRAME_FONT (emacsframe)) + for (int i = 0; i < matrix->nrows; i++) { - font_panel_result - = macfont_get_nsctfont (FRAME_FONT (emacsframe)); + struct glyph_row *row = matrix->rows + i; + if (!row->enabled_p || !row->mode_line_p) + continue; - if (font_panel_result) - [font_panel_result retain]; + struct glyph *g = row->glyphs[TEXT_AREA]; + struct glyph *end = g + row->used[TEXT_AREA]; + for (; g < end; g++) + { + if (g->type == CHAR_GLYPH && g->u.ch >= 32) + { + unichar uch = (unichar) g->u.ch; + [text appendString:[NSString stringWithCharacters:&uch + length:1]]; + } + } } - - [NSApp stop: self]; + return text; } -- (void) noteUserCancelledSelection + +/* ---- Helper: screen rect for a character range via glyph matrix ---- */ + +static NSRect +ns_ax_frame_for_range (struct window *w, EmacsView *view, + ptrdiff_t charpos_start, + ptrdiff_t charpos_len) { - font_panel_active = NO; + if (!w || !w->current_matrix || !view) + return NSZeroRect; - if (font_panel_result) - [font_panel_result release]; - font_panel_result = nil; + /* charpos_start and charpos_len are already in buffer charpos + space — the caller maps AX string indices through + charposForAccessibilityIndex which handles invisible text. */ + ptrdiff_t cp_start = charpos_start; + ptrdiff_t cp_end = cp_start + charpos_len; - [NSApp stop: self]; + struct glyph_matrix *matrix = w->current_matrix; + NSRect result = NSZeroRect; + BOOL found = NO; + + for (int i = 0; i < matrix->nrows; i++) + { + struct glyph_row *row = matrix->rows + i; + if (!row->enabled_p || row->mode_line_p) + continue; + if (!row->displays_text_p && !row->ends_at_zv_p) + continue; + + ptrdiff_t row_start = MATRIX_ROW_START_CHARPOS (row); + ptrdiff_t row_end = MATRIX_ROW_END_CHARPOS (row); + + if (row_start < cp_end && row_end > cp_start) + { + int window_x, window_y, window_width; + window_box (w, TEXT_AREA, &window_x, &window_y, + &window_width, 0); + + NSRect rowRect; + rowRect.origin.x = window_x; + rowRect.origin.y = WINDOW_TO_FRAME_PIXEL_Y (w, MAX (0, row->y)); + rowRect.origin.y = MAX (rowRect.origin.y, window_y); + rowRect.size.width = window_width; + rowRect.size.height = row->height; + + if (!found) + { + result = rowRect; + found = YES; + } + else + result = NSUnionRect (result, rowRect); + } + } + + if (!found) + return NSZeroRect; + + /* Clip result to text area bounds. */ + { + int text_area_x, text_area_y, text_area_w, text_area_h; + window_box (w, TEXT_AREA, &text_area_x, &text_area_y, + &text_area_w, &text_area_h); + CGFloat max_y = WINDOW_TO_FRAME_PIXEL_Y (w, text_area_y + text_area_h); + if (NSMaxY (result) > max_y) + result.size.height = max_y - result.origin.y; + } + + /* Convert from EmacsView (flipped) coords to screen coords. */ + NSRect winRect = [view convertRect:result toView:nil]; + return [[view window] convertRectToScreen:winRect]; } -#endif -- (Lisp_Object) showFontPanel +/* AX enum numeric compatibility for NSAccessibility notifications. + Values match WebKit AXObjectCacheMac fallback enums + (AXTextStateChangeType / AXTextEditType / AXTextSelectionDirection / + AXTextSelectionGranularity). */ +enum { + ns_ax_text_state_change_unknown = 0, + ns_ax_text_state_change_edit = 1, + ns_ax_text_state_change_selection_move = 2, + + ns_ax_text_edit_type_typing = 3, + + ns_ax_text_selection_direction_unknown = 0, + ns_ax_text_selection_direction_previous = 3, + ns_ax_text_selection_direction_next = 4, + ns_ax_text_selection_direction_discontiguous = 5, + + ns_ax_text_selection_granularity_unknown = 0, + ns_ax_text_selection_granularity_character = 1, + ns_ax_text_selection_granularity_word = 2, + ns_ax_text_selection_granularity_line = 3, +}; + +static BOOL +ns_ax_find_completion_overlay_range (struct buffer *b, ptrdiff_t point, + ptrdiff_t *out_start, + ptrdiff_t *out_end) { - id fm = [NSFontManager sharedFontManager]; - struct font *font = FRAME_OUTPUT_DATA (emacsframe)->font; - NSFont *nsfont, *result; - struct timespec timeout; -#ifdef NS_IMPL_COCOA - NSView *buttons; - BOOL canceled; -#endif + if (!b || !out_start || !out_end) + return NO; -#ifdef NS_IMPL_GNUSTEP - nsfont = ((struct nsfont_info *) font)->nsfont; -#else - nsfont = (NSFont *) macfont_get_nsctfont (font); -#endif + Lisp_Object faceSym = Qcompletions_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; -#ifdef NS_IMPL_COCOA - buttons - = ns_create_font_panel_buttons (self, - @selector (noteUserSelectedFont), - @selector (noteUserCancelledSelection)); - [[fm fontPanel: YES] setAccessoryView: buttons]; - [buttons release]; -#endif + 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; + } + } - [fm setSelectedFont: nsfont isMultiple: NO]; - [fm orderFrontFontPanel: NSApp]; + 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; + } + } + } - font_panel_active = YES; - timeout = make_timespec (0, 100000000); + if (!found) + return NO; - block_input (); - while (font_panel_active -#ifdef NS_IMPL_COCOA - && (canceled = [[fm fontPanel: YES] isVisible]) -#else - && [[fm fontPanel: YES] isVisible] -#endif - ) - ns_select_1 (0, NULL, NULL, NULL, &timeout, NULL, YES); - unblock_input (); + *out_start = best_start; + *out_end = best_end; + return YES; +} - if (font_panel_result) - [font_panel_result autorelease]; +static bool +ns_ax_event_is_line_nav_key (int *which) +{ + Lisp_Object ev = last_command_event; + if (CONSP (ev)) + ev = EVENT_HEAD (ev); -#ifdef NS_IMPL_COCOA - if (!canceled) - font_panel_result = nil; -#endif + if (!FIXNUMP (ev)) + { + /* Handle symbol events: backtab (S-Tab = previous completion). */ + if (SYMBOLP (ev) && !NILP (ev)) + { + if (EQ (ev, Qbacktab)) + { + if (which) + *which = -1; + return true; + } + } + return false; + } - result = font_panel_result; - font_panel_result = nil; + EMACS_INT c = XFIXNUM (ev); + if (c == 14) /* C-n */ + { + if (which) + *which = 1; + return true; + } + if (c == 16) /* C-p */ + { + if (which) + *which = -1; + return true; + } + if (c == 9) /* Tab — next completion/link */ + { + if (which) + *which = 1; + return true; + } + return false; +} - [[fm fontPanel: YES] setIsVisible: NO]; - font_panel_active = NO; +/* =================================================================== + EmacsAccessibilityInteractiveSpan — helpers and implementation + =================================================================== */ - if (result) - return ns_font_desc_to_font_spec ([result fontDescriptor], - result); +/* Extract announcement string from completion--string property value. + The property can be a plain Lisp string (simple completion) or + a list ("candidate" "annotation") for annotated completions. + Returns nil on failure. */ +static NSString * +ns_ax_completion_string_from_prop (Lisp_Object cstr) +{ + if (STRINGP (cstr)) + return [NSString stringWithLispString: cstr]; + if (CONSP (cstr) && STRINGP (XCAR (cstr))) + return [NSString stringWithLispString: XCAR (cstr)]; + return nil; +} - return Qnil; +/* Return the Emacs buffer Lisp object for window W, or Qnil. */ +static Lisp_Object +ns_ax_window_buffer_object (struct window *w) +{ + if (!w) + return Qnil; + if (!BUFFERP (w->contents)) + return Qnil; + return w->contents; } -- (BOOL)acceptsFirstResponder +/* Compute visible-end charpos for window W. + Emacs stores it as BUF_Z - window_end_pos. */ +static ptrdiff_t +ns_ax_window_end_charpos (struct window *w, struct buffer *b) { - NSTRACE ("[EmacsView acceptsFirstResponder]"); - return YES; + return BUF_Z (b) - w->window_end_pos; } -/* Tell NS we want to accept clicks that activate the window */ -- (BOOL)acceptsFirstMouse:(NSEvent *)theEvent +/* Fetch text property PROP at charpos POS in BUF_OBJ. */ +static Lisp_Object +ns_ax_text_prop_at (ptrdiff_t pos, Lisp_Object prop, Lisp_Object buf_obj) { - NSTRACE_MSG ("First mouse event: type=%ld, clickCount=%ld", - [theEvent type], [theEvent clickCount]); - return ns_click_through; + Lisp_Object plist = Ftext_properties_at (make_fixnum (pos), buf_obj); + return Fplist_get (plist, prop, Qnil); } -- (void)resetCursorRects + +/* Next charpos where PROP changes, capped at LIMIT. */ +static ptrdiff_t +ns_ax_next_prop_change (ptrdiff_t pos, Lisp_Object prop, + Lisp_Object buf_obj, ptrdiff_t limit) { - NSRect visible = [self visibleRect]; - NSCursor *currentCursor = FRAME_POINTER_TYPE (emacsframe); - NSTRACE ("[EmacsView resetCursorRects]"); + Lisp_Object result + = Fnext_single_property_change (make_fixnum (pos), prop, + buf_obj, make_fixnum (limit)); + return FIXNUMP (result) ? XFIXNUM (result) : limit; +} - if (currentCursor == nil) - currentCursor = [NSCursor arrowCursor]; +/* Build label for span [START, END) in BUF_OBJ. + Priority: completion--string → buffer text → help-echo. */ +static NSString * +ns_ax_get_span_label (ptrdiff_t start, ptrdiff_t end, + Lisp_Object buf_obj) +{ + Lisp_Object cs = ns_ax_text_prop_at (start, Qcompletion__string, + buf_obj); + if (STRINGP (cs)) + return [NSString stringWithLispString: cs]; - if (!NSIsEmptyRect (visible)) - [self addCursorRect: visible cursor: currentCursor]; + if (end > start) + { + Lisp_Object substr = Fbuffer_substring_no_properties ( + make_fixnum (start), make_fixnum (end)); + if (STRINGP (substr)) + { + NSString *s = [NSString stringWithLispString: substr]; + s = [s stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (s.length > 0) + return s; + } + } -#if defined (NS_IMPL_GNUSTEP) || MAC_OS_X_VERSION_MIN_REQUIRED < 101300 -#if defined (NS_IMPL_COCOA) && MAC_OS_X_VERSION_MAX_ALLOWED >= 101300 - if ([currentCursor respondsToSelector: @selector(setOnMouseEntered:)]) -#endif - [currentCursor setOnMouseEntered: YES]; -#endif + Lisp_Object he = ns_ax_text_prop_at (start, Qhelp_echo, buf_obj); + if (STRINGP (he)) + return [NSString stringWithLispString: he]; + + return @""; } +/* 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 @[]; -/*****************************************************************************/ -/* Keyboard handling. */ -#define NS_KEYLOG 0 + Lisp_Object buf_obj = ns_ax_window_buffer_object (w); + if (NILP (buf_obj)) + return @[]; -- (void)keyDown: (NSEvent *)theEvent -{ - Mouse_HLInfo *hlinfo = MOUSE_HL_INFO (emacsframe); + 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), Qcompletion_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 = (EmacsAXSpanType) -1; + Lisp_Object limit_prop = Qnil; + + if (!NILP (Fplist_get (plist, Qwidget, Qnil))) + { + span_type = EmacsAXSpanTypeWidget; + limit_prop = Qwidget; + } + else if (!NILP (Fplist_get (plist, Qbutton, Qnil))) + { + span_type = EmacsAXSpanTypeButton; + limit_prop = Qbutton; + } + else if (!NILP (Fplist_get (plist, Qfollow_link, Qnil))) + { + span_type = EmacsAXSpanTypeLink; + limit_prop = Qfollow_link; + } + else if (!NILP (Fplist_get (plist, Qorg_link, Qnil))) + { + span_type = EmacsAXSpanTypeLink; + limit_prop = Qorg_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 = Qcompletion__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 ((NSInteger) span_type == -1) + { + pos++; + 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 + +- (BOOL) isAccessibilityElement { return YES; } + +- (NSAccessibilityRole) accessibilityRole +{ + switch (self.spanType) + { + case EmacsAXSpanTypeLink: return NSAccessibilityLinkRole; + case EmacsAXSpanTypeCheckBox: return NSAccessibilityCheckBoxRole; + case EmacsAXSpanTypeTextField: return NSAccessibilityTextFieldRole; + case EmacsAXSpanTypePopUpButton: return NSAccessibilityPopUpButtonRole; + 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]; + 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 (); + 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), + Qcompletion__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), + Qcompletion, + 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 + +- (instancetype)init +{ + self = [super init]; + if (self) + self.lispWindow = Qnil; + return self; +} + +/* Return the associated Emacs window if it is still live, else NULL. + Use this instead of storing a raw struct window * which can become a + dangling pointer after delete-window or kill-buffer. */ +- (struct window *)validWindow +{ + if (NILP (self.lispWindow) || !WINDOW_LIVE_P (self.lispWindow)) + return NULL; + return XWINDOW (self.lispWindow); +} + +- (NSRect)screenRectFromEmacsX:(int)x y:(int)y width:(int)ew height:(int)eh +{ + EmacsView *view = self.emacsView; + if (!view || ![view window]) + return NSZeroRect; + + NSRect r = NSMakeRect (x, y, ew, eh); + NSRect winRect = [view convertRect:r toView:nil]; + return [[view window] convertRectToScreen:winRect]; +} + +- (BOOL)isAccessibilityElement +{ + return YES; +} + +/* ---- Hierarchy plumbing (required for VoiceOver to find us) ---- */ + +- (id)accessibilityParent +{ + return NSAccessibilityUnignoredAncestor (self.emacsView); +} + +- (id)accessibilityWindow +{ + return [self.emacsView window]; +} + +- (id)accessibilityTopLevelUIElement +{ + return [self.emacsView window]; +} + +@end + + +@implementation EmacsAccessibilityBuffer +@synthesize cachedText; +@synthesize cachedTextModiff; +@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. */ + 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 pt = BUF_PT (b); + NSUInteger textLen = cachedText ? [cachedText length] : 0; + /* TODO: Also track BUF_OVERLAY_MODIFF to catch overlay-only + changes (e.g., timer-based completion highlight move without + point change). Currently, overlay changes without text edits + are detected only when point also moves. */ + if (cachedText && cachedTextModiff == modiff + && 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; + 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) + { + for (NSUInteger i = 0; i < visibleRunCount; i++) + { + ns_ax_visible_run *r = &visibleRuns[i]; + if (charpos >= r->charpos && charpos < r->charpos + r->length) + { + /* Compute UTF-16 delta inside this run directly from cachedText + (an NSString built on the main thread) — no Lisp calls needed. */ + NSUInteger chars_in = (NSUInteger)(charpos - r->charpos); + if (chars_in == 0 || !cachedText) + return r->ax_start; + /* ax_start + UTF-16 units for the first chars_in chars of the run. */ + NSUInteger run_end_ax = r->ax_start + r->ax_length; + NSUInteger scan = r->ax_start; + /* Each visible Emacs char maps to 1 or 2 UTF-16 units. + Walk the NSString using rangeOfComposedCharacterSequenceAtIndex + which handles surrogates correctly. */ + 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; + } + /* If charpos falls in an invisible gap before the next run, + map it to the start of the next visible run. */ + if (charpos < r->charpos) + return r->ax_start; + } + /* Past end — return total length. */ + if (visibleRunCount > 0) + { + ns_ax_visible_run *last = &visibleRuns[visibleRunCount - 1]; + return last->ax_start + last->ax_length; + } + return 0; + } /* @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) + { + for (NSUInteger i = 0; i < visibleRunCount; i++) + { + ns_ax_visible_run *r = &visibleRuns[i]; + if (ax_idx >= r->ax_start + && ax_idx < r->ax_start + r->ax_length) + { + if (!cachedText) + return r->charpos; + + /* Walk forward through NSString composed character sequences to + count Emacs characters (= composed sequences) up to ax_idx. */ + 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 (visibleRunCount > 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 (); + + /* 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); + + block_input (); + + /* 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); + + unblock_input (); + + /* 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; + + struct window *w = [self validWindow]; + if (!w || !WINDOW_LEAF_P (w)) + return; + + EmacsView *view = self.emacsView; + if (!view || !view->emacsframe) + return; + + block_input (); + + /* Raise the frame's NS window to ensure keyboard focus. */ + NSWindow *nswin = [view window]; + if (nswin && ![nswin isKeyWindow]) + [nswin makeKeyAndOrderFront:nil]; + + unblock_input (); + + /* 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 + }; + NSAccessibilityPostNotificationWithUserInfo ( + 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 newlines from start to point_idx. */ + NSInteger line = 0; + for (NSUInteger i = 0; i < point_idx; i++) + { + if ([cachedText characterAtIndex:i] == '\n') + line++; + } + 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 newlines from start of cachedText to idx. */ + NSInteger line = 0; + for (NSUInteger i = 0; i < idx; i++) + { + if ([cachedText characterAtIndex:i] == '\n') + line++; + } + 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]; + NSInteger cur_line = 0; + + for (NSUInteger i = 0; i <= len; i++) + { + if (cur_line == line) + { + /* Find end of this line. */ + NSUInteger line_end = i; + while (line_end < len + && [cachedText characterAtIndex:line_end] != '\n') + line_end++; + /* Include the trailing newline so empty lines have length 1. */ + if (line_end < len + && [cachedText characterAtIndex:line_end] == '\n') + line_end++; + return NSMakeRange (i, line_end - i); + } + if (i < len && [cachedText characterAtIndex:i] == '\n') + { + cur_line++; + } + } + /* Phantom final line after the last newline. */ + if (cur_line == line) + return NSMakeRange (len, 0); + return NSMakeRange (NSNotFound, 0); +} + +- (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 — simple approach. */ + 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. */ + 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) + { + unblock_input (); + 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]; + + unblock_input (); + 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 ---- */ + +- (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 → typing echo --- + WebKit AXObjectCacheMac fallback enum: Edit = 1, Typing = 3. */ + if (modiff != self.cachedModiff) + { + /* 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]; + } + + self.cachedModiff = modiff; + /* Update cachedPoint here so the selection-move branch below + 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 + }; + NSAccessibilityPostNotificationWithUserInfo ( + self, NSAccessibilityValueChangedNotification, userInfo); + } + + /* --- Cursor moved or selection changed → line reading --- + WebKit AXObjectCacheMac fallback enum: SelectionMove = 2. + Use 'else if' — edits and selection moves are mutually exclusive + per the WebKit/Chromium pattern. VoiceOver gets confused if + both notifications arrive in the same runloop iteration. */ + 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 --- + Compare old and new cursor positions in cachedText to determine + what kind of move happened. Three levels: + - line: different line (lineRangeForRange) + - word: same line, distance > 1 UTF-16 unit + - character: same line, distance == 1 UTF-16 unit + C-n/C-p force line regardless of detected granularity. */ + NSInteger granularity = ns_ax_text_selection_granularity_unknown; + [self ensureTextCache]; + NSUInteger oldIdx = 0, newIdx = 0; + if (cachedText && oldPoint > 0) + { + NSUInteger tlen = [cachedText length]; + oldIdx = [self accessibilityIndexForCharpos:oldPoint]; + 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; + } + + /* --- NOTIFICATION STRATEGY --- + SelectedTextChanged ALWAYS posted for focused element: + - Interrupts VoiceOver auto-read (buffer switch reading) + - Provides word/line/selection reading via VoiceOver defaults + + For CHARACTER moves only: omit granularity from userInfo so + VoiceOver cannot derive speech from SelectedTextChanged, then + post AnnouncementRequested with char AT point. This avoids + double-speech while keeping the interrupt behaviour. + + For WORD and LINE moves: include granularity in userInfo — + VoiceOver reads the word/line correctly on its own. + + For SELECTION changes: include granularity — VoiceOver reads + selected/deselected text. + + Non-focused buffers: AnnouncementRequested only (see below). */ + if ([self isAccessibilityFocused]) + { + 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); + + NSAccessibilityPostNotificationWithUserInfo ( + 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) + }; + NSAccessibilityPostNotificationWithUserInfo ( + 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), + Qcompletion__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) + }; + NSAccessibilityPostNotificationWithUserInfo ( + NSApp, + NSAccessibilityAnnouncementRequestedNotification, + annInfo); + } + } + } + } + + /* --- Completions announcement --- + When point changes in a non-focused buffer (e.g. *Completions* + while the minibuffer has keyboard focus), VoiceOver won't read + the change because it's tracking the focused element. Post an + announcement so the user hears the selected completion. + + If there is a `completions-highlight` overlay at point (Emacs + highlights the selected completion candidate), read its full + text instead of just the current line. */ + if (![self isAccessibilityFocused] && cachedText) + { + 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 when present. + completion--string can be a plain string (simple completion) + or a list ("candidate" "annotation") for annotated completions. + In the list case, use car (the completion itself). */ + Lisp_Object cstr = Fget_char_property (make_fixnum (point), + Qcompletion__string, + Qnil); + announceText = ns_ax_completion_string_from_prop (cstr); + + /* 2) Fallback: announce the mouse-face span at point. + completion-list-mode often marks the active candidate this way. */ + 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); + + /* Find mouse-face span boundaries using property + change functions — O(log n) instead of O(n). */ + 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: check completions-highlight overlay span at point. */ + if (!announceText) + { + Lisp_Object faceSym = Qcompletions_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: select the best completions-highlight overlay. + Prefer overlay nearest to point over first-found in buffer. */ + 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 the 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]; + } + } + + 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) + }; + NSAccessibilityPostNotificationWithUserInfo ( + 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; + } + } + + } + else + { + /* Nothing changed (no text edit, no cursor move, no mark change). + Overlay state cannot change without a modiff bump, so no scan + needed for non-focused buffers. Just 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 */ + + +/* ========================================================================== + + EmacsView implementation + + ========================================================================== */ + + +@implementation EmacsView + +- (void)windowDidEndLiveResize:(NSNotification *)notification +{ + [self updateFramePosition]; +} + +/* Needed to inform when window closed from lisp. */ +- (void) setWindowClosing: (BOOL)closing +{ + NSTRACE ("[EmacsView setWindowClosing:%d]", closing); + + windowClosing = closing; +} + + +- (void)dealloc +{ + NSTRACE ("[EmacsView dealloc]"); + + /* Clear the view resize notification. */ + [[NSNotificationCenter defaultCenter] + removeObserver:self + name:NSViewFrameDidChangeNotification + object:nil]; + + if (fs_state == FULLSCREEN_BOTH) + [nonfs_window release]; + +#if defined (NS_IMPL_COCOA) && MAC_OS_X_VERSION_MIN_REQUIRED >= 101400 + /* Release layer and menu */ + EmacsLayer *layer = (EmacsLayer *)[self layer]; + [layer release]; +#endif + + [accessibilityElements release]; + [[self menu] release]; + [super dealloc]; +} + + +/* Called on font panel selection. */ +- (void) changeFont: (id) sender +{ + struct font *font = FRAME_OUTPUT_DATA (emacsframe)->font; + NSFont *nsfont; + +#ifdef NS_IMPL_GNUSTEP + nsfont = ((struct nsfont_info *) font)->nsfont; +#else + nsfont = (NSFont *) macfont_get_nsctfont (font); +#endif + + if (!font_panel_active) + return; + + if (font_panel_result) + [font_panel_result release]; + + font_panel_result = (NSFont *) [sender convertFont: nsfont]; + + if (font_panel_result) + [font_panel_result retain]; + +#ifndef NS_IMPL_COCOA + font_panel_active = NO; + [NSApp stop: self]; +#endif +} + +#ifdef NS_IMPL_COCOA +- (void) noteUserSelectedFont +{ + font_panel_active = NO; + + /* If no font was previously selected, use the currently selected + font. */ + + if (!font_panel_result && FRAME_FONT (emacsframe)) + { + font_panel_result + = macfont_get_nsctfont (FRAME_FONT (emacsframe)); + + if (font_panel_result) + [font_panel_result retain]; + } + + [NSApp stop: self]; +} + +- (void) noteUserCancelledSelection +{ + font_panel_active = NO; + + if (font_panel_result) + [font_panel_result release]; + font_panel_result = nil; + + [NSApp stop: self]; +} +#endif + +- (Lisp_Object) showFontPanel +{ + id fm = [NSFontManager sharedFontManager]; + struct font *font = FRAME_OUTPUT_DATA (emacsframe)->font; + NSFont *nsfont, *result; + struct timespec timeout; +#ifdef NS_IMPL_COCOA + NSView *buttons; + BOOL canceled; +#endif + +#ifdef NS_IMPL_GNUSTEP + nsfont = ((struct nsfont_info *) font)->nsfont; +#else + nsfont = (NSFont *) macfont_get_nsctfont (font); +#endif + +#ifdef NS_IMPL_COCOA + buttons + = ns_create_font_panel_buttons (self, + @selector (noteUserSelectedFont), + @selector (noteUserCancelledSelection)); + [[fm fontPanel: YES] setAccessoryView: buttons]; + [buttons release]; +#endif + + [fm setSelectedFont: nsfont isMultiple: NO]; + [fm orderFrontFontPanel: NSApp]; + + font_panel_active = YES; + timeout = make_timespec (0, 100000000); + + block_input (); + while (font_panel_active +#ifdef NS_IMPL_COCOA + && (canceled = [[fm fontPanel: YES] isVisible]) +#else + && [[fm fontPanel: YES] isVisible] +#endif + ) + ns_select_1 (0, NULL, NULL, NULL, &timeout, NULL, YES); + unblock_input (); + + if (font_panel_result) + [font_panel_result autorelease]; + +#ifdef NS_IMPL_COCOA + if (!canceled) + font_panel_result = nil; +#endif + + result = font_panel_result; + font_panel_result = nil; + + [[fm fontPanel: YES] setIsVisible: NO]; + font_panel_active = NO; + + if (result) + return ns_font_desc_to_font_spec ([result fontDescriptor], + result); + + return Qnil; +} + +- (BOOL)acceptsFirstResponder +{ + NSTRACE ("[EmacsView acceptsFirstResponder]"); + return YES; +} + +/* Tell NS we want to accept clicks that activate the window */ +- (BOOL)acceptsFirstMouse:(NSEvent *)theEvent +{ + NSTRACE_MSG ("First mouse event: type=%ld, clickCount=%ld", + [theEvent type], [theEvent clickCount]); + return ns_click_through; +} +- (void)resetCursorRects +{ + NSRect visible = [self visibleRect]; + NSCursor *currentCursor = FRAME_POINTER_TYPE (emacsframe); + NSTRACE ("[EmacsView resetCursorRects]"); + + if (currentCursor == nil) + currentCursor = [NSCursor arrowCursor]; + + if (!NSIsEmptyRect (visible)) + [self addCursorRect: visible cursor: currentCursor]; + +#if defined (NS_IMPL_GNUSTEP) || MAC_OS_X_VERSION_MIN_REQUIRED < 101300 +#if defined (NS_IMPL_COCOA) && MAC_OS_X_VERSION_MAX_ALLOWED >= 101300 + if ([currentCursor respondsToSelector: @selector(setOnMouseEntered:)]) +#endif + [currentCursor setOnMouseEntered: YES]; +#endif +} + + + +/*****************************************************************************/ +/* Keyboard handling. */ +#define NS_KEYLOG 0 + +- (void)keyDown: (NSEvent *)theEvent +{ + Mouse_HLInfo *hlinfo = MOUSE_HL_INFO (emacsframe); int code; unsigned fnKeysym = 0; static NSMutableArray *nsEvArray; @@ -8237,6 +10447,31 @@ - (void)windowDidBecomeKey /* for direct calls */ XSETFRAME (event.frame_or_window, emacsframe); kbd_buffer_store_event (&event); ns_send_appdefined (-1); // Kick main loop + +#ifdef NS_IMPL_COCOA + /* Notify VoiceOver that the focused accessibility element changed. + Post on the focused virtual element so VoiceOver starts tracking it. + This is critical for initial focus and app-switch scenarios. */ + { + id focused = [self accessibilityFocusedUIElement]; + if (focused + && [focused isKindOfClass:[EmacsAccessibilityBuffer class]]) + { + NSAccessibilityPostNotification (focused, + NSAccessibilityFocusedUIElementChangedNotification); + NSDictionary *info = @{ + @"AXTextStateChangeType": + @(ns_ax_text_state_change_selection_move), + @"AXTextChangeElement": focused + }; + NSAccessibilityPostNotificationWithUserInfo (focused, + NSAccessibilitySelectedTextChangedNotification, info); + } + else if (focused) + NSAccessibilityPostNotification (focused, + NSAccessibilityFocusedUIElementChangedNotification); + } +#endif } @@ -9474,6 +11709,332 @@ - (int) fullscreenState return fs_state; } +#ifdef NS_IMPL_COCOA + +/* ---- Accessibility: walk the Emacs window tree ---- */ + +static void +ns_ax_collect_windows (Lisp_Object window, EmacsView *view, + NSMutableArray *elements, + NSDictionary *existing) +{ + if (NILP (window)) + return; + + struct window *w = XWINDOW (window); + + if (WINDOW_LEAF_P (w)) + { + /* Buffer element — reuse existing if available. */ + EmacsAccessibilityBuffer *elem + = [existing objectForKey:[NSValue valueWithPointer:w]]; + if (!elem) + { + elem = [[EmacsAccessibilityBuffer alloc] init]; + elem.emacsView = view; + + /* Initialize cached state to -1 to force first notification. */ + elem.cachedModiff = -1; + elem.cachedPoint = -1; + elem.cachedMarkActive = NO; + } + else + { + [elem retain]; + } + elem.lispWindow = window; + [elements addObject:elem]; + [elem release]; + + /* Mode line element (skip for minibuffer). */ + if (!MINI_WINDOW_P (w)) + { + EmacsAccessibilityModeLine *ml + = [[EmacsAccessibilityModeLine alloc] init]; + ml.emacsView = view; + ml.lispWindow = window; + [elements addObject:ml]; + [ml release]; + } + } + else + { + /* Internal (combination) window — recurse into children. */ + Lisp_Object child = w->contents; + while (!NILP (child)) + { + ns_ax_collect_windows (child, view, elements, existing); + child = XWINDOW (child)->next; + } + } +} + +- (void)rebuildAccessibilityTree +{ + NSTRACE ("[EmacsView rebuildAccessibilityTree]"); + if (!emacsframe) + return; + + /* Build map of existing elements by window pointer for reuse. */ + NSMutableDictionary *existing = [NSMutableDictionary dictionary]; + if (accessibilityElements) + { + for (EmacsAccessibilityElement *elem in accessibilityElements) + { + if ([elem isKindOfClass:[EmacsAccessibilityBuffer class]] + && !NILP (elem.lispWindow)) + [existing setObject:elem + forKey:[NSValue valueWithPointer: + XWINDOW (elem.lispWindow)]]; + } + } + + NSMutableArray *newElements = [NSMutableArray arrayWithCapacity:8]; + + /* Collect from main window tree. */ + Lisp_Object root = FRAME_ROOT_WINDOW (emacsframe); + ns_ax_collect_windows (root, self, newElements, existing); + + /* Include minibuffer. */ + Lisp_Object mini = emacsframe->minibuffer_window; + if (!NILP (mini)) + ns_ax_collect_windows (mini, self, newElements, existing); + + [accessibilityElements release]; + accessibilityElements = [newElements retain]; + accessibilityTreeValid = YES; +} + +- (void)invalidateAccessibilityTree +{ + accessibilityTreeValid = NO; +} + +- (NSAccessibilityRole)accessibilityRole +{ + return NSAccessibilityGroupRole; +} + +- (NSString *)accessibilityLabel +{ + return @"Emacs"; +} + +- (BOOL)isAccessibilityElement +{ + return YES; +} + +- (NSArray *)accessibilityChildren +{ + if (!accessibilityElements || !accessibilityTreeValid) + [self rebuildAccessibilityTree]; + return accessibilityElements; +} + +- (id)accessibilityFocusedUIElement +{ + if (!emacsframe) + return self; + + if (!accessibilityElements || !accessibilityTreeValid) + [self rebuildAccessibilityTree]; + + for (EmacsAccessibilityElement *elem in accessibilityElements) + { + if ([elem isKindOfClass:[EmacsAccessibilityBuffer class]] + && EQ (elem.lispWindow, emacsframe->selected_window)) + return elem; + } + return self; +} + +/* Called from ns_update_end to post AX notifications. + + Important: post notifications BEFORE rebuilding the tree. + The existing elements carry cached state (modiff, point) from the + previous redisplay cycle. Rebuilding first would create fresh + elements with current values, making change detection impossible. */ +- (void)postAccessibilityUpdates +{ + NSTRACE ("[EmacsView postAccessibilityUpdates]"); + eassert ([NSThread isMainThread]); + + if (!emacsframe) + return; + + /* Re-entrance guard: VoiceOver callbacks during notification posting + can trigger redisplay, which calls ns_update_end, which calls us + again. Prevent infinite recursion. */ + if (accessibilityUpdating) + return; + accessibilityUpdating = YES; + + /* Detect window tree change (split, delete, new buffer). Compare + FRAME_ROOT_WINDOW — if it changed, the tree structure changed. */ + Lisp_Object curRoot = FRAME_ROOT_WINDOW (emacsframe); + if (!EQ (curRoot, lastRootWindow)) + { + lastRootWindow = curRoot; + accessibilityTreeValid = NO; + } + + /* If tree is stale, rebuild FIRST so we don't iterate freed + window pointers. Skip notifications for this cycle — the + freshly-built elements have no previous state to diff against. */ + if (!accessibilityTreeValid) + { + [self rebuildAccessibilityTree]; + /* Invalidate span cache — window layout changed. */ + for (EmacsAccessibilityElement *elem in accessibilityElements) + if ([elem isKindOfClass: [EmacsAccessibilityBuffer class]]) + [(EmacsAccessibilityBuffer *) elem invalidateInteractiveSpans]; + NSAccessibilityPostNotification (self, + NSAccessibilityLayoutChangedNotification); + + /* Post focus change so VoiceOver picks up the new tree. */ + id focused = [self accessibilityFocusedUIElement]; + if (focused && focused != self) + NSAccessibilityPostNotification (focused, + NSAccessibilityFocusedUIElementChangedNotification); + + lastSelectedWindow = emacsframe->selected_window; + accessibilityUpdating = NO; + return; + } + + /* Post per-buffer notifications using EXISTING elements that have + cached state from the previous cycle. Validate each window + pointer before use. */ + for (EmacsAccessibilityElement *elem in accessibilityElements) + { + if ([elem isKindOfClass:[EmacsAccessibilityBuffer class]]) + { + struct window *w = [elem validWindow]; + if (w && WINDOW_LEAF_P (w) + && BUFFERP (w->contents) && XBUFFER (w->contents)) + [(EmacsAccessibilityBuffer *) elem + postAccessibilityNotificationsForFrame:emacsframe]; + } + } + + /* Check for window switch (C-x o). */ + Lisp_Object curSel = emacsframe->selected_window; + BOOL windowSwitched = !EQ (curSel, lastSelectedWindow); + if (windowSwitched) + { + lastSelectedWindow = curSel; + id focused = [self accessibilityFocusedUIElement]; + if (focused && focused != self + && [focused isKindOfClass:[EmacsAccessibilityBuffer class]]) + { + NSAccessibilityPostNotification (focused, + NSAccessibilityFocusedUIElementChangedNotification); + NSDictionary *info = @{ + @"AXTextStateChangeType": + @(ns_ax_text_state_change_selection_move), + @"AXTextChangeElement": focused + }; + NSAccessibilityPostNotificationWithUserInfo (focused, + NSAccessibilitySelectedTextChangedNotification, info); + } + else if (focused && focused != self) + NSAccessibilityPostNotification (focused, + NSAccessibilityFocusedUIElementChangedNotification); + } + + accessibilityUpdating = NO; +} + +/* ---- Cursor position for Zoom (via accessibilityBoundsForRange:) ---- + + accessibilityFrame returns the VIEW's frame (standard behavior). + The cursor location is exposed through accessibilityBoundsForRange: + which AT tools query using the selectedTextRange. */ + +- (NSRect)accessibilityBoundsForRange:(NSRange)range +{ + /* Delegate to the focused buffer element for accurate per-range + geometry when possible. Fall back to the cached cursor rect + (set by ns_draw_phys_cursor) for Zoom and simple AT queries. */ + id focused = [self accessibilityFocusedUIElement]; + if ([focused isKindOfClass:[EmacsAccessibilityBuffer class]]) + { + NSRect bufRect = [(EmacsAccessibilityBuffer *) focused + accessibilityFrameForRange:range]; + if (!NSIsEmptyRect (bufRect)) + return bufRect; + } + + NSRect viewRect = lastAccessibilityCursorRect; + + if (viewRect.size.width < 1) + viewRect.size.width = 1; + if (viewRect.size.height < 1) + viewRect.size.height = 8; + + NSWindow *win = [self window]; + if (win == nil) + return NSZeroRect; + + NSRect windowRect = [self convertRect:viewRect toView:nil]; + return [win convertRectToScreen:windowRect]; +} + +/* Modern NSAccessibility protocol entry point. Delegates to + accessibilityBoundsForRange: which holds the real implementation + shared with the legacy parameterized-attribute API. */ +- (NSRect)accessibilityFrameForRange:(NSRange)range +{ + return [self accessibilityBoundsForRange:range]; +} + +/* Delegate to the focused virtual buffer element so both the modern + and legacy APIs return the correct string data. */ +- (NSString *)accessibilityStringForRange:(NSRange)range +{ + id focused = [self accessibilityFocusedUIElement]; + if ([focused isKindOfClass:[EmacsAccessibilityBuffer class]]) + return [(EmacsAccessibilityBuffer *) focused + accessibilityStringForRange:range]; + return @""; +} + +/* ---- Legacy parameterized attribute APIs (Zoom uses these) ---- */ + +- (NSArray *)accessibilityParameterizedAttributeNames +{ + NSArray *superAttrs = [super accessibilityParameterizedAttributeNames]; + if (superAttrs == nil) + superAttrs = @[]; + return [superAttrs arrayByAddingObjectsFromArray: + @[NSAccessibilityBoundsForRangeParameterizedAttribute, + NSAccessibilityStringForRangeParameterizedAttribute]]; +} + +- (id)accessibilityAttributeValue:(NSString *)attribute + forParameter:(id)parameter +{ + if ([attribute isEqualToString: + NSAccessibilityBoundsForRangeParameterizedAttribute]) + { + NSRange range = [(NSValue *) parameter rangeValue]; + return [NSValue valueWithRect: + [self accessibilityBoundsForRange:range]]; + } + + if ([attribute isEqualToString: + NSAccessibilityStringForRangeParameterizedAttribute]) + { + NSRange range = [(NSValue *) parameter rangeValue]; + return [self accessibilityStringForRange:range]; + } + + return [super accessibilityAttributeValue:attribute forParameter:parameter]; +} + +#endif /* NS_IMPL_COCOA */ + @end /* EmacsView */ @@ -11303,6 +13864,18 @@ Convert an X font name (XLFD) to an NS font name. DEFSYM (Qns_drag_operation_generic, "ns-drag-operation-generic"); DEFSYM (Qns_handle_drag_motion, "ns-handle-drag-motion"); + /* Accessibility span scanning symbols. */ + DEFSYM (Qwidget, "widget"); + DEFSYM (Qbutton, "button"); + DEFSYM (Qfollow_link, "follow-link"); + DEFSYM (Qorg_link, "org-link"); + DEFSYM (Qcompletion_list_mode, "completion-list-mode"); + DEFSYM (Qcompletion__string, "completion--string"); + DEFSYM (Qcompletion, "completion"); + DEFSYM (Qcompletions_highlight, "completions-highlight"); + DEFSYM (Qbacktab, "backtab"); + /* Qmouse_face and Qkeymap are defined in textprop.c / keymap.c. */ + Fput (Qalt, Qmodifier_value, make_fixnum (alt_modifier)); Fput (Qhyper, Qmodifier_value, make_fixnum (hyper_modifier)); Fput (Qmeta, Qmodifier_value, make_fixnum (meta_modifier)); -- 2.43.0