diff --git a/patches/0000-ns-integrate-with-macOS-Zoom-for-cursor-tracking.patch b/patches/0000-ns-integrate-with-macOS-Zoom-for-cursor-tracking.patch index 0d1272a..c358012 100644 --- a/patches/0000-ns-integrate-with-macOS-Zoom-for-cursor-tracking.patch +++ b/patches/0000-ns-integrate-with-macOS-Zoom-for-cursor-tracking.patch @@ -1,7 +1,7 @@ -From 0470786c91eb4a464d8580387b83a4a8d4e4f8eb Mon Sep 17 00:00:00 2001 +From fcc1826baee5b424d5fdc176239c5675aee6159b Mon Sep 17 00:00:00 2001 From: Martin Sukany Date: Sat, 28 Feb 2026 22:39:35 +0100 -Subject: [PATCH] ns: integrate with macOS Zoom for cursor tracking +Subject: [PATCH 1/9] ns: integrate with macOS Zoom for cursor tracking Inform macOS Zoom of the text cursor position so the zoomed viewport follows keyboard focus in Emacs. Also track completion candidates so @@ -86,7 +86,7 @@ index 932d209f56..88c9251c18 100644 #endif static EmacsMenu *dockMenu; -@@ -1081,6 +1086,284 @@ static NSRect constrain_frame_rect(NSRect frameRect, bool isFullscreen) +@@ -1081,6 +1086,281 @@ static NSRect constrain_frame_rect(NSRect frameRect, bool isFullscreen) } @@ -103,9 +103,9 @@ index 932d209f56..88c9251c18 100644 + +/* Cached wrapper around ns_zoom_enabled_p (). + ns_zoom_enabled_p () performs a synchronous Mach IPC roundtrip to the -+ macOS Accessibility server (~50-200 uss per call). With call sites ++ macOS Accessibility server (~50-200 µs per call). With call sites + in ns_draw_window_cursor, ns_update_end, and ns_zoom_track_completion, -+ the overhead accumulates to ~150-600 uss per redisplay cycle. Zoom ++ the overhead accumulates to ~150-600 µs per redisplay cycle. Zoom + state changes only on explicit user action in System Settings, so a + 1-second TTL is safe and indistinguishable from querying every frame. + Uses CFAbsoluteTimeGetCurrent() (~5 ns, a VDSO read) for timing. */ @@ -130,9 +130,6 @@ index 932d209f56..88c9251c18 100644 + Defined here so the Zoom patch compiles independently of the + VoiceOver patches. */ +static bool -+/* Note: ns_ax_face_is_selected in the VoiceOver series has identical -+ logic. The duplication is intentional: both series are -+ independent and must compile standalone. */ +ns_zoom_face_is_selected (Lisp_Object face) +{ + if (SYMBOLP (face)) diff --git a/patches/0001-ns-add-accessibility-base-classes-and-text-extractio.patch b/patches/0001-ns-add-accessibility-base-classes-and-text-extractio.patch index 6776091..b5ed4ba 100644 --- a/patches/0001-ns-add-accessibility-base-classes-and-text-extractio.patch +++ b/patches/0001-ns-add-accessibility-base-classes-and-text-extractio.patch @@ -1,7 +1,7 @@ -From 6c075e29fccc7dc6e9df693c4123ce0001a2dbfc Mon Sep 17 00:00:00 2001 +From 488b91178be9a2dfd022533fce6b4adcd5c2ead0 Mon Sep 17 00:00:00 2001 From: Martin Sukany Date: Sat, 28 Feb 2026 12:58:11 +0100 -Subject: [PATCH 1/8] ns: add accessibility base classes and text extraction +Subject: [PATCH 2/9] ns: add accessibility base classes and text extraction Add the foundation for macOS VoiceOver accessibility in the NS (Cocoa) port. No existing code paths are modified. @@ -33,7 +33,7 @@ set non-nil automatically when an AT is detected at startup. 2 files changed, 585 insertions(+) diff --git a/src/nsterm.h b/src/nsterm.h -index ea6e7ba4f5..5746e9e9bd 100644 +index ea6e7ba4f5..f245675513 100644 --- a/src/nsterm.h +++ b/src/nsterm.h @@ -453,6 +453,124 @@ enum ns_return_frame_mode @@ -44,7 +44,7 @@ index ea6e7ba4f5..5746e9e9bd 100644 + + Accessibility virtual elements (macOS / Cocoa only) + -+ ========================================================================= */ ++ ========================================================================== */ + +#ifdef NS_IMPL_COCOA +@class EmacsView; @@ -52,11 +52,11 @@ index ea6e7ba4f5..5746e9e9bd 100644 +/* Base class for virtual accessibility elements attached to EmacsView. */ +@interface EmacsAccessibilityElement : NSAccessibilityElement +@property (nonatomic, unsafe_unretained) EmacsView *emacsView; -+/* Lisp window object --- safe across GC cycles. ++/* Lisp window object — safe across GC cycles. + GC safety: these Lisp_Objects are NOT visible to GC via staticpro + or the specpdl stack. This is safe because: + (1) Emacs GC runs only on the main thread, at well-defined safe -+ points during Lisp evaluation --- never during redisplay. ++ points during Lisp evaluation — never during redisplay. + (2) Accessibility elements are owned by EmacsView which belongs to + an active frame; windows referenced here are always reachable + from the frame's window tree until rebuildAccessibilityTree @@ -81,7 +81,7 @@ index ea6e7ba4f5..5746e9e9bd 100644 + NSUInteger ax_length; /* Length in accessibility string (UTF-16 units). */ +} ns_ax_visible_run; + -+/* Virtual AXTextArea element --- one per visible Emacs window (buffer). */ ++/* Virtual AXTextArea element — one per visible Emacs window (buffer). */ +@interface EmacsAccessibilityBuffer + : EmacsAccessibilityElement +{ @@ -119,12 +119,12 @@ index ea6e7ba4f5..5746e9e9bd 100644 +- (void)invalidateInteractiveSpans; +@end + -+/* Virtual AXStaticText element --- one per mode line. */ ++/* Virtual AXStaticText element — one per mode line. */ +@interface EmacsAccessibilityModeLine : EmacsAccessibilityElement +@end + +/* Span types for interactive AX child elements. */ -+typedef NS_ENUM (NSInteger, EmacsAXSpanType) ++typedef NS_ENUM(NSInteger, EmacsAXSpanType) +{ + EmacsAXSpanTypeNone = -1, + EmacsAXSpanTypeButton = 0, @@ -200,7 +200,7 @@ index 88c9251c18..9d36de66f9 100644 #include "systime.h" #include "character.h" #include "xwidget.h" -@@ -7201,6 +7202,436 @@ - (BOOL)fulfillService: (NSString *)name withArg: (NSString *)arg +@@ -7201,6 +7202,432 @@ - (BOOL)fulfillService: (NSString *)name withArg: (NSString *)arg } #endif @@ -208,7 +208,7 @@ index 88c9251c18..9d36de66f9 100644 + + Accessibility virtual elements (macOS / Cocoa only) + -+ ========================================================================= */ ++ ========================================================================== */ + +#ifdef NS_IMPL_COCOA + @@ -249,12 +249,8 @@ index 88c9251c18..9d36de66f9 100644 + + specpdl_ref count = SPECPDL_INDEX (); + record_unwind_current_buffer (); -+ /* block_input must precede record_unwind_protect_void (unblock_input): -+ if anything between SPECPDL_INDEX and block_input were to throw, -+ the unwind handler would call unblock_input without a matching -+ block_input, corrupting the input-blocking reference count. */ -+ block_input (); + record_unwind_protect_void (unblock_input); ++ block_input (); + if (b != current_buffer) + set_buffer_internal_1 (b); + @@ -295,7 +291,7 @@ index 88c9251c18..9d36de66f9 100644 + + /* 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 ++ 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)); @@ -376,7 +372,7 @@ index 88c9251c18..9d36de66f9 100644 + return NSZeroRect; + + /* charpos_start and charpos_len are already in buffer charpos -+ space --- the caller maps AX string indices through ++ 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; @@ -518,7 +514,7 @@ index 88c9251c18..9d36de66f9 100644 +} + +/* Build label for span [START, END) in BUF_OBJ. -+ Priority: completion--string -> buffer text -> help-echo. */ ++ Priority: completion--string → buffer text → help-echo. */ +static NSString * +ns_ax_get_span_label (ptrdiff_t start, ptrdiff_t end, + Lisp_Object buf_obj) diff --git a/patches/0002-ns-implement-buffer-accessibility-element-core-proto.patch b/patches/0002-ns-implement-buffer-accessibility-element-core-proto.patch index 1325edf..3dce851 100644 --- a/patches/0002-ns-implement-buffer-accessibility-element-core-proto.patch +++ b/patches/0002-ns-implement-buffer-accessibility-element-core-proto.patch @@ -1,7 +1,7 @@ -From 705bba0809db081f538c124aa900bc911de9b0ff Mon Sep 17 00:00:00 2001 +From ba093f98d3bb1281278170f01f0792c2b24cf94f Mon Sep 17 00:00:00 2001 From: Martin Sukany Date: Sat, 28 Feb 2026 12:58:11 +0100 -Subject: [PATCH 2/8] ns: implement buffer accessibility element (core +Subject: [PATCH 3/9] ns: implement buffer accessibility element (core protocol) Implement the NSAccessibility text protocol for Emacs buffer windows. @@ -17,10 +17,13 @@ loop is safe: it runs only on actual character modifications. (accessibilityIndexForCharpos:): O(1) fast path for pure-ASCII runs (ax_length == length); fall back to sequence walk for multi-byte runs. (charposForAccessibilityIndex:): Symmetric O(1) fast path. -(accessibilitySelectedTextRange, accessibilityLineForIndex:) -(accessibilityIndexForLine:, accessibilityRangeForIndex:) -(accessibilityStringForRange:, accessibilityFrameForRange:) -(accessibilityRangeForPosition:, setAccessibilitySelectedTextRange:) +(accessibilityRole, accessibilityLabel, accessibilityValue) +(accessibilityNumberOfCharacters, accessibilitySelectedText) +(accessibilitySelectedTextRange, accessibilityInsertionPointLineNumber) +(accessibilityRangeForLine:, accessibilityRangeForIndex:) +(accessibilityStyleRangeForIndex:, accessibilityFrameForRange:) +(accessibilityRangeForPosition:, accessibilityVisibleCharacterRange) +(accessibilityFrame, setAccessibilitySelectedTextRange:) (setAccessibilityFocused:): Implement NSAccessibility protocol methods. --- src/nsterm.m | 1115 ++++++++++++++++++++++++++++++++++++++++++++++++++ @@ -352,7 +355,7 @@ index 9d36de66f9..6256dbc22e 100644 + NSTRACE ("EmacsAccessibilityBuffer ensureTextCache"); + /* This method is only called from the main thread (AX getters + dispatch_sync to main first). Reads of cachedText/cachedTextModiff -+ below are therefore safe without @synchronized --- only the ++ below are therefore safe without @synchronized — only the + write section at the end needs synchronization to protect + against concurrent reads from AX server thread. */ + eassert ([NSThread isMainThread]); @@ -467,7 +470,7 @@ index 9d36de66f9..6256dbc22e 100644 + /* Binary search: runs are sorted by charpos (ascending). Find the + run whose [charpos, charpos+length) range contains the target, + or the nearest run after an invisible gap. O(log n) instead of -+ O(n) --- matters for org-mode with many folded sections. */ ++ O(n) — matters for org-mode with many folded sections. */ + NSUInteger lo = 0, hi = visibleRunCount; + while (lo < hi) + { @@ -516,10 +519,10 @@ index 9d36de66f9..6256dbc22e 100644 + +/* Convert accessibility string index to buffer charpos. + Safe to call from any thread: uses only cachedText (NSString) and -+ visibleRuns --- no Lisp calls. */ ++ visibleRuns — no Lisp calls. */ +- (ptrdiff_t)charposForAccessibilityIndex:(NSUInteger)ax_idx +{ -+ /* May be called from AX server thread --- synchronize. */ ++ /* May be called from AX server thread — synchronize. */ + @synchronized (self) + { + if (visibleRunCount == 0) @@ -561,7 +564,7 @@ index 9d36de66f9..6256dbc22e 100644 + return cp; + } + } -+ /* Past end --- return last charpos. */ ++ /* Past end — return last charpos. */ + if (lo > 0) + { + ns_ax_visible_run *last = &visibleRuns[visibleRunCount - 1]; @@ -583,7 +586,7 @@ index 9d36de66f9..6256dbc22e 100644 + deadlocking the AX server thread. This is prevented by: + + 1. validWindow checks WINDOW_LIVE_P and BUFFERP before every -+ Lisp access --- the window and buffer are verified live. ++ Lisp access — the window and buffer are verified live. + 2. All dispatch_sync blocks run on the main thread where no + concurrent Lisp code can modify state between checks. + 3. block_input prevents timer events and process output from diff --git a/patches/0003-ns-add-buffer-notification-dispatch-and-mode-line-el.patch b/patches/0003-ns-add-buffer-notification-dispatch-and-mode-line-el.patch index 6e69ecb..811d347 100644 --- a/patches/0003-ns-add-buffer-notification-dispatch-and-mode-line-el.patch +++ b/patches/0003-ns-add-buffer-notification-dispatch-and-mode-line-el.patch @@ -1,7 +1,7 @@ -From 94d2aa2736ec116e2965d02241ad8b20d8daf4bc Mon Sep 17 00:00:00 2001 +From 0566d2cf3bc1b6309b3b3dd1a048bac7c63937e9 Mon Sep 17 00:00:00 2001 From: Martin Sukany Date: Sat, 28 Feb 2026 12:58:11 +0100 -Subject: [PATCH 3/8] ns: add buffer notification dispatch and mode-line +Subject: [PATCH 4/9] ns: add buffer notification dispatch and mode-line element Add VoiceOver notification dispatch and mode-line readout. @@ -36,7 +36,7 @@ index 6256dbc22e..9e0e317237 100644 + + +/* =================================================================== -+ EmacsAccessibilityBuffer (Notifications) --- AX event dispatch ++ EmacsAccessibilityBuffer (Notifications) — AX event dispatch + + These methods notify VoiceOver of text and selection changes. + Called from the redisplay cycle (postAccessibilityUpdates). @@ -51,7 +51,7 @@ index 6256dbc22e..9e0e317237 100644 + if (point > self.cachedPoint + && point - self.cachedPoint == 1) + { -+ /* Single char inserted --- refresh cache and grab it. */ ++ /* Single char inserted — refresh cache and grab it. */ + [self invalidateTextCache]; + [self ensureTextCache]; + if (cachedText) @@ -70,7 +70,7 @@ index 6256dbc22e..9e0e317237 100644 + /* Update cachedPoint here so the selection-move branch does NOT + fire for point changes caused by edits. WebKit and Chromium + never send both ValueChanged and SelectedTextChanged for the -+ same user action --- they are mutually exclusive. */ ++ same user action — they are mutually exclusive. */ + self.cachedPoint = point; + + NSDictionary *change = @{ @@ -471,7 +471,7 @@ index 6256dbc22e..9e0e317237 100644 + } + + /* --- Cursor moved or selection changed --- -+ Use 'else if' --- edits and selection moves are mutually exclusive ++ Use 'else if' — edits and selection moves are mutually exclusive + per the WebKit/Chromium pattern. */ + else if (point != self.cachedPoint || markActive != self.cachedMarkActive) + { diff --git a/patches/0004-ns-add-interactive-span-elements-for-Tab-navigation.patch b/patches/0004-ns-add-interactive-span-elements-for-Tab-navigation.patch index 193f852..7cc44de 100644 --- a/patches/0004-ns-add-interactive-span-elements-for-Tab-navigation.patch +++ b/patches/0004-ns-add-interactive-span-elements-for-Tab-navigation.patch @@ -1,7 +1,7 @@ -From 3206d93511fe9337c4ca683a5dc1e6885ed9985c Mon Sep 17 00:00:00 2001 +From ce123c5b0c25467dd6fb6d4a2aeda59687fadefc Mon Sep 17 00:00:00 2001 From: Martin Sukany Date: Sat, 28 Feb 2026 12:58:11 +0100 -Subject: [PATCH 4/8] ns: add interactive span elements for Tab navigation +Subject: [PATCH 5/9] ns: add interactive span elements for Tab navigation * src/nsterm.m (ns_ax_scan_interactive_spans): New function; scans the visible portion of a buffer for interactive text properties @@ -14,14 +14,14 @@ elements with an AXPress action that sends a synthetic TAB keystroke. (accessibilityChildrenInNavigationOrder): Return cached span array, rebuilding lazily when interactiveSpansDirty is set. --- - src/nsterm.m | 292 +++++++++++++++++++++++++++++++++++++++++++++++++++ - 1 file changed, 292 insertions(+) + src/nsterm.m | 293 +++++++++++++++++++++++++++++++++++++++++++++++++++ + 1 file changed, 293 insertions(+) diff --git a/src/nsterm.m b/src/nsterm.m -index 9e0e317237..8aa5b6ac1b 100644 +index 9e0e317237..d65609cc79 100644 --- a/src/nsterm.m +++ b/src/nsterm.m -@@ -9346,6 +9346,298 @@ - (NSRect)accessibilityFrame +@@ -9346,6 +9346,299 @@ - (NSRect)accessibilityFrame @end @@ -76,6 +76,7 @@ index 9e0e317237..8aa5b6ac1b 100644 + EmacsAXSpanType span_type = EmacsAXSpanTypeNone; + Lisp_Object limit_prop = Qnil; + ++ /* Fplist_get third arg Qnil: use `eq' predicate (the default). */ + if (!NILP (Fplist_get (plist, Qns_ax_widget, Qnil))) + { + span_type = EmacsAXSpanTypeWidget; diff --git a/patches/0005-ns-integrate-accessibility-with-EmacsView-and-redisp.patch b/patches/0005-ns-integrate-accessibility-with-EmacsView-and-redisp.patch index 3a7fbb9..e690f16 100644 --- a/patches/0005-ns-integrate-accessibility-with-EmacsView-and-redisp.patch +++ b/patches/0005-ns-integrate-accessibility-with-EmacsView-and-redisp.patch @@ -1,7 +1,7 @@ -From f2e97ea6ba4ffc1c73e625f9d61636b7261cbecf Mon Sep 17 00:00:00 2001 +From 3f894218b771f2aa098f19dfb4bdc8b13408c8c8 Mon Sep 17 00:00:00 2001 From: Martin Sukany Date: Sat, 28 Feb 2026 12:58:11 +0100 -Subject: [PATCH 5/8] ns: integrate accessibility with EmacsView and redisplay +Subject: [PATCH 6/9] ns: integrate accessibility with EmacsView and redisplay Wire the accessibility element tree into EmacsView and hook it into the redisplay cycle. @@ -24,7 +24,7 @@ com.apple.accessibility.api distributed notification. --- etc/NEWS | 13 ++ src/nsterm.m | 474 +++++++++++++++++++++++++++++++++++++++++++++++++-- - 2 files changed, 475 insertions(+), 12 deletions(-) + 2 files changed, 477 insertions(+), 10 deletions(-) diff --git a/etc/NEWS b/etc/NEWS index 4c149e41d6..7f917f93b2 100644 @@ -51,24 +51,15 @@ index 4c149e41d6..7f917f93b2 100644 ** Re-introduced dictation, lost in Emacs v30 (macOS). We lost macOS dictation in v30 when migrating to NSTextInputClient. diff --git a/src/nsterm.m b/src/nsterm.m -index 8aa5b6ac1b..32eb04acef 100644 +index d65609cc79..4ba9b41b3b 100644 --- a/src/nsterm.m +++ b/src/nsterm.m -@@ -1275,7 +1275,7 @@ If a completion candidate is selected (overlay or child frame), - static void - ns_zoom_track_completion (struct frame *f, EmacsView *view) - { -- if (!ns_zoom_enabled_p ()) -+ if (!ns_accessibility_enabled || !ns_zoom_enabled_p ()) - return; - if (!WINDOWP (f->selected_window)) - return; @@ -1393,7 +1393,8 @@ so the visual offset is (ov_line + 1) * line_h from (zoomCursorUpdated is NO). */ #if defined (MAC_OS_X_VERSION_MIN_REQUIRED) \ && MAC_OS_X_VERSION_MIN_REQUIRED >= 101000 - if (view && !view->zoomCursorUpdated && ns_zoom_enabled_p () -+ if (ns_accessibility_enabled && view && !view->zoomCursorUpdated ++ if (view && !view->zoomCursorUpdated + && ns_zoom_enabled_p () && !NSIsEmptyRect (view->lastCursorRect)) { @@ -83,15 +74,6 @@ index 8aa5b6ac1b..32eb04acef 100644 } static void -@@ -3567,7 +3571,7 @@ EmacsView pixels (AppKit, flipped, top-left origin) - - #if defined (MAC_OS_X_VERSION_MIN_REQUIRED) \ - && MAC_OS_X_VERSION_MIN_REQUIRED >= 101000 -- if (ns_zoom_enabled_p ()) -+ if (ns_accessibility_enabled && ns_zoom_enabled_p ()) - { - NSRect windowRect = [view convertRect:r toView:nil]; - NSRect screenRect @@ -6723,9 +6727,56 @@ - (void)applicationDidFinishLaunching: (NSNotification *)notification } #endif @@ -163,14 +145,18 @@ index 8aa5b6ac1b..32eb04acef 100644 - /* =================================================================== - EmacsAccessibilityBuffer (Notifications) --- AX event dispatch + EmacsAccessibilityBuffer (Notifications) — AX event dispatch -@@ -9235,6 +9284,50 @@ - (void)postAccessibilityNotificationsForFrame:(struct frame *)f +@@ -9235,6 +9284,54 @@ - (void)postAccessibilityNotificationsForFrame:(struct frame *)f granularity = ns_ax_text_selection_granularity_line; } ++ /* Treat all moves as Emacs-initiated until voiceoverSetPoint ++ tracking is introduced (subsequent patch). */ ++ BOOL emacsMovedCursor = YES; ++ + /* Programmatic jumps that cross a line boundary (]], [[, M-<, -+ xref, imenu, ...) are discontiguous: the cursor teleported to an ++ xref, imenu, …) are discontiguous: the cursor teleported to an + arbitrary position, not one sequential step forward/backward. + Reporting AXTextSelectionDirectionDiscontiguous causes VoiceOver + to re-anchor its rotor browse cursor at the new @@ -216,7 +202,7 @@ index 8aa5b6ac1b..32eb04acef 100644 /* Post notifications for focused and non-focused elements. */ if ([self isAccessibilityFocused]) [self postFocusedCursorNotification:point -@@ -9347,7 +9440,6 @@ - (NSRect)accessibilityFrame +@@ -9347,7 +9444,6 @@ - (NSRect)accessibilityFrame @end @@ -224,7 +210,7 @@ index 8aa5b6ac1b..32eb04acef 100644 /* =================================================================== EmacsAccessibilityInteractiveSpan --- helpers and implementation =================================================================== */ -@@ -9683,6 +9775,7 @@ - (void)dealloc +@@ -9684,6 +9780,7 @@ - (void)dealloc [layer release]; #endif @@ -232,7 +218,7 @@ index 8aa5b6ac1b..32eb04acef 100644 [[self menu] release]; [super dealloc]; } -@@ -11031,6 +11124,32 @@ - (void)windowDidBecomeKey /* for direct calls */ +@@ -11032,6 +11129,32 @@ - (void)windowDidBecomeKey /* for direct calls */ XSETFRAME (event.frame_or_window, emacsframe); kbd_buffer_store_event (&event); ns_send_appdefined (-1); // Kick main loop @@ -265,7 +251,7 @@ index 8aa5b6ac1b..32eb04acef 100644 } -@@ -12268,6 +12387,332 @@ - (int) fullscreenState +@@ -12269,6 +12392,332 @@ - (int) fullscreenState return fs_state; } @@ -285,7 +271,7 @@ index 8aa5b6ac1b..32eb04acef 100644 + + if (WINDOW_LEAF_P (w)) + { -+ /* Buffer element --- reuse existing if available. */ ++ /* Buffer element — reuse existing if available. */ + EmacsAccessibilityBuffer *elem + = [existing objectForKey:[NSValue valueWithPointer:w]]; + if (!elem) @@ -319,7 +305,7 @@ index 8aa5b6ac1b..32eb04acef 100644 + } + else + { -+ /* Internal (combination) window --- recurse into children. */ ++ /* Internal (combination) window — recurse into children. */ + Lisp_Object child = w->contents; + while (!NILP (child)) + { @@ -431,7 +417,7 @@ index 8aa5b6ac1b..32eb04acef 100644 + accessibilityUpdating = YES; + + /* Detect window tree change (split, delete, new buffer). Compare -+ FRAME_ROOT_WINDOW --- if it changed, the tree structure changed. */ ++ FRAME_ROOT_WINDOW — if it changed, the tree structure changed. */ + Lisp_Object curRoot = FRAME_ROOT_WINDOW (emacsframe); + if (!EQ (curRoot, lastRootWindow)) + { @@ -440,12 +426,12 @@ index 8aa5b6ac1b..32eb04acef 100644 + } + + /* If tree is stale, rebuild FIRST so we don't iterate freed -+ window pointers. Skip notifications for this cycle --- the ++ 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. */ ++ /* Invalidate span cache — window layout changed. */ + for (EmacsAccessibilityElement *elem in accessibilityElements) + if ([elem isKindOfClass: [EmacsAccessibilityBuffer class]]) + [(EmacsAccessibilityBuffer *) elem invalidateInteractiveSpans]; @@ -598,7 +584,7 @@ index 8aa5b6ac1b..32eb04acef 100644 @end /* EmacsView */ -@@ -14264,12 +14709,17 @@ Nil means use fullscreen the old (< 10.7) way. The old way works better with +@@ -14265,12 +14714,17 @@ Nil means use fullscreen the old (< 10.7) way. The old way works better with ns_use_srgb_colorspace = YES; DEFVAR_BOOL ("ns-accessibility-enabled", ns_accessibility_enabled, diff --git a/patches/0006-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch b/patches/0006-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch index f37df74..8a94671 100644 --- a/patches/0006-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch +++ b/patches/0006-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch @@ -1,7 +1,7 @@ -From 4310549aa1e486dba054948a2937bb8bb236bb27 Mon Sep 17 00:00:00 2001 +From 23139d3e63a0d97cf1fdf0421fd7c41acce0bd6b Mon Sep 17 00:00:00 2001 From: Martin Sukany Date: Sat, 28 Feb 2026 12:58:11 +0100 -Subject: [PATCH 6/8] doc: add VoiceOver accessibility section to macOS +Subject: [PATCH 7/9] doc: add VoiceOver accessibility section to macOS appendix * doc/emacs/macos.texi (VoiceOver Accessibility): New node between @@ -11,12 +11,12 @@ enabled, and known limitations. Use @xref for cross-reference at sentence start. Correct description of ns-accessibility-enabled default: initial value is nil, set automatically at startup. --- - doc/emacs/macos.texi | 76 ++++++++++++++++++++++++++++++++++++++++++++ + doc/emacs/macos.texi | 77 ++++++++++++++++++++++++++++++++++++++++++++ src/nsterm.m | 10 ++++-- - 2 files changed, 83 insertions(+), 3 deletions(-) + 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/doc/emacs/macos.texi b/doc/emacs/macos.texi -index 6bd334f48e..8d4a7825d8 100644 +index 6bd334f48e..72ac3a9aa9 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. @@ -27,7 +27,7 @@ index 6bd334f48e..8d4a7825d8 100644 * GNUstep Support:: Details on status of GNUstep support. @end menu -@@ -272,6 +273,82 @@ +@@ -272,6 +273,82 @@ 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. @@ -97,24 +97,24 @@ index 6bd334f48e..8d4a7825d8 100644 +Right-to-left (bidi) text is exposed correctly as buffer content, +but @code{accessibilityRangeForPosition} hit-testing assumes +left-to-right glyph layout. ++@item ++Block-style cursors are handled correctly: character navigation ++announces the character at the cursor position, not the character ++before it. +@end itemize + + This support is available only on the Cocoa build. GNUstep has a +different accessibility model and is not yet supported. + -+Block-style cursors are handled -+correctly: character navigation announces the character at the cursor -+position, not the character before it. -+ + @node GNUstep Support @section GNUstep Support diff --git a/src/nsterm.m b/src/nsterm.m -index 32eb04acef..8e5cc7e1d7 100644 +index 4ba9b41b3b..a0419bb5df 100644 --- a/src/nsterm.m +++ b/src/nsterm.m -@@ -14710,9 +14710,13 @@ Nil means use fullscreen the old (< 10.7) way. The old way works better with +@@ -14715,9 +14715,13 @@ Nil means use fullscreen the old (< 10.7) way. The old way works better with DEFVAR_BOOL ("ns-accessibility-enabled", ns_accessibility_enabled, doc: /* Non-nil enables Zoom cursor tracking and VoiceOver support. diff --git a/patches/0007-ns-announce-overlay-completion-candidates-for-VoiceO.patch b/patches/0007-ns-announce-overlay-completion-candidates-for-VoiceO.patch index 99a23ee..f5bcd1d 100644 --- a/patches/0007-ns-announce-overlay-completion-candidates-for-VoiceO.patch +++ b/patches/0007-ns-announce-overlay-completion-candidates-for-VoiceO.patch @@ -1,11 +1,11 @@ -From affdaf60d28ad4d9836c6505216e19599b31c437 Mon Sep 17 00:00:00 2001 -From: Daneel +From 7474a4e1ddbf37286842e3beda1810c40f2a3ef7 Mon Sep 17 00:00:00 2001 +From: Martin Sukany Date: Mon, 2 Mar 2026 18:39:46 +0100 -Subject: [PATCH 7/8] ns: announce overlay completion candidates for VoiceOver +Subject: [PATCH 8/9] ns: announce overlay completion candidates for VoiceOver -Overlay-based completion UIs (Vertico, Ivy, Icomplete) render candidates -via overlay before-string/after-string properties. Without this change -VoiceOver cannot read overlay-based completion UIs. +Completion frameworks such as Vertico, Ivy, and Icomplete render +candidates via overlay before-string/after-string properties. Without +this change VoiceOver cannot read overlay-based completion UIs. * src/nsterm.m (ns_ax_face_is_selected): New static function; matches 'current', 'selected', 'selection' in face symbol names. @@ -18,11 +18,11 @@ ValueChanged; keep overlay_modiff out of ensureTextCache to prevent a race where an AX query consumes the change before notification. --- src/nsterm.h | 1 + - src/nsterm.m | 348 ++++++++++++++++++++++++++++++++++++++++++++------- - 2 files changed, 307 insertions(+), 42 deletions(-) + src/nsterm.m | 345 ++++++++++++++++++++++++++++++++++++++++++++------- + 2 files changed, 304 insertions(+), 42 deletions(-) diff --git a/src/nsterm.h b/src/nsterm.h -index 5746e9e9bd..21a93bc799 100644 +index f245675513..a210ceba14 100644 --- a/src/nsterm.h +++ b/src/nsterm.h @@ -510,6 +510,7 @@ typedef struct ns_ax_visible_run @@ -34,10 +34,10 @@ index 5746e9e9bd..21a93bc799 100644 @property (nonatomic, assign) BOOL cachedMarkActive; @property (nonatomic, copy) NSString *cachedCompletionAnnouncement; diff --git a/src/nsterm.m b/src/nsterm.m -index 8e5cc7e1d7..8ef344d9fe 100644 +index a0419bb5df..54cee74401 100644 --- a/src/nsterm.m +++ b/src/nsterm.m -@@ -7263,11 +7263,157 @@ Accessibility virtual elements (macOS / Cocoa only) +@@ -7263,11 +7263,154 @@ Accessibility virtual elements (macOS / Cocoa only) /* ---- Helper: extract buffer text for accessibility ---- */ @@ -46,9 +46,6 @@ index 8e5cc7e1d7..8ef344d9fe 100644 + completion candidate. Works for vertico-current, + icomplete-selected-match, ivy-current-match, etc. */ +static bool -+/* Note: ns_zoom_face_is_selected in the Zoom series has identical -+ logic. The duplication is intentional: both series are -+ independent and must compile standalone. */ +ns_ax_face_is_selected (Lisp_Object face) +{ + if (SYMBOLP (face) && !NILP (face)) @@ -200,7 +197,7 @@ index 8e5cc7e1d7..8ef344d9fe 100644 /* 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 +- buffer gap — raw BUF_BYTE_ADDRESS reads across the gap would + 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 ( @@ -209,7 +206,7 @@ index 8e5cc7e1d7..8ef344d9fe 100644 return NSZeroRect; /* charpos_start and charpos_len are already in buffer charpos -- space --- the caller maps AX string indices through +- space — the caller maps AX string indices through + space --- the caller maps AX string indices through charposForAccessibilityIndex which handles invisible text. */ ptrdiff_t cp_start = charpos_start; @@ -226,7 +223,7 @@ index 8e5cc7e1d7..8ef344d9fe 100644 NSTRACE ("EmacsAccessibilityBuffer ensureTextCache"); /* This method is only called from the main thread (AX getters dispatch_sync to main first). Reads of cachedText/cachedTextModiff -- below are therefore safe without @synchronized --- only the +- below are therefore safe without @synchronized — only the + below are therefore safe without @synchronized --- only the write section at the end needs synchronization to protect against concurrent reads from AX server thread. */ @@ -309,7 +306,7 @@ index 8e5cc7e1d7..8ef344d9fe 100644 /* Binary search: runs are sorted by charpos (ascending). Find the run whose [charpos, charpos+length) range contains the target, or the nearest run after an invisible gap. O(log n) instead of -- O(n) --- matters for org-mode with many folded sections. */ +- O(n) — matters for org-mode with many folded sections. */ + O(n) --- matters for org-mode with many folded sections. */ NSUInteger lo = 0, hi = visibleRunCount; while (lo < hi) @@ -318,11 +315,11 @@ index 8e5cc7e1d7..8ef344d9fe 100644 /* Convert accessibility string index to buffer charpos. Safe to call from any thread: uses only cachedText (NSString) and -- visibleRuns --- no Lisp calls. */ +- visibleRuns — no Lisp calls. */ + visibleRuns --- no Lisp calls. */ - (ptrdiff_t)charposForAccessibilityIndex:(NSUInteger)ax_idx { -- /* May be called from AX server thread --- synchronize. */ +- /* May be called from AX server thread — synchronize. */ + /* May be called from AX server thread --- synchronize. */ @synchronized (self) { @@ -331,7 +328,7 @@ index 8e5cc7e1d7..8ef344d9fe 100644 return cp; } } -- /* Past end --- return last charpos. */ +- /* Past end — return last charpos. */ + /* Past end --- return last charpos. */ if (lo > 0) { @@ -340,7 +337,7 @@ index 8e5cc7e1d7..8ef344d9fe 100644 deadlocking the AX server thread. This is prevented by: 1. validWindow checks WINDOW_LIVE_P and BUFFERP before every -- Lisp access --- the window and buffer are verified live. +- Lisp access — the window and buffer are verified live. + Lisp access --- the window and buffer are verified live. 2. All dispatch_sync blocks run on the main thread where no concurrent Lisp code can modify state between checks. @@ -400,7 +397,7 @@ index 8e5cc7e1d7..8ef344d9fe 100644 /* =================================================================== -- EmacsAccessibilityBuffer (Notifications) --- AX event dispatch +- EmacsAccessibilityBuffer (Notifications) — AX event dispatch + EmacsAccessibilityBuffer (Notifications) --- AX event dispatch These methods notify VoiceOver of text and selection changes. @@ -409,7 +406,7 @@ index 8e5cc7e1d7..8ef344d9fe 100644 if (point > self.cachedPoint && point - self.cachedPoint == 1) { -- /* Single char inserted --- refresh cache and grab it. */ +- /* Single char inserted — refresh cache and grab it. */ + /* Single char inserted --- refresh cache and grab it. */ [self invalidateTextCache]; [self ensureTextCache]; @@ -418,12 +415,12 @@ index 8e5cc7e1d7..8ef344d9fe 100644 /* Update cachedPoint here so the selection-move branch does NOT fire for point changes caused by edits. WebKit and Chromium never send both ValueChanged and SelectedTextChanged for the -- same user action --- they are mutually exclusive. */ +- same user action — they are mutually exclusive. */ + same user action --- they are mutually exclusive. */ self.cachedPoint = point; NSDictionary *change = @{ -@@ -9220,16 +9417,83 @@ - (void)postAccessibilityNotificationsForFrame:(struct frame *)f +@@ -9220,16 +9417,80 @@ - (void)postAccessibilityNotificationsForFrame:(struct frame *)f BOOL markActive = !NILP (BVAR (b, mark_active)); /* --- Text changed (edit) --- */ @@ -465,90 +462,87 @@ index 8e5cc7e1d7..8ef344d9fe 100644 + font-lock and other modes change BUF_OVERLAY_MODIFF on + every redisplay, triggering O(overlays) work per keystroke. + Restrict the scan to minibuffer windows. */ -+ if (!MINI_WINDOW_P (w)) -+ goto skip_overlay_scan; -+ -+ int selected_line = -1; -+ NSString *candidate -+ = ns_ax_selected_overlay_text (b, BUF_BEGV (b), BUF_ZV (b), -+ &selected_line); -+ if (candidate) ++ if (MINI_WINDOW_P (w)) + { -+ /* Deduplicate: only announce when the candidate changed. */ -+ if (![candidate isEqualToString: -+ self.cachedCompletionAnnouncement]) ++ int selected_line = -1; ++ NSString *candidate ++ = ns_ax_selected_overlay_text (b, BUF_BEGV (b), BUF_ZV (b), ++ &selected_line); ++ if (candidate) + { -+ self.cachedCompletionAnnouncement = candidate; -+ -+ /* Announce the candidate text directly via NSApp. -+ Do NOT post SelectedTextChanged --- that would cause -+ VoiceOver to read the AX text at the cursor position -+ (the minibuffer input line), not the overlay candidate. -+ AnnouncementRequested with High priority interrupts -+ any current speech and announces our text. */ -+ NSDictionary *annInfo = @{ -+ NSAccessibilityAnnouncementKey: candidate, -+ NSAccessibilityPriorityKey: -+ @(NSAccessibilityPriorityHigh) -+ }; -+ ns_ax_post_notification_with_info ( -+ NSApp, -+ NSAccessibilityAnnouncementRequestedNotification, -+ annInfo); ++ /* Deduplicate: only announce when the candidate changed. */ ++ if (![candidate isEqualToString: ++ self.cachedCompletionAnnouncement]) ++ { ++ self.cachedCompletionAnnouncement = candidate; + ++ /* Announce the candidate text directly via NSApp. ++ Do NOT post SelectedTextChanged --- that would cause ++ VoiceOver to read the AX text at the cursor position ++ (the minibuffer input line), not the overlay candidate. ++ AnnouncementRequested with High priority interrupts ++ any current speech and announces our text. */ ++ NSDictionary *annInfo = @{ ++ NSAccessibilityAnnouncementKey: candidate, ++ NSAccessibilityPriorityKey: ++ @(NSAccessibilityPriorityHigh) ++ }; ++ ns_ax_post_notification_with_info ( ++ NSApp, ++ NSAccessibilityAnnouncementRequestedNotification, ++ annInfo); ++ } + } + } } -+ skip_overlay_scan:; /* --- Cursor moved or selection changed --- -- Use 'else if' --- edits and selection moves are mutually exclusive +- Use 'else if' — edits and selection moves are mutually exclusive - per the WebKit/Chromium pattern. */ - else if (point != self.cachedPoint || markActive != self.cachedMarkActive) -+ Independent check: the goto above may jump here from the overlay -+ branch, so this must be a standalone if, not else-if. */ ++ Independent check from the overlay branch above. */ + if (point != self.cachedPoint || markActive != self.cachedMarkActive) { ptrdiff_t oldPoint = self.cachedPoint; BOOL oldMarkActive = self.cachedMarkActive; -@@ -12403,7 +12667,7 @@ - (int) fullscreenState +@@ -12408,7 +12669,7 @@ - (int) fullscreenState if (WINDOW_LEAF_P (w)) { -- /* Buffer element --- reuse existing if available. */ +- /* Buffer element — reuse existing if available. */ + /* Buffer element --- reuse existing if available. */ EmacsAccessibilityBuffer *elem = [existing objectForKey:[NSValue valueWithPointer:w]]; if (!elem) -@@ -12437,7 +12701,7 @@ - (int) fullscreenState +@@ -12442,7 +12703,7 @@ - (int) fullscreenState } else { -- /* Internal (combination) window --- recurse into children. */ +- /* Internal (combination) window — recurse into children. */ + /* Internal (combination) window --- recurse into children. */ Lisp_Object child = w->contents; while (!NILP (child)) { -@@ -12549,7 +12813,7 @@ - (void)postAccessibilityUpdates +@@ -12554,7 +12815,7 @@ - (void)postAccessibilityUpdates accessibilityUpdating = YES; /* Detect window tree change (split, delete, new buffer). Compare -- FRAME_ROOT_WINDOW --- if it changed, the tree structure changed. */ +- FRAME_ROOT_WINDOW — if it changed, the tree structure changed. */ + FRAME_ROOT_WINDOW --- if it changed, the tree structure changed. */ Lisp_Object curRoot = FRAME_ROOT_WINDOW (emacsframe); if (!EQ (curRoot, lastRootWindow)) { -@@ -12558,12 +12822,12 @@ - (void)postAccessibilityUpdates +@@ -12563,12 +12824,12 @@ - (void)postAccessibilityUpdates } /* If tree is stale, rebuild FIRST so we don't iterate freed -- window pointers. Skip notifications for this cycle --- the +- window pointers. Skip notifications for this cycle — the + 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. */ +- /* Invalidate span cache — window layout changed. */ + /* Invalidate span cache --- window layout changed. */ for (EmacsAccessibilityElement *elem in accessibilityElements) if ([elem isKindOfClass: [EmacsAccessibilityBuffer class]]) diff --git a/patches/0008-ns-announce-child-frame-completion-candidates-for-Vo.patch b/patches/0008-ns-announce-child-frame-completion-candidates-for-Vo.patch index d0488ea..b7482fe 100644 --- a/patches/0008-ns-announce-child-frame-completion-candidates-for-Vo.patch +++ b/patches/0008-ns-announce-child-frame-completion-candidates-for-Vo.patch @@ -1,54 +1,50 @@ -From 1e3d3919fd41e4480a02190fb89bee1ef8107d62 Mon Sep 17 00:00:00 2001 -From: Daneel +From 137cb30bb546a9599983c25a9873d1518ad8edee Mon Sep 17 00:00:00 2001 +From: Martin Sukany Date: Mon, 2 Mar 2026 18:49:13 +0100 -Subject: [PATCH 8/8] ns: announce child frame completion candidates for +Subject: [PATCH 9/9] ns: announce child frame completion candidates for VoiceOver -Child frame popups (Corfu, Company-mode) render completion candidates in -a separate frame whose buffer is not accessible via the minibuffer -overlay path. This patch scans child frame buffers for selected -candidates and announces them via VoiceOver. +Child frame popups (Corfu, Company-mode child frames) render completion +candidates in a separate frame whose buffer is not accessible via the +minibuffer overlay path. This patch scans child frame buffers for +selected candidates and announces them via VoiceOver. * src/nsterm.h (EmacsView): Add childFrameLastBuffer, childFrameLastModiff, childFrameLastCandidate, childFrameCompletionActive, lastEchoCharsModiff -ivars; remove cachedOverlayModiffForText (unused after BUF_OVERLAY_MODIFF -removed from ensureTextCache to prevent hl-line-mode O(N) rebuilds). -Initialize voiceoverSetPoint, childFrameLastBuffer in initFrameFromEmacs:. +ivars. Initialize childFrameLastBuffer to Qnil in initFrameFromEmacs:. (EmacsAccessibilityBuffer): Add voiceoverSetPoint ivar. -* src/nsterm.m (ns_ax_buffer_text): Add block_input protection for -Lisp calls; use record_unwind_protect_void to guarantee unblock_input. -(ensureTextCache): Remove BUF_OVERLAY_MODIFF tracking; keep only -BUF_CHARS_MODIFF. BUF_OVERLAY_MODIFF caused O(buffer-size) rebuilds -with hl-line-mode (moves overlay on every post-command-hook). +* src/nsterm.m (ns_ax_selected_child_frame_text): New function; scans +child frame buffer text for the selected completion candidate. (announceChildFrameCompletion): New method; scans child frame buffers for selected completion candidates. Store childFrameLastBuffer as BVAR(b, name) (buffer name symbol, GC-reachable via obarray) rather -than make_lisp_ptr to avoid dangling pointer after buffer kill. +than a raw buffer pointer to avoid a dangling pointer after buffer kill. (postEchoAreaAnnouncementIfNeeded): New method; announces echo area -changes for commands like C-g. +changes (e.g., "Wrote file", "Quit") for commands that produce output +while the minibuffer is inactive. (postAccessibilityNotificationsForFrame:): Drive child frame and echo -area announcements. -* doc/emacs/macos.texi: Fix dangling semicolon in GNUstep paragraph. +area announcements. Add voiceoverSetPoint flag and singleLineMove +adjacency detection to distinguish VoiceOver-initiated cursor moves +from Emacs-initiated moves; sequential adjacent-line moves use +next/previous direction, teleports use discontiguous. Add didTextChange +guard to suppress overlay completion announcements while the user types. +(setAccessibilitySelectedTextRange:): Set voiceoverSetPoint so that the +subsequent notification cycle uses sequential direction. +* doc/emacs/macos.texi (VoiceOver Accessibility): Update to document +echo area announcements and VoiceOver rotor cursor synchronization. +Remove Zoom section (covered by patch 0000). Fix dangling paragraph. --- - doc/emacs/macos.texi | 14 +- - etc/NEWS | 25 ++- + doc/emacs/macos.texi | 13 +- + etc/NEWS | 25 +- src/nsterm.h | 21 ++ - src/nsterm.m | 514 +++++++++++++++++++++++++++++++++++++++---- - 4 files changed, 510 insertions(+), 64 deletions(-) + src/nsterm.m | 561 +++++++++++++++++++++++++++++++++++++------ + 4 files changed, 529 insertions(+), 91 deletions(-) diff --git a/doc/emacs/macos.texi b/doc/emacs/macos.texi -index 8d4a7825d8..03a657f970 100644 +index 72ac3a9aa9..cf5ed0ff28 100644 --- a/doc/emacs/macos.texi +++ b/doc/emacs/macos.texi -@@ -278,7 +278,6 @@ restart Emacs to access newly-available services. - @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 -@@ -309,10 +308,15 @@ Shift-modified movement announces selected or deselected text. +@@ -309,10 +309,15 @@ Shift-modified movement announces selected or deselected text. The @file{*Completions*} buffer announces each completion candidate as you navigate, even while keyboard focus remains in the minibuffer. @@ -115,7 +111,7 @@ index 7f917f93b2..bbec21b635 100644 interface and eliminate the associated overhead. diff --git a/src/nsterm.h b/src/nsterm.h -index 21a93bc799..b5c9f84499 100644 +index a210ceba14..2edd7cd6e0 100644 --- a/src/nsterm.h +++ b/src/nsterm.h @@ -504,9 +504,20 @@ typedef struct ns_ax_visible_run @@ -139,7 +135,7 @@ index 21a93bc799..b5c9f84499 100644 @property (nonatomic, assign) ptrdiff_t cachedOverlayModiff; @property (nonatomic, assign) ptrdiff_t cachedTextStart; @property (nonatomic, assign) ptrdiff_t cachedModiff; -@@ -596,6 +607,14 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType) +@@ -596,6 +607,14 @@ typedef NS_ENUM(NSInteger, EmacsAXSpanType) Lisp_Object lastRootWindow; BOOL accessibilityTreeValid; BOOL accessibilityUpdating; @@ -154,7 +150,7 @@ index 21a93bc799..b5c9f84499 100644 #endif BOOL font_panel_active; NSFont *font_panel_result; -@@ -665,6 +684,8 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType) +@@ -665,6 +684,8 @@ typedef NS_ENUM(NSInteger, EmacsAXSpanType) - (void)rebuildAccessibilityTree; - (void)invalidateAccessibilityTree; - (void)postAccessibilityUpdates; @@ -164,42 +160,22 @@ index 21a93bc799..b5c9f84499 100644 @end diff --git a/src/nsterm.m b/src/nsterm.m -index 8ef344d9fe..1acb64630a 100644 +index 54cee74401..6ba2229639 100644 --- a/src/nsterm.m +++ b/src/nsterm.m -@@ -1275,7 +1275,13 @@ If a completion candidate is selected (overlay or child frame), +@@ -1275,6 +1275,12 @@ If a completion candidate is selected (overlay or child frame), static void ns_zoom_track_completion (struct frame *f, EmacsView *view) { -- if (!ns_accessibility_enabled || !ns_zoom_enabled_p ()) + /* Zoom cursor tracking is controlled exclusively by + ns_zoom_enabled_p (). We do NOT gate on ns_accessibility_enabled: + users can run Zoom without VoiceOver, and those users should still + get completion-candidate tracking. ns_accessibility_enabled is + only set when a screen reader (VoiceOver or similar) activates the + AX layer; it has no bearing on the Zoom feature. */ -+ if (!ns_zoom_enabled_p ()) + if (!ns_zoom_enabled_p ()) return; if (!WINDOWP (f->selected_window)) - return; -@@ -1393,7 +1399,7 @@ so the visual offset is (ov_line + 1) * line_h from - (zoomCursorUpdated is NO). */ - #if defined (MAC_OS_X_VERSION_MIN_REQUIRED) \ - && MAC_OS_X_VERSION_MIN_REQUIRED >= 101000 -- if (ns_accessibility_enabled && view && !view->zoomCursorUpdated -+ if (view && !view->zoomCursorUpdated - && ns_zoom_enabled_p () - && !NSIsEmptyRect (view->lastCursorRect)) - { -@@ -3571,7 +3577,7 @@ EmacsView pixels (AppKit, flipped, top-left origin) - - #if defined (MAC_OS_X_VERSION_MIN_REQUIRED) \ - && MAC_OS_X_VERSION_MIN_REQUIRED >= 101000 -- if (ns_accessibility_enabled && ns_zoom_enabled_p ()) -+ if (ns_zoom_enabled_p ()) - { - NSRect windowRect = [view convertRect:r toView:nil]; - NSRect screenRect @@ -7407,6 +7413,117 @@ visual line index for Zoom (skip whitespace-only lines return nil; @@ -318,6 +294,21 @@ index 8ef344d9fe..1acb64630a 100644 /* 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 +@@ -7440,9 +7557,13 @@ visual line index for Zoom (skip whitespace-only lines + return @""; + + specpdl_ref count = SPECPDL_INDEX (); ++ /* block_input must precede record_unwind_protect_void (unblock_input): ++ if anything between SPECPDL_INDEX and block_input were to throw, ++ the unwind handler would call unblock_input without a matching ++ block_input, corrupting the input-blocking reference count. */ ++ block_input (); + record_unwind_current_buffer (); + record_unwind_protect_void (unblock_input); +- block_input (); + if (b != current_buffer) + set_buffer_internal_1 (b); + @@ -8605,6 +8726,11 @@ - (void)setAccessibilitySelectedTextRange:(NSRange)range [self ensureTextCache]; @@ -392,7 +383,7 @@ index 8ef344d9fe..1acb64630a 100644 + - C-n/C-p: SelectedTextChanged carries granularity=line, but + VoiceOver processes those keystrokes specially and may not + produce speech; the explicit announcement is the reliable path. -+ - Discontiguous jumps (]], M-<, xref, imenu, ...): granularity=line ++ - Discontiguous jumps (]], M-<, xref, imenu, …): granularity=line + in the notification is omitted (see above) so VoiceOver will + not announce automatically; this explicit announcement fills + the gap. @@ -422,7 +413,7 @@ index 8ef344d9fe..1acb64630a 100644 + postEchoAreaAnnouncementIfNeeded (called from postAccessibilityUpdates + before this per-element loop) so that they are never lost to a + concurrent tree rebuild. For the inactive minibuffer (minibuf_level -+ == 0), skip normal cursor and completion processing --- there is no ++ == 0), skip normal cursor and completion processing — there is no + meaningful cursor to track. */ + if (MINI_WINDOW_P (w) && minibuf_level == 0) + return; @@ -452,12 +443,12 @@ index 8ef344d9fe..1acb64630a 100644 } } -@@ -9453,8 +9625,15 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property +@@ -9453,37 +9625,44 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property displayed in the minibuffer. In normal editing buffers, font-lock and other modes change BUF_OVERLAY_MODIFF on every redisplay, triggering O(overlays) work per keystroke. - Restrict the scan to minibuffer windows. */ -- if (!MINI_WINDOW_P (w)) +- if (MINI_WINDOW_P (w)) + Restrict the scan to minibuffer windows. + Skip overlay announcements when the user just typed a character + (didTextChange). Completion frameworks update their overlay @@ -467,10 +458,66 @@ index 8ef344d9fe..1acb64630a 100644 + characters inaudible. VoiceOver should read the overlay + candidate only when the user navigates (C-n/C-p), not types. */ + if (!MINI_WINDOW_P (w) || didTextChange) - goto skip_overlay_scan; - - int selected_line = -1; -@@ -9500,7 +9679,18 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property ++ goto skip_overlay_scan; ++ ++ int selected_line = -1; ++ NSString *candidate ++ = ns_ax_selected_overlay_text (b, BUF_BEGV (b), BUF_ZV (b), ++ &selected_line); ++ if (candidate) + { +- int selected_line = -1; +- NSString *candidate +- = ns_ax_selected_overlay_text (b, BUF_BEGV (b), BUF_ZV (b), +- &selected_line); +- if (candidate) ++ /* Deduplicate: only announce when the candidate changed. */ ++ if (![candidate isEqualToString: ++ self.cachedCompletionAnnouncement]) + { +- /* Deduplicate: only announce when the candidate changed. */ +- if (![candidate isEqualToString: +- self.cachedCompletionAnnouncement]) +- { +- self.cachedCompletionAnnouncement = candidate; +- +- /* Announce the candidate text directly via NSApp. +- Do NOT post SelectedTextChanged --- that would cause +- VoiceOver to read the AX text at the cursor position +- (the minibuffer input line), not the overlay candidate. +- AnnouncementRequested with High priority interrupts +- any current speech and announces our text. */ +- NSDictionary *annInfo = @{ +- NSAccessibilityAnnouncementKey: candidate, +- NSAccessibilityPriorityKey: +- @(NSAccessibilityPriorityHigh) +- }; +- ns_ax_post_notification_with_info ( +- NSApp, +- NSAccessibilityAnnouncementRequestedNotification, +- annInfo); +- } ++ self.cachedCompletionAnnouncement = candidate; ++ ++ /* Announce the candidate text directly via NSApp. ++ Do NOT post SelectedTextChanged --- that would cause ++ VoiceOver to read the AX text at the cursor position ++ (the minibuffer input line), not the overlay candidate. ++ AnnouncementRequested with High priority interrupts ++ any current speech and announces our text. */ ++ NSDictionary *annInfo = @{ ++ NSAccessibilityAnnouncementKey: candidate, ++ NSAccessibilityPriorityKey: ++ @(NSAccessibilityPriorityHigh) ++ }; ++ ns_ax_post_notification_with_info ( ++ NSApp, ++ NSAccessibilityAnnouncementRequestedNotification, ++ annInfo); + } + } + } +@@ -9497,7 +9676,18 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property self.cachedPoint = point; self.cachedMarkActive = markActive; @@ -490,7 +537,7 @@ index 8ef344d9fe..1acb64630a 100644 NSInteger direction = ns_ax_text_selection_direction_discontiguous; if (point > oldPoint) direction = ns_ax_text_selection_direction_next; -@@ -9512,6 +9702,7 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property +@@ -9509,6 +9699,7 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property /* --- Granularity detection --- */ NSInteger granularity = ns_ax_text_selection_granularity_unknown; @@ -498,7 +545,7 @@ index 8ef344d9fe..1acb64630a 100644 [self ensureTextCache]; if (cachedText && oldPoint > 0) { -@@ -9526,7 +9717,18 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property +@@ -9523,7 +9714,18 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property NSRange newLine = [cachedText lineRangeForRange: NSMakeRange (newIdx, 0)]; if (oldLine.location != newLine.location) @@ -518,12 +565,16 @@ index 8ef344d9fe..1acb64630a 100644 else { NSUInteger dist = (newIdx > oldIdx -@@ -9548,34 +9750,23 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property +@@ -9545,38 +9747,23 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property granularity = ns_ax_text_selection_granularity_line; } +- /* Treat all moves as Emacs-initiated until voiceoverSetPoint +- tracking is introduced (subsequent patch). */ +- BOOL emacsMovedCursor = YES; +- - /* Programmatic jumps that cross a line boundary (]], [[, M-<, -- xref, imenu, ...) are discontiguous: the cursor teleported to an +- xref, imenu, …) are discontiguous: the cursor teleported to an - arbitrary position, not one sequential step forward/backward. - Reporting AXTextSelectionDirectionDiscontiguous causes VoiceOver - to re-anchor its rotor browse cursor at the new @@ -566,7 +617,7 @@ index 8ef344d9fe..1acb64630a 100644 { NSWindow *win = [self.emacsView window]; if (win) -@@ -9734,6 +9925,13 @@ - (NSRect)accessibilityFrame +@@ -9735,6 +9922,13 @@ - (NSRect)accessibilityFrame if (vis_start >= vis_end) return @[]; @@ -580,7 +631,7 @@ index 8ef344d9fe..1acb64630a 100644 block_input (); specpdl_ref blk_count = SPECPDL_INDEX (); record_unwind_protect_void (unblock_input); -@@ -10040,6 +10238,10 @@ - (void)dealloc +@@ -10042,6 +10236,10 @@ - (void)dealloc #endif [accessibilityElements release]; @@ -591,7 +642,7 @@ index 8ef344d9fe..1acb64630a 100644 [[self menu] release]; [super dealloc]; } -@@ -11489,6 +11691,9 @@ - (instancetype) initFrameFromEmacs: (struct frame *)f +@@ -11491,6 +11689,9 @@ - (instancetype) initFrameFromEmacs: (struct frame *)f windowClosing = NO; processingCompose = NO; @@ -601,7 +652,7 @@ index 8ef344d9fe..1acb64630a 100644 scrollbarsNeedingUpdate = 0; fs_state = FULLSCREEN_NONE; fs_before_fs = next_maximized = -1; -@@ -12797,6 +13002,161 @@ - (id)accessibilityFocusedUIElement +@@ -12799,6 +13000,156 @@ - (id)accessibilityFocusedUIElement 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. */ @@ -702,11 +753,6 @@ index 8ef344d9fe..1acb64630a 100644 + childFrameLastBuffer = BVAR (b, name); + childFrameLastModiff = modiff; + -+ /* BUFFER_LIVE_P(b) is already checked at entry (line above the -+ EQ comparison). No code between that check and here can kill -+ the buffer, so this second check is redundant. */ -+ eassert (BUFFER_LIVE_P (b)); -+ + /* Skip buffers larger than a typical completion popup. + This avoids scanning eldoc, which-key, or other child + frame buffers that are not completion UIs. */ @@ -763,7 +809,7 @@ index 8ef344d9fe..1acb64630a 100644 - (void)postAccessibilityUpdates { NSTRACE ("[EmacsView postAccessibilityUpdates]"); -@@ -12807,11 +13167,69 @@ - (void)postAccessibilityUpdates +@@ -12809,11 +13160,69 @@ - (void)postAccessibilityUpdates /* Re-entrance guard: VoiceOver callbacks during notification posting can trigger redisplay, which calls ns_update_end, which calls us