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.
628 lines
21 KiB
Diff
628 lines
21 KiB
Diff
From d4cda4bda0bee73c14946f20322975edd1580d46 Mon Sep 17 00:00:00 2001
|
|
From: Martin Sukany <martin@sukany.cz>
|
|
Date: Sat, 28 Feb 2026 12:58:11 +0100
|
|
Subject: [PATCH 5/8] ns: integrate accessibility with EmacsView and redisplay
|
|
|
|
Wire the accessibility element tree into EmacsView and hook it into
|
|
the redisplay cycle.
|
|
|
|
* etc/NEWS: Document VoiceOver accessibility support.
|
|
* src/nsterm.m (ns_update_end): Call -[EmacsView postAccessibilityUpdates].
|
|
(EmacsApp ns_update_accessibility_state): New method; query
|
|
AXIsProcessTrustedWithOptions and UAZoomEnabled to set
|
|
ns_accessibility_enabled automatically.
|
|
(EmacsApp ns_accessibility_did_change:): New method; handle
|
|
com.apple.accessibility.api distributed notification.
|
|
(EmacsView dealloc): Release accessibilityElements.
|
|
(EmacsView windowDidBecomeKey:): Post accessibility focus notification.
|
|
(ns_ax_collect_windows): New function.
|
|
(EmacsView rebuildAccessibilityTree, invalidateAccessibilityTree)
|
|
(accessibilityChildren, accessibilityFocusedUIElement)
|
|
(postAccessibilityUpdates, accessibilityBoundsForRange:)
|
|
(accessibilityParameterizedAttributeNames)
|
|
(accessibilityAttributeValue:forParameter:): New methods.
|
|
---
|
|
etc/NEWS | 13 ++
|
|
src/nsterm.m | 474 +++++++++++++++++++++++++++++++++++++++++++++++++--
|
|
2 files changed, 475 insertions(+), 12 deletions(-)
|
|
|
|
diff --git a/etc/NEWS b/etc/NEWS
|
|
index 4c149e41d6..7f917f93b2 100644
|
|
--- a/etc/NEWS
|
|
+++ b/etc/NEWS
|
|
@@ -4385,6 +4385,19 @@ allowing Emacs users access to speech recognition utilities.
|
|
Note: Accepting this permission allows the use of system APIs, which may
|
|
send user data to Apple's speech recognition servers.
|
|
|
|
+---
|
|
+** VoiceOver accessibility support on macOS.
|
|
+Emacs now exposes buffer content, cursor position, and interactive
|
|
+elements to the macOS accessibility subsystem (VoiceOver). This
|
|
+includes AXBoundsForRange for macOS Zoom cursor tracking, line and
|
|
+word navigation announcements, Tab-navigable interactive spans
|
|
+(buttons, links, completion candidates), and completion announcements
|
|
+for the *Completions* buffer. The implementation uses a virtual
|
|
+accessibility tree with per-window elements, hybrid SelectedTextChanged
|
|
+and AnnouncementRequested notifications, and thread-safe text caching.
|
|
+Set 'ns-accessibility-enabled' to nil to disable the accessibility
|
|
+interface and eliminate the associated overhead.
|
|
+
|
|
---
|
|
** 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..7c118045bd 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
|
|
+ && ns_zoom_enabled_p ()
|
|
&& !NSIsEmptyRect (view->lastCursorRect))
|
|
{
|
|
NSRect r = view->lastCursorRect;
|
|
@@ -1420,6 +1421,9 @@ so the visual offset is (ov_line + 1) * line_h from
|
|
if (view)
|
|
ns_zoom_track_completion (f, view);
|
|
#endif /* NS_IMPL_COCOA */
|
|
+
|
|
+ /* Post accessibility notifications after each redisplay cycle. */
|
|
+ [view postAccessibilityUpdates];
|
|
}
|
|
|
|
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
|
|
|
|
+#ifdef NS_IMPL_COCOA
|
|
+ /* Auto-detect Zoom and VoiceOver at startup and whenever their state
|
|
+ changes. The "com.apple.accessibility.api" distributed notification
|
|
+ fires when any assistive technology connects or disconnects.
|
|
+ Both code paths set ns_accessibility_enabled so that one variable
|
|
+ gates all our accessibility overhead. */
|
|
+ [self ns_update_accessibility_state];
|
|
+ [[NSDistributedNotificationCenter defaultCenter]
|
|
+ addObserver: self
|
|
+ selector: @selector(ns_accessibility_did_change:)
|
|
+ name: @"com.apple.accessibility.api"
|
|
+ object: nil
|
|
+suspensionBehavior: NSNotificationSuspensionBehaviorDeliverImmediately];
|
|
+#endif
|
|
+
|
|
ns_send_appdefined (-2);
|
|
}
|
|
|
|
+#ifdef NS_IMPL_COCOA
|
|
+/* Set ns_accessibility_enabled based on current AT state.
|
|
+ Called at startup and from the "com.apple.accessibility.api"
|
|
+ distributed notification handler. Checks both UAZoomEnabled()
|
|
+ (Zoom) and AXIsProcessTrustedWithOptions() (VoiceOver and other
|
|
+ ATs that have connected to this process). */
|
|
+- (void) ns_update_accessibility_state
|
|
+{
|
|
+ NSTRACE ("[EmacsApp ns_update_accessibility_state]");
|
|
+ BOOL zoom_on = UAZoomEnabled ();
|
|
+ NSDictionary *opts = @{(__bridge id) kAXTrustedCheckOptionPrompt: @NO};
|
|
+ BOOL at_on = AXIsProcessTrustedWithOptions ((__bridge CFDictionaryRef) opts);
|
|
+ BOOL new_state = zoom_on || at_on;
|
|
+ if ((BOOL) ns_accessibility_enabled != new_state)
|
|
+ {
|
|
+ ns_accessibility_enabled = new_state;
|
|
+ /* Reset the UAZoomEnabled cache so ns_zoom_enabled_p() reflects
|
|
+ the new Zoom state on its next call. */
|
|
+ ns_zoom_cache_time = 0;
|
|
+ }
|
|
+}
|
|
+
|
|
+/* Handler for the "com.apple.accessibility.api" distributed notification,
|
|
+ posted by macOS when any AT (VoiceOver, Switch Control, etc.) starts
|
|
+ or stops. */
|
|
+- (void) ns_accessibility_did_change: (NSNotification *) notification
|
|
+{
|
|
+ NSTRACE ("[EmacsApp ns_accessibility_did_change:]");
|
|
+ [self ns_update_accessibility_state];
|
|
+}
|
|
+#endif
|
|
+
|
|
- (void)antialiasThresholdDidChange:(NSNotification *)notification
|
|
{
|
|
#ifdef NS_IMPL_COCOA
|
|
@@ -7628,7 +7679,6 @@ - (id)accessibilityTopLevelUIElement
|
|
|
|
|
|
|
|
-
|
|
static BOOL
|
|
ns_ax_find_completion_overlay_range (struct buffer *b, ptrdiff_t point,
|
|
ptrdiff_t *out_start,
|
|
@@ -8741,7 +8791,6 @@ - (NSRect)accessibilityFrame
|
|
@end
|
|
|
|
|
|
-
|
|
/* ===================================================================
|
|
EmacsAccessibilityBuffer (Notifications) — AX event dispatch
|
|
|
|
@@ -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
|
|
|
|
|
|
-
|
|
/* ===================================================================
|
|
EmacsAccessibilityInteractiveSpan --- helpers and implementation
|
|
=================================================================== */
|
|
@@ -9682,6 +9774,7 @@ - (void)dealloc
|
|
[layer release];
|
|
#endif
|
|
|
|
+ [accessibilityElements release];
|
|
[[self menu] release];
|
|
[super dealloc];
|
|
}
|
|
@@ -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
|
|
+
|
|
+#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
|
|
}
|
|
|
|
|
|
@@ -12267,6 +12386,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 = lastCursorRect;
|
|
+
|
|
+ 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 */
|
|
|
|
|
|
@@ -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,
|
|
- doc: /* Non-nil means expose buffer content to the macOS accessibility
|
|
-subsystem (VoiceOver, Zoom, and other assistive technology).
|
|
-When nil, the accessibility virtual element tree is not built and no
|
|
-notifications are posted, eliminating the associated overhead.
|
|
-Requires the Cocoa (NS) build on macOS; ignored on GNUstep.
|
|
-Default is nil. Set to t to enable VoiceOver support. */);
|
|
+ doc: /* Non-nil enables Zoom cursor tracking and VoiceOver support.
|
|
+Emacs sets this automatically at startup when macOS Zoom is active or
|
|
+any assistive technology (VoiceOver, Switch Control, etc.) is connected,
|
|
+and updates it whenever that state changes. You can override manually:
|
|
+
|
|
+ (setq ns-accessibility-enabled t) ; always on
|
|
+ (setq ns-accessibility-enabled nil) ; always off
|
|
+
|
|
+When nil, no AX tree is built and no notifications are posted,
|
|
+giving zero per-redisplay overhead.
|
|
+Requires the Cocoa (NS) build on macOS; ignored on GNUstep. */);
|
|
ns_accessibility_enabled = NO;
|
|
|
|
DEFVAR_BOOL ("ns-use-mwheel-acceleration",
|
|
--
|
|
2.43.0
|
|
|