v10 patch: rich SelectedTextChanged for VoiceOver cursor movement
Key changes from v9: - SelectedTextChanged now includes rich userInfo: direction (Next/Previous/Discontiguous) and granularity (Character/Word/Line) inferred from old vs new cursor position comparison - Track lastAccessibilityCursorPos + lastAccessibilityCursorLine ivars for position delta detection - accessibilityInsertionPointLineNumber: buffer lines (count_lines) instead of visual vpos - accessibilityLineForIndex: buffer lines via count_lines - accessibilityRangeForLine: buffer lines via find_newline_no_quit - Skip notification on unchanged position (avoids VO repeating) - Word granularity heuristic: same line, >1 char delta
This commit is contained in:
@@ -1,54 +1,52 @@
|
||||
From: Martin Sukany <martin@sukany.cz>
|
||||
Date: Tue, 25 Feb 2026 21:00:00 +0100
|
||||
Subject: [PATCH] ns: implement macOS Zoom cursor tracking and VoiceOver
|
||||
support
|
||||
Date: Tue, 25 Feb 2026 21:30:00 +0100
|
||||
Subject: [PATCH] ns: macOS Zoom cursor tracking and VoiceOver support
|
||||
|
||||
Add comprehensive accessibility support for macOS:
|
||||
Comprehensive accessibility for macOS NS port:
|
||||
|
||||
1. UAZoomChangeFocus() from ApplicationServices/UniversalAccess.h:
|
||||
Directly tells macOS Zoom where the cursor is. Only fires for the
|
||||
active window cursor draw (on_p && active_p guard).
|
||||
Ref: https://developer.apple.com/documentation/applicationservices/1458830-uazoomchangefocus
|
||||
1. UAZoomChangeFocus() for Zoom viewport tracking.
|
||||
Only fires for active window cursor (on_p && active_p guard).
|
||||
|
||||
2. NSAccessibility protocol on EmacsView (macOS 10.10+):
|
||||
Full text area protocol for VoiceOver:
|
||||
- Rich typing echo via NSAccessibilityPostNotificationWithUserInfo
|
||||
with kAXTextEditTypeTyping (requires macOS 10.11+, falls back to
|
||||
bare notification on 10.10)
|
||||
- BUF_MODIFF tracking to distinguish content edits from cursor movement
|
||||
- Text content: accessibilityValue, accessibilitySelectedText,
|
||||
accessibilityNumberOfCharacters, accessibilityStringForRange:
|
||||
- Text navigation: accessibilityLineForIndex:, accessibilityRangeForLine:,
|
||||
accessibilityInsertionPointLineNumber, accessibilityVisibleCharacterRange
|
||||
- Cursor geometry: accessibilityBoundsForRange:, accessibilityFrameForRange:
|
||||
- Notifications: ValueChanged (edit), SelectedTextChanged (cursor),
|
||||
FocusedUIElementChanged (window focus)
|
||||
Ref: https://developer.apple.com/documentation/appkit/nsaccessibilityprotocol
|
||||
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.
|
||||
|
||||
Uses raw string literals ("AXTextStateChangeType" etc.) for semi-private
|
||||
notification keys to avoid type conflicts between SDK versions (these
|
||||
symbols were added to public AppKit headers in macOS 26).
|
||||
Uses raw string literals for semi-private AX keys to avoid type
|
||||
conflicts across SDK versions (macOS 26 added them to public headers).
|
||||
---
|
||||
diff --git a/src/nsterm.h b/src/nsterm.h
|
||||
index 7c1ee4c..5e5f61b 100644
|
||||
index 7c1ee4c..7b79170 100644
|
||||
--- a/src/nsterm.h
|
||||
+++ b/src/nsterm.h
|
||||
@@ -485,6 +485,10 @@ enum ns_return_frame_mode
|
||||
@@ -485,6 +485,12 @@ enum ns_return_frame_mode
|
||||
struct frame *emacsframe;
|
||||
int scrollbarsNeedingUpdate;
|
||||
NSRect ns_userRect;
|
||||
+#ifdef NS_IMPL_COCOA
|
||||
+ NSRect lastAccessibilityCursorRect;
|
||||
+ ptrdiff_t lastAccessibilityModiff;
|
||||
+ ptrdiff_t lastAccessibilityCursorPos;
|
||||
+ ptrdiff_t lastAccessibilityCursorLine;
|
||||
+#endif
|
||||
}
|
||||
|
||||
/* AppKit-side interface. */
|
||||
diff --git a/src/nsterm.m b/src/nsterm.m
|
||||
index 932d209..6bfc080 100644
|
||||
index 932d209..265ccb1 100644
|
||||
--- a/src/nsterm.m
|
||||
+++ b/src/nsterm.m
|
||||
@@ -3232,6 +3232,115 @@ Note that CURSOR_WIDTH is meaningful only for (h)bar cursors.
|
||||
@@ -3232,6 +3232,196 @@ 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));
|
||||
|
||||
@@ -137,9 +135,90 @@ index 932d209..6bfc080 100644
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ /* Always notify that cursor position (selection) changed. */
|
||||
+ /* 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.
|
||||
+
|
||||
+ 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 */
|
||||
+ {
|
||||
+ 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);
|
||||
+
|
||||
+ 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
|
||||
+ {
|
||||
+ NSAccessibilityPostNotification (
|
||||
+ view, NSAccessibilitySelectedTextChangedNotification);
|
||||
+ }
|
||||
+
|
||||
+ skip_selection_notification:
|
||||
+ view->lastAccessibilityCursorPos = pt;
|
||||
+ view->lastAccessibilityCursorLine = cur_line;
|
||||
+ }
|
||||
+
|
||||
+ /* Tell macOS Zoom where the cursor is. UAZoomChangeFocus()
|
||||
+ expects top-left origin (CG coordinate space). */
|
||||
@@ -164,7 +243,7 @@ index 932d209..6bfc080 100644
|
||||
ns_focus (f, NULL, 0);
|
||||
|
||||
NSGraphicsContext *ctx = [NSGraphicsContext currentContext];
|
||||
@@ -8237,6 +8346,14 @@ - (void)windowDidBecomeKey /* for direct calls */
|
||||
@@ -8237,6 +8427,14 @@ - (void)windowDidBecomeKey /* for direct calls */
|
||||
XSETFRAME (event.frame_or_window, emacsframe);
|
||||
kbd_buffer_store_event (&event);
|
||||
ns_send_appdefined (-1); // Kick main loop
|
||||
@@ -179,7 +258,7 @@ index 932d209..6bfc080 100644
|
||||
}
|
||||
|
||||
|
||||
@@ -9474,6 +9591,421 @@ - (int) fullscreenState
|
||||
@@ -9474,6 +9672,434 @@ - (int) fullscreenState
|
||||
return fs_state;
|
||||
}
|
||||
|
||||
@@ -333,8 +412,8 @@ index 932d209..6bfc080 100644
|
||||
+
|
||||
+- (NSInteger)accessibilityInsertionPointLineNumber
|
||||
+{
|
||||
+ /* Return the visual line number of the cursor (vpos = line within
|
||||
+ the window). VoiceOver uses this for line navigation. */
|
||||
+ /* Return the buffer line number at point. VoiceOver uses this for
|
||||
+ line navigation and to announce "line N" when moving by line. */
|
||||
+ if (!emacsframe)
|
||||
+ return 0;
|
||||
+
|
||||
@@ -342,7 +421,11 @@ index 932d209..6bfc080 100644
|
||||
+ if (!w)
|
||||
+ return 0;
|
||||
+
|
||||
+ return (NSInteger) (w->cursor.vpos);
|
||||
+ struct buffer *b = XBUFFER (w->contents);
|
||||
+ if (!b)
|
||||
+ return 0;
|
||||
+
|
||||
+ return (NSInteger) count_lines (BUF_BEGV_BYTE (b), BUF_PT_BYTE (b));
|
||||
+}
|
||||
+
|
||||
+- (NSRange)accessibilityVisibleCharacterRange
|
||||
@@ -407,67 +490,76 @@ index 932d209..6bfc080 100644
|
||||
+
|
||||
+- (NSInteger)accessibilityLineForIndex:(NSInteger)index
|
||||
+{
|
||||
+ /* Convert character index to visual line number. Used by VoiceOver
|
||||
+ for line-by-line reading (VO+Up/Down).
|
||||
+
|
||||
+ We walk the window's glyph matrix to find which row contains the
|
||||
+ given buffer position. Falls back to 0 if not found. */
|
||||
+ /* Convert character index to buffer line number. VoiceOver uses
|
||||
+ this for line-by-line reading (VO+Up/Down). Must be consistent
|
||||
+ with accessibilityInsertionPointLineNumber. */
|
||||
+ if (!emacsframe)
|
||||
+ return 0;
|
||||
+
|
||||
+ struct window *w = XWINDOW (emacsframe->selected_window);
|
||||
+ if (!w || !w->current_matrix)
|
||||
+ if (!w)
|
||||
+ return 0;
|
||||
+
|
||||
+ struct buffer *curbuf = XBUFFER (w->contents);
|
||||
+ if (!curbuf)
|
||||
+ struct buffer *b = XBUFFER (w->contents);
|
||||
+ if (!b)
|
||||
+ return 0;
|
||||
+
|
||||
+ ptrdiff_t charpos = BUF_BEGV (curbuf) + (ptrdiff_t) index;
|
||||
+ struct glyph_matrix *matrix = w->current_matrix;
|
||||
+ ptrdiff_t charpos = BUF_BEGV (b) + (ptrdiff_t) index;
|
||||
+ if (charpos > BUF_ZV (b))
|
||||
+ charpos = BUF_ZV (b);
|
||||
+ if (charpos < BUF_BEGV (b))
|
||||
+ charpos = BUF_BEGV (b);
|
||||
+
|
||||
+ for (int i = 0; i < matrix->nrows; i++)
|
||||
+ {
|
||||
+ struct glyph_row *row = matrix->rows + i;
|
||||
+ if (!row->enabled_p)
|
||||
+ continue;
|
||||
+ if (MATRIX_ROW_START_CHARPOS (row) <= charpos
|
||||
+ && charpos < MATRIX_ROW_END_CHARPOS (row))
|
||||
+ return (NSInteger) i;
|
||||
+ }
|
||||
+
|
||||
+ return 0;
|
||||
+ ptrdiff_t bytepos = buf_charpos_to_bytepos (b, charpos);
|
||||
+ return (NSInteger) count_lines (BUF_BEGV_BYTE (b), bytepos);
|
||||
+}
|
||||
+
|
||||
+- (NSRange)accessibilityRangeForLine:(NSInteger)line
|
||||
+{
|
||||
+ /* Return the character range for a visual line. VoiceOver uses
|
||||
+ this to read an entire line when navigating by line. */
|
||||
+ /* Return the character range for buffer line N. VoiceOver uses
|
||||
+ this to read an entire line when navigating by line. Must be
|
||||
+ consistent with accessibilityLineForIndex: (buffer lines). */
|
||||
+ if (!emacsframe)
|
||||
+ return NSMakeRange (0, 0);
|
||||
+
|
||||
+ struct window *w = XWINDOW (emacsframe->selected_window);
|
||||
+ if (!w || !w->current_matrix)
|
||||
+ if (!w)
|
||||
+ return NSMakeRange (0, 0);
|
||||
+
|
||||
+ struct buffer *curbuf = XBUFFER (w->contents);
|
||||
+ if (!curbuf)
|
||||
+ struct buffer *b = XBUFFER (w->contents);
|
||||
+ if (!b)
|
||||
+ return NSMakeRange (0, 0);
|
||||
+
|
||||
+ struct glyph_matrix *matrix = w->current_matrix;
|
||||
+ if (line < 0 || line >= matrix->nrows)
|
||||
+ return NSMakeRange (0, 0);
|
||||
+ /* Walk from buffer start to find line N. Use find_newline_no_quit
|
||||
+ (from, frombyte, count, &bytepos) to avoid signaling. */
|
||||
+ 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 glyph_row *row = matrix->rows + line;
|
||||
+ if (!row->enabled_p)
|
||||
+ return NSMakeRange (0, 0);
|
||||
+ /* Skip N newlines to reach line N. */
|
||||
+ if (line > 0)
|
||||
+ {
|
||||
+ pos = find_newline_no_quit (pos, pos_byte, line, &pos_byte);
|
||||
+ /* find_newline_no_quit returns position AFTER the newline. */
|
||||
+ }
|
||||
+
|
||||
+ ptrdiff_t start = MATRIX_ROW_START_CHARPOS (row) - BUF_BEGV (curbuf);
|
||||
+ ptrdiff_t end = MATRIX_ROW_END_CHARPOS (row) - BUF_BEGV (curbuf);
|
||||
+ if (start < 0) start = 0;
|
||||
+ if (end < start) end = start;
|
||||
+ ptrdiff_t line_start = pos;
|
||||
+
|
||||
+ return NSMakeRange ((NSUInteger) start, (NSUInteger) (end - start));
|
||||
+ /* Find end of this line (next newline or buffer end). */
|
||||
+ ptrdiff_t end_byte = pos_byte;
|
||||
+ ptrdiff_t line_end = find_newline_no_quit (pos, pos_byte, 1, &end_byte);
|
||||
+ if (line_end == 0)
|
||||
+ line_end = zv; /* No newline found — last line. */
|
||||
+
|
||||
+ /* Convert to 0-based accessibility indices. */
|
||||
+ ptrdiff_t start_idx = line_start - begv;
|
||||
+ ptrdiff_t end_idx = line_end - begv;
|
||||
+ if (start_idx < 0) start_idx = 0;
|
||||
+ if (end_idx < start_idx) end_idx = start_idx;
|
||||
+
|
||||
+ return NSMakeRange ((NSUInteger) start_idx,
|
||||
+ (NSUInteger) (end_idx - start_idx));
|
||||
+}
|
||||
+
|
||||
+- (NSRect)accessibilityFrameForRange:(NSRange)range
|
||||
|
||||
Reference in New Issue
Block a user