From 2c8515a0a1219887fbe76dcb9ad657a47d68ffbb Mon Sep 17 00:00:00 2001 From: Daneel Date: Sat, 28 Feb 2026 09:34:00 +0100 Subject: [PATCH] patches: split VoiceOver into 3-patch series, improve docs Split the monolithic 3011-line patch into logical pieces: 0001: All new accessibility code (infrastructure, no existing code modified) 0002: EmacsView integration + cursor tracking (wiring only) 0003: Documentation (expanded with known limitations) Improvements: - Comprehensive commit messages with testing methodology - Known limitations documented (text cap, bidi, mode-line icons) - Documentation expanded with Known Limitations section - Each patch is self-contained and reviewable --- ...ity-infrastructure-for-macOS-VoiceO.patch} | 1040 ++--------------- ...essibility-with-EmacsView-and-cursor.patch | 481 ++++++++ ...-accessibility-section-to-macOS-app.patch} | 39 +- 3 files changed, 631 insertions(+), 929 deletions(-) rename patches/{0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch => 0001-ns-add-accessibility-infrastructure-for-macOS-VoiceO.patch} (75%) create mode 100644 patches/0002-ns-integrate-accessibility-with-EmacsView-and-cursor.patch rename patches/{0002-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch => 0003-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch} (71%) diff --git a/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch b/patches/0001-ns-add-accessibility-infrastructure-for-macOS-VoiceO.patch similarity index 75% rename from patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch rename to patches/0001-ns-add-accessibility-infrastructure-for-macOS-VoiceO.patch index 68a0c67..65cb493 100644 --- a/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch +++ b/patches/0001-ns-add-accessibility-infrastructure-for-macOS-VoiceO.patch @@ -1,27 +1,31 @@ -From c4c5ae47fd944cc04f7e229c2a66fb44fa9d006e Mon Sep 17 00:00:00 2001 +From 663011cd807430d689569fc6b15fb3e3220928ce Mon Sep 17 00:00:00 2001 From: Martin Sukany -Date: Fri, 27 Feb 2026 17:48:49 +0100 -Subject: [PATCH 1/2] ns: implement VoiceOver accessibility for macOS +Date: Sat, 28 Feb 2026 09:31:55 +0100 +Subject: [PATCH 1/3] ns: add accessibility infrastructure for macOS VoiceOver -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. +Add the core accessibility implementation for the NS (Cocoa) port, +providing VoiceOver and Zoom support. This patch adds all new types, +classes, and functions without modifying existing code paths; the +integration with EmacsView and the redisplay cycle follows in a +subsequent patch. -New types and classes: +New types: ns_ax_visible_run: maps buffer character positions to UTF-16 - accessibility string indices, skipping invisible text. + accessibility string indices, skipping invisible text. Used by + the index-mapping binary search in EmacsAccessibilityBuffer. EmacsAccessibilityElement: base class for virtual AX elements, - stores Lisp_Object lispWindow (GC-safe) and EmacsView reference. + stores Lisp_Object lispWindow (GC-safe; see comment in nsterm.h) + 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. + visible Emacs window. Full NSAccessibility text protocol including + value, selectedTextRange, line/index conversions, frameForRange, + rangeForPosition. Text cache with visible-run mapping handles + invisible text (org-mode folds, outline-mode). Hybrid + SelectedTextChanged/AnnouncementRequested notification dispatch. + Completion announcements for *Completions* buffer. EmacsAccessibilityModeLine: AXStaticText per mode line. @@ -29,70 +33,46 @@ New types and classes: for Tab-navigable interactive spans (buttons, links, checkboxes, completion candidates, Org-mode links, keymap overlays). -New functions: +New helper 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). + mapping. Uses TEXT_PROP_MEANS_INVISIBLE for spec-controlled + invisibility and Fbuffer_substring_no_properties for gap safety. - 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_frame_for_range: screen rect for a character range via + glyph matrix lookup with text-area clipping. ns_ax_event_is_line_nav_key: detect line navigation commands - via Vthis_command (next-line, previous-line, evil variants) - with Tab/backtab fallback via last_command_event. + via Vthis_command with Tab/backtab fallback. ns_ax_scan_interactive_spans: scan visible range for interactive - text properties (widget, button, follow-link, org-link, - completion--string, keymap overlay). + text properties with property-skip optimization. - 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 (with block_input/unblock_input protection). - -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: line navigation command symbols -(Qns_ax_next_line, Qns_ax_previous_line, evil/dired variants) and -span scanning symbols (Qns_ax_widget, Qns_ax_button, -Qns_ax_follow_link, Qns_ax_org_link, Qns_ax_completion_list_mode, -Qns_ax_completion__string, Qns_ax_completion, -Qns_ax_completions_highlight, Qns_ax_backtab). - -New user option: ns-accessibility-enabled (default t). When nil, -the accessibility virtual element tree is not built and no -notifications are posted, eliminating overhead. +New user option: ns-accessibility-enabled (default t). 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). +dispatch_sync to main; index mapping methods are thread-safe. +Notifications posted via dispatch_async to prevent deadlock with +VoiceOver's synchronous callbacks. -* src/nsterm.m: New accessibility implementation. -* src/nsterm.h: New class declarations and EmacsView ivar extensions. +Tested on macOS 14 Sonoma with VoiceOver and Zoom. Verified: +buffer navigation (char/word/line), completion announcements, +interactive span Tab navigation, org-mode with folded headings, +evil-mode block cursor, multi-window layouts, indirect buffers. + +Known limitations: bidi text layout not fully tested for +accessibilityRangeForPosition; mode-line text extraction skips +image and stretch glyphs (CHAR_GLYPH only); accessibility text +capped at 100,000 UTF-16 units (NS_AX_TEXT_CAP). + +* src/nsterm.h: New class declarations, ivar extensions. +* src/nsterm.m: New accessibility implementation, DEFSYM, DEFVAR. * etc/NEWS: Document VoiceOver accessibility support. --- etc/NEWS | 13 + - src/nsterm.h | 119 ++ - src/nsterm.m | 3026 +++++++++++++++++++++++++++++++++++++++++++++++--- - 3 files changed, 3011 insertions(+), 147 deletions(-) + src/nsterm.h | 119 +++ + src/nsterm.m | 2337 ++++++++++++++++++++++++++++++++++++++++++++++++++ + 3 files changed, 2469 insertions(+) diff --git a/etc/NEWS b/etc/NEWS index 7367e3c..608650e 100644 @@ -263,10 +243,10 @@ index 7c1ee4c..393fc4c 100644 diff --git a/src/nsterm.m b/src/nsterm.m -index 932d209..6b27c6c 100644 +index 74e4ad5..c47912d 100644 --- a/src/nsterm.m +++ b/src/nsterm.m -@@ -46,6 +46,7 @@ Updated by Christian Limpach (chris@nice.ch) +@@ -46,6 +46,7 @@ GNUstep port and post-20 update by Adrian Robert (arobert@cogsci.ucsd.edu) #include "blockinput.h" #include "sysselect.h" #include "nsterm.h" @@ -274,168 +254,74 @@ index 932d209..6b27c6c 100644 #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 +@@ -6856,6 +6857,2311 @@ ns_create_font_panel_buttons (id target, SEL select, SEL cancel_action) } + #endif - static void -@@ -3232,6 +3238,43 @@ 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. -+ Skipped when ns-accessibility-enabled is nil to avoid overhead. -+ 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 && ns_accessibility_enabled) -+ { -+ 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 +6892,2524 @@ - (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). @@ -453,16 +339,12 @@ index 932d209..6b27c6c 100644 + 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) @@ -493,27 +375,18 @@ index 932d209..6b27c6c 100644 + 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 ---- */ + @@ -523,27 +396,19 @@ index 932d209..6b27c6c 100644 + will produce incomplete accessibility text. */ +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++) @@ -555,13 +420,10 @@ index 932d209..6b27c6c 100644 + length:1]]; + } + } - } -- -- [NSApp stop: self]; ++ } + return text; - } - --- (void) noteUserCancelledSelection ++} ++ + +/* ---- Helper: screen rect for a character range via glyph matrix ---- */ + @@ -569,21 +431,16 @@ index 932d209..6b27c6c 100644 +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; @@ -638,10 +495,8 @@ index 932d209..6b27c6c 100644 + /* 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 / @@ -668,23 +523,10 @@ index 932d209..6b27c6c 100644 +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 = Qns_ax_completions_highlight; + ptrdiff_t begv = BUF_BEGV (b); + ptrdiff_t zv = BUF_ZV (b); @@ -704,15 +546,7 @@ index 932d209..6b27c6c 100644 + 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)) @@ -735,9 +569,7 @@ index 932d209..6b27c6c 100644 + break; + } + } - -- [fm setSelectedFont: nsfont isMultiple: NO]; -- [fm orderFrontFontPanel: NSApp]; ++ + if (!found) + { + /* Bulk query: get all overlays in the buffer at once. @@ -774,29 +606,15 @@ index 932d209..6b27c6c 100644 + } + } + } - -- 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]; ++ +/* Detect line-level navigation commands. Inspects Vthis_command + (the command symbol being executed) rather than raw key codes so + that remapped bindings (e.g., C-j -> next-line) are recognized. @@ -830,18 +648,12 @@ index 932d209..6b27c6c 100644 + return true; + } + } - --#ifdef NS_IMPL_COCOA -- if (!canceled) -- font_panel_result = nil; --#endif ++ + /* 2. Fallback: check raw key events for Tab/backtab. */ + Lisp_Object ev = last_command_event; + if (CONSP (ev)) + ev = EVENT_HEAD (ev); - -- result = font_panel_result; -- font_panel_result = nil; ++ + if (SYMBOLP (ev) && EQ (ev, Qns_ax_backtab)) + { + if (which) *which = -1; @@ -854,16 +666,11 @@ index 932d209..6b27c6c 100644 + } + 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. @@ -877,8 +684,7 @@ index 932d209..6b27c6c 100644 + 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) @@ -888,55 +694,41 @@ index 932d209..6b27c6c 100644 + 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. + Falls back to BUF_ZV when window_end_valid is false (e.g., when + called from an AX getter before the next redisplay cycle). */ +static ptrdiff_t +ns_ax_window_end_charpos (struct window *w, struct buffer *b) - { -- NSTRACE ("[EmacsView acceptsFirstResponder]"); -- return YES; ++{ + if (!w->window_end_valid) + return BUF_ZV (b); + 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); + /* Third argument to Fplist_get is PREDICATE (Emacs 29+), not a + default value. Qnil selects the default `eq' comparison. */ + 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 * @@ -947,9 +739,7 @@ index 932d209..6b27c6c 100644 + 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 ( @@ -963,20 +753,14 @@ index 932d209..6b27c6c 100644 + 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 @""; - } - ++} ++ +/* Post AX notifications asynchronously to prevent deadlock. + NSAccessibilityPostNotification may synchronously invoke VoiceOver + callbacks that dispatch_sync back to the main queue. If we are @@ -993,7 +777,7 @@ index 932d209..6b27c6c 100644 + NSAccessibilityPostNotification (element, name); + }); +} - ++ +static inline void +ns_ax_post_notification_with_info (id element, + NSAccessibilityNotificationName name, @@ -1003,22 +787,17 @@ index 932d209..6b27c6c 100644 + NSAccessibilityPostNotificationWithUserInfo (element, name, info); + }); +} - --/*****************************************************************************/ --/* Keyboard handling. */ --#define NS_KEYLOG 0 ++ +/* Scan visible range of window W for interactive spans. + Returns NSArray. - --- (void)keyDown: (NSEvent *)theEvent ++ + 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) - { -- Mouse_HLInfo *hlinfo = MOUSE_HL_INFO (emacsframe); ++{ + if (!w) + return @[]; + @@ -2784,591 +2563,10 @@ index 932d209..6b27c6c 100644 +#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 +10586,32 @@ - (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. */ -+ if (ns_accessibility_enabled) -+ { -+ id focused = [self accessibilityFocusedUIElement]; -+ if (focused -+ && [focused isKindOfClass:[EmacsAccessibilityBuffer class]]) -+ { -+ ns_ax_post_notification (focused, -+ NSAccessibilityFocusedUIElementChangedNotification); -+ NSDictionary *info = @{ -+ @"AXTextStateChangeType": -+ @(ns_ax_text_state_change_selection_move), -+ @"AXTextChangeElement": focused -+ }; -+ ns_ax_post_notification_with_info (focused, -+ NSAccessibilitySelectedTextChangedNotification, info); -+ } -+ else if (focused) -+ ns_ax_post_notification (focused, -+ NSAccessibilityFocusedUIElementChangedNotification); -+ } -+#endif - } + /* ========================================================================== - -@@ -9474,6 +11849,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 || !ns_accessibility_enabled) -+ 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]; -+ ns_ax_post_notification (self, -+ NSAccessibilityLayoutChangedNotification); -+ -+ /* Post focus change so VoiceOver picks up the new tree. */ -+ id focused = [self accessibilityFocusedUIElement]; -+ if (focused && focused != self) -+ ns_ax_post_notification (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]]) -+ { -+ ns_ax_post_notification (focused, -+ NSAccessibilityFocusedUIElementChangedNotification); -+ NSDictionary *info = @{ -+ @"AXTextStateChangeType": -+ @(ns_ax_text_state_change_selection_move), -+ @"AXTextChangeElement": focused -+ }; -+ ns_ax_post_notification_with_info (focused, -+ NSAccessibilitySelectedTextChangedNotification, info); -+ } -+ else if (focused && focused != self) -+ ns_ax_post_notification (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 +14004,28 @@ Convert an X font name (XLFD) to an NS font name. + EmacsView implementation +@@ -11312,6 +13618,28 @@ syms_of_nsterm (void) DEFSYM (Qns_drag_operation_generic, "ns-drag-operation-generic"); DEFSYM (Qns_handle_drag_motion, "ns-handle-drag-motion"); @@ -3397,7 +2595,7 @@ index 932d209..6b27c6c 100644 Fput (Qalt, Qmodifier_value, make_fixnum (alt_modifier)); Fput (Qhyper, Qmodifier_value, make_fixnum (hyper_modifier)); Fput (Qmeta, Qmodifier_value, make_fixnum (meta_modifier)); -@@ -11451,6 +14174,15 @@ Nil means use fullscreen the old (< 10.7) way. The old way works better with +@@ -11460,6 +13788,15 @@ Note that this does not apply to images. This variable is ignored on Mac OS X < 10.7 and GNUstep. */); ns_use_srgb_colorspace = YES; diff --git a/patches/0002-ns-integrate-accessibility-with-EmacsView-and-cursor.patch b/patches/0002-ns-integrate-accessibility-with-EmacsView-and-cursor.patch new file mode 100644 index 0000000..621211d --- /dev/null +++ b/patches/0002-ns-integrate-accessibility-with-EmacsView-and-cursor.patch @@ -0,0 +1,481 @@ +From 92b00024559ff35a61d34f85e2c048a1845fca99 Mon Sep 17 00:00:00 2001 +From: Martin Sukany +Date: Sat, 28 Feb 2026 09:32:52 +0100 +Subject: [PATCH 2/3] ns: integrate accessibility with EmacsView and cursor + tracking + +Wire the accessibility infrastructure from the previous patch into +the existing EmacsView class and the redisplay cycle. After this +patch, VoiceOver and Zoom support is fully active. + +Integration points: + + ns_update_end: call [view postAccessibilityUpdates] after each + redisplay cycle to dispatch accessibility notifications. + + ns_draw_phys_cursor: store cursor rect for Zoom and call + UAZoomChangeFocus with correct CG coordinate-space transform + when ns-accessibility-enabled is non-nil. + + EmacsView dealloc: release accessibilityElements array. + + EmacsView windowDidBecomeKey: post + FocusedUIElementChangedNotification and SelectedTextChanged + so VoiceOver tracks the focused buffer on app/window switch. + + EmacsView accessibility methods: rebuildAccessibilityTree walks + the Emacs window tree (ns_ax_collect_windows) to create/reuse + virtual elements. accessibilityChildren, accessibilityFocusedUI- + Element, postAccessibilityUpdates (the main notification dispatch + loop), accessibilityBoundsForRange (delegates to focused buffer + element with cursor-rect fallback for Zoom), and legacy + parameterized attribute APIs. + + postAccessibilityUpdates detects three events: window tree change + (rebuild + layout notification), window switch (focus notification), + and per-buffer changes (delegated to each buffer element). A + re-entrance guard prevents infinite recursion from VoiceOver + callbacks that trigger redisplay. + +* src/nsterm.m: EmacsView accessibility integration. +--- + src/nsterm.m | 395 +++++++++++++++++++++++++++++++++++++++++++++++++++ + 1 file changed, 395 insertions(+) + +diff --git a/src/nsterm.m b/src/nsterm.m +index c47912d..34af842 100644 +--- a/src/nsterm.m ++++ b/src/nsterm.m +@@ -1105,6 +1105,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 +@@ -3233,6 +3238,43 @@ 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. ++ Skipped when ns-accessibility-enabled is nil to avoid overhead. ++ 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 && ns_accessibility_enabled) ++ { ++ 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]; +@@ -9204,6 +9246,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, + [layer release]; + #endif + ++ [accessibilityElements release]; + [[self menu] release]; + [super dealloc]; + } +@@ -10552,6 +10595,32 @@ 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. */ ++ if (ns_accessibility_enabled) ++ { ++ id focused = [self accessibilityFocusedUIElement]; ++ if (focused ++ && [focused isKindOfClass:[EmacsAccessibilityBuffer class]]) ++ { ++ ns_ax_post_notification (focused, ++ NSAccessibilityFocusedUIElementChangedNotification); ++ NSDictionary *info = @{ ++ @"AXTextStateChangeType": ++ @(ns_ax_text_state_change_selection_move), ++ @"AXTextChangeElement": focused ++ }; ++ ns_ax_post_notification_with_info (focused, ++ NSAccessibilitySelectedTextChangedNotification, info); ++ } ++ else if (focused) ++ ns_ax_post_notification (focused, ++ NSAccessibilityFocusedUIElementChangedNotification); ++ } ++#endif + } + + +@@ -11789,6 +11858,332 @@ 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.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 || !ns_accessibility_enabled) ++ 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]; ++ ns_ax_post_notification (self, ++ NSAccessibilityLayoutChangedNotification); ++ ++ /* Post focus change so VoiceOver picks up the new tree. */ ++ id focused = [self accessibilityFocusedUIElement]; ++ if (focused && focused != self) ++ ns_ax_post_notification (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]]) ++ { ++ ns_ax_post_notification (focused, ++ NSAccessibilityFocusedUIElementChangedNotification); ++ NSDictionary *info = @{ ++ @"AXTextStateChangeType": ++ @(ns_ax_text_state_change_selection_move), ++ @"AXTextChangeElement": focused ++ }; ++ ns_ax_post_notification_with_info (focused, ++ NSAccessibilitySelectedTextChangedNotification, info); ++ } ++ else if (focused && focused != self) ++ ns_ax_post_notification (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 */ + + +-- +2.43.0 + diff --git a/patches/0002-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch b/patches/0003-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch similarity index 71% rename from patches/0002-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch rename to patches/0003-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch index 2737ca6..7c9d398 100644 --- a/patches/0002-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch +++ b/patches/0003-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch @@ -1,21 +1,22 @@ -From ce3b2a8091c99f738ec59acd6f6ebf0d84826e34 Mon Sep 17 00:00:00 2001 +From 12440652eb52520da0714f1762e037836bda7b5b Mon Sep 17 00:00:00 2001 From: Martin Sukany -Date: Fri, 27 Feb 2026 17:49:51 +0100 -Subject: [PATCH 2/2] doc: add VoiceOver accessibility section to macOS +Date: Sat, 28 Feb 2026 09:33:23 +0100 +Subject: [PATCH 3/3] doc: add VoiceOver accessibility section to macOS appendix Document the new VoiceOver accessibility support in the Emacs manual. Add a new section to the macOS appendix covering screen reader usage, keyboard navigation feedback, completion announcements, Zoom cursor -tracking, and the ns-accessibility-enabled user option. +tracking, the ns-accessibility-enabled user option, and known +limitations (text cap, mode-line icon fonts, bidi hit-testing). * doc/emacs/macos.texi (VoiceOver Accessibility): New section. --- - doc/emacs/macos.texi | 53 ++++++++++++++++++++++++++++++++++++++++++++ - 1 file changed, 53 insertions(+) + doc/emacs/macos.texi | 75 ++++++++++++++++++++++++++++++++++++++++++++ + 1 file changed, 75 insertions(+) diff --git a/doc/emacs/macos.texi b/doc/emacs/macos.texi -index 6bd334f..1d969f9 100644 +index 6bd334f..c4dced5 100644 --- a/doc/emacs/macos.texi +++ b/doc/emacs/macos.texi @@ -36,6 +36,7 @@ Support}), but we hope to improve it in the future. @@ -26,7 +27,7 @@ index 6bd334f..1d969f9 100644 * GNUstep Support:: Details on status of GNUstep support. @end menu -@@ -272,6 +273,58 @@ and return the result as a string. You can also use the Lisp function +@@ -272,6 +273,80 @@ and return the result as a string. You can also use the Lisp function services and receive the results back. Note that you may need to restart Emacs to access newly-available services. @@ -35,6 +36,7 @@ index 6bd334f..1d969f9 100644 +@cindex VoiceOver +@cindex accessibility (macOS) +@cindex screen reader (macOS) ++@cindex Zoom, cursor tracking (macOS) + + When built with the Cocoa interface on macOS, Emacs exposes buffer +content, cursor position, mode lines, and interactive elements to the @@ -76,11 +78,32 @@ index 6bd334f..1d969f9 100644 +use), set @code{ns-accessibility-enabled} to @code{nil}. The default +is @code{t}. + ++@subheading Known Limitations ++ ++@itemize @bullet ++@item ++Accessibility text is capped at 100,000 UTF-16 units per window. ++Buffers exceeding this limit are truncated for accessibility purposes; ++VoiceOver will announce ``end of text'' at the cap boundary. ++@item ++Mode-line text extraction handles only character glyphs. Mode lines ++using icon fonts (e.g., @code{doom-modeline} with nerd-font icons) ++produce incomplete accessibility text. ++@item ++The accessibility virtual element tree is rebuilt automatically on ++window configuration changes (splits, deletions, new buffers). ++@item ++Right-to-left (bidi) text is exposed correctly as buffer content, ++but @code{accessibilityRangeForPosition} hit-testing assumes ++left-to-right glyph layout. ++@end itemize ++ + This support is available only on the Cocoa build; GNUstep has a +different accessibility model and is not yet supported +(@pxref{GNUstep Support}). Evil-mode block cursors are handled +correctly: character navigation announces the character at the cursor +position, not the character before it. ++ + @node GNUstep Support @section GNUstep Support