Files
emacs-doom/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch
Daneel 97776b5141 Fix patch after 3-reviewer pipeline review
- BLOCKER: real_this_command → Vreal_this_command (DEFVAR_LISP uses V prefix)
- Thread safety: setAccessibilitySelectedTextRange dispatches to main thread
- Defensive: 7 new BUFFERP(w->contents) guards before XBUFFER calls

Reviewed by: symbol checker, logic/memory reviewer, ABI/build reviewer
Verified: git apply --check OK, zero real_this_command refs, dispatch_async present
2026-02-26 22:27:25 +01:00

2449 lines
69 KiB
Diff

From 6a256eb9269e0cfc4d1270ef61c228dd9f9989da Mon Sep 17 00:00:00 2001
From: Daneel <daneel@sukany.cz>
Date: Thu, 26 Feb 2026 18:41:28 +0100
Subject: [PATCH] ns: implement AXBoundsForRange and VoiceOver interaction
fixes
---
nsterm.h | 71 ++
nsterm.m | 2201 ++++++++++++++++++++++++++++++++++++++++++++++++++----
2 files changed, 2133 insertions(+), 139 deletions(-)
diff --git a/src/nsterm.h b/src/nsterm.h
index 7c1ee4c..22828f2 100644
--- a/src/nsterm.h
+++ b/src/nsterm.h
@@ -453,6 +453,62 @@ 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;
+@property (nonatomic, assign) struct window *emacsWindow;
+- (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;
+}
+@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
+#endif /* NS_IMPL_COCOA */
+
+
/* ==========================================================================
The main Emacs view
@@ -471,6 +527,14 @@ 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
+ NSRect lastAccessibilityCursorRect;
+ @protected
#endif
BOOL font_panel_active;
NSFont *font_panel_result;
@@ -528,6 +592,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 932d209..da40369 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -1104,6 +1104,11 @@ ns_update_end (struct frame *f)
unblock_input ();
ns_updating_frame = NULL;
+
+#ifdef NS_IMPL_COCOA
+ /* Post accessibility notifications after each redisplay cycle. */
+ [view postAccessibilityUpdates];
+#endif
}
static void
@@ -3232,6 +3237,37 @@ ns_draw_window_cursor (struct window *w, struct glyph_row *glyph_row,
/* 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). */
+ 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
+
ns_focus (f, NULL, 0);
NSGraphicsContext *ctx = [NSGraphicsContext currentContext];
@@ -6849,214 +6885,1801 @@ ns_create_font_panel_buttons (id target, SEL select, SEL cancel_action)
/* ==========================================================================
- EmacsView implementation
+ Accessibility virtual elements (macOS / Cocoa only)
========================================================================== */
+#ifdef NS_IMPL_COCOA
-@implementation EmacsView
+/* ---- Helper: extract buffer text for accessibility ---- */
-- (void)windowDidEndLiveResize:(NSNotification *)notification
+/* Maximum characters exposed via accessibilityValue. */
+#define NS_AX_TEXT_CAP 100000
+
+/* 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(). */
+
+static NSString *
+ns_ax_buffer_text (struct window *w, ptrdiff_t *out_start,
+ ns_ax_visible_run **out_runs, NSUInteger *out_nruns)
{
- [self updateFramePosition];
+ *out_runs = NULL;
+ *out_nruns = 0;
+
+ if (!w || !WINDOW_LEAF_P (w))
+ {
+ *out_start = 0;
+ return @"";
+ }
+
+ if (!BUFFERP (w->contents))
+ {
+ *out_start = 0;
+ return @"";
+ }
+ struct buffer *b = XBUFFER (w->contents);
+ if (!b)
+ {
+ *out_start = 0;
+ return @"";
+ }
+
+ ptrdiff_t begv = BUF_BEGV (b);
+ ptrdiff_t zv = BUF_ZV (b);
+
+ *out_start = begv;
+
+ if (zv <= begv)
+ return @"";
+
+ struct buffer *oldb = 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;
+
+ NSMutableString *result = [NSMutableString string];
+ ptrdiff_t pos = begv;
+
+ while (pos < zv)
+ {
+ /* Check invisible property (text properties + overlays). */
+ Lisp_Object invis = Fget_char_property (make_fixnum (pos),
+ Qinvisible, Qnil);
+ /* Check if invisible property means truly invisible.
+ TEXT_PROP_MEANS_INVISIBLE is defined only in xdisp.c,
+ so we replicate: EQ(invis, Qt), or invis is on the
+ buffer's invisibility-spec list. Simplified: any
+ non-nil invisible property hides the text. This matches
+ the common case (invisible t) and org-mode/dired usage. */
+ if (!NILP (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;
+ }
+
+ /* 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;
+
+ /* 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++;
+
+ ax_offset += ns_len;
+ pos = run_end;
+ }
+
+ if (b != oldb)
+ set_buffer_internal_1 (oldb);
+
+ *out_runs = runs;
+ *out_nruns = nruns;
+ return result;
}
-/* Needed to inform when window closed from lisp. */
-- (void) setWindowClosing: (BOOL)closing
+
+/* ---- Helper: extract mode line text from glyph rows ---- */
+
+static NSString *
+ns_ax_mode_line_text (struct window *w)
{
- NSTRACE ("[EmacsView setWindowClosing:%d]", closing);
+ if (!w || !w->current_matrix)
+ return @"";
- windowClosing = closing;
+ struct glyph_matrix *matrix = w->current_matrix;
+ NSMutableString *text = [NSMutableString string];
+
+ for (int i = 0; i < matrix->nrows; i++)
+ {
+ struct glyph_row *row = matrix->rows + i;
+ if (!row->enabled_p || !row->mode_line_p)
+ continue;
+
+ 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]];
+ }
+ }
+ }
+ return text;
}
-- (void)dealloc
+/* ---- Helper: screen rect for a character range via glyph matrix ---- */
+
+static NSRect
+ns_ax_frame_for_range (struct window *w, EmacsView *view,
+ ptrdiff_t text_start, NSRange range)
{
- NSTRACE ("[EmacsView dealloc]");
+ if (!w || !w->current_matrix || !view)
+ return NSZeroRect;
- /* Clear the view resize notification. */
- [[NSNotificationCenter defaultCenter]
- removeObserver:self
- name:NSViewFrameDidChangeNotification
- object:nil];
+ /* Convert range indices back to buffer charpos. */
+ ptrdiff_t cp_start = text_start + (ptrdiff_t) range.location;
+ ptrdiff_t cp_end = cp_start + (ptrdiff_t) range.length;
- if (fs_state == FULLSCREEN_BOTH)
- [nonfs_window release];
+ struct glyph_matrix *matrix = w->current_matrix;
+ NSRect result = NSZeroRect;
+ BOOL found = NO;
-#if defined (NS_IMPL_COCOA) && MAC_OS_X_VERSION_MIN_REQUIRED >= 101400
- /* Release layer and menu */
- EmacsLayer *layer = (EmacsLayer *)[self layer];
- [layer release];
-#endif
+ 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;
- [[self menu] release];
- [super dealloc];
+ 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];
}
+/* 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,
-/* Called on font panel selection. */
-- (void) changeFont: (id) sender
-{
- struct font *font = FRAME_OUTPUT_DATA (emacsframe)->font;
- NSFont *nsfont;
+ ns_ax_text_edit_type_typing = 3,
-#ifdef NS_IMPL_GNUSTEP
- nsfont = ((struct nsfont_info *) font)->nsfont;
-#else
- nsfont = (NSFont *) macfont_get_nsctfont (font);
-#endif
+ 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,
- if (!font_panel_active)
- return;
+ ns_ax_text_selection_granularity_unknown = 0,
+ ns_ax_text_selection_granularity_character = 1,
+ ns_ax_text_selection_granularity_line = 3,
+};
- if (font_panel_result)
- [font_panel_result release];
+static NSUInteger
+ns_ax_utf16_length_for_buffer_range (struct buffer *b, ptrdiff_t start,
+ ptrdiff_t end)
+{
+ if (!b || end <= start)
+ return 0;
- font_panel_result = (NSFont *) [sender convertFont: nsfont];
+ struct buffer *oldb = current_buffer;
+ if (b != current_buffer)
+ set_buffer_internal_1 (b);
- if (font_panel_result)
- [font_panel_result retain];
+ Lisp_Object lstr = Fbuffer_substring_no_properties (make_fixnum (start),
+ make_fixnum (end));
+ NSString *nsstr = [NSString stringWithLispString:lstr];
+ NSUInteger len = [nsstr length];
-#ifndef NS_IMPL_COCOA
- font_panel_active = NO;
- [NSApp stop: self];
-#endif
+ if (b != oldb)
+ set_buffer_internal_1 (oldb);
+
+ return len;
}
-#ifdef NS_IMPL_COCOA
-- (void) noteUserSelectedFont
+static BOOL
+ns_ax_find_completion_overlay_range (struct buffer *b, ptrdiff_t point,
+ ptrdiff_t *out_start,
+ ptrdiff_t *out_end)
{
- font_panel_active = NO;
+ if (!b || !out_start || !out_end)
+ return NO;
- /* If no font was previously selected, use the currently selected
- font. */
+ Lisp_Object faceSym = intern ("completions-highlight");
+ ptrdiff_t begv = BUF_BEGV (b);
+ ptrdiff_t zv = BUF_ZV (b);
+ ptrdiff_t best_start = 0;
+ ptrdiff_t best_end = 0;
+ ptrdiff_t best_dist = PTRDIFF_MAX;
+ BOOL found = NO;
- if (!font_panel_result && FRAME_FONT (emacsframe))
+ /* Fast path: look at point and immediate neighbors first. */
+ ptrdiff_t probes[3] = { point, point - 1, point + 1 };
+ for (int i = 0; i < 3 && !found; i++)
{
- font_panel_result
- = macfont_get_nsctfont (FRAME_FONT (emacsframe));
+ ptrdiff_t p = probes[i];
+ if (p < begv || p > zv)
+ continue;
- if (font_panel_result)
- [font_panel_result retain];
+ 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;
+ }
}
- [NSApp stop: self];
+ if (!found)
+ {
+ for (ptrdiff_t scan = begv; scan < zv; scan++)
+ {
+ Lisp_Object overlays = Foverlays_at (make_fixnum (scan), 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;
+
+ 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
+ || (dist == best_dist
+ && (ov_start < point && best_start >= point)))
+ {
+ best_start = ov_start;
+ best_end = ov_end;
+ best_dist = dist;
+ found = YES;
+ }
+ }
+ }
+ }
+
+ if (!found)
+ return NO;
+
+ *out_start = best_start;
+ *out_end = best_end;
+ return YES;
}
-- (void) noteUserCancelledSelection
+static bool
+ns_ax_event_is_ctrl_n_or_p (int *which)
{
- font_panel_active = NO;
+ Lisp_Object ev = last_command_event;
+ if (CONSP (ev))
+ ev = EVENT_HEAD (ev);
- if (font_panel_result)
- [font_panel_result release];
- font_panel_result = nil;
+ if (!FIXNUMP (ev))
+ return false;
- [NSApp stop: self];
+ EMACS_INT c = XFIXNUM (ev);
+ if (c == ('n' & 0x1f))
+ {
+ if (which)
+ *which = 1;
+ return true;
+ }
+ if (c == ('p' & 0x1f))
+ {
+ if (which)
+ *which = -1;
+ return true;
+ }
+ return false;
}
-#endif
-- (Lisp_Object) showFontPanel
+static bool
+ns_ax_command_is_basic_line_move (void)
{
- 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 (!SYMBOLP (Vreal_this_command))
+ return false;
-#ifdef NS_IMPL_GNUSTEP
- nsfont = ((struct nsfont_info *) font)->nsfont;
-#else
- nsfont = (NSFont *) macfont_get_nsctfont (font);
-#endif
+ Lisp_Object next = intern ("next-line");
+ Lisp_Object prev = intern ("previous-line");
+ return EQ (Vreal_this_command, next) || EQ (Vreal_this_command, prev);
+}
-#ifdef NS_IMPL_COCOA
- buttons
- = ns_create_font_panel_buttons (self,
- @selector (noteUserSelectedFont),
- @selector (noteUserCancelledSelection));
- [[fm fontPanel: YES] setAccessoryView: buttons];
- [buttons release];
-#endif
+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;
- [fm setSelectedFont: nsfont isMultiple: NO];
- [fm orderFrontFontPanel: NSApp];
+ NSString *text = nil;
+ struct buffer *oldb = current_buffer;
+ if (b != current_buffer)
+ set_buffer_internal_1 (b);
- font_panel_active = YES;
- timeout = make_timespec (0, 100000000);
+ /* Prefer canonical completion candidate string from text property. */
+ 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),
+ intern ("completion--string"),
+ Qnil);
+ if (STRINGP (cstr))
+ text = [NSString stringWithLispString:cstr];
+ }
- 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 (!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)];
+ }
- if (font_panel_result)
- [font_panel_result autorelease];
+ if (b != oldb)
+ set_buffer_internal_1 (oldb);
-#ifdef NS_IMPL_COCOA
- if (!canceled)
- font_panel_result = nil;
-#endif
+ if (text)
+ {
+ text = [text stringByTrimmingCharactersInSet:
+ [NSCharacterSet whitespaceAndNewlineCharacterSet]];
+ if ([text length] == 0)
+ text = nil;
+ }
- result = font_panel_result;
- font_panel_result = nil;
+ return text;
+}
- [[fm fontPanel: YES] setIsVisible: NO];
- font_panel_active = NO;
- if (result)
- return ns_font_desc_to_font_spec ([result fontDescriptor],
- result);
+@implementation EmacsAccessibilityElement
- return Qnil;
+- (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)acceptsFirstResponder
+- (BOOL)isAccessibilityElement
{
- NSTRACE ("[EmacsView acceptsFirstResponder]");
return YES;
}
-/* Tell NS we want to accept clicks that activate the window */
-- (BOOL)acceptsFirstMouse:(NSEvent *)theEvent
+/* ---- Hierarchy plumbing (required for VoiceOver to find us) ---- */
+
+- (id)accessibilityParent
{
- NSTRACE_MSG ("First mouse event: type=%ld, clickCount=%ld",
- [theEvent type], [theEvent clickCount]);
- return ns_click_through;
+ return NSAccessibilityUnignoredAncestor (self.emacsView);
}
-- (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];
+- (id)accessibilityWindow
+{
+ return [self.emacsView window];
+}
-#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
+- (id)accessibilityTopLevelUIElement
+{
+ return [self.emacsView window];
}
+@end
-/*****************************************************************************/
-/* Keyboard handling. */
-#define NS_KEYLOG 0
+@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];
+ if (visibleRuns)
+ xfree (visibleRuns);
+ [super dealloc];
+}
+
+/* ---- Text cache ---- */
+
+- (void)invalidateTextCache
+{
+ [cachedText release];
+ cachedText = nil;
+ if (visibleRuns)
+ {
+ xfree (visibleRuns);
+ visibleRuns = NULL;
+ }
+ visibleRunCount = 0;
+}
+
+- (void)ensureTextCache
+{
+ struct window *w = self.emacsWindow;
+ if (!w || !WINDOW_LEAF_P (w))
+ return;
+
+ if (!BUFFERP (w->contents))
+ 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;
+ 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);
+
+ [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
+{
+ struct window *w = self.emacsWindow;
+ struct buffer *b = (w && WINDOW_LEAF_P (w)) ? XBUFFER (w->contents) : NULL;
+
+ for (NSUInteger i = 0; i < visibleRunCount; i++)
+ {
+ ns_ax_visible_run *r = &visibleRuns[i];
+ if (charpos >= r->charpos && charpos < r->charpos + r->length)
+ {
+ if (!b)
+ return r->ax_start;
+ NSUInteger delta = ns_ax_utf16_length_for_buffer_range (b, r->charpos,
+ charpos);
+ if (delta > r->ax_length)
+ delta = r->ax_length;
+ return r->ax_start + delta;
+ }
+ /* 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;
+}
+
+/* Convert accessibility string index to buffer charpos. */
+- (ptrdiff_t)charposForAccessibilityIndex:(NSUInteger)ax_idx
+{
+ struct window *w = self.emacsWindow;
+ struct buffer *b = (w && WINDOW_LEAF_P (w)) ? XBUFFER (w->contents) : NULL;
+
+ 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 (!b)
+ return r->charpos;
+
+ NSUInteger target = ax_idx - r->ax_start;
+ ptrdiff_t lo = r->charpos;
+ ptrdiff_t hi = r->charpos + r->length;
+
+ while (lo < hi)
+ {
+ ptrdiff_t mid = lo + (hi - lo) / 2;
+ NSUInteger mid_len = ns_ax_utf16_length_for_buffer_range (b,
+ r->charpos,
+ mid);
+ if (mid_len < target)
+ lo = mid + 1;
+ else
+ hi = mid;
+ }
+
+ return lo;
+ }
+ }
+ /* Past end — return last charpos. */
+ if (visibleRunCount > 0)
+ {
+ ns_ax_visible_run *last = &visibleRuns[visibleRunCount - 1];
+ return last->charpos + last->length;
+ }
+ return cachedTextStart;
+}
+
+/* ---- NSAccessibility protocol ---- */
+
+- (NSAccessibilityRole)accessibilityRole
+{
+ return NSAccessibilityTextAreaRole;
+}
+
+- (NSString *)accessibilityRoleDescription
+{
+ struct window *w = self.emacsWindow;
+ if (w && MINI_WINDOW_P (w))
+ return @"minibuffer";
+ return @"editor";
+}
+
+- (NSString *)accessibilityLabel
+{
+ struct window *w = self.emacsWindow;
+ 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
+{
+ struct window *w = self.emacsWindow;
+ 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
+{
+ [self ensureTextCache];
+ return cachedText ? cachedText : @"";
+}
+
+- (NSInteger)accessibilityNumberOfCharacters
+{
+ [self ensureTextCache];
+ return cachedText ? [cachedText length] : 0;
+}
+
+- (NSString *)accessibilitySelectedText
+{
+ struct window *w = self.emacsWindow;
+ if (!w || !WINDOW_LEAF_P (w))
+ return @"";
+
+ if (!BUFFERP (w->contents))
+ 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
+{
+ struct window *w = self.emacsWindow;
+ if (!w || !WINDOW_LEAF_P (w))
+ return NSMakeRange (0, 0);
+
+ if (!BUFFERP (w->contents))
+ return 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
+{
+ struct window *w = self.emacsWindow;
+ if (!w || !WINDOW_LEAF_P (w))
+ return;
+
+ if (!BUFFERP (w->contents))
+ return;
+
+ dispatch_async (dispatch_get_main_queue (), ^{
+ struct window *w2 = self.emacsWindow;
+ if (!w2 || !WINDOW_LEAF_P (w2))
+ return;
+
+ if (!BUFFERP (w2->contents))
+ return;
+ struct buffer *b = XBUFFER (w2->contents);
+
+ [self ensureTextCache];
+
+ /* 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. Use set_point_both which
+ operates on the current buffer — temporarily switch if needed. */
+ struct buffer *oldb = current_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);
+
+ if (b != oldb)
+ set_buffer_internal_1 (oldb);
+
+ 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.emacsWindow;
+ 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
+{
+ struct window *w = self.emacsWindow;
+ if (!w || !WINDOW_LEAF_P (w))
+ return 0;
+
+ if (!BUFFERP (w->contents))
+ 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
+{
+ [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
+{
+ [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
+{
+ [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
+{
+ [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
+{
+ struct window *w = self.emacsWindow;
+ 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];
+ NSRange charRange = NSMakeRange (0, (NSUInteger) (cp_end - cp_start));
+ return ns_ax_frame_for_range (w, view, cp_start, charRange);
+}
+
+- (NSRange)accessibilityRangeForPosition:(NSPoint)screenPoint
+{
+ /* Hit test: convert screen point to buffer character index. */
+ struct window *w = self.emacsWindow;
+ 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);
+
+ /* 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)
+ 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];
+ return NSMakeRange (ax_idx, 1);
+}
+
+- (NSRange)accessibilityVisibleCharacterRange
+{
+ /* 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
+{
+ struct window *w = self.emacsWindow;
+ 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
+{
+ struct window *w = self.emacsWindow;
+ if (!w || !WINDOW_LEAF_P (w))
+ return;
+
+ if (!BUFFERP (w->contents))
+ 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;
+ 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_ctrl_n_or_p (&ctrlNP);
+
+ /* Compute granularity from movement distance.
+ Prefer robust line-range comparison for vertical movement,
+ otherwise single char (1) or unknown (0). */
+ NSInteger granularity = ns_ax_text_selection_granularity_unknown;
+ [self ensureTextCache];
+ if (cachedText && oldPoint > 0)
+ {
+ ptrdiff_t delta = point - oldPoint;
+ if (delta == 1 || delta == -1)
+ granularity = ns_ax_text_selection_granularity_character; /* Character. */
+ else
+ {
+ 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; /* Line. */
+
+ }
+ }
+
+ /* Force line semantics for explicit C-n/C-p keystrokes.
+ This isolates the key-path difference from arrow-down/up. */
+ if (isCtrlNP)
+ {
+ direction = (ctrlNP > 0
+ ? ns_ax_text_selection_direction_next
+ : ns_ax_text_selection_direction_previous);
+ granularity = ns_ax_text_selection_granularity_line;
+ }
+
+ NSDictionary *moveInfo = @{
+ @"AXTextStateChangeType": @(ns_ax_text_state_change_selection_move),
+ @"AXTextSelectionDirection": @(direction),
+ @"AXTextSelectionGranularity": @(granularity),
+ @"AXTextChangeElement": self
+ };
+ NSAccessibilityPostNotificationWithUserInfo (
+ self,
+ NSAccessibilitySelectedTextChangedNotification,
+ moveInfo);
+
+ /* C-n/C-p (`next-line' / `previous-line') can diverge from
+ arrow-down/up command paths in some major modes (completion list,
+ Dired, etc.). Emit an explicit line announcement for this basic
+ line-motion path so VoiceOver tracks the Emacs point reliably. */
+ if ([self isAccessibilityFocused]
+ && cachedText
+ && (isCtrlNP || ns_ax_command_is_basic_line_move ())
+ && (direction == ns_ax_text_selection_direction_next
+ || direction == ns_ax_text_selection_direction_previous))
+ {
+ 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])
+ {
+ NSString *lineText = [cachedText substringWithRange:lineRange];
+ lineText = [lineText stringByTrimmingCharactersInSet:
+ [NSCharacterSet whitespaceAndNewlineCharacterSet]];
+ if ([lineText length] > 0)
+ {
+ NSDictionary *annInfo = @{
+ NSAccessibilityAnnouncementKey: lineText,
+ 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;
+
+ struct buffer *oldb2 = current_buffer;
+ if (b != current_buffer)
+ set_buffer_internal_1 (b);
+
+ /* 1) Prefer explicit completion candidate property when present. */
+ Lisp_Object cstr = Fget_char_property (make_fixnum (point),
+ intern ("completion--string"),
+ Qnil);
+ if (STRINGP (cstr))
+ announceText = [NSString stringWithLispString: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);
+ ptrdiff_t s2 = point;
+ ptrdiff_t e2 = point;
+
+ while (s2 > begv2)
+ {
+ Lisp_Object prev = Fget_char_property (
+ make_fixnum (s2 - 1), Qmouse_face, Qnil);
+ if (!NILP (Fequal (prev, mf)))
+ s2--;
+ else
+ break;
+ }
+
+ while (e2 < zv2)
+ {
+ Lisp_Object cur = Fget_char_property (
+ make_fixnum (e2), Qmouse_face, Qnil);
+ if (!NILP (Fequal (cur, mf)))
+ e2++;
+ else
+ break;
+ }
+
+ 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 = intern ("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: 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;
+ }
+ }
+
+ if (b != oldb2)
+ set_buffer_internal_1 (oldb2);
+
+ /* 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
+ {
+ if ([self isAccessibilityFocused])
+ {
+ self.cachedCompletionAnnouncement = nil;
+ self.cachedCompletionOverlayStart = 0;
+ self.cachedCompletionOverlayEnd = 0;
+ self.cachedCompletionPoint = 0;
+ }
+ else
+ {
+ [self ensureTextCache];
+ if (cachedText)
+ {
+ NSString *announceText = nil;
+ ptrdiff_t currentOverlayStart = 0;
+ ptrdiff_t currentOverlayEnd = 0;
+ struct buffer *oldb2 = current_buffer;
+ if (b != current_buffer)
+ set_buffer_internal_1 (b);
+
+ if (ns_ax_find_completion_overlay_range (b, point,
+ &currentOverlayStart,
+ &currentOverlayEnd))
+ {
+ announceText = ns_ax_completion_text_for_span (self, b,
+ currentOverlayStart,
+ currentOverlayEnd,
+ cachedText);
+ }
+
+ if (b != oldb2)
+ set_buffer_internal_1 (oldb2);
+
+ 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;
+ }
+ }
+ }
+ }
+}
+
+@end
+
+
+@implementation EmacsAccessibilityModeLine
+
+- (NSAccessibilityRole)accessibilityRole
+{
+ return NSAccessibilityStaticTextRole;
+}
+
+- (NSString *)accessibilityLabel
+{
+ struct window *w = self.emacsWindow;
+ 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
+{
+ struct window *w = self.emacsWindow;
+ if (!w)
+ return @"";
+ return ns_ax_mode_line_text (w);
+}
+
+- (NSRect)accessibilityFrame
+{
+ struct window *w = self.emacsWindow;
+ 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
{
@@ -8237,6 +9860,28 @@ ns_in_echo_area (void)
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 +11119,298 @@ ns_in_echo_area (void)
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.emacsWindow = w;
+ [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.emacsWindow = w;
+ [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
+{
+ 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]]
+ && elem.emacsWindow)
+ [existing setObject:elem
+ forKey:[NSValue valueWithPointer:
+ elem.emacsWindow]];
+ }
+ }
+
+ 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];
+
+ struct window *sel = XWINDOW (emacsframe->selected_window);
+ for (EmacsAccessibilityElement *elem in accessibilityElements)
+ {
+ if ([elem isKindOfClass:[EmacsAccessibilityBuffer class]]
+ && elem.emacsWindow == sel)
+ 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
+{
+ 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];
+ 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.emacsWindow;
+ 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
+{
+ /* Return cursor screen rect. AT tools call this with the
+ selectedTextRange to locate the insertion point. */
+ 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];
+}
+
+- (NSRect)accessibilityFrameForRange:(NSRange)range
+{
+ return [self accessibilityBoundsForRange:range];
+}
+
+/* ---- 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 */
@@ -9941,6 +11878,14 @@ nswindow_orderedIndex_sort (id w1, id w2, void *c)
return [super accessibilityAttributeValue:attribute];
}
+
+- (id)accessibilityFocusedUIElement
+{
+ EmacsView *view = (EmacsView *)[self delegate];
+ if (view && [view respondsToSelector:@selector(accessibilityFocusedUIElement)])
+ return [view accessibilityFocusedUIElement];
+ return self;
+}
#endif /* NS_IMPL_COCOA */
/* Constrain size and placement of a frame.
--
2.43.0