From 7a0b4f6cf247f1f0536b2dfbad056c828661e156 Mon Sep 17 00:00:00 2001 From: Daneel Date: Mon, 2 Mar 2026 20:48:57 +0100 Subject: [PATCH] patches: fix C-n/C-p VoiceOver regression - exclude isCtrlNP from re-anchor When Emacs moves the cursor (emacsMovedCursor=YES), we post FocusedUIElementChanged on the NSWindow to re-anchor VoiceOver's browse cursor. For C-n/C-p this notification races with AXSelectedTextChanged(granularity=line) and causes VoiceOver to drop the line-read speech. Arrow key movement works because VoiceOver intercepts those as AX selection changes (setAccessibilitySelectedTextRange:), making voiceoverSetPoint=YES and emacsMovedCursor=NO, so no FocusedUIElementChanged is posted. Fix: skip FocusedUIElementChanged for sequential C-n/C-p moves (isCtrlNP). AXSelectedTextChanged with direction=next/previous + granularity=line is sufficient for VoiceOver to read the new line. FocusedUIElementChanged is only needed for discontiguous jumps (]], M-<, isearch, xref etc.) where VoiceOver must re-anchor. Also merge duplicate comment blocks and fix two compile errors from a64d24c that Martin caught during testing. --- ...-with-macOS-Zoom-for-cursor-tracking.patch | 2 +- ...lity-base-classes-and-text-extractio.patch | 2 +- ...fer-accessibility-element-core-proto.patch | 2 +- ...tification-dispatch-and-mode-line-el.patch | 2 +- ...ive-span-elements-for-Tab-navigation.patch | 2 +- ...essibility-with-EmacsView-and-redisp.patch | 69 +++++++++++++--- ...r-accessibility-section-to-macOS-app.patch | 6 +- ...lay-completion-candidates-for-VoiceO.patch | 12 +-- ...d-frame-completion-candidates-for-Vo.patch | 79 +++---------------- 9 files changed, 84 insertions(+), 92 deletions(-) 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 57ac230..8443f9f 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,4 +1,4 @@ -From cac0509dbcd958d3bc71cac18c414af1543ca993 Mon Sep 17 00:00:00 2001 +From 3f97f3b69fdb10c1781ded98292434525838a369 Mon Sep 17 00:00:00 2001 From: Martin Sukany Date: Sat, 28 Feb 2026 22:39:35 +0100 Subject: [PATCH 0/8] ns: integrate with macOS Zoom for cursor tracking 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 df7659c..965c906 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,4 +1,4 @@ -From af06bbe06123968e08a2ea81ec2c24995f0a91f6 Mon Sep 17 00:00:00 2001 +From 234da3ced54798fe9e4bafb0eae08d571a4ffcfc 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 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 429773e..a010a00 100644 --- a/patches/0002-ns-implement-buffer-accessibility-element-core-proto.patch +++ b/patches/0002-ns-implement-buffer-accessibility-element-core-proto.patch @@ -1,4 +1,4 @@ -From 60f0e223190b158679322411b4186b7a378114e7 Mon Sep 17 00:00:00 2001 +From 5a786a29a4d4067ce7a75994136f945c49e4624e 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 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 6626840..bc430ca 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,4 +1,4 @@ -From 7bd0a761b78c1c05fe74e150d841bf15686efbb9 Mon Sep 17 00:00:00 2001 +From 1dd6de1a46d86a87640129745bb0db01618b2879 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 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 894b537..70ccf42 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,4 +1,4 @@ -From e4d2760f5e99cb6015fb85e68db37fa766a4ab53 Mon Sep 17 00:00:00 2001 +From 3b8838647b39912753157d76b2aa4d8d0da0c55c 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 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 d24a87b..a08058d 100644 --- a/patches/0005-ns-integrate-accessibility-with-EmacsView-and-redisp.patch +++ b/patches/0005-ns-integrate-accessibility-with-EmacsView-and-redisp.patch @@ -1,4 +1,4 @@ -From 7c042d446bddee16a83e4cd8f0050e24e262ef77 Mon Sep 17 00:00:00 2001 +From d4cda4bda0bee73c14946f20322975edd1580d46 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 @@ -23,8 +23,8 @@ com.apple.accessibility.api distributed notification. (accessibilityAttributeValue:forParameter:): New methods. --- etc/NEWS | 13 ++ - src/nsterm.m | 430 +++++++++++++++++++++++++++++++++++++++++++++++++-- - 2 files changed, 431 insertions(+), 12 deletions(-) + src/nsterm.m | 474 +++++++++++++++++++++++++++++++++++++++++++++++++-- + 2 files changed, 475 insertions(+), 12 deletions(-) diff --git a/etc/NEWS b/etc/NEWS index 4c149e41d6..7f917f93b2 100644 @@ -51,7 +51,7 @@ 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 b460beb00c..95a5b378c1 100644 +index b460beb00c..7c118045bd 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -1275,7 +1275,7 @@ If a completion candidate is selected (overlay or child frame), @@ -165,7 +165,58 @@ index b460beb00c..95a5b378c1 100644 /* =================================================================== EmacsAccessibilityBuffer (Notifications) — AX event dispatch -@@ -9347,7 +9396,6 @@ - (NSRect)accessibilityFrame +@@ -9235,6 +9284,50 @@ - (void)postAccessibilityNotificationsForFrame:(struct frame *)f + granularity = ns_ax_text_selection_granularity_line; + } + ++ /* Programmatic jumps that cross a line boundary (]], [[, M-<, ++ 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 ++ accessibilitySelectedTextRange rather than advancing linearly ++ from its previous internal position. */ ++ if (!isCtrlNP && granularity == ns_ax_text_selection_granularity_line) ++ direction = ns_ax_text_selection_direction_discontiguous; ++ ++ /* If Emacs moved the cursor (not VoiceOver), force discontiguous ++ so VoiceOver re-anchors its browse cursor to the current ++ accessibilitySelectedTextRange. This covers all Emacs-initiated ++ moves: editing commands, ELisp, isearch, etc. ++ Exception: C-n/C-p (isCtrlNP) already uses next/previous with ++ line granularity; those are already sequential and VoiceOver ++ handles them correctly. */ ++ if (emacsMovedCursor && !isCtrlNP) ++ direction = ns_ax_text_selection_direction_discontiguous; ++ ++ /* Re-anchor VoiceOver's browse cursor for discontiguous (teleport) ++ moves only. For sequential C-n/C-p (isCtrlNP), posting ++ FocusedUIElementChanged on the window races with the ++ AXSelectedTextChanged(granularity=line) notification and ++ causes VoiceOver to drop the line-read speech. Sequential ++ moves are already handled correctly by AXSelectedTextChanged ++ with direction=next/previous + granularity=line. */ ++ if (emacsMovedCursor && !isCtrlNP && [self isAccessibilityFocused]) ++ { ++ NSWindow *win = [self.emacsView window]; ++ if (win) ++ ns_ax_post_notification ( ++ win, ++ NSAccessibilityFocusedUIElementChangedNotification); ++ ++ NSDictionary *layoutInfo = @{ ++ NSAccessibilityUIElementsKey: @[self] ++ }; ++ ns_ax_post_notification_with_info ( ++ self.emacsView, ++ NSAccessibilityLayoutChangedNotification, ++ layoutInfo); ++ } ++ + /* Post notifications for focused and non-focused elements. */ + if ([self isAccessibilityFocused]) + [self postFocusedCursorNotification:point +@@ -9347,7 +9440,6 @@ - (NSRect)accessibilityFrame @end @@ -173,7 +224,7 @@ index b460beb00c..95a5b378c1 100644 /* =================================================================== EmacsAccessibilityInteractiveSpan --- helpers and implementation =================================================================== */ -@@ -9682,6 +9730,7 @@ - (void)dealloc +@@ -9682,6 +9774,7 @@ - (void)dealloc [layer release]; #endif @@ -181,7 +232,7 @@ index b460beb00c..95a5b378c1 100644 [[self menu] release]; [super dealloc]; } -@@ -11030,6 +11079,32 @@ - (void)windowDidBecomeKey /* for direct calls */ +@@ -11030,6 +11123,32 @@ - (void)windowDidBecomeKey /* for direct calls */ XSETFRAME (event.frame_or_window, emacsframe); kbd_buffer_store_event (&event); ns_send_appdefined (-1); // Kick main loop @@ -214,7 +265,7 @@ index b460beb00c..95a5b378c1 100644 } -@@ -12267,6 +12342,332 @@ - (int) fullscreenState +@@ -12267,6 +12386,332 @@ - (int) fullscreenState return fs_state; } @@ -547,7 +598,7 @@ index b460beb00c..95a5b378c1 100644 @end /* EmacsView */ -@@ -14263,12 +14664,17 @@ Nil means use fullscreen the old (< 10.7) way. The old way works better with +@@ -14263,12 +14708,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 efd632c..e4bdaab 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,4 +1,4 @@ -From b33ad461f56034270d8461ad48e838f5f36a98da Mon Sep 17 00:00:00 2001 +From 33ed790921c1d78dec79f803807deae65fff365e 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 @@ -110,10 +110,10 @@ index 6bd334f48e..8d4a7825d8 100644 @section GNUstep Support diff --git a/src/nsterm.m b/src/nsterm.m -index 95a5b378c1..8a1bfd9eb1 100644 +index 7c118045bd..a0598a73c2 100644 --- a/src/nsterm.m +++ b/src/nsterm.m -@@ -14665,9 +14665,13 @@ Nil means use fullscreen the old (< 10.7) way. The old way works better with +@@ -14709,9 +14709,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 080b718..ffd6614 100644 --- a/patches/0007-ns-announce-overlay-completion-candidates-for-VoiceO.patch +++ b/patches/0007-ns-announce-overlay-completion-candidates-for-VoiceO.patch @@ -1,4 +1,4 @@ -From dd8b0911c8a690aa7110b881b6a88977e2fc67f1 Mon Sep 17 00:00:00 2001 +From 8c99359156443223d13905de4cfbca58fb3e1177 Mon Sep 17 00:00:00 2001 From: Daneel Date: Mon, 2 Mar 2026 18:39:46 +0100 Subject: [PATCH 7/8] ns: announce overlay completion candidates for VoiceOver @@ -34,7 +34,7 @@ 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 8a1bfd9eb1..523f79d235 100644 +index a0598a73c2..3d8a5dd0fc 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -7263,11 +7263,154 @@ Accessibility virtual elements (macOS / Cocoa only) @@ -465,7 +465,7 @@ index 8a1bfd9eb1..523f79d235 100644 { ptrdiff_t oldPoint = self.cachedPoint; BOOL oldMarkActive = self.cachedMarkActive; -@@ -12358,7 +12604,7 @@ - (int) fullscreenState +@@ -12402,7 +12648,7 @@ - (int) fullscreenState if (WINDOW_LEAF_P (w)) { @@ -474,7 +474,7 @@ index 8a1bfd9eb1..523f79d235 100644 EmacsAccessibilityBuffer *elem = [existing objectForKey:[NSValue valueWithPointer:w]]; if (!elem) -@@ -12392,7 +12638,7 @@ - (int) fullscreenState +@@ -12436,7 +12682,7 @@ - (int) fullscreenState } else { @@ -483,7 +483,7 @@ index 8a1bfd9eb1..523f79d235 100644 Lisp_Object child = w->contents; while (!NILP (child)) { -@@ -12504,7 +12750,7 @@ - (void)postAccessibilityUpdates +@@ -12548,7 +12794,7 @@ - (void)postAccessibilityUpdates accessibilityUpdating = YES; /* Detect window tree change (split, delete, new buffer). Compare @@ -492,7 +492,7 @@ index 8a1bfd9eb1..523f79d235 100644 Lisp_Object curRoot = FRAME_ROOT_WINDOW (emacsframe); if (!EQ (curRoot, lastRootWindow)) { -@@ -12513,12 +12759,12 @@ - (void)postAccessibilityUpdates +@@ -12557,12 +12803,12 @@ - (void)postAccessibilityUpdates } /* If tree is stale, rebuild FIRST so we don't iterate freed 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 f93076f..90212b9 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,4 +1,4 @@ -From 13da736610fc631a9ea420d6918eefd2791940d3 Mon Sep 17 00:00:00 2001 +From 33333f637c51c1ee2080c780fd623e67d3a85545 Mon Sep 17 00:00:00 2001 From: Daneel Date: Mon, 2 Mar 2026 18:49:13 +0100 Subject: [PATCH 8/8] ns: announce child frame completion candidates for @@ -33,8 +33,8 @@ area announcements. doc/emacs/macos.texi | 14 +- etc/NEWS | 18 +- src/nsterm.h | 20 ++ - src/nsterm.m | 508 ++++++++++++++++++++++++++++++++++++++++--- - 4 files changed, 512 insertions(+), 48 deletions(-) + src/nsterm.m | 456 +++++++++++++++++++++++++++++++++++++++---- + 4 files changed, 460 insertions(+), 48 deletions(-) diff --git a/doc/emacs/macos.texi b/doc/emacs/macos.texi index 8d4a7825d8..03a657f970 100644 @@ -149,7 +149,7 @@ index 21a93bc799..bdd40b8eb7 100644 @end diff --git a/src/nsterm.m b/src/nsterm.m -index 523f79d235..45f83f3ac6 100644 +index 3d8a5dd0fc..7555ae3e95 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -1126,24 +1126,19 @@ Uses CFAbsoluteTimeGetCurrent() (~5 ns, a VDSO read) for timing. */ @@ -483,66 +483,7 @@ index 523f79d235..45f83f3ac6 100644 NSInteger direction = ns_ax_text_selection_direction_discontiguous; if (point > oldPoint) direction = ns_ax_text_selection_direction_next; -@@ -9530,6 +9698,58 @@ frameworks like Vertico bump BOTH BUF_MODIFF (via text property - granularity = ns_ax_text_selection_granularity_line; - } - -+ /* Programmatic jumps that cross a line boundary (]], [[, M-<, -+ 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 -+ accessibilitySelectedTextRange rather than advancing linearly -+ from its previous internal position. */ -+ if (!isCtrlNP && granularity == ns_ax_text_selection_granularity_line) -+ direction = ns_ax_text_selection_direction_discontiguous; -+ -+ /* If Emacs moved the cursor (not VoiceOver), force discontiguous -+ so VoiceOver re-anchors its browse cursor to the current -+ accessibilitySelectedTextRange. This covers all Emacs-initiated -+ moves: editing commands, ELisp, isearch, etc. -+ Exception: C-n/C-p (isCtrlNP) already uses next/previous with -+ line granularity; those are already sequential and VoiceOver -+ handles them correctly. */ -+ if (emacsMovedCursor && !isCtrlNP) -+ direction = ns_ax_text_selection_direction_discontiguous; -+ -+ /* When Emacs moves the cursor (not VoiceOver-initiated), -+ VoiceOver's browse cursor must re-anchor to the new -+ insertion point. Posting FocusedUIElementChanged on self -+ (a custom NSObject-based element, not an NSView) is -+ insufficient \u2014 VoiceOver only re-anchors its browse cursor -+ when it receives FocusedUIElementChanged from the NSWindow, -+ which triggers accessibilityFocusedUIElement to walk the -+ hierarchy and re-anchor at the returned element. -+ -+ Post on the window so VoiceOver calls -+ accessibilityFocusedUIElement on it, receives our buffer -+ element, and re-anchors its rotor browse cursor. Also -+ post LayoutChanged with UIElementsKey on the parent view -+ so VoiceOver re-examines our element's properties -+ (including accessibilitySelectedTextRange). */ -+ if (emacsMovedCursor && [self isAccessibilityFocused]) -+ { -+ NSWindow *win = [self.emacsView window]; -+ if (win) -+ ns_ax_post_notification ( -+ win, -+ NSAccessibilityFocusedUIElementChangedNotification); -+ -+ NSDictionary *layoutInfo = @{ -+ NSAccessibilityUIElementsKey: @[self] -+ }; -+ ns_ax_post_notification_with_info ( -+ self.emacsView, -+ NSAccessibilityLayoutChangedNotification, -+ layoutInfo); -+ } -+ - /* Post notifications for focused and non-focused elements. */ - if ([self isAccessibilityFocused]) - [self postFocusedCursorNotification:point -@@ -9672,6 +9892,13 @@ - (NSRect)accessibilityFrame +@@ -9716,6 +9884,13 @@ - (NSRect)accessibilityFrame if (vis_start >= vis_end) return @[]; @@ -556,7 +497,7 @@ index 523f79d235..45f83f3ac6 100644 block_input (); specpdl_ref blk_count = SPECPDL_INDEX (); record_unwind_protect_void (unblock_input); -@@ -9796,6 +10023,7 @@ than O(chars). Fall back to pos+1 as safety net. */ +@@ -9840,6 +10015,7 @@ than O(chars). Fall back to pos+1 as safety net. */ pos = span_end; } @@ -564,7 +505,7 @@ index 523f79d235..45f83f3ac6 100644 return [[spans copy] autorelease]; } -@@ -9977,6 +10205,10 @@ - (void)dealloc +@@ -10021,6 +10197,10 @@ - (void)dealloc #endif [accessibilityElements release]; @@ -575,7 +516,7 @@ index 523f79d235..45f83f3ac6 100644 [[self menu] release]; [super dealloc]; } -@@ -11426,6 +11658,9 @@ - (instancetype) initFrameFromEmacs: (struct frame *)f +@@ -11470,6 +11650,9 @@ - (instancetype) initFrameFromEmacs: (struct frame *)f windowClosing = NO; processingCompose = NO; @@ -585,7 +526,7 @@ index 523f79d235..45f83f3ac6 100644 scrollbarsNeedingUpdate = 0; fs_state = FULLSCREEN_NONE; fs_before_fs = next_maximized = -1; -@@ -12734,6 +12969,154 @@ - (id)accessibilityFocusedUIElement +@@ -12778,6 +12961,154 @@ - (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. */ @@ -740,7 +681,7 @@ index 523f79d235..45f83f3ac6 100644 - (void)postAccessibilityUpdates { NSTRACE ("[EmacsView postAccessibilityUpdates]"); -@@ -12744,11 +13127,64 @@ - (void)postAccessibilityUpdates +@@ -12788,11 +13119,64 @@ - (void)postAccessibilityUpdates /* Re-entrance guard: VoiceOver callbacks during notification posting can trigger redisplay, which calls ns_update_end, which calls us