- 0001: remove NS_AX_TEXT_CAP (100K char cap), add lineStartOffsets/ lineCount ivars and method declarations to nsterm.h - 0002: add lineForAXIndex:/rangeForLine: O(log L) helpers, build line index in ensureTextCache, replace O(L) line scanning in accessibilityInsertionPointLineNumber/accessibilityLineForIndex/ accessibilityRangeForLine, free index in invalidateTextCache/dealloc - 0009 deleted (folded into 0001+0002) - README.txt: remove NS_AX_TEXT_CAP references, update known limitations, stress test threshold 50K lines
1391 lines
41 KiB
Diff
1391 lines
41 KiB
Diff
From 6f2e1b097c2ed1d2f45e99cf85792a1b28556202 Mon Sep 17 00:00:00 2001
|
|
From: Martin Sukany <martin@sukany.cz>
|
|
Date: Sat, 28 Feb 2026 12:58:11 +0100
|
|
Subject: [PATCH 2/8] 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.
|
|
(ns_ax_event_is_line_nav_key): New function.
|
|
(ns_ax_completion_text_for_span): New function.
|
|
(EmacsAccessibilityBuffer): Implement core NSAccessibility protocol:
|
|
text cache with @synchronized, visible-run binary search O(log n),
|
|
selectedTextRange, lineForIndex/indexForLine, frameForRange,
|
|
rangeForPosition, setAccessibilitySelectedTextRange,
|
|
setAccessibilityFocused.
|
|
|
|
Tested on macOS 14 with VoiceOver. Verified: buffer reading,
|
|
line-by-line navigation, word/character announcements.
|
|
---
|
|
src/nsterm.m | 1346 ++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
1 file changed, 1346 insertions(+)
|
|
|
|
diff --git a/src/nsterm.m b/src/nsterm.m
|
|
index 2ac1d9d..fc5906a 100644
|
|
--- a/src/nsterm.m
|
|
+++ b/src/nsterm.m
|
|
@@ -6867,6 +6867,256 @@ ns_create_font_panel_buttons (id target, SEL select, SEL cancel_action)
|
|
|
|
/* ---- Helper: extract buffer text for accessibility ---- */
|
|
|
|
+/* Return true if FACE is or contains a face symbol whose name
|
|
+ includes "current" or "selected", indicating a highlighted
|
|
+ completion candidate. Works for vertico-current,
|
|
+ icomplete-selected-match, ivy-current-match, etc. */
|
|
+static bool
|
|
+ns_ax_face_is_selected (Lisp_Object face)
|
|
+{
|
|
+ if (SYMBOLP (face) && !NILP (face))
|
|
+ {
|
|
+ const char *name = SSDATA (SYMBOL_NAME (face));
|
|
+ /* Substring match is intentionally broad --- it catches
|
|
+ vertico-current, icomplete-selected-match, ivy-current-match,
|
|
+ company-tooltip-selection, and similar. False positives are
|
|
+ harmless since this runs only on overlay strings during
|
|
+ completion. */
|
|
+ if (strstr (name, "current") || strstr (name, "selected")
|
|
+ || strstr (name, "selection"))
|
|
+ return true;
|
|
+ }
|
|
+ if (CONSP (face))
|
|
+ {
|
|
+ for (Lisp_Object tail = face; CONSP (tail); tail = XCDR (tail))
|
|
+ if (ns_ax_face_is_selected (XCAR (tail)))
|
|
+ return true;
|
|
+ }
|
|
+ return false;
|
|
+}
|
|
+
|
|
+/* Extract the currently selected candidate text from overlay display
|
|
+ strings. Completion frameworks render candidates as overlay
|
|
+ before-string/after-string and highlight the current candidate
|
|
+ with a face whose name contains "current" or "selected"
|
|
+ (e.g. vertico-current, icomplete-selected-match, ivy-current-match).
|
|
+
|
|
+ Scan all overlays in the buffer region [BEG, END), find the line
|
|
+ whose face matches the selection heuristic, and return it (already
|
|
+ trimmed of surrounding whitespace).
|
|
+
|
|
+ Also set *OUT_LINE_INDEX to the 0-based visual line index of the
|
|
+ selected candidate (for Zoom positioning), counting only non-trivial
|
|
+ lines. Set to -1 if not found.
|
|
+
|
|
+ Returns nil if no selected candidate is found. */
|
|
+static NSString *
|
|
+ns_ax_selected_overlay_text (struct buffer *b,
|
|
+ ptrdiff_t beg, ptrdiff_t end,
|
|
+ int *out_line_index)
|
|
+{
|
|
+ *out_line_index = -1;
|
|
+
|
|
+ Lisp_Object ov_list = Foverlays_in (make_fixnum (beg),
|
|
+ make_fixnum (end));
|
|
+
|
|
+ for (Lisp_Object tail = ov_list; CONSP (tail); tail = XCDR (tail))
|
|
+ {
|
|
+ Lisp_Object ov = XCAR (tail);
|
|
+ Lisp_Object strings[2];
|
|
+ strings[0] = Foverlay_get (ov, intern_c_string ("before-string"));
|
|
+ strings[1] = Foverlay_get (ov, intern_c_string ("after-string"));
|
|
+
|
|
+ for (int s = 0; s < 2; s++)
|
|
+ {
|
|
+ if (!STRINGP (strings[s]))
|
|
+ continue;
|
|
+
|
|
+ Lisp_Object str = strings[s];
|
|
+ ptrdiff_t slen = SCHARS (str);
|
|
+ if (slen == 0)
|
|
+ continue;
|
|
+
|
|
+ /* Scan for newline positions using SDATA for efficiency.
|
|
+ The data pointer is used only in this loop, before any
|
|
+ Lisp calls (Fget_text_property etc.) that could trigger
|
|
+ GC and relocate string data. */
|
|
+ const unsigned char *data = SDATA (str);
|
|
+ ptrdiff_t byte_len = SBYTES (str);
|
|
+ /* 512 lines is sufficient for any completion UI;
|
|
+ vertico-count defaults to 10. */
|
|
+ ptrdiff_t line_starts[512];
|
|
+ ptrdiff_t line_ends[512];
|
|
+ int nlines = 0;
|
|
+ ptrdiff_t char_pos = 0, byte_pos = 0, lstart = 0;
|
|
+
|
|
+ while (byte_pos < byte_len && nlines < 512)
|
|
+ {
|
|
+ if (data[byte_pos] == '\n')
|
|
+ {
|
|
+ if (char_pos > lstart)
|
|
+ {
|
|
+ line_starts[nlines] = lstart;
|
|
+ line_ends[nlines] = char_pos;
|
|
+ nlines++;
|
|
+ }
|
|
+ lstart = char_pos + 1;
|
|
+ }
|
|
+ if (STRING_MULTIBYTE (str))
|
|
+ byte_pos += BYTES_BY_CHAR_HEAD (data[byte_pos]);
|
|
+ else
|
|
+ byte_pos++;
|
|
+ char_pos++;
|
|
+ }
|
|
+ if (char_pos > lstart && nlines < 512)
|
|
+ {
|
|
+ line_starts[nlines] = lstart;
|
|
+ line_ends[nlines] = char_pos;
|
|
+ nlines++;
|
|
+ }
|
|
+
|
|
+ /* Find the line whose face indicates selection. Track
|
|
+ visual line index for Zoom (skip whitespace-only lines
|
|
+ like Vertico's leading cursor-space). */
|
|
+ int candidate_idx = 0;
|
|
+ for (int li = 0; li < nlines; li++)
|
|
+ {
|
|
+ Lisp_Object face
|
|
+ = Fget_text_property (make_fixnum (line_starts[li]),
|
|
+ Qface, str);
|
|
+ if (ns_ax_face_is_selected (face))
|
|
+ {
|
|
+ Lisp_Object line
|
|
+ = Fsubstring_no_properties (
|
|
+ str,
|
|
+ make_fixnum (line_starts[li]),
|
|
+ make_fixnum (line_ends[li]));
|
|
+ NSString *text = [NSString stringWithLispString:line];
|
|
+ text = [text stringByTrimmingCharactersInSet:
|
|
+ [NSCharacterSet
|
|
+ whitespaceAndNewlineCharacterSet]];
|
|
+ if ([text length] > 0)
|
|
+ {
|
|
+ *out_line_index = candidate_idx;
|
|
+ return text;
|
|
+ }
|
|
+ }
|
|
+
|
|
+ /* Count non-trivial lines as candidates for Zoom. */
|
|
+ if (line_ends[li] - line_starts[li] > 1)
|
|
+ candidate_idx++;
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+
|
|
+ return nil;
|
|
+}
|
|
+
|
|
+
|
|
+/* Scan buffer text of a child frame for the selected completion
|
|
+ candidate. Used for frameworks that render candidates in a
|
|
+ child frame (e.g. Corfu, Company-box) rather than as overlay
|
|
+ strings. Check the effective face (text properties + overlays)
|
|
+ at the start of each line via Fget_char_property.
|
|
+
|
|
+ Returns the candidate text (trimmed) or nil. Sets
|
|
+ *OUT_LINE_INDEX to the 0-based line index for Zoom. */
|
|
+static NSString *
|
|
+ns_ax_selected_child_frame_text (struct buffer *b, Lisp_Object buf_obj,
|
|
+ int *out_line_index)
|
|
+{
|
|
+ *out_line_index = -1;
|
|
+ ptrdiff_t beg = BUF_BEGV (b);
|
|
+ ptrdiff_t end = BUF_ZV (b);
|
|
+
|
|
+ if (beg >= end)
|
|
+ return nil;
|
|
+
|
|
+ /* Temporarily switch to the child frame buffer.
|
|
+ Fbuffer_substring_no_properties operates on current_buffer,
|
|
+ which may be a different buffer (e.g., the parent frame's). */
|
|
+ specpdl_ref count = SPECPDL_INDEX ();
|
|
+ record_unwind_current_buffer ();
|
|
+ set_buffer_internal_1 (b);
|
|
+
|
|
+ /* Get buffer text as a Lisp string for efficient scanning.
|
|
+ The buffer is a small completion popup (typically < 20 lines). */
|
|
+ Lisp_Object str
|
|
+ = Fbuffer_substring_no_properties (make_fixnum (beg),
|
|
+ make_fixnum (end));
|
|
+ if (!STRINGP (str) || SCHARS (str) == 0)
|
|
+ {
|
|
+ unbind_to (count, Qnil);
|
|
+ return nil;
|
|
+ }
|
|
+
|
|
+ /* Scan newlines (same pattern as ns_ax_selected_overlay_text).
|
|
+ The data pointer is used only in this loop, before Lisp calls. */
|
|
+ const unsigned char *data = SDATA (str);
|
|
+ ptrdiff_t byte_len = SBYTES (str);
|
|
+ ptrdiff_t line_starts[128];
|
|
+ ptrdiff_t line_ends[128];
|
|
+ int nlines = 0;
|
|
+ ptrdiff_t char_pos = 0, byte_pos = 0, lstart = 0;
|
|
+
|
|
+ while (byte_pos < byte_len && nlines < 128)
|
|
+ {
|
|
+ if (data[byte_pos] == '\n')
|
|
+ {
|
|
+ if (char_pos > lstart)
|
|
+ {
|
|
+ line_starts[nlines] = lstart;
|
|
+ line_ends[nlines] = char_pos;
|
|
+ nlines++;
|
|
+ }
|
|
+ lstart = char_pos + 1;
|
|
+ }
|
|
+ if (STRING_MULTIBYTE (str))
|
|
+ byte_pos += BYTES_BY_CHAR_HEAD (data[byte_pos]);
|
|
+ else
|
|
+ byte_pos++;
|
|
+ char_pos++;
|
|
+ }
|
|
+ if (char_pos > lstart && nlines < 128)
|
|
+ {
|
|
+ line_starts[nlines] = lstart;
|
|
+ line_ends[nlines] = char_pos;
|
|
+ nlines++;
|
|
+ }
|
|
+
|
|
+ /* Find the line with a selected face. Use Fget_char_property on
|
|
+ the BUFFER (not the string) so overlay faces are included.
|
|
+ Offset string positions by beg to get buffer positions. */
|
|
+ for (int li = 0; li < nlines; li++)
|
|
+ {
|
|
+ ptrdiff_t buf_pos = beg + line_starts[li];
|
|
+ Lisp_Object face
|
|
+ = Fget_char_property (make_fixnum (buf_pos), Qface, buf_obj);
|
|
+
|
|
+ if (ns_ax_face_is_selected (face))
|
|
+ {
|
|
+ Lisp_Object line
|
|
+ = Fsubstring_no_properties (str,
|
|
+ make_fixnum (line_starts[li]),
|
|
+ make_fixnum (line_ends[li]));
|
|
+ NSString *text = [NSString stringWithLispString:line];
|
|
+ text = [text stringByTrimmingCharactersInSet:
|
|
+ [NSCharacterSet
|
|
+ whitespaceAndNewlineCharacterSet]];
|
|
+ if ([text length] > 0)
|
|
+ {
|
|
+ *out_line_index = li;
|
|
+ unbind_to (count, Qnil);
|
|
+ return text;
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+
|
|
+ unbind_to (count, Qnil);
|
|
+ return nil;
|
|
+}
|
|
+
|
|
+
|
|
/* Build accessibility text for window W, skipping invisible text.
|
|
Populates *OUT_START with the buffer start charpos.
|
|
Populates *OUT_RUNS with an array of visible runs and *OUT_NRUNS
|
|
@@ -7278,6 +7528,1102 @@ ns_ax_post_notification_with_info (id element,
|
|
|
|
@end
|
|
|
|
+
|
|
+
|
|
+
|
|
+
|
|
+static BOOL
|
|
+ns_ax_find_completion_overlay_range (struct buffer *b, ptrdiff_t point,
|
|
+ ptrdiff_t *out_start,
|
|
+ ptrdiff_t *out_end)
|
|
+{
|
|
+ if (!b || !out_start || !out_end)
|
|
+ return NO;
|
|
+
|
|
+ Lisp_Object faceSym = Qns_ax_completions_highlight;
|
|
+ ptrdiff_t begv = BUF_BEGV (b);
|
|
+ ptrdiff_t zv = BUF_ZV (b);
|
|
+ ptrdiff_t best_start = 0;
|
|
+ ptrdiff_t best_end = 0;
|
|
+ ptrdiff_t best_dist = PTRDIFF_MAX;
|
|
+ BOOL found = NO;
|
|
+
|
|
+ /* Fast path: look at point and immediate neighbors first.
|
|
+ Prefer point+1 over point-1: when Tab moves to a new completion,
|
|
+ point is at the START of the new entry while point-1 is still
|
|
+ inside the previous entry's overlay. Forward probe finds the
|
|
+ correct new entry; backward probe finds the wrong old one. */
|
|
+ ptrdiff_t probes[3] = { point, point + 1, point - 1 };
|
|
+ for (int i = 0; i < 3 && !found; i++)
|
|
+ {
|
|
+ ptrdiff_t p = probes[i];
|
|
+ if (p < begv || p > zv)
|
|
+ continue;
|
|
+
|
|
+ Lisp_Object overlays = Foverlays_at (make_fixnum (p), 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)))))
|
|
+ continue;
|
|
+
|
|
+ ptrdiff_t ov_start = OVERLAY_START (ov);
|
|
+ ptrdiff_t ov_end = OVERLAY_END (ov);
|
|
+ if (ov_end <= ov_start)
|
|
+ continue;
|
|
+
|
|
+ best_start = ov_start;
|
|
+ best_end = ov_end;
|
|
+ best_dist = 0;
|
|
+ found = YES;
|
|
+ break;
|
|
+ }
|
|
+ }
|
|
+
|
|
+ if (!found)
|
|
+ {
|
|
+ /* Bulk query: get all overlays in the buffer at once.
|
|
+ Avoids the previous O(n) per-character Foverlays_at loop. */
|
|
+ Lisp_Object all = Foverlays_in (make_fixnum (begv),
|
|
+ make_fixnum (zv));
|
|
+ Lisp_Object tail;
|
|
+ for (tail = all; 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)))))
|
|
+ continue;
|
|
+
|
|
+ ptrdiff_t ov_start = OVERLAY_START (ov);
|
|
+ ptrdiff_t ov_end = OVERLAY_END (ov);
|
|
+ if (ov_end <= ov_start)
|
|
+ continue;
|
|
+
|
|
+ ptrdiff_t dist = 0;
|
|
+ if (point < ov_start)
|
|
+ dist = ov_start - point;
|
|
+ else if (point > ov_end)
|
|
+ dist = point - ov_end;
|
|
+
|
|
+ if (!found || dist < best_dist)
|
|
+ {
|
|
+ best_start = ov_start;
|
|
+ best_end = ov_end;
|
|
+ best_dist = dist;
|
|
+ found = YES;
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+
|
|
+ if (!found)
|
|
+ return NO;
|
|
+
|
|
+ *out_start = best_start;
|
|
+ *out_end = best_end;
|
|
+ return YES;
|
|
+}
|
|
+
|
|
+/* Detect line-level navigation commands. Inspects Vthis_command
|
|
+ (the command symbol being executed) rather than raw key codes so
|
|
+ that remapped bindings (e.g., C-j -> next-line) are recognized.
|
|
+ Falls back to last_command_event for Tab/backtab which are not
|
|
+ bound to a single canonical command symbol. */
|
|
+static bool
|
|
+ns_ax_event_is_line_nav_key (int *which)
|
|
+{
|
|
+ /* 1. Check Vthis_command for known navigation command symbols.
|
|
+ All symbols are registered via DEFSYM in syms_of_nsterm to avoid
|
|
+ per-call obarray lookups in this hot path (runs every cursor move). */
|
|
+ if (SYMBOLP (Vthis_command) && !NILP (Vthis_command))
|
|
+ {
|
|
+ Lisp_Object cmd = Vthis_command;
|
|
+ /* Forward line commands. */
|
|
+ if (EQ (cmd, Qns_ax_next_line)
|
|
+ || EQ (cmd, Qns_ax_dired_next_line)
|
|
+ || EQ (cmd, Qns_ax_evil_next_line)
|
|
+ || EQ (cmd, Qns_ax_evil_next_visual_line))
|
|
+ {
|
|
+ if (which) *which = 1;
|
|
+ return true;
|
|
+ }
|
|
+ /* Backward line commands. */
|
|
+ if (EQ (cmd, Qns_ax_previous_line)
|
|
+ || EQ (cmd, Qns_ax_dired_previous_line)
|
|
+ || EQ (cmd, Qns_ax_evil_previous_line)
|
|
+ || EQ (cmd, Qns_ax_evil_previous_visual_line))
|
|
+ {
|
|
+ if (which) *which = -1;
|
|
+ return true;
|
|
+ }
|
|
+ }
|
|
+
|
|
+ /* 2. Fallback: check raw key events for Tab/backtab. */
|
|
+ Lisp_Object ev = last_command_event;
|
|
+ if (CONSP (ev))
|
|
+ ev = EVENT_HEAD (ev);
|
|
+
|
|
+ if (SYMBOLP (ev) && EQ (ev, Qns_ax_backtab))
|
|
+ {
|
|
+ if (which) *which = -1;
|
|
+ return true;
|
|
+ }
|
|
+ if (FIXNUMP (ev) && XFIXNUM (ev) == 9) /* Tab */
|
|
+ {
|
|
+ if (which) *which = 1;
|
|
+ return true;
|
|
+ }
|
|
+ return false;
|
|
+}
|
|
+
|
|
+
|
|
+
|
|
+static NSString *
|
|
+ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
|
|
+ struct buffer *b,
|
|
+ ptrdiff_t start,
|
|
+ ptrdiff_t end,
|
|
+ NSString *cachedText)
|
|
+{
|
|
+ if (!elem || !b || !cachedText || end <= start)
|
|
+ return nil;
|
|
+
|
|
+ NSString *text = nil;
|
|
+ specpdl_ref count = SPECPDL_INDEX ();
|
|
+ record_unwind_current_buffer ();
|
|
+ /* Block input to prevent concurrent redisplay from modifying buffer
|
|
+ state while we read text properties. Unwind-protected so
|
|
+ block_input is always matched by unblock_input on signal. */
|
|
+ record_unwind_protect_void (unblock_input);
|
|
+ block_input ();
|
|
+ if (b != current_buffer)
|
|
+ set_buffer_internal_1 (b);
|
|
+
|
|
+ /* Prefer canonical completion candidate string from text property.
|
|
+ Try both completion--string (new API, set by minibuffer.el) and
|
|
+ completion (older API used by some modes). */
|
|
+ ptrdiff_t probes[2] = { start, end - 1 };
|
|
+ for (int i = 0; i < 2 && !text; i++)
|
|
+ {
|
|
+ ptrdiff_t p = probes[i];
|
|
+ Lisp_Object cstr = Fget_char_property (make_fixnum (p),
|
|
+ Qns_ax_completion__string,
|
|
+ Qnil);
|
|
+ if (STRINGP (cstr))
|
|
+ text = [NSString stringWithLispString:cstr];
|
|
+ if (!text)
|
|
+ {
|
|
+ /* Fallback: 'completion property used by display-completion-list. */
|
|
+ cstr = Fget_char_property (make_fixnum (p),
|
|
+ Qns_ax_completion,
|
|
+ Qnil);
|
|
+ if (STRINGP (cstr))
|
|
+ text = [NSString stringWithLispString:cstr];
|
|
+ }
|
|
+ }
|
|
+
|
|
+ if (!text)
|
|
+ {
|
|
+ NSUInteger ax_s = [elem accessibilityIndexForCharpos:start];
|
|
+ NSUInteger ax_e = [elem accessibilityIndexForCharpos:end];
|
|
+ if (ax_e > ax_s && ax_e <= [cachedText length])
|
|
+ text = [cachedText substringWithRange:NSMakeRange (ax_s, ax_e - ax_s)];
|
|
+ }
|
|
+
|
|
+ unbind_to (count, Qnil);
|
|
+
|
|
+ if (text)
|
|
+ {
|
|
+ text = [text stringByTrimmingCharactersInSet:
|
|
+ [NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
|
+ if ([text length] == 0)
|
|
+ text = nil;
|
|
+ }
|
|
+
|
|
+ return text;
|
|
+}
|
|
+
|
|
+@implementation EmacsAccessibilityBuffer
|
|
+@synthesize cachedText;
|
|
+@synthesize cachedTextModiff;
|
|
+@synthesize cachedOverlayModiff;
|
|
+@synthesize cachedTextStart;
|
|
+@synthesize cachedModiff;
|
|
+@synthesize cachedPoint;
|
|
+@synthesize cachedMarkActive;
|
|
+@synthesize cachedCompletionAnnouncement;
|
|
+@synthesize cachedCompletionOverlayStart;
|
|
+@synthesize cachedCompletionOverlayEnd;
|
|
+@synthesize cachedCompletionPoint;
|
|
+
|
|
+- (void)dealloc
|
|
+{
|
|
+ [cachedText release];
|
|
+ [cachedCompletionAnnouncement release];
|
|
+ [cachedInteractiveSpans release];
|
|
+ if (visibleRuns)
|
|
+ xfree (visibleRuns);
|
|
+ if (lineStartOffsets)
|
|
+ xfree (lineStartOffsets);
|
|
+ [super dealloc];
|
|
+}
|
|
+
|
|
+/* ---- Text cache ---- */
|
|
+
|
|
+- (void)invalidateTextCache
|
|
+{
|
|
+ @synchronized (self)
|
|
+ {
|
|
+ [cachedText release];
|
|
+ cachedText = nil;
|
|
+ if (visibleRuns)
|
|
+ {
|
|
+ xfree (visibleRuns);
|
|
+ visibleRuns = NULL;
|
|
+ }
|
|
+ visibleRunCount = 0;
|
|
+ if (lineStartOffsets)
|
|
+ {
|
|
+ xfree (lineStartOffsets);
|
|
+ lineStartOffsets = NULL;
|
|
+ }
|
|
+ lineCount = 0;
|
|
+ }
|
|
+ [self invalidateInteractiveSpans];
|
|
+}
|
|
+
|
|
+/* ---- Line index helpers ---- */
|
|
+
|
|
+/* Return the line number for AX string index IDX using the
|
|
+ precomputed lineStartOffsets array. Binary search: O(log L)
|
|
+ where L is the number of lines in the cached text.
|
|
+
|
|
+ lineStartOffsets[i] holds the AX string index where line i
|
|
+ begins. Built once per cache rebuild in ensureTextCache. */
|
|
+- (NSInteger)lineForAXIndex:(NSUInteger)idx
|
|
+{
|
|
+ @synchronized (self)
|
|
+ {
|
|
+ if (!lineStartOffsets || lineCount == 0)
|
|
+ return 0;
|
|
+
|
|
+ /* Binary search for the largest line whose start offset <= idx. */
|
|
+ NSUInteger lo = 0, hi = lineCount;
|
|
+ while (lo < hi)
|
|
+ {
|
|
+ NSUInteger mid = lo + (hi - lo) / 2;
|
|
+ if (lineStartOffsets[mid] <= idx)
|
|
+ lo = mid + 1;
|
|
+ else
|
|
+ hi = mid;
|
|
+ }
|
|
+ return (NSInteger) (lo > 0 ? lo - 1 : 0);
|
|
+ }
|
|
+}
|
|
+
|
|
+/* Return the AX string range for LINE using the precomputed
|
|
+ lineStartOffsets array. O(1) lookup. */
|
|
+- (NSRange)rangeForLine:(NSUInteger)line textLength:(NSUInteger)tlen
|
|
+{
|
|
+ @synchronized (self)
|
|
+ {
|
|
+ if (!lineStartOffsets || lineCount == 0)
|
|
+ return NSMakeRange (NSNotFound, 0);
|
|
+
|
|
+ if (line >= lineCount)
|
|
+ return NSMakeRange (NSNotFound, 0);
|
|
+
|
|
+ NSUInteger start = lineStartOffsets[line];
|
|
+ NSUInteger end = (line + 1 < lineCount)
|
|
+ ? lineStartOffsets[line + 1]
|
|
+ : tlen;
|
|
+ return NSMakeRange (start, end - start);
|
|
+ }
|
|
+}
|
|
+
|
|
+- (void)ensureTextCache
|
|
+{
|
|
+ NSTRACE ("EmacsAccessibilityBuffer ensureTextCache");
|
|
+ /* This method is only called from the main thread (AX getters
|
|
+ dispatch_sync to main first). Reads of cachedText/cachedTextModiff
|
|
+ below are therefore safe without @synchronized — only the
|
|
+ write section at the end needs synchronization to protect
|
|
+ against concurrent reads from AX server thread. */
|
|
+ eassert ([NSThread isMainThread]);
|
|
+ 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 overlay_modiff = BUF_OVERLAY_MODIFF (b);
|
|
+ ptrdiff_t pt = BUF_PT (b);
|
|
+ NSUInteger textLen = cachedText ? [cachedText length] : 0;
|
|
+ /* Track both BUF_MODIFF and BUF_OVERLAY_MODIFF. Overlay-only
|
|
+ changes (e.g., timer-based completion highlight move without
|
|
+ text edit) bump overlay_modiff but not modiff. Also detect
|
|
+ narrowing/widening which changes BUF_BEGV without bumping
|
|
+ either modiff counter. */
|
|
+ if (cachedText && cachedTextModiff == modiff
|
|
+ && cachedOverlayModiff == overlay_modiff
|
|
+ && cachedTextStart == BUF_BEGV (b)
|
|
+ && pt >= cachedTextStart
|
|
+ && (textLen == 0
|
|
+ || [self accessibilityIndexForCharpos:pt] <= textLen))
|
|
+ return;
|
|
+
|
|
+ ptrdiff_t start;
|
|
+ ns_ax_visible_run *runs = NULL;
|
|
+ NSUInteger nruns = 0;
|
|
+ NSString *text = ns_ax_buffer_text (w, &start, &runs, &nruns);
|
|
+
|
|
+ @synchronized (self)
|
|
+ {
|
|
+ [cachedText release];
|
|
+ cachedText = [text retain];
|
|
+ cachedTextModiff = modiff;
|
|
+ cachedOverlayModiff = overlay_modiff;
|
|
+ cachedTextStart = start;
|
|
+
|
|
+ if (visibleRuns)
|
|
+ xfree (visibleRuns);
|
|
+ visibleRuns = runs;
|
|
+ visibleRunCount = nruns;
|
|
+
|
|
+ /* Build line-start index for O(log L) line queries.
|
|
+ Walk the cached text once, recording the start offset
|
|
+ of each line. This runs once per cache rebuild (on text
|
|
+ change or narrowing), not per cursor move. */
|
|
+ if (lineStartOffsets)
|
|
+ xfree (lineStartOffsets);
|
|
+ lineStartOffsets = NULL;
|
|
+ lineCount = 0;
|
|
+
|
|
+ NSUInteger tlen = [cachedText length];
|
|
+ if (tlen > 0)
|
|
+ {
|
|
+ NSUInteger cap = 256;
|
|
+ lineStartOffsets = xmalloc (cap * sizeof (NSUInteger));
|
|
+ lineStartOffsets[0] = 0;
|
|
+ lineCount = 1;
|
|
+ NSUInteger pos = 0;
|
|
+ while (pos < tlen)
|
|
+ {
|
|
+ NSRange lr = [cachedText lineRangeForRange:
|
|
+ NSMakeRange (pos, 0)];
|
|
+ NSUInteger next = NSMaxRange (lr);
|
|
+ if (next <= pos)
|
|
+ break; /* safety */
|
|
+ if (next < tlen)
|
|
+ {
|
|
+ if (lineCount >= cap)
|
|
+ {
|
|
+ cap *= 2;
|
|
+ lineStartOffsets = xrealloc (lineStartOffsets,
|
|
+ cap * sizeof (NSUInteger));
|
|
+ }
|
|
+ lineStartOffsets[lineCount] = next;
|
|
+ lineCount++;
|
|
+ }
|
|
+ pos = next;
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+}
|
|
+
|
|
+/* ---- Index mapping ---- */
|
|
+
|
|
+/* Convert buffer charpos to accessibility string index. */
|
|
+- (NSUInteger)accessibilityIndexForCharpos:(ptrdiff_t)charpos
|
|
+{
|
|
+ /* This method may be called from the AX server thread.
|
|
+ Synchronize on self to prevent use-after-free if the main
|
|
+ thread invalidates the text cache concurrently. */
|
|
+ @synchronized (self)
|
|
+ {
|
|
+ if (visibleRunCount == 0)
|
|
+ return 0;
|
|
+
|
|
+ /* Binary search: runs are sorted by charpos (ascending). Find the
|
|
+ run whose [charpos, charpos+length) range contains the target,
|
|
+ or the nearest run after an invisible gap. O(log n) instead of
|
|
+ O(n) — matters for org-mode with many folded sections. */
|
|
+ NSUInteger lo = 0, hi = visibleRunCount;
|
|
+ while (lo < hi)
|
|
+ {
|
|
+ NSUInteger mid = lo + (hi - lo) / 2;
|
|
+ ns_ax_visible_run *r = &visibleRuns[mid];
|
|
+ if (charpos < r->charpos)
|
|
+ hi = mid;
|
|
+ else if (charpos >= r->charpos + r->length)
|
|
+ lo = mid + 1;
|
|
+ else
|
|
+ {
|
|
+ /* Found: charpos is inside this run. Compute UTF-16 delta
|
|
+ directly from cachedText — no Lisp calls needed. */
|
|
+ NSUInteger chars_in = (NSUInteger)(charpos - r->charpos);
|
|
+ if (chars_in == 0 || !cachedText)
|
|
+ return r->ax_start;
|
|
+ NSUInteger run_end_ax = r->ax_start + r->ax_length;
|
|
+ NSUInteger scan = r->ax_start;
|
|
+ for (NSUInteger c = 0; c < chars_in && scan < run_end_ax; c++)
|
|
+ {
|
|
+ NSRange seq = [cachedText
|
|
+ rangeOfComposedCharacterSequenceAtIndex:scan];
|
|
+ scan = NSMaxRange (seq);
|
|
+ }
|
|
+ return (scan <= run_end_ax) ? scan : run_end_ax;
|
|
+ }
|
|
+ }
|
|
+ /* charpos falls in an invisible gap or past the end. */
|
|
+ if (lo < visibleRunCount)
|
|
+ return visibleRuns[lo].ax_start;
|
|
+ ns_ax_visible_run *last = &visibleRuns[visibleRunCount - 1];
|
|
+ return last->ax_start + last->ax_length;
|
|
+ } /* @synchronized */
|
|
+}
|
|
+
|
|
+/* Convert accessibility string index to buffer charpos.
|
|
+ Safe to call from any thread: uses only cachedText (NSString) and
|
|
+ visibleRuns — no Lisp calls. */
|
|
+- (ptrdiff_t)charposForAccessibilityIndex:(NSUInteger)ax_idx
|
|
+{
|
|
+ /* May be called from AX server thread — synchronize. */
|
|
+ @synchronized (self)
|
|
+ {
|
|
+ if (visibleRunCount == 0)
|
|
+ return cachedTextStart;
|
|
+
|
|
+ /* Binary search: runs are sorted by ax_start (ascending). */
|
|
+ NSUInteger lo = 0, hi = visibleRunCount;
|
|
+ while (lo < hi)
|
|
+ {
|
|
+ NSUInteger mid = lo + (hi - lo) / 2;
|
|
+ ns_ax_visible_run *r = &visibleRuns[mid];
|
|
+ if (ax_idx < r->ax_start)
|
|
+ hi = mid;
|
|
+ else if (ax_idx >= r->ax_start + r->ax_length)
|
|
+ lo = mid + 1;
|
|
+ else
|
|
+ {
|
|
+ /* Found: ax_idx is inside this run. Walk composed character
|
|
+ sequences to count Emacs characters up to ax_idx. */
|
|
+ if (!cachedText)
|
|
+ return r->charpos;
|
|
+ NSUInteger scan = r->ax_start;
|
|
+ ptrdiff_t cp = r->charpos;
|
|
+ while (scan < ax_idx)
|
|
+ {
|
|
+ NSRange seq = [cachedText
|
|
+ rangeOfComposedCharacterSequenceAtIndex:scan];
|
|
+ scan = NSMaxRange (seq);
|
|
+ cp++;
|
|
+ }
|
|
+ return cp;
|
|
+ }
|
|
+ }
|
|
+ /* Past end — return last charpos. */
|
|
+ if (lo > 0)
|
|
+ {
|
|
+ ns_ax_visible_run *last = &visibleRuns[visibleRunCount - 1];
|
|
+ return last->charpos + last->length;
|
|
+ }
|
|
+ return cachedTextStart;
|
|
+ } /* @synchronized */
|
|
+}
|
|
+
|
|
+/* --- Threading and signal safety ---
|
|
+
|
|
+ AX getter methods may be called from the VoiceOver server thread.
|
|
+ All methods that access Lisp objects or buffer state dispatch_sync
|
|
+ to the main thread where Emacs state is consistent.
|
|
+
|
|
+ Longjmp safety: Lisp functions called inside dispatch_sync blocks
|
|
+ (Fget_char_property, Fbuffer_substring_no_properties, etc.) could
|
|
+ theoretically signal and longjmp through the dispatch_sync frame,
|
|
+ deadlocking the AX server thread. This is prevented by:
|
|
+
|
|
+ 1. validWindow checks WINDOW_LIVE_P and BUFFERP before every
|
|
+ Lisp access — the window and buffer are verified live.
|
|
+ 2. All dispatch_sync blocks run on the main thread where no
|
|
+ concurrent Lisp code can modify state between checks.
|
|
+ 3. block_input prevents timer events and process output from
|
|
+ running between precondition checks and Lisp calls.
|
|
+ 4. Buffer positions are clamped to BUF_BEGV/BUF_ZV before
|
|
+ use, preventing out-of-range signals.
|
|
+ 5. specpdl unwind protection ensures block_input is always
|
|
+ matched by unblock_input, even on longjmp.
|
|
+
|
|
+ This matches the safety model of existing nsterm.m F-function
|
|
+ calls (24 direct calls, none wrapped in internal_condition_case).
|
|
+
|
|
+ Known gap: if the Emacs window tree is modified between redisplay
|
|
+ cycles in a way that invalidates validWindow's cached result,
|
|
+ a stale dereference could occur. In practice this does not happen
|
|
+ because window tree modifications go through the event loop which
|
|
+ we are blocking via dispatch_sync. */
|
|
+
|
|
+/* ---- NSAccessibility protocol ---- */
|
|
+
|
|
+- (NSAccessibilityRole)accessibilityRole
|
|
+{
|
|
+ if (![NSThread isMainThread])
|
|
+ {
|
|
+ __block NSAccessibilityRole result;
|
|
+ dispatch_sync (dispatch_get_main_queue (), ^{
|
|
+ result = [self accessibilityRole];
|
|
+ });
|
|
+ return result;
|
|
+ }
|
|
+ struct window *w = [self validWindow];
|
|
+ if (w && MINI_WINDOW_P (w))
|
|
+ return NSAccessibilityTextFieldRole;
|
|
+ return NSAccessibilityTextAreaRole;
|
|
+}
|
|
+
|
|
+- (NSString *)accessibilityPlaceholderValue
|
|
+{
|
|
+ if (![NSThread isMainThread])
|
|
+ {
|
|
+ __block NSString *result;
|
|
+ dispatch_sync (dispatch_get_main_queue (), ^{
|
|
+ result = [self accessibilityPlaceholderValue];
|
|
+ });
|
|
+ return result;
|
|
+ }
|
|
+ struct window *w = [self validWindow];
|
|
+ if (!w || !MINI_WINDOW_P (w))
|
|
+ return nil;
|
|
+ Lisp_Object prompt = Fminibuffer_prompt ();
|
|
+ if (STRINGP (prompt))
|
|
+ return [NSString stringWithLispString: prompt];
|
|
+ return nil;
|
|
+}
|
|
+
|
|
+- (NSString *)accessibilityRoleDescription
|
|
+{
|
|
+ if (![NSThread isMainThread])
|
|
+ {
|
|
+ __block NSString *result;
|
|
+ dispatch_sync (dispatch_get_main_queue (), ^{
|
|
+ result = [self accessibilityRoleDescription];
|
|
+ });
|
|
+ return result;
|
|
+ }
|
|
+ struct window *w = [self validWindow];
|
|
+ if (w && MINI_WINDOW_P (w))
|
|
+ return @"minibuffer";
|
|
+ return @"editor";
|
|
+}
|
|
+
|
|
+- (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))
|
|
+ {
|
|
+ if (MINI_WINDOW_P (w))
|
|
+ return @"Minibuffer";
|
|
+
|
|
+ struct buffer *b = XBUFFER (w->contents);
|
|
+ if (b)
|
|
+ {
|
|
+ Lisp_Object name = BVAR (b, name);
|
|
+ if (STRINGP (name))
|
|
+ return [NSString stringWithLispString:name];
|
|
+ }
|
|
+ }
|
|
+ return @"buffer";
|
|
+}
|
|
+
|
|
+- (BOOL)isAccessibilityFocused
|
|
+{
|
|
+ if (![NSThread isMainThread])
|
|
+ {
|
|
+ __block BOOL result;
|
|
+ dispatch_sync (dispatch_get_main_queue (), ^{
|
|
+ result = [self isAccessibilityFocused];
|
|
+ });
|
|
+ return result;
|
|
+ }
|
|
+ struct window *w = [self validWindow];
|
|
+ if (!w)
|
|
+ return NO;
|
|
+ EmacsView *view = self.emacsView;
|
|
+ if (!view || !view->emacsframe)
|
|
+ return NO;
|
|
+ struct frame *f = view->emacsframe;
|
|
+ return (w == XWINDOW (f->selected_window));
|
|
+}
|
|
+
|
|
+- (id)accessibilityValue
|
|
+{
|
|
+ /* AX getters can be called from any thread by the AT subsystem.
|
|
+ Dispatch to main thread where Emacs buffer state is consistent. */
|
|
+ if (![NSThread isMainThread])
|
|
+ {
|
|
+ __block id result;
|
|
+ dispatch_sync (dispatch_get_main_queue (), ^{
|
|
+ result = [self accessibilityValue];
|
|
+ });
|
|
+ return result;
|
|
+ }
|
|
+ [self ensureTextCache];
|
|
+ return cachedText ? cachedText : @"";
|
|
+}
|
|
+
|
|
+- (NSInteger)accessibilityNumberOfCharacters
|
|
+{
|
|
+ if (![NSThread isMainThread])
|
|
+ {
|
|
+ __block NSInteger result;
|
|
+ dispatch_sync (dispatch_get_main_queue (), ^{
|
|
+ result = [self accessibilityNumberOfCharacters];
|
|
+ });
|
|
+ return result;
|
|
+ }
|
|
+ [self ensureTextCache];
|
|
+ return cachedText ? [cachedText length] : 0;
|
|
+}
|
|
+
|
|
+- (NSString *)accessibilitySelectedText
|
|
+{
|
|
+ if (![NSThread isMainThread])
|
|
+ {
|
|
+ __block NSString *result;
|
|
+ dispatch_sync (dispatch_get_main_queue (), ^{
|
|
+ result = [self accessibilitySelectedText];
|
|
+ });
|
|
+ return result;
|
|
+ }
|
|
+ struct window *w = [self validWindow];
|
|
+ if (!w || !WINDOW_LEAF_P (w))
|
|
+ return @"";
|
|
+
|
|
+ struct buffer *b = XBUFFER (w->contents);
|
|
+ if (!b || NILP (BVAR (b, mark_active)))
|
|
+ return @"";
|
|
+
|
|
+ NSRange sel = [self accessibilitySelectedTextRange];
|
|
+ [self ensureTextCache];
|
|
+ if (!cachedText || sel.location == NSNotFound
|
|
+ || sel.location + sel.length > [cachedText length])
|
|
+ return @"";
|
|
+ return [cachedText substringWithRange:sel];
|
|
+}
|
|
+
|
|
+- (NSRange)accessibilitySelectedTextRange
|
|
+{
|
|
+ if (![NSThread isMainThread])
|
|
+ {
|
|
+ __block NSRange result;
|
|
+ dispatch_sync (dispatch_get_main_queue (), ^{
|
|
+ result = [self accessibilitySelectedTextRange];
|
|
+ });
|
|
+ return result;
|
|
+ }
|
|
+ struct window *w = [self validWindow];
|
|
+ if (!w || !WINDOW_LEAF_P (w))
|
|
+ return NSMakeRange (0, 0);
|
|
+
|
|
+ if (!BUFFERP (w->contents))
|
|
+ return NSMakeRange (0, 0);
|
|
+ struct buffer *b = XBUFFER (w->contents);
|
|
+ if (!b)
|
|
+ return NSMakeRange (0, 0);
|
|
+
|
|
+ [self ensureTextCache];
|
|
+ ptrdiff_t pt = BUF_PT (b);
|
|
+ NSUInteger point_idx = [self accessibilityIndexForCharpos:pt];
|
|
+
|
|
+ if (NILP (BVAR (b, mark_active)))
|
|
+ return NSMakeRange (point_idx, 0);
|
|
+
|
|
+ ptrdiff_t mark_pos = marker_position (BVAR (b, mark));
|
|
+ NSUInteger mark_idx = [self accessibilityIndexForCharpos:mark_pos];
|
|
+ NSUInteger start_idx = MIN (point_idx, mark_idx);
|
|
+ NSUInteger end_idx = MAX (point_idx, mark_idx);
|
|
+ return NSMakeRange (start_idx, end_idx - start_idx);
|
|
+}
|
|
+
|
|
+- (void)setAccessibilitySelectedTextRange:(NSRange)range
|
|
+{
|
|
+ if (![NSThread isMainThread])
|
|
+ {
|
|
+ dispatch_async (dispatch_get_main_queue (), ^{
|
|
+ [self setAccessibilitySelectedTextRange:range];
|
|
+ });
|
|
+ return;
|
|
+ }
|
|
+ struct window *w = [self validWindow];
|
|
+ if (!w || !WINDOW_LEAF_P (w))
|
|
+ return;
|
|
+
|
|
+ struct buffer *b = XBUFFER (w->contents);
|
|
+ if (!b)
|
|
+ return;
|
|
+
|
|
+ [self ensureTextCache];
|
|
+
|
|
+ specpdl_ref count = SPECPDL_INDEX ();
|
|
+ record_unwind_current_buffer ();
|
|
+ /* Ensure block_input is always matched by unblock_input even if
|
|
+ Fset_marker or another Lisp call signals (longjmp). */
|
|
+ record_unwind_protect_void (unblock_input);
|
|
+ block_input ();
|
|
+
|
|
+ /* Convert accessibility index to buffer charpos via mapping. */
|
|
+ ptrdiff_t charpos = [self charposForAccessibilityIndex:range.location];
|
|
+
|
|
+ /* Clamp to buffer bounds. */
|
|
+ if (charpos < BUF_BEGV (b))
|
|
+ charpos = BUF_BEGV (b);
|
|
+ if (charpos > BUF_ZV (b))
|
|
+ charpos = BUF_ZV (b);
|
|
+
|
|
+ /* Move point directly in the buffer. */
|
|
+ if (b != current_buffer)
|
|
+ set_buffer_internal_1 (b);
|
|
+
|
|
+ SET_PT_BOTH (charpos, CHAR_TO_BYTE (charpos));
|
|
+
|
|
+ /* Keep mark state aligned with requested selection range. */
|
|
+ if (range.length > 0)
|
|
+ {
|
|
+ ptrdiff_t mark_charpos = [self charposForAccessibilityIndex:
|
|
+ range.location + range.length];
|
|
+ if (mark_charpos > BUF_ZV (b))
|
|
+ mark_charpos = BUF_ZV (b);
|
|
+ Fset_marker (BVAR (b, mark), make_fixnum (mark_charpos),
|
|
+ Fcurrent_buffer ());
|
|
+ bset_mark_active (b, Qt);
|
|
+ }
|
|
+ else
|
|
+ bset_mark_active (b, Qnil);
|
|
+
|
|
+ unbind_to (count, Qnil);
|
|
+
|
|
+ /* Update cached state so the next notification cycle doesn't
|
|
+ re-announce this movement. */
|
|
+ self.cachedPoint = charpos;
|
|
+ self.cachedMarkActive = (range.length > 0);
|
|
+}
|
|
+
|
|
+- (void)setAccessibilityFocused:(BOOL)flag
|
|
+{
|
|
+ if (!flag)
|
|
+ return;
|
|
+
|
|
+ /* VoiceOver may call this from the AX server thread.
|
|
+ All Lisp reads, block_input, and AppKit calls require main. */
|
|
+ if (![NSThread isMainThread])
|
|
+ {
|
|
+ dispatch_async (dispatch_get_main_queue (), ^{
|
|
+ [self setAccessibilityFocused:flag];
|
|
+ });
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ struct window *w = [self validWindow];
|
|
+ if (!w || !WINDOW_LEAF_P (w))
|
|
+ return;
|
|
+
|
|
+ EmacsView *view = self.emacsView;
|
|
+ if (!view || !view->emacsframe)
|
|
+ return;
|
|
+
|
|
+ /* Use specpdl unwind protection for block_input safety. */
|
|
+ specpdl_ref count = SPECPDL_INDEX ();
|
|
+ record_unwind_protect_void (unblock_input);
|
|
+ block_input ();
|
|
+
|
|
+ /* Select the Emacs window so keyboard focus follows VoiceOver. */
|
|
+ struct frame *f = view->emacsframe;
|
|
+ if (w != XWINDOW (f->selected_window))
|
|
+ Fselect_window (self.lispWindow, Qnil);
|
|
+
|
|
+ /* Raise the frame's NS window to ensure keyboard focus. */
|
|
+ NSWindow *nswin = [view window];
|
|
+ if (nswin && ![nswin isKeyWindow])
|
|
+ [nswin makeKeyAndOrderFront:nil];
|
|
+
|
|
+ unbind_to (count, Qnil);
|
|
+
|
|
+ /* Post SelectedTextChanged so VoiceOver reads the current line
|
|
+ upon entering text interaction mode.
|
|
+ WebKit AXObjectCacheMac fallback enum: SelectionMove = 2. */
|
|
+ NSDictionary *info = @{
|
|
+ @"AXTextStateChangeType":
|
|
+ @(ns_ax_text_state_change_selection_move),
|
|
+ @"AXTextChangeElement": self
|
|
+ };
|
|
+ ns_ax_post_notification_with_info (
|
|
+ self, NSAccessibilitySelectedTextChangedNotification, info);
|
|
+}
|
|
+
|
|
+- (NSInteger)accessibilityInsertionPointLineNumber
|
|
+{
|
|
+ if (![NSThread isMainThread])
|
|
+ {
|
|
+ __block NSInteger result;
|
|
+ dispatch_sync (dispatch_get_main_queue (), ^{
|
|
+ result = [self accessibilityInsertionPointLineNumber];
|
|
+ });
|
|
+ return result;
|
|
+ }
|
|
+ struct window *w = [self validWindow];
|
|
+ if (!w || !WINDOW_LEAF_P (w))
|
|
+ return 0;
|
|
+
|
|
+ struct buffer *b = XBUFFER (w->contents);
|
|
+ if (!b)
|
|
+ return 0;
|
|
+
|
|
+ [self ensureTextCache];
|
|
+ if (!cachedText)
|
|
+ return 0;
|
|
+
|
|
+ ptrdiff_t pt = BUF_PT (b);
|
|
+ NSUInteger point_idx = [self accessibilityIndexForCharpos:pt];
|
|
+ if (point_idx > [cachedText length])
|
|
+ point_idx = [cachedText length];
|
|
+
|
|
+ return [self lineForAXIndex:idx];
|
|
+}
|
|
+
|
|
+- (NSRange)accessibilityRangeForLine:(NSInteger)line
|
|
+{
|
|
+ if (![NSThread isMainThread])
|
|
+ {
|
|
+ __block NSRange result;
|
|
+ dispatch_sync (dispatch_get_main_queue (), ^{
|
|
+ result = [self accessibilityRangeForLine:line];
|
|
+ });
|
|
+ return result;
|
|
+ }
|
|
+ [self ensureTextCache];
|
|
+ if (!cachedText || line < 0)
|
|
+ return NSMakeRange (NSNotFound, 0);
|
|
+
|
|
+ NSUInteger len = [cachedText length];
|
|
+ if (len == 0)
|
|
+ return (line == 0) ? NSMakeRange (0, 0)
|
|
+ : NSMakeRange (NSNotFound, 0);
|
|
+
|
|
+ return [self rangeForLine:(NSUInteger)line textLength:len];
|
|
+}
|
|
+
|
|
+- (NSRange)accessibilityRangeForIndex:(NSInteger)index
|
|
+{
|
|
+ if (![NSThread isMainThread])
|
|
+ {
|
|
+ __block NSRange result;
|
|
+ dispatch_sync (dispatch_get_main_queue (), ^{
|
|
+ result = [self accessibilityRangeForIndex:index];
|
|
+ });
|
|
+ return result;
|
|
+ }
|
|
+ [self ensureTextCache];
|
|
+ if (!cachedText || index < 0
|
|
+ || (NSUInteger) index >= [cachedText length])
|
|
+ return NSMakeRange (NSNotFound, 0);
|
|
+ return [cachedText rangeOfComposedCharacterSequenceAtIndex:(NSUInteger)index];
|
|
+}
|
|
+
|
|
+- (NSRange)accessibilityStyleRangeForIndex:(NSInteger)index
|
|
+{
|
|
+ /* Return the range of the current line. A more accurate
|
|
+ implementation would return face/font property boundaries,
|
|
+ but line granularity is acceptable for VoiceOver. */
|
|
+ NSInteger line = [self accessibilityLineForIndex:index];
|
|
+ return [self accessibilityRangeForLine:line];
|
|
+}
|
|
+
|
|
+- (NSRect)accessibilityFrameForRange:(NSRange)range
|
|
+{
|
|
+ if (![NSThread isMainThread])
|
|
+ {
|
|
+ __block NSRect result;
|
|
+ dispatch_sync (dispatch_get_main_queue (), ^{
|
|
+ result = [self accessibilityFrameForRange:range];
|
|
+ });
|
|
+ return result;
|
|
+ }
|
|
+ struct window *w = [self validWindow];
|
|
+ EmacsView *view = self.emacsView;
|
|
+ if (!w || !view)
|
|
+ return NSZeroRect;
|
|
+ /* Convert ax-index range to charpos range for glyph lookup. */
|
|
+ [self ensureTextCache];
|
|
+ ptrdiff_t cp_start = [self charposForAccessibilityIndex:range.location];
|
|
+ ptrdiff_t cp_end = [self charposForAccessibilityIndex:
|
|
+ range.location + range.length];
|
|
+ return ns_ax_frame_for_range (w, view, cp_start,
|
|
+ cp_end - cp_start);
|
|
+}
|
|
+
|
|
+- (NSRange)accessibilityRangeForPosition:(NSPoint)screenPoint
|
|
+{
|
|
+ if (![NSThread isMainThread])
|
|
+ {
|
|
+ __block NSRange result;
|
|
+ dispatch_sync (dispatch_get_main_queue (), ^{
|
|
+ result
|
|
+ = [self accessibilityRangeForPosition:screenPoint];
|
|
+ });
|
|
+ return result;
|
|
+ }
|
|
+ /* Hit test: convert screen point to buffer character index. */
|
|
+ struct window *w = [self validWindow];
|
|
+ EmacsView *view = self.emacsView;
|
|
+ if (!w || !view || !w->current_matrix)
|
|
+ return NSMakeRange (0, 0);
|
|
+
|
|
+ /* Convert screen point to EmacsView coordinates. */
|
|
+ NSPoint windowPoint = [[view window] convertPointFromScreen:screenPoint];
|
|
+ NSPoint viewPoint = [view convertPoint:windowPoint fromView:nil];
|
|
+
|
|
+ /* Convert to window-relative pixel coordinates. */
|
|
+ int x = (int) viewPoint.x - w->pixel_left;
|
|
+ int y = (int) viewPoint.y - w->pixel_top;
|
|
+
|
|
+ if (x < 0 || y < 0 || x >= w->pixel_width || y >= w->pixel_height)
|
|
+ return NSMakeRange (0, 0);
|
|
+
|
|
+ /* Block input to prevent concurrent redisplay from modifying the
|
|
+ glyph matrix while we traverse it. Use specpdl unwind protection
|
|
+ so block_input is always matched by unblock_input, even if
|
|
+ ensureTextCache triggers a Lisp signal (longjmp). */
|
|
+ specpdl_ref count = SPECPDL_INDEX ();
|
|
+ record_unwind_protect_void (unblock_input);
|
|
+ block_input ();
|
|
+
|
|
+ /* Find the glyph row at this y coordinate. */
|
|
+ struct glyph_matrix *matrix = w->current_matrix;
|
|
+ struct glyph_row *hit_row = NULL;
|
|
+
|
|
+ for (int i = 0; i < matrix->nrows; i++)
|
|
+ {
|
|
+ struct glyph_row *row = matrix->rows + i;
|
|
+ if (!row->enabled_p || !row->displays_text_p || row->mode_line_p)
|
|
+ continue;
|
|
+ int row_top = WINDOW_TO_FRAME_PIXEL_Y (w, MAX (0, row->y));
|
|
+ if ((int) viewPoint.y >= row_top
|
|
+ && (int) viewPoint.y < row_top + row->visible_height)
|
|
+ {
|
|
+ hit_row = row;
|
|
+ break;
|
|
+ }
|
|
+ }
|
|
+
|
|
+ if (!hit_row)
|
|
+ {
|
|
+ unbind_to (count, Qnil);
|
|
+ return NSMakeRange (0, 0);
|
|
+ }
|
|
+
|
|
+ /* Find the glyph at this x coordinate within the row. */
|
|
+ struct glyph *glyph = hit_row->glyphs[TEXT_AREA];
|
|
+ struct glyph *end = glyph + hit_row->used[TEXT_AREA];
|
|
+ int glyph_x = 0;
|
|
+ ptrdiff_t best_charpos = MATRIX_ROW_START_CHARPOS (hit_row);
|
|
+
|
|
+ for (; glyph < end; glyph++)
|
|
+ {
|
|
+ if (glyph->type == CHAR_GLYPH && glyph->charpos > 0)
|
|
+ {
|
|
+ if (x >= glyph_x && x < glyph_x + glyph->pixel_width)
|
|
+ {
|
|
+ best_charpos = glyph->charpos;
|
|
+ break;
|
|
+ }
|
|
+ best_charpos = glyph->charpos;
|
|
+ }
|
|
+ glyph_x += glyph->pixel_width;
|
|
+ }
|
|
+
|
|
+ /* Convert buffer charpos to accessibility index via mapping. */
|
|
+ [self ensureTextCache];
|
|
+ NSUInteger ax_idx = [self accessibilityIndexForCharpos:best_charpos];
|
|
+ if (cachedText && ax_idx > [cachedText length])
|
|
+ ax_idx = [cachedText length];
|
|
+
|
|
+ unbind_to (count, Qnil);
|
|
+ return NSMakeRange (ax_idx, 1);
|
|
+}
|
|
+
|
|
+- (NSRange)accessibilityVisibleCharacterRange
|
|
+{
|
|
+ if (![NSThread isMainThread])
|
|
+ {
|
|
+ __block NSRange result;
|
|
+ dispatch_sync (dispatch_get_main_queue (), ^{
|
|
+ result = [self accessibilityVisibleCharacterRange];
|
|
+ });
|
|
+ return result;
|
|
+ }
|
|
+ /* Return the full cached text range. VoiceOver interprets the
|
|
+ visible range boundary as end-of-text, so we must expose the
|
|
+ entire buffer to avoid premature "end of text" announcements. */
|
|
+ [self ensureTextCache];
|
|
+ return NSMakeRange (0, cachedText ? [cachedText length] : 0);
|
|
+}
|
|
+
|
|
+- (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)
|
|
+ return NSZeroRect;
|
|
+
|
|
+ /* Subtract mode line height so the buffer element does not overlap it. */
|
|
+ int text_h = w->pixel_height;
|
|
+ if (w->current_matrix)
|
|
+ {
|
|
+ for (int i = w->current_matrix->nrows - 1; i >= 0; i--)
|
|
+ {
|
|
+ struct glyph_row *row = w->current_matrix->rows + i;
|
|
+ if (row->enabled_p && row->mode_line_p)
|
|
+ {
|
|
+ text_h -= row->visible_height;
|
|
+ break;
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ return [self screenRectFromEmacsX:w->pixel_left
|
|
+ y:w->pixel_top
|
|
+ width:w->pixel_width
|
|
+ height:text_h];
|
|
+}
|
|
+
|
|
+/* ---- Notification dispatch (helper methods) ---- */
|
|
+
|
|
+/* Post NSAccessibilityValueChangedNotification for a text edit.
|
|
+ Called when BUF_MODIFF changes between redisplay cycles. */
|
|
+
|
|
+@end
|
|
+
|
|
#endif /* NS_IMPL_COCOA */
|
|
|
|
|
|
--
|
|
2.43.0
|
|
|