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:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user