Split the monolithic 3011-line patch into logical pieces: 0001: All new accessibility code (infrastructure, no existing code modified) 0002: EmacsView integration + cursor tracking (wiring only) 0003: Documentation (expanded with known limitations) Improvements: - Comprehensive commit messages with testing methodology - Known limitations documented (text cap, bidi, mode-line icons) - Documentation expanded with Known Limitations section - Each patch is self-contained and reviewable
2617 lines
81 KiB
Diff
2617 lines
81 KiB
Diff
From 663011cd807430d689569fc6b15fb3e3220928ce Mon Sep 17 00:00:00 2001
|
|
From: Martin Sukany <martin@sukany.cz>
|
|
Date: Sat, 28 Feb 2026 09:31:55 +0100
|
|
Subject: [PATCH 1/3] ns: add accessibility infrastructure for macOS VoiceOver
|
|
|
|
Add the core accessibility implementation for the NS (Cocoa) port,
|
|
providing VoiceOver and Zoom support. This patch adds all new types,
|
|
classes, and functions without modifying existing code paths; the
|
|
integration with EmacsView and the redisplay cycle follows in a
|
|
subsequent patch.
|
|
|
|
New types:
|
|
|
|
ns_ax_visible_run: maps buffer character positions to UTF-16
|
|
accessibility string indices, skipping invisible text. Used by
|
|
the index-mapping binary search in EmacsAccessibilityBuffer.
|
|
|
|
EmacsAccessibilityElement: base class for virtual AX elements,
|
|
stores Lisp_Object lispWindow (GC-safe; see comment in nsterm.h)
|
|
and EmacsView reference.
|
|
|
|
EmacsAccessibilityBuffer <NSAccessibility>: AXTextArea element per
|
|
visible Emacs window. Full NSAccessibility text protocol including
|
|
value, selectedTextRange, line/index conversions, frameForRange,
|
|
rangeForPosition. Text cache with visible-run mapping handles
|
|
invisible text (org-mode folds, outline-mode). Hybrid
|
|
SelectedTextChanged/AnnouncementRequested notification dispatch.
|
|
Completion announcements for *Completions* buffer.
|
|
|
|
EmacsAccessibilityModeLine: AXStaticText per mode line.
|
|
|
|
EmacsAccessibilityInteractiveSpan: lightweight AX child elements
|
|
for Tab-navigable interactive spans (buttons, links, checkboxes,
|
|
completion candidates, Org-mode links, keymap overlays).
|
|
|
|
New helper functions:
|
|
|
|
ns_ax_buffer_text: build accessibility string with visible-run
|
|
mapping. Uses TEXT_PROP_MEANS_INVISIBLE for spec-controlled
|
|
invisibility and Fbuffer_substring_no_properties for gap safety.
|
|
|
|
ns_ax_frame_for_range: screen rect for a character range via
|
|
glyph matrix lookup with text-area clipping.
|
|
|
|
ns_ax_event_is_line_nav_key: detect line navigation commands
|
|
via Vthis_command with Tab/backtab fallback.
|
|
|
|
ns_ax_scan_interactive_spans: scan visible range for interactive
|
|
text properties with property-skip optimization.
|
|
|
|
New user option: ns-accessibility-enabled (default t).
|
|
|
|
Threading model: all Lisp calls on main thread; AX getters use
|
|
dispatch_sync to main; index mapping methods are thread-safe.
|
|
Notifications posted via dispatch_async to prevent deadlock with
|
|
VoiceOver's synchronous callbacks.
|
|
|
|
Tested on macOS 14 Sonoma with VoiceOver and Zoom. Verified:
|
|
buffer navigation (char/word/line), completion announcements,
|
|
interactive span Tab navigation, org-mode with folded headings,
|
|
evil-mode block cursor, multi-window layouts, indirect buffers.
|
|
|
|
Known limitations: bidi text layout not fully tested for
|
|
accessibilityRangeForPosition; mode-line text extraction skips
|
|
image and stretch glyphs (CHAR_GLYPH only); accessibility text
|
|
capped at 100,000 UTF-16 units (NS_AX_TEXT_CAP).
|
|
|
|
* src/nsterm.h: New class declarations, ivar extensions.
|
|
* src/nsterm.m: New accessibility implementation, DEFSYM, DEFVAR.
|
|
* etc/NEWS: Document VoiceOver accessibility support.
|
|
---
|
|
etc/NEWS | 13 +
|
|
src/nsterm.h | 119 +++
|
|
src/nsterm.m | 2337 ++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
3 files changed, 2469 insertions(+)
|
|
|
|
diff --git a/etc/NEWS b/etc/NEWS
|
|
index 7367e3c..608650e 100644
|
|
--- a/etc/NEWS
|
|
+++ b/etc/NEWS
|
|
@@ -4374,6 +4374,19 @@ allowing Emacs users access to speech recognition utilities.
|
|
Note: Accepting this permission allows the use of system APIs, which may
|
|
send user data to Apple's speech recognition servers.
|
|
|
|
+---
|
|
+** VoiceOver accessibility support on macOS.
|
|
+Emacs now exposes buffer content, cursor position, and interactive
|
|
+elements to the macOS accessibility subsystem (VoiceOver). This
|
|
+includes AXBoundsForRange for macOS Zoom cursor tracking, line and
|
|
+word navigation announcements, Tab-navigable interactive spans
|
|
+(buttons, links, completion candidates), and completion announcements
|
|
+for the *Completions* buffer. The implementation uses a virtual
|
|
+accessibility tree with per-window elements, hybrid SelectedTextChanged
|
|
+and AnnouncementRequested notifications, and thread-safe text caching.
|
|
+Set 'ns-accessibility-enabled' to nil to disable the accessibility
|
|
+interface and eliminate the associated overhead.
|
|
+
|
|
---
|
|
** Re-introduced dictation, lost in Emacs v30 (macOS).
|
|
We lost macOS dictation in v30 when migrating to NSTextInputClient.
|
|
diff --git a/src/nsterm.h b/src/nsterm.h
|
|
index 7c1ee4c..393fc4c 100644
|
|
--- a/src/nsterm.h
|
|
+++ b/src/nsterm.h
|
|
@@ -453,6 +453,110 @@ enum ns_return_frame_mode
|
|
@end
|
|
|
|
|
|
+/* ==========================================================================
|
|
+
|
|
+ Accessibility virtual elements (macOS / Cocoa only)
|
|
+
|
|
+ ========================================================================== */
|
|
+
|
|
+#ifdef NS_IMPL_COCOA
|
|
+@class EmacsView;
|
|
+
|
|
+/* Base class for virtual accessibility elements attached to EmacsView. */
|
|
+@interface EmacsAccessibilityElement : NSAccessibilityElement
|
|
+@property (nonatomic, unsafe_unretained) EmacsView *emacsView;
|
|
+/* Lisp window object — safe across GC cycles.
|
|
+ GC safety: these Lisp_Objects are NOT visible to GC via staticpro
|
|
+ or the specpdl stack. This is safe because:
|
|
+ (1) Emacs GC runs only on the main thread, at well-defined safe
|
|
+ points during Lisp evaluation — never during redisplay.
|
|
+ (2) Accessibility elements are owned by EmacsView which belongs to
|
|
+ an active frame; windows referenced here are always reachable
|
|
+ from the frame's window tree until rebuildAccessibilityTree
|
|
+ updates them during the next redisplay cycle.
|
|
+ (3) AX getters dispatch_sync to main before accessing Lisp state,
|
|
+ so GC cannot run concurrently with any access to lispWindow.
|
|
+ (4) validWindow checks WINDOW_LIVE_P before dereferencing. */
|
|
+@property (nonatomic, assign) Lisp_Object lispWindow;
|
|
+- (struct window *)validWindow; /* Returns live window or NULL. */
|
|
+- (NSRect)screenRectFromEmacsX:(int)x y:(int)y width:(int)w height:(int)h;
|
|
+@end
|
|
+
|
|
+/* A visible run: maps a contiguous range of accessibility indices
|
|
+ to a contiguous range of buffer character positions. Invisible
|
|
+ text is skipped, so ax_start values are consecutive across runs
|
|
+ while charpos values may have gaps. */
|
|
+typedef struct ns_ax_visible_run
|
|
+{
|
|
+ ptrdiff_t charpos; /* Buffer charpos where this visible run starts. */
|
|
+ ptrdiff_t length; /* Number of visible Emacs characters in this run. */
|
|
+ NSUInteger ax_start; /* Starting index in the accessibility string. */
|
|
+ NSUInteger ax_length; /* Length in accessibility string (UTF-16 units). */
|
|
+} ns_ax_visible_run;
|
|
+
|
|
+/* Virtual AXTextArea element — one per visible Emacs window (buffer). */
|
|
+@interface EmacsAccessibilityBuffer : EmacsAccessibilityElement <NSAccessibility>
|
|
+{
|
|
+ ns_ax_visible_run *visibleRuns;
|
|
+ NSUInteger visibleRunCount;
|
|
+ NSMutableArray *cachedInteractiveSpans;
|
|
+ BOOL interactiveSpansDirty;
|
|
+}
|
|
+@property (nonatomic, retain) NSString *cachedText;
|
|
+@property (nonatomic, assign) ptrdiff_t cachedTextModiff;
|
|
+@property (nonatomic, assign) ptrdiff_t cachedOverlayModiff;
|
|
+@property (nonatomic, assign) ptrdiff_t cachedTextStart;
|
|
+@property (nonatomic, assign) ptrdiff_t cachedModiff;
|
|
+@property (nonatomic, assign) ptrdiff_t cachedPoint;
|
|
+@property (nonatomic, assign) BOOL cachedMarkActive;
|
|
+@property (nonatomic, copy) NSString *cachedCompletionAnnouncement;
|
|
+@property (nonatomic, assign) ptrdiff_t cachedCompletionOverlayStart;
|
|
+@property (nonatomic, assign) ptrdiff_t cachedCompletionOverlayEnd;
|
|
+@property (nonatomic, assign) ptrdiff_t cachedCompletionPoint;
|
|
+- (void)invalidateTextCache;
|
|
+- (void)postAccessibilityNotificationsForFrame:(struct frame *)f;
|
|
+- (ptrdiff_t)charposForAccessibilityIndex:(NSUInteger)ax_idx;
|
|
+- (NSUInteger)accessibilityIndexForCharpos:(ptrdiff_t)charpos;
|
|
+@end
|
|
+
|
|
+/* Virtual AXStaticText element — one per mode line. */
|
|
+@interface EmacsAccessibilityModeLine : EmacsAccessibilityElement
|
|
+@end
|
|
+
|
|
+/* Span types for interactive AX child elements. */
|
|
+typedef NS_ENUM (NSInteger, EmacsAXSpanType)
|
|
+{
|
|
+ EmacsAXSpanTypeNone = -1,
|
|
+ EmacsAXSpanTypeButton = 0,
|
|
+ EmacsAXSpanTypeLink = 1,
|
|
+ EmacsAXSpanTypeCompletionItem = 2,
|
|
+ EmacsAXSpanTypeWidget = 3,
|
|
+};
|
|
+
|
|
+/* A lightweight AX element representing one interactive text span
|
|
+ (button, link, checkbox, completion candidate, etc.) within a buffer
|
|
+ window. Exposed as AX child of EmacsAccessibilityBuffer so VoiceOver
|
|
+ Tab navigation can reach individual interactive elements. */
|
|
+@interface EmacsAccessibilityInteractiveSpan : EmacsAccessibilityElement
|
|
+
|
|
+@property (nonatomic, assign) ptrdiff_t charposStart;
|
|
+@property (nonatomic, assign) ptrdiff_t charposEnd;
|
|
+@property (nonatomic, assign) EmacsAXSpanType spanType;
|
|
+@property (nonatomic, copy) NSString *spanLabel;
|
|
+@property (nonatomic, copy) NSString *spanValue;
|
|
+@property (nonatomic, unsafe_unretained) EmacsAccessibilityBuffer *parentBuffer;
|
|
+
|
|
+- (NSAccessibilityRole) accessibilityRole;
|
|
+- (NSString *) accessibilityLabel;
|
|
+- (NSRect) accessibilityFrame;
|
|
+- (BOOL) isAccessibilityElement;
|
|
+- (BOOL) isAccessibilityFocused;
|
|
+- (void) setAccessibilityFocused: (BOOL) focused;
|
|
+
|
|
+@end
|
|
+#endif /* NS_IMPL_COCOA */
|
|
+
|
|
+
|
|
/* ==========================================================================
|
|
|
|
The main Emacs view
|
|
@@ -471,6 +575,14 @@ enum ns_return_frame_mode
|
|
#ifdef NS_IMPL_COCOA
|
|
char *old_title;
|
|
BOOL maximizing_resize;
|
|
+ NSMutableArray *accessibilityElements;
|
|
+ /* See GC safety comment on EmacsAccessibilityElement.lispWindow. */
|
|
+ Lisp_Object lastSelectedWindow;
|
|
+ Lisp_Object lastRootWindow;
|
|
+ BOOL accessibilityTreeValid;
|
|
+ BOOL accessibilityUpdating;
|
|
+ @public /* Accessed by ns_draw_phys_cursor (C function). */
|
|
+ NSRect lastAccessibilityCursorRect;
|
|
#endif
|
|
BOOL font_panel_active;
|
|
NSFont *font_panel_result;
|
|
@@ -528,6 +640,13 @@ enum ns_return_frame_mode
|
|
- (void)windowWillExitFullScreen;
|
|
- (void)windowDidExitFullScreen;
|
|
- (void)windowDidBecomeKey;
|
|
+
|
|
+#ifdef NS_IMPL_COCOA
|
|
+/* Accessibility support. */
|
|
+- (void)rebuildAccessibilityTree;
|
|
+- (void)invalidateAccessibilityTree;
|
|
+- (void)postAccessibilityUpdates;
|
|
+#endif
|
|
@end
|
|
|
|
|
|
diff --git a/src/nsterm.m b/src/nsterm.m
|
|
index 74e4ad5..c47912d 100644
|
|
--- a/src/nsterm.m
|
|
+++ b/src/nsterm.m
|
|
@@ -46,6 +46,7 @@ GNUstep port and post-20 update by Adrian Robert (arobert@cogsci.ucsd.edu)
|
|
#include "blockinput.h"
|
|
#include "sysselect.h"
|
|
#include "nsterm.h"
|
|
+#include "intervals.h" /* TEXT_PROP_MEANS_INVISIBLE */
|
|
#include "systime.h"
|
|
#include "character.h"
|
|
#include "xwidget.h"
|
|
@@ -6856,6 +6857,2311 @@ ns_create_font_panel_buttons (id target, SEL select, SEL cancel_action)
|
|
}
|
|
#endif
|
|
|
|
+/* ==========================================================================
|
|
+
|
|
+ Accessibility virtual elements (macOS / Cocoa only)
|
|
+
|
|
+ ========================================================================== */
|
|
+
|
|
+#ifdef NS_IMPL_COCOA
|
|
+
|
|
+/* ---- Helper: extract buffer text for accessibility ---- */
|
|
+
|
|
+/* Maximum characters exposed via accessibilityValue. */
|
|
+/* Cap accessibility text at 100,000 UTF-16 units (~200 KB). VoiceOver
|
|
+ performance degrades beyond this; buffers larger than ~50,000 lines
|
|
+ are truncated for accessibility purposes. */
|
|
+#define NS_AX_TEXT_CAP 100000
|
|
+
|
|
+/* 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
|
|
+ with the count. Caller must free *OUT_RUNS with xfree(). */
|
|
+
|
|
+static NSString *
|
|
+ns_ax_buffer_text (struct window *w, ptrdiff_t *out_start,
|
|
+ ns_ax_visible_run **out_runs, NSUInteger *out_nruns)
|
|
+{
|
|
+ *out_runs = NULL;
|
|
+ *out_nruns = 0;
|
|
+
|
|
+ if (!w || !WINDOW_LEAF_P (w))
|
|
+ {
|
|
+ *out_start = 0;
|
|
+ return @"";
|
|
+ }
|
|
+
|
|
+ struct buffer *b = XBUFFER (w->contents);
|
|
+ if (!b)
|
|
+ {
|
|
+ *out_start = 0;
|
|
+ return @"";
|
|
+ }
|
|
+
|
|
+ ptrdiff_t begv = BUF_BEGV (b);
|
|
+ ptrdiff_t zv = BUF_ZV (b);
|
|
+
|
|
+ *out_start = begv;
|
|
+
|
|
+ if (zv <= begv)
|
|
+ return @"";
|
|
+
|
|
+ specpdl_ref count = SPECPDL_INDEX ();
|
|
+ record_unwind_current_buffer ();
|
|
+ if (b != current_buffer)
|
|
+ set_buffer_internal_1 (b);
|
|
+
|
|
+ /* First pass: count visible runs to allocate the mapping array. */
|
|
+ NSUInteger run_capacity = 64;
|
|
+ ns_ax_visible_run *runs = xmalloc (run_capacity
|
|
+ * sizeof (ns_ax_visible_run));
|
|
+ NSUInteger nruns = 0;
|
|
+ NSUInteger ax_offset = 0;
|
|
+
|
|
+ NSMutableString *result = [NSMutableString string];
|
|
+ ptrdiff_t pos = begv;
|
|
+
|
|
+ while (pos < zv)
|
|
+ {
|
|
+ /* Check invisible property (text properties + overlays).
|
|
+ Use TEXT_PROP_MEANS_INVISIBLE which respects buffer-invisibility-spec,
|
|
+ matching the logic in xdisp.c. This correctly handles org-mode,
|
|
+ outline-mode, hideshow and any mode using spec-controlled
|
|
+ invisibility (not just `invisible t'). */
|
|
+ Lisp_Object invis = Fget_char_property (make_fixnum (pos),
|
|
+ Qinvisible, Qnil);
|
|
+ if (TEXT_PROP_MEANS_INVISIBLE (invis))
|
|
+ {
|
|
+ /* Skip to the next position where invisible changes. */
|
|
+ Lisp_Object next = Fnext_single_char_property_change (
|
|
+ make_fixnum (pos), Qinvisible, Qnil, make_fixnum (zv));
|
|
+ pos = FIXNUMP (next) ? XFIXNUM (next) : zv;
|
|
+ continue;
|
|
+ }
|
|
+
|
|
+ /* Find end of this visible run: where invisible property changes. */
|
|
+ Lisp_Object next = Fnext_single_char_property_change (
|
|
+ make_fixnum (pos), Qinvisible, Qnil, make_fixnum (zv));
|
|
+ ptrdiff_t run_end = FIXNUMP (next) ? XFIXNUM (next) : zv;
|
|
+
|
|
+ /* Cap total text at NS_AX_TEXT_CAP. */
|
|
+ ptrdiff_t run_len = run_end - pos;
|
|
+ if (ax_offset + (NSUInteger) run_len > NS_AX_TEXT_CAP)
|
|
+ run_len = (ptrdiff_t) (NS_AX_TEXT_CAP - ax_offset);
|
|
+ if (run_len <= 0)
|
|
+ break;
|
|
+ run_end = pos + run_len;
|
|
+
|
|
+ /* Extract this visible run's text. Use
|
|
+ Fbuffer_substring_no_properties which correctly handles the
|
|
+ buffer gap — raw BUF_BYTE_ADDRESS reads across the gap would
|
|
+ include garbage bytes when the run spans the gap position. */
|
|
+ Lisp_Object lstr = Fbuffer_substring_no_properties (
|
|
+ make_fixnum (pos), make_fixnum (run_end));
|
|
+ NSString *nsstr = [NSString stringWithLispString:lstr];
|
|
+ NSUInteger ns_len = [nsstr length];
|
|
+ [result appendString:nsstr];
|
|
+
|
|
+ /* Record this visible run in the mapping. */
|
|
+ if (nruns >= run_capacity)
|
|
+ {
|
|
+ run_capacity *= 2;
|
|
+ runs = xrealloc (runs, run_capacity
|
|
+ * sizeof (ns_ax_visible_run));
|
|
+ }
|
|
+ runs[nruns].charpos = pos;
|
|
+ runs[nruns].length = run_len;
|
|
+ runs[nruns].ax_start = ax_offset;
|
|
+ runs[nruns].ax_length = ns_len;
|
|
+ nruns++;
|
|
+
|
|
+ ax_offset += ns_len;
|
|
+ pos = run_end;
|
|
+ }
|
|
+
|
|
+ unbind_to (count, Qnil);
|
|
+
|
|
+ *out_runs = runs;
|
|
+ *out_nruns = nruns;
|
|
+ return result;
|
|
+}
|
|
+
|
|
+
|
|
+/* ---- Helper: extract mode line text from glyph rows ---- */
|
|
+
|
|
+/* TODO: Only CHAR_GLYPH characters (>= 32) are extracted. Image
|
|
+ glyphs, stretch glyphs, and composed glyphs are silently skipped.
|
|
+ Mode lines using icon fonts (e.g. doom-modeline with nerd-font)
|
|
+ will produce incomplete accessibility text. */
|
|
+static NSString *
|
|
+ns_ax_mode_line_text (struct window *w)
|
|
+{
|
|
+ if (!w || !w->current_matrix)
|
|
+ return @"";
|
|
+
|
|
+ struct glyph_matrix *matrix = w->current_matrix;
|
|
+ NSMutableString *text = [NSMutableString string];
|
|
+
|
|
+ for (int i = 0; i < matrix->nrows; i++)
|
|
+ {
|
|
+ struct glyph_row *row = matrix->rows + i;
|
|
+ if (!row->enabled_p || !row->mode_line_p)
|
|
+ continue;
|
|
+
|
|
+ struct glyph *g = row->glyphs[TEXT_AREA];
|
|
+ struct glyph *end = g + row->used[TEXT_AREA];
|
|
+ for (; g < end; g++)
|
|
+ {
|
|
+ if (g->type == CHAR_GLYPH && g->u.ch >= 32)
|
|
+ {
|
|
+ unichar uch = (unichar) g->u.ch;
|
|
+ [text appendString:[NSString stringWithCharacters:&uch
|
|
+ length:1]];
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ return text;
|
|
+}
|
|
+
|
|
+
|
|
+/* ---- Helper: screen rect for a character range via glyph matrix ---- */
|
|
+
|
|
+static NSRect
|
|
+ns_ax_frame_for_range (struct window *w, EmacsView *view,
|
|
+ ptrdiff_t charpos_start,
|
|
+ ptrdiff_t charpos_len)
|
|
+{
|
|
+ if (!w || !w->current_matrix || !view)
|
|
+ return NSZeroRect;
|
|
+
|
|
+ /* charpos_start and charpos_len are already in buffer charpos
|
|
+ space — the caller maps AX string indices through
|
|
+ charposForAccessibilityIndex which handles invisible text. */
|
|
+ ptrdiff_t cp_start = charpos_start;
|
|
+ ptrdiff_t cp_end = cp_start + charpos_len;
|
|
+
|
|
+ struct glyph_matrix *matrix = w->current_matrix;
|
|
+ NSRect result = NSZeroRect;
|
|
+ BOOL found = NO;
|
|
+
|
|
+ for (int i = 0; i < matrix->nrows; i++)
|
|
+ {
|
|
+ struct glyph_row *row = matrix->rows + i;
|
|
+ if (!row->enabled_p || row->mode_line_p)
|
|
+ continue;
|
|
+ if (!row->displays_text_p && !row->ends_at_zv_p)
|
|
+ continue;
|
|
+
|
|
+ ptrdiff_t row_start = MATRIX_ROW_START_CHARPOS (row);
|
|
+ ptrdiff_t row_end = MATRIX_ROW_END_CHARPOS (row);
|
|
+
|
|
+ if (row_start < cp_end && row_end > cp_start)
|
|
+ {
|
|
+ int window_x, window_y, window_width;
|
|
+ window_box (w, TEXT_AREA, &window_x, &window_y,
|
|
+ &window_width, 0);
|
|
+
|
|
+ NSRect rowRect;
|
|
+ rowRect.origin.x = window_x;
|
|
+ rowRect.origin.y = WINDOW_TO_FRAME_PIXEL_Y (w, MAX (0, row->y));
|
|
+ rowRect.origin.y = MAX (rowRect.origin.y, window_y);
|
|
+ rowRect.size.width = window_width;
|
|
+ rowRect.size.height = row->height;
|
|
+
|
|
+ if (!found)
|
|
+ {
|
|
+ result = rowRect;
|
|
+ found = YES;
|
|
+ }
|
|
+ else
|
|
+ result = NSUnionRect (result, rowRect);
|
|
+ }
|
|
+ }
|
|
+
|
|
+ if (!found)
|
|
+ return NSZeroRect;
|
|
+
|
|
+ /* Clip result to text area bounds. */
|
|
+ {
|
|
+ int text_area_x, text_area_y, text_area_w, text_area_h;
|
|
+ window_box (w, TEXT_AREA, &text_area_x, &text_area_y,
|
|
+ &text_area_w, &text_area_h);
|
|
+ CGFloat max_y = WINDOW_TO_FRAME_PIXEL_Y (w, text_area_y + text_area_h);
|
|
+ if (NSMaxY (result) > max_y)
|
|
+ result.size.height = max_y - result.origin.y;
|
|
+ }
|
|
+
|
|
+ /* Convert from EmacsView (flipped) coords to screen coords. */
|
|
+ NSRect winRect = [view convertRect:result toView:nil];
|
|
+ return [[view window] convertRectToScreen:winRect];
|
|
+}
|
|
+
|
|
+/* AX enum numeric compatibility for NSAccessibility notifications.
|
|
+ Values match WebKit AXObjectCacheMac fallback enums
|
|
+ (AXTextStateChangeType / AXTextEditType / AXTextSelectionDirection /
|
|
+ AXTextSelectionGranularity). */
|
|
+enum {
|
|
+ ns_ax_text_state_change_unknown = 0,
|
|
+ ns_ax_text_state_change_edit = 1,
|
|
+ ns_ax_text_state_change_selection_move = 2,
|
|
+
|
|
+ ns_ax_text_edit_type_typing = 3,
|
|
+
|
|
+ ns_ax_text_selection_direction_unknown = 0,
|
|
+ ns_ax_text_selection_direction_previous = 3,
|
|
+ ns_ax_text_selection_direction_next = 4,
|
|
+ ns_ax_text_selection_direction_discontiguous = 5,
|
|
+
|
|
+ ns_ax_text_selection_granularity_unknown = 0,
|
|
+ ns_ax_text_selection_granularity_character = 1,
|
|
+ ns_ax_text_selection_granularity_word = 2,
|
|
+ ns_ax_text_selection_granularity_line = 3,
|
|
+};
|
|
+
|
|
+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;
|
|
+}
|
|
+
|
|
+/* ===================================================================
|
|
+ EmacsAccessibilityInteractiveSpan — helpers and implementation
|
|
+ =================================================================== */
|
|
+
|
|
+/* Extract announcement string from completion--string property value.
|
|
+ The property can be a plain Lisp string (simple completion) or
|
|
+ a list ("candidate" "annotation") for annotated completions.
|
|
+ Returns nil on failure. */
|
|
+static NSString *
|
|
+ns_ax_completion_string_from_prop (Lisp_Object cstr)
|
|
+{
|
|
+ if (STRINGP (cstr))
|
|
+ return [NSString stringWithLispString: cstr];
|
|
+ if (CONSP (cstr) && STRINGP (XCAR (cstr)))
|
|
+ return [NSString stringWithLispString: XCAR (cstr)];
|
|
+ return nil;
|
|
+}
|
|
+
|
|
+/* Return the Emacs buffer Lisp object for window W, or Qnil. */
|
|
+static Lisp_Object
|
|
+ns_ax_window_buffer_object (struct window *w)
|
|
+{
|
|
+ if (!w)
|
|
+ return Qnil;
|
|
+ if (!BUFFERP (w->contents))
|
|
+ return Qnil;
|
|
+ return w->contents;
|
|
+}
|
|
+
|
|
+/* Compute visible-end charpos for window W.
|
|
+ Emacs stores it as BUF_Z - window_end_pos.
|
|
+ Falls back to BUF_ZV when window_end_valid is false (e.g., when
|
|
+ called from an AX getter before the next redisplay cycle). */
|
|
+static ptrdiff_t
|
|
+ns_ax_window_end_charpos (struct window *w, struct buffer *b)
|
|
+{
|
|
+ if (!w->window_end_valid)
|
|
+ return BUF_ZV (b);
|
|
+ return BUF_Z (b) - w->window_end_pos;
|
|
+}
|
|
+
|
|
+/* Fetch text property PROP at charpos POS in BUF_OBJ. */
|
|
+static Lisp_Object
|
|
+ns_ax_text_prop_at (ptrdiff_t pos, Lisp_Object prop, Lisp_Object buf_obj)
|
|
+{
|
|
+ Lisp_Object plist = Ftext_properties_at (make_fixnum (pos), buf_obj);
|
|
+ /* Third argument to Fplist_get is PREDICATE (Emacs 29+), not a
|
|
+ default value. Qnil selects the default `eq' comparison. */
|
|
+ return Fplist_get (plist, prop, Qnil);
|
|
+}
|
|
+
|
|
+/* Next charpos where PROP changes, capped at LIMIT. */
|
|
+static ptrdiff_t
|
|
+ns_ax_next_prop_change (ptrdiff_t pos, Lisp_Object prop,
|
|
+ Lisp_Object buf_obj, ptrdiff_t limit)
|
|
+{
|
|
+ Lisp_Object result
|
|
+ = Fnext_single_property_change (make_fixnum (pos), prop,
|
|
+ buf_obj, make_fixnum (limit));
|
|
+ return FIXNUMP (result) ? XFIXNUM (result) : limit;
|
|
+}
|
|
+
|
|
+/* Build label for span [START, END) in BUF_OBJ.
|
|
+ Priority: completion--string → buffer text → help-echo. */
|
|
+static NSString *
|
|
+ns_ax_get_span_label (ptrdiff_t start, ptrdiff_t end,
|
|
+ Lisp_Object buf_obj)
|
|
+{
|
|
+ Lisp_Object cs = ns_ax_text_prop_at (start, Qns_ax_completion__string,
|
|
+ buf_obj);
|
|
+ if (STRINGP (cs))
|
|
+ return [NSString stringWithLispString: cs];
|
|
+
|
|
+ if (end > start)
|
|
+ {
|
|
+ Lisp_Object substr = Fbuffer_substring_no_properties (
|
|
+ make_fixnum (start), make_fixnum (end));
|
|
+ if (STRINGP (substr))
|
|
+ {
|
|
+ NSString *s = [NSString stringWithLispString: substr];
|
|
+ s = [s stringByTrimmingCharactersInSet:
|
|
+ [NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
|
+ if (s.length > 0)
|
|
+ return s;
|
|
+ }
|
|
+ }
|
|
+
|
|
+ Lisp_Object he = ns_ax_text_prop_at (start, Qhelp_echo, buf_obj);
|
|
+ if (STRINGP (he))
|
|
+ return [NSString stringWithLispString: he];
|
|
+
|
|
+ return @"";
|
|
+}
|
|
+
|
|
+/* Post AX notifications asynchronously to prevent deadlock.
|
|
+ NSAccessibilityPostNotification may synchronously invoke VoiceOver
|
|
+ callbacks that dispatch_sync back to the main queue. If we are
|
|
+ already on the main queue (e.g., inside postAccessibilityUpdates
|
|
+ called from ns_update_end), that dispatch_sync deadlocks.
|
|
+ Deferring via dispatch_async lets the current method return first,
|
|
+ freeing the main queue for VoiceOver's dispatch_sync calls. */
|
|
+
|
|
+static inline void
|
|
+ns_ax_post_notification (id element,
|
|
+ NSAccessibilityNotificationName name)
|
|
+{
|
|
+ dispatch_async (dispatch_get_main_queue (), ^{
|
|
+ NSAccessibilityPostNotification (element, name);
|
|
+ });
|
|
+}
|
|
+
|
|
+static inline void
|
|
+ns_ax_post_notification_with_info (id element,
|
|
+ NSAccessibilityNotificationName name,
|
|
+ NSDictionary *info)
|
|
+{
|
|
+ dispatch_async (dispatch_get_main_queue (), ^{
|
|
+ NSAccessibilityPostNotificationWithUserInfo (element, name, info);
|
|
+ });
|
|
+}
|
|
+
|
|
+/* Scan visible range of window W for interactive spans.
|
|
+ Returns NSArray<EmacsAccessibilityInteractiveSpan *>.
|
|
+
|
|
+ Priority when properties overlap:
|
|
+ widget > button > follow-link > org-link >
|
|
+ completion-candidate > keymap-overlay. */
|
|
+static NSArray *
|
|
+ns_ax_scan_interactive_spans (struct window *w,
|
|
+ EmacsAccessibilityBuffer *parent_buf)
|
|
+{
|
|
+ if (!w)
|
|
+ return @[];
|
|
+
|
|
+ Lisp_Object buf_obj = ns_ax_window_buffer_object (w);
|
|
+ if (NILP (buf_obj))
|
|
+ return @[];
|
|
+
|
|
+ struct buffer *b = XBUFFER (buf_obj);
|
|
+ ptrdiff_t vis_start = marker_position (w->start);
|
|
+ ptrdiff_t vis_end = ns_ax_window_end_charpos (w, b);
|
|
+
|
|
+ if (vis_start < BUF_BEGV (b)) vis_start = BUF_BEGV (b);
|
|
+ if (vis_end > BUF_ZV (b)) vis_end = BUF_ZV (b);
|
|
+ if (vis_start >= vis_end)
|
|
+ return @[];
|
|
+
|
|
+ /* Symbols are interned once at startup via DEFSYM in syms_of_nsterm;
|
|
+ reference them directly here (GC-safe, no repeated obarray lookup). */
|
|
+
|
|
+ BOOL is_completion_buf = EQ (BVAR (b, major_mode), Qns_ax_completion_list_mode);
|
|
+
|
|
+ NSMutableArray *spans = [NSMutableArray array];
|
|
+ ptrdiff_t pos = vis_start;
|
|
+
|
|
+ while (pos < vis_end)
|
|
+ {
|
|
+ Lisp_Object plist = Ftext_properties_at (make_fixnum (pos), buf_obj);
|
|
+ EmacsAXSpanType span_type = EmacsAXSpanTypeNone;
|
|
+ Lisp_Object limit_prop = Qnil;
|
|
+
|
|
+ if (!NILP (Fplist_get (plist, Qns_ax_widget, Qnil)))
|
|
+ {
|
|
+ span_type = EmacsAXSpanTypeWidget;
|
|
+ limit_prop = Qns_ax_widget;
|
|
+ }
|
|
+ else if (!NILP (Fplist_get (plist, Qns_ax_button, Qnil)))
|
|
+ {
|
|
+ span_type = EmacsAXSpanTypeButton;
|
|
+ limit_prop = Qns_ax_button;
|
|
+ }
|
|
+ else if (!NILP (Fplist_get (plist, Qns_ax_follow_link, Qnil)))
|
|
+ {
|
|
+ span_type = EmacsAXSpanTypeLink;
|
|
+ limit_prop = Qns_ax_follow_link;
|
|
+ }
|
|
+ else if (!NILP (Fplist_get (plist, Qns_ax_org_link, Qnil)))
|
|
+ {
|
|
+ span_type = EmacsAXSpanTypeLink;
|
|
+ limit_prop = Qns_ax_org_link;
|
|
+ }
|
|
+ else if (is_completion_buf
|
|
+ && !NILP (Fplist_get (plist, Qmouse_face, Qnil)))
|
|
+ {
|
|
+ /* For completions, use completion--string as boundary so we
|
|
+ don't accidentally merge two column-adjacent candidates
|
|
+ whose mouse-face regions may share padding whitespace.
|
|
+ Fall back to mouse-face if completion--string is absent. */
|
|
+ Lisp_Object cs_sym = Qns_ax_completion__string;
|
|
+ Lisp_Object cs_val = ns_ax_text_prop_at (pos, cs_sym, buf_obj);
|
|
+ span_type = EmacsAXSpanTypeCompletionItem;
|
|
+ limit_prop = NILP (cs_val) ? Qmouse_face : cs_sym;
|
|
+ }
|
|
+ else
|
|
+ {
|
|
+ /* Check overlays for keymap. */
|
|
+ Lisp_Object ovs
|
|
+ = Foverlays_in (make_fixnum (pos), make_fixnum (pos + 1));
|
|
+ while (CONSP (ovs))
|
|
+ {
|
|
+ if (!NILP (Foverlay_get (XCAR (ovs), Qkeymap)))
|
|
+ {
|
|
+ span_type = EmacsAXSpanTypeButton;
|
|
+ limit_prop = Qkeymap;
|
|
+ break;
|
|
+ }
|
|
+ ovs = XCDR (ovs);
|
|
+ }
|
|
+ }
|
|
+
|
|
+ if (span_type == EmacsAXSpanTypeNone)
|
|
+ {
|
|
+ /* Skip to the next position where any interactive property
|
|
+ changes. Try each scannable property in turn and take
|
|
+ the nearest change point â O(properties) per gap rather
|
|
+ than O(chars). Fall back to pos+1 as safety net. */
|
|
+ ptrdiff_t next_interesting = vis_end;
|
|
+ Lisp_Object skip_props[5]
|
|
+ = { Qns_ax_widget, Qns_ax_button, Qns_ax_follow_link,
|
|
+ Qns_ax_org_link, Qmouse_face };
|
|
+ for (int sp = 0; sp < 5; sp++)
|
|
+ {
|
|
+ ptrdiff_t np
|
|
+ = ns_ax_next_prop_change (pos, skip_props[sp],
|
|
+ buf_obj, vis_end);
|
|
+ if (np > pos && np < next_interesting)
|
|
+ next_interesting = np;
|
|
+ }
|
|
+ /* Also check overlay keymap changes. */
|
|
+ Lisp_Object np_ov
|
|
+ = Fnext_single_char_property_change (make_fixnum (pos),
|
|
+ Qkeymap, buf_obj,
|
|
+ make_fixnum (vis_end));
|
|
+ if (FIXNUMP (np_ov))
|
|
+ {
|
|
+ ptrdiff_t npv = XFIXNUM (np_ov);
|
|
+ if (npv > pos && npv < next_interesting)
|
|
+ next_interesting = npv;
|
|
+ }
|
|
+ pos = (next_interesting > pos) ? next_interesting : pos + 1;
|
|
+ continue;
|
|
+ }
|
|
+
|
|
+ ptrdiff_t span_end = !NILP (limit_prop)
|
|
+ ? ns_ax_next_prop_change (pos, limit_prop, buf_obj, vis_end)
|
|
+ : pos + 1;
|
|
+
|
|
+ if (span_end > vis_end) span_end = vis_end;
|
|
+ if (span_end <= pos) span_end = pos + 1;
|
|
+
|
|
+ EmacsAccessibilityInteractiveSpan *span
|
|
+ = [[EmacsAccessibilityInteractiveSpan alloc] init];
|
|
+ span.charposStart = pos;
|
|
+ span.charposEnd = span_end;
|
|
+ span.spanType = span_type;
|
|
+ span.parentBuffer = parent_buf;
|
|
+ span.emacsView = parent_buf.emacsView;
|
|
+ span.lispWindow = parent_buf.lispWindow;
|
|
+ span.spanLabel = ns_ax_get_span_label (pos, span_end, buf_obj);
|
|
+
|
|
+ [spans addObject: span];
|
|
+ [span release];
|
|
+
|
|
+ pos = span_end;
|
|
+ }
|
|
+
|
|
+ return [[spans copy] autorelease];
|
|
+}
|
|
+
|
|
+@implementation EmacsAccessibilityInteractiveSpan
|
|
+@synthesize spanLabel;
|
|
+@synthesize spanValue;
|
|
+
|
|
+- (void)dealloc
|
|
+{
|
|
+ [spanLabel release];
|
|
+ [spanValue release];
|
|
+ [super dealloc];
|
|
+}
|
|
+
|
|
+- (BOOL) isAccessibilityElement { return YES; }
|
|
+
|
|
+- (NSAccessibilityRole) accessibilityRole
|
|
+{
|
|
+ switch (self.spanType)
|
|
+ {
|
|
+ case EmacsAXSpanTypeLink: return NSAccessibilityLinkRole;
|
|
+ default: return NSAccessibilityButtonRole;
|
|
+ }
|
|
+}
|
|
+
|
|
+- (NSString *) accessibilityLabel { return self.spanLabel ?: @""; }
|
|
+- (NSString *) accessibilityValue { return self.spanValue; }
|
|
+
|
|
+- (NSRect) accessibilityFrame
|
|
+{
|
|
+ EmacsAccessibilityBuffer *pb = self.parentBuffer;
|
|
+ if (!pb || ![self validWindow])
|
|
+ return NSZeroRect;
|
|
+ NSUInteger ax_s = [pb accessibilityIndexForCharpos: self.charposStart];
|
|
+ NSUInteger ax_e = [pb accessibilityIndexForCharpos: self.charposEnd];
|
|
+ if (ax_e < ax_s) ax_e = ax_s;
|
|
+ return [pb accessibilityFrameForRange: NSMakeRange (ax_s, ax_e - ax_s)];
|
|
+}
|
|
+
|
|
+- (BOOL) isAccessibilityFocused
|
|
+{
|
|
+ /* Read the cached point stored by EmacsAccessibilityBuffer on the main
|
|
+ thread — safe to read from any thread (plain ptrdiff_t, no Lisp calls). */
|
|
+ EmacsAccessibilityBuffer *pb = self.parentBuffer;
|
|
+ if (!pb)
|
|
+ return NO;
|
|
+ ptrdiff_t pt = pb.cachedPoint;
|
|
+ return pt >= self.charposStart && pt < self.charposEnd;
|
|
+}
|
|
+
|
|
+- (void) setAccessibilityFocused: (BOOL) focused
|
|
+{
|
|
+ if (!focused)
|
|
+ return;
|
|
+ ptrdiff_t target = self.charposStart;
|
|
+ Lisp_Object lwin = self.lispWindow;
|
|
+ dispatch_async (dispatch_get_main_queue (), ^{
|
|
+ /* lwin is a Lisp_Object captured by value. This is GC-safe
|
|
+ because Lisp_Objects are tagged integers/pointers that
|
|
+ remain valid across GC — GC does not relocate objects in
|
|
+ Emacs. The WINDOW_LIVE_P check below guards against the
|
|
+ window being deleted between capture and execution. */
|
|
+ if (!WINDOWP (lwin) || NILP (Fwindow_live_p (lwin)))
|
|
+ return;
|
|
+ /* Use specpdl unwind protection so that block_input is always
|
|
+ matched by unblock_input, even if Fselect_window signals. */
|
|
+ specpdl_ref count = SPECPDL_INDEX ();
|
|
+ record_unwind_protect_void (unblock_input);
|
|
+ block_input ();
|
|
+ record_unwind_current_buffer ();
|
|
+ Fselect_window (lwin, Qnil);
|
|
+ struct window *w = XWINDOW (lwin);
|
|
+ struct buffer *b = XBUFFER (w->contents);
|
|
+ if (b != current_buffer)
|
|
+ set_buffer_internal_1 (b);
|
|
+ ptrdiff_t pos = target;
|
|
+ if (pos < BUF_BEGV (b)) pos = BUF_BEGV (b);
|
|
+ if (pos > BUF_ZV (b)) pos = BUF_ZV (b);
|
|
+ SET_PT_BOTH (pos, CHAR_TO_BYTE (pos));
|
|
+ unbind_to (count, Qnil);
|
|
+ });
|
|
+}
|
|
+
|
|
+@end
|
|
+
|
|
+/* EmacsAccessibilityBuffer — InteractiveSpans category.
|
|
+ Methods are kept here (same .m file) so they access the ivars
|
|
+ declared in the @interface ivar block. */
|
|
+@implementation EmacsAccessibilityBuffer (InteractiveSpans)
|
|
+
|
|
+- (void) invalidateInteractiveSpans
|
|
+{
|
|
+ interactiveSpansDirty = YES;
|
|
+}
|
|
+
|
|
+- (NSArray *) accessibilityChildrenInNavigationOrder
|
|
+{
|
|
+ if (!interactiveSpansDirty && cachedInteractiveSpans != nil)
|
|
+ return cachedInteractiveSpans;
|
|
+
|
|
+ if (![NSThread isMainThread])
|
|
+ {
|
|
+ __block NSArray *result;
|
|
+ dispatch_sync (dispatch_get_main_queue (), ^{
|
|
+ result = [self accessibilityChildrenInNavigationOrder];
|
|
+ });
|
|
+ return result;
|
|
+ }
|
|
+
|
|
+ struct window *w = [self validWindow];
|
|
+ if (!w)
|
|
+ return cachedInteractiveSpans ? cachedInteractiveSpans : @[];
|
|
+
|
|
+ /* Validate buffer before scanning. The Lisp calls inside
|
|
+ ns_ax_scan_interactive_spans (Ftext_properties_at, Fplist_get,
|
|
+ Fnext_single_property_change) do not signal on valid buffers
|
|
+ with valid positions. Verify those preconditions here so we
|
|
+ never enter the scan with invalid state, which could longjmp
|
|
+ out of a dispatch_sync block and deadlock the AX thread. */
|
|
+ if (!BUFFERP (w->contents) || !XBUFFER (w->contents))
|
|
+ return cachedInteractiveSpans ? cachedInteractiveSpans : @[];
|
|
+
|
|
+ NSArray *spans = ns_ax_scan_interactive_spans (w, self);
|
|
+
|
|
+ if (!cachedInteractiveSpans)
|
|
+ cachedInteractiveSpans = [[NSMutableArray alloc] init];
|
|
+ [cachedInteractiveSpans setArray: spans];
|
|
+ interactiveSpansDirty = NO;
|
|
+
|
|
+ return cachedInteractiveSpans;
|
|
+}
|
|
+
|
|
+@end
|
|
+
|
|
+
|
|
+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 EmacsAccessibilityElement
|
|
+
|
|
+- (instancetype)init
|
|
+{
|
|
+ self = [super init];
|
|
+ if (self)
|
|
+ self.lispWindow = Qnil;
|
|
+ return self;
|
|
+}
|
|
+
|
|
+/* Return the associated Emacs window if it is still live, else NULL.
|
|
+ Use this instead of storing a raw struct window * which can become a
|
|
+ dangling pointer after delete-window or kill-buffer. */
|
|
+- (struct window *)validWindow
|
|
+{
|
|
+ if (NILP (self.lispWindow) || !WINDOW_LIVE_P (self.lispWindow))
|
|
+ return NULL;
|
|
+ return XWINDOW (self.lispWindow);
|
|
+}
|
|
+
|
|
+- (NSRect)screenRectFromEmacsX:(int)x y:(int)y width:(int)ew height:(int)eh
|
|
+{
|
|
+ EmacsView *view = self.emacsView;
|
|
+ if (!view || ![view window])
|
|
+ return NSZeroRect;
|
|
+
|
|
+ NSRect r = NSMakeRect (x, y, ew, eh);
|
|
+ NSRect winRect = [view convertRect:r toView:nil];
|
|
+ return [[view window] convertRectToScreen:winRect];
|
|
+}
|
|
+
|
|
+- (BOOL)isAccessibilityElement
|
|
+{
|
|
+ return YES;
|
|
+}
|
|
+
|
|
+/* ---- Hierarchy plumbing (required for VoiceOver to find us) ---- */
|
|
+
|
|
+- (id)accessibilityParent
|
|
+{
|
|
+ return NSAccessibilityUnignoredAncestor (self.emacsView);
|
|
+}
|
|
+
|
|
+- (id)accessibilityWindow
|
|
+{
|
|
+ return [self.emacsView window];
|
|
+}
|
|
+
|
|
+- (id)accessibilityTopLevelUIElement
|
|
+{
|
|
+ return [self.emacsView window];
|
|
+}
|
|
+
|
|
+@end
|
|
+
|
|
+
|
|
+@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);
|
|
+ [super dealloc];
|
|
+}
|
|
+
|
|
+/* ---- Text cache ---- */
|
|
+
|
|
+- (void)invalidateTextCache
|
|
+{
|
|
+ @synchronized (self)
|
|
+ {
|
|
+ [cachedText release];
|
|
+ cachedText = nil;
|
|
+ if (visibleRuns)
|
|
+ {
|
|
+ xfree (visibleRuns);
|
|
+ visibleRuns = NULL;
|
|
+ }
|
|
+ visibleRunCount = 0;
|
|
+ }
|
|
+ [self invalidateInteractiveSpans];
|
|
+}
|
|
+
|
|
+- (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;
|
|
+ }
|
|
+}
|
|
+
|
|
+/* ---- 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 */
|
|
+}
|
|
+
|
|
+/* ---- 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];
|
|
+
|
|
+ /* Count lines by iterating lineRangeForRange from the start.
|
|
+ Each call jumps an entire line â O(lines) not O(chars). */
|
|
+ NSInteger line = 0;
|
|
+ NSUInteger scan = 0;
|
|
+ NSUInteger len = [cachedText length];
|
|
+ while (scan < point_idx && scan < len)
|
|
+ {
|
|
+ NSRange lr = [cachedText lineRangeForRange:NSMakeRange (scan, 0)];
|
|
+ NSUInteger next = NSMaxRange (lr);
|
|
+ if (next <= scan) break; /* safety */
|
|
+ if (next > point_idx) break;
|
|
+ line++;
|
|
+ scan = next;
|
|
+ }
|
|
+ return line;
|
|
+}
|
|
+
|
|
+- (NSString *)accessibilityStringForRange:(NSRange)range
|
|
+{
|
|
+ if (![NSThread isMainThread])
|
|
+ {
|
|
+ __block NSString *result;
|
|
+ dispatch_sync (dispatch_get_main_queue (), ^{
|
|
+ result = [self accessibilityStringForRange:range];
|
|
+ });
|
|
+ return result;
|
|
+ }
|
|
+ [self ensureTextCache];
|
|
+ if (!cachedText || range.location + range.length > [cachedText length])
|
|
+ return @"";
|
|
+ return [cachedText substringWithRange:range];
|
|
+}
|
|
+
|
|
+- (NSAttributedString *)accessibilityAttributedStringForRange:(NSRange)range
|
|
+{
|
|
+ NSString *str = [self accessibilityStringForRange:range];
|
|
+ return [[[NSAttributedString alloc] initWithString:str] autorelease];
|
|
+}
|
|
+
|
|
+- (NSInteger)accessibilityLineForIndex:(NSInteger)index
|
|
+{
|
|
+ if (![NSThread isMainThread])
|
|
+ {
|
|
+ __block NSInteger result;
|
|
+ dispatch_sync (dispatch_get_main_queue (), ^{
|
|
+ result = [self accessibilityLineForIndex:index];
|
|
+ });
|
|
+ return result;
|
|
+ }
|
|
+ [self ensureTextCache];
|
|
+ if (!cachedText || index < 0)
|
|
+ return 0;
|
|
+
|
|
+ NSUInteger idx = (NSUInteger) index;
|
|
+ if (idx > [cachedText length])
|
|
+ idx = [cachedText length];
|
|
+
|
|
+ /* Count lines by iterating lineRangeForRange — O(lines). */
|
|
+ NSInteger line = 0;
|
|
+ NSUInteger scan = 0;
|
|
+ NSUInteger len = [cachedText length];
|
|
+ while (scan < idx && scan < len)
|
|
+ {
|
|
+ NSRange lr = [cachedText lineRangeForRange:NSMakeRange (scan, 0)];
|
|
+ NSUInteger next = NSMaxRange (lr);
|
|
+ if (next <= scan) break;
|
|
+ if (next > idx) break;
|
|
+ line++;
|
|
+ scan = next;
|
|
+ }
|
|
+ return line;
|
|
+}
|
|
+
|
|
+- (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);
|
|
+
|
|
+ /* Skip to the requested line using lineRangeForRange â O(lines)
|
|
+ not O(chars), consistent with accessibilityLineForIndex:. */
|
|
+ NSInteger cur_line = 0;
|
|
+ NSUInteger scan = 0;
|
|
+ while (cur_line < line && scan < len)
|
|
+ {
|
|
+ NSRange lr = [cachedText lineRangeForRange:NSMakeRange (scan, 0)];
|
|
+ NSUInteger next = NSMaxRange (lr);
|
|
+ if (next <= scan) break; /* safety */
|
|
+ cur_line++;
|
|
+ scan = next;
|
|
+ }
|
|
+ if (cur_line != line)
|
|
+ return NSMakeRange (NSNotFound, 0);
|
|
+
|
|
+ /* Return the range of the target line. */
|
|
+ if (scan >= len)
|
|
+ return NSMakeRange (len, 0); /* phantom line after final newline */
|
|
+ NSRange lr = [cachedText lineRangeForRange:NSMakeRange (scan, 0)];
|
|
+ return lr;
|
|
+}
|
|
+
|
|
+- (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. */
|
|
+- (void)postTextChangedNotification:(ptrdiff_t)point
|
|
+{
|
|
+ /* Capture changed char before invalidating cache. */
|
|
+ NSString *changedChar = @"";
|
|
+ if (point > self.cachedPoint
|
|
+ && point - self.cachedPoint == 1)
|
|
+ {
|
|
+ /* Single char inserted — refresh cache and grab it. */
|
|
+ [self invalidateTextCache];
|
|
+ [self ensureTextCache];
|
|
+ if (cachedText)
|
|
+ {
|
|
+ NSUInteger idx = [self accessibilityIndexForCharpos:point - 1];
|
|
+ if (idx < [cachedText length])
|
|
+ changedChar = [cachedText substringWithRange:
|
|
+ NSMakeRange (idx, 1)];
|
|
+ }
|
|
+ }
|
|
+ else
|
|
+ {
|
|
+ [self invalidateTextCache];
|
|
+ }
|
|
+
|
|
+ /* Update cachedPoint here so the selection-move branch does NOT
|
|
+ fire for point changes caused by edits. WebKit and Chromium
|
|
+ never send both ValueChanged and SelectedTextChanged for the
|
|
+ same user action — they are mutually exclusive. */
|
|
+ self.cachedPoint = point;
|
|
+
|
|
+ NSDictionary *change = @{
|
|
+ @"AXTextEditType": @(ns_ax_text_edit_type_typing),
|
|
+ @"AXTextChangeValue": changedChar,
|
|
+ @"AXTextChangeValueLength": @([changedChar length])
|
|
+ };
|
|
+ NSDictionary *userInfo = @{
|
|
+ @"AXTextStateChangeType": @(ns_ax_text_state_change_edit),
|
|
+ @"AXTextChangeValues": @[change],
|
|
+ @"AXTextChangeElement": self
|
|
+ };
|
|
+ ns_ax_post_notification_with_info (
|
|
+ self, NSAccessibilityValueChangedNotification, userInfo);
|
|
+}
|
|
+
|
|
+/* Post SelectedTextChanged and AnnouncementRequested for the
|
|
+ focused buffer element when point or mark changes. */
|
|
+- (void)postFocusedCursorNotification:(ptrdiff_t)point
|
|
+ direction:(NSInteger)direction
|
|
+ granularity:(NSInteger)granularity
|
|
+ markActive:(BOOL)markActive
|
|
+ oldMarkActive:(BOOL)oldMarkActive
|
|
+{
|
|
+ BOOL isCharMove
|
|
+ = (!markActive && !oldMarkActive
|
|
+ && granularity
|
|
+ == ns_ax_text_selection_granularity_character);
|
|
+
|
|
+ /* Always post SelectedTextChanged to interrupt VoiceOver reading
|
|
+ and update cursor tracking / braille displays. */
|
|
+ NSMutableDictionary *moveInfo = [NSMutableDictionary dictionary];
|
|
+ moveInfo[@"AXTextStateChangeType"]
|
|
+ = @(ns_ax_text_state_change_selection_move);
|
|
+ moveInfo[@"AXTextSelectionDirection"] = @(direction);
|
|
+ moveInfo[@"AXTextChangeElement"] = self;
|
|
+ /* Omit granularity for character moves so VoiceOver does not
|
|
+ derive its own speech (it would read the wrong character
|
|
+ for evil block-cursor mode). Include it for word/line/
|
|
+ selection so VoiceOver reads the appropriate text. */
|
|
+ if (!isCharMove)
|
|
+ moveInfo[@"AXTextSelectionGranularity"] = @(granularity);
|
|
+
|
|
+ ns_ax_post_notification_with_info (
|
|
+ self,
|
|
+ NSAccessibilitySelectedTextChangedNotification,
|
|
+ moveInfo);
|
|
+
|
|
+ /* For character moves: explicit announcement of char AT point.
|
|
+ This is the ONLY speech source for character navigation.
|
|
+ Correct for evil block-cursor (cursor ON the character)
|
|
+ and harmless for insert-mode. */
|
|
+ if (isCharMove && cachedText)
|
|
+ {
|
|
+ NSUInteger point_idx
|
|
+ = [self accessibilityIndexForCharpos:point];
|
|
+ NSUInteger tlen = [cachedText length];
|
|
+ if (point_idx < tlen)
|
|
+ {
|
|
+ NSRange charRange = [cachedText
|
|
+ rangeOfComposedCharacterSequenceAtIndex: point_idx];
|
|
+ if (charRange.location != NSNotFound
|
|
+ && charRange.length > 0
|
|
+ && NSMaxRange (charRange) <= tlen)
|
|
+ {
|
|
+ NSString *ch
|
|
+ = [cachedText substringWithRange: charRange];
|
|
+ if (![ch isEqualToString: @"\n"])
|
|
+ {
|
|
+ NSDictionary *annInfo = @{
|
|
+ NSAccessibilityAnnouncementKey: ch,
|
|
+ NSAccessibilityPriorityKey:
|
|
+ @(NSAccessibilityPriorityHigh)
|
|
+ };
|
|
+ ns_ax_post_notification_with_info (
|
|
+ NSApp,
|
|
+ NSAccessibilityAnnouncementRequestedNotification,
|
|
+ annInfo);
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+
|
|
+ /* For focused line moves: always announce line text explicitly.
|
|
+ SelectedTextChanged with granularity=line works for arrow keys,
|
|
+ but C-n/C-p need the explicit announcement (VoiceOver processes
|
|
+ these keystrokes differently from arrows).
|
|
+ In completion-list-mode, read the completion candidate instead
|
|
+ of the whole line. */
|
|
+ if (cachedText
|
|
+ && granularity == ns_ax_text_selection_granularity_line)
|
|
+ {
|
|
+ NSString *announceText = nil;
|
|
+
|
|
+ /* 1. completion--string at point. */
|
|
+ Lisp_Object cstr
|
|
+ = Fget_char_property (make_fixnum (point),
|
|
+ Qns_ax_completion__string, Qnil);
|
|
+ announceText = ns_ax_completion_string_from_prop (cstr);
|
|
+
|
|
+ /* 2. Fallback: full line text. */
|
|
+ if (!announceText)
|
|
+ {
|
|
+ NSUInteger point_idx
|
|
+ = [self accessibilityIndexForCharpos:point];
|
|
+ if (point_idx <= [cachedText length])
|
|
+ {
|
|
+ NSInteger lineNum
|
|
+ = [self accessibilityLineForIndex:point_idx];
|
|
+ NSRange lineRange
|
|
+ = [self accessibilityRangeForLine:lineNum];
|
|
+ if (lineRange.location != NSNotFound
|
|
+ && lineRange.length > 0
|
|
+ && NSMaxRange (lineRange) <= [cachedText length])
|
|
+ announceText
|
|
+ = [cachedText substringWithRange:lineRange];
|
|
+ }
|
|
+ }
|
|
+
|
|
+ if (announceText)
|
|
+ {
|
|
+ announceText = [announceText
|
|
+ stringByTrimmingCharactersInSet:
|
|
+ [NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
|
+ if ([announceText length] > 0)
|
|
+ {
|
|
+ NSDictionary *annInfo = @{
|
|
+ NSAccessibilityAnnouncementKey: announceText,
|
|
+ NSAccessibilityPriorityKey:
|
|
+ @(NSAccessibilityPriorityHigh)
|
|
+ };
|
|
+ ns_ax_post_notification_with_info (
|
|
+ NSApp,
|
|
+ NSAccessibilityAnnouncementRequestedNotification,
|
|
+ annInfo);
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+}
|
|
+
|
|
+/* Post AnnouncementRequested for non-focused buffers (typically
|
|
+ *Completions* while minibuffer has keyboard focus).
|
|
+ VoiceOver does not automatically read changes in non-focused
|
|
+ elements, so we announce the selected completion explicitly. */
|
|
+- (void)postCompletionAnnouncementForBuffer:(struct buffer *)b
|
|
+ point:(ptrdiff_t)point
|
|
+{
|
|
+ NSString *announceText = nil;
|
|
+ ptrdiff_t currentOverlayStart = 0;
|
|
+ ptrdiff_t currentOverlayEnd = 0;
|
|
+
|
|
+ specpdl_ref count2 = SPECPDL_INDEX ();
|
|
+ record_unwind_current_buffer ();
|
|
+ if (b != current_buffer)
|
|
+ set_buffer_internal_1 (b);
|
|
+
|
|
+ /* 1) Prefer explicit completion candidate property. */
|
|
+ Lisp_Object cstr = Fget_char_property (make_fixnum (point),
|
|
+ Qns_ax_completion__string,
|
|
+ Qnil);
|
|
+ announceText = ns_ax_completion_string_from_prop (cstr);
|
|
+
|
|
+ /* 2) Fallback: mouse-face span at point. */
|
|
+ if (!announceText)
|
|
+ {
|
|
+ Lisp_Object mf = Fget_char_property (make_fixnum (point),
|
|
+ Qmouse_face, Qnil);
|
|
+ if (!NILP (mf))
|
|
+ {
|
|
+ ptrdiff_t begv2 = BUF_BEGV (b);
|
|
+ ptrdiff_t zv2 = BUF_ZV (b);
|
|
+
|
|
+ Lisp_Object prev_change
|
|
+ = Fprevious_single_char_property_change (
|
|
+ make_fixnum (point + 1), Qmouse_face,
|
|
+ Qnil, make_fixnum (begv2));
|
|
+ ptrdiff_t s2
|
|
+ = FIXNUMP (prev_change) ? XFIXNUM (prev_change)
|
|
+ : begv2;
|
|
+
|
|
+ Lisp_Object next_change
|
|
+ = Fnext_single_char_property_change (
|
|
+ make_fixnum (point), Qmouse_face,
|
|
+ Qnil, make_fixnum (zv2));
|
|
+ ptrdiff_t e2
|
|
+ = FIXNUMP (next_change) ? XFIXNUM (next_change)
|
|
+ : zv2;
|
|
+
|
|
+ if (e2 > s2)
|
|
+ {
|
|
+ NSUInteger ax_s = [self accessibilityIndexForCharpos:s2];
|
|
+ NSUInteger ax_e = [self accessibilityIndexForCharpos:e2];
|
|
+ if (ax_e > ax_s && ax_e <= [cachedText length])
|
|
+ announceText = [cachedText substringWithRange:
|
|
+ NSMakeRange (ax_s, ax_e - ax_s)];
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+
|
|
+ /* 3) Fallback: completions-highlight overlay at point. */
|
|
+ if (!announceText)
|
|
+ {
|
|
+ Lisp_Object faceSym = Qns_ax_completions_highlight;
|
|
+ Lisp_Object overlays = Foverlays_at (make_fixnum (point), 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))))
|
|
+ {
|
|
+ ptrdiff_t ov_start = OVERLAY_START (ov);
|
|
+ ptrdiff_t ov_end = OVERLAY_END (ov);
|
|
+ if (ov_end > ov_start)
|
|
+ {
|
|
+ announceText = ns_ax_completion_text_for_span (self, b,
|
|
+ ov_start,
|
|
+ ov_end,
|
|
+ cachedText);
|
|
+ currentOverlayStart = ov_start;
|
|
+ currentOverlayEnd = ov_end;
|
|
+ }
|
|
+ break;
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+
|
|
+ /* 4) Fallback: nearest completions-highlight overlay. */
|
|
+ if (!announceText)
|
|
+ {
|
|
+ ptrdiff_t ov_start = 0;
|
|
+ ptrdiff_t ov_end = 0;
|
|
+ if (ns_ax_find_completion_overlay_range (b, point,
|
|
+ &ov_start, &ov_end))
|
|
+ {
|
|
+ announceText = ns_ax_completion_text_for_span (self, b,
|
|
+ ov_start, ov_end,
|
|
+ cachedText);
|
|
+ currentOverlayStart = ov_start;
|
|
+ currentOverlayEnd = ov_end;
|
|
+ }
|
|
+ }
|
|
+
|
|
+ unbind_to (count2, Qnil);
|
|
+
|
|
+ /* Final fallback: read current line at point. */
|
|
+ if (!announceText)
|
|
+ {
|
|
+ NSUInteger point_idx = [self accessibilityIndexForCharpos:point];
|
|
+ if (point_idx <= [cachedText length])
|
|
+ {
|
|
+ NSInteger lineNum = [self accessibilityLineForIndex:
|
|
+ point_idx];
|
|
+ NSRange lineRange = [self accessibilityRangeForLine:lineNum];
|
|
+ if (lineRange.location != NSNotFound
|
|
+ && lineRange.length > 0
|
|
+ && lineRange.location + lineRange.length
|
|
+ <= [cachedText length])
|
|
+ announceText = [cachedText substringWithRange:lineRange];
|
|
+ }
|
|
+ }
|
|
+
|
|
+ /* Deduplicate: post only when text, overlay, or point changed. */
|
|
+ if (announceText)
|
|
+ {
|
|
+ announceText = [announceText stringByTrimmingCharactersInSet:
|
|
+ [NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
|
+ if ([announceText length] > 0)
|
|
+ {
|
|
+ BOOL textChanged = ![announceText isEqualToString:
|
|
+ self.cachedCompletionAnnouncement];
|
|
+ BOOL overlayChanged =
|
|
+ (currentOverlayStart != self.cachedCompletionOverlayStart
|
|
+ || currentOverlayEnd != self.cachedCompletionOverlayEnd);
|
|
+ BOOL pointChanged = (point != self.cachedCompletionPoint);
|
|
+ if (textChanged || overlayChanged || pointChanged)
|
|
+ {
|
|
+ NSDictionary *annInfo = @{
|
|
+ NSAccessibilityAnnouncementKey: announceText,
|
|
+ NSAccessibilityPriorityKey:
|
|
+ @(NSAccessibilityPriorityHigh)
|
|
+ };
|
|
+ ns_ax_post_notification_with_info (
|
|
+ NSApp,
|
|
+ NSAccessibilityAnnouncementRequestedNotification,
|
|
+ annInfo);
|
|
+ }
|
|
+ self.cachedCompletionAnnouncement = announceText;
|
|
+ self.cachedCompletionOverlayStart = currentOverlayStart;
|
|
+ self.cachedCompletionOverlayEnd = currentOverlayEnd;
|
|
+ self.cachedCompletionPoint = point;
|
|
+ }
|
|
+ else
|
|
+ {
|
|
+ self.cachedCompletionAnnouncement = nil;
|
|
+ self.cachedCompletionOverlayStart = 0;
|
|
+ self.cachedCompletionOverlayEnd = 0;
|
|
+ self.cachedCompletionPoint = 0;
|
|
+ }
|
|
+ }
|
|
+ else
|
|
+ {
|
|
+ self.cachedCompletionAnnouncement = nil;
|
|
+ self.cachedCompletionOverlayStart = 0;
|
|
+ self.cachedCompletionOverlayEnd = 0;
|
|
+ self.cachedCompletionPoint = 0;
|
|
+ }
|
|
+}
|
|
+
|
|
+/* ---- Notification dispatch (main entry point) ---- */
|
|
+
|
|
+/* Dispatch accessibility notifications after a redisplay cycle.
|
|
+ Detects three mutually exclusive events: text edit, cursor/mark
|
|
+ change, or no change. Delegates to helper methods above. */
|
|
+- (void)postAccessibilityNotificationsForFrame:(struct frame *)f
|
|
+{
|
|
+ NSTRACE ("[EmacsView postAccessibilityNotificationsForFrame:]");
|
|
+ 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 point = BUF_PT (b);
|
|
+ BOOL markActive = !NILP (BVAR (b, mark_active));
|
|
+
|
|
+ /* --- Text changed (edit) --- */
|
|
+ if (modiff != self.cachedModiff)
|
|
+ {
|
|
+ self.cachedModiff = modiff;
|
|
+ [self postTextChangedNotification:point];
|
|
+ }
|
|
+
|
|
+ /* --- Cursor moved or selection changed ---
|
|
+ Use 'else if' — edits and selection moves are mutually exclusive
|
|
+ per the WebKit/Chromium pattern. */
|
|
+ else if (point != self.cachedPoint || markActive != self.cachedMarkActive)
|
|
+ {
|
|
+ ptrdiff_t oldPoint = self.cachedPoint;
|
|
+ BOOL oldMarkActive = self.cachedMarkActive;
|
|
+ self.cachedPoint = point;
|
|
+ self.cachedMarkActive = markActive;
|
|
+
|
|
+ /* Compute direction. */
|
|
+ NSInteger direction = ns_ax_text_selection_direction_discontiguous;
|
|
+ if (point > oldPoint)
|
|
+ direction = ns_ax_text_selection_direction_next;
|
|
+ else if (point < oldPoint)
|
|
+ direction = ns_ax_text_selection_direction_previous;
|
|
+
|
|
+ int ctrlNP = 0;
|
|
+ bool isCtrlNP = ns_ax_event_is_line_nav_key (&ctrlNP);
|
|
+
|
|
+ /* --- Granularity detection --- */
|
|
+ NSInteger granularity = ns_ax_text_selection_granularity_unknown;
|
|
+ [self ensureTextCache];
|
|
+ if (cachedText && oldPoint > 0)
|
|
+ {
|
|
+ NSUInteger tlen = [cachedText length];
|
|
+ NSUInteger oldIdx = [self accessibilityIndexForCharpos:oldPoint];
|
|
+ NSUInteger newIdx = [self accessibilityIndexForCharpos:point];
|
|
+ if (oldIdx > tlen) oldIdx = tlen;
|
|
+ if (newIdx > tlen) newIdx = tlen;
|
|
+
|
|
+ NSRange oldLine = [cachedText lineRangeForRange:
|
|
+ NSMakeRange (oldIdx, 0)];
|
|
+ NSRange newLine = [cachedText lineRangeForRange:
|
|
+ NSMakeRange (newIdx, 0)];
|
|
+ if (oldLine.location != newLine.location)
|
|
+ granularity = ns_ax_text_selection_granularity_line;
|
|
+ else
|
|
+ {
|
|
+ NSUInteger dist = (newIdx > oldIdx
|
|
+ ? newIdx - oldIdx
|
|
+ : oldIdx - newIdx);
|
|
+ if (dist > 1)
|
|
+ granularity = ns_ax_text_selection_granularity_word;
|
|
+ else if (dist == 1)
|
|
+ granularity = ns_ax_text_selection_granularity_character;
|
|
+ }
|
|
+ }
|
|
+
|
|
+ /* Force line semantics for explicit C-n/C-p / Tab / backtab. */
|
|
+ if (isCtrlNP)
|
|
+ {
|
|
+ direction = (ctrlNP > 0
|
|
+ ? ns_ax_text_selection_direction_next
|
|
+ : ns_ax_text_selection_direction_previous);
|
|
+ granularity = ns_ax_text_selection_granularity_line;
|
|
+ }
|
|
+
|
|
+ /* Post notifications for focused and non-focused elements. */
|
|
+ if ([self isAccessibilityFocused])
|
|
+ [self postFocusedCursorNotification:point
|
|
+ direction:direction
|
|
+ granularity:granularity
|
|
+ markActive:markActive
|
|
+ oldMarkActive:oldMarkActive];
|
|
+
|
|
+ if (![self isAccessibilityFocused] && cachedText)
|
|
+ [self postCompletionAnnouncementForBuffer:b point:point];
|
|
+ }
|
|
+ else
|
|
+ {
|
|
+ /* Nothing changed. Reset completion cache for focused buffer
|
|
+ to avoid stale announcements. */
|
|
+ if ([self isAccessibilityFocused])
|
|
+ {
|
|
+ self.cachedCompletionAnnouncement = nil;
|
|
+ self.cachedCompletionOverlayStart = 0;
|
|
+ self.cachedCompletionOverlayEnd = 0;
|
|
+ self.cachedCompletionPoint = 0;
|
|
+ }
|
|
+ }
|
|
+}
|
|
+
|
|
+@end
|
|
+
|
|
+
|
|
+@implementation EmacsAccessibilityModeLine
|
|
+
|
|
+- (NSAccessibilityRole)accessibilityRole
|
|
+{
|
|
+ return NSAccessibilityStaticTextRole;
|
|
+}
|
|
+
|
|
+- (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))
|
|
+ {
|
|
+ struct buffer *b = XBUFFER (w->contents);
|
|
+ if (b)
|
|
+ {
|
|
+ Lisp_Object name = BVAR (b, name);
|
|
+ if (STRINGP (name))
|
|
+ {
|
|
+ NSString *bufName = [NSString stringWithLispString:name];
|
|
+ return [NSString stringWithFormat:@"Mode Line - %@", bufName];
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ return @"Mode Line";
|
|
+}
|
|
+
|
|
+- (id)accessibilityValue
|
|
+{
|
|
+ if (![NSThread isMainThread])
|
|
+ {
|
|
+ __block id result;
|
|
+ dispatch_sync (dispatch_get_main_queue (), ^{
|
|
+ result = [self accessibilityValue];
|
|
+ });
|
|
+ return result;
|
|
+ }
|
|
+ struct window *w = [self validWindow];
|
|
+ if (!w)
|
|
+ return @"";
|
|
+ return ns_ax_mode_line_text (w);
|
|
+}
|
|
+
|
|
+- (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 || !w->current_matrix)
|
|
+ return NSZeroRect;
|
|
+
|
|
+ /* Find the mode line row and return its screen rect. */
|
|
+ struct glyph_matrix *matrix = w->current_matrix;
|
|
+ for (int i = 0; i < matrix->nrows; i++)
|
|
+ {
|
|
+ struct glyph_row *row = matrix->rows + i;
|
|
+ if (row->enabled_p && row->mode_line_p)
|
|
+ {
|
|
+ return [self screenRectFromEmacsX:w->pixel_left
|
|
+ y:WINDOW_TO_FRAME_PIXEL_Y (w,
|
|
+ MAX (0, row->y))
|
|
+ width:w->pixel_width
|
|
+ height:row->visible_height];
|
|
+ }
|
|
+ }
|
|
+ return NSZeroRect;
|
|
+}
|
|
+
|
|
+@end
|
|
+
|
|
+#endif /* NS_IMPL_COCOA */
|
|
+
|
|
+
|
|
/* ==========================================================================
|
|
|
|
EmacsView implementation
|
|
@@ -11312,6 +13618,28 @@ syms_of_nsterm (void)
|
|
DEFSYM (Qns_drag_operation_generic, "ns-drag-operation-generic");
|
|
DEFSYM (Qns_handle_drag_motion, "ns-handle-drag-motion");
|
|
|
|
+ /* Accessibility: line navigation command symbols for
|
|
+ ns_ax_event_is_line_nav_key (hot path, avoid intern per call). */
|
|
+ DEFSYM (Qns_ax_next_line, "next-line");
|
|
+ DEFSYM (Qns_ax_previous_line, "previous-line");
|
|
+ DEFSYM (Qns_ax_dired_next_line, "dired-next-line");
|
|
+ DEFSYM (Qns_ax_dired_previous_line, "dired-previous-line");
|
|
+ DEFSYM (Qns_ax_evil_next_line, "evil-next-line");
|
|
+ DEFSYM (Qns_ax_evil_previous_line, "evil-previous-line");
|
|
+ DEFSYM (Qns_ax_evil_next_visual_line, "evil-next-visual-line");
|
|
+ DEFSYM (Qns_ax_evil_previous_visual_line, "evil-previous-visual-line");
|
|
+
|
|
+ /* Accessibility span scanning symbols. */
|
|
+ DEFSYM (Qns_ax_widget, "widget");
|
|
+ DEFSYM (Qns_ax_button, "button");
|
|
+ DEFSYM (Qns_ax_follow_link, "follow-link");
|
|
+ DEFSYM (Qns_ax_org_link, "org-link");
|
|
+ DEFSYM (Qns_ax_completion_list_mode, "completion-list-mode");
|
|
+ DEFSYM (Qns_ax_completion__string, "completion--string");
|
|
+ DEFSYM (Qns_ax_completion, "completion");
|
|
+ DEFSYM (Qns_ax_completions_highlight, "completions-highlight");
|
|
+ DEFSYM (Qns_ax_backtab, "backtab");
|
|
+ /* Qmouse_face and Qkeymap are defined in textprop.c / keymap.c. */
|
|
Fput (Qalt, Qmodifier_value, make_fixnum (alt_modifier));
|
|
Fput (Qhyper, Qmodifier_value, make_fixnum (hyper_modifier));
|
|
Fput (Qmeta, Qmodifier_value, make_fixnum (meta_modifier));
|
|
@@ -11460,6 +13788,15 @@ Note that this does not apply to images.
|
|
This variable is ignored on Mac OS X < 10.7 and GNUstep. */);
|
|
ns_use_srgb_colorspace = YES;
|
|
|
|
+ DEFVAR_BOOL ("ns-accessibility-enabled", ns_accessibility_enabled,
|
|
+ doc: /* Non-nil means expose buffer content to the macOS accessibility
|
|
+subsystem (VoiceOver, Zoom, and other assistive technology).
|
|
+When nil, the accessibility virtual element tree is not built and no
|
|
+notifications are posted, eliminating the associated overhead.
|
|
+Requires the Cocoa (NS) build on macOS; ignored on GNUstep.
|
|
+Default is t. */);
|
|
+ ns_accessibility_enabled = YES;
|
|
+
|
|
DEFVAR_BOOL ("ns-use-mwheel-acceleration",
|
|
ns_use_mwheel_acceleration,
|
|
doc: /* Non-nil means use macOS's standard mouse wheel acceleration.
|
|
--
|
|
2.43.0
|
|
|