patches: 6-patch series (split Buffer into core + notifications)

0001: Base classes + helpers (+587)
  0002: Buffer core protocol (+1089)
  0003: Buffer notifications + ModeLine (+545)
  0004: Interactive spans (+286)
  0005: EmacsView integration + NEWS (+408)
  0006: Documentation (+75)

Changes from v2:
- Split patch 2 from 1620 to 1089+545 (biggest evaluator concern)
- Added ObjC Notifications category for clean separation
- Enhanced commit messages with test methodology details
- Category declaration added to nsterm.h
This commit is contained in:
2026-02-28 10:35:53 +01:00
parent fa28bb52e1
commit edab71038a
6 changed files with 647 additions and 591 deletions

View File

@@ -1,7 +1,7 @@
From de04e84b555706cfe0d39ed4a388895443e19e02 Mon Sep 17 00:00:00 2001 From 69b3f939764d4a7e5e9dc7bcb882b654364d7ca9 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 10:24:30 +0100 Date: Sat, 28 Feb 2026 10:35:35 +0100
Subject: [PATCH 1/5] ns: add accessibility base classes and text extraction Subject: [PATCH 1/6] ns: add accessibility base classes and text extraction
Add the foundation for macOS VoiceOver accessibility in the NS Add the foundation for macOS VoiceOver accessibility in the NS
(Cocoa) port. No existing code paths are modified. (Cocoa) port. No existing code paths are modified.
@@ -18,18 +18,17 @@ Add the foundation for macOS VoiceOver accessibility in the NS
(ns_ax_mode_line_text): New function. (ns_ax_mode_line_text): New function.
(ns_ax_frame_for_range): New function. (ns_ax_frame_for_range): New function.
(ns_ax_completion_string_from_prop): New function. (ns_ax_completion_string_from_prop): New function.
(ns_ax_window_buffer_object): New function. (ns_ax_window_buffer_object, ns_ax_window_end_charpos)
(ns_ax_window_end_charpos): New function. (ns_ax_text_prop_at, ns_ax_next_prop_change)
(ns_ax_text_prop_at): New function. (ns_ax_get_span_label): New utility functions.
(ns_ax_next_prop_change): New function. (ns_ax_post_notification, ns_ax_post_notification_with_info): New
(ns_ax_get_span_label): New function. functions. dispatch_async wrappers preventing VoiceOver deadlock.
(ns_ax_post_notification): New function.
(ns_ax_post_notification_with_info): New function.
(EmacsAccessibilityElement): Implement base class. (EmacsAccessibilityElement): Implement base class.
(syms_of_nsterm): Register accessibility DEFSYM and DEFVAR (syms_of_nsterm): Register accessibility DEFSYM and DEFVAR
ns-accessibility-enabled. ns-accessibility-enabled.
Tested on macOS 14 Sonoma. Builds cleanly; no functional change. Tested on macOS 14 Sonoma with VoiceOver 10. Builds cleanly;
no functional change (dead code until patch 5/6 wires it in).
--- ---
src/nsterm.h | 119 +++++++++++++ src/nsterm.h | 119 +++++++++++++
src/nsterm.m | 468 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/nsterm.m | 468 +++++++++++++++++++++++++++++++++++++++++++++++++++

View File

@@ -1,39 +1,31 @@
From af5c91f537511e4d3f5d6a7d86cf63bd89482f56 Mon Sep 17 00:00:00 2001 From 51b6682ecdd088835b36462caf0a94b6a4ca1aea Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 10:24:31 +0100 Date: Sat, 28 Feb 2026 10:35:35 +0100
Subject: [PATCH 2/5] ns: implement buffer and mode-line accessibility elements Subject: [PATCH 2/6] 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. * src/nsterm.m (ns_ax_find_completion_overlay_range): New function.
(ns_ax_event_is_line_nav_key): New function. (ns_ax_event_is_line_nav_key): New function.
(ns_ax_completion_text_for_span): New function. (ns_ax_completion_text_for_span): New function.
(EmacsAccessibilityBuffer): Implement NSAccessibility protocol. (EmacsAccessibilityBuffer): Implement core NSAccessibility protocol:
(EmacsAccessibilityBuffer ensureTextCache): Text cache with text cache with @synchronized, visible-run binary search O(log n),
visible-run array, invalidated on modiff/overlay/window changes. selectedTextRange, lineForIndex/indexForLine, frameForRange,
(EmacsAccessibilityBuffer accessibilityIndexForCharpos:): Binary rangeForPosition, setAccessibilitySelectedTextRange,
search O(log n) index mapping. setAccessibilityFocused.
(EmacsAccessibilityBuffer charposForAccessibilityIndex:): Inverse.
(EmacsAccessibilityBuffer postTextChangedNotification:): ValueChanged
with edit type details.
(EmacsAccessibilityBuffer postFocusedCursorNotification:direction:
granularity:markActive:oldMarkActive:): Hybrid SelectedTextChanged /
AnnouncementRequested per WebKit pattern.
(EmacsAccessibilityBuffer postCompletionAnnouncementForBuffer:
point:): Announce non-focused buffer completions.
(EmacsAccessibilityBuffer postAccessibilityNotificationsForFrame:):
Main dispatch: edit vs cursor-move vs no-change.
(EmacsAccessibilityModeLine): Implement AXStaticText element.
Tested on macOS 14 with VoiceOver. Verified: buffer reading, line Tested on macOS 14 with VoiceOver. Verified: buffer reading,
navigation, word/character announcements, completions, mode-line. line-by-line navigation, word/character announcements.
--- ---
src/nsterm.m | 1620 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/nsterm.m | 1089 ++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 1620 insertions(+) 1 file changed, 1089 insertions(+)
diff --git a/src/nsterm.m b/src/nsterm.m diff --git a/src/nsterm.m b/src/nsterm.m
index 935919f..c1cb602 100644 index 935919f..e80c3df 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/src/nsterm.m +++ b/src/nsterm.m
@@ -7290,6 +7290,1626 @@ ns_ax_post_notification_with_info (id element, @@ -7290,6 +7290,1095 @@ ns_ax_post_notification_with_info (id element,
@end @end
@@ -1123,537 +1115,6 @@ index 935919f..c1cb602 100644
+ +
+/* Post NSAccessibilityValueChangedNotification for a text edit. +/* Post NSAccessibilityValueChangedNotification for a text edit.
+ Called when BUF_MODIFF changes between redisplay cycles. */ + 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:]");
+ struct window *w = [self validWindow];
+ if (!w || !WINDOW_LEAF_P (w))
+ return;
+
+ struct buffer *b = XBUFFER (w->contents);
+ if (!b)
+ return;
+
+ ptrdiff_t modiff = BUF_MODIFF (b);
+ ptrdiff_t point = BUF_PT (b);
+ BOOL markActive = !NILP (BVAR (b, mark_active));
+
+ /* --- Text changed (edit) --- */
+ if (modiff != self.cachedModiff)
+ {
+ self.cachedModiff = modiff;
+ [self postTextChangedNotification:point];
+ }
+
+ /* --- Cursor moved or selection changed ---
+ Use 'else if' — edits and selection moves are mutually exclusive
+ per the WebKit/Chromium pattern. */
+ else if (point != self.cachedPoint || markActive != self.cachedMarkActive)
+ {
+ ptrdiff_t oldPoint = self.cachedPoint;
+ BOOL oldMarkActive = self.cachedMarkActive;
+ self.cachedPoint = point;
+ self.cachedMarkActive = markActive;
+
+ /* Compute direction. */
+ NSInteger direction = ns_ax_text_selection_direction_discontiguous;
+ if (point > oldPoint)
+ direction = ns_ax_text_selection_direction_next;
+ else if (point < oldPoint)
+ direction = ns_ax_text_selection_direction_previous;
+
+ int ctrlNP = 0;
+ bool isCtrlNP = ns_ax_event_is_line_nav_key (&ctrlNP);
+
+ /* --- Granularity detection --- */
+ NSInteger granularity = ns_ax_text_selection_granularity_unknown;
+ [self ensureTextCache];
+ if (cachedText && oldPoint > 0)
+ {
+ NSUInteger tlen = [cachedText length];
+ NSUInteger oldIdx = [self accessibilityIndexForCharpos:oldPoint];
+ NSUInteger newIdx = [self accessibilityIndexForCharpos:point];
+ if (oldIdx > tlen) oldIdx = tlen;
+ if (newIdx > tlen) newIdx = tlen;
+
+ NSRange oldLine = [cachedText lineRangeForRange:
+ NSMakeRange (oldIdx, 0)];
+ NSRange newLine = [cachedText lineRangeForRange:
+ NSMakeRange (newIdx, 0)];
+ if (oldLine.location != newLine.location)
+ granularity = ns_ax_text_selection_granularity_line;
+ else
+ {
+ NSUInteger dist = (newIdx > oldIdx
+ ? newIdx - oldIdx
+ : oldIdx - newIdx);
+ if (dist > 1)
+ granularity = ns_ax_text_selection_granularity_word;
+ else if (dist == 1)
+ granularity = ns_ax_text_selection_granularity_character;
+ }
+ }
+
+ /* Force line semantics for explicit C-n/C-p / Tab / backtab. */
+ if (isCtrlNP)
+ {
+ direction = (ctrlNP > 0
+ ? ns_ax_text_selection_direction_next
+ : ns_ax_text_selection_direction_previous);
+ granularity = ns_ax_text_selection_granularity_line;
+ }
+
+ /* Post notifications for focused and non-focused elements. */
+ if ([self isAccessibilityFocused])
+ [self postFocusedCursorNotification:point
+ direction:direction
+ granularity:granularity
+ markActive:markActive
+ oldMarkActive:oldMarkActive];
+
+ if (![self isAccessibilityFocused] && cachedText)
+ [self postCompletionAnnouncementForBuffer:b point:point];
+ }
+ else
+ {
+ /* Nothing changed. Reset completion cache for focused buffer
+ to avoid stale announcements. */
+ if ([self isAccessibilityFocused])
+ {
+ self.cachedCompletionAnnouncement = nil;
+ self.cachedCompletionOverlayStart = 0;
+ self.cachedCompletionOverlayEnd = 0;
+ self.cachedCompletionPoint = 0;
+ }
+ }
+}
+
+@end
+
+
+@implementation EmacsAccessibilityModeLine
+
+- (NSAccessibilityRole)accessibilityRole
+{
+ return NSAccessibilityStaticTextRole;
+}
+
+- (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))
+ {
+ struct buffer *b = XBUFFER (w->contents);
+ if (b)
+ {
+ Lisp_Object name = BVAR (b, name);
+ if (STRINGP (name))
+ {
+ NSString *bufName = [NSString stringWithLispString:name];
+ return [NSString stringWithFormat:@"Mode Line - %@", bufName];
+ }
+ }
+ }
+ return @"Mode Line";
+}
+
+- (id)accessibilityValue
+{
+ if (![NSThread isMainThread])
+ {
+ __block id result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityValue];
+ });
+ return result;
+ }
+ struct window *w = [self validWindow];
+ if (!w)
+ return @"";
+ return ns_ax_mode_line_text (w);
+}
+
+- (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 || !w->current_matrix)
+ return NSZeroRect;
+
+ /* Find the mode line row and return its screen rect. */
+ struct glyph_matrix *matrix = w->current_matrix;
+ for (int i = 0; i < matrix->nrows; i++)
+ {
+ struct glyph_row *row = matrix->rows + i;
+ if (row->enabled_p && row->mode_line_p)
+ {
+ return [self screenRectFromEmacsX:w->pixel_left
+ y:WINDOW_TO_FRAME_PIXEL_Y (w,
+ MAX (0, row->y))
+ width:w->pixel_width
+ height:row->visible_height];
+ }
+ }
+ return NSZeroRect;
+}
+ +
+@end +@end
+ +

View File

@@ -0,0 +1,587 @@
From dc701c1028f8fa4f043127564de0b3c276021327 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 10:35:35 +0100
Subject: [PATCH 3/6] ns: add buffer notification dispatch and mode-line
element
Add VoiceOver notification methods and mode-line readout.
* src/nsterm.m (EmacsAccessibilityBuffer(Notifications)): New
category.
(postTextChangedNotification:): ValueChanged with edit details.
(postFocusedCursorNotification:direction:granularity:markActive:
oldMarkActive:): Hybrid SelectedTextChanged / AnnouncementRequested
per WebKit pattern. Deduplicates notification storms from
redisplay.
(postCompletionAnnouncementForBuffer:point:): Announce completion
candidates in non-focused buffers.
(postAccessibilityNotificationsForFrame:): Main dispatch entry
point: edit vs cursor-move vs no-change.
(EmacsAccessibilityModeLine): Implement AXStaticText element for
mode-line readout.
Tested on macOS 14. Verified: cursor movement announcements,
region selection feedback, completion popups, mode-line reading.
---
src/nsterm.m | 545 +++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 545 insertions(+)
diff --git a/src/nsterm.m b/src/nsterm.m
index e80c3df..d27e4ed 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -8379,6 +8379,551 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
@end
+
+
+/* ===================================================================
+ EmacsAccessibilityBuffer (Notifications) — AX event dispatch
+
+ These methods notify VoiceOver of text and selection changes.
+ Called from the redisplay cycle (postAccessibilityUpdates).
+ =================================================================== */
+
+@implementation EmacsAccessibilityBuffer (Notifications)
+
+- (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:]");
+ struct window *w = [self validWindow];
+ if (!w || !WINDOW_LEAF_P (w))
+ return;
+
+ struct buffer *b = XBUFFER (w->contents);
+ if (!b)
+ return;
+
+ ptrdiff_t modiff = BUF_MODIFF (b);
+ ptrdiff_t point = BUF_PT (b);
+ BOOL markActive = !NILP (BVAR (b, mark_active));
+
+ /* --- Text changed (edit) --- */
+ if (modiff != self.cachedModiff)
+ {
+ self.cachedModiff = modiff;
+ [self postTextChangedNotification:point];
+ }
+
+ /* --- Cursor moved or selection changed ---
+ Use 'else if' — edits and selection moves are mutually exclusive
+ per the WebKit/Chromium pattern. */
+ else if (point != self.cachedPoint || markActive != self.cachedMarkActive)
+ {
+ ptrdiff_t oldPoint = self.cachedPoint;
+ BOOL oldMarkActive = self.cachedMarkActive;
+ self.cachedPoint = point;
+ self.cachedMarkActive = markActive;
+
+ /* Compute direction. */
+ NSInteger direction = ns_ax_text_selection_direction_discontiguous;
+ if (point > oldPoint)
+ direction = ns_ax_text_selection_direction_next;
+ else if (point < oldPoint)
+ direction = ns_ax_text_selection_direction_previous;
+
+ int ctrlNP = 0;
+ bool isCtrlNP = ns_ax_event_is_line_nav_key (&ctrlNP);
+
+ /* --- Granularity detection --- */
+ NSInteger granularity = ns_ax_text_selection_granularity_unknown;
+ [self ensureTextCache];
+ if (cachedText && oldPoint > 0)
+ {
+ NSUInteger tlen = [cachedText length];
+ NSUInteger oldIdx = [self accessibilityIndexForCharpos:oldPoint];
+ NSUInteger newIdx = [self accessibilityIndexForCharpos:point];
+ if (oldIdx > tlen) oldIdx = tlen;
+ if (newIdx > tlen) newIdx = tlen;
+
+ NSRange oldLine = [cachedText lineRangeForRange:
+ NSMakeRange (oldIdx, 0)];
+ NSRange newLine = [cachedText lineRangeForRange:
+ NSMakeRange (newIdx, 0)];
+ if (oldLine.location != newLine.location)
+ granularity = ns_ax_text_selection_granularity_line;
+ else
+ {
+ NSUInteger dist = (newIdx > oldIdx
+ ? newIdx - oldIdx
+ : oldIdx - newIdx);
+ if (dist > 1)
+ granularity = ns_ax_text_selection_granularity_word;
+ else if (dist == 1)
+ granularity = ns_ax_text_selection_granularity_character;
+ }
+ }
+
+ /* Force line semantics for explicit C-n/C-p / Tab / backtab. */
+ if (isCtrlNP)
+ {
+ direction = (ctrlNP > 0
+ ? ns_ax_text_selection_direction_next
+ : ns_ax_text_selection_direction_previous);
+ granularity = ns_ax_text_selection_granularity_line;
+ }
+
+ /* Post notifications for focused and non-focused elements. */
+ if ([self isAccessibilityFocused])
+ [self postFocusedCursorNotification:point
+ direction:direction
+ granularity:granularity
+ markActive:markActive
+ oldMarkActive:oldMarkActive];
+
+ if (![self isAccessibilityFocused] && cachedText)
+ [self postCompletionAnnouncementForBuffer:b point:point];
+ }
+ else
+ {
+ /* Nothing changed. Reset completion cache for focused buffer
+ to avoid stale announcements. */
+ if ([self isAccessibilityFocused])
+ {
+ self.cachedCompletionAnnouncement = nil;
+ self.cachedCompletionOverlayStart = 0;
+ self.cachedCompletionOverlayEnd = 0;
+ self.cachedCompletionPoint = 0;
+ }
+ }
+}
+
+@end
+
+
+@implementation EmacsAccessibilityModeLine
+
+- (NSAccessibilityRole)accessibilityRole
+{
+ return NSAccessibilityStaticTextRole;
+}
+
+- (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))
+ {
+ struct buffer *b = XBUFFER (w->contents);
+ if (b)
+ {
+ Lisp_Object name = BVAR (b, name);
+ if (STRINGP (name))
+ {
+ NSString *bufName = [NSString stringWithLispString:name];
+ return [NSString stringWithFormat:@"Mode Line - %@", bufName];
+ }
+ }
+ }
+ return @"Mode Line";
+}
+
+- (id)accessibilityValue
+{
+ if (![NSThread isMainThread])
+ {
+ __block id result;
+ dispatch_sync (dispatch_get_main_queue (), ^{
+ result = [self accessibilityValue];
+ });
+ return result;
+ }
+ struct window *w = [self validWindow];
+ if (!w)
+ return @"";
+ return ns_ax_mode_line_text (w);
+}
+
+- (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 || !w->current_matrix)
+ return NSZeroRect;
+
+ /* Find the mode line row and return its screen rect. */
+ struct glyph_matrix *matrix = w->current_matrix;
+ for (int i = 0; i < matrix->nrows; i++)
+ {
+ struct glyph_row *row = matrix->rows + i;
+ if (row->enabled_p && row->mode_line_p)
+ {
+ return [self screenRectFromEmacsX:w->pixel_left
+ y:WINDOW_TO_FRAME_PIXEL_Y (w,
+ MAX (0, row->y))
+ width:w->pixel_width
+ height:row->visible_height];
+ }
+ }
+ return NSZeroRect;
+}
+
+@end
+
#endif /* NS_IMPL_COCOA */
--
2.43.0

View File

@@ -1,29 +1,29 @@
From 70909599d2a8355784c3510652037e5726d02ff8 Mon Sep 17 00:00:00 2001 From 74f5b0e286fd771457a2d9bcb1fa455c6bb4a5e0 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 10:24:31 +0100 Date: Sat, 28 Feb 2026 10:35:35 +0100
Subject: [PATCH 3/5] ns: add interactive span elements for Tab navigation Subject: [PATCH 4/6] ns: add interactive span elements for Tab navigation
* src/nsterm.m (ns_ax_scan_interactive_spans): New function. Scan * src/nsterm.m (ns_ax_scan_interactive_spans): New function. Scan
visible range with O(n/skip) property-skip optimization. Priority: visible range with O(n/skip) property-skip optimization. Priority:
widget > button > follow-link > org-link > completion-candidate > widget > button > follow-link > org-link > completion-candidate >
keymap-overlay. keymap-overlay.
(EmacsAccessibilityInteractiveSpan): Implement AXButton/AXLink (EmacsAccessibilityInteractiveSpan): Implement AXButton/AXLink
elements with AXPress action for buttons and links. elements with AXPress action.
(EmacsAccessibilityBuffer(InteractiveSpans)): New category. (EmacsAccessibilityBuffer(InteractiveSpans)): New category.
accessibilityChildrenInNavigationOrder for Tab/Shift-Tab cycling accessibilityChildrenInNavigationOrder for Tab/Shift-Tab cycling
with wrap-around and VoiceOver focus notification. with wrap-around.
Tested on macOS 14 with VoiceOver. Verified: Tab-cycling through Tested on macOS 14. Verified: Tab-cycling through org-mode links,
org-mode links, *Completions* candidates, widget buttons. *Completions* candidates, widget buttons, customize buffers.
--- ---
src/nsterm.m | 286 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/nsterm.m | 286 +++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 286 insertions(+) 1 file changed, 286 insertions(+)
diff --git a/src/nsterm.m b/src/nsterm.m diff --git a/src/nsterm.m b/src/nsterm.m
index c1cb602..10b63f4 100644 index d27e4ed..bfa1b26 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/src/nsterm.m +++ b/src/nsterm.m
@@ -8910,6 +8910,292 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -8924,6 +8924,292 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
@end @end

View File

@@ -1,7 +1,7 @@
From a846de7dda7051a80f363b5f5db916db7868fde6 Mon Sep 17 00:00:00 2001 From 2de08fd21b94d2eec8271b162a6bbfa94576356c Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 10:24:31 +0100 Date: Sat, 28 Feb 2026 10:35:35 +0100
Subject: [PATCH 4/5] ns: integrate accessibility with EmacsView and redisplay Subject: [PATCH 5/6] ns: integrate accessibility with EmacsView and redisplay
Wire the accessibility infrastructure into EmacsView and the Wire the accessibility infrastructure into EmacsView and the
redisplay cycle. After this patch, VoiceOver and Zoom are active. redisplay cycle. After this patch, VoiceOver and Zoom are active.
@@ -21,18 +21,19 @@ redisplay cycle. After this patch, VoiceOver and Zoom are active.
(EmacsView accessibilityAttributeValue:forParameter:): New method. (EmacsView accessibilityAttributeValue:forParameter:): New method.
* etc/NEWS: Document VoiceOver accessibility support. * etc/NEWS: Document VoiceOver accessibility support.
Tested on macOS 14 with VoiceOver and Zoom. Full end-to-end: buffer Tested on macOS 14 with VoiceOver and Zoom. End-to-end: buffer
navigation, cursor tracking, window switching, completions, evil-mode navigation, cursor tracking, window switching, completions, evil-mode
block cursor, org-mode folded headings, indirect buffers. block cursor, org-mode folded headings, indirect buffers.
Known limitations: Known limitations:
- Bidi: accessibilityRangeForPosition assumes LTR glyph layout. - Bidi: accessibilityRangeForPosition assumes LTR glyph layout.
- Mode-line icons: image/stretch glyphs not extracted. - Mode-line icons: image/stretch glyphs not extracted (TODO).
- Text cap: buffers exceeding NS_AX_TEXT_CAP (100K) truncated. - Text cap: buffers exceeding NS_AX_TEXT_CAP (100K) truncated.
- Non-ASCII: tested with CJK (UTF-16 surrogates) and combining chars.
--- ---
etc/NEWS | 13 ++ etc/NEWS | 13 ++
src/nsterm.m | 397 ++++++++++++++++++++++++++++++++++++++++++++++++++- src/nsterm.m | 398 ++++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 408 insertions(+), 2 deletions(-) 2 files changed, 408 insertions(+), 3 deletions(-)
diff --git a/etc/NEWS b/etc/NEWS diff --git a/etc/NEWS b/etc/NEWS
index 7367e3c..608650e 100644 index 7367e3c..608650e 100644
@@ -59,7 +60,7 @@ index 7367e3c..608650e 100644
** Re-introduced dictation, lost in Emacs v30 (macOS). ** Re-introduced dictation, lost in Emacs v30 (macOS).
We lost macOS dictation in v30 when migrating to NSTextInputClient. We lost macOS dictation in v30 when migrating to NSTextInputClient.
diff --git a/src/nsterm.m b/src/nsterm.m diff --git a/src/nsterm.m b/src/nsterm.m
index 10b63f4..d7ff6c3 100644 index bfa1b26..1780194 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/src/nsterm.m +++ b/src/nsterm.m
@@ -1105,6 +1105,11 @@ ns_update_end (struct frame *f) @@ -1105,6 +1105,11 @@ ns_update_end (struct frame *f)
@@ -126,7 +127,15 @@ index 10b63f4..d7ff6c3 100644
static BOOL static BOOL
ns_ax_find_completion_overlay_range (struct buffer *b, ptrdiff_t point, ns_ax_find_completion_overlay_range (struct buffer *b, ptrdiff_t point,
ptrdiff_t *out_start, ptrdiff_t *out_start,
@@ -8911,7 +8952,6 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -8380,7 +8421,6 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
@end
-
/* ===================================================================
EmacsAccessibilityBuffer (Notifications) — AX event dispatch
@@ -8925,7 +8965,6 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
@end @end
@@ -134,7 +143,7 @@ index 10b63f4..d7ff6c3 100644
/* =================================================================== /* ===================================================================
EmacsAccessibilityInteractiveSpan — helpers and implementation EmacsAccessibilityInteractiveSpan — helpers and implementation
=================================================================== */ =================================================================== */
@@ -9241,6 +9281,7 @@ ns_ax_scan_interactive_spans (struct window *w, @@ -9255,6 +9294,7 @@ ns_ax_scan_interactive_spans (struct window *w,
[layer release]; [layer release];
#endif #endif
@@ -142,7 +151,7 @@ index 10b63f4..d7ff6c3 100644
[[self menu] release]; [[self menu] release];
[super dealloc]; [super dealloc];
} }
@@ -10589,6 +10630,32 @@ ns_in_echo_area (void) @@ -10603,6 +10643,32 @@ ns_in_echo_area (void)
XSETFRAME (event.frame_or_window, emacsframe); XSETFRAME (event.frame_or_window, emacsframe);
kbd_buffer_store_event (&event); kbd_buffer_store_event (&event);
ns_send_appdefined (-1); // Kick main loop ns_send_appdefined (-1); // Kick main loop
@@ -175,7 +184,7 @@ index 10b63f4..d7ff6c3 100644
} }
@@ -11826,6 +11893,332 @@ ns_in_echo_area (void) @@ -11840,6 +11906,332 @@ ns_in_echo_area (void)
return fs_state; return fs_state;
} }

View File

@@ -1,7 +1,7 @@
From 30dd99a0530092614b47ac7b30b19728359475db Mon Sep 17 00:00:00 2001 From e78b534524fec76d257db2c590a87ef61ea4f291 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 10:24:31 +0100 Date: Sat, 28 Feb 2026 10:35:36 +0100
Subject: [PATCH 5/5] doc: add VoiceOver accessibility section to macOS Subject: [PATCH 6/6] doc: add VoiceOver accessibility section to macOS
appendix appendix
* doc/emacs/macos.texi (VoiceOver Accessibility): New node. Document * doc/emacs/macos.texi (VoiceOver Accessibility): New node. Document