patches: 4-patch VoiceOver series (split + improved docs)
Split VoiceOver accessibility into 4 logical patches: 0001: Base classes + text extraction (+753) 0002: Buffer/ModeLine/InteractiveSpan implementations (+1716) 0003: EmacsView integration + cursor tracking (+395) 0004: Documentation with known limitations (+75) Each patch is self-contained: 0001 adds infrastructure that compiles but doesn't change behavior. 0002 adds protocol implementations. 0003 wires everything into EmacsView. 0004 documents for users. All patches verified: apply cleanly to current Emacs master, final state identical to original monolithic patch.
This commit is contained in:
@@ -0,0 +1,887 @@
|
|||||||
|
From eb8038a4d9c4fb4640b0987d6529e8b961353596 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Martin Sukany <martin@sukany.cz>
|
||||||
|
Date: Sat, 28 Feb 2026 09:54:28 +0100
|
||||||
|
Subject: [PATCH 1/4] ns: add accessibility base classes and text extraction
|
||||||
|
|
||||||
|
Add the foundation for macOS VoiceOver accessibility support in the
|
||||||
|
NS (Cocoa) port. This patch provides the base class hierarchy, text
|
||||||
|
extraction with invisible-text handling, coordinate mapping, and
|
||||||
|
notification helpers. No existing code paths are modified.
|
||||||
|
|
||||||
|
New types (nsterm.h):
|
||||||
|
|
||||||
|
ns_ax_visible_run: maps buffer character positions to UTF-16
|
||||||
|
accessibility indices, skipping invisible text.
|
||||||
|
|
||||||
|
EmacsAccessibilityElement: base class for virtual AX elements.
|
||||||
|
|
||||||
|
Forward declarations for EmacsAccessibilityBuffer,
|
||||||
|
EmacsAccessibilityModeLine, EmacsAccessibilityInteractiveSpan.
|
||||||
|
|
||||||
|
EmacsAXSpanType: enum for interactive span classification.
|
||||||
|
|
||||||
|
EmacsView ivar extensions: accessibilityElements, last-
|
||||||
|
SelectedWindow, accessibilityTreeValid, lastAccessibilityCursorRect.
|
||||||
|
|
||||||
|
New helper functions (nsterm.m):
|
||||||
|
|
||||||
|
ns_ax_buffer_text: build accessibility string with visible-run
|
||||||
|
mapping. Uses TEXT_PROP_MEANS_INVISIBLE for spec-controlled
|
||||||
|
invisibility, Fbuffer_substring_no_properties for buffer-gap
|
||||||
|
safety. Capped at NS_AX_TEXT_CAP (100,000 UTF-16 units).
|
||||||
|
|
||||||
|
ns_ax_mode_line_text: extract mode-line text from glyph matrix
|
||||||
|
(CHAR_GLYPH only; image/stretch glyphs skipped with TODO note).
|
||||||
|
|
||||||
|
ns_ax_frame_for_range: screen rect for character range via glyph
|
||||||
|
matrix lookup with text-area clipping.
|
||||||
|
|
||||||
|
ns_ax_post_notification, ns_ax_post_notification_with_info:
|
||||||
|
dispatch_async wrappers to prevent deadlock.
|
||||||
|
|
||||||
|
Utility helpers: ns_ax_completion_string_from_prop,
|
||||||
|
ns_ax_window_buffer_object, ns_ax_window_end_charpos,
|
||||||
|
ns_ax_text_prop_at, ns_ax_next_prop_change, ns_ax_get_span_label.
|
||||||
|
|
||||||
|
EmacsAccessibilityElement @implementation: base class with
|
||||||
|
validWindow, screenRectFromEmacsX:y:width:height:, and hierarchy
|
||||||
|
plumbing (accessibilityParent, accessibilityWindow).
|
||||||
|
|
||||||
|
New user option: ns-accessibility-enabled (default t).
|
||||||
|
|
||||||
|
Tested on macOS 14 Sonoma. Builds cleanly; base class instantiates;
|
||||||
|
symbols register; no functional change (integration in next patch).
|
||||||
|
|
||||||
|
* src/nsterm.h: New class declarations, struct, enum, ivar extensions.
|
||||||
|
* src/nsterm.m: Helper functions, base element, DEFSYM, DEFVAR.
|
||||||
|
* etc/NEWS: Document VoiceOver accessibility support.
|
||||||
|
---
|
||||||
|
etc/NEWS | 13 ++
|
||||||
|
src/nsterm.h | 119 ++++++++++
|
||||||
|
src/nsterm.m | 621 +++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
3 files changed, 753 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..ee27df1 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,595 @@ 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);
|
||||||
|
+ });
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+
|
||||||
|
+@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
|
||||||
|
+
|
||||||
|
+#endif /* NS_IMPL_COCOA */
|
||||||
|
+
|
||||||
|
+
|
||||||
|
/* ==========================================================================
|
||||||
|
|
||||||
|
EmacsView implementation
|
||||||
|
@@ -11312,6 +11902,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 +12072,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
|
||||||
|
|
||||||
@@ -1,793 +1,70 @@
|
|||||||
From 663011cd807430d689569fc6b15fb3e3220928ce Mon Sep 17 00:00:00 2001
|
From a49c6b5a9601fe11a6a03292e8b4d685a0ce50af Mon Sep 17 00:00:00 2001
|
||||||
From: Martin Sukany <martin@sukany.cz>
|
From: Martin Sukany <martin@sukany.cz>
|
||||||
Date: Sat, 28 Feb 2026 09:31:55 +0100
|
Date: Sat, 28 Feb 2026 09:54:28 +0100
|
||||||
Subject: [PATCH 1/3] ns: add accessibility infrastructure for macOS VoiceOver
|
Subject: [PATCH 2/4] ns: implement buffer, mode-line, and interactive span
|
||||||
|
elements
|
||||||
|
|
||||||
Add the core accessibility implementation for the NS (Cocoa) port,
|
Add the three remaining virtual element classes, completing the
|
||||||
providing VoiceOver and Zoom support. This patch adds all new types,
|
accessibility object model. Combined with the previous patch, this
|
||||||
classes, and functions without modifying existing code paths; the
|
provides a full NSAccessibility text protocol implementation.
|
||||||
integration with EmacsView and the redisplay cycle follows in a
|
|
||||||
subsequent patch.
|
|
||||||
|
|
||||||
New types:
|
EmacsAccessibilityBuffer <NSAccessibility>: full text protocol for
|
||||||
|
a single Emacs window.
|
||||||
|
|
||||||
ns_ax_visible_run: maps buffer character positions to UTF-16
|
Text cache: @synchronized caching of buffer text and visible-run
|
||||||
accessibility string indices, skipping invisible text. Used by
|
array. Cache invalidated on modiff_count, window start, or
|
||||||
the index-mapping binary search in EmacsAccessibilityBuffer.
|
invisible-text configuration change.
|
||||||
|
|
||||||
EmacsAccessibilityElement: base class for virtual AX elements,
|
Index mapping: binary search O(log n) between buffer positions and
|
||||||
stores Lisp_Object lispWindow (GC-safe; see comment in nsterm.h)
|
UTF-16 accessibility indices via the visible-run array.
|
||||||
and EmacsView reference.
|
|
||||||
|
|
||||||
EmacsAccessibilityBuffer <NSAccessibility>: AXTextArea element per
|
Selection: selectedTextRange from point/mark; insertion point from
|
||||||
visible Emacs window. Full NSAccessibility text protocol including
|
point via index mapping.
|
||||||
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.
|
Geometry: lineForIndex/indexForLine by newline scanning.
|
||||||
|
frameForRange delegates to ns_ax_frame_for_range.
|
||||||
|
|
||||||
EmacsAccessibilityInteractiveSpan: lightweight AX child elements
|
Notification dispatch (postTextChangedNotification): hybrid
|
||||||
for Tab-navigable interactive spans (buttons, links, checkboxes,
|
SelectedTextChanged / ValueChanged / AnnouncementRequested,
|
||||||
completion candidates, Org-mode links, keymap overlays).
|
modeled on WebKit's pattern. Line navigation emits ValueChanged;
|
||||||
|
character/word motion emits SelectedTextChanged only. Completion
|
||||||
|
buffer announcements via AnnouncementRequested with High priority.
|
||||||
|
|
||||||
New helper functions:
|
EmacsAccessibilityModeLine: AXStaticText exposing mode-line content.
|
||||||
|
|
||||||
ns_ax_buffer_text: build accessibility string with visible-run
|
EmacsAccessibilityInteractiveSpan: lightweight child of a buffer
|
||||||
mapping. Uses TEXT_PROP_MEANS_INVISIBLE for spec-controlled
|
element for Tab-navigable interactive text.
|
||||||
invisibility and Fbuffer_substring_no_properties for gap safety.
|
|
||||||
|
|
||||||
ns_ax_frame_for_range: screen rect for a character range via
|
ns_ax_scan_interactive_spans: scan visible range with O(n/skip)
|
||||||
glyph matrix lookup with text-area clipping.
|
property-skip optimization. Priority: widget > button > follow-link
|
||||||
|
> org-link > completion-candidate > keymap-overlay.
|
||||||
|
|
||||||
ns_ax_event_is_line_nav_key: detect line navigation commands
|
Buffer (InteractiveSpans) category: Tab/Shift-Tab cycling with
|
||||||
via Vthis_command with Tab/backtab fallback.
|
wrap-around and VoiceOver focus notification.
|
||||||
|
|
||||||
ns_ax_scan_interactive_spans: scan visible range for interactive
|
ns_ax_completion_text_for_span: extract completion candidate text.
|
||||||
text properties with property-skip optimization.
|
|
||||||
|
|
||||||
New user option: ns-accessibility-enabled (default t).
|
Threading: Lisp-accessing methods use dispatch_sync to main thread;
|
||||||
|
@synchronized protects text cache.
|
||||||
|
|
||||||
Threading model: all Lisp calls on main thread; AX getters use
|
Tested on macOS 14 with VoiceOver. Verified: buffer reading, line
|
||||||
dispatch_sync to main; index mapping methods are thread-safe.
|
navigation, word/character announcements, completion announcements,
|
||||||
Notifications posted via dispatch_async to prevent deadlock with
|
Tab-cycling interactive spans, mode-line readout.
|
||||||
VoiceOver's synchronous callbacks.
|
|
||||||
|
|
||||||
Tested on macOS 14 Sonoma with VoiceOver and Zoom. Verified:
|
* src/nsterm.m: EmacsAccessibilityBuffer, EmacsAccessibilityModeLine,
|
||||||
buffer navigation (char/word/line), completion announcements,
|
EmacsAccessibilityInteractiveSpan, supporting functions.
|
||||||
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.m | 1716 ++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
src/nsterm.h | 119 +++
|
1 file changed, 1716 insertions(+)
|
||||||
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
|
diff --git a/src/nsterm.m b/src/nsterm.m
|
||||||
index 74e4ad5..c47912d 100644
|
index ee27df1..c47912d 100644
|
||||||
--- a/src/nsterm.m
|
--- a/src/nsterm.m
|
||||||
+++ b/src/nsterm.m
|
+++ b/src/nsterm.m
|
||||||
@@ -46,6 +46,7 @@ GNUstep port and post-20 update by Adrian Robert (arobert@cogsci.ucsd.edu)
|
@@ -7387,6 +7387,351 @@ ns_ax_post_notification_with_info (id element,
|
||||||
#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.
|
+/* Scan visible range of window W for interactive spans.
|
||||||
+ Returns NSArray<EmacsAccessibilityInteractiveSpan *>.
|
+ Returns NSArray<EmacsAccessibilityInteractiveSpan *>.
|
||||||
+
|
+
|
||||||
@@ -1133,62 +410,13 @@ index 74e4ad5..c47912d 100644
|
|||||||
+ return text;
|
+ return text;
|
||||||
+}
|
+}
|
||||||
+
|
+
|
||||||
+
|
|
||||||
+@implementation EmacsAccessibilityElement
|
@implementation EmacsAccessibilityElement
|
||||||
+
|
|
||||||
+- (instancetype)init
|
@@ -7443,6 +7788,1377 @@ ns_ax_post_notification_with_info (id element,
|
||||||
+{
|
|
||||||
+ self = [super init];
|
@end
|
||||||
+ 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
|
+@implementation EmacsAccessibilityBuffer
|
||||||
+@synthesize cachedText;
|
+@synthesize cachedText;
|
||||||
@@ -2560,57 +1788,9 @@ index 74e4ad5..c47912d 100644
|
|||||||
+
|
+
|
||||||
+@end
|
+@end
|
||||||
+
|
+
|
||||||
+#endif /* NS_IMPL_COCOA */
|
#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
|
2.43.0
|
||||||
|
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
From 92b00024559ff35a61d34f85e2c048a1845fca99 Mon Sep 17 00:00:00 2001
|
From 1bebb38dc851cb4e656fe25078f1e36d54900be5 Mon Sep 17 00:00:00 2001
|
||||||
From: Martin Sukany <martin@sukany.cz>
|
From: Martin Sukany <martin@sukany.cz>
|
||||||
Date: Sat, 28 Feb 2026 09:32:52 +0100
|
Date: Sat, 28 Feb 2026 09:54:28 +0100
|
||||||
Subject: [PATCH 2/3] ns: integrate accessibility with EmacsView and cursor
|
Subject: [PATCH 3/4] ns: integrate accessibility with EmacsView and redisplay
|
||||||
tracking
|
|
||||||
|
|
||||||
Wire the accessibility infrastructure from the previous patch into
|
Wire the accessibility infrastructure from the previous patches into
|
||||||
the existing EmacsView class and the redisplay cycle. After this
|
EmacsView and the redisplay cycle. After this patch, VoiceOver and
|
||||||
patch, VoiceOver and Zoom support is fully active.
|
Zoom support is fully active.
|
||||||
|
|
||||||
Integration points:
|
Integration points:
|
||||||
|
|
||||||
ns_update_end: call [view postAccessibilityUpdates] after each
|
ns_update_end: call [view postAccessibilityUpdates] after each
|
||||||
redisplay cycle to dispatch accessibility notifications.
|
redisplay cycle to dispatch queued accessibility notifications.
|
||||||
|
|
||||||
ns_draw_phys_cursor: store cursor rect for Zoom and call
|
ns_draw_phys_cursor: store cursor rect for Zoom and call
|
||||||
UAZoomChangeFocus with correct CG coordinate-space transform
|
UAZoomChangeFocus with correct CG coordinate-space transform
|
||||||
@@ -19,25 +18,49 @@ Integration points:
|
|||||||
|
|
||||||
EmacsView dealloc: release accessibilityElements array.
|
EmacsView dealloc: release accessibilityElements array.
|
||||||
|
|
||||||
EmacsView windowDidBecomeKey: post
|
windowDidBecomeKey: post FocusedUIElementChangedNotification and
|
||||||
FocusedUIElementChangedNotification and SelectedTextChanged
|
SelectedTextChanged so VoiceOver tracks the focused buffer on
|
||||||
so VoiceOver tracks the focused buffer on app/window switch.
|
app/window switch.
|
||||||
|
|
||||||
EmacsView accessibility methods: rebuildAccessibilityTree walks
|
EmacsView accessibility methods:
|
||||||
the Emacs window tree (ns_ax_collect_windows) to create/reuse
|
|
||||||
virtual elements. accessibilityChildren, accessibilityFocusedUI-
|
|
||||||
Element, postAccessibilityUpdates (the main notification dispatch
|
|
||||||
loop), accessibilityBoundsForRange (delegates to focused buffer
|
|
||||||
element with cursor-rect fallback for Zoom), and legacy
|
|
||||||
parameterized attribute APIs.
|
|
||||||
|
|
||||||
postAccessibilityUpdates detects three events: window tree change
|
rebuildAccessibilityTree: walk Emacs window tree via
|
||||||
(rebuild + layout notification), window switch (focus notification),
|
ns_ax_collect_windows to create/reuse virtual elements
|
||||||
and per-buffer changes (delegated to each buffer element). A
|
(EmacsAccessibilityBuffer per window, EmacsAccessibilityModeLine
|
||||||
re-entrance guard prevents infinite recursion from VoiceOver
|
per mode line).
|
||||||
callbacks that trigger redisplay.
|
|
||||||
|
|
||||||
* src/nsterm.m: EmacsView accessibility integration.
|
invalidateAccessibilityTree: mark tree dirty; deferred rebuild
|
||||||
|
on next AX query.
|
||||||
|
|
||||||
|
accessibilityChildren, accessibilityFocusedUIElement: expose
|
||||||
|
virtual elements to VoiceOver.
|
||||||
|
|
||||||
|
postAccessibilityUpdates: main notification dispatch. Detects
|
||||||
|
three events: window tree change (rebuild + LayoutChanged),
|
||||||
|
window switch (focus notification), per-buffer text/cursor
|
||||||
|
changes (delegated to buffer elements). Re-entrance guard
|
||||||
|
prevents infinite recursion from VoiceOver callbacks.
|
||||||
|
|
||||||
|
accessibilityBoundsForRange: cursor rect for Zoom, delegates to
|
||||||
|
focused buffer element with cursor-rect fallback.
|
||||||
|
|
||||||
|
Legacy parameterized APIs (accessibilityParameterizedAttribute-
|
||||||
|
Names, accessibilityAttributeValue:forParameter:) for pre-10.10
|
||||||
|
compatibility.
|
||||||
|
|
||||||
|
Tested on macOS 14 Sonoma with VoiceOver and Zoom. Full end-to-end:
|
||||||
|
buffer navigation, cursor tracking, window switching, completion
|
||||||
|
announcements, interactive spans, org-mode folded headings,
|
||||||
|
evil-mode block cursor, indirect buffers, multi-window layouts.
|
||||||
|
|
||||||
|
Known limitations:
|
||||||
|
- Bidi text: accessibilityRangeForPosition assumes LTR glyph layout.
|
||||||
|
- Mode-line icons: image/stretch glyphs not extracted.
|
||||||
|
- Text cap: buffers exceeding NS_AX_TEXT_CAP truncated.
|
||||||
|
- Indirect buffers: share modiff_count, may cause redundant cache
|
||||||
|
invalidation (correctness preserved, minor performance cost).
|
||||||
|
|
||||||
|
* src/nsterm.m: EmacsView accessibility integration, cursor tracking.
|
||||||
---
|
---
|
||||||
src/nsterm.m | 395 +++++++++++++++++++++++++++++++++++++++++++++++++++
|
src/nsterm.m | 395 +++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
1 file changed, 395 insertions(+)
|
1 file changed, 395 insertions(+)
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
From 12440652eb52520da0714f1762e037836bda7b5b Mon Sep 17 00:00:00 2001
|
From 3e6f8148a01ee7934a357858477ac3c61b491088 Mon Sep 17 00:00:00 2001
|
||||||
From: Martin Sukany <martin@sukany.cz>
|
From: Martin Sukany <martin@sukany.cz>
|
||||||
Date: Sat, 28 Feb 2026 09:33:23 +0100
|
Date: Sat, 28 Feb 2026 09:54:28 +0100
|
||||||
Subject: [PATCH 3/3] doc: add VoiceOver accessibility section to macOS
|
Subject: [PATCH 4/4] doc: add VoiceOver accessibility section to macOS
|
||||||
appendix
|
appendix
|
||||||
|
|
||||||
Document the new VoiceOver accessibility support in the Emacs manual.
|
Document the new VoiceOver accessibility support in the Emacs manual.
|
||||||
Add a new section to the macOS appendix covering screen reader usage,
|
|
||||||
keyboard navigation feedback, completion announcements, Zoom cursor
|
|
||||||
tracking, the ns-accessibility-enabled user option, and known
|
|
||||||
limitations (text cap, mode-line icon fonts, bidi hit-testing).
|
|
||||||
|
|
||||||
* doc/emacs/macos.texi (VoiceOver Accessibility): New section.
|
* doc/emacs/macos.texi (VoiceOver Accessibility): New section
|
||||||
|
covering screen reader usage, keyboard navigation feedback,
|
||||||
|
completion announcements, Zoom cursor tracking, the
|
||||||
|
ns-accessibility-enabled user option, and known limitations (text
|
||||||
|
cap, mode-line icon fonts, bidi hit-testing, tree rebuild behavior).
|
||||||
---
|
---
|
||||||
doc/emacs/macos.texi | 75 ++++++++++++++++++++++++++++++++++++++++++++
|
doc/emacs/macos.texi | 75 ++++++++++++++++++++++++++++++++++++++++++++
|
||||||
1 file changed, 75 insertions(+)
|
1 file changed, 75 insertions(+)
|
||||||
Reference in New Issue
Block a user