For discontiguous moves (teleports, org-agenda items separated by blank
lines, multi-line jumps), AXSelectedTextChanged was sent with
AXTextSelectionDirection=discontiguous. VoiceOver interprets an
explicit discontiguous direction as 're-anchor only' and reads only the
word at the cursor, ignoring VoiceOver's own line-browse mode.
The pre-review code (51f5944) omitted direction/granularity for all
moves and let VoiceOver determine what to read from its navigation state.
This correctly reads the full line when the VoiceOver rotor is in line
mode, which is the typical setting for text navigation.
Fix: omit AXTextSelectionDirection and AXTextSelectionGranularity from
AXSelectedTextChanged when direction=discontiguous. Include them only
for sequential moves (direction=next/previous), where the explicit hint
ensures VoiceOver reads the correct unit without an extra state query.
This fixes:
- org-agenda / org-super-agenda j/k: items separated by blank lines
cause singleLineMove=NO (non-adjacent AX indices), so direction was
discontiguous -> only first word read.
- Any other navigation that crosses blank or invisible lines.
Sequential moves (C-n/C-p, single adjacent j/k) still include
direction + granularity=line for reliable full-line reads.
680 lines
23 KiB
Diff
680 lines
23 KiB
Diff
From 9650910e7ac6e423ea9beaa75033d12693b93c89 Mon Sep 17 00:00:00 2001
|
|
From: Martin Sukany <martin@sukany.cz>
|
|
Date: Sat, 28 Feb 2026 12:58:11 +0100
|
|
Subject: [PATCH 1/8] ns: add accessibility base classes and text extraction
|
|
|
|
Add the foundation for macOS VoiceOver accessibility in the NS (Cocoa)
|
|
port. No existing code paths are modified.
|
|
|
|
* src/nsterm.h (ns_ax_visible_run): New struct.
|
|
(EmacsAccessibilityElement): New base Objective-C class.
|
|
(EmacsAccessibilityBuffer, EmacsAccessibilityModeLine)
|
|
(EmacsAccessibilityInteractiveSpan): Forward-declare new classes.
|
|
(EmacsAXSpanType): New enum for interactive span types.
|
|
(EmacsView): New ivars for accessibility element tree.
|
|
* src/nsterm.m: Include intervals.h for TEXT_PROP_MEANS_INVISIBLE.
|
|
(ns_ax_buffer_text): New function; build visible-text string and
|
|
run array for a window, skipping invisible character regions.
|
|
(ns_ax_mode_line_text): New function; extract mode-line text.
|
|
(ns_ax_frame_for_range): New function; map charpos range to screen
|
|
rect via glyph matrix.
|
|
(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, ns_ax_post_notification)
|
|
(ns_ax_post_notification_with_info): New helper functions.
|
|
(EmacsAccessibilityElement): Implement base class.
|
|
(syms_of_nsterm): Register accessibility DEFSYMs. Add DEFVAR_BOOL
|
|
ns-accessibility-enabled with corrected doc: initial value is nil,
|
|
set non-nil automatically when an AT is detected at startup.
|
|
---
|
|
src/nsterm.h | 131 +++++++++++++++
|
|
src/nsterm.m | 454 +++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
2 files changed, 585 insertions(+)
|
|
|
|
diff --git a/src/nsterm.h b/src/nsterm.h
|
|
index ea6e7ba4f5..5746e9e9bd 100644
|
|
--- a/src/nsterm.h
|
|
+++ b/src/nsterm.h
|
|
@@ -453,6 +453,124 @@ 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;
|
|
+ NSUInteger *lineStartOffsets; /* AX index for each line. */
|
|
+ NSUInteger lineCount; /* Entries in lineStartOffsets. */
|
|
+ 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;
|
|
+- (NSInteger)lineForAXIndex:(NSUInteger)idx;
|
|
+- (NSRange)rangeForLine:(NSUInteger)line textLength:(NSUInteger)tlen;
|
|
+- (ptrdiff_t)charposForAccessibilityIndex:(NSUInteger)ax_idx;
|
|
+- (NSUInteger)accessibilityIndexForCharpos:(ptrdiff_t)charpos;
|
|
+@end
|
|
+
|
|
+@interface EmacsAccessibilityBuffer (Notifications)
|
|
+- (void)postTextChangedNotification:(ptrdiff_t)point;
|
|
+- (void)postAccessibilityNotificationsForFrame:(struct frame *)f;
|
|
+@end
|
|
+
|
|
+@interface EmacsAccessibilityBuffer (InteractiveSpans)
|
|
+- (void)invalidateInteractiveSpans;
|
|
+@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 +589,12 @@ 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;
|
|
#endif
|
|
BOOL font_panel_active;
|
|
NSFont *font_panel_result;
|
|
@@ -534,6 +658,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 88c9251c18..9d36de66f9 100644
|
|
--- a/src/nsterm.m
|
|
+++ b/src/nsterm.m
|
|
@@ -46,6 +46,7 @@ Updated by Christian Limpach (chris@nice.ch)
|
|
#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"
|
|
@@ -7201,6 +7202,432 @@ - (BOOL)fulfillService: (NSString *)name withArg: (NSString *)arg
|
|
}
|
|
#endif
|
|
|
|
+/* ==========================================================================
|
|
+
|
|
+ Accessibility virtual elements (macOS / Cocoa only)
|
|
+
|
|
+ ========================================================================== */
|
|
+
|
|
+#ifdef NS_IMPL_COCOA
|
|
+
|
|
+/* ---- Helper: extract buffer text for accessibility ---- */
|
|
+
|
|
+/* 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 ();
|
|
+ record_unwind_protect_void (unblock_input);
|
|
+ block_input ();
|
|
+ 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;
|
|
+
|
|
+ ptrdiff_t run_len = run_end - pos;
|
|
+
|
|
+ /* 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. nerd-font icons)
|
|
+ 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,
|
|
+};
|
|
+
|
|
+/* 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
|
|
@@ -11657,6 +12084,24 @@ Convert an X font name (XLFD) to an NS font name.
|
|
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");
|
|
+
|
|
+ /* 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));
|
|
@@ -11805,6 +12250,15 @@ Nil means use fullscreen the old (< 10.7) way. The old way works better with
|
|
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 nil. Set to t to enable VoiceOver support. */);
|
|
+ ns_accessibility_enabled = NO;
|
|
+
|
|
DEFVAR_BOOL ("ns-use-mwheel-acceleration",
|
|
ns_use_mwheel_acceleration,
|
|
doc: /* Non-nil means use macOS's standard mouse wheel acceleration.
|
|
--
|
|
2.43.0
|
|
|