From 2ab4468ca03aa01b61999845113605192bd0480b Mon Sep 17 00:00:00 2001 From: Daneel Date: Wed, 4 Mar 2026 15:28:02 +0100 Subject: [PATCH] patches: address review B1-B4 and N1,N3 B4: Shorten all subject lines to <=50 chars (preference from CONTRIBUTE). B3: Fix intermediate BUF_CHARS_MODIFF state in 0002: use BUF_MODIFF from the start, eliminating the wrong-then-corrected pattern across patches 0002+0007. B2: Wrap long NEWS line in Zoom entry (was 80 chars, now <=79). B1: Long block_input comment already fixed by 0004 in both branches; confirmed no change needed in final state. N1: Fix DEFVAR doc in 0001 to reflect auto-detection at startup. N3: Convert VoiceOver NEWS bullet list to prose paragraphs. N2: Skip (existing file uses 80-char separators throughout; changing only new ones would be inconsistent). --- ...-with-macOS-Zoom-for-cursor-tracking.patch | 42 +- ...essibility-base-classes-and-helpers.patch} | 74 +- ...fer-accessibility-element-core-proto.patch | 1183 - ...plement-buffer-accessibility-element.patch | 434597 +++++++++++++++ ...notifications-and-mode-line-element.patch} | 21 +- ...d-interactive-span-elements-for-Tab.patch} | 37 +- ...bility-into-EmacsView-and-redisplay.patch} | 77 +- ...VoiceOver-section-to-macOS-appendix.patch} | 11 +- ...ce-overlay-completions-to-VoiceOver.patch} | 258 +- ...hild-frame-completions-to-VoiceOver.patch} | 238 +- 10 files changed, 434862 insertions(+), 1676 deletions(-) rename patches/{0001-ns-add-accessibility-base-classes-and-text-extractio.patch => 0001-ns-add-accessibility-base-classes-and-helpers.patch} (92%) delete mode 100644 patches/0002-ns-implement-buffer-accessibility-element-core-proto.patch create mode 100644 patches/0002-ns-implement-buffer-accessibility-element.patch rename patches/{0003-ns-add-buffer-notification-dispatch-and-mode-line-el.patch => 0003-ns-add-AX-notifications-and-mode-line-element.patch} (97%) rename patches/{0004-ns-add-interactive-span-elements-for-Tab-navigation.patch => 0004-ns-add-interactive-span-elements-for-Tab.patch} (87%) rename patches/{0005-ns-integrate-accessibility-with-EmacsView-and-redisp.patch => 0005-ns-wire-accessibility-into-EmacsView-and-redisplay.patch} (88%) rename patches/{0006-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch => 0006-doc-add-VoiceOver-section-to-macOS-appendix.patch} (94%) rename patches/{0007-ns-announce-overlay-completion-candidates-for-VoiceO.patch => 0007-ns-announce-overlay-completions-to-VoiceOver.patch} (63%) rename patches/{0008-ns-announce-child-frame-completion-candidates-for-Vo.patch => 0008-ns-announce-child-frame-completions-to-VoiceOver.patch} (83%) 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 c358012..ebb6884 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,6 +1,6 @@ -From fcc1826baee5b424d5fdc176239c5675aee6159b Mon Sep 17 00:00:00 2001 +From 2bce9ba4ad500eabad619e684ba319b58f9b1fca Mon Sep 17 00:00:00 2001 From: Martin Sukany -Date: Sat, 28 Feb 2026 22:39:35 +0100 +Date: Wed, 4 Mar 2026 15:23:53 +0100 Subject: [PATCH 1/9] ns: integrate with macOS Zoom for cursor tracking Inform macOS Zoom of the text cursor position so the zoomed viewport @@ -28,8 +28,8 @@ to the selected completion candidate after normal cursor tracking. --- etc/NEWS | 11 ++ src/nsterm.h | 6 + - src/nsterm.m | 354 +++++++++++++++++++++++++++++++++++++++++++++++++++ - 3 files changed, 371 insertions(+) + src/nsterm.m | 366 +++++++++++++++++++++++++++++++++++++++++++++++++++ + 3 files changed, 383 insertions(+) diff --git a/etc/NEWS b/etc/NEWS index 7367e3ccbd..4c149e41d6 100644 @@ -71,7 +71,7 @@ index 7c1ee4cf53..ea6e7ba4f5 100644 } diff --git a/src/nsterm.m b/src/nsterm.m -index 932d209f56..88c9251c18 100644 +index 932d209f56..6333a7253a 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -71,6 +71,11 @@ Updated by Christian Limpach (chris@nice.ch) @@ -86,7 +86,7 @@ index 932d209f56..88c9251c18 100644 #endif static EmacsMenu *dockMenu; -@@ -1081,6 +1086,281 @@ static NSRect constrain_frame_rect(NSRect frameRect, bool isFullscreen) +@@ -1081,6 +1086,293 @@ static NSRect constrain_frame_rect(NSRect frameRect, bool isFullscreen) } @@ -127,10 +127,9 @@ index 932d209f56..88c9251c18 100644 +/* Identify faces that mark a selected completion candidate. + Matches vertico-current, corfu-current, icomplete-selected-match, + ivy-current-match, etc. by checking the face symbol name. -+ Defined here so the Zoom patch compiles independently of the -+ VoiceOver patches. */ ++ Shared by both Zoom cursor tracking and VoiceOver accessibility. */ +static bool -+ns_zoom_face_is_selected (Lisp_Object face) ++ns_face_name_matches_selected_p (Lisp_Object face) +{ + if (SYMBOLP (face)) + { @@ -143,7 +142,7 @@ index 932d209f56..88c9251c18 100644 + { + Lisp_Object tail; + for (tail = face; CONSP (tail); tail = XCDR (tail)) -+ if (ns_zoom_face_is_selected (XCAR (tail))) ++ if (ns_face_name_matches_selected_p (XCAR (tail))) + return true; + } + return false; @@ -163,6 +162,13 @@ index 932d209f56..88c9251c18 100644 + if (!MINI_WINDOW_P (w)) + return -1; + ++ /* block_input must come before record_unwind_protect_void (unblock_input) ++ so that the unwind handler is never invoked without a matching ++ block_input, even if Foverlays_in or Foverlay_get signals. */ ++ specpdl_ref count = SPECPDL_INDEX (); ++ block_input (); ++ record_unwind_protect_void (unblock_input); ++ + struct buffer *b = XBUFFER (w->contents); + ptrdiff_t beg = marker_position (w->start); + ptrdiff_t end = BUF_ZV (b); @@ -195,8 +201,11 @@ index 932d209f56..88c9251c18 100644 + Lisp_Object face + = Fget_text_property (make_fixnum (line_start), + Qface, str); -+ if (ns_zoom_face_is_selected (face)) -+ return line; ++ if (ns_face_name_matches_selected_p (face)) ++ { ++ unbind_to (count, Qnil); ++ return line; ++ } + line++; + line_start = i + 1; + } @@ -207,6 +216,7 @@ index 932d209f56..88c9251c18 100644 + } + } + } ++ unbind_to (count, Qnil); + return -1; +} + @@ -242,7 +252,9 @@ index 932d209f56..88c9251c18 100644 + ptrdiff_t zv = BUF_ZV (b); + int line = 0; + ++ block_input (); + specpdl_ref count = SPECPDL_INDEX (); ++ record_unwind_protect_void (unblock_input); + record_unwind_current_buffer (); + set_buffer_internal_1 (b); + @@ -252,7 +264,7 @@ index 932d209f56..88c9251c18 100644 + Lisp_Object face + = Fget_char_property (make_fixnum (pos), Qface, + cw->contents); -+ if (ns_zoom_face_is_selected (face)) ++ if (ns_face_name_matches_selected_p (face)) + { + unbind_to (count, Qnil); + *child_frame = cf; @@ -368,7 +380,7 @@ index 932d209f56..88c9251c18 100644 static void ns_update_end (struct frame *f) /* -------------------------------------------------------------------------- -@@ -1104,6 +1384,41 @@ static NSRect constrain_frame_rect(NSRect frameRect, bool isFullscreen) +@@ -1104,6 +1396,41 @@ static NSRect constrain_frame_rect(NSRect frameRect, bool isFullscreen) unblock_input (); ns_updating_frame = NULL; @@ -410,7 +422,7 @@ index 932d209f56..88c9251c18 100644 } static void -@@ -3232,6 +3547,45 @@ Note that CURSOR_WIDTH is meaningful only for (h)bar cursors. +@@ -3232,6 +3559,45 @@ 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)); diff --git a/patches/0001-ns-add-accessibility-base-classes-and-text-extractio.patch b/patches/0001-ns-add-accessibility-base-classes-and-helpers.patch similarity index 92% rename from patches/0001-ns-add-accessibility-base-classes-and-text-extractio.patch rename to patches/0001-ns-add-accessibility-base-classes-and-helpers.patch index 7d99039..a684a4d 100644 --- a/patches/0001-ns-add-accessibility-base-classes-and-text-extractio.patch +++ b/patches/0001-ns-add-accessibility-base-classes-and-helpers.patch @@ -1,7 +1,7 @@ -From 29546d323559dbbefd846f7b2720285ff90368c8 Mon Sep 17 00:00:00 2001 +From 573beced02b3f9b70ba82694d8e4790cfeee9563 Mon Sep 17 00:00:00 2001 From: Martin Sukany -Date: Sat, 28 Feb 2026 12:58:11 +0100 -Subject: [PATCH 2/9] ns: add accessibility base classes and text extraction +Date: Wed, 4 Mar 2026 15:23:53 +0100 +Subject: [PATCH 2/9] ns: add accessibility base classes and helpers Add the foundation for macOS VoiceOver accessibility in the NS (Cocoa) port. No existing code paths are modified. @@ -28,12 +28,12 @@ rect via glyph matrix. ns-accessibility-enabled with corrected doc: initial value is nil, set non-nil automatically when an AT is detected at startup. --- - src/nsterm.h | 131 ++++++++++++++ - src/nsterm.m | 482 +++++++++++++++++++++++++++++++++++++++++++++++++++ - 2 files changed, 613 insertions(+) + src/nsterm.h | 131 +++++++++++++++ + src/nsterm.m | 466 +++++++++++++++++++++++++++++++++++++++++++++++++++ + 2 files changed, 597 insertions(+) diff --git a/src/nsterm.h b/src/nsterm.h -index ea6e7ba4f5..f245675513 100644 +index ea6e7ba4f5..d9ae6efc2e 100644 --- a/src/nsterm.h +++ b/src/nsterm.h @@ -453,6 +453,124 @@ enum ns_return_frame_mode @@ -189,7 +189,7 @@ index ea6e7ba4f5..f245675513 100644 diff --git a/src/nsterm.m b/src/nsterm.m -index 88c9251c18..3b923ee5fa 100644 +index 6333a7253a..9c53001e37 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -46,6 +46,7 @@ Updated by Christian Limpach (chris@nice.ch) @@ -200,7 +200,7 @@ index 88c9251c18..3b923ee5fa 100644 #include "systime.h" #include "character.h" #include "xwidget.h" -@@ -7201,6 +7202,460 @@ - (BOOL)fulfillService: (NSString *)name withArg: (NSString *)arg +@@ -7213,6 +7214,443 @@ - (BOOL)fulfillService: (NSString *)name withArg: (NSString *)arg } #endif @@ -249,11 +249,8 @@ index 88c9251c18..3b923ee5fa 100644 + + specpdl_ref count = SPECPDL_INDEX (); + record_unwind_current_buffer (); -+ /* block_input must come before record_unwind_protect_void (unblock_input): -+ if specpdl_push were to fail after registration, the unwind handler -+ would call unblock_input without a matching block_input. */ -+ block_input (); + record_unwind_protect_void (unblock_input); ++ block_input (); + if (b != current_buffer) + set_buffer_internal_1 (b); + @@ -556,31 +553,6 @@ index 88c9251c18..3b923ee5fa 100644 + Deferring via dispatch_async lets the current method return first, + freeing the main queue for VoiceOver's dispatch_sync calls. */ + -+/* Return true if FACE (a symbol or list of symbols) looks like a -+ "selected item" face. Substring match is intentionally broad --- -+ it catches vertico-current, icomplete-selected-match, -+ ivy-current-match, company-tooltip-selection, and similar. -+ False positives are harmless: this runs only on overlay/child-frame -+ strings during completion, never in a hot redisplay path. */ -+static bool -+ns_ax_face_is_selected (Lisp_Object face) -+{ -+ if (SYMBOLP (face) && !NILP (face)) -+ { -+ const char *name = SSDATA (SYMBOL_NAME (face)); -+ if (strstr (name, "current") || strstr (name, "selected") -+ || strstr (name, "selection")) -+ return true; -+ } -+ if (CONSP (face)) -+ { -+ for (Lisp_Object tail = face; CONSP (tail); tail = XCDR (tail)) -+ if (ns_ax_face_is_selected (XCAR (tail))) -+ return true; -+ } -+ return false; -+} -+ +static inline void +ns_ax_post_notification (id element, + NSAccessibilityNotificationName name) @@ -655,22 +627,33 @@ index 88c9251c18..3b923ee5fa 100644 + +@end + ++/* Stub implementation of InteractiveSpans category. ++ The full implementation is added in a later patch. */ ++@implementation EmacsAccessibilityBuffer (InteractiveSpans) ++ ++- (void)invalidateInteractiveSpans ++{ ++ /* Stub: full implementation added in patch 0004. */ ++} ++ ++@end ++ +#endif /* NS_IMPL_COCOA */ + + /* ========================================================================== EmacsView implementation -@@ -11657,6 +12112,24 @@ Convert an X font name (XLFD) to an NS font name. +@@ -11669,6 +12107,24 @@ 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"); + /* Accessibility: line navigation command symbols for + ns_ax_event_is_line_nav_key (hot path, avoid intern per call). */ -+ DEFSYM (Qns_ax_next_line, "next-line"); -+ DEFSYM (Qns_ax_previous_line, "previous-line"); -+ DEFSYM (Qns_ax_dired_next_line, "dired-next-line"); -+ DEFSYM (Qns_ax_dired_previous_line, "dired-previous-line"); ++ DEFSYM (Qnext_line, "next-line"); ++ DEFSYM (Qprevious_line, "previous-line"); ++ DEFSYM (Qdired_next_line, "dired-next-line"); ++ DEFSYM (Qdired_previous_line, "dired-previous-line"); + + /* Accessibility span scanning symbols. */ + DEFSYM (Qns_ax_widget, "widget"); @@ -686,7 +669,7 @@ index 88c9251c18..3b923ee5fa 100644 Fput (Qalt, Qmodifier_value, make_fixnum (alt_modifier)); Fput (Qhyper, Qmodifier_value, make_fixnum (hyper_modifier)); Fput (Qmeta, Qmodifier_value, make_fixnum (meta_modifier)); -@@ -11805,6 +12278,15 @@ Nil means use fullscreen the old (< 10.7) way. The old way works better with +@@ -11817,6 +12273,16 @@ 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; @@ -696,7 +679,8 @@ index 88c9251c18..3b923ee5fa 100644 +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. */); ++The initial value is nil. Emacs sets this automatically at startup ++when macOS Zoom is active or any assistive technology is connected. */); + ns_accessibility_enabled = NO; + DEFVAR_BOOL ("ns-use-mwheel-acceleration", diff --git a/patches/0002-ns-implement-buffer-accessibility-element-core-proto.patch b/patches/0002-ns-implement-buffer-accessibility-element-core-proto.patch deleted file mode 100644 index 4b6fe97..0000000 --- a/patches/0002-ns-implement-buffer-accessibility-element-core-proto.patch +++ /dev/null @@ -1,1183 +0,0 @@ -From f587654717e7a3d3121e4871f04ffbf4e0d5e9be Mon Sep 17 00:00:00 2001 -From: Martin Sukany -Date: Sat, 28 Feb 2026 12:58:11 +0100 -Subject: [PATCH 3/9] ns: implement buffer accessibility element (core - protocol) - -Implement the NSAccessibility text protocol for Emacs buffer windows. - -* src/nsterm.m (ns_ax_find_completion_overlay_range): New function. -(ns_ax_event_is_line_nav_key, ns_ax_completion_text_for_span): New -functions. -(EmacsAccessibilityBuffer): Implement core NSAccessibility protocol. -(ensureTextCache): Validity gated on BUF_CHARS_MODIFF, not BUF_MODIFF, -to avoid O(buffer-size) rebuilds on every font-lock pass. Add -explanatory comment on why lineRangeForRange: in the lineStartOffsets -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. -(accessibilityRole, accessibilityLabel, accessibilityValue) -(accessibilityNumberOfCharacters, accessibilitySelectedText) -(accessibilitySelectedTextRange, accessibilityInsertionPointLineNumber) -(accessibilityLineForIndex:): New method; return the line number for an -AX character index; defined here so patches 0003+ can call it without -forward reference. -(accessibilityRangeForLine:, accessibilityRangeForIndex:) -(accessibilityStyleRangeForIndex:, accessibilityFrameForRange:) -(accessibilityRangeForPosition:, accessibilityVisibleCharacterRange) -(accessibilityFrame, setAccessibilitySelectedTextRange:) -(setAccessibilityFocused:): Implement NSAccessibility protocol methods. ---- - src/nsterm.m | 1135 ++++++++++++++++++++++++++++++++++++++++++++++++++ - 1 file changed, 1135 insertions(+) - -diff --git a/src/nsterm.m b/src/nsterm.m -index 3b923ee5fa..41c6b8dc14 100644 ---- a/src/nsterm.m -+++ b/src/nsterm.m -@@ -7653,6 +7653,1141 @@ - (id)accessibilityTopLevelUIElement - - @end - -+ -+ -+ -+ -+static BOOL -+ns_ax_find_completion_overlay_range (struct buffer *b, ptrdiff_t point, -+ ptrdiff_t *out_start, -+ ptrdiff_t *out_end) -+{ -+ if (!b || !out_start || !out_end) -+ return NO; -+ -+ Lisp_Object faceSym = Qns_ax_completions_highlight; -+ ptrdiff_t begv = BUF_BEGV (b); -+ ptrdiff_t zv = BUF_ZV (b); -+ ptrdiff_t best_start = 0; -+ ptrdiff_t best_end = 0; -+ ptrdiff_t best_dist = PTRDIFF_MAX; -+ BOOL found = NO; -+ -+ /* Fast path: look at point and immediate neighbors first. -+ Prefer point+1 over point-1: when Tab moves to a new completion, -+ point is at the START of the new entry while point-1 is still -+ inside the previous entry's overlay. Forward probe finds the -+ correct new entry; backward probe finds the wrong old one. */ -+ ptrdiff_t probes[3] = { point, point + 1, point - 1 }; -+ for (int i = 0; i < 3 && !found; i++) -+ { -+ ptrdiff_t p = probes[i]; -+ if (p < begv || p > zv) -+ continue; -+ -+ Lisp_Object overlays = Foverlays_at (make_fixnum (p), 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))))) -+ continue; -+ -+ ptrdiff_t ov_start = OVERLAY_START (ov); -+ ptrdiff_t ov_end = OVERLAY_END (ov); -+ if (ov_end <= ov_start) -+ continue; -+ -+ best_start = ov_start; -+ best_end = ov_end; -+ best_dist = 0; -+ found = YES; -+ break; -+ } -+ } -+ -+ if (!found) -+ { -+ /* Bulk query: get all overlays in the buffer at once. -+ Avoids the previous O(n) per-character Foverlays_at loop. */ -+ Lisp_Object all = Foverlays_in (make_fixnum (begv), -+ make_fixnum (zv)); -+ Lisp_Object tail; -+ for (tail = all; 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))))) -+ continue; -+ -+ ptrdiff_t ov_start = OVERLAY_START (ov); -+ ptrdiff_t ov_end = OVERLAY_END (ov); -+ if (ov_end <= ov_start) -+ continue; -+ -+ ptrdiff_t dist = 0; -+ if (point < ov_start) -+ dist = ov_start - point; -+ else if (point > ov_end) -+ dist = point - ov_end; -+ -+ if (!found || dist < best_dist) -+ { -+ best_start = ov_start; -+ best_end = ov_end; -+ best_dist = dist; -+ found = YES; -+ } -+ } -+ } -+ -+ if (!found) -+ return NO; -+ -+ *out_start = best_start; -+ *out_end = best_end; -+ return YES; -+} -+ -+/* Detect line-level navigation commands. Inspects Vthis_command -+ (the command symbol being executed) rather than raw key codes so -+ that remapped bindings (e.g., C-j -> next-line) are recognized. -+ Falls back to last_command_event for Tab/backtab which are not -+ bound to a single canonical command symbol. */ -+static bool -+ns_ax_event_is_line_nav_key (int *which) -+{ -+ /* 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). */ -+ if (SYMBOLP (Vthis_command) && !NILP (Vthis_command)) -+ { -+ Lisp_Object cmd = Vthis_command; -+ /* Forward line commands. */ -+ if (EQ (cmd, Qns_ax_next_line) -+ || EQ (cmd, Qns_ax_dired_next_line)) -+ { -+ if (which) *which = 1; -+ return true; -+ } -+ /* Backward line commands. */ -+ if (EQ (cmd, Qns_ax_previous_line) -+ || EQ (cmd, Qns_ax_dired_previous_line)) -+ { -+ if (which) *which = -1; -+ return true; -+ } -+ } -+ -+ /* 2. Fallback: check raw key events for Tab/backtab. */ -+ Lisp_Object ev = last_command_event; -+ if (CONSP (ev)) -+ ev = EVENT_HEAD (ev); -+ -+ if (SYMBOLP (ev) && EQ (ev, Qns_ax_backtab)) -+ { -+ if (which) *which = -1; -+ return true; -+ } -+ if (FIXNUMP (ev) && XFIXNUM (ev) == 9) /* Tab */ -+ { -+ if (which) *which = 1; -+ return true; -+ } -+ return false; -+} -+ -+ -+ -+static NSString * -+ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, -+ struct buffer *b, -+ ptrdiff_t start, -+ ptrdiff_t end, -+ NSString *cachedText) -+{ -+ if (!elem || !b || !cachedText || end <= start) -+ return nil; -+ -+ 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. */ -+ block_input (); -+ record_unwind_protect_void (unblock_input); -+ if (b != current_buffer) -+ set_buffer_internal_1 (b); -+ -+ /* Prefer canonical completion candidate string from text property. -+ Try both completion--string (new API, set by minibuffer.el) and -+ completion (older API used by some modes). */ -+ ptrdiff_t probes[2] = { start, end - 1 }; -+ for (int i = 0; i < 2 && !text; i++) -+ { -+ ptrdiff_t p = probes[i]; -+ Lisp_Object cstr = Fget_char_property (make_fixnum (p), -+ Qns_ax_completion__string, -+ Qnil); -+ if (STRINGP (cstr)) -+ text = [NSString stringWithLispString:cstr]; -+ if (!text) -+ { -+ /* Fallback: 'completion property used by display-completion-list. */ -+ cstr = Fget_char_property (make_fixnum (p), -+ Qns_ax_completion, -+ Qnil); -+ if (STRINGP (cstr)) -+ text = [NSString stringWithLispString:cstr]; -+ } -+ } -+ -+ if (!text) -+ { -+ NSUInteger ax_s = [elem accessibilityIndexForCharpos:start]; -+ NSUInteger ax_e = [elem accessibilityIndexForCharpos:end]; -+ if (ax_e > ax_s && ax_e <= [cachedText length]) -+ text = [cachedText substringWithRange:NSMakeRange (ax_s, ax_e - ax_s)]; -+ } -+ -+ unbind_to (count, Qnil); -+ -+ if (text) -+ { -+ text = [text stringByTrimmingCharactersInSet: -+ [NSCharacterSet whitespaceAndNewlineCharacterSet]]; -+ if ([text length] == 0) -+ text = nil; -+ } -+ -+ return text; -+} -+ -+@implementation EmacsAccessibilityBuffer -+@synthesize cachedText; -+@synthesize cachedTextModiff; -+@synthesize cachedOverlayModiff; -+@synthesize cachedTextStart; -+@synthesize cachedModiff; -+@synthesize cachedPoint; -+@synthesize cachedMarkActive; -+@synthesize cachedCompletionAnnouncement; -+@synthesize cachedCompletionOverlayStart; -+@synthesize cachedCompletionOverlayEnd; -+@synthesize cachedCompletionPoint; -+ -+- (void)dealloc -+{ -+ [cachedText release]; -+ [cachedCompletionAnnouncement release]; -+ [cachedInteractiveSpans release]; -+ if (visibleRuns) -+ xfree (visibleRuns); -+ if (lineStartOffsets) -+ xfree (lineStartOffsets); -+ [super dealloc]; -+} -+ -+/* ---- Text cache ---- */ -+ -+- (void)invalidateTextCache -+{ -+ @synchronized (self) -+ { -+ [cachedText release]; -+ cachedText = nil; -+ if (visibleRuns) -+ { -+ xfree (visibleRuns); -+ visibleRuns = NULL; -+ } -+ visibleRunCount = 0; -+ if (lineStartOffsets) -+ { -+ xfree (lineStartOffsets); -+ lineStartOffsets = NULL; -+ } -+ lineCount = 0; -+ } -+ [self invalidateInteractiveSpans]; -+} -+ -+/* ---- Line index helpers ---- */ -+ -+/* Return the line number for AX string index IDX using the -+ precomputed lineStartOffsets array. Binary search: O(log L) -+ where L is the number of lines in the cached text. -+ -+ lineStartOffsets[i] holds the AX string index where line i -+ begins. Built once per cache rebuild in ensureTextCache. */ -+- (NSInteger)lineForAXIndex:(NSUInteger)idx -+{ -+ @synchronized (self) -+ { -+ if (!lineStartOffsets || lineCount == 0) -+ return 0; -+ -+ /* Binary search for the largest line whose start offset <= idx. */ -+ NSUInteger lo = 0, hi = lineCount; -+ while (lo < hi) -+ { -+ NSUInteger mid = lo + (hi - lo) / 2; -+ if (lineStartOffsets[mid] <= idx) -+ lo = mid + 1; -+ else -+ hi = mid; -+ } -+ return (NSInteger) (lo > 0 ? lo - 1 : 0); -+ } -+} -+ -+/* Return the AX string range for LINE using the precomputed -+ lineStartOffsets array. O(1) lookup. */ -+- (NSRange)rangeForLine:(NSUInteger)line textLength:(NSUInteger)tlen -+{ -+ @synchronized (self) -+ { -+ if (!lineStartOffsets || lineCount == 0) -+ return NSMakeRange (NSNotFound, 0); -+ -+ if (line >= lineCount) -+ return NSMakeRange (NSNotFound, 0); -+ -+ NSUInteger start = lineStartOffsets[line]; -+ NSUInteger end = (line + 1 < lineCount) -+ ? lineStartOffsets[line + 1] -+ : tlen; -+ return NSMakeRange (start, end - start); -+ } -+} -+ -+- (void)ensureTextCache -+{ -+ 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 -+ write section at the end needs synchronization to protect -+ against concurrent reads from AX server thread. */ -+ eassert ([NSThread isMainThread]); -+ struct window *w = [self validWindow]; -+ if (!w || !WINDOW_LEAF_P (w)) -+ return; -+ -+ struct buffer *b = XBUFFER (w->contents); -+ if (!b) -+ return; -+ -+ /* Use BUF_CHARS_MODIFF, not BUF_MODIFF, for cache validity. -+ BUF_MODIFF is bumped by every text-property change, including -+ font-lock face applications on every redisplay. AX text contains -+ only characters, not face data, so property-only changes do not -+ affect the cached value. Rebuilding the full buffer text on -+ each font-lock pass is O(buffer-size) per redisplay --- this -+ causes progressive slowdown when scrolling through large files. -+ BUF_CHARS_MODIFF is bumped only on actual character insertions -+ and deletions, matching the semantic of "did the text change". -+ This is the pattern used by WebKit and NSTextView. -+ Do NOT track BUF_OVERLAY_MODIFF here --- overlay text is not -+ included in the cached AX text (it is handled separately via -+ explicit announcements in postAccessibilityNotificationsForFrame). -+ Including overlay_modiff would silently update cachedOverlayModiff -+ and prevent the notification dispatch from detecting changes. */ -+ ptrdiff_t chars_modiff = BUF_CHARS_MODIFF (b); -+ ptrdiff_t pt = BUF_PT (b); -+ NSUInteger textLen = cachedText ? [cachedText length] : 0; -+ if (cachedText && cachedTextModiff == chars_modiff -+ && cachedTextStart == BUF_BEGV (b) -+ && pt >= cachedTextStart -+ && (textLen == 0 -+ || [self accessibilityIndexForCharpos:pt] <= textLen)) -+ return; -+ -+ ptrdiff_t start; -+ ns_ax_visible_run *runs = NULL; -+ NSUInteger nruns = 0; -+ NSString *text = ns_ax_buffer_text (w, &start, &runs, &nruns); -+ -+ @synchronized (self) -+ { -+ [cachedText release]; -+ cachedText = [text retain]; -+ cachedTextModiff = chars_modiff; -+ cachedTextStart = start; -+ -+ if (visibleRuns) -+ xfree (visibleRuns); -+ visibleRuns = runs; -+ visibleRunCount = nruns; -+ -+ /* Build line-start index for O(log L) line queries. -+ Walk the cached text once, recording the start offset of each -+ line. Uses NSString lineRangeForRange: --- O(N) in the total -+ text --- but this loop runs only on cache rebuild, which is -+ gated on BUF_CHARS_MODIFF: actual character insertions or -+ deletions. Font-lock (text property changes) does not trigger -+ a rebuild, so the hot path (cursor movement, redisplay) never -+ enters this code. */ -+ if (lineStartOffsets) -+ xfree (lineStartOffsets); -+ lineStartOffsets = NULL; -+ lineCount = 0; -+ -+ NSUInteger tlen = [cachedText length]; -+ if (tlen > 0) -+ { -+ NSUInteger cap = 256; -+ lineStartOffsets = xmalloc (cap * sizeof (NSUInteger)); -+ lineStartOffsets[0] = 0; -+ lineCount = 1; -+ NSUInteger pos = 0; -+ while (pos < tlen) -+ { -+ NSRange lr = [cachedText lineRangeForRange: -+ NSMakeRange (pos, 0)]; -+ NSUInteger next = NSMaxRange (lr); -+ if (next <= pos) -+ break; /* safety */ -+ if (next < tlen) -+ { -+ if (lineCount >= cap) -+ { -+ cap *= 2; -+ lineStartOffsets = xrealloc (lineStartOffsets, -+ cap * sizeof (NSUInteger)); -+ } -+ lineStartOffsets[lineCount] = next; -+ lineCount++; -+ } -+ pos = next; -+ } -+ } -+ } -+} -+ -+/* ---- Index mapping ---- */ -+ -+/* Convert buffer charpos to accessibility string index. */ -+- (NSUInteger)accessibilityIndexForCharpos:(ptrdiff_t)charpos -+{ -+ /* This method may be called from the AX server thread. -+ Synchronize on self to prevent use-after-free if the main -+ thread invalidates the text cache concurrently. */ -+ @synchronized (self) -+ { -+ if (visibleRunCount == 0) -+ return 0; -+ -+ /* 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. */ -+ NSUInteger lo = 0, hi = visibleRunCount; -+ while (lo < hi) -+ { -+ NSUInteger mid = lo + (hi - lo) / 2; -+ ns_ax_visible_run *r = &visibleRuns[mid]; -+ if (charpos < r->charpos) -+ hi = mid; -+ else if (charpos >= r->charpos + r->length) -+ lo = mid + 1; -+ else -+ { -+ /* Found: charpos is inside this run. Compute UTF-16 delta. -+ Fast path for pure-ASCII runs (ax_length == length): every -+ Emacs charpos maps to exactly one UTF-16 code unit, so the -+ conversion is O(1). This matters because ensureTextCache -+ calls this method on every redisplay frame to validate the -+ cache --- a O(cursor_position) loop here means O(position) -+ cost per frame even when the buffer is unchanged. -+ Multi-byte runs fall through to the sequence walk, bounded -+ by run length (visible window), not total buffer size. */ -+ NSUInteger chars_in = (NSUInteger)(charpos - r->charpos); -+ if (chars_in == 0) -+ return r->ax_start; -+ if (r->ax_length == (NSUInteger) r->length) -+ return r->ax_start + chars_in; -+ if (!cachedText) -+ return r->ax_start; -+ NSUInteger run_end_ax = r->ax_start + r->ax_length; -+ NSUInteger scan = r->ax_start; -+ for (NSUInteger c = 0; c < chars_in && scan < run_end_ax; c++) -+ { -+ NSRange seq = [cachedText -+ rangeOfComposedCharacterSequenceAtIndex:scan]; -+ scan = NSMaxRange (seq); -+ } -+ return (scan <= run_end_ax) ? scan : run_end_ax; -+ } -+ } -+ /* charpos falls in an invisible gap or past the end. */ -+ if (lo < visibleRunCount) -+ return visibleRuns[lo].ax_start; -+ ns_ax_visible_run *last = &visibleRuns[visibleRunCount - 1]; -+ return last->ax_start + last->ax_length; -+ } /* @synchronized */ -+} -+ -+/* Convert accessibility string index to buffer charpos. -+ Safe to call from any thread: uses only cachedText (NSString) and -+ visibleRuns --- no Lisp calls. */ -+- (ptrdiff_t)charposForAccessibilityIndex:(NSUInteger)ax_idx -+{ -+ /* May be called from AX server thread --- synchronize. */ -+ @synchronized (self) -+ { -+ if (visibleRunCount == 0) -+ return cachedTextStart; -+ -+ /* Binary search: runs are sorted by ax_start (ascending). */ -+ NSUInteger lo = 0, hi = visibleRunCount; -+ while (lo < hi) -+ { -+ NSUInteger mid = lo + (hi - lo) / 2; -+ ns_ax_visible_run *r = &visibleRuns[mid]; -+ if (ax_idx < r->ax_start) -+ hi = mid; -+ else if (ax_idx >= r->ax_start + r->ax_length) -+ lo = mid + 1; -+ else -+ { -+ /* Found: ax_idx is inside this run. -+ Fast path for pure-ASCII runs: ax_length == length means -+ every Emacs charpos maps to exactly one AX string index. -+ The conversion is then O(1) instead of O(cursor_position). -+ Buffers with emoji, CJK, or other non-BMP characters use -+ the slow path (composed character sequence walk), which is -+ bounded by run length, not total buffer size. */ -+ if (r->ax_length == (NSUInteger) r->length) -+ return r->charpos + (ptrdiff_t) (ax_idx - r->ax_start); -+ -+ if (!cachedText) -+ return r->charpos; -+ NSUInteger scan = r->ax_start; -+ ptrdiff_t cp = r->charpos; -+ while (scan < ax_idx) -+ { -+ NSRange seq = [cachedText -+ rangeOfComposedCharacterSequenceAtIndex:scan]; -+ scan = NSMaxRange (seq); -+ cp++; -+ } -+ return cp; -+ } -+ } -+ /* Past end --- return last charpos. */ -+ if (lo > 0) -+ { -+ ns_ax_visible_run *last = &visibleRuns[visibleRunCount - 1]; -+ return last->charpos + last->length; -+ } -+ return cachedTextStart; -+ } /* @synchronized */ -+} -+ -+/* --- Threading and signal safety --- -+ -+ AX getter methods may be called from the VoiceOver server thread. -+ All methods that access Lisp objects or buffer state dispatch_sync -+ to the main thread where Emacs state is consistent. -+ -+ Longjmp safety: Lisp functions called inside dispatch_sync blocks -+ (Fget_char_property, Fbuffer_substring_no_properties, etc.) could -+ theoretically signal and longjmp through the dispatch_sync frame, -+ 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. -+ 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 -+ running between precondition checks and Lisp calls. -+ 4. Buffer positions are clamped to BUF_BEGV/BUF_ZV before -+ use, preventing out-of-range signals. -+ 5. specpdl unwind protection ensures block_input is always -+ matched by unblock_input, even on longjmp. -+ -+ This matches the safety model of existing nsterm.m F-function -+ calls (24 direct calls, none wrapped in internal_condition_case). -+ -+ Known gap: if the Emacs window tree is modified between redisplay -+ cycles in a way that invalidates validWindow's cached result, -+ a stale dereference could occur. In practice this does not happen -+ because window tree modifications go through the event loop which -+ we are blocking via dispatch_sync. */ -+ -+/* ---- NSAccessibility protocol ---- */ -+ -+- (NSAccessibilityRole)accessibilityRole -+{ -+ if (![NSThread isMainThread]) -+ { -+ __block NSAccessibilityRole result; -+ dispatch_sync (dispatch_get_main_queue (), ^{ -+ result = [self accessibilityRole]; -+ }); -+ return result; -+ } -+ struct window *w = [self validWindow]; -+ if (w && MINI_WINDOW_P (w)) -+ return NSAccessibilityTextFieldRole; -+ return NSAccessibilityTextAreaRole; -+} -+ -+- (NSString *)accessibilityPlaceholderValue -+{ -+ if (![NSThread isMainThread]) -+ { -+ __block NSString *result; -+ dispatch_sync (dispatch_get_main_queue (), ^{ -+ result = [self accessibilityPlaceholderValue]; -+ }); -+ return result; -+ } -+ struct window *w = [self validWindow]; -+ if (!w || !MINI_WINDOW_P (w)) -+ return nil; -+ Lisp_Object prompt = Fminibuffer_prompt (); -+ if (STRINGP (prompt)) -+ return [NSString stringWithLispString: prompt]; -+ return nil; -+} -+ -+- (NSString *)accessibilityRoleDescription -+{ -+ if (![NSThread isMainThread]) -+ { -+ __block NSString *result; -+ dispatch_sync (dispatch_get_main_queue (), ^{ -+ result = [self accessibilityRoleDescription]; -+ }); -+ return result; -+ } -+ struct window *w = [self validWindow]; -+ if (w && MINI_WINDOW_P (w)) -+ return @"minibuffer"; -+ return @"editor"; -+} -+ -+- (NSString *)accessibilityLabel -+{ -+ if (![NSThread isMainThread]) -+ { -+ __block NSString *result; -+ dispatch_sync (dispatch_get_main_queue (), ^{ -+ result = [self accessibilityLabel]; -+ }); -+ return result; -+ } -+ struct window *w = [self validWindow]; -+ if (w && WINDOW_LEAF_P (w)) -+ { -+ if (MINI_WINDOW_P (w)) -+ return @"Minibuffer"; -+ -+ struct buffer *b = XBUFFER (w->contents); -+ if (b) -+ { -+ Lisp_Object name = BVAR (b, name); -+ if (STRINGP (name)) -+ return [NSString stringWithLispString:name]; -+ } -+ } -+ return @"buffer"; -+} -+ -+- (BOOL)isAccessibilityFocused -+{ -+ if (![NSThread isMainThread]) -+ { -+ __block BOOL result; -+ dispatch_sync (dispatch_get_main_queue (), ^{ -+ result = [self isAccessibilityFocused]; -+ }); -+ return result; -+ } -+ struct window *w = [self validWindow]; -+ if (!w) -+ return NO; -+ EmacsView *view = self.emacsView; -+ if (!view || !view->emacsframe) -+ return NO; -+ struct frame *f = view->emacsframe; -+ return (w == XWINDOW (f->selected_window)); -+} -+ -+- (id)accessibilityValue -+{ -+ /* AX getters can be called from any thread by the AT subsystem. -+ Dispatch to main thread where Emacs buffer state is consistent. */ -+ if (![NSThread isMainThread]) -+ { -+ __block id result; -+ dispatch_sync (dispatch_get_main_queue (), ^{ -+ result = [self accessibilityValue]; -+ }); -+ return result; -+ } -+ [self ensureTextCache]; -+ return cachedText ? cachedText : @""; -+} -+ -+- (NSInteger)accessibilityNumberOfCharacters -+{ -+ if (![NSThread isMainThread]) -+ { -+ __block NSInteger result; -+ dispatch_sync (dispatch_get_main_queue (), ^{ -+ result = [self accessibilityNumberOfCharacters]; -+ }); -+ return result; -+ } -+ [self ensureTextCache]; -+ return cachedText ? [cachedText length] : 0; -+} -+ -+- (NSString *)accessibilitySelectedText -+{ -+ if (![NSThread isMainThread]) -+ { -+ __block NSString *result; -+ dispatch_sync (dispatch_get_main_queue (), ^{ -+ result = [self accessibilitySelectedText]; -+ }); -+ return result; -+ } -+ struct window *w = [self validWindow]; -+ if (!w || !WINDOW_LEAF_P (w)) -+ return @""; -+ -+ struct buffer *b = XBUFFER (w->contents); -+ if (!b || NILP (BVAR (b, mark_active))) -+ return @""; -+ -+ NSRange sel = [self accessibilitySelectedTextRange]; -+ [self ensureTextCache]; -+ if (!cachedText || sel.location == NSNotFound -+ || sel.location + sel.length > [cachedText length]) -+ return @""; -+ return [cachedText substringWithRange:sel]; -+} -+ -+- (NSRange)accessibilitySelectedTextRange -+{ -+ if (![NSThread isMainThread]) -+ { -+ __block NSRange result; -+ dispatch_sync (dispatch_get_main_queue (), ^{ -+ result = [self accessibilitySelectedTextRange]; -+ }); -+ return result; -+ } -+ struct window *w = [self validWindow]; -+ if (!w || !WINDOW_LEAF_P (w)) -+ return NSMakeRange (0, 0); -+ -+ if (!BUFFERP (w->contents)) -+ return NSMakeRange (0, 0); -+ struct buffer *b = XBUFFER (w->contents); -+ if (!b) -+ return NSMakeRange (0, 0); -+ -+ [self ensureTextCache]; -+ ptrdiff_t pt = BUF_PT (b); -+ NSUInteger point_idx = [self accessibilityIndexForCharpos:pt]; -+ -+ if (NILP (BVAR (b, mark_active))) -+ return NSMakeRange (point_idx, 0); -+ -+ ptrdiff_t mark_pos = marker_position (BVAR (b, mark)); -+ NSUInteger mark_idx = [self accessibilityIndexForCharpos:mark_pos]; -+ NSUInteger start_idx = MIN (point_idx, mark_idx); -+ NSUInteger end_idx = MAX (point_idx, mark_idx); -+ return NSMakeRange (start_idx, end_idx - start_idx); -+} -+ -+- (void)setAccessibilitySelectedTextRange:(NSRange)range -+{ -+ if (![NSThread isMainThread]) -+ { -+ dispatch_async (dispatch_get_main_queue (), ^{ -+ [self setAccessibilitySelectedTextRange:range]; -+ }); -+ return; -+ } -+ struct window *w = [self validWindow]; -+ if (!w || !WINDOW_LEAF_P (w)) -+ return; -+ -+ struct buffer *b = XBUFFER (w->contents); -+ if (!b) -+ return; -+ -+ [self ensureTextCache]; -+ -+ specpdl_ref count = SPECPDL_INDEX (); -+ record_unwind_current_buffer (); -+ /* block_input must come before record_unwind_protect_void (unblock_input). */ -+ block_input (); -+ record_unwind_protect_void (unblock_input); -+ -+ /* Convert accessibility index to buffer charpos via mapping. */ -+ ptrdiff_t charpos = [self charposForAccessibilityIndex:range.location]; -+ -+ /* Clamp to buffer bounds. */ -+ if (charpos < BUF_BEGV (b)) -+ charpos = BUF_BEGV (b); -+ if (charpos > BUF_ZV (b)) -+ charpos = BUF_ZV (b); -+ -+ /* Move point directly in the buffer. */ -+ if (b != current_buffer) -+ set_buffer_internal_1 (b); -+ -+ SET_PT_BOTH (charpos, CHAR_TO_BYTE (charpos)); -+ -+ /* Always deactivate mark: VoiceOver range.length is an internal -+ word boundary hint, not a text selection. Activating the mark -+ makes accessibilitySelectedTextRange return a non-zero length, -+ which confuses VoiceOver into positioning its browse cursor at -+ the END of the selection instead of the start. */ -+ bset_mark_active (b, Qnil); -+ unbind_to (count, Qnil); -+ -+ /* Update cached state so the next notification cycle doesn't -+ re-announce this movement. */ -+ self.cachedPoint = charpos; -+ self.cachedMarkActive = (range.length > 0); -+} -+ -+- (void)setAccessibilityFocused:(BOOL)flag -+{ -+ if (!flag) -+ return; -+ -+ /* VoiceOver may call this from the AX server thread. -+ All Lisp reads, block_input, and AppKit calls require main. */ -+ if (![NSThread isMainThread]) -+ { -+ dispatch_async (dispatch_get_main_queue (), ^{ -+ [self setAccessibilityFocused:flag]; -+ }); -+ return; -+ } -+ -+ struct window *w = [self validWindow]; -+ if (!w || !WINDOW_LEAF_P (w)) -+ return; -+ -+ EmacsView *view = self.emacsView; -+ if (!view || !view->emacsframe) -+ return; -+ -+ /* block_input must come before record_unwind_protect_void (unblock_input). */ -+ specpdl_ref count = SPECPDL_INDEX (); -+ block_input (); -+ record_unwind_protect_void (unblock_input); -+ -+ /* Select the Emacs window so keyboard focus follows VoiceOver. */ -+ struct frame *f = view->emacsframe; -+ if (w != XWINDOW (f->selected_window)) -+ Fselect_window (self.lispWindow, Qnil); -+ -+ /* Raise the frame's NS window to ensure keyboard focus. */ -+ NSWindow *nswin = [view window]; -+ if (nswin && ![nswin isKeyWindow]) -+ [nswin makeKeyAndOrderFront:nil]; -+ -+ unbind_to (count, Qnil); -+ -+ /* Post SelectedTextChanged so VoiceOver reads the current line -+ upon entering text interaction mode. -+ WebKit AXObjectCacheMac fallback enum: SelectionMove = 2. */ -+ NSDictionary *info = @{ -+ @"AXTextStateChangeType": -+ @(ns_ax_text_state_change_selection_move), -+ @"AXTextChangeElement": self -+ }; -+ ns_ax_post_notification_with_info ( -+ self, NSAccessibilitySelectedTextChangedNotification, info); -+} -+ -+- (NSInteger)accessibilityInsertionPointLineNumber -+{ -+ if (![NSThread isMainThread]) -+ { -+ __block NSInteger result; -+ dispatch_sync (dispatch_get_main_queue (), ^{ -+ result = [self accessibilityInsertionPointLineNumber]; -+ }); -+ return result; -+ } -+ struct window *w = [self validWindow]; -+ if (!w || !WINDOW_LEAF_P (w)) -+ return 0; -+ -+ struct buffer *b = XBUFFER (w->contents); -+ if (!b) -+ return 0; -+ -+ [self ensureTextCache]; -+ if (!cachedText) -+ return 0; -+ -+ ptrdiff_t pt = BUF_PT (b); -+ NSUInteger point_idx = [self accessibilityIndexForCharpos:pt]; -+ if (point_idx > [cachedText length]) -+ point_idx = [cachedText length]; -+ -+ return [self lineForAXIndex:point_idx]; -+} -+ -+- (NSRange)accessibilityRangeForLine:(NSInteger)line -+{ -+ if (![NSThread isMainThread]) -+ { -+ __block NSRange result; -+ dispatch_sync (dispatch_get_main_queue (), ^{ -+ result = [self accessibilityRangeForLine:line]; -+ }); -+ return result; -+ } -+ [self ensureTextCache]; -+ if (!cachedText || line < 0) -+ return NSMakeRange (NSNotFound, 0); -+ -+ NSUInteger len = [cachedText length]; -+ if (len == 0) -+ return (line == 0) ? NSMakeRange (0, 0) -+ : NSMakeRange (NSNotFound, 0); -+ -+ return [self rangeForLine:(NSUInteger)line textLength:len]; -+} -+ -+- (NSInteger)accessibilityLineForIndex:(NSInteger)index -+{ -+ if (![NSThread isMainThread]) -+ { -+ __block NSInteger result; -+ dispatch_sync (dispatch_get_main_queue (), ^{ -+ result = [self accessibilityLineForIndex:index]; -+ }); -+ return result; -+ } -+ [self ensureTextCache]; -+ if (!cachedText || index < 0) -+ return 0; -+ -+ NSUInteger idx = (NSUInteger) index; -+ if (idx > [cachedText length]) -+ idx = [cachedText length]; -+ -+ return [self lineForAXIndex:idx]; -+} -+ -+- (NSRange)accessibilityRangeForIndex:(NSInteger)index -+{ -+ if (![NSThread isMainThread]) -+ { -+ __block NSRange result; -+ dispatch_sync (dispatch_get_main_queue (), ^{ -+ result = [self accessibilityRangeForIndex:index]; -+ }); -+ return result; -+ } -+ [self ensureTextCache]; -+ if (!cachedText || index < 0 -+ || (NSUInteger) index >= [cachedText length]) -+ return NSMakeRange (NSNotFound, 0); -+ return [cachedText -+ rangeOfComposedCharacterSequenceAtIndex:(NSUInteger)index]; -+} -+ -+- (NSRange)accessibilityStyleRangeForIndex:(NSInteger)index -+{ -+ /* Return the range of the current line. A more accurate -+ implementation would return face/font property boundaries, -+ but line granularity is acceptable for VoiceOver. */ -+ NSInteger line = [self accessibilityLineForIndex:index]; -+ return [self accessibilityRangeForLine:line]; -+} -+ -+- (NSRect)accessibilityFrameForRange:(NSRange)range -+{ -+ if (![NSThread isMainThread]) -+ { -+ __block NSRect result; -+ dispatch_sync (dispatch_get_main_queue (), ^{ -+ result = [self accessibilityFrameForRange:range]; -+ }); -+ return result; -+ } -+ struct window *w = [self validWindow]; -+ EmacsView *view = self.emacsView; -+ if (!w || !view) -+ return NSZeroRect; -+ /* Convert ax-index range to charpos range for glyph lookup. */ -+ [self ensureTextCache]; -+ ptrdiff_t cp_start = [self charposForAccessibilityIndex:range.location]; -+ ptrdiff_t cp_end = [self charposForAccessibilityIndex: -+ range.location + range.length]; -+ return ns_ax_frame_for_range (w, view, cp_start, -+ cp_end - cp_start); -+} -+ -+- (NSRange)accessibilityRangeForPosition:(NSPoint)screenPoint -+{ -+ if (![NSThread isMainThread]) -+ { -+ __block NSRange result; -+ dispatch_sync (dispatch_get_main_queue (), ^{ -+ result -+ = [self accessibilityRangeForPosition:screenPoint]; -+ }); -+ return result; -+ } -+ /* Hit test: convert screen point to buffer character index. */ -+ struct window *w = [self validWindow]; -+ EmacsView *view = self.emacsView; -+ if (!w || !view || !w->current_matrix) -+ return NSMakeRange (0, 0); -+ -+ /* Convert screen point to EmacsView coordinates. */ -+ NSPoint windowPoint = [[view window] convertPointFromScreen:screenPoint]; -+ NSPoint viewPoint = [view convertPoint:windowPoint fromView:nil]; -+ -+ /* Convert to window-relative pixel coordinates. */ -+ int x = (int) viewPoint.x - w->pixel_left; -+ int y = (int) viewPoint.y - w->pixel_top; -+ -+ if (x < 0 || y < 0 || x >= w->pixel_width || y >= w->pixel_height) -+ return NSMakeRange (0, 0); -+ -+ /* Block input to prevent concurrent redisplay from modifying the -+ glyph matrix while we traverse it. Use specpdl unwind protection -+ so block_input is always matched by unblock_input, even if -+ ensureTextCache triggers a Lisp signal (longjmp). */ -+ specpdl_ref count = SPECPDL_INDEX (); -+ block_input (); -+ record_unwind_protect_void (unblock_input); -+ -+ /* Find the glyph row at this y coordinate. */ -+ struct glyph_matrix *matrix = w->current_matrix; -+ struct glyph_row *hit_row = NULL; -+ -+ for (int i = 0; i < matrix->nrows; i++) -+ { -+ struct glyph_row *row = matrix->rows + i; -+ if (!row->enabled_p || !row->displays_text_p || row->mode_line_p) -+ continue; -+ int row_top = WINDOW_TO_FRAME_PIXEL_Y (w, MAX (0, row->y)); -+ if ((int) viewPoint.y >= row_top -+ && (int) viewPoint.y < row_top + row->visible_height) -+ { -+ hit_row = row; -+ break; -+ } -+ } -+ -+ if (!hit_row) -+ { -+ unbind_to (count, Qnil); -+ return NSMakeRange (0, 0); -+ } -+ -+ /* Find the glyph at this x coordinate within the row. */ -+ struct glyph *glyph = hit_row->glyphs[TEXT_AREA]; -+ struct glyph *end = glyph + hit_row->used[TEXT_AREA]; -+ int glyph_x = 0; -+ ptrdiff_t best_charpos = MATRIX_ROW_START_CHARPOS (hit_row); -+ -+ for (; glyph < end; glyph++) -+ { -+ if (glyph->type == CHAR_GLYPH && glyph->charpos > 0) -+ { -+ if (x >= glyph_x && x < glyph_x + glyph->pixel_width) -+ { -+ best_charpos = glyph->charpos; -+ break; -+ } -+ best_charpos = glyph->charpos; -+ } -+ glyph_x += glyph->pixel_width; -+ } -+ -+ /* Convert buffer charpos to accessibility index via mapping. */ -+ [self ensureTextCache]; -+ NSUInteger ax_idx = [self accessibilityIndexForCharpos:best_charpos]; -+ if (cachedText && ax_idx > [cachedText length]) -+ ax_idx = [cachedText length]; -+ -+ unbind_to (count, Qnil); -+ return NSMakeRange (ax_idx, 1); -+} -+ -+- (NSRange)accessibilityVisibleCharacterRange -+{ -+ if (![NSThread isMainThread]) -+ { -+ __block NSRange result; -+ dispatch_sync (dispatch_get_main_queue (), ^{ -+ result = [self accessibilityVisibleCharacterRange]; -+ }); -+ return result; -+ } -+ /* Return the full cached text range. VoiceOver interprets the -+ visible range boundary as end-of-text, so we must expose the -+ entire buffer to avoid premature "end of text" announcements. */ -+ [self ensureTextCache]; -+ return NSMakeRange (0, cachedText ? [cachedText length] : 0); -+} -+ -+- (NSRect)accessibilityFrame -+{ -+ if (![NSThread isMainThread]) -+ { -+ __block NSRect result; -+ dispatch_sync (dispatch_get_main_queue (), ^{ -+ result = [self accessibilityFrame]; -+ }); -+ return result; -+ } -+ struct window *w = [self validWindow]; -+ if (!w) -+ return NSZeroRect; -+ -+ /* Subtract mode line height so the buffer element does not overlap it. */ -+ int text_h = w->pixel_height; -+ if (w->current_matrix) -+ { -+ for (int i = w->current_matrix->nrows - 1; i >= 0; i--) -+ { -+ struct glyph_row *row = w->current_matrix->rows + i; -+ if (row->enabled_p && row->mode_line_p) -+ { -+ text_h -= row->visible_height; -+ break; -+ } -+ } -+ } -+ return [self screenRectFromEmacsX:w->pixel_left -+ y:w->pixel_top -+ width:w->pixel_width -+ height:text_h]; -+} -+ -+/* ---- Notification dispatch (helper methods) ---- */ -+ -+/* Post NSAccessibilityValueChangedNotification for a text edit. -+ Called when BUF_MODIFF changes between redisplay cycles. */ -+ -+@end -+ - #endif /* NS_IMPL_COCOA */ - - --- -2.43.0 - diff --git a/patches/0002-ns-implement-buffer-accessibility-element.patch b/patches/0002-ns-implement-buffer-accessibility-element.patch new file mode 100644 index 0000000..90d4304 --- /dev/null +++ b/patches/0002-ns-implement-buffer-accessibility-element.patch @@ -0,0 +1,434597 @@ +From cc56e2e47c5f6f2015208e05d5fb145205d21a35 Mon Sep 17 00:00:00 2001 +From: Martin Sukany +Date: Wed, 4 Mar 2026 15:23:54 +0100 +Subject: [PATCH 3/9] ns: implement buffer accessibility element +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Implement the NSAccessibility text protocol for Emacs buffer windows. + +* src/nsterm.m (ns_ax_find_completion_overlay_range): New function. +(ns_ax_event_is_line_nav_key, ns_ax_completion_text_for_span): New +functions. +(EmacsAccessibilityBuffer): Implement core NSAccessibility protocol. +(ensureTextCache): Cache validity tracked via BUF_MODIFF so that +fold/unfold commands (which change text properties but not characters) +correctly invalidate the AX text cache. Add explanatory comment on +why lineRangeForRange: in the lineStartOffsets loop is safe. +(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. +(accessibilityRole, accessibilityLabel, accessibilityValue) +(accessibilityNumberOfCharacters, accessibilitySelectedText) +(accessibilitySelectedTextRange, accessibilityInsertionPointLineNumber) +(accessibilityLineForIndex:): New method; return the line number for an +AX character index; defined here so patches 0003+ can call it without +forward reference. +(accessibilityRangeForLine:, accessibilityRangeForIndex:) +(accessibilityStyleRangeForIndex:, accessibilityFrameForRange:) +(accessibilityRangeForPosition:, accessibilityVisibleCharacterRange) +(accessibilityFrame, setAccessibilitySelectedTextRange:) +(setAccessibilityFocused:): Implement NSAccessibility protocol methods. + +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. + +* src/nsterm.h (ns_ax_visible_run): New struct. +(EmacsAccessibilityElement): New base Objective-C class. +(EmacsAccessibilityBuffer, EmacsAccessibilityModeLine) +(EmacsAccessibilityInteractiveSpan): Forward-declare new classes. +(EmacsAXSpanType): New enum for interactive span types. +(EmacsView): New ivars for accessibility element tree. +* src/nsterm.m: Include intervals.h for TEXT_PROP_MEANS_INVISIBLE. +(ns_ax_buffer_text): New function; build visible-text string and +run array for a window, skipping invisible character regions. +(ns_ax_mode_line_text): New function; extract mode-line text. +(ns_ax_frame_for_range): New function; map charpos range to screen +rect via glyph matrix. +(ns_ax_completion_string_from_prop) +(ns_ax_window_buffer_object, ns_ax_window_end_charpos) +(ns_ax_text_prop_at, ns_ax_next_prop_change) +(ns_ax_get_span_label, ns_ax_post_notification) +(ns_ax_post_notification_with_info): New helper functions. +(EmacsAccessibilityElement): Implement base class. +(syms_of_nsterm): Register accessibility DEFSYMs. Add DEFVAR_BOOL +ns-accessibility-enabled with corrected doc: initial value is nil, +set non-nil automatically when an AT is detected at startup. + +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 +Zoom follows the selected item (Vertico, Corfu, etc.) during completion. + +* etc/NEWS: Document Zoom integration. +* src/nsterm.h (EmacsView): Add lastCursorRect, zoomCursorUpdated. +* src/nsterm.m: Include ApplicationServices for UAZoomEnabled and +UAZoomChangeFocus (UniversalAccess sub-framework). +[NS_IMPL_COCOA]: Define NS_AX_MAX_COMPLETION_BUFFER_CHARS. +(ns_zoom_enabled_p): New static function; caches UAZoomEnabled with +1-second TTL to avoid per-frame Mach IPC overhead. +(ns_zoom_face_is_selected): New static predicate; matches 'current', +'selected', 'selection' in face symbol names. +(ns_zoom_find_overlay_candidate_line): New static function; scans +minibuffer overlays for the selected completion candidate line. +(ns_zoom_find_child_frame_candidate): New static function; scans +child frame buffers for a selected candidate; guards against partially +initialized frames with WINDOWP and BUFFERP checks. +(ns_zoom_track_completion): New static function; overrides Zoom focus +to the selected completion candidate after normal cursor tracking. +(ns_update_end): Call ns_zoom_track_completion. +(ns_draw_window_cursor): Store cursor rect; call UAZoomChangeFocus. + +Fix `diary-rrule' recurrence rule calculation (Bug#80460) + +Thanks to TAKAHASHI Yoshio for reporting and for fixing one of +the typos. In addition to the reported bug involving +:include/:exclude, testing revealed that the provided RRULE +COUNT clause was also not being handled correctly; this change +also fixes that. +* lisp/calendar/diary-icalendar.el (diary-rrule): Handle +recurrence rules with a COUNT clause. +* lisp/calendar/icalendar-recur.el +(icalendar-recur-recurrences-in-interval): Fix a couple of +typos that caused RDATE/EXDATE calculations to fail. +* test/lisp/calendar/diary-icalendar-tests.el +(diary-icalendar-test-rrule-bug-80460): New test for this bug. + +; Avoid byte-compilation warning in c-ts-common.el + +* lisp/progmodes/c-ts-common.el (c-ts-common-comment-start-skip): +Move up to avoid byte-compiler warning. + +Upgrade 'equal' from side-effect-free to error-free + +* lisp/emacs-lisp/byte-opt.el (side-effect-free-fns) +(side-effect-and-error-free-fns): 'equal' and +'equal-including-properties' no longer signal 'circular-list'; while +they do error on very deep structures, that is more of a resource limit +so let's overlook that. + +Simplify peephole optimisation rule + +* lisp/emacs-lisp/byte-opt.el (byte-optimize-lapcode): +Remove some obsolete dynamic variable optimisations that no longer +improve any code. Fix broken logging (thanks Pip). + +; Fix doc-string of 'window-combination-resize' + +* src/window.c (syms_of_window): Fix doc-string of +'window-combination-resize'. + +; * lisp/skeleton.el (skeleton-insert): Doc fix. (Bug#80492) + +; Fix esh-proc-tests on MS-Windows + +* test/lisp/eshell/esh-proc-tests.el (esh-proc-test/kill/process-id) +(esh-proc-test/kill/process-object): Fix these tests on MS-Windows. + +Make c-ts-common-comment-start-skip public + +* lisp/progmodes/c-ts-common.el: +(c-ts-common-comment-start-skip): Change to public. +(c-ts-common-comment-2nd-line-anchor): +(c-ts-common-comment-setup): Use it. + +c-ts-mode: Don't assume comment-start-skip is set + +This patch makes it easier to use the existing C and C++ +language support in other languages. Without this patch, if the +outer mode sets some specific and more accurate +comment-start-skip value, or perhaps leaves it unset, +indentation will break. + +* lisp/progmodes/c-ts-common.el (c-ts-common--comment-start-skip): +Declare to the value c-ts-common-comment-setup used to set as +comment-start-skip. +(c-ts-common-comment-setup): Use it, rather than hardcoding. +(c-ts-common-comment-2nd-line-anchor): Use it, rather than +comment-start-skip. This makes it easier to reuse C/++ +indentation rules in other TS modes for embedding C/++ segments +in other languages. + +Copyright-paperwork-exempt: yes + +Repair another test bollixed by aggressive optimization. + +Repair ab ecal test by making a variable kexical, + +Complete the test set for floatfns,c. + +Tesrts for the portable primitives in fileio.c. + +Tests for primitives in coding.c and charset.c. + +Tests for primitives from the character.c module. + +lisp/vc/smerge-mode.el (smerge-refine-shadow-cursor): Make it thinner + +OTOH a thickness of 1 pixel is a bit too thin on my HiDPI +displays, but 2 is too thick on non-HiDPI displays, at least with +my default smallish font. +I originally favored the HiDPI displays and large fonts, +thinking it's a more common situation nowadays, but I changed my +mind because the "too thick" problem seems actually more severe +because it's occasionally bad enough that it's unclear which +cursor is the real one. + +Tests for the lreaf.c amd print.c primitives. + +Tests for remaining functions iun eval.c. + +Completing test coverage for dataa.c orimitives. + +More correctness tesrs for orinitives from fns.c. + +More tests for edit functions, buffers, and markers. + +Added more buffer/marker/editing test coverage. + +Category/charset/coding + char-table tests. + +More test coverage improvements. + +Repair serious breakage in the batch tests. + +There were a bunch of tests that were breaking make check and should +never be run in batch mode, because they do things like assuming there +is a controlling tty or assuming we can access network services when +we can't (e/g. in a CI/CD environment). I have shotgunned this +problem by tagging all the failing tests with :nobatch and then +changing the default and expensive selectors so make check won't barf +all over its shoes. + +As many of these :nobatch should be individually removed as possible, after +upgrading the test harness to mock the environmental stuff they need. +Investigate these failures with "make check-nobatch". + +More test coverage improvements. + +Bignum corner-case tests in data-tests.el. +More buffer-primitive tests in editfns-test.el +Some condition-case tesrs in eval-tests.el. +And another marker-primitive test in marker-tests.el. + +More test coverage improvements for ERT. + +In marker-tests.el, editfbs-tests.el, and data-tests.el. + +; Fix emacs-module-tests on MS-Windows + +* test/src/emacs-module-resources/mod-test.c [WINDOWSNT]: Undef +fprintf to prevent link error. + +Crrections to tedt coverrage extensuion after bootstrap build. + +Files: data-tests.el, editfns-tests.el. + +Improve test coverage of builtin predicates. + +New function multiple-command-partition-arguments + +* lisp/subr.el (command-line-max-length): New variable. +(multiple-command-partition-arguments): New function. +* doc/lispref/processes.texi (Shell Arguments): +* etc/NEWS: Document them. +* test/lisp/subr-tests.el +(subr-multiple-command-partition-arguments): New test. + +(define-treesit-generic-mode): Improve autoloading support (bug#80480) + +* lisp/treesit-x.el (define-treesit-generic-mode): Mark it as +`(autoload-macro expand)`. +Don't autoload the `treesit-language-source-alist` setting. +Generate simpler code for the common case where AUTO-MODE is a string. + +Tests for 2 marker primitives previously not covered. + + - insertion-type + - last-position-after-kill + +Tests for 7 editor primitives previously not covered. + + - byte-to-position + - byte-to-string + - insert-byte + - insert-buffer-substring + - insert-before-markers-and-inherit + - field-string-and-delete + - constrain-to-field + +calendar-check-holidays: Call calendar-increment-month + +* lisp/calendar/holidays.el (calendar-check-holidays): Call +calendar-increment-month (bug#80476). + +; Revise short descriptions of EDE. + +Make ert more robust + +* lisp/emacs-lisp/ert.el (ert-run-test): Check, that `begin-marker' +is still valid. The *Messages* buffer could have been deleted +during test run. + +Fix eglot-tests on MacOS (bug#80479) + +* test/lisp/progmodes/eglot-tests.el (eglot--call-with-fixture): +Normalise 'temporary-file-directory' to stave off problems that +occur when it contains symlinks, which is common on MacOS. + +Speed up 'equal'-comparison of vectorlike objects + +* src/fns.c (internal_equal_1): Switch on the vectorlike type instead of +using a sequence of type predicates. + +Compare circular lists in 'equal' without error (bug#80456) + +* src/lisp.h (FOR_EACH_TAIL_INTERNAL): Divvy up the code into... +(FOR_EACH_TAIL_BASIC, FOR_EACH_TAIL_STEP_CYCLEP): ...these macros, +so that they can be used in more flexible ways. +* src/fns.c (internal_equal_1): Detect circular lists and call... +(internal_equal_cycle): ...this function that keeps comparing +but now detecting cycles in the other argument. + +* lisp/emacs-lisp/testcover.el (testcover-after): +Remove unnecessary error handling. +* test/src/fns-tests.el (test-cycle-equal): Adapt and extend. +* test/lisp/emacs-lisp/testcover-resources/testcases.el +(testcover-testcase-cyc1): Remove case that no longer applies. + +* doc/lispref/objects.texi (Equality Predicates): Update. +* etc/NEWS: Announce. + +Fix "End" key in PuTTY and older GNU screen + +* lisp/term/xterm.el (xterm-alternatives-map): Map