diff --git a/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch b/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch index 9c6fe19..2f8c091 100644 --- a/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch +++ b/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch @@ -1,7 +1,7 @@ -From 17a100d99a31e0fae9b641c7ce163efd9bf5945b Mon Sep 17 00:00:00 2001 +From c4c5ae47fd944cc04f7e229c2a66fb44fa9d006e Mon Sep 17 00:00:00 2001 From: Martin Sukany -Date: Fri, 27 Feb 2026 15:09:15 +0100 -Subject: [PATCH] ns: implement VoiceOver accessibility for macOS +Date: Fri, 27 Feb 2026 17:48:49 +0100 +Subject: [PATCH 1/2] ns: implement VoiceOver accessibility for macOS Add comprehensive macOS VoiceOver accessibility support to the NS (Cocoa) port. Before this patch, Emacs exposed only a minimal, @@ -29,8 +29,6 @@ New types and classes: for Tab-navigable interactive spans (buttons, links, checkboxes, completion candidates, Org-mode links, keymap overlays). - EmacsAXSpanType: enum for span classification. - New functions: ns_ax_buffer_text: build accessibility string with visible-run @@ -57,7 +55,7 @@ New functions: completions-highlight overlay for completion announcements. ns_ax_completion_text_for_span: extract announcement text for a - completion overlay span. + completion overlay span (with block_input/unblock_input protection). EmacsView extensions: @@ -72,10 +70,16 @@ EmacsView extensions: ns_draw_phys_cursor: stores cursor rect for Zoom, calls UAZoomChangeFocus with correct CG coordinate-space transform. -DEFSYM additions in syms_of_nsterm (ns_ax_ prefix to avoid -collisions): Qns_ax_widget, Qns_ax_button, Qns_ax_follow_link, -Qns_ax_org_link, Qns_ax_completion_list_mode, Qns_ax_completion__string, Qns_ax_completion, -Qns_ax_completions_highlight, Qns_ax_backtab. +DEFSYM additions in syms_of_nsterm: line navigation command symbols +(Qns_ax_next_line, Qns_ax_previous_line, evil/dired variants) and +span scanning symbols (Qns_ax_widget, Qns_ax_button, +Qns_ax_follow_link, Qns_ax_org_link, Qns_ax_completion_list_mode, +Qns_ax_completion__string, Qns_ax_completion, +Qns_ax_completions_highlight, Qns_ax_backtab). + +New user option: ns-accessibility-enabled (default t). When nil, +the accessibility virtual element tree is not built and no +notifications are posted, eliminating overhead. Threading model: all Lisp calls on main thread; AX getters use dispatch_sync to main; index mapping methods are thread-safe (no @@ -85,16 +89,16 @@ Lisp calls, read only immutable NSString and scalar cache). * src/nsterm.h: New class declarations and EmacsView ivar extensions. * etc/NEWS: Document VoiceOver accessibility support. --- - etc/NEWS | 11 + - src/nsterm.h | 108 ++ - src/nsterm.m | 2870 +++++++++++++++++++++++++++++++++++++++++++++++--- - 3 files changed, 2987 insertions(+), 149 deletions(-) + etc/NEWS | 13 + + src/nsterm.h | 119 ++ + src/nsterm.m | 3024 +++++++++++++++++++++++++++++++++++++++++++++++--- + 3 files changed, 3009 insertions(+), 147 deletions(-) diff --git a/etc/NEWS b/etc/NEWS -index 7367e3cc..0e4480ad 100644 +index 7367e3c..608650e 100644 --- a/etc/NEWS +++ b/etc/NEWS -@@ -4374,6 +4374,17 @@ allowing Emacs users access to speech recognition utilities. +@@ -4374,6 +4374,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. @@ -108,15 +112,17 @@ index 7367e3cc..0e4480ad 100644 +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.h b/src/nsterm.h -index 7c1ee4cf..542e7d59 100644 +index 7c1ee4c..393fc4c 100644 --- a/src/nsterm.h +++ b/src/nsterm.h -@@ -453,6 +453,97 @@ enum ns_return_frame_mode +@@ -453,6 +453,110 @@ enum ns_return_frame_mode @end @@ -132,7 +138,18 @@ index 7c1ee4cf..542e7d59 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. NULL_LISP when unset. */ ++/* 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. ++ (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 ++ updates them during the next redisplay cycle. ++ (3) AX getters dispatch_sync to main before accessing Lisp state, ++ so GC cannot run concurrently with any access to lispWindow. ++ (4) validWindow checks WINDOW_LIVE_P before dereferencing. */ +@property (nonatomic, assign) Lisp_Object lispWindow; +- (struct window *)validWindow; /* Returns live window or NULL. */ +- (NSRect)screenRectFromEmacsX:(int)x y:(int)y width:(int)w height:(int)h; @@ -216,11 +233,12 @@ index 7c1ee4cf..542e7d59 100644 /* ========================================================================== The main Emacs view -@@ -471,6 +562,13 @@ enum ns_return_frame_mode +@@ -471,6 +575,14 @@ enum ns_return_frame_mode #ifdef NS_IMPL_COCOA char *old_title; BOOL maximizing_resize; + NSMutableArray *accessibilityElements; ++ /* See GC safety comment on EmacsAccessibilityElement.lispWindow. */ + Lisp_Object lastSelectedWindow; + Lisp_Object lastRootWindow; + BOOL accessibilityTreeValid; @@ -230,7 +248,7 @@ index 7c1ee4cf..542e7d59 100644 #endif BOOL font_panel_active; NSFont *font_panel_result; -@@ -528,6 +626,13 @@ enum ns_return_frame_mode +@@ -528,6 +640,13 @@ enum ns_return_frame_mode - (void)windowWillExitFullScreen; - (void)windowDidExitFullScreen; - (void)windowDidBecomeKey; @@ -245,7 +263,7 @@ index 7c1ee4cf..542e7d59 100644 diff --git a/src/nsterm.m b/src/nsterm.m -index 932d209f..ea2de6f2 100644 +index 932d209..6b27c6c 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -46,6 +46,7 @@ Updated by Christian Limpach (chris@nice.ch) @@ -268,18 +286,19 @@ index 932d209f..ea2de6f2 100644 } static void -@@ -3232,6 +3238,42 @@ Note that CURSOR_WIDTH is meaningful only for (h)bar cursors. +@@ -3232,6 +3238,43 @@ Note that CURSOR_WIDTH is meaningful only for (h)bar cursors. /* Prevent the cursor from being drawn outside the text area. */ r = NSIntersectionRect (r, ns_row_rect (w, glyph_row, TEXT_AREA)); +#ifdef NS_IMPL_COCOA + /* Accessibility: store cursor rect for Zoom and bounds queries. ++ Skipped when ns-accessibility-enabled is nil to avoid overhead. + VoiceOver notifications are handled solely by + postAccessibilityUpdates (called from ns_update_end) + to avoid duplicate notifications and mid-redisplay fragility. */ + { + EmacsView *view = FRAME_NS_VIEW (f); -+ if (view && on_p && active_p) ++ if (view && on_p && active_p && ns_accessibility_enabled) + { + view->lastAccessibilityCursorRect = r; + @@ -311,7 +330,7 @@ index 932d209f..ea2de6f2 100644 ns_focus (f, NULL, 0); NSGraphicsContext *ctx = [NSGraphicsContext currentContext]; -@@ -6849,218 +6891,2498 @@ - (BOOL)fulfillService: (NSString *)name withArg: (NSString *)arg +@@ -6849,218 +6892,2522 @@ - (BOOL)fulfillService: (NSString *)name withArg: (NSString *)arg /* ========================================================================== @@ -786,14 +805,6 @@ index 932d209f..ea2de6f2 100644 +static bool +ns_ax_event_is_line_nav_key (int *which) +{ -- --#ifdef NS_IMPL_COCOA -- if (!canceled) -- font_panel_result = nil; --#endif -- -- result = font_panel_result; -- font_panel_result = nil; + /* 1. Check Vthis_command for known navigation command symbols. + All symbols are registered via DEFSYM in syms_of_nsterm to avoid + per-call obarray lookups in this hot path (runs every cursor move). */ @@ -819,12 +830,18 @@ index 932d209f..ea2de6f2 100644 + return true; + } + } -+ + +-#ifdef NS_IMPL_COCOA +- if (!canceled) +- font_panel_result = nil; +-#endif + /* 2. Fallback: check raw key events for Tab/backtab. */ + Lisp_Object ev = last_command_event; + if (CONSP (ev)) + ev = EVENT_HEAD (ev); -+ + +- result = font_panel_result; +- font_panel_result = nil; + if (SYMBOLP (ev) && EQ (ev, Qns_ax_backtab)) + { + if (which) *which = -1; @@ -875,12 +892,16 @@ index 932d209f..ea2de6f2 100644 -- (BOOL)acceptsFirstResponder +/* Compute visible-end charpos for window W. -+ Emacs stores it as BUF_Z - window_end_pos. */ ++ Emacs stores it as BUF_Z - window_end_pos. ++ Falls back to BUF_ZV when window_end_valid is false (e.g., when ++ called from an AX getter before the next redisplay cycle). */ +static ptrdiff_t +ns_ax_window_end_charpos (struct window *w, struct buffer *b) { - NSTRACE ("[EmacsView acceptsFirstResponder]"); - return YES; ++ if (!w->window_end_valid) ++ return BUF_ZV (b); + return BUF_Z (b) - w->window_end_pos; } @@ -894,6 +915,8 @@ index 932d209f..ea2de6f2 100644 - [theEvent type], [theEvent clickCount]); - return ns_click_through; + Lisp_Object plist = Ftext_properties_at (make_fixnum (pos), buf_obj); ++ /* Third argument to Fplist_get is PREDICATE (Emacs 29+), not a ++ default value. Qnil selects the default `eq' comparison. */ + return Fplist_get (plist, prop, Qnil); } -- (void)resetCursorRects @@ -970,7 +993,7 @@ index 932d209f..ea2de6f2 100644 + NSAccessibilityPostNotification (element, name); + }); +} -+ + +static inline void +ns_ax_post_notification_with_info (id element, + NSAccessibilityNotificationName name, @@ -980,30 +1003,29 @@ index 932d209f..ea2de6f2 100644 + NSAccessibilityPostNotificationWithUserInfo (element, name, info); + }); +} -+ + +-/*****************************************************************************/ +-/* Keyboard handling. */ +-#define NS_KEYLOG 0 +/* Scan visible range of window W for interactive spans. + Returns NSArray. +-- (void)keyDown: (NSEvent *)theEvent + Priority when properties overlap: + widget > button > follow-link > org-link > + completion-candidate > keymap-overlay. */ +static NSArray * +ns_ax_scan_interactive_spans (struct window *w, + EmacsAccessibilityBuffer *parent_buf) -+{ + { +- Mouse_HLInfo *hlinfo = MOUSE_HL_INFO (emacsframe); + if (!w) + return @[]; - --/*****************************************************************************/ --/* Keyboard handling. */ --#define NS_KEYLOG 0 ++ + Lisp_Object buf_obj = ns_ax_window_buffer_object (w); + if (NILP (buf_obj)) + return @[]; - --- (void)keyDown: (NSEvent *)theEvent --{ -- Mouse_HLInfo *hlinfo = MOUSE_HL_INFO (emacsframe); ++ + struct buffer *b = XBUFFER (buf_obj); + ptrdiff_t vis_start = marker_position (w->start); + ptrdiff_t vis_end = ns_ax_window_end_charpos (w, b); @@ -1278,6 +1300,11 @@ index 932d209f..ea2de6f2 100644 + NSString *text = nil; + specpdl_ref count = SPECPDL_INDEX (); + record_unwind_current_buffer (); ++ /* Block input to prevent concurrent redisplay from modifying buffer ++ state while we read text properties. Unwind-protected so ++ block_input is always matched by unblock_input on signal. */ ++ record_unwind_protect_void (unblock_input); ++ block_input (); + if (b != current_buffer) + set_buffer_internal_1 (b); + @@ -2214,8 +2241,353 @@ index 932d209f..ea2de6f2 100644 + height:text_h]; +} + -+/* ---- Notification dispatch ---- */ ++/* ---- Notification dispatch (helper methods) ---- */ + ++/* Post NSAccessibilityValueChangedNotification for a text edit. ++ Called when BUF_MODIFF changes between redisplay cycles. */ ++- (void)postTextChangedNotification:(ptrdiff_t)point ++{ ++ /* Capture changed char before invalidating cache. */ ++ NSString *changedChar = @""; ++ if (point > self.cachedPoint ++ && point - self.cachedPoint == 1) ++ { ++ /* Single char inserted — refresh cache and grab it. */ ++ [self invalidateTextCache]; ++ [self ensureTextCache]; ++ if (cachedText) ++ { ++ NSUInteger idx = [self accessibilityIndexForCharpos:point - 1]; ++ if (idx < [cachedText length]) ++ changedChar = [cachedText substringWithRange: ++ NSMakeRange (idx, 1)]; ++ } ++ } ++ else ++ { ++ [self invalidateTextCache]; ++ } ++ ++ /* 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. */ ++ self.cachedPoint = point; ++ ++ NSDictionary *change = @{ ++ @"AXTextEditType": @(ns_ax_text_edit_type_typing), ++ @"AXTextChangeValue": changedChar, ++ @"AXTextChangeValueLength": @([changedChar length]) ++ }; ++ NSDictionary *userInfo = @{ ++ @"AXTextStateChangeType": @(ns_ax_text_state_change_edit), ++ @"AXTextChangeValues": @[change], ++ @"AXTextChangeElement": self ++ }; ++ ns_ax_post_notification_with_info ( ++ self, NSAccessibilityValueChangedNotification, userInfo); ++} ++ ++/* Post SelectedTextChanged and AnnouncementRequested for the ++ focused buffer element when point or mark changes. */ ++- (void)postFocusedCursorNotification:(ptrdiff_t)point ++ direction:(NSInteger)direction ++ granularity:(NSInteger)granularity ++ markActive:(BOOL)markActive ++ oldMarkActive:(BOOL)oldMarkActive ++{ ++ BOOL isCharMove ++ = (!markActive && !oldMarkActive ++ && granularity ++ == ns_ax_text_selection_granularity_character); ++ ++ /* Always post SelectedTextChanged to interrupt VoiceOver reading ++ and update cursor tracking / braille displays. */ ++ NSMutableDictionary *moveInfo = [NSMutableDictionary dictionary]; ++ moveInfo[@"AXTextStateChangeType"] ++ = @(ns_ax_text_state_change_selection_move); ++ moveInfo[@"AXTextSelectionDirection"] = @(direction); ++ moveInfo[@"AXTextChangeElement"] = self; ++ /* Omit granularity for character moves so VoiceOver does not ++ derive its own speech (it would read the wrong character ++ for evil block-cursor mode). Include it for word/line/ ++ selection so VoiceOver reads the appropriate text. */ ++ if (!isCharMove) ++ moveInfo[@"AXTextSelectionGranularity"] = @(granularity); ++ ++ ns_ax_post_notification_with_info ( ++ self, ++ NSAccessibilitySelectedTextChangedNotification, ++ moveInfo); ++ ++ /* For character moves: explicit announcement of char AT point. ++ This is the ONLY speech source for character navigation. ++ Correct for evil block-cursor (cursor ON the character) ++ and harmless for insert-mode. */ ++ if (isCharMove && cachedText) ++ { ++ NSUInteger point_idx ++ = [self accessibilityIndexForCharpos:point]; ++ NSUInteger tlen = [cachedText length]; ++ if (point_idx < tlen) ++ { ++ NSRange charRange = [cachedText ++ rangeOfComposedCharacterSequenceAtIndex: point_idx]; ++ if (charRange.location != NSNotFound ++ && charRange.length > 0 ++ && NSMaxRange (charRange) <= tlen) ++ { ++ NSString *ch ++ = [cachedText substringWithRange: charRange]; ++ if (![ch isEqualToString: @"\n"]) ++ { ++ NSDictionary *annInfo = @{ ++ NSAccessibilityAnnouncementKey: ch, ++ NSAccessibilityPriorityKey: ++ @(NSAccessibilityPriorityHigh) ++ }; ++ ns_ax_post_notification_with_info ( ++ NSApp, ++ NSAccessibilityAnnouncementRequestedNotification, ++ annInfo); ++ } ++ } ++ } ++ } ++ ++ /* For focused line moves: always announce line text explicitly. ++ SelectedTextChanged with granularity=line works for arrow keys, ++ but C-n/C-p need the explicit announcement (VoiceOver processes ++ these keystrokes differently from arrows). ++ In completion-list-mode, read the completion candidate instead ++ of the whole line. */ ++ if (cachedText ++ && granularity == ns_ax_text_selection_granularity_line) ++ { ++ NSString *announceText = nil; ++ ++ /* 1. completion--string at point. */ ++ Lisp_Object cstr ++ = Fget_char_property (make_fixnum (point), ++ Qns_ax_completion__string, Qnil); ++ announceText = ns_ax_completion_string_from_prop (cstr); ++ ++ /* 2. Fallback: full line text. */ ++ if (!announceText) ++ { ++ NSUInteger point_idx ++ = [self accessibilityIndexForCharpos:point]; ++ if (point_idx <= [cachedText length]) ++ { ++ NSInteger lineNum ++ = [self accessibilityLineForIndex:point_idx]; ++ NSRange lineRange ++ = [self accessibilityRangeForLine:lineNum]; ++ if (lineRange.location != NSNotFound ++ && lineRange.length > 0 ++ && NSMaxRange (lineRange) <= [cachedText length]) ++ announceText ++ = [cachedText substringWithRange:lineRange]; ++ } ++ } ++ ++ if (announceText) ++ { ++ announceText = [announceText ++ stringByTrimmingCharactersInSet: ++ [NSCharacterSet whitespaceAndNewlineCharacterSet]]; ++ if ([announceText length] > 0) ++ { ++ NSDictionary *annInfo = @{ ++ NSAccessibilityAnnouncementKey: announceText, ++ NSAccessibilityPriorityKey: ++ @(NSAccessibilityPriorityHigh) ++ }; ++ ns_ax_post_notification_with_info ( ++ NSApp, ++ NSAccessibilityAnnouncementRequestedNotification, ++ annInfo); ++ } ++ } ++ } ++} ++ ++/* Post AnnouncementRequested for non-focused buffers (typically ++ *Completions* while minibuffer has keyboard focus). ++ VoiceOver does not automatically read changes in non-focused ++ elements, so we announce the selected completion explicitly. */ ++- (void)postCompletionAnnouncementForBuffer:(struct buffer *)b ++ point:(ptrdiff_t)point ++{ ++ NSString *announceText = nil; ++ ptrdiff_t currentOverlayStart = 0; ++ ptrdiff_t currentOverlayEnd = 0; ++ ++ specpdl_ref count2 = SPECPDL_INDEX (); ++ record_unwind_current_buffer (); ++ if (b != current_buffer) ++ set_buffer_internal_1 (b); ++ ++ /* 1) Prefer explicit completion candidate property. */ ++ Lisp_Object cstr = Fget_char_property (make_fixnum (point), ++ Qns_ax_completion__string, ++ Qnil); ++ announceText = ns_ax_completion_string_from_prop (cstr); ++ ++ /* 2) Fallback: mouse-face span at point. */ ++ if (!announceText) ++ { ++ Lisp_Object mf = Fget_char_property (make_fixnum (point), ++ Qmouse_face, Qnil); ++ if (!NILP (mf)) ++ { ++ ptrdiff_t begv2 = BUF_BEGV (b); ++ ptrdiff_t zv2 = BUF_ZV (b); ++ ++ Lisp_Object prev_change ++ = Fprevious_single_char_property_change ( ++ make_fixnum (point + 1), Qmouse_face, ++ Qnil, make_fixnum (begv2)); ++ ptrdiff_t s2 ++ = FIXNUMP (prev_change) ? XFIXNUM (prev_change) ++ : begv2; ++ ++ Lisp_Object next_change ++ = Fnext_single_char_property_change ( ++ make_fixnum (point), Qmouse_face, ++ Qnil, make_fixnum (zv2)); ++ ptrdiff_t e2 ++ = FIXNUMP (next_change) ? XFIXNUM (next_change) ++ : zv2; ++ ++ if (e2 > s2) ++ { ++ NSUInteger ax_s = [self accessibilityIndexForCharpos:s2]; ++ NSUInteger ax_e = [self accessibilityIndexForCharpos:e2]; ++ if (ax_e > ax_s && ax_e <= [cachedText length]) ++ announceText = [cachedText substringWithRange: ++ NSMakeRange (ax_s, ax_e - ax_s)]; ++ } ++ } ++ } ++ ++ /* 3) Fallback: completions-highlight overlay at point. */ ++ if (!announceText) ++ { ++ Lisp_Object faceSym = Qns_ax_completions_highlight; ++ Lisp_Object overlays = Foverlays_at (make_fixnum (point), Qnil); ++ Lisp_Object tail; ++ for (tail = overlays; CONSP (tail); tail = XCDR (tail)) ++ { ++ Lisp_Object ov = XCAR (tail); ++ Lisp_Object face = Foverlay_get (ov, Qface); ++ if (EQ (face, faceSym) ++ || (CONSP (face) ++ && !NILP (Fmemq (faceSym, face)))) ++ { ++ ptrdiff_t ov_start = OVERLAY_START (ov); ++ ptrdiff_t ov_end = OVERLAY_END (ov); ++ if (ov_end > ov_start) ++ { ++ announceText = ns_ax_completion_text_for_span (self, b, ++ ov_start, ++ ov_end, ++ cachedText); ++ currentOverlayStart = ov_start; ++ currentOverlayEnd = ov_end; ++ } ++ break; ++ } ++ } ++ } ++ ++ /* 4) Fallback: nearest completions-highlight overlay. */ ++ if (!announceText) ++ { ++ ptrdiff_t ov_start = 0; ++ ptrdiff_t ov_end = 0; ++ if (ns_ax_find_completion_overlay_range (b, point, ++ &ov_start, &ov_end)) ++ { ++ announceText = ns_ax_completion_text_for_span (self, b, ++ ov_start, ov_end, ++ cachedText); ++ currentOverlayStart = ov_start; ++ currentOverlayEnd = ov_end; ++ } ++ } ++ ++ unbind_to (count2, Qnil); ++ ++ /* Final fallback: read current line at point. */ ++ if (!announceText) ++ { ++ NSUInteger point_idx = [self accessibilityIndexForCharpos:point]; ++ if (point_idx <= [cachedText length]) ++ { ++ NSInteger lineNum = [self accessibilityLineForIndex: ++ point_idx]; ++ NSRange lineRange = [self accessibilityRangeForLine:lineNum]; ++ if (lineRange.location != NSNotFound ++ && lineRange.length > 0 ++ && lineRange.location + lineRange.length ++ <= [cachedText length]) ++ announceText = [cachedText substringWithRange:lineRange]; ++ } ++ } ++ ++ /* Deduplicate: post only when text, overlay, or point changed. */ ++ if (announceText) ++ { ++ announceText = [announceText stringByTrimmingCharactersInSet: ++ [NSCharacterSet whitespaceAndNewlineCharacterSet]]; ++ if ([announceText length] > 0) ++ { ++ BOOL textChanged = ![announceText isEqualToString: ++ self.cachedCompletionAnnouncement]; ++ BOOL overlayChanged = ++ (currentOverlayStart != self.cachedCompletionOverlayStart ++ || currentOverlayEnd != self.cachedCompletionOverlayEnd); ++ BOOL pointChanged = (point != self.cachedCompletionPoint); ++ if (textChanged || overlayChanged || pointChanged) ++ { ++ NSDictionary *annInfo = @{ ++ NSAccessibilityAnnouncementKey: announceText, ++ NSAccessibilityPriorityKey: ++ @(NSAccessibilityPriorityHigh) ++ }; ++ ns_ax_post_notification_with_info ( ++ NSApp, ++ NSAccessibilityAnnouncementRequestedNotification, ++ annInfo); ++ } ++ self.cachedCompletionAnnouncement = announceText; ++ self.cachedCompletionOverlayStart = currentOverlayStart; ++ self.cachedCompletionOverlayEnd = currentOverlayEnd; ++ self.cachedCompletionPoint = point; ++ } ++ else ++ { ++ self.cachedCompletionAnnouncement = nil; ++ self.cachedCompletionOverlayStart = 0; ++ self.cachedCompletionOverlayEnd = 0; ++ self.cachedCompletionPoint = 0; ++ } ++ } ++ else ++ { ++ self.cachedCompletionAnnouncement = nil; ++ self.cachedCompletionOverlayStart = 0; ++ self.cachedCompletionOverlayEnd = 0; ++ self.cachedCompletionPoint = 0; ++ } ++} ++ ++/* ---- Notification dispatch (main entry point) ---- */ ++ ++/* Dispatch accessibility notifications after a redisplay cycle. ++ Detects three mutually exclusive events: text edit, cursor/mark ++ change, or no change. Delegates to helper methods above. */ +- (void)postAccessibilityNotificationsForFrame:(struct frame *)f +{ + NSTRACE ("[EmacsView postAccessibilityNotificationsForFrame:]"); @@ -2231,57 +2603,16 @@ index 932d209f..ea2de6f2 100644 + ptrdiff_t point = BUF_PT (b); + BOOL markActive = !NILP (BVAR (b, mark_active)); + -+ /* --- Text changed → typing echo --- -+ WebKit AXObjectCacheMac fallback enum: Edit = 1, Typing = 3. */ ++ /* --- Text changed (edit) --- */ + if (modiff != self.cachedModiff) + { -+ /* Capture changed char before invalidating cache. */ -+ NSString *changedChar = @""; -+ if (point > self.cachedPoint -+ && point - self.cachedPoint == 1) -+ { -+ /* Single char inserted — refresh cache and grab it. */ -+ [self invalidateTextCache]; -+ [self ensureTextCache]; -+ if (cachedText) -+ { -+ NSUInteger idx = [self accessibilityIndexForCharpos:point - 1]; -+ if (idx < [cachedText length]) -+ changedChar = [cachedText substringWithRange: -+ NSMakeRange (idx, 1)]; -+ } -+ } -+ else -+ { -+ [self invalidateTextCache]; -+ } -+ + self.cachedModiff = modiff; -+ /* Update cachedPoint here so the selection-move branch below -+ does NOT fire for point changes caused by edits. WebKit and -+ Chromium never send both ValueChanged and SelectedTextChanged -+ for the same user action — they are mutually exclusive. */ -+ self.cachedPoint = point; -+ -+ NSDictionary *change = @{ -+ @"AXTextEditType": @(ns_ax_text_edit_type_typing), -+ @"AXTextChangeValue": changedChar, -+ @"AXTextChangeValueLength": @([changedChar length]) -+ }; -+ NSDictionary *userInfo = @{ -+ @"AXTextStateChangeType": @(ns_ax_text_state_change_edit), -+ @"AXTextChangeValues": @[change], -+ @"AXTextChangeElement": self -+ }; -+ ns_ax_post_notification_with_info ( -+ self, NSAccessibilityValueChangedNotification, userInfo); ++ [self postTextChangedNotification:point]; + } + -+ /* --- Cursor moved or selection changed → line reading --- -+ WebKit AXObjectCacheMac fallback enum: SelectionMove = 2. ++ /* --- Cursor moved or selection changed --- + Use 'else if' — edits and selection moves are mutually exclusive -+ per the WebKit/Chromium pattern. VoiceOver gets confused if -+ both notifications arrive in the same runloop iteration. */ ++ per the WebKit/Chromium pattern. */ + else if (point != self.cachedPoint || markActive != self.cachedMarkActive) + { + ptrdiff_t oldPoint = self.cachedPoint; @@ -2299,21 +2630,14 @@ index 932d209f..ea2de6f2 100644 + int ctrlNP = 0; + bool isCtrlNP = ns_ax_event_is_line_nav_key (&ctrlNP); + -+ /* --- Granularity detection --- -+ Compare old and new cursor positions in cachedText to determine -+ what kind of move happened. Three levels: -+ - line: different line (lineRangeForRange) -+ - word: same line, distance > 1 UTF-16 unit -+ - character: same line, distance == 1 UTF-16 unit -+ C-n/C-p force line regardless of detected granularity. */ ++ /* --- Granularity detection --- */ + NSInteger granularity = ns_ax_text_selection_granularity_unknown; + [self ensureTextCache]; -+ NSUInteger oldIdx = 0, newIdx = 0; + if (cachedText && oldPoint > 0) + { + NSUInteger tlen = [cachedText length]; -+ oldIdx = [self accessibilityIndexForCharpos:oldPoint]; -+ newIdx = [self accessibilityIndexForCharpos:point]; ++ NSUInteger oldIdx = [self accessibilityIndexForCharpos:oldPoint]; ++ NSUInteger newIdx = [self accessibilityIndexForCharpos:point]; + if (oldIdx > tlen) oldIdx = tlen; + if (newIdx > tlen) newIdx = tlen; + @@ -2344,329 +2668,21 @@ index 932d209f..ea2de6f2 100644 + granularity = ns_ax_text_selection_granularity_line; + } + -+ /* --- NOTIFICATION STRATEGY --- -+ SelectedTextChanged ALWAYS posted for focused element: -+ - Interrupts VoiceOver auto-read (buffer switch reading) -+ - Provides word/line/selection reading via VoiceOver defaults -+ -+ For CHARACTER moves only: omit granularity from userInfo so -+ VoiceOver cannot derive speech from SelectedTextChanged, then -+ post AnnouncementRequested with char AT point. This avoids -+ double-speech while keeping the interrupt behaviour. -+ -+ For WORD and LINE moves: include granularity in userInfo — -+ VoiceOver reads the word/line correctly on its own. -+ -+ For SELECTION changes: include granularity — VoiceOver reads -+ selected/deselected text. -+ -+ Non-focused buffers: AnnouncementRequested only (see below). */ ++ /* Post notifications for focused and non-focused elements. */ + if ([self isAccessibilityFocused]) -+ { -+ BOOL isCharMove -+ = (!markActive && !oldMarkActive -+ && granularity -+ == ns_ax_text_selection_granularity_character); ++ [self postFocusedCursorNotification:point ++ direction:direction ++ granularity:granularity ++ markActive:markActive ++ oldMarkActive:oldMarkActive]; + -+ /* Always post SelectedTextChanged to interrupt VoiceOver reading -+ and update cursor tracking / braille displays. */ -+ NSMutableDictionary *moveInfo = [NSMutableDictionary dictionary]; -+ moveInfo[@"AXTextStateChangeType"] -+ = @(ns_ax_text_state_change_selection_move); -+ moveInfo[@"AXTextSelectionDirection"] = @(direction); -+ moveInfo[@"AXTextChangeElement"] = self; -+ /* Omit granularity for character moves so VoiceOver does not -+ derive its own speech (it would read the wrong character -+ for evil block-cursor mode). Include it for word/line/ -+ selection so VoiceOver reads the appropriate text. */ -+ if (!isCharMove) -+ moveInfo[@"AXTextSelectionGranularity"] = @(granularity); -+ -+ ns_ax_post_notification_with_info ( -+ self, -+ NSAccessibilitySelectedTextChangedNotification, -+ moveInfo); -+ -+ /* For character moves: explicit announcement of char AT point. -+ This is the ONLY speech source for character navigation. -+ Correct for evil block-cursor (cursor ON the character) -+ and harmless for insert-mode. */ -+ if (isCharMove && cachedText) -+ { -+ NSUInteger point_idx -+ = [self accessibilityIndexForCharpos:point]; -+ NSUInteger tlen = [cachedText length]; -+ if (point_idx < tlen) -+ { -+ NSRange charRange = [cachedText -+ rangeOfComposedCharacterSequenceAtIndex: point_idx]; -+ if (charRange.location != NSNotFound -+ && charRange.length > 0 -+ && NSMaxRange (charRange) <= tlen) -+ { -+ NSString *ch -+ = [cachedText substringWithRange: charRange]; -+ if (![ch isEqualToString: @"\n"]) -+ { -+ NSDictionary *annInfo = @{ -+ NSAccessibilityAnnouncementKey: ch, -+ NSAccessibilityPriorityKey: -+ @(NSAccessibilityPriorityHigh) -+ }; -+ ns_ax_post_notification_with_info ( -+ NSApp, -+ NSAccessibilityAnnouncementRequestedNotification, -+ annInfo); -+ } -+ } -+ } -+ } -+ -+ /* For focused line moves: always announce line text explicitly. -+ SelectedTextChanged with granularity=line works for arrow -+ keys, but C-n/C-p need the explicit announcement (VoiceOver -+ processes these keystrokes differently from arrows). -+ In completion-list-mode, read the completion candidate -+ instead of the whole line. */ -+ if (cachedText -+ && granularity == ns_ax_text_selection_granularity_line) -+ { -+ NSString *announceText = nil; -+ -+ /* 1. completion--string at point. */ -+ Lisp_Object cstr -+ = Fget_char_property (make_fixnum (point), -+ Qns_ax_completion__string, Qnil); -+ announceText = ns_ax_completion_string_from_prop (cstr); -+ -+ /* 2. Fallback: full line text. */ -+ if (!announceText) -+ { -+ NSUInteger point_idx -+ = [self accessibilityIndexForCharpos:point]; -+ if (point_idx <= [cachedText length]) -+ { -+ NSInteger lineNum -+ = [self accessibilityLineForIndex:point_idx]; -+ NSRange lineRange -+ = [self accessibilityRangeForLine:lineNum]; -+ if (lineRange.location != NSNotFound -+ && lineRange.length > 0 -+ && NSMaxRange (lineRange) <= [cachedText length]) -+ announceText -+ = [cachedText substringWithRange:lineRange]; -+ } -+ } -+ -+ if (announceText) -+ { -+ announceText = [announceText -+ stringByTrimmingCharactersInSet: -+ [NSCharacterSet whitespaceAndNewlineCharacterSet]]; -+ if ([announceText length] > 0) -+ { -+ NSDictionary *annInfo = @{ -+ NSAccessibilityAnnouncementKey: announceText, -+ NSAccessibilityPriorityKey: -+ @(NSAccessibilityPriorityHigh) -+ }; -+ ns_ax_post_notification_with_info ( -+ NSApp, -+ NSAccessibilityAnnouncementRequestedNotification, -+ annInfo); -+ } -+ } -+ } -+ } -+ -+ /* --- Completions announcement --- -+ When point changes in a non-focused buffer (e.g. *Completions* -+ while the minibuffer has keyboard focus), VoiceOver won't read -+ the change because it's tracking the focused element. Post an -+ announcement so the user hears the selected completion. -+ -+ If there is a `completions-highlight` overlay at point (Emacs -+ highlights the selected completion candidate), read its full -+ text instead of just the current line. */ + if (![self isAccessibilityFocused] && cachedText) -+ { -+ NSString *announceText = nil; -+ ptrdiff_t currentOverlayStart = 0; -+ ptrdiff_t currentOverlayEnd = 0; -+ -+ specpdl_ref count2 = SPECPDL_INDEX (); -+ record_unwind_current_buffer (); -+ if (b != current_buffer) -+ set_buffer_internal_1 (b); -+ -+ /* 1) Prefer explicit completion candidate property when present. -+ completion--string can be a plain string (simple completion) -+ or a list ("candidate" "annotation") for annotated completions. -+ In the list case, use car (the completion itself). */ -+ Lisp_Object cstr = Fget_char_property (make_fixnum (point), -+ Qns_ax_completion__string, -+ Qnil); -+ announceText = ns_ax_completion_string_from_prop (cstr); -+ -+ /* 2) Fallback: announce the mouse-face span at point. -+ completion-list-mode often marks the active candidate this way. */ -+ if (!announceText) -+ { -+ Lisp_Object mf = Fget_char_property (make_fixnum (point), -+ Qmouse_face, Qnil); -+ if (!NILP (mf)) -+ { -+ ptrdiff_t begv2 = BUF_BEGV (b); -+ ptrdiff_t zv2 = BUF_ZV (b); -+ -+ /* Find mouse-face span boundaries using property -+ change functions — O(log n) instead of O(n). */ -+ Lisp_Object prev_change -+ = Fprevious_single_char_property_change ( -+ make_fixnum (point + 1), Qmouse_face, -+ Qnil, make_fixnum (begv2)); -+ ptrdiff_t s2 -+ = FIXNUMP (prev_change) ? XFIXNUM (prev_change) -+ : begv2; -+ -+ Lisp_Object next_change -+ = Fnext_single_char_property_change ( -+ make_fixnum (point), Qmouse_face, -+ Qnil, make_fixnum (zv2)); -+ ptrdiff_t e2 -+ = FIXNUMP (next_change) ? XFIXNUM (next_change) -+ : zv2; -+ -+ if (e2 > s2) -+ { -+ NSUInteger ax_s = [self accessibilityIndexForCharpos:s2]; -+ NSUInteger ax_e = [self accessibilityIndexForCharpos:e2]; -+ if (ax_e > ax_s && ax_e <= [cachedText length]) -+ announceText = [cachedText substringWithRange: -+ NSMakeRange (ax_s, ax_e - ax_s)]; -+ } -+ } -+ } -+ -+ /* 3) Fallback: check completions-highlight overlay span at point. */ -+ if (!announceText) -+ { -+ Lisp_Object faceSym = Qns_ax_completions_highlight; -+ Lisp_Object overlays = Foverlays_at (make_fixnum (point), Qnil); -+ Lisp_Object tail; -+ for (tail = overlays; CONSP (tail); tail = XCDR (tail)) -+ { -+ Lisp_Object ov = XCAR (tail); -+ Lisp_Object face = Foverlay_get (ov, Qface); -+ if (EQ (face, faceSym) -+ || (CONSP (face) -+ && !NILP (Fmemq (faceSym, face)))) -+ { -+ ptrdiff_t ov_start = OVERLAY_START (ov); -+ ptrdiff_t ov_end = OVERLAY_END (ov); -+ if (ov_end > ov_start) -+ { -+ announceText = ns_ax_completion_text_for_span (self, b, -+ ov_start, -+ ov_end, -+ cachedText); -+ currentOverlayStart = ov_start; -+ currentOverlayEnd = ov_end; -+ } -+ break; -+ } -+ } -+ } -+ -+ /* 4) Fallback: select the best completions-highlight overlay. -+ Prefer overlay nearest to point over first-found in buffer. */ -+ if (!announceText) -+ { -+ ptrdiff_t ov_start = 0; -+ ptrdiff_t ov_end = 0; -+ if (ns_ax_find_completion_overlay_range (b, point, &ov_start, &ov_end)) -+ { -+ announceText = ns_ax_completion_text_for_span (self, b, -+ ov_start, -+ ov_end, -+ cachedText); -+ currentOverlayStart = ov_start; -+ currentOverlayEnd = ov_end; -+ } -+ } -+ -+ unbind_to (count2, Qnil); -+ -+ /* Final fallback: read the current line at point. */ -+ if (!announceText) -+ { -+ NSUInteger point_idx = [self accessibilityIndexForCharpos:point]; -+ if (point_idx <= [cachedText length]) -+ { -+ NSInteger lineNum = [self accessibilityLineForIndex: -+ point_idx]; -+ NSRange lineRange = [self accessibilityRangeForLine:lineNum]; -+ if (lineRange.location != NSNotFound -+ && lineRange.length > 0 -+ && lineRange.location + lineRange.length -+ <= [cachedText length]) -+ announceText = [cachedText substringWithRange:lineRange]; -+ } -+ } -+ -+ if (announceText) -+ { -+ announceText = [announceText stringByTrimmingCharactersInSet: -+ [NSCharacterSet whitespaceAndNewlineCharacterSet]]; -+ if ([announceText length] > 0) -+ { -+ BOOL textChanged = ![announceText isEqualToString: -+ self.cachedCompletionAnnouncement]; -+ BOOL overlayChanged = -+ (currentOverlayStart != self.cachedCompletionOverlayStart -+ || currentOverlayEnd != self.cachedCompletionOverlayEnd); -+ BOOL pointChanged = (point != self.cachedCompletionPoint); -+ if (textChanged || overlayChanged || pointChanged) -+ { -+ NSDictionary *annInfo = @{ -+ NSAccessibilityAnnouncementKey: announceText, -+ NSAccessibilityPriorityKey: -+ @(NSAccessibilityPriorityHigh) -+ }; -+ ns_ax_post_notification_with_info ( -+ NSApp, -+ NSAccessibilityAnnouncementRequestedNotification, -+ annInfo); -+ } -+ self.cachedCompletionAnnouncement = announceText; -+ self.cachedCompletionOverlayStart = currentOverlayStart; -+ self.cachedCompletionOverlayEnd = currentOverlayEnd; -+ self.cachedCompletionPoint = point; -+ } -+ else -+ { -+ self.cachedCompletionAnnouncement = nil; -+ self.cachedCompletionOverlayStart = 0; -+ self.cachedCompletionOverlayEnd = 0; -+ self.cachedCompletionPoint = 0; -+ } -+ } -+ else -+ { -+ self.cachedCompletionAnnouncement = nil; -+ self.cachedCompletionOverlayStart = 0; -+ self.cachedCompletionOverlayEnd = 0; -+ self.cachedCompletionPoint = 0; -+ } -+ } -+ ++ [self postCompletionAnnouncementForBuffer:b point:point]; + } + else + { -+ /* Nothing changed (no text edit, no cursor move, no mark change). -+ Overlay state cannot change without a modiff bump, so no scan -+ needed for non-focused buffers. Just reset completion cache -+ for focused buffer to avoid stale announcements. */ ++ /* Nothing changed. Reset completion cache for focused buffer ++ to avoid stale announcements. */ + if ([self isAccessibilityFocused]) + { + self.cachedCompletionAnnouncement = nil; @@ -2984,7 +3000,7 @@ index 932d209f..ea2de6f2 100644 int code; unsigned fnKeysym = 0; static NSMutableArray *nsEvArray; -@@ -8237,6 +10559,31 @@ - (void)windowDidBecomeKey /* for direct calls */ +@@ -8237,6 +10584,32 @@ - (void)windowDidBecomeKey /* for direct calls */ XSETFRAME (event.frame_or_window, emacsframe); kbd_buffer_store_event (&event); ns_send_appdefined (-1); // Kick main loop @@ -2993,6 +3009,7 @@ index 932d209f..ea2de6f2 100644 + /* 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 @@ -3016,7 +3033,7 @@ index 932d209f..ea2de6f2 100644 } -@@ -9474,6 +11821,332 @@ - (int) fullscreenState +@@ -9474,6 +11847,332 @@ - (int) fullscreenState return fs_state; } @@ -3171,7 +3188,7 @@ index 932d209f..ea2de6f2 100644 + NSTRACE ("[EmacsView postAccessibilityUpdates]"); + eassert ([NSThread isMainThread]); + -+ if (!emacsframe) ++ if (!emacsframe || !ns_accessibility_enabled) + return; + + /* Re-entrance guard: VoiceOver callbacks during notification posting @@ -3349,7 +3366,7 @@ index 932d209f..ea2de6f2 100644 @end /* EmacsView */ -@@ -11303,7 +13976,29 @@ Convert an X font name (XLFD) to an NS font name. +@@ -11303,6 +14002,28 @@ Convert an X font name (XLFD) to an NS font name. DEFSYM (Qns_drag_operation_generic, "ns-drag-operation-generic"); DEFSYM (Qns_handle_drag_motion, "ns-handle-drag-motion"); @@ -3375,10 +3392,25 @@ index 932d209f..ea2de6f2 100644 + DEFSYM (Qns_ax_completions_highlight, "completions-highlight"); + DEFSYM (Qns_ax_backtab, "backtab"); + /* Qmouse_face and Qkeymap are defined in textprop.c / keymap.c. */ -+ Fput (Qalt, Qmodifier_value, make_fixnum (alt_modifier)); Fput (Qhyper, Qmodifier_value, make_fixnum (hyper_modifier)); Fput (Qmeta, Qmodifier_value, make_fixnum (meta_modifier)); +@@ -11451,6 +14172,15 @@ Nil means use fullscreen the old (< 10.7) way. The old way works better with + This variable is ignored on Mac OS X < 10.7 and GNUstep. */); + 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 t. */); ++ ns_accessibility_enabled = YES; ++ + DEFVAR_BOOL ("ns-use-mwheel-acceleration", + ns_use_mwheel_acceleration, + doc: /* Non-nil means use macOS's standard mouse wheel acceleration. -- 2.43.0 diff --git a/patches/0002-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch b/patches/0002-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch index 66d6003..2737ca6 100644 --- a/patches/0002-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch +++ b/patches/0002-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch @@ -1,24 +1,24 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From ce3b2a8091c99f738ec59acd6f6ebf0d84826e34 Mon Sep 17 00:00:00 2001 From: Martin Sukany -Date: Fri, 27 Feb 2026 17:30:00 +0100 +Date: Fri, 27 Feb 2026 17:49:51 +0100 Subject: [PATCH 2/2] doc: add VoiceOver accessibility section to macOS appendix Document the new VoiceOver accessibility support in the Emacs manual. Add a new section to the macOS appendix covering screen reader usage, -keyboard navigation feedback, completion announcements, and Zoom -cursor tracking. +keyboard navigation feedback, completion announcements, Zoom cursor +tracking, and the ns-accessibility-enabled user option. * doc/emacs/macos.texi (VoiceOver Accessibility): New section. --- - doc/emacs/macos.texi | 46 +++++++++++++++++++++++++++++++++++++++++++ - 1 file changed, 46 insertions(+) + doc/emacs/macos.texi | 53 ++++++++++++++++++++++++++++++++++++++++++++ + 1 file changed, 53 insertions(+) diff --git a/doc/emacs/macos.texi b/doc/emacs/macos.texi -index 1234567..abcdefg 100644 +index 6bd334f..1d969f9 100644 --- a/doc/emacs/macos.texi +++ b/doc/emacs/macos.texi -@@ -31,6 +31,7 @@ Support}), but we hope to improve it in the future. +@@ -36,6 +36,7 @@ Support}), but we hope to improve it in the future. * Mac / GNUstep Basics:: Basic Emacs usage under GNUstep or macOS. * Mac / GNUstep Customization:: Customizations under GNUstep or macOS. * Mac / GNUstep Events:: How window system events are handled. @@ -26,10 +26,10 @@ index 1234567..abcdefg 100644 * GNUstep Support:: Details on status of GNUstep support. @end menu -@@ -272,6 +273,51 @@ services and receive the results back. Note that you may need to +@@ -272,6 +273,58 @@ 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. - +@node VoiceOver Accessibility +@section VoiceOver Accessibility (macOS) +@cindex VoiceOver @@ -70,6 +70,12 @@ index 1234567..abcdefg 100644 +position is communicated via @code{UAZoomChangeFocus} and the +@code{AXBoundsForRange} accessibility attribute. + ++@vindex ns-accessibility-enabled ++ To disable the accessibility interface entirely (for instance, to ++eliminate overhead on systems where assistive technology is not in ++use), set @code{ns-accessibility-enabled} to @code{nil}. The default ++is @code{t}. ++ + This support is available only on the Cocoa build; GNUstep has a +different accessibility model and is not yet supported +(@pxref{GNUstep Support}). Evil-mode block cursors are handled @@ -81,3 +87,4 @@ index 1234567..abcdefg 100644 -- 2.43.0 + diff --git a/patches/README.txt b/patches/README.txt index ec1ec01..efc9fbd 100644 --- a/patches/README.txt +++ b/patches/README.txt @@ -3,10 +3,10 @@ EMACS NS VOICEOVER ACCESSIBILITY PATCH patch: 0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch 0002-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch author: Martin Sukany -files: src/nsterm.h (+105 lines) - src/nsterm.m (+2846 ins, -151 del, +2695 net) - doc/emacs/macos.texi (+46 lines) - etc/NEWS (+11 lines) +files: src/nsterm.h (+119 lines) + src/nsterm.m (+3024 ins, -147 del, +2877 net) + doc/emacs/macos.texi (+53 lines) + etc/NEWS (+13 lines) OVERVIEW @@ -114,6 +114,18 @@ ARCHITECTURE accessibilityAttributeValue:forParameter: API. +USER OPTION +----------- + + ns-accessibility-enabled (DEFVAR_BOOL, default t): + When nil, the accessibility virtual element tree is not built, no + notifications are posted, and ns_draw_phys_cursor skips the Zoom + update. This eliminates accessibility overhead entirely on systems + where assistive technology is not in use. Guarded at three entry + points: postAccessibilityUpdates, ns_draw_phys_cursor, and + windowDidBecomeKey. + + THREADING MODEL --------------- @@ -615,4 +627,38 @@ TESTING CHECKLIST 26. Open an org-mode file with many folded sections. Verify that folded (invisible) text is not announced during navigation. + +REVIEW CHANGES (post initial implementation) +--------------------------------------------- + + The following changes were made based on maintainer-style code review: + + 1. ns_ax_window_end_charpos: added window_end_valid guard. Falls + back to BUF_ZV when the window has not been fully redisplayed, + preventing stale data in AX getters called before next redisplay. + + 2. GC safety documentation: detailed comment on lispWindow ivar + explaining why staticpro is not needed (windows reachable from + frame tree, GC only on main thread, AX getters dispatch to main). + + 3. ns-accessibility-enabled (DEFVAR_BOOL): new user option to + disable accessibility entirely. Guards three entry points. + + 4. postAccessibilityNotificationsForFrame: extracted from one ~200 + line method into four focused helpers: + - postTextChangedNotification: (typing echo) + - postFocusedCursorNotification:direction:granularity:markActive: + oldMarkActive: (focused cursor/selection) + - postCompletionAnnouncementForBuffer:point: (completions) + - postAccessibilityNotificationsForFrame: (orchestrator, ~60 lines) + + 5. ns_ax_completion_text_for_span: added block_input/unblock_input + with specpdl unwind protection for signal safety. + + 6. Fplist_get third-argument comment (PREDICATE, not default value). + + 7. Documentation: macos.texi section updated with + ns-accessibility-enabled variable reference. etc/NEWS updated. + + -- end of README --