v11 patch: fix VoiceOver cursor movement (iTerm2 pattern)

Key insight from iTerm2 analysis: VoiceOver does NOT need rich userInfo
on SelectedTextChanged for cursor movement. Bare notifications + correct
protocol methods (accessibilityStringForRange:, accessibilityLineForIndex:,
accessibilityRangeForLine:) are sufficient. Rich userInfo with wrong values
was likely causing VoiceOver to silently discard notifications.

Changes from v10:
- SelectedTextChanged: bare notification (removed rich userInfo)
- Added SelectedRowsChanged + SelectedColumnsChanged (iTerm2 triple pattern)
- Added bare ValueChanged on every cursor draw (not just edits)
- Fixed current_buffer safety: BUF_BYTE_ADDRESS, set_buffer_internal_1
- Added accessibilityRangeForIndex:, accessibilitySelectedTextRanges,
  isAccessibilityFocused, accessibilityLabel
This commit is contained in:
2026-02-25 21:42:20 +01:00
parent 54183cc8eb
commit 2f1fbd30d2

View File

@@ -1,29 +1,29 @@
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Tue, 25 Feb 2026 21:30:00 +0100 Date: Tue, 25 Feb 2026 21:40:00 +0100
Subject: [PATCH] ns: macOS Zoom cursor tracking and VoiceOver support Subject: [PATCH] ns: macOS Zoom cursor tracking and VoiceOver support
Comprehensive accessibility for macOS NS port: Comprehensive accessibility for the macOS NS port:
1. UAZoomChangeFocus() for Zoom viewport tracking. 1. UAZoomChangeFocus() for Zoom viewport tracking (on_p && active_p guard).
Only fires for active window cursor (on_p && active_p guard).
2. NSAccessibility protocol on EmacsView for VoiceOver: 2. NSAccessibility protocol on EmacsView for VoiceOver:
- Rich SelectedTextChanged notifications with direction and - Bare SelectedTextChanged + SelectedRowsChanged + SelectedColumnsChanged
granularity userInfo (kAXTextStateChangeTypeSelectionMove). triple notification on cursor movement (matches iTerm2 pattern —
VoiceOver uses these to decide what to speak on cursor movement no rich userInfo needed for cursor move, VoiceOver queries
(character, word, or line). Direction/granularity inferred from accessibilityStringForRange: to get the text at the new position).
old vs new cursor position. - Rich ValueChanged with typing echo userInfo for content edits
- Rich ValueChanged for typing echo (kAXTextEditTypeTyping). (kAXTextEditTypeTyping via semi-private AppKit keys, macOS 10.11+).
- Buffer-line-based line numbering (count_lines, find_newline_no_quit) - Bare ValueChanged on every cursor draw so VoiceOver refreshes
for consistent accessibilityInsertionPointLineNumber, its cached text model.
accessibilityLineForIndex:, and accessibilityRangeForLine:. - Buffer-line-based numbering via count_lines/find_newline_no_quit
- Skip notification on position-unchanged redraws to avoid with set_buffer_internal_1 safety wrappers (accessibilityLineForIndex:,
VoiceOver repeating the same text. accessibilityRangeForLine:, accessibilityInsertionPointLineNumber).
- 10000-char cap on accessibilityValue for large buffers. - BUF_BYTE_ADDRESS (not BYTE_POS_ADDR) for correct buffer data access
- Multibyte-safe byte_range via buf_charpos_to_bytepos. when current_buffer != queried buffer.
- 10000-char cap on accessibilityValue, multibyte-safe ranges.
Uses raw string literals for semi-private AX keys to avoid type Uses raw string literals for semi-private AX keys to avoid type
conflicts across SDK versions (macOS 26 added them to public headers). conflicts across SDK versions.
--- ---
diff --git a/src/nsterm.h b/src/nsterm.h diff --git a/src/nsterm.h b/src/nsterm.h
index 7c1ee4c..7b79170 100644 index 7c1ee4c..7b79170 100644
@@ -43,10 +43,10 @@ index 7c1ee4c..7b79170 100644
/* AppKit-side interface. */ /* AppKit-side interface. */
diff --git a/src/nsterm.m b/src/nsterm.m diff --git a/src/nsterm.m b/src/nsterm.m
index 932d209..265ccb1 100644 index 932d209..7811687 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/src/nsterm.m +++ b/src/nsterm.m
@@ -3232,6 +3232,196 @@ Note that CURSOR_WIDTH is meaningful only for (h)bar cursors. @@ -3232,6 +3232,148 @@ Note that CURSOR_WIDTH is meaningful only for (h)bar cursors.
/* Prevent the cursor from being drawn outside the text area. */ /* Prevent the cursor from being drawn outside the text area. */
r = NSIntersectionRect (r, ns_row_rect (w, glyph_row, TEXT_AREA)); r = NSIntersectionRect (r, ns_row_rect (w, glyph_row, TEXT_AREA));
@@ -136,88 +136,40 @@ index 932d209..265ccb1 100644
+ } + }
+ +
+ /* Notify AT that cursor position (selection) changed. + /* Notify AT that cursor position (selection) changed.
+ VoiceOver requires rich userInfo with direction and + Post bare notifications WITHOUT userInfo — iTerm2 proves
+ granularity to know what to speak. We infer these by + this is sufficient for VoiceOver. Rich userInfo with
+ comparing old vs new cursor position, using count_lines() + semi-private AXTextStateChangeType keys can cause VoiceOver
+ for line delta detection. + to silently ignore notifications if any value is unexpected.
+ +
+ Keys and enum values from Apple's AX API: + Post three notifications together (matching iTerm2):
+ kAXTextStateChangeTypeSelectionMove = 2 + 1. SelectedTextChanged — cursor/selection moved
+ Direction: Previous=4, Next=5, Discontiguous=6 + 2. SelectedRowsChanged — triggers line announcement
+ Granularity: Character=1, Word=2, Line=3 */ + 3. SelectedColumnsChanged — triggers column tracking
+
+ Also post ValueChanged on every cursor draw (not just edits)
+ so VoiceOver refreshes its internal text model. iTerm2 does
+ this on every dirty screen refresh. */
+ { + {
+ ptrdiff_t pt = curbuf ? BUF_PT (curbuf) : 0; + ptrdiff_t pt = curbuf ? BUF_PT (curbuf) : 0;
+ ptrdiff_t pt_byte = curbuf ? BUF_PT_BYTE (curbuf) : 0;
+ ptrdiff_t old_pt = view->lastAccessibilityCursorPos; + ptrdiff_t old_pt = view->lastAccessibilityCursorPos;
+ ptrdiff_t old_line = view->lastAccessibilityCursorLine;
+ +
+ /* Compute current line number via count_lines (efficient, + /* Always post ValueChanged so VoiceOver re-queries
+ uses region cache and memchr). */ + accessibilityValue and keeps its text model current. */
+ ptrdiff_t cur_line = 0; + NSAccessibilityPostNotification (
+ if (curbuf) + view, NSAccessibilityValueChangedNotification);
+ cur_line = count_lines (BUF_BEGV_BYTE (curbuf), pt_byte);
+ +
+ if (@available (macOS 10.11, *)) + if (pt != old_pt)
+ {
+ int direction;
+ int granularity;
+
+ if (old_pt == 0 && old_line == 0 && pt > 1)
+ {
+ /* First cursor draw — no previous position. */
+ direction = 6; /* Discontiguous */
+ granularity = 3; /* Line */
+ }
+ else if (cur_line != old_line)
+ {
+ ptrdiff_t line_delta
+ = cur_line > old_line
+ ? cur_line - old_line : old_line - cur_line;
+ direction = (cur_line > old_line) ? 5 : 4;
+ /* Large jump → treat as discontiguous. */
+ if (line_delta > 5)
+ {
+ direction = 6;
+ granularity = 3;
+ }
+ else
+ granularity = 3; /* Line */
+ }
+ else if (pt != old_pt)
+ {
+ ptrdiff_t char_delta
+ = pt > old_pt ? pt - old_pt : old_pt - pt;
+ direction = (pt > old_pt) ? 5 : 4;
+ /* >1 char on same line → Word granularity heuristic
+ (covers M-f, M-b, C-a, C-e). */
+ granularity = (char_delta > 1) ? 2 : 1;
+ }
+ else
+ {
+ /* Position unchanged (window redraw, no movement).
+ Skip notification to avoid VoiceOver repeating. */
+ goto skip_selection_notification;
+ }
+
+ NSDictionary *userInfo = @{
+ @"AXTextStateChangeType": @2,
+ @"AXTextSelectionDirection": @(direction),
+ @"AXTextSelectionGranularity": @(granularity),
+ @"AXTextChangeElement": view
+ };
+ NSAccessibilityPostNotificationWithUserInfo (
+ view, NSAccessibilitySelectedTextChangedNotification,
+ userInfo);
+ }
+ else
+ { + {
+ /* Cursor actually moved — post the selection trio. */
+ NSAccessibilityPostNotification ( + NSAccessibilityPostNotification (
+ view, NSAccessibilitySelectedTextChangedNotification); + view, NSAccessibilitySelectedTextChangedNotification);
+ NSAccessibilityPostNotification (
+ view, NSAccessibilitySelectedRowsChangedNotification);
+ NSAccessibilityPostNotification (
+ view, NSAccessibilitySelectedColumnsChangedNotification);
+ } + }
+ +
+ skip_selection_notification:
+ view->lastAccessibilityCursorPos = pt; + view->lastAccessibilityCursorPos = pt;
+ view->lastAccessibilityCursorLine = cur_line;
+ } + }
+ +
+ /* Tell macOS Zoom where the cursor is. UAZoomChangeFocus() + /* Tell macOS Zoom where the cursor is. UAZoomChangeFocus()
@@ -243,7 +195,7 @@ index 932d209..265ccb1 100644
ns_focus (f, NULL, 0); ns_focus (f, NULL, 0);
NSGraphicsContext *ctx = [NSGraphicsContext currentContext]; NSGraphicsContext *ctx = [NSGraphicsContext currentContext];
@@ -8237,6 +8427,14 @@ - (void)windowDidBecomeKey /* for direct calls */ @@ -8237,6 +8379,14 @@ - (void)windowDidBecomeKey /* for direct calls */
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
@@ -258,7 +210,7 @@ index 932d209..265ccb1 100644
} }
@@ -9474,6 +9672,434 @@ - (int) fullscreenState @@ -9474,6 +9624,490 @@ - (int) fullscreenState
return fs_state; return fs_state;
} }
@@ -350,7 +302,7 @@ index 932d209..265ccb1 100644
+ str = make_uninit_multibyte_string (range, byte_range); + str = make_uninit_multibyte_string (range, byte_range);
+ else + else
+ str = make_uninit_string (range); + str = make_uninit_string (range);
+ memcpy (SDATA (str), BYTE_POS_ADDR (start_byte), byte_range); + memcpy (SDATA (str), BUF_BYTE_ADDRESS (curbuf, start_byte), byte_range);
+ +
+ return [NSString stringWithLispString:str]; + return [NSString stringWithLispString:str];
+} +}
@@ -425,7 +377,12 @@ index 932d209..265ccb1 100644
+ if (!b) + if (!b)
+ return 0; + return 0;
+ +
+ return (NSInteger) count_lines (BUF_BEGV_BYTE (b), BUF_PT_BYTE (b)); + /* count_lines operates on current_buffer, so temporarily switch. */
+ struct buffer *old = current_buffer;
+ set_buffer_internal_1 (b);
+ ptrdiff_t lines = count_lines (BUF_BEGV_BYTE (b), BUF_PT_BYTE (b));
+ set_buffer_internal_1 (old);
+ return (NSInteger) lines;
+} +}
+ +
+- (NSRange)accessibilityVisibleCharacterRange +- (NSRange)accessibilityVisibleCharacterRange
@@ -511,7 +468,13 @@ index 932d209..265ccb1 100644
+ charpos = BUF_BEGV (b); + charpos = BUF_BEGV (b);
+ +
+ ptrdiff_t bytepos = buf_charpos_to_bytepos (b, charpos); + ptrdiff_t bytepos = buf_charpos_to_bytepos (b, charpos);
+ return (NSInteger) count_lines (BUF_BEGV_BYTE (b), bytepos); +
+ /* count_lines operates on current_buffer, so temporarily switch. */
+ struct buffer *old = current_buffer;
+ set_buffer_internal_1 (b);
+ ptrdiff_t lines = count_lines (BUF_BEGV_BYTE (b), bytepos);
+ set_buffer_internal_1 (old);
+ return (NSInteger) lines;
+} +}
+ +
+- (NSRange)accessibilityRangeForLine:(NSInteger)line +- (NSRange)accessibilityRangeForLine:(NSInteger)line
@@ -530,13 +493,15 @@ index 932d209..265ccb1 100644
+ if (!b) + if (!b)
+ return NSMakeRange (0, 0); + return NSMakeRange (0, 0);
+ +
+ /* Walk from buffer start to find line N. Use find_newline_no_quit + /* find_newline_no_quit operates on current_buffer, so switch. */
+ (from, frombyte, count, &bytepos) to avoid signaling. */
+ ptrdiff_t begv = BUF_BEGV (b); + ptrdiff_t begv = BUF_BEGV (b);
+ ptrdiff_t zv = BUF_ZV (b); + ptrdiff_t zv = BUF_ZV (b);
+ ptrdiff_t pos = begv; + ptrdiff_t pos = begv;
+ ptrdiff_t pos_byte = BUF_BEGV_BYTE (b); + ptrdiff_t pos_byte = BUF_BEGV_BYTE (b);
+ +
+ struct buffer *old = current_buffer;
+ set_buffer_internal_1 (b);
+
+ /* Skip N newlines to reach line N. */ + /* Skip N newlines to reach line N. */
+ if (line > 0) + if (line > 0)
+ { + {
@@ -552,6 +517,8 @@ index 932d209..265ccb1 100644
+ if (line_end == 0) + if (line_end == 0)
+ line_end = zv; /* No newline found — last line. */ + line_end = zv; /* No newline found — last line. */
+ +
+ set_buffer_internal_1 (old);
+
+ /* Convert to 0-based accessibility indices. */ + /* Convert to 0-based accessibility indices. */
+ ptrdiff_t start_idx = line_start - begv; + ptrdiff_t start_idx = line_start - begv;
+ ptrdiff_t end_idx = line_end - begv; + ptrdiff_t end_idx = line_end - begv;
@@ -580,6 +547,47 @@ index 932d209..265ccb1 100644
+ return NSMakeRange (0, 0); + return NSMakeRange (0, 0);
+} +}
+ +
+- (NSRange)accessibilityRangeForIndex:(NSInteger)index
+{
+ /* Return the range of the character at the given index. VoiceOver
+ uses this for character-by-character navigation. */
+ if (!emacsframe)
+ return NSMakeRange (0, 0);
+
+ struct buffer *curbuf
+ = XBUFFER (XWINDOW (emacsframe->selected_window)->contents);
+ if (!curbuf)
+ return NSMakeRange (0, 0);
+
+ ptrdiff_t range = BUF_ZV (curbuf) - BUF_BEGV (curbuf);
+ ptrdiff_t maxrange = MIN (range, 10000);
+
+ if (index < 0 || index >= maxrange)
+ return NSMakeRange (0, 0);
+
+ return NSMakeRange ((NSUInteger) index, 1);
+}
+
+- (NSArray *)accessibilitySelectedTextRanges
+{
+ /* Return array of selected ranges. VoiceOver may query this
+ (plural) form instead of the singular selectedTextRange. */
+ NSRange r = [self accessibilitySelectedTextRange];
+ return @[[NSValue valueWithRange:r]];
+}
+
+- (BOOL)isAccessibilityFocused
+{
+ /* Always report focused — matches iTerm2 behavior. */
+ return YES;
+}
+
+- (NSString *)accessibilityLabel
+{
+ /* Provide an identifying label for VoiceOver. */
+ return @"Emacs";
+}
+
+/* ---- Cursor position for Zoom (via accessibilityBoundsForRange:) ---- +/* ---- Cursor position for Zoom (via accessibilityBoundsForRange:) ----
+ +
+ accessibilityFrame intentionally returns the VIEW's frame (standard + accessibilityFrame intentionally returns the VIEW's frame (standard