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>
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
Comprehensive accessibility for macOS NS port:
Comprehensive accessibility for the macOS NS port:
1. UAZoomChangeFocus() for Zoom viewport tracking.
Only fires for active window cursor (on_p && active_p guard).
1. UAZoomChangeFocus() for Zoom viewport tracking (on_p && active_p guard).
2. NSAccessibility protocol on EmacsView for VoiceOver:
- Rich SelectedTextChanged notifications with direction and
granularity userInfo (kAXTextStateChangeTypeSelectionMove).
VoiceOver uses these to decide what to speak on cursor movement
(character, word, or line). Direction/granularity inferred from
old vs new cursor position.
- Rich ValueChanged for typing echo (kAXTextEditTypeTyping).
- Buffer-line-based line numbering (count_lines, find_newline_no_quit)
for consistent accessibilityInsertionPointLineNumber,
accessibilityLineForIndex:, and accessibilityRangeForLine:.
- Skip notification on position-unchanged redraws to avoid
VoiceOver repeating the same text.
- 10000-char cap on accessibilityValue for large buffers.
- Multibyte-safe byte_range via buf_charpos_to_bytepos.
- Bare SelectedTextChanged + SelectedRowsChanged + SelectedColumnsChanged
triple notification on cursor movement (matches iTerm2 pattern —
no rich userInfo needed for cursor move, VoiceOver queries
accessibilityStringForRange: to get the text at the new position).
- Rich ValueChanged with typing echo userInfo for content edits
(kAXTextEditTypeTyping via semi-private AppKit keys, macOS 10.11+).
- Bare ValueChanged on every cursor draw so VoiceOver refreshes
its cached text model.
- Buffer-line-based numbering via count_lines/find_newline_no_quit
with set_buffer_internal_1 safety wrappers (accessibilityLineForIndex:,
accessibilityRangeForLine:, accessibilityInsertionPointLineNumber).
- BUF_BYTE_ADDRESS (not BYTE_POS_ADDR) for correct buffer data access
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
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
index 7c1ee4c..7b79170 100644
@@ -43,10 +43,10 @@ index 7c1ee4c..7b79170 100644
/* AppKit-side interface. */
diff --git a/src/nsterm.m b/src/nsterm.m
index 932d209..265ccb1 100644
index 932d209..7811687 100644
--- a/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. */
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.
+ VoiceOver requires rich userInfo with direction and
+ granularity to know what to speak. We infer these by
+ comparing old vs new cursor position, using count_lines()
+ for line delta detection.
+ Post bare notifications WITHOUT userInfo — iTerm2 proves
+ this is sufficient for VoiceOver. Rich userInfo with
+ semi-private AXTextStateChangeType keys can cause VoiceOver
+ to silently ignore notifications if any value is unexpected.
+
+ Keys and enum values from Apple's AX API:
+ kAXTextStateChangeTypeSelectionMove = 2
+ Direction: Previous=4, Next=5, Discontiguous=6
+ Granularity: Character=1, Word=2, Line=3 */
+ Post three notifications together (matching iTerm2):
+ 1. SelectedTextChanged — cursor/selection moved
+ 2. SelectedRowsChanged — triggers line announcement
+ 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_byte = curbuf ? BUF_PT_BYTE (curbuf) : 0;
+ ptrdiff_t old_pt = view->lastAccessibilityCursorPos;
+ ptrdiff_t old_line = view->lastAccessibilityCursorLine;
+
+ /* Compute current line number via count_lines (efficient,
+ uses region cache and memchr). */
+ ptrdiff_t cur_line = 0;
+ if (curbuf)
+ cur_line = count_lines (BUF_BEGV_BYTE (curbuf), pt_byte);
+ /* Always post ValueChanged so VoiceOver re-queries
+ accessibilityValue and keeps its text model current. */
+ NSAccessibilityPostNotification (
+ view, NSAccessibilityValueChangedNotification);
+
+ if (@available (macOS 10.11, *))
+ {
+ 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
+ if (pt != old_pt)
+ {
+ /* Cursor actually moved — post the selection trio. */
+ NSAccessibilityPostNotification (
+ view, NSAccessibilitySelectedTextChangedNotification);
+ NSAccessibilityPostNotification (
+ view, NSAccessibilitySelectedRowsChangedNotification);
+ NSAccessibilityPostNotification (
+ view, NSAccessibilitySelectedColumnsChangedNotification);
+ }
+
+ skip_selection_notification:
+ view->lastAccessibilityCursorPos = pt;
+ view->lastAccessibilityCursorLine = cur_line;
+ }
+
+ /* Tell macOS Zoom where the cursor is. UAZoomChangeFocus()
@@ -243,7 +195,7 @@ index 932d209..265ccb1 100644
ns_focus (f, NULL, 0);
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);
kbd_buffer_store_event (&event);
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;
}
@@ -350,7 +302,7 @@ index 932d209..265ccb1 100644
+ str = make_uninit_multibyte_string (range, byte_range);
+ else
+ 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];
+}
@@ -425,7 +377,12 @@ index 932d209..265ccb1 100644
+ if (!b)
+ 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
@@ -511,7 +468,13 @@ index 932d209..265ccb1 100644
+ charpos = BUF_BEGV (b);
+
+ 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
@@ -530,13 +493,15 @@ index 932d209..265ccb1 100644
+ if (!b)
+ return NSMakeRange (0, 0);
+
+ /* Walk from buffer start to find line N. Use find_newline_no_quit
+ (from, frombyte, count, &bytepos) to avoid signaling. */
+ /* find_newline_no_quit operates on current_buffer, so switch. */
+ ptrdiff_t begv = BUF_BEGV (b);
+ ptrdiff_t zv = BUF_ZV (b);
+ ptrdiff_t pos = begv;
+ 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. */
+ if (line > 0)
+ {
@@ -552,6 +517,8 @@ index 932d209..265ccb1 100644
+ if (line_end == 0)
+ line_end = zv; /* No newline found — last line. */
+
+ set_buffer_internal_1 (old);
+
+ /* Convert to 0-based accessibility indices. */
+ ptrdiff_t start_idx = line_start - begv;
+ ptrdiff_t end_idx = line_end - begv;
@@ -580,6 +547,47 @@ index 932d209..265ccb1 100644
+ 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:) ----
+
+ accessibilityFrame intentionally returns the VIEW's frame (standard