Compare commits
148 Commits
anthropic-
...
30089e9413
| Author | SHA1 | Date | |
|---|---|---|---|
| 30089e9413 | |||
| 419762bde0 | |||
| 3abc7c9745 | |||
| 538e15f707 | |||
| c82ef86eaf | |||
|
|
98b3d04597 | ||
| acc2a2985e | |||
| 0f7608326c | |||
| 4f37a8660e | |||
| 6356cd9751 | |||
| ef239ddf7a | |||
| c05b46b058 | |||
| beb5e14adf | |||
| edbed0a116 | |||
| 83c3c09858 | |||
| 0996157b34 | |||
| fbbd7530c5 | |||
| 2d053f5a92 | |||
| 4c7ce352cd | |||
| afa65a8201 | |||
| e09b8c61f0 | |||
| 306cde4f79 | |||
|
|
f05d124381 | ||
| 190a4ae346 | |||
| 9772b7e33e | |||
| 1455542227 | |||
| 5a58e3b925 | |||
| 2dc4182856 | |||
| 3e5fe814b8 | |||
| 5aa0f05a33 | |||
| 31fcc1a711 | |||
| 659b9e2a1e | |||
| a8af58cff1 | |||
| 92188ab008 | |||
| be4e0bb5be | |||
| 4e5596d9de | |||
| 9129f032cf | |||
| 99609f0437 | |||
| 9359277143 | |||
| fcff3429b1 | |||
| 9408e37a90 | |||
| 6c502c7af5 | |||
| 8a48e72493 | |||
| 99ed8b4ae4 | |||
|
|
270adb363d | ||
| 24fd61f1f6 | |||
| ed3bc1e0ad | |||
|
|
1f91d94ae0 | ||
| 5799521a00 | |||
| a39ccd13d7 | |||
| c67fa525ab | |||
| 5293eef3ac | |||
| 9130268ff0 | |||
| 6da6f7c90f | |||
| ca77cc8e3d | |||
| edab71038a | |||
| fa28bb52e1 | |||
| 5016155c8a | |||
| 67b1d25c34 | |||
| 2c8515a0a1 | |||
| bbd328dc81 | |||
| b002d3004a | |||
| d408a542e5 | |||
| b83a061322 | |||
| 65c799dc3f | |||
| 765725aaef | |||
| 936c251f11 | |||
|
|
a6a3aca678 | ||
| 60e9ea2c59 | |||
| 111013ddf1 | |||
| fa3ee7cc88 | |||
| d29a33fcfb | |||
| 0b43fd25e3 | |||
| 6994403014 | |||
| e4129581b7 | |||
| 404d26e2fe | |||
| 1ecb9908af | |||
| eafc80e324 | |||
| af960683f0 | |||
| d151c40357 | |||
| b8ef731a8a | |||
| 4eb370f6f0 | |||
| 33eff6e9f8 | |||
| 95b5271e35 | |||
| 7971f42600 | |||
| 3447fcc8d5 | |||
| 495a5510c6 | |||
| 8a834448f9 | |||
| edad606809 | |||
| 5f98a78467 | |||
| 3df5dc94b1 | |||
| 75c1b471c4 | |||
| 8890e659e1 | |||
| 0110670734 | |||
|
|
ab4367c459 | ||
| f306599d94 | |||
| c138bdc6d5 | |||
| 5f08e0a315 | |||
| 23102444d4 | |||
| 92ef24332f | |||
| 7a0e7722f7 | |||
| 1245253e15 | |||
| 081d1c01e7 | |||
| d11aa168b9 | |||
| f37e06b00f | |||
| 7419d9b0e4 | |||
| 368f9600df | |||
| 97776b5141 | |||
| 3c0a68cfb9 | |||
|
|
9a08f01066 | ||
|
|
20d9605afa | ||
| 35a5326c18 | |||
|
|
f6c4328cf6 | ||
| 220e90144f | |||
|
|
c8d7d11136 | ||
| 3bad798541 | |||
|
|
259ec157d1 | ||
| 7c01587079 | |||
| 059ee5a0ea | |||
|
|
fde9454f41 | ||
| 1b42d553a2 | |||
|
|
b691e804d2 | ||
| 74b9691856 | |||
|
|
f95d06dc82 | ||
| b3a6141831 | |||
|
|
c0a9bc8c62 | ||
| 8720a43d04 | |||
|
|
e6608d9813 | ||
| 8ae3f0b367 | |||
|
|
bae4368d9e | ||
| fa4748eb4c | |||
|
|
ef18e6ef7b | ||
|
|
4449c5a3bd | ||
|
|
f9403bc6a4 | ||
|
|
14a0b703ec | ||
|
|
77bd9fb84c | ||
|
|
68eb568810 | ||
|
|
a2c9911171 | ||
|
|
cd53ce26cc | ||
|
|
5ded624f7b | ||
|
|
6d185c880a | ||
|
|
05fc543958 | ||
|
|
5b12e8c435 | ||
|
|
be642808cc | ||
|
|
fd03f2e7c4 | ||
|
|
bfe29f8da8 | ||
|
|
6724922592 | ||
| e40d502d43 |
9
TODO.org
Normal file
9
TODO.org
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
#+title: Todo
|
||||||
|
|
||||||
|
* Emacs patch
|
||||||
|
Po poslednim testovani:
|
||||||
|
- dired i completions navigace funguje sipkama
|
||||||
|
- dired i completions - kdyz naviguju emacs zpusobem C-n, C-p, atp, tak to nefunguje, cte to neco uplne jineho, nechapu proc
|
||||||
|
- V completions stale nefunguje TAB, chova se to stejne jako minule (myslim tim ten hightlighting).
|
||||||
|
|
||||||
|
Mozna jsi zapomnel kontext, protoze jsme se prepnuli z Anthropic na OpenAI modely, takze v pripade potreby nacti kontext z NOW.md a memory.
|
||||||
270
config.el
270
config.el
@@ -7,6 +7,13 @@
|
|||||||
(setq user-full-name "Martin Sukany"
|
(setq user-full-name "Martin Sukany"
|
||||||
user-mail-address "martin@sukany.cz")
|
user-mail-address "martin@sukany.cz")
|
||||||
|
|
||||||
|
;; CalDAV calendar IDs (edit here to update sync targets)
|
||||||
|
(defconst my/caldav-url "https://cal.apps.sukany.cz/dav.php/calendars/martin")
|
||||||
|
(defconst my/caldav-id-suky "default")
|
||||||
|
(defconst my/caldav-id-placeholders "4C748EE5-ECFF-4D4A-A72E-6DE37BAADEB3")
|
||||||
|
(defconst my/caldav-id-family "family")
|
||||||
|
(defconst my/caldav-id-klara "klara")
|
||||||
|
|
||||||
;; Trust all TLS certificates (corporate MITM proxy with intermediate CA)
|
;; Trust all TLS certificates (corporate MITM proxy with intermediate CA)
|
||||||
(setq gnutls-verify-error nil)
|
(setq gnutls-verify-error nil)
|
||||||
(setq tls-checktrust nil)
|
(setq tls-checktrust nil)
|
||||||
@@ -97,7 +104,9 @@
|
|||||||
|
|
||||||
;; Let Evil use the system clipboard
|
;; Let Evil use the system clipboard
|
||||||
(after! evil
|
(after! evil
|
||||||
(setq evil-want-clipboard t))
|
(setq evil-want-clipboard t)
|
||||||
|
;; Ensure dashboard buffer starts in normal state (required for SPC leader)
|
||||||
|
(evil-set-initial-state '+doom-dashboard-mode 'normal))
|
||||||
|
|
||||||
;; Standard macOS modifier keys for GUI Emacs
|
;; Standard macOS modifier keys for GUI Emacs
|
||||||
(when (display-graphic-p)
|
(when (display-graphic-p)
|
||||||
@@ -128,10 +137,6 @@
|
|||||||
(global-set-key [wheel-up] #'mwheel-scroll)
|
(global-set-key [wheel-up] #'mwheel-scroll)
|
||||||
(global-set-key [wheel-down] #'mwheel-scroll))
|
(global-set-key [wheel-down] #'mwheel-scroll))
|
||||||
|
|
||||||
;; Ensure dashboard buffer starts in normal state (required for SPC leader)
|
|
||||||
(after! evil
|
|
||||||
(evil-set-initial-state '+doom-dashboard-mode 'normal))
|
|
||||||
|
|
||||||
;; Cancel persp-mode's 2.5s cache timer after startup
|
;; Cancel persp-mode's 2.5s cache timer after startup
|
||||||
;; (reduces unnecessary redraws that cause macOS Zoom to jump)
|
;; (reduces unnecessary redraws that cause macOS Zoom to jump)
|
||||||
(run-with-timer 3 nil
|
(run-with-timer 3 nil
|
||||||
@@ -265,6 +270,7 @@
|
|||||||
|
|
||||||
;; Visual: hide markup, pretty entities, compact tags
|
;; Visual: hide markup, pretty entities, compact tags
|
||||||
(setq org-startup-indented nil ; conflicts with org-modern star display
|
(setq org-startup-indented nil ; conflicts with org-modern star display
|
||||||
|
org-startup-folded 'content ; show all headings, hide body text
|
||||||
org-hide-emphasis-markers t
|
org-hide-emphasis-markers t
|
||||||
org-pretty-entities t
|
org-pretty-entities t
|
||||||
org-ellipsis " ▾"
|
org-ellipsis " ▾"
|
||||||
@@ -630,12 +636,15 @@ Skip for beamer exports — beamer uses adjustbox on plain tabular."
|
|||||||
|
|
||||||
(after! corfu
|
(after! corfu
|
||||||
(setq corfu-auto t
|
(setq corfu-auto t
|
||||||
corfu-auto-delay 1.0
|
corfu-auto-delay 2.0
|
||||||
corfu-auto-prefix 2
|
corfu-auto-prefix 3 ; need 3+ chars before popup
|
||||||
corfu-cycle t
|
corfu-cycle t
|
||||||
corfu-preselect 'prompt
|
corfu-preselect 'prompt
|
||||||
corfu-quit-no-match 'separator
|
corfu-quit-no-match 'separator
|
||||||
corfu-preview-current nil)
|
corfu-preview-current nil)
|
||||||
|
;; Re-set delay after global-corfu-mode to override Doom defaults
|
||||||
|
(add-hook 'global-corfu-mode-hook
|
||||||
|
(lambda () (setq corfu-auto-delay 2.0)))
|
||||||
(global-corfu-mode))
|
(global-corfu-mode))
|
||||||
|
|
||||||
(use-package! cape
|
(use-package! cape
|
||||||
@@ -679,6 +688,7 @@ Skip for beamer exports — beamer uses adjustbox on plain tabular."
|
|||||||
(expand-file-name "/opt/homebrew/opt/mu/share/emacs/site-lisp/mu/mu4e"))
|
(expand-file-name "/opt/homebrew/opt/mu/share/emacs/site-lisp/mu/mu4e"))
|
||||||
|
|
||||||
(after! mu4e
|
(after! mu4e
|
||||||
|
;; --- Mailbox layout ---
|
||||||
(setq mu4e-maildir "~/.mail"
|
(setq mu4e-maildir "~/.mail"
|
||||||
mu4e-get-mail-command "mbsync personal"
|
mu4e-get-mail-command "mbsync personal"
|
||||||
mu4e-update-interval 300
|
mu4e-update-interval 300
|
||||||
@@ -687,8 +697,10 @@ Skip for beamer exports — beamer uses adjustbox on plain tabular."
|
|||||||
mu4e-sent-folder "/personal/Sent"
|
mu4e-sent-folder "/personal/Sent"
|
||||||
mu4e-drafts-folder "/personal/Drafts"
|
mu4e-drafts-folder "/personal/Drafts"
|
||||||
mu4e-trash-folder "/personal/Trash"
|
mu4e-trash-folder "/personal/Trash"
|
||||||
mu4e-refile-folder "/personal/Archive"
|
mu4e-refile-folder "/personal/Archive")
|
||||||
mu4e-headers-show-threads t
|
|
||||||
|
;; --- Headers view ---
|
||||||
|
(setq mu4e-headers-show-threads t
|
||||||
mu4e-headers-include-related t
|
mu4e-headers-include-related t
|
||||||
mu4e-use-fancy-chars t
|
mu4e-use-fancy-chars t
|
||||||
mu4e-headers-mark-for-thread t
|
mu4e-headers-mark-for-thread t
|
||||||
@@ -706,25 +718,63 @@ Skip for beamer exports — beamer uses adjustbox on plain tabular."
|
|||||||
mu4e-headers-thread-last-child-prefix '("└>" . "└▶ ")
|
mu4e-headers-thread-last-child-prefix '("└>" . "└▶ ")
|
||||||
mu4e-headers-thread-duplicate-prefix '("=" . "≡ "))
|
mu4e-headers-thread-duplicate-prefix '("=" . "≡ "))
|
||||||
|
|
||||||
;; Bookmarks — unread excludes Trash/Archive/Sent/Drafts/Spam
|
;; --- Bookmarks ---
|
||||||
|
;; Keys: u=Unread i=Inbox d=Today w=Week
|
||||||
|
;; Note: ?d for Today avoids conflict with maildir shortcut ?t=Trash
|
||||||
(setq mu4e-bookmarks
|
(setq mu4e-bookmarks
|
||||||
'((:name "Unread"
|
'((:name "Unread"
|
||||||
:query "flag:unread AND NOT maildir:/personal/Trash AND NOT maildir:/personal/Archive AND NOT maildir:/personal/Sent AND NOT maildir:/personal/Drafts AND NOT maildir:/personal/Spam"
|
:query "flag:unread AND NOT maildir:/personal/Trash AND NOT maildir:/personal/Archive AND NOT maildir:/personal/Sent AND NOT maildir:/personal/Drafts AND NOT maildir:/personal/Spam"
|
||||||
:key ?u)
|
:key ?u)
|
||||||
(:name "Inbox" :query "maildir:/personal/INBOX" :key ?i)
|
(:name "Inbox" :query "maildir:/personal/INBOX" :key ?i)
|
||||||
(:name "Today" :query "date:today AND NOT maildir:/personal/Trash AND NOT maildir:/personal/Archive AND NOT maildir:/personal/Sent" :key ?t)
|
(:name "Today" :query "date:today AND NOT maildir:/personal/Trash AND NOT maildir:/personal/Archive AND NOT maildir:/personal/Sent" :key ?d)
|
||||||
(:name "Week" :query "date:7d..now AND NOT maildir:/personal/Trash AND NOT maildir:/personal/Archive AND NOT maildir:/personal/Sent" :key ?w)))
|
(:name "Week" :query "date:7d..now AND NOT maildir:/personal/Trash AND NOT maildir:/personal/Archive AND NOT maildir:/personal/Sent" :key ?w)))
|
||||||
|
|
||||||
;; Do not cite sender's signature in replies
|
;; --- Maildir shortcuts (jump with 'j') ---
|
||||||
(setq message-cite-function #'message-cite-original-without-signature)
|
;; Keys: i=INBOX s=Sent T=Trash a=Archive
|
||||||
|
;; ?T (uppercase) for Trash avoids conflict with bookmark ?t (was Today)
|
||||||
|
(setq mu4e-maildir-shortcuts
|
||||||
|
'(("/personal/INBOX" . ?i)
|
||||||
|
("/personal/Sent" . ?s)
|
||||||
|
("/personal/Trash" . ?T)
|
||||||
|
("/personal/Archive" . ?a)))
|
||||||
|
|
||||||
;; Signature from file
|
;; --- Sending ---
|
||||||
(setq message-signature-file (expand-file-name "~/.mail/signature")
|
(setq sendmail-program "msmtp"
|
||||||
|
message-send-mail-function #'message-send-mail-with-sendmail
|
||||||
|
mail-specify-envelope-from t
|
||||||
|
message-sendmail-envelope-from 'header)
|
||||||
|
|
||||||
|
;; --- Compose / reply ---
|
||||||
|
;; message-cite-function is a message-mode setting but configured here
|
||||||
|
;; because it only matters in the context of mu4e replies.
|
||||||
|
(setq message-cite-function #'message-cite-original-without-signature
|
||||||
|
message-signature-file (expand-file-name "~/.mail/signature")
|
||||||
message-signature t)
|
message-signature t)
|
||||||
|
|
||||||
;; Move cursor past headers to message body when opening a message
|
;; --- Citation colors ---
|
||||||
|
;; gnus-cite-* : colors in the view (read) buffer
|
||||||
|
;; message-cited-text-*: colors in the compose (reply) buffer
|
||||||
|
;; Both use the same Dracula palette for visual consistency.
|
||||||
|
;; Duplicate face names are intentional — gnus and message-mode
|
||||||
|
;; use separate face systems even though they render the same content.
|
||||||
|
(setq gnus-cite-face-list
|
||||||
|
'(gnus-cite-1 gnus-cite-2 gnus-cite-3 gnus-cite-4))
|
||||||
|
(custom-set-faces!
|
||||||
|
'(gnus-cite-1 :foreground "#8be9fd" :italic t) ; cyan — level 1
|
||||||
|
'(gnus-cite-2 :foreground "#bd93f9" :italic t) ; purple — level 2
|
||||||
|
'(gnus-cite-3 :foreground "#6272a4" :italic t) ; blue — level 3
|
||||||
|
'(gnus-cite-4 :foreground "#44475a" :italic t) ; grey — level 4+
|
||||||
|
'(message-cited-text-1 :foreground "#8be9fd" :italic t)
|
||||||
|
'(message-cited-text-2 :foreground "#bd93f9" :italic t)
|
||||||
|
'(message-cited-text-3 :foreground "#6272a4" :italic t)
|
||||||
|
'(message-cited-text-4 :foreground "#44475a" :italic t))
|
||||||
|
|
||||||
|
;; --- View: skip to message body ---
|
||||||
|
;; gnus-article-prepare-hook fires when the article buffer is ready,
|
||||||
|
;; covering both the initial render and navigation between messages.
|
||||||
|
;; No need for mu4e-view-mode-hook (that fires earlier, before content).
|
||||||
(defun my/mu4e-view-goto-body ()
|
(defun my/mu4e-view-goto-body ()
|
||||||
"Position cursor at the start of the message body, skipping headers."
|
"Position cursor at message body, skipping RFC 2822 headers."
|
||||||
(run-with-idle-timer
|
(run-with-idle-timer
|
||||||
0.05 nil
|
0.05 nil
|
||||||
(lambda ()
|
(lambda ()
|
||||||
@@ -732,23 +782,15 @@ Skip for beamer exports — beamer uses adjustbox on plain tabular."
|
|||||||
(with-current-buffer buf
|
(with-current-buffer buf
|
||||||
(goto-char (point-min))
|
(goto-char (point-min))
|
||||||
(while (and (not (eobp))
|
(while (and (not (eobp))
|
||||||
(looking-at "^\\([A-Za-z-]+:\\|[ \t]\\)"))
|
(looking-at "^\([A-Za-z-]+:\|[ \t]\)"))
|
||||||
(forward-line 1))
|
(forward-line 1))
|
||||||
(while (and (not (eobp)) (looking-at "^\\s-*$"))
|
(while (and (not (eobp)) (looking-at "^\s-*$"))
|
||||||
(forward-line 1)))))))
|
(forward-line 1)))))))
|
||||||
(add-hook 'gnus-article-prepare-hook #'my/mu4e-view-goto-body)
|
(add-hook 'gnus-article-prepare-hook #'my/mu4e-view-goto-body)
|
||||||
(add-hook 'mu4e-view-mode-hook #'my/mu4e-view-goto-body)
|
|
||||||
|
|
||||||
;; Maildir shortcuts
|
;; --- Headers: keep cursor on subject column after j/k ---
|
||||||
(setq mu4e-maildir-shortcuts
|
|
||||||
'(("/personal/INBOX" . ?i)
|
|
||||||
("/personal/Sent" . ?s)
|
|
||||||
("/personal/Trash" . ?t)
|
|
||||||
("/personal/Archive" . ?a)))
|
|
||||||
|
|
||||||
;; Cursor on subject column after j/k navigation
|
|
||||||
(defun my/mu4e-goto-subject (&rest _)
|
(defun my/mu4e-goto-subject (&rest _)
|
||||||
"Move cursor to the start of the subject text in a mu4e headers line."
|
"Move cursor to the start of the subject text in a headers line."
|
||||||
(when (derived-mode-p 'mu4e-headers-mode)
|
(when (derived-mode-p 'mu4e-headers-mode)
|
||||||
(let* ((msg (mu4e-message-at-point t))
|
(let* ((msg (mu4e-message-at-point t))
|
||||||
(subject (when msg (mu4e-message-field msg :subject))))
|
(subject (when msg (mu4e-message-field msg :subject))))
|
||||||
@@ -760,22 +802,14 @@ Skip for beamer exports — beamer uses adjustbox on plain tabular."
|
|||||||
(advice-add 'mu4e-headers-next :after #'my/mu4e-goto-subject)
|
(advice-add 'mu4e-headers-next :after #'my/mu4e-goto-subject)
|
||||||
(advice-add 'mu4e-headers-prev :after #'my/mu4e-goto-subject)
|
(advice-add 'mu4e-headers-prev :after #'my/mu4e-goto-subject)
|
||||||
|
|
||||||
;; zT = toggle thread view (T alone marks thread for bulk action)
|
;; zT = toggle thread view (plain T marks the thread for bulk action)
|
||||||
(evil-define-key 'normal mu4e-headers-mode-map
|
(evil-define-key 'normal mu4e-headers-mode-map
|
||||||
(kbd "zT") #'mu4e-headers-toggle-threading))
|
(kbd "zT") #'mu4e-headers-toggle-threading))
|
||||||
|
|
||||||
(after! mu4e
|
|
||||||
(setq sendmail-program "msmtp"
|
|
||||||
message-send-mail-function #'message-send-mail-with-sendmail
|
|
||||||
mail-specify-envelope-from t
|
|
||||||
message-sendmail-envelope-from 'header))
|
|
||||||
|
|
||||||
|
|
||||||
;;; ============================================================
|
;;; ============================================================
|
||||||
;;; RSS — ELFEED
|
;;; RSS — ELFEED
|
||||||
;;; ============================================================
|
;;; ============================================================
|
||||||
|
|
||||||
(map! :leader :desc "Elfeed" "o r" #'elfeed)
|
|
||||||
|
|
||||||
(after! org
|
(after! org
|
||||||
(setq rmh-elfeed-org-files
|
(setq rmh-elfeed-org-files
|
||||||
@@ -865,6 +899,17 @@ Skip for beamer exports — beamer uses adjustbox on plain tabular."
|
|||||||
;;; DIRED & DIRVISH
|
;;; DIRED & DIRVISH
|
||||||
;;; ============================================================
|
;;; ============================================================
|
||||||
|
|
||||||
|
;; Always hide file details (permissions, size, date) for VoiceOver.
|
||||||
|
;; Toggle visibility with ( in dired/dirvish buffers.
|
||||||
|
;; Three layers of insurance: dirvish-hide-details, dired-mode-hook,
|
||||||
|
;; and dired-after-readin-hook (catches late buffer setup).
|
||||||
|
(add-hook 'dired-mode-hook #'dired-hide-details-mode)
|
||||||
|
;; Attach marked files to mail compose buffer via C-c RET C-a
|
||||||
|
(add-hook 'dired-mode-hook #'turn-on-gnus-dired-mode)
|
||||||
|
(add-hook 'dired-after-readin-hook
|
||||||
|
(lambda () (unless dired-hide-details-mode
|
||||||
|
(dired-hide-details-mode 1))))
|
||||||
|
|
||||||
;; Emacs 31 may not autoload dired-read-dir-and-switches early enough
|
;; Emacs 31 may not autoload dired-read-dir-and-switches early enough
|
||||||
(require 'dired)
|
(require 'dired)
|
||||||
;; macOS: use GNU ls (coreutils) for dired/dirvish sorting support
|
;; macOS: use GNU ls (coreutils) for dired/dirvish sorting support
|
||||||
@@ -876,14 +921,27 @@ Skip for beamer exports — beamer uses adjustbox on plain tabular."
|
|||||||
"RET" #'dired-find-alternate-file
|
"RET" #'dired-find-alternate-file
|
||||||
"^" #'dired-up-directory))
|
"^" #'dired-up-directory))
|
||||||
|
|
||||||
|
|
||||||
;; Dirvish — modern dired replacement
|
;; Dirvish — modern dired replacement
|
||||||
(use-package! dirvish
|
(use-package! dirvish
|
||||||
:init (dirvish-override-dired-mode)
|
:init (dirvish-override-dired-mode)
|
||||||
:config
|
:config
|
||||||
(setq dirvish-mode-line-format '(:left (sort symlink) :right (omit yank index))
|
(setq dirvish-hide-details t
|
||||||
dirvish-attributes '(vc-state subtree-state nerd-icons collapse git-msg file-time file-size)
|
dirvish-mode-line-format '(:left (sort symlink) :right (omit yank index))
|
||||||
|
dirvish-attributes '(vc-state subtree-state nerd-icons collapse git-msg)
|
||||||
dirvish-side-width 35)
|
dirvish-side-width 35)
|
||||||
|
(defun my/dirvish-toggle-details ()
|
||||||
|
"Toggle file-time and file-size dirvish attributes."
|
||||||
|
(interactive)
|
||||||
|
(if (memq 'file-size dirvish-attributes)
|
||||||
|
(setq-local dirvish-attributes
|
||||||
|
(seq-remove (lambda (a) (memq a '(file-time file-size)))
|
||||||
|
dirvish-attributes))
|
||||||
|
(setq-local dirvish-attributes
|
||||||
|
(append dirvish-attributes '(file-time file-size))))
|
||||||
|
(revert-buffer))
|
||||||
(map! :map dirvish-mode-map
|
(map! :map dirvish-mode-map
|
||||||
|
:n "D" #'my/dirvish-toggle-details
|
||||||
:n "q" #'dirvish-quit
|
:n "q" #'dirvish-quit
|
||||||
:n "h" #'dired-up-directory
|
:n "h" #'dired-up-directory
|
||||||
:n "l" #'dired-find-file
|
:n "l" #'dired-find-file
|
||||||
@@ -1079,10 +1137,6 @@ Skip for beamer exports — beamer uses adjustbox on plain tabular."
|
|||||||
(setq dtk-speaker-process nil))
|
(setq dtk-speaker-process nil))
|
||||||
(message "Emacspeak OFF (server restart inhibited)"))
|
(message "Emacspeak OFF (server restart inhibited)"))
|
||||||
|
|
||||||
(map! :leader
|
|
||||||
(:prefix ("t" . "toggle")
|
|
||||||
:desc "Speech ON" "s" #'my/emacspeak-on
|
|
||||||
:desc "Speech OFF" "S" #'my/emacspeak-off))
|
|
||||||
|
|
||||||
(with-eval-after-load 'dtk-speak
|
(with-eval-after-load 'dtk-speak
|
||||||
(setq dtk-speech-rate-base 300)
|
(setq dtk-speech-rate-base 300)
|
||||||
@@ -1175,23 +1229,6 @@ Skip for beamer exports — beamer uses adjustbox on plain tabular."
|
|||||||
scroll-margin 2)
|
scroll-margin 2)
|
||||||
|
|
||||||
|
|
||||||
;;; ============================================================
|
|
||||||
;;; KEYBINDINGS
|
|
||||||
;;; ============================================================
|
|
||||||
|
|
||||||
(map! :leader
|
|
||||||
(:prefix ("h" . "help")
|
|
||||||
:desc "Describe bindings (buffer-local)" "B" #'describe-bindings))
|
|
||||||
|
|
||||||
(map! :leader
|
|
||||||
(:prefix ("z" . "zoom")
|
|
||||||
:desc "Zoom in (x1.5)" "+" #'my/zoom-in
|
|
||||||
:desc "Zoom in (x1.5)" "=" #'my/zoom-in
|
|
||||||
:desc "Zoom out (x1.5)" "-" #'my/zoom-out
|
|
||||||
:desc "Reset" "0" #'my/zoom-reset
|
|
||||||
:desc "Restore previous" "z" #'my/zoom-restore))
|
|
||||||
|
|
||||||
|
|
||||||
;;; ============================================================
|
;;; ============================================================
|
||||||
;;; MATRIX — EMENT.EL
|
;;; MATRIX — EMENT.EL
|
||||||
;;; ============================================================
|
;;; ============================================================
|
||||||
@@ -1358,9 +1395,7 @@ Skip for beamer exports — beamer uses adjustbox on plain tabular."
|
|||||||
;;; WRITING — olivetti-mode
|
;;; WRITING — olivetti-mode
|
||||||
;;; ============================================================
|
;;; ============================================================
|
||||||
|
|
||||||
(use-package! olivetti
|
(after! olivetti
|
||||||
:defer t
|
|
||||||
:config
|
|
||||||
(setq olivetti-body-width 90))
|
(setq olivetti-body-width 90))
|
||||||
|
|
||||||
(add-hook 'org-mode-hook (lambda () (when buffer-file-name (olivetti-mode 1))))
|
(add-hook 'org-mode-hook (lambda () (when buffer-file-name (olivetti-mode 1))))
|
||||||
@@ -1406,27 +1441,46 @@ Skip for beamer exports — beamer uses adjustbox on plain tabular."
|
|||||||
(use-package! org-super-agenda
|
(use-package! org-super-agenda
|
||||||
:after org-agenda
|
:after org-agenda
|
||||||
:config
|
:config
|
||||||
|
;; Agenda: start from today, no past days, no duplicates
|
||||||
|
(setq org-agenda-start-on-weekday nil
|
||||||
|
org-agenda-start-day "0d"
|
||||||
|
org-agenda-span 7
|
||||||
|
org-agenda-skip-scheduled-if-done t
|
||||||
|
org-agenda-skip-deadline-if-done t
|
||||||
|
org-agenda-skip-scheduled-if-deadline-is-shown t
|
||||||
|
org-agenda-skip-deadline-prewarning-if-scheduled 'pre-scheduled)
|
||||||
|
|
||||||
|
;; Sorting: priority first, then deadline, then scheduled
|
||||||
|
(setq org-agenda-sorting-strategy
|
||||||
|
'((agenda priority-down deadline-up scheduled-up)
|
||||||
|
(todo priority-down deadline-up)
|
||||||
|
(tags priority-down deadline-up)))
|
||||||
|
|
||||||
(setq org-super-agenda-groups
|
(setq org-super-agenda-groups
|
||||||
'((:name "Kyndryl — today"
|
'((:name "Overdue"
|
||||||
:and (:tag ("kyndryl" "work") :scheduled today))
|
:deadline past)
|
||||||
(:name "Kyndryl — deadline"
|
(:name "Due today"
|
||||||
:and (:tag ("kyndryl" "work") :deadline t))
|
|
||||||
(:name "Kyndryl"
|
|
||||||
:tag ("kyndryl" "work"))
|
|
||||||
(:name "ZTJ — today"
|
|
||||||
:and (:tag "ztj" :scheduled today))
|
|
||||||
(:name "ZTJ"
|
|
||||||
:tag "ztj")
|
|
||||||
(:name "Today"
|
|
||||||
:scheduled today
|
|
||||||
:deadline today)
|
:deadline today)
|
||||||
|
(:name "Scheduled today"
|
||||||
|
:scheduled today)
|
||||||
|
(:name "Due soon"
|
||||||
|
:deadline future)
|
||||||
(:name "Waiting"
|
(:name "Waiting"
|
||||||
:todo "WAIT")
|
:todo "WAIT")
|
||||||
|
(:name "Kyndryl"
|
||||||
|
:tag ("kyndryl" "work"))
|
||||||
|
(:name "ZTJ"
|
||||||
|
:tag "ztj")
|
||||||
(:name "Other"
|
(:name "Other"
|
||||||
:anything t))))
|
:anything t))))
|
||||||
|
|
||||||
(after! org-super-agenda
|
(after! org-super-agenda
|
||||||
(org-super-agenda-mode 1))
|
(org-super-agenda-mode 1)
|
||||||
|
;; Fix: org-super-agenda applies its own keymap to group headers
|
||||||
|
;; via text properties, overriding org-agenda keybindings.
|
||||||
|
;; Setting it to nil disables the header keymap entirely so all
|
||||||
|
;; standard agenda keys (m, B, t, etc.) work on every line.
|
||||||
|
(setq org-super-agenda-header-map nil))
|
||||||
|
|
||||||
|
|
||||||
;;; ============================================================
|
;;; ============================================================
|
||||||
@@ -1553,32 +1607,32 @@ Skip for beamer exports — beamer uses adjustbox on plain tabular."
|
|||||||
(insert "#+TITLE: CalDAV sync\n#+STARTUP: overview\n"))))
|
(insert "#+TITLE: CalDAV sync\n#+STARTUP: overview\n"))))
|
||||||
|
|
||||||
;; 1. Suky (twoway): download -> suky.org, upload from calendar_outbox.org
|
;; 1. Suky (twoway): download -> suky.org, upload from calendar_outbox.org
|
||||||
(setq org-caldav-url "https://cal.apps.sukany.cz/dav.php/calendars/martin"
|
(setq org-caldav-url my/caldav-url
|
||||||
org-caldav-calendar-id "default"
|
org-caldav-calendar-id my/caldav-id-suky
|
||||||
org-caldav-inbox "~/org/caldav/suky.org"
|
org-caldav-inbox "~/org/caldav/suky.org"
|
||||||
org-caldav-files '("~/org/calendar_outbox.org")
|
org-caldav-files '("~/org/calendar_outbox.org")
|
||||||
org-caldav-sync-direction 'twoway)
|
org-caldav-sync-direction 'twoway)
|
||||||
(org-caldav-sync)
|
(org-caldav-sync)
|
||||||
|
|
||||||
;; 2. Placeholders (read-only)
|
;; 2. Placeholders (read-only)
|
||||||
(setq org-caldav-url "https://cal.apps.sukany.cz/dav.php/calendars/martin"
|
(setq org-caldav-url my/caldav-url
|
||||||
org-caldav-calendar-id "4C748EE5-ECFF-4D4A-A72E-6DE37BAADEB3"
|
org-caldav-calendar-id my/caldav-id-placeholders
|
||||||
org-caldav-inbox "~/org/caldav/placeholders.org"
|
org-caldav-inbox "~/org/caldav/placeholders.org"
|
||||||
org-caldav-files nil
|
org-caldav-files nil
|
||||||
org-caldav-sync-direction 'cal->org)
|
org-caldav-sync-direction 'cal->org)
|
||||||
(org-caldav-sync)
|
(org-caldav-sync)
|
||||||
|
|
||||||
;; 3. Family (read-only, shared via Baikal ACL)
|
;; 3. Family (read-only, shared via Baikal ACL)
|
||||||
(setq org-caldav-url "https://cal.apps.sukany.cz/dav.php/calendars/martin"
|
(setq org-caldav-url my/caldav-url
|
||||||
org-caldav-calendar-id "family"
|
org-caldav-calendar-id my/caldav-id-family
|
||||||
org-caldav-inbox "~/org/caldav/family.org"
|
org-caldav-inbox "~/org/caldav/family.org"
|
||||||
org-caldav-files nil
|
org-caldav-files nil
|
||||||
org-caldav-sync-direction 'cal->org)
|
org-caldav-sync-direction 'cal->org)
|
||||||
(org-caldav-sync)
|
(org-caldav-sync)
|
||||||
|
|
||||||
;; 4. Klara (read-only, shared via Baikal ACL)
|
;; 4. Klara (read-only, shared via Baikal ACL)
|
||||||
(setq org-caldav-url "https://cal.apps.sukany.cz/dav.php/calendars/martin"
|
(setq org-caldav-url my/caldav-url
|
||||||
org-caldav-calendar-id "klara"
|
org-caldav-calendar-id my/caldav-id-klara
|
||||||
org-caldav-inbox "~/org/caldav/klara.org"
|
org-caldav-inbox "~/org/caldav/klara.org"
|
||||||
org-caldav-files nil
|
org-caldav-files nil
|
||||||
org-caldav-sync-direction 'cal->org)
|
org-caldav-sync-direction 'cal->org)
|
||||||
@@ -1586,7 +1640,6 @@ Skip for beamer exports — beamer uses adjustbox on plain tabular."
|
|||||||
|
|
||||||
(message "CalDAV sync done: Suky + Placeholders + Family + Klara")))
|
(message "CalDAV sync done: Suky + Placeholders + Family + Klara")))
|
||||||
|
|
||||||
(map! :leader "o c" #'my/org-caldav-sync)
|
|
||||||
|
|
||||||
|
|
||||||
;;; ============================================================
|
;;; ============================================================
|
||||||
@@ -1775,7 +1828,6 @@ Formats matching what org-caldav/ox-icalendar export correctly:
|
|||||||
:n "l" #'kubel-get-pod-logs
|
:n "l" #'kubel-get-pod-logs
|
||||||
:n "d" #'kubel-describe-resource
|
:n "d" #'kubel-describe-resource
|
||||||
:n "D" #'kubel-delete-resource))
|
:n "D" #'kubel-delete-resource))
|
||||||
(map! :leader "o k" #'kubel)
|
|
||||||
|
|
||||||
|
|
||||||
;;; ============================================================
|
;;; ============================================================
|
||||||
@@ -1821,12 +1873,10 @@ Formats matching what org-caldav/ox-icalendar export correctly:
|
|||||||
;;; ============================================================
|
;;; ============================================================
|
||||||
|
|
||||||
(use-package! iedit :commands iedit-mode)
|
(use-package! iedit :commands iedit-mode)
|
||||||
(map! :leader "s e" #'iedit-mode)
|
|
||||||
|
|
||||||
(use-package! vundo
|
(use-package! vundo
|
||||||
:commands vundo
|
:commands vundo
|
||||||
:config (setq vundo-glyph-alist vundo-unicode-symbols))
|
:config (setq vundo-glyph-alist vundo-unicode-symbols))
|
||||||
(map! :leader "u" #'vundo)
|
|
||||||
|
|
||||||
(use-package! breadcrumb
|
(use-package! breadcrumb
|
||||||
:hook ((prog-mode . breadcrumb-local-mode)
|
:hook ((prog-mode . breadcrumb-local-mode)
|
||||||
@@ -2031,7 +2081,6 @@ Formats matching what org-caldav/ox-icalendar export correctly:
|
|||||||
(setq org-roam-ui-sync-theme t
|
(setq org-roam-ui-sync-theme t
|
||||||
org-roam-ui-follow t
|
org-roam-ui-follow t
|
||||||
org-roam-ui-update-on-save t))
|
org-roam-ui-update-on-save t))
|
||||||
(map! :leader "n r u" #'org-roam-ui-mode)
|
|
||||||
|
|
||||||
|
|
||||||
;;; ============================================================
|
;;; ============================================================
|
||||||
@@ -2043,9 +2092,50 @@ Formats matching what org-caldav/ox-icalendar export correctly:
|
|||||||
(expand-file-name "~/languagetool/languagetool-commandline.jar")
|
(expand-file-name "~/languagetool/languagetool-commandline.jar")
|
||||||
langtool-default-language "cs"
|
langtool-default-language "cs"
|
||||||
langtool-mother-tongue "cs"))
|
langtool-mother-tongue "cs"))
|
||||||
|
|
||||||
|
|
||||||
|
;;; ============================================================
|
||||||
|
;;; KEYBINDINGS — central reference
|
||||||
|
;;; ============================================================
|
||||||
|
;;; Standalone bindings (package-independent, safe to define top-level).
|
||||||
|
;;
|
||||||
|
;; The following bindings are defined near their packages (load-order sensitive):
|
||||||
|
;; SPC o r — elfeed (elfeed section)
|
||||||
|
;; SPC o n/N — org-noter (org-noter section)
|
||||||
|
;; SPC o C — calendar/calfw (calfw section)
|
||||||
|
;; SPC j k/K — link-hint (link-hint section)
|
||||||
|
;; SPC t o — olivetti (olivetti section)
|
||||||
|
;; SPC z +/= — zoom in/out (keybindings section below)
|
||||||
|
;; SPC o M — Matrix/ement (Matrix section)
|
||||||
|
;; SPC B b/i — bibliography/citar (bibliography section)
|
||||||
|
;; SPC s q/Q — org-ql (org-ql section)
|
||||||
|
;; SPC | di| ci| vi| — table cell objects (evil-org section)
|
||||||
|
|
||||||
|
(map! :leader
|
||||||
|
(:prefix ("z" . "zoom")
|
||||||
|
:desc "Zoom in (x1.5)" "+" #'my/zoom-in
|
||||||
|
:desc "Zoom in (x1.5)" "=" #'my/zoom-in
|
||||||
|
:desc "Zoom out (x1.5)" "-" #'my/zoom-out
|
||||||
|
:desc "Reset" "0" #'my/zoom-reset
|
||||||
|
:desc "Restore previous" "z" #'my/zoom-restore))
|
||||||
|
|
||||||
|
(map! :leader :desc "Elfeed" "o r" #'elfeed)
|
||||||
|
(map! :leader
|
||||||
|
(:prefix ("t" . "toggle")
|
||||||
|
:desc "Speech ON" "s" #'my/emacspeak-on
|
||||||
|
:desc "Speech OFF" "S" #'my/emacspeak-off))
|
||||||
|
(map! :leader
|
||||||
|
(:prefix ("h" . "help")
|
||||||
|
:desc "Describe bindings (buffer-local)" "B" #'describe-bindings))
|
||||||
|
(map! :leader "o k" #'kubel)
|
||||||
|
(map! :leader "s e" #'iedit-mode)
|
||||||
|
(map! :leader "u" #'vundo)
|
||||||
|
(map! :leader "n r u" #'org-roam-ui-mode)
|
||||||
(map! :leader
|
(map! :leader
|
||||||
"t g" #'langtool-check
|
"t g" #'langtool-check
|
||||||
"t G" #'langtool-check-done)
|
"t G" #'langtool-check-done)
|
||||||
|
(map! :leader "o c" #'my/org-caldav-sync)
|
||||||
|
|
||||||
;; gls
|
;; gls
|
||||||
(setq insert-directory-program "gls")
|
(setq insert-directory-program "gls")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,682 @@
|
|||||||
|
From d176c3c9d97574f0cd493d6491eda0a82ad28387 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 class.
|
||||||
|
(EmacsAccessibilityBuffer, EmacsAccessibilityModeLine)
|
||||||
|
(EmacsAccessibilityInteractiveSpan): Forward declarations.
|
||||||
|
(EmacsAccessibilityBuffer(Notifications)): New category interface.
|
||||||
|
(EmacsAccessibilityBuffer(InteractiveSpans)): New category interface.
|
||||||
|
(EmacsAXSpanType): New enum.
|
||||||
|
(EmacsView): New ivars for accessibility state.
|
||||||
|
* src/nsterm.m: Include intervals.h for TEXT_PROP_MEANS_INVISIBLE.
|
||||||
|
|
||||||
|
(ns_ax_buffer_text, ns_ax_mode_line_text, ns_ax_frame_for_range)
|
||||||
|
(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
|
||||||
|
functions.
|
||||||
|
(EmacsAccessibilityElement): Implement base class.
|
||||||
|
(syms_of_nsterm): Register accessibility DEFSYM and DEFVAR
|
||||||
|
ns-accessibility-enabled.
|
||||||
|
|
||||||
|
Tested on macOS 14 Sonoma with VoiceOver 10. Builds cleanly;
|
||||||
|
no functional change (dead code until patch 5/6 wires it in).
|
||||||
|
---
|
||||||
|
src/nsterm.h | 131 +++++++++++++++
|
||||||
|
src/nsterm.m | 456 +++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
2 files changed, 587 insertions(+)
|
||||||
|
|
||||||
|
diff --git a/src/nsterm.h b/src/nsterm.h
|
||||||
|
index 7c1ee4c..5298386 100644
|
||||||
|
--- a/src/nsterm.h
|
||||||
|
+++ b/src/nsterm.h
|
||||||
|
@@ -453,6 +453,122 @@ 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 string index of each line start. */
|
||||||
|
+ NSUInteger lineCount; /* Number of 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 +587,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 +652,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..2ac1d9d 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,430 @@ 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 ---- */
|
||||||
|
+
|
||||||
|
+/* 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;
|
||||||
|
+
|
||||||
|
+ 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. 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,
|
||||||
|
+};
|
||||||
|
+
|
||||||
|
+/* 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 +11737,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 +11907,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
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,584 @@
|
|||||||
|
From 97baf7b5f8b0ccc85342e7d552b69b337c98f772 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Martin Sukany <martin@sukany.cz>
|
||||||
|
Date: Sat, 28 Feb 2026 12:58:11 +0100
|
||||||
|
Subject: [PATCH 3/8] ns: add buffer notification dispatch and mode-line
|
||||||
|
element
|
||||||
|
|
||||||
|
Add VoiceOver notification methods and mode-line readout.
|
||||||
|
|
||||||
|
* src/nsterm.m (EmacsAccessibilityBuffer(Notifications)): New
|
||||||
|
category.
|
||||||
|
(postTextChangedNotification:): ValueChanged with edit details.
|
||||||
|
(postFocusedCursorNotification:direction:granularity:markActive:
|
||||||
|
oldMarkActive:): Hybrid SelectedTextChanged / AnnouncementRequested
|
||||||
|
per WebKit pattern.
|
||||||
|
(postCompletionAnnouncementForBuffer:point:): Announce completion
|
||||||
|
candidates in non-focused buffers.
|
||||||
|
(postAccessibilityNotificationsForFrame:): Main dispatch entry point.
|
||||||
|
(EmacsAccessibilityModeLine): Implement AXStaticText element.
|
||||||
|
|
||||||
|
Tested on macOS 14. Verified: cursor movement announcements,
|
||||||
|
region selection feedback, completion popups, mode-line reading.
|
||||||
|
---
|
||||||
|
src/nsterm.m | 545 +++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
1 file changed, 545 insertions(+)
|
||||||
|
|
||||||
|
diff --git a/src/nsterm.m b/src/nsterm.m
|
||||||
|
index fc5906a..f1a1b42 100644
|
||||||
|
--- a/src/nsterm.m
|
||||||
|
+++ b/src/nsterm.m
|
||||||
|
@@ -8624,6 +8624,551 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
+
|
||||||
|
+
|
||||||
|
+/* ===================================================================
|
||||||
|
+ EmacsAccessibilityBuffer (Notifications) — AX event dispatch
|
||||||
|
+
|
||||||
|
+ These methods notify VoiceOver of text and selection changes.
|
||||||
|
+ Called from the redisplay cycle (postAccessibilityUpdates).
|
||||||
|
+ =================================================================== */
|
||||||
|
+
|
||||||
|
+@implementation EmacsAccessibilityBuffer (Notifications)
|
||||||
|
+
|
||||||
|
+- (void)postTextChangedNotification:(ptrdiff_t)point
|
||||||
|
+{
|
||||||
|
+ /* Capture changed char before invalidating cache. */
|
||||||
|
+ NSString *changedChar = @"";
|
||||||
|
+ if (point > self.cachedPoint
|
||||||
|
+ && point - self.cachedPoint == 1)
|
||||||
|
+ {
|
||||||
|
+ /* Single char inserted — refresh cache and grab it. */
|
||||||
|
+ [self invalidateTextCache];
|
||||||
|
+ [self ensureTextCache];
|
||||||
|
+ if (cachedText)
|
||||||
|
+ {
|
||||||
|
+ NSUInteger idx = [self accessibilityIndexForCharpos:point - 1];
|
||||||
|
+ if (idx < [cachedText length])
|
||||||
|
+ changedChar = [cachedText substringWithRange:
|
||||||
|
+ NSMakeRange (idx, 1)];
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ else
|
||||||
|
+ {
|
||||||
|
+ [self invalidateTextCache];
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ /* Update cachedPoint here so the selection-move branch does NOT
|
||||||
|
+ fire for point changes caused by edits. WebKit and Chromium
|
||||||
|
+ never send both ValueChanged and SelectedTextChanged for the
|
||||||
|
+ same user action — they are mutually exclusive. */
|
||||||
|
+ self.cachedPoint = point;
|
||||||
|
+
|
||||||
|
+ NSDictionary *change = @{
|
||||||
|
+ @"AXTextEditType": @(ns_ax_text_edit_type_typing),
|
||||||
|
+ @"AXTextChangeValue": changedChar,
|
||||||
|
+ @"AXTextChangeValueLength": @([changedChar length])
|
||||||
|
+ };
|
||||||
|
+ NSDictionary *userInfo = @{
|
||||||
|
+ @"AXTextStateChangeType": @(ns_ax_text_state_change_edit),
|
||||||
|
+ @"AXTextChangeValues": @[change],
|
||||||
|
+ @"AXTextChangeElement": self
|
||||||
|
+ };
|
||||||
|
+ ns_ax_post_notification_with_info (
|
||||||
|
+ self, NSAccessibilityValueChangedNotification, userInfo);
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+/* Post SelectedTextChanged and AnnouncementRequested for the
|
||||||
|
+ focused buffer element when point or mark changes. */
|
||||||
|
+- (void)postFocusedCursorNotification:(ptrdiff_t)point
|
||||||
|
+ direction:(NSInteger)direction
|
||||||
|
+ granularity:(NSInteger)granularity
|
||||||
|
+ markActive:(BOOL)markActive
|
||||||
|
+ oldMarkActive:(BOOL)oldMarkActive
|
||||||
|
+{
|
||||||
|
+ BOOL isCharMove
|
||||||
|
+ = (!markActive && !oldMarkActive
|
||||||
|
+ && granularity
|
||||||
|
+ == ns_ax_text_selection_granularity_character);
|
||||||
|
+
|
||||||
|
+ /* Always post SelectedTextChanged to interrupt VoiceOver reading
|
||||||
|
+ and update cursor tracking / braille displays. */
|
||||||
|
+ NSMutableDictionary *moveInfo = [NSMutableDictionary dictionary];
|
||||||
|
+ moveInfo[@"AXTextStateChangeType"]
|
||||||
|
+ = @(ns_ax_text_state_change_selection_move);
|
||||||
|
+ moveInfo[@"AXTextSelectionDirection"] = @(direction);
|
||||||
|
+ moveInfo[@"AXTextChangeElement"] = self;
|
||||||
|
+ /* Omit granularity for character moves so VoiceOver does not
|
||||||
|
+ derive its own speech (it would read the wrong character
|
||||||
|
+ for evil block-cursor mode). Include it for word/line/
|
||||||
|
+ selection so VoiceOver reads the appropriate text. */
|
||||||
|
+ if (!isCharMove)
|
||||||
|
+ moveInfo[@"AXTextSelectionGranularity"] = @(granularity);
|
||||||
|
+
|
||||||
|
+ ns_ax_post_notification_with_info (
|
||||||
|
+ self,
|
||||||
|
+ NSAccessibilitySelectedTextChangedNotification,
|
||||||
|
+ moveInfo);
|
||||||
|
+
|
||||||
|
+ /* For character moves: explicit announcement of char AT point.
|
||||||
|
+ This is the ONLY speech source for character navigation.
|
||||||
|
+ Correct for evil block-cursor (cursor ON the character)
|
||||||
|
+ and harmless for insert-mode. */
|
||||||
|
+ if (isCharMove && cachedText)
|
||||||
|
+ {
|
||||||
|
+ NSUInteger point_idx
|
||||||
|
+ = [self accessibilityIndexForCharpos:point];
|
||||||
|
+ NSUInteger tlen = [cachedText length];
|
||||||
|
+ if (point_idx < tlen)
|
||||||
|
+ {
|
||||||
|
+ NSRange charRange = [cachedText
|
||||||
|
+ rangeOfComposedCharacterSequenceAtIndex: point_idx];
|
||||||
|
+ if (charRange.location != NSNotFound
|
||||||
|
+ && charRange.length > 0
|
||||||
|
+ && NSMaxRange (charRange) <= tlen)
|
||||||
|
+ {
|
||||||
|
+ NSString *ch
|
||||||
|
+ = [cachedText substringWithRange: charRange];
|
||||||
|
+ if (![ch isEqualToString: @"\n"])
|
||||||
|
+ {
|
||||||
|
+ NSDictionary *annInfo = @{
|
||||||
|
+ NSAccessibilityAnnouncementKey: ch,
|
||||||
|
+ NSAccessibilityPriorityKey:
|
||||||
|
+ @(NSAccessibilityPriorityHigh)
|
||||||
|
+ };
|
||||||
|
+ ns_ax_post_notification_with_info (
|
||||||
|
+ NSApp,
|
||||||
|
+ NSAccessibilityAnnouncementRequestedNotification,
|
||||||
|
+ annInfo);
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ /* For focused line moves: always announce line text explicitly.
|
||||||
|
+ SelectedTextChanged with granularity=line works for arrow keys,
|
||||||
|
+ but C-n/C-p need the explicit announcement (VoiceOver processes
|
||||||
|
+ these keystrokes differently from arrows).
|
||||||
|
+ In completion-list-mode, read the completion candidate instead
|
||||||
|
+ of the whole line. */
|
||||||
|
+ if (cachedText
|
||||||
|
+ && granularity == ns_ax_text_selection_granularity_line)
|
||||||
|
+ {
|
||||||
|
+ NSString *announceText = nil;
|
||||||
|
+
|
||||||
|
+ /* 1. completion--string at point. */
|
||||||
|
+ Lisp_Object cstr
|
||||||
|
+ = Fget_char_property (make_fixnum (point),
|
||||||
|
+ Qns_ax_completion__string, Qnil);
|
||||||
|
+ announceText = ns_ax_completion_string_from_prop (cstr);
|
||||||
|
+
|
||||||
|
+ /* 2. Fallback: full line text. */
|
||||||
|
+ if (!announceText)
|
||||||
|
+ {
|
||||||
|
+ NSUInteger point_idx
|
||||||
|
+ = [self accessibilityIndexForCharpos:point];
|
||||||
|
+ if (point_idx <= [cachedText length])
|
||||||
|
+ {
|
||||||
|
+ NSInteger lineNum
|
||||||
|
+ = [self accessibilityLineForIndex:point_idx];
|
||||||
|
+ NSRange lineRange
|
||||||
|
+ = [self accessibilityRangeForLine:lineNum];
|
||||||
|
+ if (lineRange.location != NSNotFound
|
||||||
|
+ && lineRange.length > 0
|
||||||
|
+ && NSMaxRange (lineRange) <= [cachedText length])
|
||||||
|
+ announceText
|
||||||
|
+ = [cachedText substringWithRange:lineRange];
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ if (announceText)
|
||||||
|
+ {
|
||||||
|
+ announceText = [announceText
|
||||||
|
+ stringByTrimmingCharactersInSet:
|
||||||
|
+ [NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||||
|
+ if ([announceText length] > 0)
|
||||||
|
+ {
|
||||||
|
+ NSDictionary *annInfo = @{
|
||||||
|
+ NSAccessibilityAnnouncementKey: announceText,
|
||||||
|
+ NSAccessibilityPriorityKey:
|
||||||
|
+ @(NSAccessibilityPriorityHigh)
|
||||||
|
+ };
|
||||||
|
+ ns_ax_post_notification_with_info (
|
||||||
|
+ NSApp,
|
||||||
|
+ NSAccessibilityAnnouncementRequestedNotification,
|
||||||
|
+ annInfo);
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+/* Post AnnouncementRequested for non-focused buffers (typically
|
||||||
|
+ *Completions* while minibuffer has keyboard focus).
|
||||||
|
+ VoiceOver does not automatically read changes in non-focused
|
||||||
|
+ elements, so we announce the selected completion explicitly. */
|
||||||
|
+- (void)postCompletionAnnouncementForBuffer:(struct buffer *)b
|
||||||
|
+ point:(ptrdiff_t)point
|
||||||
|
+{
|
||||||
|
+ NSString *announceText = nil;
|
||||||
|
+ ptrdiff_t currentOverlayStart = 0;
|
||||||
|
+ ptrdiff_t currentOverlayEnd = 0;
|
||||||
|
+
|
||||||
|
+ specpdl_ref count2 = SPECPDL_INDEX ();
|
||||||
|
+ record_unwind_current_buffer ();
|
||||||
|
+ if (b != current_buffer)
|
||||||
|
+ set_buffer_internal_1 (b);
|
||||||
|
+
|
||||||
|
+ /* 1) Prefer explicit completion candidate property. */
|
||||||
|
+ Lisp_Object cstr = Fget_char_property (make_fixnum (point),
|
||||||
|
+ Qns_ax_completion__string,
|
||||||
|
+ Qnil);
|
||||||
|
+ announceText = ns_ax_completion_string_from_prop (cstr);
|
||||||
|
+
|
||||||
|
+ /* 2) Fallback: mouse-face span at point. */
|
||||||
|
+ if (!announceText)
|
||||||
|
+ {
|
||||||
|
+ Lisp_Object mf = Fget_char_property (make_fixnum (point),
|
||||||
|
+ Qmouse_face, Qnil);
|
||||||
|
+ if (!NILP (mf))
|
||||||
|
+ {
|
||||||
|
+ ptrdiff_t begv2 = BUF_BEGV (b);
|
||||||
|
+ ptrdiff_t zv2 = BUF_ZV (b);
|
||||||
|
+
|
||||||
|
+ Lisp_Object prev_change
|
||||||
|
+ = Fprevious_single_char_property_change (
|
||||||
|
+ make_fixnum (point + 1), Qmouse_face,
|
||||||
|
+ Qnil, make_fixnum (begv2));
|
||||||
|
+ ptrdiff_t s2
|
||||||
|
+ = FIXNUMP (prev_change) ? XFIXNUM (prev_change)
|
||||||
|
+ : begv2;
|
||||||
|
+
|
||||||
|
+ Lisp_Object next_change
|
||||||
|
+ = Fnext_single_char_property_change (
|
||||||
|
+ make_fixnum (point), Qmouse_face,
|
||||||
|
+ Qnil, make_fixnum (zv2));
|
||||||
|
+ ptrdiff_t e2
|
||||||
|
+ = FIXNUMP (next_change) ? XFIXNUM (next_change)
|
||||||
|
+ : zv2;
|
||||||
|
+
|
||||||
|
+ if (e2 > s2)
|
||||||
|
+ {
|
||||||
|
+ NSUInteger ax_s = [self accessibilityIndexForCharpos:s2];
|
||||||
|
+ NSUInteger ax_e = [self accessibilityIndexForCharpos:e2];
|
||||||
|
+ if (ax_e > ax_s && ax_e <= [cachedText length])
|
||||||
|
+ announceText = [cachedText substringWithRange:
|
||||||
|
+ NSMakeRange (ax_s, ax_e - ax_s)];
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ /* 3) Fallback: completions-highlight overlay at point. */
|
||||||
|
+ if (!announceText)
|
||||||
|
+ {
|
||||||
|
+ Lisp_Object faceSym = Qns_ax_completions_highlight;
|
||||||
|
+ Lisp_Object overlays = Foverlays_at (make_fixnum (point), Qnil);
|
||||||
|
+ Lisp_Object tail;
|
||||||
|
+ for (tail = overlays; CONSP (tail); tail = XCDR (tail))
|
||||||
|
+ {
|
||||||
|
+ Lisp_Object ov = XCAR (tail);
|
||||||
|
+ Lisp_Object face = Foverlay_get (ov, Qface);
|
||||||
|
+ if (EQ (face, faceSym)
|
||||||
|
+ || (CONSP (face)
|
||||||
|
+ && !NILP (Fmemq (faceSym, face))))
|
||||||
|
+ {
|
||||||
|
+ ptrdiff_t ov_start = OVERLAY_START (ov);
|
||||||
|
+ ptrdiff_t ov_end = OVERLAY_END (ov);
|
||||||
|
+ if (ov_end > ov_start)
|
||||||
|
+ {
|
||||||
|
+ announceText = ns_ax_completion_text_for_span (self, b,
|
||||||
|
+ ov_start,
|
||||||
|
+ ov_end,
|
||||||
|
+ cachedText);
|
||||||
|
+ currentOverlayStart = ov_start;
|
||||||
|
+ currentOverlayEnd = ov_end;
|
||||||
|
+ }
|
||||||
|
+ break;
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ /* 4) Fallback: nearest completions-highlight overlay. */
|
||||||
|
+ if (!announceText)
|
||||||
|
+ {
|
||||||
|
+ ptrdiff_t ov_start = 0;
|
||||||
|
+ ptrdiff_t ov_end = 0;
|
||||||
|
+ if (ns_ax_find_completion_overlay_range (b, point,
|
||||||
|
+ &ov_start, &ov_end))
|
||||||
|
+ {
|
||||||
|
+ announceText = ns_ax_completion_text_for_span (self, b,
|
||||||
|
+ ov_start, ov_end,
|
||||||
|
+ cachedText);
|
||||||
|
+ currentOverlayStart = ov_start;
|
||||||
|
+ currentOverlayEnd = ov_end;
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ unbind_to (count2, Qnil);
|
||||||
|
+
|
||||||
|
+ /* Final fallback: read current line at point. */
|
||||||
|
+ if (!announceText)
|
||||||
|
+ {
|
||||||
|
+ NSUInteger point_idx = [self accessibilityIndexForCharpos:point];
|
||||||
|
+ if (point_idx <= [cachedText length])
|
||||||
|
+ {
|
||||||
|
+ NSInteger lineNum = [self accessibilityLineForIndex:
|
||||||
|
+ point_idx];
|
||||||
|
+ NSRange lineRange = [self accessibilityRangeForLine:lineNum];
|
||||||
|
+ if (lineRange.location != NSNotFound
|
||||||
|
+ && lineRange.length > 0
|
||||||
|
+ && lineRange.location + lineRange.length
|
||||||
|
+ <= [cachedText length])
|
||||||
|
+ announceText = [cachedText substringWithRange:lineRange];
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ /* Deduplicate: post only when text, overlay, or point changed. */
|
||||||
|
+ if (announceText)
|
||||||
|
+ {
|
||||||
|
+ announceText = [announceText stringByTrimmingCharactersInSet:
|
||||||
|
+ [NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||||
|
+ if ([announceText length] > 0)
|
||||||
|
+ {
|
||||||
|
+ BOOL textChanged = ![announceText isEqualToString:
|
||||||
|
+ self.cachedCompletionAnnouncement];
|
||||||
|
+ BOOL overlayChanged =
|
||||||
|
+ (currentOverlayStart != self.cachedCompletionOverlayStart
|
||||||
|
+ || currentOverlayEnd != self.cachedCompletionOverlayEnd);
|
||||||
|
+ BOOL pointChanged = (point != self.cachedCompletionPoint);
|
||||||
|
+ if (textChanged || overlayChanged || pointChanged)
|
||||||
|
+ {
|
||||||
|
+ NSDictionary *annInfo = @{
|
||||||
|
+ NSAccessibilityAnnouncementKey: announceText,
|
||||||
|
+ NSAccessibilityPriorityKey:
|
||||||
|
+ @(NSAccessibilityPriorityHigh)
|
||||||
|
+ };
|
||||||
|
+ ns_ax_post_notification_with_info (
|
||||||
|
+ NSApp,
|
||||||
|
+ NSAccessibilityAnnouncementRequestedNotification,
|
||||||
|
+ annInfo);
|
||||||
|
+ }
|
||||||
|
+ self.cachedCompletionAnnouncement = announceText;
|
||||||
|
+ self.cachedCompletionOverlayStart = currentOverlayStart;
|
||||||
|
+ self.cachedCompletionOverlayEnd = currentOverlayEnd;
|
||||||
|
+ self.cachedCompletionPoint = point;
|
||||||
|
+ }
|
||||||
|
+ else
|
||||||
|
+ {
|
||||||
|
+ self.cachedCompletionAnnouncement = nil;
|
||||||
|
+ self.cachedCompletionOverlayStart = 0;
|
||||||
|
+ self.cachedCompletionOverlayEnd = 0;
|
||||||
|
+ self.cachedCompletionPoint = 0;
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ else
|
||||||
|
+ {
|
||||||
|
+ self.cachedCompletionAnnouncement = nil;
|
||||||
|
+ self.cachedCompletionOverlayStart = 0;
|
||||||
|
+ self.cachedCompletionOverlayEnd = 0;
|
||||||
|
+ self.cachedCompletionPoint = 0;
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+/* ---- Notification dispatch (main entry point) ---- */
|
||||||
|
+
|
||||||
|
+/* Dispatch accessibility notifications after a redisplay cycle.
|
||||||
|
+ Detects three mutually exclusive events: text edit, cursor/mark
|
||||||
|
+ change, or no change. Delegates to helper methods above. */
|
||||||
|
+- (void)postAccessibilityNotificationsForFrame:(struct frame *)f
|
||||||
|
+{
|
||||||
|
+ NSTRACE ("[EmacsView postAccessibilityNotificationsForFrame:]");
|
||||||
|
+ struct window *w = [self validWindow];
|
||||||
|
+ if (!w || !WINDOW_LEAF_P (w))
|
||||||
|
+ return;
|
||||||
|
+
|
||||||
|
+ struct buffer *b = XBUFFER (w->contents);
|
||||||
|
+ if (!b)
|
||||||
|
+ return;
|
||||||
|
+
|
||||||
|
+ ptrdiff_t modiff = BUF_MODIFF (b);
|
||||||
|
+ ptrdiff_t point = BUF_PT (b);
|
||||||
|
+ BOOL markActive = !NILP (BVAR (b, mark_active));
|
||||||
|
+
|
||||||
|
+ /* --- Text changed (edit) --- */
|
||||||
|
+ if (modiff != self.cachedModiff)
|
||||||
|
+ {
|
||||||
|
+ self.cachedModiff = modiff;
|
||||||
|
+ [self postTextChangedNotification:point];
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ /* --- Cursor moved or selection changed ---
|
||||||
|
+ Use 'else if' — edits and selection moves are mutually exclusive
|
||||||
|
+ per the WebKit/Chromium pattern. */
|
||||||
|
+ else if (point != self.cachedPoint || markActive != self.cachedMarkActive)
|
||||||
|
+ {
|
||||||
|
+ ptrdiff_t oldPoint = self.cachedPoint;
|
||||||
|
+ BOOL oldMarkActive = self.cachedMarkActive;
|
||||||
|
+ self.cachedPoint = point;
|
||||||
|
+ self.cachedMarkActive = markActive;
|
||||||
|
+
|
||||||
|
+ /* Compute direction. */
|
||||||
|
+ NSInteger direction = ns_ax_text_selection_direction_discontiguous;
|
||||||
|
+ if (point > oldPoint)
|
||||||
|
+ direction = ns_ax_text_selection_direction_next;
|
||||||
|
+ else if (point < oldPoint)
|
||||||
|
+ direction = ns_ax_text_selection_direction_previous;
|
||||||
|
+
|
||||||
|
+ int ctrlNP = 0;
|
||||||
|
+ bool isCtrlNP = ns_ax_event_is_line_nav_key (&ctrlNP);
|
||||||
|
+
|
||||||
|
+ /* --- Granularity detection --- */
|
||||||
|
+ NSInteger granularity = ns_ax_text_selection_granularity_unknown;
|
||||||
|
+ [self ensureTextCache];
|
||||||
|
+ if (cachedText && oldPoint > 0)
|
||||||
|
+ {
|
||||||
|
+ NSUInteger tlen = [cachedText length];
|
||||||
|
+ NSUInteger oldIdx = [self accessibilityIndexForCharpos:oldPoint];
|
||||||
|
+ NSUInteger newIdx = [self accessibilityIndexForCharpos:point];
|
||||||
|
+ if (oldIdx > tlen) oldIdx = tlen;
|
||||||
|
+ if (newIdx > tlen) newIdx = tlen;
|
||||||
|
+
|
||||||
|
+ NSRange oldLine = [cachedText lineRangeForRange:
|
||||||
|
+ NSMakeRange (oldIdx, 0)];
|
||||||
|
+ NSRange newLine = [cachedText lineRangeForRange:
|
||||||
|
+ NSMakeRange (newIdx, 0)];
|
||||||
|
+ if (oldLine.location != newLine.location)
|
||||||
|
+ granularity = ns_ax_text_selection_granularity_line;
|
||||||
|
+ else
|
||||||
|
+ {
|
||||||
|
+ NSUInteger dist = (newIdx > oldIdx
|
||||||
|
+ ? newIdx - oldIdx
|
||||||
|
+ : oldIdx - newIdx);
|
||||||
|
+ if (dist > 1)
|
||||||
|
+ granularity = ns_ax_text_selection_granularity_word;
|
||||||
|
+ else if (dist == 1)
|
||||||
|
+ granularity = ns_ax_text_selection_granularity_character;
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ /* Force line semantics for explicit C-n/C-p / Tab / backtab. */
|
||||||
|
+ if (isCtrlNP)
|
||||||
|
+ {
|
||||||
|
+ direction = (ctrlNP > 0
|
||||||
|
+ ? ns_ax_text_selection_direction_next
|
||||||
|
+ : ns_ax_text_selection_direction_previous);
|
||||||
|
+ granularity = ns_ax_text_selection_granularity_line;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ /* Post notifications for focused and non-focused elements. */
|
||||||
|
+ if ([self isAccessibilityFocused])
|
||||||
|
+ [self postFocusedCursorNotification:point
|
||||||
|
+ direction:direction
|
||||||
|
+ granularity:granularity
|
||||||
|
+ markActive:markActive
|
||||||
|
+ oldMarkActive:oldMarkActive];
|
||||||
|
+
|
||||||
|
+ if (![self isAccessibilityFocused] && cachedText)
|
||||||
|
+ [self postCompletionAnnouncementForBuffer:b point:point];
|
||||||
|
+ }
|
||||||
|
+ else
|
||||||
|
+ {
|
||||||
|
+ /* Nothing changed. Reset completion cache for focused buffer
|
||||||
|
+ to avoid stale announcements. */
|
||||||
|
+ if ([self isAccessibilityFocused])
|
||||||
|
+ {
|
||||||
|
+ self.cachedCompletionAnnouncement = nil;
|
||||||
|
+ self.cachedCompletionOverlayStart = 0;
|
||||||
|
+ self.cachedCompletionOverlayEnd = 0;
|
||||||
|
+ self.cachedCompletionPoint = 0;
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+@end
|
||||||
|
+
|
||||||
|
+
|
||||||
|
+@implementation EmacsAccessibilityModeLine
|
||||||
|
+
|
||||||
|
+- (NSAccessibilityRole)accessibilityRole
|
||||||
|
+{
|
||||||
|
+ return NSAccessibilityStaticTextRole;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+- (NSString *)accessibilityLabel
|
||||||
|
+{
|
||||||
|
+ if (![NSThread isMainThread])
|
||||||
|
+ {
|
||||||
|
+ __block NSString *result;
|
||||||
|
+ dispatch_sync (dispatch_get_main_queue (), ^{
|
||||||
|
+ result = [self accessibilityLabel];
|
||||||
|
+ });
|
||||||
|
+ return result;
|
||||||
|
+ }
|
||||||
|
+ struct window *w = [self validWindow];
|
||||||
|
+ if (w && WINDOW_LEAF_P (w))
|
||||||
|
+ {
|
||||||
|
+ struct buffer *b = XBUFFER (w->contents);
|
||||||
|
+ if (b)
|
||||||
|
+ {
|
||||||
|
+ Lisp_Object name = BVAR (b, name);
|
||||||
|
+ if (STRINGP (name))
|
||||||
|
+ {
|
||||||
|
+ NSString *bufName = [NSString stringWithLispString:name];
|
||||||
|
+ return [NSString stringWithFormat:@"Mode Line - %@", bufName];
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ return @"Mode Line";
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+- (id)accessibilityValue
|
||||||
|
+{
|
||||||
|
+ if (![NSThread isMainThread])
|
||||||
|
+ {
|
||||||
|
+ __block id result;
|
||||||
|
+ dispatch_sync (dispatch_get_main_queue (), ^{
|
||||||
|
+ result = [self accessibilityValue];
|
||||||
|
+ });
|
||||||
|
+ return result;
|
||||||
|
+ }
|
||||||
|
+ struct window *w = [self validWindow];
|
||||||
|
+ if (!w)
|
||||||
|
+ return @"";
|
||||||
|
+ return ns_ax_mode_line_text (w);
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+- (NSRect)accessibilityFrame
|
||||||
|
+{
|
||||||
|
+ if (![NSThread isMainThread])
|
||||||
|
+ {
|
||||||
|
+ __block NSRect result;
|
||||||
|
+ dispatch_sync (dispatch_get_main_queue (), ^{
|
||||||
|
+ result = [self accessibilityFrame];
|
||||||
|
+ });
|
||||||
|
+ return result;
|
||||||
|
+ }
|
||||||
|
+ struct window *w = [self validWindow];
|
||||||
|
+ if (!w || !w->current_matrix)
|
||||||
|
+ return NSZeroRect;
|
||||||
|
+
|
||||||
|
+ /* Find the mode line row and return its screen rect. */
|
||||||
|
+ struct glyph_matrix *matrix = w->current_matrix;
|
||||||
|
+ for (int i = 0; i < matrix->nrows; i++)
|
||||||
|
+ {
|
||||||
|
+ struct glyph_row *row = matrix->rows + i;
|
||||||
|
+ if (row->enabled_p && row->mode_line_p)
|
||||||
|
+ {
|
||||||
|
+ return [self screenRectFromEmacsX:w->pixel_left
|
||||||
|
+ y:WINDOW_TO_FRAME_PIXEL_Y (w,
|
||||||
|
+ MAX (0, row->y))
|
||||||
|
+ width:w->pixel_width
|
||||||
|
+ height:row->visible_height];
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ return NSZeroRect;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+@end
|
||||||
|
+
|
||||||
|
#endif /* NS_IMPL_COCOA */
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
2.43.0
|
||||||
|
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
From 1bd12dd5d464d0c3f9774630014e434b8fb0e19e Mon Sep 17 00:00:00 2001
|
||||||
|
From: Martin Sukany <martin@sukany.cz>
|
||||||
|
Date: Sat, 28 Feb 2026 12:58:11 +0100
|
||||||
|
Subject: [PATCH 4/8] ns: add interactive span elements for Tab navigation
|
||||||
|
|
||||||
|
* src/nsterm.m (ns_ax_scan_interactive_spans): New function.
|
||||||
|
(EmacsAccessibilityInteractiveSpan): Implement AXButton/AXLink
|
||||||
|
elements with AXPress action.
|
||||||
|
(EmacsAccessibilityBuffer(InteractiveSpans)): New category.
|
||||||
|
accessibilityChildrenInNavigationOrder for Tab/Shift-Tab cycling
|
||||||
|
with wrap-around.
|
||||||
|
|
||||||
|
Tested on macOS 14. Verified: Tab-cycling through org-mode links,
|
||||||
|
*Completions* candidates, widget buttons, customize buffers.
|
||||||
|
---
|
||||||
|
src/nsterm.m | 286 +++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
1 file changed, 286 insertions(+)
|
||||||
|
|
||||||
|
diff --git a/src/nsterm.m b/src/nsterm.m
|
||||||
|
index f1a1b42..91d0241 100644
|
||||||
|
--- a/src/nsterm.m
|
||||||
|
+++ b/src/nsterm.m
|
||||||
|
@@ -9169,6 +9169,292 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
+
|
||||||
|
+
|
||||||
|
+/* ===================================================================
|
||||||
|
+ EmacsAccessibilityInteractiveSpan — helpers and implementation
|
||||||
|
+ =================================================================== */
|
||||||
|
+
|
||||||
|
+/* Scan visible range of window W for interactive spans.
|
||||||
|
+ Returns NSArray<EmacsAccessibilityInteractiveSpan *>.
|
||||||
|
+
|
||||||
|
+ Priority when properties overlap:
|
||||||
|
+ widget > button > follow-link > org-link >
|
||||||
|
+ completion-candidate > keymap-overlay. */
|
||||||
|
+static NSArray *
|
||||||
|
+ns_ax_scan_interactive_spans (struct window *w,
|
||||||
|
+ EmacsAccessibilityBuffer *parent_buf)
|
||||||
|
+{
|
||||||
|
+ if (!w)
|
||||||
|
+ return @[];
|
||||||
|
+
|
||||||
|
+ Lisp_Object buf_obj = ns_ax_window_buffer_object (w);
|
||||||
|
+ if (NILP (buf_obj))
|
||||||
|
+ return @[];
|
||||||
|
+
|
||||||
|
+ struct buffer *b = XBUFFER (buf_obj);
|
||||||
|
+ ptrdiff_t vis_start = marker_position (w->start);
|
||||||
|
+ ptrdiff_t vis_end = ns_ax_window_end_charpos (w, b);
|
||||||
|
+
|
||||||
|
+ if (vis_start < BUF_BEGV (b)) vis_start = BUF_BEGV (b);
|
||||||
|
+ if (vis_end > BUF_ZV (b)) vis_end = BUF_ZV (b);
|
||||||
|
+ if (vis_start >= vis_end)
|
||||||
|
+ return @[];
|
||||||
|
+
|
||||||
|
+ /* Symbols are interned once at startup via DEFSYM in syms_of_nsterm;
|
||||||
|
+ reference them directly here (GC-safe, no repeated obarray lookup). */
|
||||||
|
+
|
||||||
|
+ BOOL is_completion_buf
|
||||||
|
+ = EQ (BVAR (b, major_mode), Qns_ax_completion_list_mode);
|
||||||
|
+
|
||||||
|
+ NSMutableArray *spans = [NSMutableArray array];
|
||||||
|
+ ptrdiff_t pos = vis_start;
|
||||||
|
+
|
||||||
|
+ while (pos < vis_end)
|
||||||
|
+ {
|
||||||
|
+ Lisp_Object plist = Ftext_properties_at (make_fixnum (pos), buf_obj);
|
||||||
|
+ EmacsAXSpanType span_type = EmacsAXSpanTypeNone;
|
||||||
|
+ Lisp_Object limit_prop = Qnil;
|
||||||
|
+
|
||||||
|
+ if (!NILP (Fplist_get (plist, Qns_ax_widget, Qnil)))
|
||||||
|
+ {
|
||||||
|
+ span_type = EmacsAXSpanTypeWidget;
|
||||||
|
+ limit_prop = Qns_ax_widget;
|
||||||
|
+ }
|
||||||
|
+ else if (!NILP (Fplist_get (plist, Qns_ax_button, Qnil)))
|
||||||
|
+ {
|
||||||
|
+ span_type = EmacsAXSpanTypeButton;
|
||||||
|
+ limit_prop = Qns_ax_button;
|
||||||
|
+ }
|
||||||
|
+ else if (!NILP (Fplist_get (plist, Qns_ax_follow_link, Qnil)))
|
||||||
|
+ {
|
||||||
|
+ span_type = EmacsAXSpanTypeLink;
|
||||||
|
+ limit_prop = Qns_ax_follow_link;
|
||||||
|
+ }
|
||||||
|
+ else if (!NILP (Fplist_get (plist, Qns_ax_org_link, Qnil)))
|
||||||
|
+ {
|
||||||
|
+ span_type = EmacsAXSpanTypeLink;
|
||||||
|
+ limit_prop = Qns_ax_org_link;
|
||||||
|
+ }
|
||||||
|
+ else if (is_completion_buf
|
||||||
|
+ && !NILP (Fplist_get (plist, Qmouse_face, Qnil)))
|
||||||
|
+ {
|
||||||
|
+ /* For completions, use completion--string as boundary so we
|
||||||
|
+ don't accidentally merge two column-adjacent candidates
|
||||||
|
+ whose mouse-face regions may share padding whitespace.
|
||||||
|
+ Fall back to mouse-face if completion--string is absent. */
|
||||||
|
+ Lisp_Object cs_sym = Qns_ax_completion__string;
|
||||||
|
+ Lisp_Object cs_val = ns_ax_text_prop_at (pos, cs_sym, buf_obj);
|
||||||
|
+ span_type = EmacsAXSpanTypeCompletionItem;
|
||||||
|
+ limit_prop = NILP (cs_val) ? Qmouse_face : cs_sym;
|
||||||
|
+ }
|
||||||
|
+ else
|
||||||
|
+ {
|
||||||
|
+ /* Check overlays for keymap. */
|
||||||
|
+ Lisp_Object ovs
|
||||||
|
+ = Foverlays_in (make_fixnum (pos), make_fixnum (pos + 1));
|
||||||
|
+ while (CONSP (ovs))
|
||||||
|
+ {
|
||||||
|
+ if (!NILP (Foverlay_get (XCAR (ovs), Qkeymap)))
|
||||||
|
+ {
|
||||||
|
+ span_type = EmacsAXSpanTypeButton;
|
||||||
|
+ limit_prop = Qkeymap;
|
||||||
|
+ break;
|
||||||
|
+ }
|
||||||
|
+ ovs = XCDR (ovs);
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ if (span_type == EmacsAXSpanTypeNone)
|
||||||
|
+ {
|
||||||
|
+ /* Skip to the next position where any interactive property
|
||||||
|
+ changes. Try each scannable property in turn and take
|
||||||
|
+ the nearest change point â O(properties) per gap rather
|
||||||
|
+ than O(chars). Fall back to pos+1 as safety net. */
|
||||||
|
+ ptrdiff_t next_interesting = vis_end;
|
||||||
|
+ Lisp_Object skip_props[5]
|
||||||
|
+ = { Qns_ax_widget, Qns_ax_button, Qns_ax_follow_link,
|
||||||
|
+ Qns_ax_org_link, Qmouse_face };
|
||||||
|
+ for (int sp = 0; sp < 5; sp++)
|
||||||
|
+ {
|
||||||
|
+ ptrdiff_t np
|
||||||
|
+ = ns_ax_next_prop_change (pos, skip_props[sp],
|
||||||
|
+ buf_obj, vis_end);
|
||||||
|
+ if (np > pos && np < next_interesting)
|
||||||
|
+ next_interesting = np;
|
||||||
|
+ }
|
||||||
|
+ /* Also check overlay keymap changes. */
|
||||||
|
+ Lisp_Object np_ov
|
||||||
|
+ = Fnext_single_char_property_change (make_fixnum (pos),
|
||||||
|
+ Qkeymap, buf_obj,
|
||||||
|
+ make_fixnum (vis_end));
|
||||||
|
+ if (FIXNUMP (np_ov))
|
||||||
|
+ {
|
||||||
|
+ ptrdiff_t npv = XFIXNUM (np_ov);
|
||||||
|
+ if (npv > pos && npv < next_interesting)
|
||||||
|
+ next_interesting = npv;
|
||||||
|
+ }
|
||||||
|
+ pos = (next_interesting > pos) ? next_interesting : pos + 1;
|
||||||
|
+ continue;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ ptrdiff_t span_end = !NILP (limit_prop)
|
||||||
|
+ ? ns_ax_next_prop_change (pos, limit_prop, buf_obj, vis_end)
|
||||||
|
+ : pos + 1;
|
||||||
|
+
|
||||||
|
+ if (span_end > vis_end) span_end = vis_end;
|
||||||
|
+ if (span_end <= pos) span_end = pos + 1;
|
||||||
|
+
|
||||||
|
+ EmacsAccessibilityInteractiveSpan *span
|
||||||
|
+ = [[EmacsAccessibilityInteractiveSpan alloc] init];
|
||||||
|
+ span.charposStart = pos;
|
||||||
|
+ span.charposEnd = span_end;
|
||||||
|
+ span.spanType = span_type;
|
||||||
|
+ span.parentBuffer = parent_buf;
|
||||||
|
+ span.emacsView = parent_buf.emacsView;
|
||||||
|
+ span.lispWindow = parent_buf.lispWindow;
|
||||||
|
+ span.spanLabel = ns_ax_get_span_label (pos, span_end, buf_obj);
|
||||||
|
+
|
||||||
|
+ [spans addObject: span];
|
||||||
|
+ [span release];
|
||||||
|
+
|
||||||
|
+ pos = span_end;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ return [[spans copy] autorelease];
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+@implementation EmacsAccessibilityInteractiveSpan
|
||||||
|
+@synthesize spanLabel;
|
||||||
|
+@synthesize spanValue;
|
||||||
|
+
|
||||||
|
+- (void)dealloc
|
||||||
|
+{
|
||||||
|
+ [spanLabel release];
|
||||||
|
+ [spanValue release];
|
||||||
|
+ [super dealloc];
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+- (BOOL) isAccessibilityElement { return YES; }
|
||||||
|
+
|
||||||
|
+- (NSAccessibilityRole) accessibilityRole
|
||||||
|
+{
|
||||||
|
+ switch (self.spanType)
|
||||||
|
+ {
|
||||||
|
+ case EmacsAXSpanTypeLink: return NSAccessibilityLinkRole;
|
||||||
|
+ default: return NSAccessibilityButtonRole;
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+- (NSString *) accessibilityLabel { return self.spanLabel ?: @""; }
|
||||||
|
+- (NSString *) accessibilityValue { return self.spanValue; }
|
||||||
|
+
|
||||||
|
+- (NSRect) accessibilityFrame
|
||||||
|
+{
|
||||||
|
+ EmacsAccessibilityBuffer *pb = self.parentBuffer;
|
||||||
|
+ if (!pb || ![self validWindow])
|
||||||
|
+ return NSZeroRect;
|
||||||
|
+ NSUInteger ax_s = [pb accessibilityIndexForCharpos: self.charposStart];
|
||||||
|
+ NSUInteger ax_e = [pb accessibilityIndexForCharpos: self.charposEnd];
|
||||||
|
+ if (ax_e < ax_s) ax_e = ax_s;
|
||||||
|
+ return [pb accessibilityFrameForRange: NSMakeRange (ax_s, ax_e - ax_s)];
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+- (BOOL) isAccessibilityFocused
|
||||||
|
+{
|
||||||
|
+ /* Read the cached point stored by EmacsAccessibilityBuffer on the main
|
||||||
|
+ thread — safe to read from any thread (plain ptrdiff_t, no Lisp calls). */
|
||||||
|
+ EmacsAccessibilityBuffer *pb = self.parentBuffer;
|
||||||
|
+ if (!pb)
|
||||||
|
+ return NO;
|
||||||
|
+ ptrdiff_t pt = pb.cachedPoint;
|
||||||
|
+ return pt >= self.charposStart && pt < self.charposEnd;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+- (void) setAccessibilityFocused: (BOOL) focused
|
||||||
|
+{
|
||||||
|
+ if (!focused)
|
||||||
|
+ return;
|
||||||
|
+ ptrdiff_t target = self.charposStart;
|
||||||
|
+ Lisp_Object lwin = self.lispWindow;
|
||||||
|
+ dispatch_async (dispatch_get_main_queue (), ^{
|
||||||
|
+ /* lwin is a Lisp_Object captured by value. This is GC-safe
|
||||||
|
+ because Lisp_Objects are tagged integers/pointers that
|
||||||
|
+ remain valid across GC — GC does not relocate objects in
|
||||||
|
+ Emacs. The WINDOW_LIVE_P check below guards against the
|
||||||
|
+ window being deleted between capture and execution. */
|
||||||
|
+ if (!WINDOWP (lwin) || NILP (Fwindow_live_p (lwin)))
|
||||||
|
+ return;
|
||||||
|
+ /* Use specpdl unwind protection so that block_input is always
|
||||||
|
+ matched by unblock_input, even if Fselect_window signals. */
|
||||||
|
+ specpdl_ref count = SPECPDL_INDEX ();
|
||||||
|
+ record_unwind_protect_void (unblock_input);
|
||||||
|
+ block_input ();
|
||||||
|
+ record_unwind_current_buffer ();
|
||||||
|
+ Fselect_window (lwin, Qnil);
|
||||||
|
+ struct window *w = XWINDOW (lwin);
|
||||||
|
+ struct buffer *b = XBUFFER (w->contents);
|
||||||
|
+ if (b != current_buffer)
|
||||||
|
+ set_buffer_internal_1 (b);
|
||||||
|
+ ptrdiff_t pos = target;
|
||||||
|
+ if (pos < BUF_BEGV (b)) pos = BUF_BEGV (b);
|
||||||
|
+ if (pos > BUF_ZV (b)) pos = BUF_ZV (b);
|
||||||
|
+ SET_PT_BOTH (pos, CHAR_TO_BYTE (pos));
|
||||||
|
+ unbind_to (count, Qnil);
|
||||||
|
+ });
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+@end
|
||||||
|
+
|
||||||
|
+/* EmacsAccessibilityBuffer — InteractiveSpans category.
|
||||||
|
+ Methods are kept here (same .m file) so they access the ivars
|
||||||
|
+ declared in the @interface ivar block. */
|
||||||
|
+@implementation EmacsAccessibilityBuffer (InteractiveSpans)
|
||||||
|
+
|
||||||
|
+- (void) invalidateInteractiveSpans
|
||||||
|
+{
|
||||||
|
+ interactiveSpansDirty = YES;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+- (NSArray *) accessibilityChildrenInNavigationOrder
|
||||||
|
+{
|
||||||
|
+ if (!interactiveSpansDirty && cachedInteractiveSpans != nil)
|
||||||
|
+ return cachedInteractiveSpans;
|
||||||
|
+
|
||||||
|
+ if (![NSThread isMainThread])
|
||||||
|
+ {
|
||||||
|
+ __block NSArray *result;
|
||||||
|
+ dispatch_sync (dispatch_get_main_queue (), ^{
|
||||||
|
+ result = [self accessibilityChildrenInNavigationOrder];
|
||||||
|
+ });
|
||||||
|
+ return result;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ struct window *w = [self validWindow];
|
||||||
|
+ if (!w)
|
||||||
|
+ return cachedInteractiveSpans ? cachedInteractiveSpans : @[];
|
||||||
|
+
|
||||||
|
+ /* Validate buffer before scanning. The Lisp calls inside
|
||||||
|
+ ns_ax_scan_interactive_spans (Ftext_properties_at, Fplist_get,
|
||||||
|
+ Fnext_single_property_change) do not signal on valid buffers
|
||||||
|
+ with valid positions. Verify those preconditions here so we
|
||||||
|
+ never enter the scan with invalid state, which could longjmp
|
||||||
|
+ out of a dispatch_sync block and deadlock the AX thread. */
|
||||||
|
+ if (!BUFFERP (w->contents) || !XBUFFER (w->contents))
|
||||||
|
+ return cachedInteractiveSpans ? cachedInteractiveSpans : @[];
|
||||||
|
+
|
||||||
|
+ NSArray *spans = ns_ax_scan_interactive_spans (w, self);
|
||||||
|
+
|
||||||
|
+ if (!cachedInteractiveSpans)
|
||||||
|
+ cachedInteractiveSpans = [[NSMutableArray alloc] init];
|
||||||
|
+ [cachedInteractiveSpans setArray: spans];
|
||||||
|
+ interactiveSpansDirty = NO;
|
||||||
|
+
|
||||||
|
+ return cachedInteractiveSpans;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+@end
|
||||||
|
+
|
||||||
|
#endif /* NS_IMPL_COCOA */
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
2.43.0
|
||||||
|
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
From 3bbe8ba29725a4708595befa6b73e5873a2aab43 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Martin Sukany <martin@sukany.cz>
|
||||||
|
Date: Sat, 28 Feb 2026 12:58:11 +0100
|
||||||
|
Subject: [PATCH 5/8] ns: integrate accessibility with EmacsView and redisplay
|
||||||
|
|
||||||
|
Wire the accessibility infrastructure into EmacsView and the
|
||||||
|
redisplay cycle. After this patch, VoiceOver and Zoom are active.
|
||||||
|
|
||||||
|
* src/nsterm.m (ns_update_end): Call [view postAccessibilityUpdates].
|
||||||
|
(ns_draw_phys_cursor): Store cursor rect; call UAZoomChangeFocus.
|
||||||
|
(EmacsView dealloc): Release accessibilityElements.
|
||||||
|
(EmacsView windowDidBecomeKey): Post accessibility focus notification.
|
||||||
|
(ns_ax_collect_windows): New function.
|
||||||
|
(EmacsView rebuildAccessibilityTree, invalidateAccessibilityTree)
|
||||||
|
(accessibilityChildren, accessibilityFocusedUIElement)
|
||||||
|
(postAccessibilityUpdates, accessibilityBoundsForRange:)
|
||||||
|
(accessibilityParameterizedAttributeNames)
|
||||||
|
(accessibilityAttributeValue:forParameter:): New methods.
|
||||||
|
* etc/NEWS: Document VoiceOver accessibility support.
|
||||||
|
|
||||||
|
Tested on macOS 14 with VoiceOver and Zoom. End-to-end: buffer
|
||||||
|
navigation, cursor tracking, window switching, completions, evil-mode
|
||||||
|
block cursor, org-mode folded headings, indirect buffers.
|
||||||
|
|
||||||
|
Known limitations documented in patch 6 Texinfo node.
|
||||||
|
---
|
||||||
|
etc/NEWS | 13 ++
|
||||||
|
src/nsterm.m | 398 ++++++++++++++++++++++++++++++++++++++++++++++++++-
|
||||||
|
2 files changed, 408 insertions(+), 3 deletions(-)
|
||||||
|
|
||||||
|
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.m b/src/nsterm.m
|
||||||
|
index 91d0241..125e52c 100644
|
||||||
|
--- a/src/nsterm.m
|
||||||
|
+++ b/src/nsterm.m
|
||||||
|
@@ -1105,6 +1105,11 @@ ns_update_end (struct frame *f)
|
||||||
|
|
||||||
|
unblock_input ();
|
||||||
|
ns_updating_frame = NULL;
|
||||||
|
+
|
||||||
|
+#ifdef NS_IMPL_COCOA
|
||||||
|
+ /* Post accessibility notifications after each redisplay cycle. */
|
||||||
|
+ [view postAccessibilityUpdates];
|
||||||
|
+#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
static void
|
||||||
|
@@ -3233,6 +3238,43 @@ ns_draw_window_cursor (struct window *w, struct glyph_row *glyph_row,
|
||||||
|
/* Prevent the cursor from being drawn outside the text area. */
|
||||||
|
r = NSIntersectionRect (r, ns_row_rect (w, glyph_row, TEXT_AREA));
|
||||||
|
|
||||||
|
+#ifdef NS_IMPL_COCOA
|
||||||
|
+ /* Accessibility: store cursor rect for Zoom and bounds queries.
|
||||||
|
+ Skipped when ns-accessibility-enabled is nil to avoid overhead.
|
||||||
|
+ VoiceOver notifications are handled solely by
|
||||||
|
+ postAccessibilityUpdates (called from ns_update_end)
|
||||||
|
+ to avoid duplicate notifications and mid-redisplay fragility. */
|
||||||
|
+ {
|
||||||
|
+ EmacsView *view = FRAME_NS_VIEW (f);
|
||||||
|
+ if (view && on_p && active_p && ns_accessibility_enabled)
|
||||||
|
+ {
|
||||||
|
+ view->lastAccessibilityCursorRect = r;
|
||||||
|
+
|
||||||
|
+ /* Tell macOS Zoom where the cursor is. UAZoomChangeFocus()
|
||||||
|
+ expects top-left origin (CG coordinate space).
|
||||||
|
+ These APIs are available since macOS 10.4 (Universal Access
|
||||||
|
+ framework, linked via ApplicationServices umbrella). */
|
||||||
|
+#if defined (MAC_OS_X_VERSION_MIN_REQUIRED) \
|
||||||
|
+ && MAC_OS_X_VERSION_MIN_REQUIRED >= 101000
|
||||||
|
+ if (UAZoomEnabled ())
|
||||||
|
+ {
|
||||||
|
+ NSRect windowRect = [view convertRect:r toView:nil];
|
||||||
|
+ NSRect screenRect = [[view window] convertRectToScreen:windowRect];
|
||||||
|
+ CGRect cgRect = NSRectToCGRect (screenRect);
|
||||||
|
+
|
||||||
|
+ CGFloat primaryH
|
||||||
|
+ = [[[NSScreen screens] firstObject] frame].size.height;
|
||||||
|
+ cgRect.origin.y
|
||||||
|
+ = primaryH - cgRect.origin.y - cgRect.size.height;
|
||||||
|
+
|
||||||
|
+ UAZoomChangeFocus (&cgRect, &cgRect,
|
||||||
|
+ kUAZoomFocusTypeInsertionPoint);
|
||||||
|
+ }
|
||||||
|
+#endif /* MAC_OS_X_VERSION_MIN_REQUIRED >= 101000 */
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+#endif
|
||||||
|
+
|
||||||
|
ns_focus (f, NULL, 0);
|
||||||
|
|
||||||
|
NSGraphicsContext *ctx = [NSGraphicsContext currentContext];
|
||||||
|
@@ -7531,7 +7573,6 @@ ns_ax_post_notification_with_info (id element,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-
|
||||||
|
static BOOL
|
||||||
|
ns_ax_find_completion_overlay_range (struct buffer *b, ptrdiff_t point,
|
||||||
|
ptrdiff_t *out_start,
|
||||||
|
@@ -8625,7 +8666,6 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
|
||||||
|
@end
|
||||||
|
|
||||||
|
|
||||||
|
-
|
||||||
|
/* ===================================================================
|
||||||
|
EmacsAccessibilityBuffer (Notifications) — AX event dispatch
|
||||||
|
|
||||||
|
@@ -9170,7 +9210,6 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
|
||||||
|
@end
|
||||||
|
|
||||||
|
|
||||||
|
-
|
||||||
|
/* ===================================================================
|
||||||
|
EmacsAccessibilityInteractiveSpan — helpers and implementation
|
||||||
|
=================================================================== */
|
||||||
|
@@ -9500,6 +9539,7 @@ ns_ax_scan_interactive_spans (struct window *w,
|
||||||
|
[layer release];
|
||||||
|
#endif
|
||||||
|
|
||||||
|
+ [accessibilityElements release];
|
||||||
|
[[self menu] release];
|
||||||
|
[super dealloc];
|
||||||
|
}
|
||||||
|
@@ -10848,6 +10888,32 @@ ns_in_echo_area (void)
|
||||||
|
XSETFRAME (event.frame_or_window, emacsframe);
|
||||||
|
kbd_buffer_store_event (&event);
|
||||||
|
ns_send_appdefined (-1); // Kick main loop
|
||||||
|
+
|
||||||
|
+#ifdef NS_IMPL_COCOA
|
||||||
|
+ /* Notify VoiceOver that the focused accessibility element changed.
|
||||||
|
+ Post on the focused virtual element so VoiceOver starts tracking it.
|
||||||
|
+ This is critical for initial focus and app-switch scenarios. */
|
||||||
|
+ if (ns_accessibility_enabled)
|
||||||
|
+ {
|
||||||
|
+ id focused = [self accessibilityFocusedUIElement];
|
||||||
|
+ if (focused
|
||||||
|
+ && [focused isKindOfClass:[EmacsAccessibilityBuffer class]])
|
||||||
|
+ {
|
||||||
|
+ ns_ax_post_notification (focused,
|
||||||
|
+ NSAccessibilityFocusedUIElementChangedNotification);
|
||||||
|
+ NSDictionary *info = @{
|
||||||
|
+ @"AXTextStateChangeType":
|
||||||
|
+ @(ns_ax_text_state_change_selection_move),
|
||||||
|
+ @"AXTextChangeElement": focused
|
||||||
|
+ };
|
||||||
|
+ ns_ax_post_notification_with_info (focused,
|
||||||
|
+ NSAccessibilitySelectedTextChangedNotification, info);
|
||||||
|
+ }
|
||||||
|
+ else if (focused)
|
||||||
|
+ ns_ax_post_notification (focused,
|
||||||
|
+ NSAccessibilityFocusedUIElementChangedNotification);
|
||||||
|
+ }
|
||||||
|
+#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@@ -12085,6 +12151,332 @@ ns_in_echo_area (void)
|
||||||
|
return fs_state;
|
||||||
|
}
|
||||||
|
|
||||||
|
+#ifdef NS_IMPL_COCOA
|
||||||
|
+
|
||||||
|
+/* ---- Accessibility: walk the Emacs window tree ---- */
|
||||||
|
+
|
||||||
|
+static void
|
||||||
|
+ns_ax_collect_windows (Lisp_Object window, EmacsView *view,
|
||||||
|
+ NSMutableArray *elements,
|
||||||
|
+ NSDictionary *existing)
|
||||||
|
+{
|
||||||
|
+ if (NILP (window))
|
||||||
|
+ return;
|
||||||
|
+
|
||||||
|
+ struct window *w = XWINDOW (window);
|
||||||
|
+
|
||||||
|
+ if (WINDOW_LEAF_P (w))
|
||||||
|
+ {
|
||||||
|
+ /* Buffer element — reuse existing if available. */
|
||||||
|
+ EmacsAccessibilityBuffer *elem
|
||||||
|
+ = [existing objectForKey:[NSValue valueWithPointer:w]];
|
||||||
|
+ if (!elem)
|
||||||
|
+ {
|
||||||
|
+ elem = [[EmacsAccessibilityBuffer alloc] init];
|
||||||
|
+ elem.emacsView = view;
|
||||||
|
+
|
||||||
|
+ /* Initialize cached state to -1 to force first notification. */
|
||||||
|
+ elem.cachedModiff = -1;
|
||||||
|
+ elem.cachedPoint = -1;
|
||||||
|
+ elem.cachedMarkActive = NO;
|
||||||
|
+ }
|
||||||
|
+ else
|
||||||
|
+ {
|
||||||
|
+ [elem retain];
|
||||||
|
+ }
|
||||||
|
+ elem.lispWindow = window;
|
||||||
|
+ [elements addObject:elem];
|
||||||
|
+ [elem release];
|
||||||
|
+
|
||||||
|
+ /* Mode line element (skip for minibuffer). */
|
||||||
|
+ if (!MINI_WINDOW_P (w))
|
||||||
|
+ {
|
||||||
|
+ EmacsAccessibilityModeLine *ml
|
||||||
|
+ = [[EmacsAccessibilityModeLine alloc] init];
|
||||||
|
+ ml.emacsView = view;
|
||||||
|
+ ml.lispWindow = window;
|
||||||
|
+ [elements addObject:ml];
|
||||||
|
+ [ml release];
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ else
|
||||||
|
+ {
|
||||||
|
+ /* Internal (combination) window — recurse into children. */
|
||||||
|
+ Lisp_Object child = w->contents;
|
||||||
|
+ while (!NILP (child))
|
||||||
|
+ {
|
||||||
|
+ ns_ax_collect_windows (child, view, elements, existing);
|
||||||
|
+ child = XWINDOW (child)->next;
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+- (void)rebuildAccessibilityTree
|
||||||
|
+{
|
||||||
|
+ NSTRACE ("[EmacsView rebuildAccessibilityTree]");
|
||||||
|
+ if (!emacsframe)
|
||||||
|
+ return;
|
||||||
|
+
|
||||||
|
+ /* Build map of existing elements by window pointer for reuse. */
|
||||||
|
+ NSMutableDictionary *existing = [NSMutableDictionary dictionary];
|
||||||
|
+ if (accessibilityElements)
|
||||||
|
+ {
|
||||||
|
+ for (EmacsAccessibilityElement *elem in accessibilityElements)
|
||||||
|
+ {
|
||||||
|
+ if ([elem isKindOfClass:[EmacsAccessibilityBuffer class]]
|
||||||
|
+ && !NILP (elem.lispWindow))
|
||||||
|
+ [existing setObject:elem
|
||||||
|
+ forKey:[NSValue valueWithPointer:
|
||||||
|
+ XWINDOW (elem.lispWindow)]];
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ NSMutableArray *newElements = [NSMutableArray arrayWithCapacity:8];
|
||||||
|
+
|
||||||
|
+ /* Collect from main window tree. */
|
||||||
|
+ Lisp_Object root = FRAME_ROOT_WINDOW (emacsframe);
|
||||||
|
+ ns_ax_collect_windows (root, self, newElements, existing);
|
||||||
|
+
|
||||||
|
+ /* Include minibuffer. */
|
||||||
|
+ Lisp_Object mini = emacsframe->minibuffer_window;
|
||||||
|
+ if (!NILP (mini))
|
||||||
|
+ ns_ax_collect_windows (mini, self, newElements, existing);
|
||||||
|
+
|
||||||
|
+ [accessibilityElements release];
|
||||||
|
+ accessibilityElements = [newElements retain];
|
||||||
|
+ accessibilityTreeValid = YES;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+- (void)invalidateAccessibilityTree
|
||||||
|
+{
|
||||||
|
+ accessibilityTreeValid = NO;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+- (NSAccessibilityRole)accessibilityRole
|
||||||
|
+{
|
||||||
|
+ return NSAccessibilityGroupRole;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+- (NSString *)accessibilityLabel
|
||||||
|
+{
|
||||||
|
+ return @"Emacs";
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+- (BOOL)isAccessibilityElement
|
||||||
|
+{
|
||||||
|
+ return YES;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+- (NSArray *)accessibilityChildren
|
||||||
|
+{
|
||||||
|
+ if (!accessibilityElements || !accessibilityTreeValid)
|
||||||
|
+ [self rebuildAccessibilityTree];
|
||||||
|
+ return accessibilityElements;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+- (id)accessibilityFocusedUIElement
|
||||||
|
+{
|
||||||
|
+ if (!emacsframe)
|
||||||
|
+ return self;
|
||||||
|
+
|
||||||
|
+ if (!accessibilityElements || !accessibilityTreeValid)
|
||||||
|
+ [self rebuildAccessibilityTree];
|
||||||
|
+
|
||||||
|
+ for (EmacsAccessibilityElement *elem in accessibilityElements)
|
||||||
|
+ {
|
||||||
|
+ if ([elem isKindOfClass:[EmacsAccessibilityBuffer class]]
|
||||||
|
+ && EQ (elem.lispWindow, emacsframe->selected_window))
|
||||||
|
+ return elem;
|
||||||
|
+ }
|
||||||
|
+ return self;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+/* Called from ns_update_end to post AX notifications.
|
||||||
|
+
|
||||||
|
+ Important: post notifications BEFORE rebuilding the tree.
|
||||||
|
+ The existing elements carry cached state (modiff, point) from the
|
||||||
|
+ previous redisplay cycle. Rebuilding first would create fresh
|
||||||
|
+ elements with current values, making change detection impossible. */
|
||||||
|
+- (void)postAccessibilityUpdates
|
||||||
|
+{
|
||||||
|
+ NSTRACE ("[EmacsView postAccessibilityUpdates]");
|
||||||
|
+ eassert ([NSThread isMainThread]);
|
||||||
|
+
|
||||||
|
+ if (!emacsframe || !ns_accessibility_enabled)
|
||||||
|
+ return;
|
||||||
|
+
|
||||||
|
+ /* Re-entrance guard: VoiceOver callbacks during notification posting
|
||||||
|
+ can trigger redisplay, which calls ns_update_end, which calls us
|
||||||
|
+ again. Prevent infinite recursion. */
|
||||||
|
+ if (accessibilityUpdating)
|
||||||
|
+ return;
|
||||||
|
+ accessibilityUpdating = YES;
|
||||||
|
+
|
||||||
|
+ /* Detect window tree change (split, delete, new buffer). Compare
|
||||||
|
+ FRAME_ROOT_WINDOW — if it changed, the tree structure changed. */
|
||||||
|
+ Lisp_Object curRoot = FRAME_ROOT_WINDOW (emacsframe);
|
||||||
|
+ if (!EQ (curRoot, lastRootWindow))
|
||||||
|
+ {
|
||||||
|
+ lastRootWindow = curRoot;
|
||||||
|
+ accessibilityTreeValid = NO;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ /* If tree is stale, rebuild FIRST so we don't iterate freed
|
||||||
|
+ window pointers. Skip notifications for this cycle — the
|
||||||
|
+ freshly-built elements have no previous state to diff against. */
|
||||||
|
+ if (!accessibilityTreeValid)
|
||||||
|
+ {
|
||||||
|
+ [self rebuildAccessibilityTree];
|
||||||
|
+ /* Invalidate span cache — window layout changed. */
|
||||||
|
+ for (EmacsAccessibilityElement *elem in accessibilityElements)
|
||||||
|
+ if ([elem isKindOfClass: [EmacsAccessibilityBuffer class]])
|
||||||
|
+ [(EmacsAccessibilityBuffer *) elem invalidateInteractiveSpans];
|
||||||
|
+ ns_ax_post_notification (self,
|
||||||
|
+ NSAccessibilityLayoutChangedNotification);
|
||||||
|
+
|
||||||
|
+ /* Post focus change so VoiceOver picks up the new tree. */
|
||||||
|
+ id focused = [self accessibilityFocusedUIElement];
|
||||||
|
+ if (focused && focused != self)
|
||||||
|
+ ns_ax_post_notification (focused,
|
||||||
|
+ NSAccessibilityFocusedUIElementChangedNotification);
|
||||||
|
+
|
||||||
|
+ lastSelectedWindow = emacsframe->selected_window;
|
||||||
|
+ accessibilityUpdating = NO;
|
||||||
|
+ return;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ /* Post per-buffer notifications using EXISTING elements that have
|
||||||
|
+ cached state from the previous cycle. Validate each window
|
||||||
|
+ pointer before use. */
|
||||||
|
+ for (EmacsAccessibilityElement *elem in accessibilityElements)
|
||||||
|
+ {
|
||||||
|
+ if ([elem isKindOfClass:[EmacsAccessibilityBuffer class]])
|
||||||
|
+ {
|
||||||
|
+ struct window *w = [elem validWindow];
|
||||||
|
+ if (w && WINDOW_LEAF_P (w)
|
||||||
|
+ && BUFFERP (w->contents) && XBUFFER (w->contents))
|
||||||
|
+ [(EmacsAccessibilityBuffer *) elem
|
||||||
|
+ postAccessibilityNotificationsForFrame:emacsframe];
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ /* Check for window switch (C-x o). */
|
||||||
|
+ Lisp_Object curSel = emacsframe->selected_window;
|
||||||
|
+ BOOL windowSwitched = !EQ (curSel, lastSelectedWindow);
|
||||||
|
+ if (windowSwitched)
|
||||||
|
+ {
|
||||||
|
+ lastSelectedWindow = curSel;
|
||||||
|
+ id focused = [self accessibilityFocusedUIElement];
|
||||||
|
+ if (focused && focused != self
|
||||||
|
+ && [focused isKindOfClass:[EmacsAccessibilityBuffer class]])
|
||||||
|
+ {
|
||||||
|
+ ns_ax_post_notification (focused,
|
||||||
|
+ NSAccessibilityFocusedUIElementChangedNotification);
|
||||||
|
+ NSDictionary *info = @{
|
||||||
|
+ @"AXTextStateChangeType":
|
||||||
|
+ @(ns_ax_text_state_change_selection_move),
|
||||||
|
+ @"AXTextChangeElement": focused
|
||||||
|
+ };
|
||||||
|
+ ns_ax_post_notification_with_info (focused,
|
||||||
|
+ NSAccessibilitySelectedTextChangedNotification, info);
|
||||||
|
+ }
|
||||||
|
+ else if (focused && focused != self)
|
||||||
|
+ ns_ax_post_notification (focused,
|
||||||
|
+ NSAccessibilityFocusedUIElementChangedNotification);
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ accessibilityUpdating = NO;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+/* ---- Cursor position for Zoom (via accessibilityBoundsForRange:) ----
|
||||||
|
+
|
||||||
|
+ accessibilityFrame returns the VIEW's frame (standard behavior).
|
||||||
|
+ The cursor location is exposed through accessibilityBoundsForRange:
|
||||||
|
+ which AT tools query using the selectedTextRange. */
|
||||||
|
+
|
||||||
|
+- (NSRect)accessibilityBoundsForRange:(NSRange)range
|
||||||
|
+{
|
||||||
|
+ /* Delegate to the focused buffer element for accurate per-range
|
||||||
|
+ geometry when possible. Fall back to the cached cursor rect
|
||||||
|
+ (set by ns_draw_phys_cursor) for Zoom and simple AT queries. */
|
||||||
|
+ id focused = [self accessibilityFocusedUIElement];
|
||||||
|
+ if ([focused isKindOfClass:[EmacsAccessibilityBuffer class]])
|
||||||
|
+ {
|
||||||
|
+ NSRect bufRect = [(EmacsAccessibilityBuffer *) focused
|
||||||
|
+ accessibilityFrameForRange:range];
|
||||||
|
+ if (!NSIsEmptyRect (bufRect))
|
||||||
|
+ return bufRect;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ NSRect viewRect = lastAccessibilityCursorRect;
|
||||||
|
+
|
||||||
|
+ if (viewRect.size.width < 1)
|
||||||
|
+ viewRect.size.width = 1;
|
||||||
|
+ if (viewRect.size.height < 1)
|
||||||
|
+ viewRect.size.height = 8;
|
||||||
|
+
|
||||||
|
+ NSWindow *win = [self window];
|
||||||
|
+ if (win == nil)
|
||||||
|
+ return NSZeroRect;
|
||||||
|
+
|
||||||
|
+ NSRect windowRect = [self convertRect:viewRect toView:nil];
|
||||||
|
+ return [win convertRectToScreen:windowRect];
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+/* Modern NSAccessibility protocol entry point. Delegates to
|
||||||
|
+ accessibilityBoundsForRange: which holds the real implementation
|
||||||
|
+ shared with the legacy parameterized-attribute API. */
|
||||||
|
+- (NSRect)accessibilityFrameForRange:(NSRange)range
|
||||||
|
+{
|
||||||
|
+ return [self accessibilityBoundsForRange:range];
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+/* Delegate to the focused virtual buffer element so both the modern
|
||||||
|
+ and legacy APIs return the correct string data. */
|
||||||
|
+- (NSString *)accessibilityStringForRange:(NSRange)range
|
||||||
|
+{
|
||||||
|
+ id focused = [self accessibilityFocusedUIElement];
|
||||||
|
+ if ([focused isKindOfClass:[EmacsAccessibilityBuffer class]])
|
||||||
|
+ return [(EmacsAccessibilityBuffer *) focused
|
||||||
|
+ accessibilityStringForRange:range];
|
||||||
|
+ return @"";
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+/* ---- Legacy parameterized attribute APIs (Zoom uses these) ---- */
|
||||||
|
+
|
||||||
|
+- (NSArray *)accessibilityParameterizedAttributeNames
|
||||||
|
+{
|
||||||
|
+ NSArray *superAttrs = [super accessibilityParameterizedAttributeNames];
|
||||||
|
+ if (superAttrs == nil)
|
||||||
|
+ superAttrs = @[];
|
||||||
|
+ return [superAttrs arrayByAddingObjectsFromArray:
|
||||||
|
+ @[NSAccessibilityBoundsForRangeParameterizedAttribute,
|
||||||
|
+ NSAccessibilityStringForRangeParameterizedAttribute]];
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+- (id)accessibilityAttributeValue:(NSString *)attribute
|
||||||
|
+ forParameter:(id)parameter
|
||||||
|
+{
|
||||||
|
+ if ([attribute isEqualToString:
|
||||||
|
+ NSAccessibilityBoundsForRangeParameterizedAttribute])
|
||||||
|
+ {
|
||||||
|
+ NSRange range = [(NSValue *) parameter rangeValue];
|
||||||
|
+ return [NSValue valueWithRect:
|
||||||
|
+ [self accessibilityBoundsForRange:range]];
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ if ([attribute isEqualToString:
|
||||||
|
+ NSAccessibilityStringForRangeParameterizedAttribute])
|
||||||
|
+ {
|
||||||
|
+ NSRange range = [(NSValue *) parameter rangeValue];
|
||||||
|
+ return [self accessibilityStringForRange:range];
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ return [super accessibilityAttributeValue:attribute forParameter:parameter];
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+#endif /* NS_IMPL_COCOA */
|
||||||
|
+
|
||||||
|
@end /* EmacsView */
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
2.43.0
|
||||||
|
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
From 5ddf6227b581bf292fc187a1ebcaf80d2cd4cf2a Mon Sep 17 00:00:00 2001
|
||||||
|
From: Martin Sukany <martin@sukany.cz>
|
||||||
|
Date: Sat, 28 Feb 2026 12:58:11 +0100
|
||||||
|
Subject: [PATCH 6/8] doc: add VoiceOver accessibility section to macOS
|
||||||
|
appendix
|
||||||
|
|
||||||
|
* doc/emacs/macos.texi (VoiceOver Accessibility): New node. Document
|
||||||
|
screen reader usage, keyboard navigation, completion announcements,
|
||||||
|
Zoom cursor tracking, ns-accessibility-enabled, known limitations.
|
||||||
|
---
|
||||||
|
doc/emacs/macos.texi | 75 ++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
1 file changed, 75 insertions(+)
|
||||||
|
|
||||||
|
diff --git a/doc/emacs/macos.texi b/doc/emacs/macos.texi
|
||||||
|
index 6bd334f..c4dced5 100644
|
||||||
|
--- a/doc/emacs/macos.texi
|
||||||
|
+++ b/doc/emacs/macos.texi
|
||||||
|
@@ -36,6 +36,7 @@ Support}), but we hope to improve it in the future.
|
||||||
|
* Mac / GNUstep Basics:: Basic Emacs usage under GNUstep or macOS.
|
||||||
|
* Mac / GNUstep Customization:: Customizations under GNUstep or macOS.
|
||||||
|
* Mac / GNUstep Events:: How window system events are handled.
|
||||||
|
+* VoiceOver Accessibility:: Screen reader support on macOS.
|
||||||
|
* GNUstep Support:: Details on status of GNUstep support.
|
||||||
|
@end menu
|
||||||
|
|
||||||
|
@@ -272,6 +273,80 @@ and return the result as a string. You can also use the Lisp function
|
||||||
|
services and receive the results back. Note that you may need to
|
||||||
|
restart Emacs to access newly-available services.
|
||||||
|
|
||||||
|
+@node VoiceOver Accessibility
|
||||||
|
+@section VoiceOver Accessibility (macOS)
|
||||||
|
+@cindex VoiceOver
|
||||||
|
+@cindex accessibility (macOS)
|
||||||
|
+@cindex screen reader (macOS)
|
||||||
|
+@cindex Zoom, cursor tracking (macOS)
|
||||||
|
+
|
||||||
|
+ When built with the Cocoa interface on macOS, Emacs exposes buffer
|
||||||
|
+content, cursor position, mode lines, and interactive elements to the
|
||||||
|
+macOS accessibility subsystem. This enables use with VoiceOver,
|
||||||
|
+Apple's built-in screen reader, and with other assistive technology
|
||||||
|
+such as macOS Zoom.
|
||||||
|
+
|
||||||
|
+ Toggle VoiceOver with @kbd{Cmd-F5} (or via System Settings,
|
||||||
|
+Accessibility, VoiceOver). When Emacs is focused, VoiceOver announces
|
||||||
|
+the buffer name and current line. Standard Emacs navigation produces
|
||||||
|
+speech feedback:
|
||||||
|
+
|
||||||
|
+@itemize @bullet
|
||||||
|
+@item
|
||||||
|
+Arrow keys read individual characters (left/right) or full lines
|
||||||
|
+(up/down).
|
||||||
|
+@item
|
||||||
|
+@kbd{M-f} and @kbd{M-b} announce words.
|
||||||
|
+@item
|
||||||
|
+@kbd{C-n} and @kbd{C-p} read the destination line.
|
||||||
|
+@item
|
||||||
|
+Shift-modified movement announces selected or deselected text.
|
||||||
|
+@item
|
||||||
|
+@key{TAB} and @kbd{S-@key{TAB}} navigate interactive elements
|
||||||
|
+(buttons, links, completion candidates) within a buffer.
|
||||||
|
+@end itemize
|
||||||
|
+
|
||||||
|
+ The @file{*Completions*} buffer announces each completion candidate
|
||||||
|
+as you navigate, even while keyboard focus remains in the minibuffer.
|
||||||
|
+
|
||||||
|
+ macOS Zoom (System Settings, Accessibility, Zoom) tracks the Emacs
|
||||||
|
+cursor automatically when set to follow keyboard focus. The cursor
|
||||||
|
+position is communicated via @code{UAZoomChangeFocus} and the
|
||||||
|
+@code{AXBoundsForRange} accessibility attribute.
|
||||||
|
+
|
||||||
|
+@vindex ns-accessibility-enabled
|
||||||
|
+ To disable the accessibility interface entirely (for instance, to
|
||||||
|
+eliminate overhead on systems where assistive technology is not in
|
||||||
|
+use), set @code{ns-accessibility-enabled} to @code{nil}. The default
|
||||||
|
+is @code{t}.
|
||||||
|
+
|
||||||
|
+@subheading Known Limitations
|
||||||
|
+
|
||||||
|
+@itemize @bullet
|
||||||
|
+@item
|
||||||
|
+Accessibility text is capped at 100,000 UTF-16 units per window.
|
||||||
|
+Buffers exceeding this limit are truncated for accessibility purposes;
|
||||||
|
+VoiceOver will announce ``end of text'' at the cap boundary.
|
||||||
|
+@item
|
||||||
|
+Mode-line text extraction handles only character glyphs. Mode lines
|
||||||
|
+using icon fonts (e.g., @code{doom-modeline} with nerd-font icons)
|
||||||
|
+produce incomplete accessibility text.
|
||||||
|
+@item
|
||||||
|
+The accessibility virtual element tree is rebuilt automatically on
|
||||||
|
+window configuration changes (splits, deletions, new buffers).
|
||||||
|
+@item
|
||||||
|
+Right-to-left (bidi) text is exposed correctly as buffer content,
|
||||||
|
+but @code{accessibilityRangeForPosition} hit-testing assumes
|
||||||
|
+left-to-right glyph layout.
|
||||||
|
+@end itemize
|
||||||
|
+
|
||||||
|
+ This support is available only on the Cocoa build; GNUstep has a
|
||||||
|
+different accessibility model and is not yet supported
|
||||||
|
+(@pxref{GNUstep Support}). Evil-mode block cursors are handled
|
||||||
|
+correctly: character navigation announces the character at the cursor
|
||||||
|
+position, not the character before it.
|
||||||
|
+
|
||||||
|
+
|
||||||
|
@node GNUstep Support
|
||||||
|
@section GNUstep Support
|
||||||
|
|
||||||
|
--
|
||||||
|
2.43.0
|
||||||
|
|
||||||
@@ -0,0 +1,660 @@
|
|||||||
|
From 8f619411ec75efbd18e663bb3f2ed6f8c9af60d8 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Martin Sukany <martin@sukany.cz>
|
||||||
|
Date: Sat, 28 Feb 2026 14:46:25 +0100
|
||||||
|
Subject: [PATCH 7/8] ns: announce overlay completion candidates for VoiceOver
|
||||||
|
|
||||||
|
Completion frameworks such as Vertico, Ivy, and Icomplete render
|
||||||
|
candidates via overlay before-string/after-string properties rather
|
||||||
|
than buffer text. Without this patch, VoiceOver cannot read
|
||||||
|
overlay-based completion UIs.
|
||||||
|
|
||||||
|
Identify the selected candidate by scanning overlay strings for a
|
||||||
|
face whose symbol name contains "current", "selected", or
|
||||||
|
"selection" --- this matches vertico-current, icomplete-selected-match,
|
||||||
|
ivy-current-match, company-tooltip-selection, and similar framework
|
||||||
|
faces without hard-coding any specific name.
|
||||||
|
|
||||||
|
Key implementation details:
|
||||||
|
|
||||||
|
- The overlay detection branch runs independently (if, not else-if)
|
||||||
|
of the text-change branch, because Vertico bumps both BUF_MODIFF
|
||||||
|
(via text property changes in vertico--prompt-selection) and
|
||||||
|
BUF_OVERLAY_MODIFF (via overlay-put) in the same command cycle.
|
||||||
|
|
||||||
|
- Use BUF_CHARS_MODIFF to gate ValueChanged notifications, since
|
||||||
|
text property changes bump BUF_MODIFF but not BUF_CHARS_MODIFF.
|
||||||
|
|
||||||
|
- Remove BUF_OVERLAY_MODIFF from ensureTextCache validity checks
|
||||||
|
to prevent a race condition where VoiceOver AX queries silently
|
||||||
|
consume the overlay change before the notification dispatch runs.
|
||||||
|
|
||||||
|
- Announce via AnnouncementRequested to NSApp with High priority.
|
||||||
|
Do not post SelectedTextChanged (that reads the AX text at cursor
|
||||||
|
position, which is the minibuffer input, not the candidate).
|
||||||
|
|
||||||
|
- Zoom tracking: store the selected candidate's rect (at the text
|
||||||
|
area left edge, computed from FRAME_LINE_HEIGHT) in overlayZoomRect.
|
||||||
|
ns_draw_window_cursor checks overlayZoomActive and uses the stored
|
||||||
|
rect instead of the text cursor rect, keeping Zoom focused on the
|
||||||
|
candidate line start. The flag is cleared when the user types
|
||||||
|
(BUF_CHARS_MODIFF changes) or when no candidate is found
|
||||||
|
(minibuffer exit, C-g).
|
||||||
|
|
||||||
|
* src/nsterm.h (EmacsView): Add overlayZoomActive, overlayZoomRect.
|
||||||
|
(EmacsAccessibilityBuffer): Add cachedCharsModiff.
|
||||||
|
* src/nsterm.m (ns_ax_face_is_selected): New predicate. Match
|
||||||
|
"current", "selected", and "selection" in face symbol names.
|
||||||
|
(ns_ax_selected_overlay_text): New function.
|
||||||
|
(ns_draw_window_cursor): Use overlayZoomRect when active.
|
||||||
|
(EmacsAccessibilityBuffer ensureTextCache): Remove overlay_modiff.
|
||||||
|
(EmacsAccessibilityBuffer postAccessibilityNotificationsForFrame:):
|
||||||
|
Independent overlay branch, BUF_CHARS_MODIFF gating, candidate
|
||||||
|
announcement with overlay Zoom rect storage.
|
||||||
|
---
|
||||||
|
src/nsterm.h | 3 +
|
||||||
|
src/nsterm.m | 361 ++++++++++++++++++++++++++++++++++++++++++++++-----
|
||||||
|
2 files changed, 329 insertions(+), 35 deletions(-)
|
||||||
|
|
||||||
|
diff --git a/src/nsterm.h b/src/nsterm.h
|
||||||
|
index 5298386..a007925 100644
|
||||||
|
--- a/src/nsterm.h
|
||||||
|
+++ b/src/nsterm.h
|
||||||
|
@@ -509,6 +509,7 @@ typedef struct ns_ax_visible_run
|
||||||
|
@property (nonatomic, assign) ptrdiff_t cachedOverlayModiff;
|
||||||
|
@property (nonatomic, assign) ptrdiff_t cachedTextStart;
|
||||||
|
@property (nonatomic, assign) ptrdiff_t cachedModiff;
|
||||||
|
+@property (nonatomic, assign) ptrdiff_t cachedCharsModiff;
|
||||||
|
@property (nonatomic, assign) ptrdiff_t cachedPoint;
|
||||||
|
@property (nonatomic, assign) BOOL cachedMarkActive;
|
||||||
|
@property (nonatomic, copy) NSString *cachedCompletionAnnouncement;
|
||||||
|
@@ -595,6 +596,8 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType)
|
||||||
|
BOOL accessibilityUpdating;
|
||||||
|
@public /* Accessed by ns_draw_phys_cursor (C function). */
|
||||||
|
NSRect lastAccessibilityCursorRect;
|
||||||
|
+ BOOL overlayZoomActive;
|
||||||
|
+ NSRect overlayZoomRect;
|
||||||
|
#endif
|
||||||
|
BOOL font_panel_active;
|
||||||
|
NSFont *font_panel_result;
|
||||||
|
diff --git a/src/nsterm.m b/src/nsterm.m
|
||||||
|
index 125e52c..ebd52c6 100644
|
||||||
|
--- a/src/nsterm.m
|
||||||
|
+++ b/src/nsterm.m
|
||||||
|
@@ -3258,7 +3258,12 @@ ns_draw_window_cursor (struct window *w, struct glyph_row *glyph_row,
|
||||||
|
&& MAC_OS_X_VERSION_MIN_REQUIRED >= 101000
|
||||||
|
if (UAZoomEnabled ())
|
||||||
|
{
|
||||||
|
- NSRect windowRect = [view convertRect:r toView:nil];
|
||||||
|
+ /* When overlay completion is active (e.g. Vertico),
|
||||||
|
+ focus Zoom on the selected candidate row instead
|
||||||
|
+ of the text cursor. */
|
||||||
|
+ NSRect zoomSrc = view->overlayZoomActive
|
||||||
|
+ ? view->overlayZoomRect : r;
|
||||||
|
+ NSRect windowRect = [view convertRect:zoomSrc toView:nil];
|
||||||
|
NSRect screenRect = [[view window] convertRectToScreen:windowRect];
|
||||||
|
CGRect cgRect = NSRectToCGRect (screenRect);
|
||||||
|
|
||||||
|
@@ -7159,11 +7164,156 @@ ns_ax_selected_child_frame_text (struct buffer *b, Lisp_Object buf_obj,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
+/* Return true if FACE is or contains a face symbol whose name
|
||||||
|
+ includes "current" or "selected", indicating a highlighted
|
||||||
|
+ completion candidate. Works for vertico-current,
|
||||||
|
+ icomplete-selected-match, ivy-current-match, etc. */
|
||||||
|
+static bool
|
||||||
|
+ns_ax_face_is_selected (Lisp_Object face)
|
||||||
|
+{
|
||||||
|
+ if (SYMBOLP (face) && !NILP (face))
|
||||||
|
+ {
|
||||||
|
+ const char *name = SSDATA (SYMBOL_NAME (face));
|
||||||
|
+ /* Substring match is intentionally broad --- it catches
|
||||||
|
+ vertico-current, icomplete-selected-match, ivy-current-match,
|
||||||
|
+ company-tooltip-selection, and similar. False positives are
|
||||||
|
+ harmless since this runs only on overlay strings during
|
||||||
|
+ completion. */
|
||||||
|
+ if (strstr (name, "current") || strstr (name, "selected")
|
||||||
|
+ || strstr (name, "selection"))
|
||||||
|
+ return true;
|
||||||
|
+ }
|
||||||
|
+ if (CONSP (face))
|
||||||
|
+ {
|
||||||
|
+ for (Lisp_Object tail = face; CONSP (tail); tail = XCDR (tail))
|
||||||
|
+ if (ns_ax_face_is_selected (XCAR (tail)))
|
||||||
|
+ return true;
|
||||||
|
+ }
|
||||||
|
+ return false;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+/* Extract the currently selected candidate text from overlay display
|
||||||
|
+ strings. Completion frameworks render candidates as overlay
|
||||||
|
+ before-string/after-string and highlight the current candidate
|
||||||
|
+ with a face whose name contains "current" or "selected"
|
||||||
|
+ (e.g. vertico-current, icomplete-selected-match, ivy-current-match).
|
||||||
|
+
|
||||||
|
+ Scan all overlays in the buffer region [BEG, END), find the line
|
||||||
|
+ whose face matches the selection heuristic, and return it (already
|
||||||
|
+ trimmed of surrounding whitespace).
|
||||||
|
+
|
||||||
|
+ Also set *OUT_LINE_INDEX to the 0-based visual line index of the
|
||||||
|
+ selected candidate (for Zoom positioning), counting only non-trivial
|
||||||
|
+ lines. Set to -1 if not found.
|
||||||
|
+
|
||||||
|
+ Returns nil if no selected candidate is found. */
|
||||||
|
+static NSString *
|
||||||
|
+ns_ax_selected_overlay_text (struct buffer *b,
|
||||||
|
+ ptrdiff_t beg, ptrdiff_t end,
|
||||||
|
+ int *out_line_index)
|
||||||
|
+{
|
||||||
|
+ *out_line_index = -1;
|
||||||
|
+
|
||||||
|
+ Lisp_Object ov_list = Foverlays_in (make_fixnum (beg),
|
||||||
|
+ make_fixnum (end));
|
||||||
|
+
|
||||||
|
+ for (Lisp_Object tail = ov_list; CONSP (tail); tail = XCDR (tail))
|
||||||
|
+ {
|
||||||
|
+ Lisp_Object ov = XCAR (tail);
|
||||||
|
+ Lisp_Object strings[2];
|
||||||
|
+ strings[0] = Foverlay_get (ov, intern_c_string ("before-string"));
|
||||||
|
+ strings[1] = Foverlay_get (ov, intern_c_string ("after-string"));
|
||||||
|
+
|
||||||
|
+ for (int s = 0; s < 2; s++)
|
||||||
|
+ {
|
||||||
|
+ if (!STRINGP (strings[s]))
|
||||||
|
+ continue;
|
||||||
|
+
|
||||||
|
+ Lisp_Object str = strings[s];
|
||||||
|
+ ptrdiff_t slen = SCHARS (str);
|
||||||
|
+ if (slen == 0)
|
||||||
|
+ continue;
|
||||||
|
+
|
||||||
|
+ /* Scan for newline positions using SDATA for efficiency.
|
||||||
|
+ The data pointer is used only in this loop, before any
|
||||||
|
+ Lisp calls (Fget_text_property etc.) that could trigger
|
||||||
|
+ GC and relocate string data. */
|
||||||
|
+ const unsigned char *data = SDATA (str);
|
||||||
|
+ ptrdiff_t byte_len = SBYTES (str);
|
||||||
|
+ /* 512 lines is sufficient for any completion UI;
|
||||||
|
+ vertico-count defaults to 10. */
|
||||||
|
+ ptrdiff_t line_starts[512];
|
||||||
|
+ ptrdiff_t line_ends[512];
|
||||||
|
+ int nlines = 0;
|
||||||
|
+ ptrdiff_t char_pos = 0, byte_pos = 0, lstart = 0;
|
||||||
|
+
|
||||||
|
+ while (byte_pos < byte_len && nlines < 512)
|
||||||
|
+ {
|
||||||
|
+ if (data[byte_pos] == '\n')
|
||||||
|
+ {
|
||||||
|
+ if (char_pos > lstart)
|
||||||
|
+ {
|
||||||
|
+ line_starts[nlines] = lstart;
|
||||||
|
+ line_ends[nlines] = char_pos;
|
||||||
|
+ nlines++;
|
||||||
|
+ }
|
||||||
|
+ lstart = char_pos + 1;
|
||||||
|
+ }
|
||||||
|
+ if (STRING_MULTIBYTE (str))
|
||||||
|
+ byte_pos += BYTES_BY_CHAR_HEAD (data[byte_pos]);
|
||||||
|
+ else
|
||||||
|
+ byte_pos++;
|
||||||
|
+ char_pos++;
|
||||||
|
+ }
|
||||||
|
+ if (char_pos > lstart && nlines < 512)
|
||||||
|
+ {
|
||||||
|
+ line_starts[nlines] = lstart;
|
||||||
|
+ line_ends[nlines] = char_pos;
|
||||||
|
+ nlines++;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ /* Find the line whose face indicates selection. Track
|
||||||
|
+ visual line index for Zoom (skip whitespace-only lines
|
||||||
|
+ like Vertico's leading cursor-space). */
|
||||||
|
+ int candidate_idx = 0;
|
||||||
|
+ for (int li = 0; li < nlines; li++)
|
||||||
|
+ {
|
||||||
|
+ Lisp_Object face
|
||||||
|
+ = Fget_text_property (make_fixnum (line_starts[li]),
|
||||||
|
+ Qface, str);
|
||||||
|
+ if (ns_ax_face_is_selected (face))
|
||||||
|
+ {
|
||||||
|
+ Lisp_Object line
|
||||||
|
+ = Fsubstring_no_properties (
|
||||||
|
+ str,
|
||||||
|
+ make_fixnum (line_starts[li]),
|
||||||
|
+ make_fixnum (line_ends[li]));
|
||||||
|
+ NSString *text = [NSString stringWithLispString:line];
|
||||||
|
+ text = [text stringByTrimmingCharactersInSet:
|
||||||
|
+ [NSCharacterSet
|
||||||
|
+ whitespaceAndNewlineCharacterSet]];
|
||||||
|
+ if ([text length] > 0)
|
||||||
|
+ {
|
||||||
|
+ *out_line_index = candidate_idx;
|
||||||
|
+ return text;
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ /* Count non-trivial lines as candidates for Zoom. */
|
||||||
|
+ if (line_ends[li] - line_starts[li] > 1)
|
||||||
|
+ candidate_idx++;
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ return nil;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+
|
||||||
|
/* 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)
|
||||||
|
@@ -7234,7 +7384,7 @@ ns_ax_buffer_text (struct window *w, ptrdiff_t *out_start,
|
||||||
|
|
||||||
|
/* 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
|
||||||
|
+ 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));
|
||||||
|
@@ -7315,7 +7465,7 @@ ns_ax_frame_for_range (struct window *w, EmacsView *view,
|
||||||
|
return NSZeroRect;
|
||||||
|
|
||||||
|
/* charpos_start and charpos_len are already in buffer charpos
|
||||||
|
- space — the caller maps AX string indices through
|
||||||
|
+ 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;
|
||||||
|
@@ -7794,6 +7944,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
|
||||||
|
@synthesize cachedOverlayModiff;
|
||||||
|
@synthesize cachedTextStart;
|
||||||
|
@synthesize cachedModiff;
|
||||||
|
+@synthesize cachedCharsModiff;
|
||||||
|
@synthesize cachedPoint;
|
||||||
|
@synthesize cachedMarkActive;
|
||||||
|
@synthesize cachedCompletionAnnouncement;
|
||||||
|
@@ -7891,7 +8042,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
|
||||||
|
NSTRACE ("EmacsAccessibilityBuffer ensureTextCache");
|
||||||
|
/* This method is only called from the main thread (AX getters
|
||||||
|
dispatch_sync to main first). Reads of cachedText/cachedTextModiff
|
||||||
|
- below are therefore safe without @synchronized — only the
|
||||||
|
+ below are therefore safe without @synchronized --- only the
|
||||||
|
write section at the end needs synchronization to protect
|
||||||
|
against concurrent reads from AX server thread. */
|
||||||
|
eassert ([NSThread isMainThread]);
|
||||||
|
@@ -7904,16 +8055,15 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
|
||||||
|
return;
|
||||||
|
|
||||||
|
ptrdiff_t modiff = BUF_MODIFF (b);
|
||||||
|
- ptrdiff_t overlay_modiff = BUF_OVERLAY_MODIFF (b);
|
||||||
|
ptrdiff_t pt = BUF_PT (b);
|
||||||
|
NSUInteger textLen = cachedText ? [cachedText length] : 0;
|
||||||
|
- /* Track both BUF_MODIFF and BUF_OVERLAY_MODIFF. Overlay-only
|
||||||
|
- changes (e.g., timer-based completion highlight move without
|
||||||
|
- text edit) bump overlay_modiff but not modiff. Also detect
|
||||||
|
- narrowing/widening which changes BUF_BEGV without bumping
|
||||||
|
- either modiff counter. */
|
||||||
|
+ /* Cache validity: track BUF_MODIFF and buffer narrowing.
|
||||||
|
+ Do NOT track BUF_OVERLAY_MODIFF here --- overlay text is not
|
||||||
|
+ included in the cached AX text (it is handled separately via
|
||||||
|
+ explicit announcements). Including overlay_modiff would
|
||||||
|
+ silently update cachedOverlayModiff and prevent the
|
||||||
|
+ notification dispatch from detecting overlay changes. */
|
||||||
|
if (cachedText && cachedTextModiff == modiff
|
||||||
|
- && cachedOverlayModiff == overlay_modiff
|
||||||
|
&& cachedTextStart == BUF_BEGV (b)
|
||||||
|
&& pt >= cachedTextStart
|
||||||
|
&& (textLen == 0
|
||||||
|
@@ -7930,7 +8080,6 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
|
||||||
|
[cachedText release];
|
||||||
|
cachedText = [text retain];
|
||||||
|
cachedTextModiff = modiff;
|
||||||
|
- cachedOverlayModiff = overlay_modiff;
|
||||||
|
cachedTextStart = start;
|
||||||
|
|
||||||
|
if (visibleRuns)
|
||||||
|
@@ -7995,7 +8144,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
|
||||||
|
/* Binary search: runs are sorted by charpos (ascending). Find the
|
||||||
|
run whose [charpos, charpos+length) range contains the target,
|
||||||
|
or the nearest run after an invisible gap. O(log n) instead of
|
||||||
|
- O(n) — matters for org-mode with many folded sections. */
|
||||||
|
+ O(n) --- matters for org-mode with many folded sections. */
|
||||||
|
NSUInteger lo = 0, hi = visibleRunCount;
|
||||||
|
while (lo < hi)
|
||||||
|
{
|
||||||
|
@@ -8008,7 +8157,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
|
||||||
|
else
|
||||||
|
{
|
||||||
|
/* Found: charpos is inside this run. Compute UTF-16 delta
|
||||||
|
- directly from cachedText — no Lisp calls needed. */
|
||||||
|
+ directly from cachedText --- no Lisp calls needed. */
|
||||||
|
NSUInteger chars_in = (NSUInteger)(charpos - r->charpos);
|
||||||
|
if (chars_in == 0 || !cachedText)
|
||||||
|
return r->ax_start;
|
||||||
|
@@ -8033,10 +8182,10 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
|
||||||
|
|
||||||
|
/* Convert accessibility string index to buffer charpos.
|
||||||
|
Safe to call from any thread: uses only cachedText (NSString) and
|
||||||
|
- visibleRuns — no Lisp calls. */
|
||||||
|
+ visibleRuns --- no Lisp calls. */
|
||||||
|
- (ptrdiff_t)charposForAccessibilityIndex:(NSUInteger)ax_idx
|
||||||
|
{
|
||||||
|
- /* May be called from AX server thread — synchronize. */
|
||||||
|
+ /* May be called from AX server thread --- synchronize. */
|
||||||
|
@synchronized (self)
|
||||||
|
{
|
||||||
|
if (visibleRunCount == 0)
|
||||||
|
@@ -8070,7 +8219,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
|
||||||
|
return cp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
- /* Past end — return last charpos. */
|
||||||
|
+ /* Past end --- return last charpos. */
|
||||||
|
if (lo > 0)
|
||||||
|
{
|
||||||
|
ns_ax_visible_run *last = &visibleRuns[visibleRunCount - 1];
|
||||||
|
@@ -8092,7 +8241,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
|
||||||
|
deadlocking the AX server thread. This is prevented by:
|
||||||
|
|
||||||
|
1. validWindow checks WINDOW_LIVE_P and BUFFERP before every
|
||||||
|
- Lisp access — the window and buffer are verified live.
|
||||||
|
+ Lisp access --- the window and buffer are verified live.
|
||||||
|
2. All dispatch_sync blocks run on the main thread where no
|
||||||
|
concurrent Lisp code can modify state between checks.
|
||||||
|
3. block_input prevents timer events and process output from
|
||||||
|
@@ -8443,7 +8592,51 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
|
||||||
|
if (point_idx > [cachedText length])
|
||||||
|
point_idx = [cachedText length];
|
||||||
|
|
||||||
|
+ return [self lineForAXIndex:point_idx];
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+- (NSString *)accessibilityStringForRange:(NSRange)range
|
||||||
|
+{
|
||||||
|
+ if (![NSThread isMainThread])
|
||||||
|
+ {
|
||||||
|
+ __block NSString *result;
|
||||||
|
+ dispatch_sync (dispatch_get_main_queue (), ^{
|
||||||
|
+ result = [self accessibilityStringForRange:range];
|
||||||
|
+ });
|
||||||
|
+ return result;
|
||||||
|
+ }
|
||||||
|
+ [self ensureTextCache];
|
||||||
|
+ if (!cachedText || range.location + range.length > [cachedText length])
|
||||||
|
+ return @"";
|
||||||
|
+ return [cachedText substringWithRange:range];
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+- (NSAttributedString *)accessibilityAttributedStringForRange:(NSRange)range
|
||||||
|
+{
|
||||||
|
+ NSString *str = [self accessibilityStringForRange:range];
|
||||||
|
+ return [[[NSAttributedString alloc] initWithString:str] autorelease];
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+- (NSInteger)accessibilityLineForIndex:(NSInteger)index
|
||||||
|
+{
|
||||||
|
+ if (![NSThread isMainThread])
|
||||||
|
+ {
|
||||||
|
+ __block NSInteger result;
|
||||||
|
+ dispatch_sync (dispatch_get_main_queue (), ^{
|
||||||
|
+ result = [self accessibilityLineForIndex:index];
|
||||||
|
+ });
|
||||||
|
+ return result;
|
||||||
|
+ }
|
||||||
|
+ [self ensureTextCache];
|
||||||
|
+ if (!cachedText || index < 0)
|
||||||
|
+ return 0;
|
||||||
|
+
|
||||||
|
+ NSUInteger idx = (NSUInteger) index;
|
||||||
|
+ if (idx > [cachedText length])
|
||||||
|
+ idx = [cachedText length];
|
||||||
|
+
|
||||||
|
return [self lineForAXIndex:idx];
|
||||||
|
+
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSRange)accessibilityRangeForLine:(NSInteger)line
|
||||||
|
@@ -8667,7 +8860,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
|
||||||
|
|
||||||
|
|
||||||
|
/* ===================================================================
|
||||||
|
- EmacsAccessibilityBuffer (Notifications) — AX event dispatch
|
||||||
|
+ EmacsAccessibilityBuffer (Notifications) --- AX event dispatch
|
||||||
|
|
||||||
|
These methods notify VoiceOver of text and selection changes.
|
||||||
|
Called from the redisplay cycle (postAccessibilityUpdates).
|
||||||
|
@@ -8682,7 +8875,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
|
||||||
|
if (point > self.cachedPoint
|
||||||
|
&& point - self.cachedPoint == 1)
|
||||||
|
{
|
||||||
|
- /* Single char inserted — refresh cache and grab it. */
|
||||||
|
+ /* Single char inserted --- refresh cache and grab it. */
|
||||||
|
[self invalidateTextCache];
|
||||||
|
[self ensureTextCache];
|
||||||
|
if (cachedText)
|
||||||
|
@@ -8701,7 +8894,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
|
||||||
|
/* Update cachedPoint here so the selection-move branch does NOT
|
||||||
|
fire for point changes caused by edits. WebKit and Chromium
|
||||||
|
never send both ValueChanged and SelectedTextChanged for the
|
||||||
|
- same user action — they are mutually exclusive. */
|
||||||
|
+ same user action --- they are mutually exclusive. */
|
||||||
|
self.cachedPoint = point;
|
||||||
|
|
||||||
|
NSDictionary *change = @{
|
||||||
|
@@ -9034,14 +9227,112 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
|
||||||
|
BOOL markActive = !NILP (BVAR (b, mark_active));
|
||||||
|
|
||||||
|
/* --- Text changed (edit) --- */
|
||||||
|
+ ptrdiff_t chars_modiff = BUF_CHARS_MODIFF (b);
|
||||||
|
if (modiff != self.cachedModiff)
|
||||||
|
{
|
||||||
|
self.cachedModiff = modiff;
|
||||||
|
- [self postTextChangedNotification:point];
|
||||||
|
+ /* Only post ValueChanged when actual characters changed.
|
||||||
|
+ Text property changes (e.g. face updates from
|
||||||
|
+ vertico--prompt-selection) bump BUF_MODIFF but not
|
||||||
|
+ BUF_CHARS_MODIFF. Posting ValueChanged for property-only
|
||||||
|
+ changes causes VoiceOver to say "new line" when the diff
|
||||||
|
+ is non-empty due to overlay content changes. */
|
||||||
|
+ if (chars_modiff != self.cachedCharsModiff)
|
||||||
|
+ {
|
||||||
|
+ self.cachedCharsModiff = chars_modiff;
|
||||||
|
+ self.emacsView->overlayZoomActive = NO;
|
||||||
|
+ [self postTextChangedNotification:point];
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+
|
||||||
|
+ /* --- Overlay content changed (e.g. Vertico/Ivy candidate switch) ---
|
||||||
|
+ Check independently of the modiff branch above, because
|
||||||
|
+ frameworks like Vertico bump BOTH BUF_MODIFF (via text property
|
||||||
|
+ changes in vertico--prompt-selection) and BUF_OVERLAY_MODIFF
|
||||||
|
+ (via overlay-put) in the same command cycle. If this were an
|
||||||
|
+ else-if, the modiff branch would always win and overlay
|
||||||
|
+ announcements would never fire.
|
||||||
|
+ Do NOT invalidate the text cache --- the buffer text has not
|
||||||
|
+ changed, and cache invalidation causes VoiceOver to diff old
|
||||||
|
+ vs new AX text and announce spurious "new line". */
|
||||||
|
+ if (BUF_OVERLAY_MODIFF (b) != self.cachedOverlayModiff)
|
||||||
|
+ {
|
||||||
|
+ self.cachedOverlayModiff = BUF_OVERLAY_MODIFF (b);
|
||||||
|
+
|
||||||
|
+ int selected_line = -1;
|
||||||
|
+ NSString *candidate
|
||||||
|
+ = ns_ax_selected_overlay_text (b, BUF_BEGV (b), BUF_ZV (b),
|
||||||
|
+ &selected_line);
|
||||||
|
+ if (candidate)
|
||||||
|
+ {
|
||||||
|
+ /* Deduplicate: only announce when the candidate changed. */
|
||||||
|
+ if (![candidate isEqualToString:
|
||||||
|
+ self.cachedCompletionAnnouncement])
|
||||||
|
+ {
|
||||||
|
+ self.cachedCompletionAnnouncement = candidate;
|
||||||
|
+
|
||||||
|
+ /* Announce the candidate text directly via NSApp.
|
||||||
|
+ Do NOT post SelectedTextChanged --- that would cause
|
||||||
|
+ VoiceOver to read the AX text at the cursor position
|
||||||
|
+ (the minibuffer input line), not the overlay candidate.
|
||||||
|
+ AnnouncementRequested with High priority interrupts
|
||||||
|
+ any current speech and announces our text. */
|
||||||
|
+ NSDictionary *annInfo = @{
|
||||||
|
+ NSAccessibilityAnnouncementKey: candidate,
|
||||||
|
+ NSAccessibilityPriorityKey:
|
||||||
|
+ @(NSAccessibilityPriorityHigh)
|
||||||
|
+ };
|
||||||
|
+ ns_ax_post_notification_with_info (
|
||||||
|
+ NSApp,
|
||||||
|
+ NSAccessibilityAnnouncementRequestedNotification,
|
||||||
|
+ annInfo);
|
||||||
|
+
|
||||||
|
+ /* --- Zoom tracking for overlay candidates ---
|
||||||
|
+ Store the candidate row rect so draw_window_cursor
|
||||||
|
+ focuses Zoom there instead of on the text cursor.
|
||||||
|
+ Cleared when the user types (chars_modiff change).
|
||||||
|
+
|
||||||
|
+ Use default line height to compute the Y offset:
|
||||||
|
+ row 0 is the input line, overlay candidates start
|
||||||
|
+ from row 1. This avoids fragile glyph matrix row
|
||||||
|
+ index mapping which can be off when group titles
|
||||||
|
+ or wrapped lines shift row numbering. */
|
||||||
|
+ if (selected_line >= 0)
|
||||||
|
+ {
|
||||||
|
+ struct window *w2 = [self validWindow];
|
||||||
|
+ if (w2)
|
||||||
|
+ {
|
||||||
|
+ EmacsView *view = self.emacsView;
|
||||||
|
+ struct frame *f2 = XFRAME (w2->frame);
|
||||||
|
+ int line_h = FRAME_LINE_HEIGHT (f2);
|
||||||
|
+ int y_off = (selected_line + 1) * line_h;
|
||||||
|
+
|
||||||
|
+ if (y_off < w2->pixel_height)
|
||||||
|
+ {
|
||||||
|
+ view->overlayZoomRect = NSMakeRect (
|
||||||
|
+ WINDOW_TEXT_TO_FRAME_PIXEL_X (w2, 0),
|
||||||
|
+ WINDOW_TO_FRAME_PIXEL_Y (w2, y_off),
|
||||||
|
+ FRAME_COLUMN_WIDTH (f2),
|
||||||
|
+ line_h);
|
||||||
|
+ view->overlayZoomActive = YES;
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ else
|
||||||
|
+ {
|
||||||
|
+ /* No selected candidate --- overlay completion ended
|
||||||
|
+ (minibuffer exit, C-g, etc.) or overlay has no
|
||||||
|
+ recognizable selection face. Return Zoom to the
|
||||||
|
+ text cursor. */
|
||||||
|
+ self.emacsView->overlayZoomActive = NO;
|
||||||
|
+ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Cursor moved or selection changed ---
|
||||||
|
- Use 'else if' — edits and selection moves are mutually exclusive
|
||||||
|
+ Use 'else if' --- edits and selection moves are mutually exclusive
|
||||||
|
per the WebKit/Chromium pattern. */
|
||||||
|
else if (point != self.cachedPoint || markActive != self.cachedMarkActive)
|
||||||
|
{
|
||||||
|
@@ -9211,7 +9502,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
|
||||||
|
|
||||||
|
|
||||||
|
/* ===================================================================
|
||||||
|
- EmacsAccessibilityInteractiveSpan — helpers and implementation
|
||||||
|
+ EmacsAccessibilityInteractiveSpan --- helpers and implementation
|
||||||
|
=================================================================== */
|
||||||
|
|
||||||
|
/* Scan visible range of window W for interactive spans.
|
||||||
|
@@ -9402,7 +9693,7 @@ ns_ax_scan_interactive_spans (struct window *w,
|
||||||
|
- (BOOL) isAccessibilityFocused
|
||||||
|
{
|
||||||
|
/* Read the cached point stored by EmacsAccessibilityBuffer on the main
|
||||||
|
- thread — safe to read from any thread (plain ptrdiff_t, no Lisp calls). */
|
||||||
|
+ thread --- safe to read from any thread (plain ptrdiff_t, no Lisp calls). */
|
||||||
|
EmacsAccessibilityBuffer *pb = self.parentBuffer;
|
||||||
|
if (!pb)
|
||||||
|
return NO;
|
||||||
|
@@ -9419,7 +9710,7 @@ ns_ax_scan_interactive_spans (struct window *w,
|
||||||
|
dispatch_async (dispatch_get_main_queue (), ^{
|
||||||
|
/* lwin is a Lisp_Object captured by value. This is GC-safe
|
||||||
|
because Lisp_Objects are tagged integers/pointers that
|
||||||
|
- remain valid across GC — GC does not relocate objects in
|
||||||
|
+ remain valid across GC --- GC does not relocate objects in
|
||||||
|
Emacs. The WINDOW_LIVE_P check below guards against the
|
||||||
|
window being deleted between capture and execution. */
|
||||||
|
if (!WINDOWP (lwin) || NILP (Fwindow_live_p (lwin)))
|
||||||
|
@@ -9445,7 +9736,7 @@ ns_ax_scan_interactive_spans (struct window *w,
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
-/* EmacsAccessibilityBuffer — InteractiveSpans category.
|
||||||
|
+/* EmacsAccessibilityBuffer --- InteractiveSpans category.
|
||||||
|
Methods are kept here (same .m file) so they access the ivars
|
||||||
|
declared in the @interface ivar block. */
|
||||||
|
@implementation EmacsAccessibilityBuffer (InteractiveSpans)
|
||||||
|
@@ -10765,13 +11056,13 @@ ns_in_echo_area (void)
|
||||||
|
if (old_title == 0)
|
||||||
|
{
|
||||||
|
char *t = strdup ([[[self window] title] UTF8String]);
|
||||||
|
- char *pos = strstr (t, " — ");
|
||||||
|
+ char *pos = strstr (t, " --- ");
|
||||||
|
if (pos)
|
||||||
|
*pos = '\0';
|
||||||
|
old_title = t;
|
||||||
|
}
|
||||||
|
size_title = xmalloc (strlen (old_title) + 40);
|
||||||
|
- esprintf (size_title, "%s — (%d × %d)", old_title, cols, rows);
|
||||||
|
+ esprintf (size_title, "%s --- (%d × %d)", old_title, cols, rows);
|
||||||
|
[window setTitle: [NSString stringWithUTF8String: size_title]];
|
||||||
|
[window display];
|
||||||
|
xfree (size_title);
|
||||||
|
@@ -12167,7 +12458,7 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view,
|
||||||
|
|
||||||
|
if (WINDOW_LEAF_P (w))
|
||||||
|
{
|
||||||
|
- /* Buffer element — reuse existing if available. */
|
||||||
|
+ /* Buffer element --- reuse existing if available. */
|
||||||
|
EmacsAccessibilityBuffer *elem
|
||||||
|
= [existing objectForKey:[NSValue valueWithPointer:w]];
|
||||||
|
if (!elem)
|
||||||
|
@@ -12201,7 +12492,7 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view,
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
- /* Internal (combination) window — recurse into children. */
|
||||||
|
+ /* Internal (combination) window --- recurse into children. */
|
||||||
|
Lisp_Object child = w->contents;
|
||||||
|
while (!NILP (child))
|
||||||
|
{
|
||||||
|
@@ -12313,7 +12604,7 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view,
|
||||||
|
accessibilityUpdating = YES;
|
||||||
|
|
||||||
|
/* Detect window tree change (split, delete, new buffer). Compare
|
||||||
|
- FRAME_ROOT_WINDOW — if it changed, the tree structure changed. */
|
||||||
|
+ FRAME_ROOT_WINDOW --- if it changed, the tree structure changed. */
|
||||||
|
Lisp_Object curRoot = FRAME_ROOT_WINDOW (emacsframe);
|
||||||
|
if (!EQ (curRoot, lastRootWindow))
|
||||||
|
{
|
||||||
|
@@ -12322,12 +12613,12 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view,
|
||||||
|
}
|
||||||
|
|
||||||
|
/* If tree is stale, rebuild FIRST so we don't iterate freed
|
||||||
|
- window pointers. Skip notifications for this cycle — the
|
||||||
|
+ window pointers. Skip notifications for this cycle --- the
|
||||||
|
freshly-built elements have no previous state to diff against. */
|
||||||
|
if (!accessibilityTreeValid)
|
||||||
|
{
|
||||||
|
[self rebuildAccessibilityTree];
|
||||||
|
- /* Invalidate span cache — window layout changed. */
|
||||||
|
+ /* Invalidate span cache --- window layout changed. */
|
||||||
|
for (EmacsAccessibilityElement *elem in accessibilityElements)
|
||||||
|
if ([elem isKindOfClass: [EmacsAccessibilityBuffer class]])
|
||||||
|
[(EmacsAccessibilityBuffer *) elem invalidateInteractiveSpans];
|
||||||
|
--
|
||||||
|
2.43.0
|
||||||
|
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
From d68d1334147a7de273e39cf26c778389faa424ad Mon Sep 17 00:00:00 2001
|
||||||
|
From: Martin Sukany <martin@sukany.cz>
|
||||||
|
Date: Sat, 28 Feb 2026 16:01:29 +0100
|
||||||
|
Subject: [PATCH 8/8] ns: announce child frame completion candidates for
|
||||||
|
VoiceOver
|
||||||
|
|
||||||
|
Completion frameworks such as Corfu, Company-box, and similar
|
||||||
|
render candidates in a child frame rather than as overlay strings
|
||||||
|
in the minibuffer. This patch extends the overlay announcement
|
||||||
|
support (patch 7/8) to handle child frame popups.
|
||||||
|
|
||||||
|
Detect child frames via FRAME_PARENT_FRAME in postAccessibilityUpdates.
|
||||||
|
Scan the child frame buffer text line by line using Fget_char_property
|
||||||
|
(which checks both text properties and overlay face properties) to
|
||||||
|
find the selected candidate. Reuse ns_ax_face_is_selected from
|
||||||
|
the overlay patch to identify "current", "selected", and
|
||||||
|
"selection" faces.
|
||||||
|
|
||||||
|
Safety:
|
||||||
|
- record_unwind_current_buffer / set_buffer_internal_1 to switch to
|
||||||
|
the child frame buffer for Fbuffer_substring_no_properties.
|
||||||
|
- Re-entrance guard (accessibilityUpdating) before child frame dispatch.
|
||||||
|
- BUF_MODIFF gating prevents redundant scans.
|
||||||
|
- WINDOWP, BUFFERP validation for partially initialized frames.
|
||||||
|
- Buffer size limit (10000 chars) skips non-completion child frames.
|
||||||
|
|
||||||
|
When the child frame closes, post FocusedUIElementChangedNotification
|
||||||
|
on the parent buffer element to restore VoiceOver's character echo
|
||||||
|
and cursor tracking. The flag childFrameCompletionActive is set by
|
||||||
|
the child frame handler and cleared on the parent's next accessibility
|
||||||
|
cycle when no child frame is visible (via FOR_EACH_FRAME).
|
||||||
|
|
||||||
|
Announce via AnnouncementRequested to NSApp with High priority.
|
||||||
|
Use direct UAZoomChangeFocus because the child frame renders
|
||||||
|
independently --- its ns_update_end runs after the parent's
|
||||||
|
draw_window_cursor, so the last Zoom call wins.
|
||||||
|
|
||||||
|
* src/nsterm.h (EmacsView): Add announceChildFrameCompletion,
|
||||||
|
childFrameCompletionActive flag.
|
||||||
|
* src/nsterm.m (ns_ax_selected_child_frame_text): New function.
|
||||||
|
(EmacsView announceChildFrameCompletion): New method, set parent flag.
|
||||||
|
(EmacsView postAccessibilityUpdates): Dispatch to child frame handler,
|
||||||
|
refocus parent buffer element when child frame closes.
|
||||||
|
---
|
||||||
|
src/nsterm.h | 2 +
|
||||||
|
src/nsterm.m | 253 ++++++++++++++++++++++++++++++++++++++++++++++++++-
|
||||||
|
2 files changed, 254 insertions(+), 1 deletion(-)
|
||||||
|
|
||||||
|
diff --git a/src/nsterm.h b/src/nsterm.h
|
||||||
|
index a007925..1a8a84d 100644
|
||||||
|
--- a/src/nsterm.h
|
||||||
|
+++ b/src/nsterm.h
|
||||||
|
@@ -598,6 +598,7 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType)
|
||||||
|
NSRect lastAccessibilityCursorRect;
|
||||||
|
BOOL overlayZoomActive;
|
||||||
|
NSRect overlayZoomRect;
|
||||||
|
+ BOOL childFrameCompletionActive;
|
||||||
|
#endif
|
||||||
|
BOOL font_panel_active;
|
||||||
|
NSFont *font_panel_result;
|
||||||
|
@@ -661,6 +662,7 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType)
|
||||||
|
- (void)rebuildAccessibilityTree;
|
||||||
|
- (void)invalidateAccessibilityTree;
|
||||||
|
- (void)postAccessibilityUpdates;
|
||||||
|
+- (void)announceChildFrameCompletion;
|
||||||
|
#endif
|
||||||
|
@end
|
||||||
|
|
||||||
|
diff --git a/src/nsterm.m b/src/nsterm.m
|
||||||
|
index ebd52c6..a7025a9 100644
|
||||||
|
--- a/src/nsterm.m
|
||||||
|
+++ b/src/nsterm.m
|
||||||
|
@@ -7310,6 +7310,110 @@ ns_ax_selected_overlay_text (struct buffer *b,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
+/* Scan buffer text of a child frame for the selected completion
|
||||||
|
+ candidate. Used for frameworks that render candidates in a
|
||||||
|
+ child frame (e.g. Corfu, Company-box) rather than as overlay
|
||||||
|
+ strings. Check the effective face (text properties + overlays)
|
||||||
|
+ at the start of each line via Fget_char_property.
|
||||||
|
+
|
||||||
|
+ Returns the candidate text (trimmed) or nil. Sets
|
||||||
|
+ *OUT_LINE_INDEX to the 0-based line index for Zoom. */
|
||||||
|
+static NSString *
|
||||||
|
+ns_ax_selected_child_frame_text (struct buffer *b, Lisp_Object buf_obj,
|
||||||
|
+ int *out_line_index)
|
||||||
|
+{
|
||||||
|
+ *out_line_index = -1;
|
||||||
|
+ ptrdiff_t beg = BUF_BEGV (b);
|
||||||
|
+ ptrdiff_t end = BUF_ZV (b);
|
||||||
|
+
|
||||||
|
+ if (beg >= end)
|
||||||
|
+ return nil;
|
||||||
|
+
|
||||||
|
+ /* Temporarily switch to the child frame buffer.
|
||||||
|
+ Fbuffer_substring_no_properties operates on current_buffer,
|
||||||
|
+ which may be a different buffer (e.g., the parent frame's). */
|
||||||
|
+ specpdl_ref count = SPECPDL_INDEX ();
|
||||||
|
+ record_unwind_current_buffer ();
|
||||||
|
+ set_buffer_internal_1 (b);
|
||||||
|
+
|
||||||
|
+ /* Get buffer text as a Lisp string for efficient scanning.
|
||||||
|
+ The buffer is a small completion popup (typically < 20 lines). */
|
||||||
|
+ Lisp_Object str
|
||||||
|
+ = Fbuffer_substring_no_properties (make_fixnum (beg),
|
||||||
|
+ make_fixnum (end));
|
||||||
|
+ if (!STRINGP (str) || SCHARS (str) == 0)
|
||||||
|
+ {
|
||||||
|
+ unbind_to (count, Qnil);
|
||||||
|
+ return nil;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ /* Scan newlines (same pattern as ns_ax_selected_overlay_text).
|
||||||
|
+ The data pointer is used only in this loop, before Lisp calls. */
|
||||||
|
+ const unsigned char *data = SDATA (str);
|
||||||
|
+ ptrdiff_t byte_len = SBYTES (str);
|
||||||
|
+ ptrdiff_t line_starts[128];
|
||||||
|
+ ptrdiff_t line_ends[128];
|
||||||
|
+ int nlines = 0;
|
||||||
|
+ ptrdiff_t char_pos = 0, byte_pos = 0, lstart = 0;
|
||||||
|
+
|
||||||
|
+ while (byte_pos < byte_len && nlines < 128)
|
||||||
|
+ {
|
||||||
|
+ if (data[byte_pos] == '\n')
|
||||||
|
+ {
|
||||||
|
+ if (char_pos > lstart)
|
||||||
|
+ {
|
||||||
|
+ line_starts[nlines] = lstart;
|
||||||
|
+ line_ends[nlines] = char_pos;
|
||||||
|
+ nlines++;
|
||||||
|
+ }
|
||||||
|
+ lstart = char_pos + 1;
|
||||||
|
+ }
|
||||||
|
+ if (STRING_MULTIBYTE (str))
|
||||||
|
+ byte_pos += BYTES_BY_CHAR_HEAD (data[byte_pos]);
|
||||||
|
+ else
|
||||||
|
+ byte_pos++;
|
||||||
|
+ char_pos++;
|
||||||
|
+ }
|
||||||
|
+ if (char_pos > lstart && nlines < 128)
|
||||||
|
+ {
|
||||||
|
+ line_starts[nlines] = lstart;
|
||||||
|
+ line_ends[nlines] = char_pos;
|
||||||
|
+ nlines++;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ /* Find the line with a selected face. Use Fget_char_property on
|
||||||
|
+ the BUFFER (not the string) so overlay faces are included.
|
||||||
|
+ Offset string positions by beg to get buffer positions. */
|
||||||
|
+ for (int li = 0; li < nlines; li++)
|
||||||
|
+ {
|
||||||
|
+ ptrdiff_t buf_pos = beg + line_starts[li];
|
||||||
|
+ Lisp_Object face
|
||||||
|
+ = Fget_char_property (make_fixnum (buf_pos), Qface, buf_obj);
|
||||||
|
+
|
||||||
|
+ if (ns_ax_face_is_selected (face))
|
||||||
|
+ {
|
||||||
|
+ Lisp_Object line
|
||||||
|
+ = Fsubstring_no_properties (str,
|
||||||
|
+ make_fixnum (line_starts[li]),
|
||||||
|
+ make_fixnum (line_ends[li]));
|
||||||
|
+ NSString *text = [NSString stringWithLispString:line];
|
||||||
|
+ text = [text stringByTrimmingCharactersInSet:
|
||||||
|
+ [NSCharacterSet
|
||||||
|
+ whitespaceAndNewlineCharacterSet]];
|
||||||
|
+ if ([text length] > 0)
|
||||||
|
+ {
|
||||||
|
+ *out_line_index = li;
|
||||||
|
+ unbind_to (count, Qnil);
|
||||||
|
+ return text;
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ unbind_to (count, Qnil);
|
||||||
|
+ return nil;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+
|
||||||
|
/* Build accessibility text for window W, skipping invisible text.
|
||||||
|
Populates *OUT_START with the buffer start charpos.
|
||||||
|
Populates *OUT_RUNS with an array of visible runs and *OUT_NRUNS
|
||||||
|
@@ -12588,6 +12692,105 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view,
|
||||||
|
The existing elements carry cached state (modiff, point) from the
|
||||||
|
previous redisplay cycle. Rebuilding first would create fresh
|
||||||
|
elements with current values, making change detection impossible. */
|
||||||
|
+
|
||||||
|
+/* Announce the selected candidate in a child frame completion popup.
|
||||||
|
+ Handles Corfu, Company-box, and similar frameworks that render
|
||||||
|
+ candidates in a separate child frame rather than as overlay strings
|
||||||
|
+ in the minibuffer. Uses direct UAZoomChangeFocus (not the
|
||||||
|
+ overlayZoomRect flag) because the child frame's ns_update_end runs
|
||||||
|
+ after the parent's draw_window_cursor. */
|
||||||
|
+- (void)announceChildFrameCompletion
|
||||||
|
+{
|
||||||
|
+ static char *lastCandidate;
|
||||||
|
+ static struct buffer *lastBuffer;
|
||||||
|
+ static EMACS_INT lastModiff;
|
||||||
|
+
|
||||||
|
+ /* Validate frame state --- child frames may be partially
|
||||||
|
+ initialized during creation. */
|
||||||
|
+ if (!WINDOWP (emacsframe->selected_window))
|
||||||
|
+ return;
|
||||||
|
+ struct window *w = XWINDOW (emacsframe->selected_window);
|
||||||
|
+ if (!BUFFERP (w->contents))
|
||||||
|
+ return;
|
||||||
|
+ struct buffer *b = XBUFFER (w->contents);
|
||||||
|
+
|
||||||
|
+ /* Only scan when the buffer content has actually changed.
|
||||||
|
+ This prevents redundant work on every redisplay tick and
|
||||||
|
+ also guards against re-entrance: if Lisp calls below
|
||||||
|
+ trigger redisplay, the modiff check short-circuits. */
|
||||||
|
+ EMACS_INT modiff = BUF_MODIFF (b);
|
||||||
|
+ if (b == lastBuffer && modiff == lastModiff)
|
||||||
|
+ return;
|
||||||
|
+ lastBuffer = b;
|
||||||
|
+ lastModiff = modiff;
|
||||||
|
+
|
||||||
|
+ /* Skip buffers larger than a typical completion popup.
|
||||||
|
+ This avoids scanning eldoc, which-key, or other child
|
||||||
|
+ frame buffers that are not completion UIs. */
|
||||||
|
+ if (BUF_ZV (b) - BUF_BEGV (b) > 10000)
|
||||||
|
+ return;
|
||||||
|
+
|
||||||
|
+ int selected_line = -1;
|
||||||
|
+ NSString *candidate
|
||||||
|
+ = ns_ax_selected_child_frame_text (b, w->contents, &selected_line);
|
||||||
|
+
|
||||||
|
+ if (!candidate)
|
||||||
|
+ return;
|
||||||
|
+
|
||||||
|
+ /* Deduplicate --- avoid re-announcing the same candidate. */
|
||||||
|
+ const char *cstr = [candidate UTF8String];
|
||||||
|
+ if (lastCandidate && strcmp (cstr, lastCandidate) == 0)
|
||||||
|
+ return;
|
||||||
|
+ xfree (lastCandidate);
|
||||||
|
+ lastCandidate = xstrdup (cstr);
|
||||||
|
+
|
||||||
|
+ NSDictionary *annInfo = @{
|
||||||
|
+ NSAccessibilityAnnouncementKey: candidate,
|
||||||
|
+ NSAccessibilityPriorityKey:
|
||||||
|
+ @(NSAccessibilityPriorityHigh)
|
||||||
|
+ };
|
||||||
|
+ ns_ax_post_notification_with_info (
|
||||||
|
+ NSApp,
|
||||||
|
+ NSAccessibilityAnnouncementRequestedNotification,
|
||||||
|
+ annInfo);
|
||||||
|
+
|
||||||
|
+ /* Mark the parent as having an active child frame completion.
|
||||||
|
+ When the child frame closes, the parent's next accessibility
|
||||||
|
+ cycle will post FocusedUIElementChanged to restore VoiceOver's
|
||||||
|
+ focus to the buffer text element. */
|
||||||
|
+ struct frame *parent = FRAME_PARENT_FRAME (emacsframe);
|
||||||
|
+ if (parent)
|
||||||
|
+ {
|
||||||
|
+ EmacsView *parentView = FRAME_NS_VIEW (parent);
|
||||||
|
+ if (parentView)
|
||||||
|
+ parentView->childFrameCompletionActive = YES;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ /* Zoom tracking: focus on the selected row in the child frame.
|
||||||
|
+ Use direct UAZoomChangeFocus rather than overlayZoomRect because
|
||||||
|
+ the child frame renders independently of the parent. */
|
||||||
|
+ if (selected_line >= 0 && UAZoomEnabled ())
|
||||||
|
+ {
|
||||||
|
+ int line_h = FRAME_LINE_HEIGHT (emacsframe);
|
||||||
|
+ int y_off = selected_line * line_h;
|
||||||
|
+ NSRect r = NSMakeRect (
|
||||||
|
+ WINDOW_TEXT_TO_FRAME_PIXEL_X (w, 0),
|
||||||
|
+ WINDOW_TO_FRAME_PIXEL_Y (w, y_off),
|
||||||
|
+ FRAME_COLUMN_WIDTH (emacsframe),
|
||||||
|
+ line_h);
|
||||||
|
+ NSRect winRect = [self convertRect:r toView:nil];
|
||||||
|
+ NSRect screenRect
|
||||||
|
+ = [[self window] convertRectToScreen:winRect];
|
||||||
|
+ CGRect cgRect = NSRectToCGRect (screenRect);
|
||||||
|
+ CGFloat primaryH
|
||||||
|
+ = [[[NSScreen screens] firstObject] frame].size.height;
|
||||||
|
+ cgRect.origin.y
|
||||||
|
+ = primaryH - cgRect.origin.y - cgRect.size.height;
|
||||||
|
+ UAZoomChangeFocus (&cgRect, &cgRect,
|
||||||
|
+ kUAZoomFocusTypeInsertionPoint);
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
- (void)postAccessibilityUpdates
|
||||||
|
{
|
||||||
|
NSTRACE ("[EmacsView postAccessibilityUpdates]");
|
||||||
|
@@ -12598,11 +12801,59 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view,
|
||||||
|
|
||||||
|
/* Re-entrance guard: VoiceOver callbacks during notification posting
|
||||||
|
can trigger redisplay, which calls ns_update_end, which calls us
|
||||||
|
- again. Prevent infinite recursion. */
|
||||||
|
+ again. Prevent infinite recursion. This MUST come before the
|
||||||
|
+ child frame check --- announceChildFrameCompletion makes Lisp
|
||||||
|
+ calls that can trigger redisplay. */
|
||||||
|
if (accessibilityUpdating)
|
||||||
|
return;
|
||||||
|
accessibilityUpdating = YES;
|
||||||
|
|
||||||
|
+ /* Child frame completion popup (Corfu, Company-box, etc.).
|
||||||
|
+ Child frames don't participate in the accessibility tree;
|
||||||
|
+ announce the selected candidate directly. */
|
||||||
|
+ if (FRAME_PARENT_FRAME (emacsframe))
|
||||||
|
+ {
|
||||||
|
+ [self announceChildFrameCompletion];
|
||||||
|
+ accessibilityUpdating = NO;
|
||||||
|
+ return;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ /* If a child frame completion was recently active but no child
|
||||||
|
+ frame is visible anymore, refocus VoiceOver on the buffer
|
||||||
|
+ element so character echo and cursor tracking resume.
|
||||||
|
+ Skip if a child frame still exists (completion still open). */
|
||||||
|
+ if (childFrameCompletionActive)
|
||||||
|
+ {
|
||||||
|
+ Lisp_Object tail, frame;
|
||||||
|
+ BOOL childStillVisible = NO;
|
||||||
|
+ FOR_EACH_FRAME (tail, frame)
|
||||||
|
+ if (FRAME_PARENT_FRAME (XFRAME (frame)) == emacsframe
|
||||||
|
+ && FRAME_VISIBLE_P (XFRAME (frame)))
|
||||||
|
+ {
|
||||||
|
+ childStillVisible = YES;
|
||||||
|
+ break;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ if (!childStillVisible)
|
||||||
|
+ {
|
||||||
|
+ childFrameCompletionActive = NO;
|
||||||
|
+ EmacsAccessibilityBuffer *focused = nil;
|
||||||
|
+ for (id elem in accessibilityElements)
|
||||||
|
+ if ([elem isKindOfClass:
|
||||||
|
+ [EmacsAccessibilityBuffer class]]
|
||||||
|
+ && [(EmacsAccessibilityBuffer *)elem
|
||||||
|
+ isAccessibilityFocused])
|
||||||
|
+ {
|
||||||
|
+ focused = elem;
|
||||||
|
+ break;
|
||||||
|
+ }
|
||||||
|
+ if (focused)
|
||||||
|
+ ns_ax_post_notification (
|
||||||
|
+ focused,
|
||||||
|
+ NSAccessibilityFocusedUIElementChangedNotification);
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
/* Detect window tree change (split, delete, new buffer). Compare
|
||||||
|
FRAME_ROOT_WINDOW --- if it changed, the tree structure changed. */
|
||||||
|
Lisp_Object curRoot = FRAME_ROOT_WINDOW (emacsframe);
|
||||||
|
--
|
||||||
|
2.43.0
|
||||||
|
|
||||||
794
patches/README.txt
Normal file
794
patches/README.txt
Normal file
@@ -0,0 +1,794 @@
|
|||||||
|
EMACS NS VOICEOVER ACCESSIBILITY PATCH
|
||||||
|
========================================
|
||||||
|
patch: 0001-0008 (8 patches, see PATCH SERIES below)
|
||||||
|
author: Martin Sukany <martin@sukany.cz>
|
||||||
|
files: src/nsterm.h (+124 lines)
|
||||||
|
src/nsterm.m (+3577 ins, -185 del, +3392 net)
|
||||||
|
doc/emacs/macos.texi (+53 lines)
|
||||||
|
etc/NEWS (+13 lines)
|
||||||
|
|
||||||
|
|
||||||
|
PATCH SERIES
|
||||||
|
------------
|
||||||
|
|
||||||
|
0001 ns: add accessibility base classes and text extraction
|
||||||
|
0002 ns: implement buffer accessibility element (core protocol)
|
||||||
|
0003 ns: add buffer notification dispatch and mode-line element
|
||||||
|
0004 ns: add interactive span elements for Tab navigation
|
||||||
|
0005 ns: integrate accessibility with EmacsView and redisplay
|
||||||
|
0006 doc: add VoiceOver accessibility section to macOS appendix
|
||||||
|
0007 ns: announce overlay completion candidates for VoiceOver
|
||||||
|
0008 ns: announce child frame completion candidates for VoiceOver
|
||||||
|
0009 Performance: precomputed line index for O(log L) line queries
|
||||||
|
|
||||||
|
|
||||||
|
OVERVIEW
|
||||||
|
--------
|
||||||
|
|
||||||
|
This patch adds comprehensive macOS VoiceOver accessibility support
|
||||||
|
to the Emacs NS (Cocoa) port. Before this patch, Emacs exposed only
|
||||||
|
a minimal, largely broken accessibility interface to macOS assistive
|
||||||
|
technology (AT) clients: EmacsView identified itself as a generic
|
||||||
|
NSAccessibilityGroup with no text content, no cursor tracking, and
|
||||||
|
no notifications. VoiceOver users could activate the application
|
||||||
|
but received no meaningful speech feedback when editing text.
|
||||||
|
|
||||||
|
The patch introduces a layered virtual element tree above EmacsView.
|
||||||
|
Each visible Emacs window is represented by an EmacsAccessibilityBuffer
|
||||||
|
element (AXTextArea / AXTextField for minibuffer) with a full text
|
||||||
|
cache, a visible-run mapping table that bridges buffer character
|
||||||
|
positions to UTF-16 accessibility string indices, and an interactive
|
||||||
|
span child array for Tab navigation. A companion
|
||||||
|
EmacsAccessibilityModeLine element (AXStaticText) represents the mode
|
||||||
|
line of each window. These virtual elements are wired into the macOS
|
||||||
|
Accessibility API through EmacsView acting as the AXGroup root.
|
||||||
|
|
||||||
|
Two additional integration points are provided: (1) macOS Zoom is
|
||||||
|
informed of the cursor position after every physical cursor redraw via
|
||||||
|
UAZoomChangeFocus(), using the correct CoreGraphics (top-left-origin)
|
||||||
|
coordinate space; (2) EmacsView implements accessibilityBoundsForRange:
|
||||||
|
and its legacy parameterized-attribute equivalent so that both Zoom
|
||||||
|
and third-party AT tools can locate the insertion point. The patch
|
||||||
|
also covers completion announcements for the *Completions* buffer and
|
||||||
|
Tab-navigable interactive spans for buttons, links, checkboxes,
|
||||||
|
Org-mode links, completion candidates, and keymap overlays.
|
||||||
|
|
||||||
|
|
||||||
|
ARCHITECTURE
|
||||||
|
------------
|
||||||
|
|
||||||
|
Class hierarchy (Cocoa only):
|
||||||
|
|
||||||
|
NSAccessibilityElement
|
||||||
|
|
|
||||||
|
+-- EmacsAccessibilityElement (base: owns emacsView + lispWindow)
|
||||||
|
|
|
||||||
|
+-- EmacsAccessibilityBuffer (AXTextArea; one per leaf window)
|
||||||
|
| [category InteractiveSpans] (Tab nav children)
|
||||||
|
|
|
||||||
|
+-- EmacsAccessibilityModeLine (AXStaticText; one per non-mini)
|
||||||
|
|
|
||||||
|
+-- EmacsAccessibilityInteractiveSpan (AXButton/Link/etc.)
|
||||||
|
|
||||||
|
EmacsView (NSView subclass, existing)
|
||||||
|
|
|
||||||
|
+-- owns NSMutableArray *accessibilityElements
|
||||||
|
contains EmacsAccessibilityBuffer + EmacsAccessibilityModeLine
|
||||||
|
instances for every visible leaf window and minibuffer.
|
||||||
|
EmacsAccessibilityInteractiveSpan instances are children of
|
||||||
|
their parent EmacsAccessibilityBuffer, NOT of this array.
|
||||||
|
|
||||||
|
EmacsAccessibilityElement (base class)
|
||||||
|
- Stores a weak (unsafe_unretained) pointer to EmacsView and a
|
||||||
|
Lisp_Object lispWindow (GC-safe window reference).
|
||||||
|
- Provides -validWindow which verifies WINDOW_LIVE_P before
|
||||||
|
returning the raw struct window *. All subclasses use this to
|
||||||
|
avoid dangling pointers after delete-window or kill-buffer.
|
||||||
|
- Provides -screenRectFromEmacsX:y:width:height: which converts
|
||||||
|
EmacsView pixel coordinates (flipped AppKit space) to screen
|
||||||
|
coordinates via the NSWindow coordinate chain.
|
||||||
|
|
||||||
|
EmacsAccessibilityBuffer
|
||||||
|
- Implements the full NSAccessibility text protocol: value, selected
|
||||||
|
text range, line/index/range conversions, frame-for-range,
|
||||||
|
range-for-position, and insertion-point-line-number.
|
||||||
|
- Maintains a text cache (cachedText / visibleRuns) keyed on
|
||||||
|
BUF_MODIFF and BUF_BEGV (narrowing). BUF_OVERLAY_MODIFF is
|
||||||
|
tracked separately for notification dispatch (patch 0007)
|
||||||
|
but not for cache invalidation.
|
||||||
|
The cache is the single source of truth for all
|
||||||
|
index-to-charpos and charpos-to-index mappings.
|
||||||
|
- Detects buffer edits (modiff change), cursor movement (point
|
||||||
|
change), and mark changes, and posts the appropriate
|
||||||
|
NSAccessibility notifications after each redisplay cycle.
|
||||||
|
- Stores cached values for the previous cycle (cachedModiff,
|
||||||
|
cachedPoint, cachedMarkActive) to enable change detection.
|
||||||
|
|
||||||
|
EmacsAccessibilityModeLine
|
||||||
|
- Reads mode line text directly from the window's current glyph
|
||||||
|
matrix (CHAR_GLYPH rows with mode_line_p set).
|
||||||
|
- Stateless: no cache; text is read fresh on every AX query.
|
||||||
|
|
||||||
|
EmacsAccessibilityInteractiveSpan
|
||||||
|
- Lightweight child element representing one contiguous interactive
|
||||||
|
region (button, link, completion item, etc.).
|
||||||
|
- Reports isAccessibilityFocused by comparing cachedPoint of the
|
||||||
|
parent EmacsAccessibilityBuffer against its charpos range.
|
||||||
|
- On setAccessibilityFocused: dispatches to the main queue via
|
||||||
|
GCD to move Emacs point, using block_input around SET_PT_BOTH.
|
||||||
|
|
||||||
|
EmacsView (extensions)
|
||||||
|
- accessibilityElements array: rebuilt by -rebuildAccessibilityTree
|
||||||
|
when the window tree changes (split, delete, new buffer).
|
||||||
|
- -postAccessibilityUpdates: called from ns_update_end() after
|
||||||
|
every redisplay cycle; drives the notification dispatch loop.
|
||||||
|
- lastAccessibilityCursorRect: updated by ns_draw_phys_cursor
|
||||||
|
(C function) for Zoom integration.
|
||||||
|
- Implements accessibilityBoundsForRange: /
|
||||||
|
accessibilityFrameForRange: and the legacy
|
||||||
|
accessibilityAttributeValue:forParameter: API.
|
||||||
|
|
||||||
|
|
||||||
|
USER OPTION
|
||||||
|
-----------
|
||||||
|
|
||||||
|
ns-accessibility-enabled (DEFVAR_BOOL, default t):
|
||||||
|
When nil, the accessibility virtual element tree is not built, no
|
||||||
|
notifications are posted, and ns_draw_phys_cursor skips the Zoom
|
||||||
|
update. This eliminates accessibility overhead entirely on systems
|
||||||
|
where assistive technology is not in use. Guarded at three entry
|
||||||
|
points: postAccessibilityUpdates, ns_draw_phys_cursor, and
|
||||||
|
windowDidBecomeKey.
|
||||||
|
|
||||||
|
|
||||||
|
THREADING MODEL
|
||||||
|
---------------
|
||||||
|
|
||||||
|
Emacs runs all Lisp evaluation and buffer mutation on the main thread
|
||||||
|
(the Cocoa/AppKit main thread). The macOS Accessibility server
|
||||||
|
(axserver / AT daemon) calls AX getters from a private background
|
||||||
|
thread.
|
||||||
|
|
||||||
|
Rules enforced by this patch:
|
||||||
|
|
||||||
|
Main thread only:
|
||||||
|
- ns_update_end -> postAccessibilityUpdates
|
||||||
|
- rebuildAccessibilityTree / invalidateAccessibilityTree
|
||||||
|
- ensureTextCache / ns_ax_buffer_text (Lisp calls:
|
||||||
|
Fget_char_property, Fnext_single_char_property_change,
|
||||||
|
Fbuffer_substring_no_properties)
|
||||||
|
- postAccessibilityNotificationsForFrame: (full notify logic)
|
||||||
|
- setAccessibilitySelectedTextRange: (SET_PT_BOTH, marker moves)
|
||||||
|
- setAccessibilityFocused: on EmacsAccessibilityInteractiveSpan
|
||||||
|
(dispatches to main queue via dispatch_async; uses specpdl
|
||||||
|
unwind protection so block_input is always matched by
|
||||||
|
unblock_input even if Fselect_window signals an error)
|
||||||
|
- ns_draw_phys_cursor partial update (lastAccessibilityCursorRect,
|
||||||
|
UAZoomChangeFocus)
|
||||||
|
|
||||||
|
Safe from any thread (no Lisp calls, no mutable Emacs state):
|
||||||
|
- accessibilityIndexForCharpos: reads visibleRuns + cachedText
|
||||||
|
- charposForAccessibilityIndex: same
|
||||||
|
- isAccessibilityFocused on EmacsAccessibilityInteractiveSpan
|
||||||
|
(reads cachedPoint, a plain ptrdiff_t)
|
||||||
|
|
||||||
|
Dispatch-gated (marshalled to main thread when called off-thread):
|
||||||
|
- accessibilityValue (EmacsAccessibilityBuffer)
|
||||||
|
- accessibilitySelectedTextRange
|
||||||
|
- accessibilityInsertionPointLineNumber
|
||||||
|
- accessibilityFrameForRange:
|
||||||
|
- accessibilityRangeForPosition:
|
||||||
|
- accessibilityChildrenInNavigationOrder
|
||||||
|
|
||||||
|
The marshalling pattern used throughout:
|
||||||
|
|
||||||
|
if (![NSThread isMainThread]) {
|
||||||
|
__block T result;
|
||||||
|
dispatch_sync(dispatch_get_main_queue(), ^{ result = ...; });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Async notification posting (deadlock prevention):
|
||||||
|
|
||||||
|
NSAccessibilityPostNotification may synchronously invoke VoiceOver
|
||||||
|
callbacks from a private AX server thread. Those callbacks call
|
||||||
|
AX getters which dispatch_sync back to the main queue. If the
|
||||||
|
main thread is still inside the notification-posting method (e.g.,
|
||||||
|
postAccessibilityUpdates called from ns_update_end), the
|
||||||
|
dispatch_sync deadlocks: the main thread waits for VoiceOver to
|
||||||
|
finish processing the notification, while VoiceOver's thread waits
|
||||||
|
for the main queue to become available.
|
||||||
|
|
||||||
|
To break this cycle, all notification posting goes through two
|
||||||
|
static inline wrappers:
|
||||||
|
|
||||||
|
ns_ax_post_notification(element, name)
|
||||||
|
ns_ax_post_notification_with_info(element, name, info)
|
||||||
|
|
||||||
|
These wrappers defer the actual NSAccessibilityPostNotification
|
||||||
|
call via dispatch_async(dispatch_get_main_queue(), ^{ ... }).
|
||||||
|
The current method returns first, freeing the main queue, so
|
||||||
|
VoiceOver's dispatch_sync calls can proceed without deadlock.
|
||||||
|
Block captures retain ObjC objects (element, info dictionary)
|
||||||
|
for the lifetime of the deferred block.
|
||||||
|
|
||||||
|
Cached data written on main thread and read from any thread:
|
||||||
|
- cachedText (NSString *): written by ensureTextCache on main.
|
||||||
|
- visibleRuns (ns_ax_visible_run *): written by ensureTextCache.
|
||||||
|
- cachedPoint (ptrdiff_t): plain scalar; atomic on 64-bit ARM/x86.
|
||||||
|
No explicit lock is used; the design relies on the fact that index
|
||||||
|
mapping methods make no Lisp calls and read only the above scalars
|
||||||
|
and the immutable NSString object.
|
||||||
|
|
||||||
|
|
||||||
|
NOTIFICATION STRATEGY
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
All notifications are posted asynchronously via
|
||||||
|
ns_ax_post_notification / ns_ax_post_notification_with_info
|
||||||
|
(dispatch_async wrappers -- see THREADING MODEL for rationale).
|
||||||
|
|
||||||
|
Notifications are generated by -postAccessibilityNotificationsForFrame:
|
||||||
|
which runs on the main thread after every redisplay cycle. The
|
||||||
|
method detects three mutually exclusive events:
|
||||||
|
|
||||||
|
1. TEXT CHANGED (modiff != cachedModiff)
|
||||||
|
Posts NSAccessibilityValueChangedNotification with AXTextEditType
|
||||||
|
= Typing and, when exactly one character was inserted, provides
|
||||||
|
AXTextChangeValue for echo feedback. cachedPoint is updated here
|
||||||
|
to suppress a spurious selection-move event in the same cycle
|
||||||
|
(WebKit/Chromium convention: edit and selection-move are mutually
|
||||||
|
exclusive per runloop iteration).
|
||||||
|
|
||||||
|
2. CURSOR MOVED OR MARK CHANGED (point != cachedPoint OR mark change)
|
||||||
|
Granularity is computed by comparing oldIdx and newIdx in
|
||||||
|
cachedText:
|
||||||
|
- different line range -> LINE granularity
|
||||||
|
- same line, distance > 1 UTF-16 unit -> WORD granularity
|
||||||
|
- same line, distance == 1 UTF-16 unit -> CHARACTER granularity
|
||||||
|
C-n / C-p / Tab / backtab force LINE granularity
|
||||||
|
(detected by ns_ax_event_is_line_nav_key which inspects
|
||||||
|
last_command_event) regardless.
|
||||||
|
|
||||||
|
For FOCUSED elements the hybrid strategy applies:
|
||||||
|
|
||||||
|
CHARACTER moves:
|
||||||
|
SelectedTextChanged is posted WITHOUT AXTextSelectionGranularity
|
||||||
|
in userInfo. Omitting the key prevents VoiceOver from deriving
|
||||||
|
its own speech (it would read the character BEFORE point,
|
||||||
|
which is wrong for evil block-cursor mode where the cursor
|
||||||
|
sits ON the character). Then AnnouncementRequested is posted
|
||||||
|
separately with the character AT point as the announcement.
|
||||||
|
Newline is skipped (VoiceOver handles end-of-line internally).
|
||||||
|
|
||||||
|
WORD and LINE moves:
|
||||||
|
SelectedTextChanged is posted WITH AXTextSelectionGranularity.
|
||||||
|
VoiceOver reads the word/line correctly from the element text
|
||||||
|
using the granularity hint. For LINE moves an additional
|
||||||
|
AnnouncementRequested is also posted with the line text (or
|
||||||
|
the completion--string at point if in a completion buffer) to
|
||||||
|
handle C-n/C-p -- VoiceOver processes these keystrokes
|
||||||
|
differently from arrow keys internally.
|
||||||
|
|
||||||
|
SELECTION changes (mark becomes active or extends):
|
||||||
|
SelectedTextChanged with LINE or WORD granularity. VoiceOver
|
||||||
|
reads the newly selected or deselected text.
|
||||||
|
|
||||||
|
For NON-FOCUSED elements (e.g. *Completions* while minibuffer has
|
||||||
|
focus): AnnouncementRequested only. See COMPLETION ANNOUNCEMENTS.
|
||||||
|
|
||||||
|
3. NO CHANGE
|
||||||
|
Nothing is posted. Completion cache is cleared for focused buffer.
|
||||||
|
|
||||||
|
|
||||||
|
TEXT CACHE AND VISIBLE RUNS
|
||||||
|
----------------------------
|
||||||
|
|
||||||
|
ns_ax_buffer_text(w, out_start, out_runs, out_nruns) builds the
|
||||||
|
accessibility string for window W. It operates on the current
|
||||||
|
buffer with set_buffer_internal_1, scanning from BUF_BEGV to BUF_ZV.
|
||||||
|
|
||||||
|
Invisible text detection uses TEXT_PROP_MEANS_INVISIBLE(invis) where
|
||||||
|
invis = Fget_char_property(pos, Qinvisible, Qnil). This respects
|
||||||
|
buffer-invisibility-spec, correctly handling org-mode folding,
|
||||||
|
outline mode, and hideshow -- not just `invisible t' text properties.
|
||||||
|
When an invisible region is found, the scanner jumps ahead using
|
||||||
|
Fnext_single_char_property_change to skip the entire region in O(1)
|
||||||
|
iterations rather than character by character.
|
||||||
|
|
||||||
|
Text extraction uses Fbuffer_substring_no_properties (not raw
|
||||||
|
BUF_BYTE_ADDRESS) to handle the buffer gap correctly. Raw byte
|
||||||
|
access across the gap position yields garbage bytes.
|
||||||
|
|
||||||
|
The ns_ax_visible_run structure:
|
||||||
|
|
||||||
|
typedef struct ns_ax_visible_run {
|
||||||
|
ptrdiff_t charpos; /* Buffer charpos of run start. */
|
||||||
|
ptrdiff_t length; /* Emacs characters in this run. */
|
||||||
|
NSUInteger ax_start; /* UTF-16 index in accessibility string. */
|
||||||
|
NSUInteger ax_length; /* UTF-16 units for this run. */
|
||||||
|
} ns_ax_visible_run;
|
||||||
|
|
||||||
|
Multiple runs are produced when invisible text splits the buffer into
|
||||||
|
non-contiguous visible segments. The mapping array is stored in the
|
||||||
|
EmacsAccessibilityBuffer ivar `visibleRuns' (C array, xmalloc'd).
|
||||||
|
|
||||||
|
Index mapping (charpos <-> ax_index) uses binary search over the
|
||||||
|
sorted run array — O(log n) per lookup. Within a run, UTF-16 unit
|
||||||
|
counting uses
|
||||||
|
rangeOfComposedCharacterSequenceAtIndex: to handle surrogate pairs
|
||||||
|
(emoji, rare CJK) correctly -- one Emacs character may occupy 2
|
||||||
|
UTF-16 units.
|
||||||
|
|
||||||
|
Cache invalidation is triggered whenever BUF_MODIFF or
|
||||||
|
BUF_OVERLAY_MODIFF changes (ensureTextCache compares both
|
||||||
|
cachedTextModiff and cachedOverlayModiff). Additionally,
|
||||||
|
narrowing/widening is detected by comparing cachedTextStart
|
||||||
|
against BUF_BEGV — these operations change the visible region
|
||||||
|
without bumping either modiff counter. The cache is also
|
||||||
|
invalidated when the window tree is rebuilt.
|
||||||
|
|
||||||
|
There is no character cap on the accessibility text. The entire
|
||||||
|
visible (non-invisible) buffer content is exposed to VoiceOver.
|
||||||
|
Users who do not need accessibility can set ns-accessibility-enabled
|
||||||
|
to nil for zero overhead.
|
||||||
|
|
||||||
|
A lineStartOffsets array is built during each cache rebuild,
|
||||||
|
recording the AX string index where each line begins. This
|
||||||
|
makes accessibilityLineForIndex: and accessibilityRangeForLine:
|
||||||
|
O(log L) via binary search instead of O(L) linear scanning.
|
||||||
|
The index is freed and rebuilt alongside the text cache.
|
||||||
|
|
||||||
|
|
||||||
|
COMPLETION ANNOUNCEMENTS
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
When point moves in a non-focused buffer (the common case:
|
||||||
|
*Completions* window while the minibuffer retains keyboard focus),
|
||||||
|
VoiceOver does not automatically read the change because it is
|
||||||
|
tracking the focused element. The patch posts AnnouncementRequested
|
||||||
|
with a 4-step fallback chain to find the best text to announce:
|
||||||
|
|
||||||
|
Step 1 -- completion--string property at point.
|
||||||
|
The `completion--string' text property (set by minibuffer.el
|
||||||
|
since Emacs 29) carries the canonical completion candidate string.
|
||||||
|
It can be a plain Lisp string or a list (CANDIDATE ANNOTATION) where both
|
||||||
|
are strings.
|
||||||
|
ns_ax_completion_string_from_prop handles both: plain string ->
|
||||||
|
use directly; cons -> use car (the candidate without annotation).
|
||||||
|
This is the preferred source: precisely the candidate text with
|
||||||
|
no surrounding whitespace.
|
||||||
|
|
||||||
|
Step 2 -- mouse-face span at point.
|
||||||
|
completion-list-mode marks the active candidate with mouse-face.
|
||||||
|
The code walks backward and forward from point to find the span
|
||||||
|
boundaries, then reads the corresponding slice of cachedText.
|
||||||
|
Used when completion--string is absent (older Emacs or non-
|
||||||
|
standard completion modes).
|
||||||
|
|
||||||
|
Step 3 -- completions-highlight overlay at point.
|
||||||
|
Emacs 29+ highlights the selected completion with the
|
||||||
|
`completions-highlight' face applied via an overlay. The overlay
|
||||||
|
text is extracted via ns_ax_completion_text_for_span which itself
|
||||||
|
tries completion--string first, then the `completion' property,
|
||||||
|
then falls back to the ax string slice.
|
||||||
|
|
||||||
|
Step 4 -- nearest completions-highlight overlay.
|
||||||
|
ns_ax_find_completion_overlay_range scans the buffer for the
|
||||||
|
closest completions-highlight overlay to point. Uses fast probes
|
||||||
|
at {point, point+1, point-1} before falling back to a full O(n)
|
||||||
|
scan.
|
||||||
|
|
||||||
|
Final fallback -- current line text.
|
||||||
|
Read the line containing point from cachedText.
|
||||||
|
|
||||||
|
Deduplication: the announcement is posted only when announceText,
|
||||||
|
overlay bounds, or point have changed since the last cycle
|
||||||
|
(cachedCompletionAnnouncement, cachedCompletionOverlayStart/End,
|
||||||
|
cachedCompletionPoint).
|
||||||
|
|
||||||
|
|
||||||
|
INTERACTIVE SPANS
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
ns_ax_scan_interactive_spans(w, parent_buf) scans the visible range
|
||||||
|
of window W looking for text properties that indicate interactive
|
||||||
|
content. Properties are checked in priority order:
|
||||||
|
|
||||||
|
widget -> EmacsAXSpanTypeWidget (AXButton, via default)
|
||||||
|
button -> EmacsAXSpanTypeButton (AXButton, via default)
|
||||||
|
follow-link -> EmacsAXSpanTypeLink (AXLink)
|
||||||
|
org-link -> EmacsAXSpanTypeLink (AXLink)
|
||||||
|
mouse-face -> EmacsAXSpanTypeCompletionItem
|
||||||
|
(AXButton; completion-list-mode only)
|
||||||
|
keymap overlay-> EmacsAXSpanTypeButton (AXButton)
|
||||||
|
|
||||||
|
For completion buffers (major-mode == completion-list-mode), the span
|
||||||
|
boundary for mouse-face regions uses completion--string as the property
|
||||||
|
key when present, rather than mouse-face itself. This prevents two
|
||||||
|
column-adjacent completion candidates from being merged into one span
|
||||||
|
when their mouse-face regions share padding whitespace.
|
||||||
|
|
||||||
|
All property symbols are registered with DEFSYM in syms_of_nsterm
|
||||||
|
using ns_ax_ prefixed C variable names (e.g., Qns_ax_button for
|
||||||
|
"button") to avoid collisions with other Emacs source files.
|
||||||
|
Referenced directly -- no repeated intern() calls.
|
||||||
|
|
||||||
|
Each span is allocated, configured, added to the spans array, then
|
||||||
|
released (the array retains it). The function returns an autoreleased
|
||||||
|
immutable copy of the spans array. Label priority:
|
||||||
|
completion--string > buffer substring > help-echo.
|
||||||
|
|
||||||
|
Tab navigation: -accessibilityChildrenInNavigationOrder returns the
|
||||||
|
cached span array, rebuilt lazily when interactiveSpansDirty is set.
|
||||||
|
Calls from off-thread are marshalled with dispatch_sync.
|
||||||
|
|
||||||
|
Focus movement: -setAccessibilityFocused: on a span dispatches
|
||||||
|
Fselect_window + SET_PT_BOTH to the main queue via dispatch_async,
|
||||||
|
wrapped in block_input/unblock_input.
|
||||||
|
|
||||||
|
|
||||||
|
ZOOM INTEGRATION
|
||||||
|
----------------
|
||||||
|
|
||||||
|
macOS Zoom (accessibility zoom) tracks a "focus element" to keep the
|
||||||
|
zoomed viewport centered on the relevant screen area. Two mechanisms
|
||||||
|
are provided:
|
||||||
|
|
||||||
|
1. ns_draw_phys_cursor (C function, main thread, called during
|
||||||
|
redisplay). After clipping the cursor rect to the text area,
|
||||||
|
stores the rect in view->lastAccessibilityCursorRect. If
|
||||||
|
UAZoomEnabled(), converts the rect to screen coordinates and calls
|
||||||
|
UAZoomChangeFocus(kUAZoomFocusTypeInsertionPoint).
|
||||||
|
|
||||||
|
Coordinate conversion chain:
|
||||||
|
EmacsView pixels (AppKit, flipped, origin at top-left of view)
|
||||||
|
-[convertRect:toView:nil]-> NSWindow coordinates
|
||||||
|
-[convertRectToScreen:]-> NSScreen coordinates
|
||||||
|
NSRectToCGRect -> CGRect (same values, no transform)
|
||||||
|
CG y-flip: cgRect.origin.y = primaryH - y - height
|
||||||
|
The flip is required because CoreGraphics uses top-left origin
|
||||||
|
(primary screen) while AppKit screen rects use bottom-left.
|
||||||
|
primaryH = [[NSScreen screens] firstObject].frame.size.height.
|
||||||
|
|
||||||
|
2. EmacsView -accessibilityBoundsForRange: /
|
||||||
|
-accessibilityFrameForRange:
|
||||||
|
AT tools (including Zoom) call these with the selectedTextRange
|
||||||
|
to locate the insertion point. The implementation first delegates
|
||||||
|
to the focused EmacsAccessibilityBuffer element for accurate
|
||||||
|
per-range geometry via its accessibilityFrameForRange: method.
|
||||||
|
If the buffer element returns an empty rect (no valid window or
|
||||||
|
glyph data), the fallback uses the cached cursor rect stored in
|
||||||
|
lastAccessibilityCursorRect (minimum size 1x8 pixels). The legacy
|
||||||
|
parameterized-attribute API
|
||||||
|
(NSAccessibilityBoundsForRangeParameterizedAttribute) is supported
|
||||||
|
via -accessibilityAttributeValue:forParameter: for older AT
|
||||||
|
clients.
|
||||||
|
|
||||||
|
|
||||||
|
KEY DESIGN DECISIONS
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
1. DEFSYM instead of intern for all frequently-used symbols.
|
||||||
|
DEFSYM registers symbols at startup (syms_of_nsterm) and stores
|
||||||
|
them in C globals (e.g. Qns_ax_completion__string, Qns_ax_next_line).
|
||||||
|
This covers both property scanning symbols and line navigation
|
||||||
|
command symbols used in ns_ax_event_is_line_nav_key (hot path:
|
||||||
|
runs on every cursor movement). Using intern() would perform
|
||||||
|
obarray lookups on each redisplay cycle. DEFSYM symbols are
|
||||||
|
also always reachable by the GC via staticpro, eliminating any
|
||||||
|
risk of premature collection.
|
||||||
|
|
||||||
|
2. AnnouncementRequested for character moves, not SelectedTextChanged.
|
||||||
|
VoiceOver derives the speech character from SelectedTextChanged by
|
||||||
|
looking at the character BEFORE the new cursor position (the char
|
||||||
|
"passed over"). In evil-mode with a block cursor, the cursor sits
|
||||||
|
ON the character, not between characters. AnnouncementRequested
|
||||||
|
with the character AT point produces correct speech in both insert
|
||||||
|
and normal (block-cursor) modes. SelectedTextChanged is still
|
||||||
|
posted without granularity to interrupt ongoing VoiceOver reading
|
||||||
|
and update braille display tracking.
|
||||||
|
|
||||||
|
3. completion--string, not mouse-face, as span boundary.
|
||||||
|
mouse-face regions in completion-list-mode sometimes include
|
||||||
|
leading or trailing whitespace shared between column-adjacent
|
||||||
|
candidates, which could merge two candidates into one span.
|
||||||
|
completion--string changes precisely at candidate boundaries.
|
||||||
|
|
||||||
|
4. Probe order {point, point+1, point-1} for overlay search.
|
||||||
|
After Tab advances to a new completion candidate, point is at the
|
||||||
|
START of the new entry. The previous entry's overlay covers the
|
||||||
|
position before the new start, so point-1 is inside the OLD
|
||||||
|
overlay. Trying point+1 before point-1 finds the new (correct)
|
||||||
|
entry first.
|
||||||
|
|
||||||
|
5. Notifications posted BEFORE rebuilding the tree.
|
||||||
|
postAccessibilityUpdates uses existing elements which carry cached
|
||||||
|
state from the previous cycle. Rebuilding first would create
|
||||||
|
fresh elements with current values, making change detection
|
||||||
|
impossible. Tree rebuild is deferred to cycles where
|
||||||
|
accessibilityTreeValid is false; no notifications are posted in
|
||||||
|
that cycle.
|
||||||
|
|
||||||
|
6. Re-entrance guard (accessibilityUpdating flag).
|
||||||
|
VoiceOver callbacks triggered by notification posting can cause
|
||||||
|
Cocoa to re-enter the run loop, which may trigger redisplay, which
|
||||||
|
calls ns_update_end -> postAccessibilityUpdates. The BOOL flag
|
||||||
|
breaks this recursion.
|
||||||
|
|
||||||
|
6a. Async notification posting (dispatch_async wrappers).
|
||||||
|
NSAccessibilityPostNotification can synchronously trigger
|
||||||
|
VoiceOver queries from a background AX server thread. Those
|
||||||
|
queries dispatch_sync to the main queue. If the main thread
|
||||||
|
is still inside postAccessibilityUpdates (or windowDidBecomeKey,
|
||||||
|
or setAccessibilityFocused:), the dispatch_sync deadlocks.
|
||||||
|
All 14 notification sites use ns_ax_post_notification / _with_info
|
||||||
|
wrappers that defer posting via dispatch_async, freeing the main
|
||||||
|
queue before VoiceOver's callbacks arrive. This follows the same
|
||||||
|
pattern used by WebKit's AXObjectCacheMac (deferred posting via
|
||||||
|
performSelector:withObject:afterDelay:0).
|
||||||
|
|
||||||
|
7. lispWindow (Lisp_Object) instead of raw struct window *.
|
||||||
|
struct window pointers can become dangling after delete-window.
|
||||||
|
Storing the Lisp_Object and using WINDOW_LIVE_P + XWINDOW at the
|
||||||
|
call site is the standard safe pattern in Emacs C code.
|
||||||
|
|
||||||
|
8. accessibilityVisibleCharacterRange returns full buffer range.
|
||||||
|
VoiceOver treats the visible range boundary as end-of-text. If
|
||||||
|
this returned only the on-screen portion, VoiceOver would announce
|
||||||
|
"end of text" prematurely when the cursor reaches the visible
|
||||||
|
bottom, even though more buffer content exists below.
|
||||||
|
|
||||||
|
|
||||||
|
OVERLAY COMPLETION ANNOUNCEMENTS (Patch 0007)
|
||||||
|
----------------------------------------------
|
||||||
|
|
||||||
|
Overlay-based completion frameworks (Vertico, Icomplete, Ivy, etc.)
|
||||||
|
render candidates as overlay strings in the minibuffer. VoiceOver
|
||||||
|
does not see overlay content changes automatically. This patch
|
||||||
|
detects overlay candidate changes and announces the selected
|
||||||
|
candidate.
|
||||||
|
|
||||||
|
Detection:
|
||||||
|
ns_ax_face_is_selected(face) checks whether a face name contains
|
||||||
|
"current", "selected", or "selection" (matching vertico-current,
|
||||||
|
icomplete-selected-match, ivy-current-match, etc.). Supports
|
||||||
|
both single face symbols and face lists.
|
||||||
|
|
||||||
|
ns_ax_selected_overlay_text(b, beg, end, out_line) scans the
|
||||||
|
buffer region line by line using Fget_char_property to check
|
||||||
|
both text properties and overlay face properties.
|
||||||
|
|
||||||
|
Overlay changes are tracked independently of text changes:
|
||||||
|
BUF_OVERLAY_MODIFF is checked in an independent if-branch (not
|
||||||
|
else-if) because Vertico bumps both BUF_MODIFF (text properties)
|
||||||
|
and BUF_OVERLAY_MODIFF (overlays) in the same command cycle.
|
||||||
|
|
||||||
|
textDidChange flag:
|
||||||
|
hl-line-mode and similar packages update face properties (text
|
||||||
|
properties, not characters) on every cursor movement, bumping
|
||||||
|
BUF_MODIFF without changing BUF_CHARS_MODIFF. The original
|
||||||
|
else-if structure caused the modiff branch to fire (correctly
|
||||||
|
skipping ValueChanged) but also blocked the cursor-move branch
|
||||||
|
(SelectedTextChanged). A BOOL textDidChange flag decouples the
|
||||||
|
two branches: ValueChanged and SelectedTextChanged remain
|
||||||
|
mutually exclusive for real edits, but SelectedTextChanged fires
|
||||||
|
correctly when only text properties changed.
|
||||||
|
|
||||||
|
Zoom:
|
||||||
|
The selected candidate position is stored in overlayZoomRect /
|
||||||
|
overlayZoomActive on the parent EmacsView. draw_window_cursor
|
||||||
|
uses this rect instead of the text cursor when a candidate is
|
||||||
|
active. Cleared when BUF_CHARS_MODIFF changes (user types)
|
||||||
|
or when no candidate is found.
|
||||||
|
|
||||||
|
|
||||||
|
CHILD FRAME COMPLETION ANNOUNCEMENTS (Patch 0008)
|
||||||
|
--------------------------------------------------
|
||||||
|
|
||||||
|
Completion frameworks such as Corfu, Company-box, and similar render
|
||||||
|
candidates in a child frame rather than as overlay strings. This
|
||||||
|
patch detects child frames via FRAME_PARENT_FRAME and announces
|
||||||
|
the selected candidate.
|
||||||
|
|
||||||
|
Detection:
|
||||||
|
Child frames are dispatched in postAccessibilityUpdates before
|
||||||
|
the main tree rebuild logic. FRAME_PARENT_FRAME(emacsframe)
|
||||||
|
returns non-NULL for child frames.
|
||||||
|
|
||||||
|
ns_ax_selected_child_frame_text(b, buf_obj, out_line) scans the
|
||||||
|
child frame buffer line by line, reusing ns_ax_face_is_selected
|
||||||
|
from patch 0007.
|
||||||
|
|
||||||
|
Buffer switch safety:
|
||||||
|
Fbuffer_substring_no_properties operates on current_buffer, which
|
||||||
|
may differ from the child frame buffer during ns_update_end.
|
||||||
|
The function uses record_unwind_current_buffer /
|
||||||
|
set_buffer_internal_1 to temporarily switch, with unbind_to on
|
||||||
|
all three return paths after the switch. Uses specpdl_ref (not
|
||||||
|
ptrdiff_t) for the SPECPDL_INDEX return value.
|
||||||
|
|
||||||
|
Re-entrance protection:
|
||||||
|
The accessibilityUpdating guard MUST precede the child frame
|
||||||
|
dispatch because Lisp calls in the scan function (Fget_char_property,
|
||||||
|
Fbuffer_substring_no_properties) can trigger redisplay.
|
||||||
|
BUF_MODIFF gating provides a secondary guard and prevents
|
||||||
|
redundant scans.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- WINDOWP / BUFFERP checks for partially initialized child frames.
|
||||||
|
- Buffer size limit (10000 chars) skips non-completion child frames
|
||||||
|
(eldoc, which-key, etc.).
|
||||||
|
|
||||||
|
Focus restoration:
|
||||||
|
childFrameCompletionActive (BOOL on EmacsView) is set by the child
|
||||||
|
frame handler on the parent view. On the parent's next accessibility
|
||||||
|
cycle, FOR_EACH_FRAME checks whether any child frame is still
|
||||||
|
visible. If not, FocusedUIElementChangedNotification is posted on
|
||||||
|
the focused buffer element to restore VoiceOver character echo and
|
||||||
|
cursor tracking.
|
||||||
|
|
||||||
|
Zoom:
|
||||||
|
Direct UAZoomChangeFocus (not overlayZoomRect) because the child
|
||||||
|
frame's ns_update_end runs after the parent's draw_window_cursor,
|
||||||
|
so the last Zoom call wins.
|
||||||
|
|
||||||
|
Deduplication:
|
||||||
|
Static C string cache (lastCandidate via xstrdup/xfree) avoids
|
||||||
|
re-announcing the same candidate.
|
||||||
|
|
||||||
|
|
||||||
|
KNOWN LIMITATIONS
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
- Interactive span scan uses Fnext_single_property_change across
|
||||||
|
multiple properties to skip non-interactive regions in bulk, but
|
||||||
|
still visits every property-change boundary. For buffers with
|
||||||
|
many overlapping text properties (e.g. heavily fontified source
|
||||||
|
code), the number of boundaries can be significant. The scan
|
||||||
|
runs on every redisplay cycle when interactiveSpansDirty is set.
|
||||||
|
|
||||||
|
- Mode line text is extracted from CHAR_GLYPH rows only. Image
|
||||||
|
glyphs, stretch glyphs, and composed glyphs are silently skipped.
|
||||||
|
Mode lines with icon fonts (e.g. doom-modeline with nerd-font)
|
||||||
|
produce incomplete or garbled accessibility text.
|
||||||
|
|
||||||
|
- Line counting (accessibilityInsertionPointLineNumber,
|
||||||
|
accessibilityLineForIndex:) uses a precomputed lineStartOffsets
|
||||||
|
array built once per cache rebuild. Queries are O(log L) via
|
||||||
|
binary search.
|
||||||
|
|
||||||
|
- No multi-frame coordination. EmacsView.accessibilityElements is
|
||||||
|
per-view; there is no cross-frame notification ordering.
|
||||||
|
|
||||||
|
- Overlay completion (0007) face matching uses string containment
|
||||||
|
("current", "selected", "selection"). Custom completion frameworks
|
||||||
|
with face names not containing these substrings will not be detected.
|
||||||
|
|
||||||
|
- Child frame completion (0008) static lastBuffer pointer may become
|
||||||
|
stale if the buffer is freed and a new one allocated at the same
|
||||||
|
address. This is harmless (worst case: one missed announcement).
|
||||||
|
|
||||||
|
- Child frame window-appeared announcement: macOS automatically
|
||||||
|
announces the window title when a child frame NSWindow appears.
|
||||||
|
This cannot be suppressed without breaking VoiceOver focus tracking
|
||||||
|
or Zoom integration.
|
||||||
|
|
||||||
|
- GNUstep is explicitly excluded (#ifdef NS_IMPL_COCOA). GNUstep
|
||||||
|
has a different accessibility model and requires separate work.
|
||||||
|
|
||||||
|
- Line navigation detection (ns_ax_event_is_line_nav_key) checks
|
||||||
|
Vthis_command against known navigation command symbols
|
||||||
|
(next-line, previous-line, evil-next-line, etc.) and falls back
|
||||||
|
to raw key codes for Tab/backtab. Custom navigation commands
|
||||||
|
not in the recognized list will not get forced line-granularity
|
||||||
|
announcements.
|
||||||
|
|
||||||
|
- UAZoomChangeFocus always uses kUAZoomFocusTypeInsertionPoint
|
||||||
|
regardless of cursor style (box, bar, hbar). This is cosmetically
|
||||||
|
imprecise but functionally correct.
|
||||||
|
|
||||||
|
|
||||||
|
TESTING CHECKLIST
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- macOS with VoiceOver (Cmd-F5 to toggle).
|
||||||
|
- Emacs built from source with this patch applied.
|
||||||
|
- Evil-mode recommended for block-cursor tests.
|
||||||
|
|
||||||
|
Basic text reading:
|
||||||
|
1. Open Emacs. Press Cmd-F5 to start VoiceOver.
|
||||||
|
2. Switch to Emacs (Cmd-Tab). VoiceOver should announce
|
||||||
|
"Emacs, editor" and read the current line.
|
||||||
|
3. Move cursor with arrow keys. VoiceOver should read each
|
||||||
|
character (left/right) or line (up/down) as you move.
|
||||||
|
4. Verify: right/left arrow reads the character AT the cursor
|
||||||
|
position, not the character left behind. (evil block-cursor)
|
||||||
|
|
||||||
|
Word and line navigation:
|
||||||
|
5. Press M-f / M-b (forward/backward word). VoiceOver should
|
||||||
|
announce the word landed on.
|
||||||
|
6. Press C-n / C-p. VoiceOver should read the full new line.
|
||||||
|
7. Hold Shift and press arrow keys to extend selection. VoiceOver
|
||||||
|
should announce the selected text.
|
||||||
|
|
||||||
|
Completion navigation:
|
||||||
|
8. Type M-x to open the minibuffer.
|
||||||
|
9. Type a partial command name. Press Tab to open *Completions*.
|
||||||
|
10. Press Tab / S-Tab to cycle through completions. VoiceOver
|
||||||
|
should announce each candidate name as you move.
|
||||||
|
11. Verify no double-speech (each candidate read exactly once).
|
||||||
|
|
||||||
|
Interactive span Tab navigation:
|
||||||
|
12. Open a buffer with buttons (e.g. M-x describe-key).
|
||||||
|
13. Use VoiceOver Item Chooser (VO-I) or Tab with VoiceOver
|
||||||
|
interaction mode to navigate interactive elements.
|
||||||
|
14. Verify each button/link is reachable and its label is read.
|
||||||
|
15. In an org-mode file with links, verify links appear as
|
||||||
|
separate navigable AXLink elements.
|
||||||
|
|
||||||
|
Mode line:
|
||||||
|
16. Use the VoiceOver cursor to navigate to the mode line below a
|
||||||
|
buffer. VoiceOver should read the mode line text.
|
||||||
|
|
||||||
|
Zoom integration:
|
||||||
|
17. Enable macOS Zoom (System Settings -> Accessibility -> Zoom).
|
||||||
|
18. Set Zoom to "Follow keyboard focus".
|
||||||
|
19. Move cursor in Emacs. Zoom viewport should track the cursor.
|
||||||
|
20. Verify Zoom follows the cursor across split windows.
|
||||||
|
|
||||||
|
Window operations:
|
||||||
|
21. Split window with C-x 2. VoiceOver should announce a layout
|
||||||
|
change. Switch with C-x o; VoiceOver should read the new
|
||||||
|
window content.
|
||||||
|
22. Delete a window with C-x 0. No crash should occur.
|
||||||
|
23. Switch buffers with C-x b. VoiceOver should read new buffer.
|
||||||
|
|
||||||
|
Deadlock regression (async notifications):
|
||||||
|
24. With VoiceOver on: M-x, type partial command, M-v to
|
||||||
|
*Completions*, Tab to a candidate, Enter to execute, then
|
||||||
|
C-x o to switch windows. Emacs must not hang.
|
||||||
|
|
||||||
|
Stress test (line index):
|
||||||
|
25. Open a large file (>50,000 lines). Navigate to the end with
|
||||||
|
M-> or C-v repeatedly. VoiceOver speech should remain fluid
|
||||||
|
at all positions (no progressive slowdown).
|
||||||
|
26. Open an org-mode file with many folded sections. Verify that
|
||||||
|
folded (invisible) text is not announced during navigation.
|
||||||
|
|
||||||
|
|
||||||
|
REVIEW CHANGES (post initial implementation)
|
||||||
|
---------------------------------------------
|
||||||
|
|
||||||
|
The following changes were made based on maintainer-style code review:
|
||||||
|
|
||||||
|
1. ns_ax_window_end_charpos: added window_end_valid guard. Falls
|
||||||
|
back to BUF_ZV when the window has not been fully redisplayed,
|
||||||
|
preventing stale data in AX getters called before next redisplay.
|
||||||
|
|
||||||
|
2. GC safety documentation: detailed comment on lispWindow ivar
|
||||||
|
explaining why staticpro is not needed (windows reachable from
|
||||||
|
frame tree, GC only on main thread, AX getters dispatch to main).
|
||||||
|
|
||||||
|
3. ns-accessibility-enabled (DEFVAR_BOOL): new user option to
|
||||||
|
disable accessibility entirely. Guards three entry points.
|
||||||
|
|
||||||
|
4. postAccessibilityNotificationsForFrame: extracted from one ~200
|
||||||
|
line method into four focused helpers:
|
||||||
|
- postTextChangedNotification: (typing echo)
|
||||||
|
- postFocusedCursorNotification:direction:granularity:markActive:
|
||||||
|
oldMarkActive: (focused cursor/selection)
|
||||||
|
- postCompletionAnnouncementForBuffer:point: (completions)
|
||||||
|
- postAccessibilityNotificationsForFrame: (orchestrator, ~60 lines)
|
||||||
|
|
||||||
|
5. ns_ax_completion_text_for_span: added block_input/unblock_input
|
||||||
|
with specpdl unwind protection for signal safety.
|
||||||
|
|
||||||
|
6. Fplist_get third-argument comment (PREDICATE, not default value).
|
||||||
|
|
||||||
|
7. Documentation: macos.texi section updated with
|
||||||
|
ns-accessibility-enabled variable reference. etc/NEWS updated.
|
||||||
|
|
||||||
|
|
||||||
|
-- end of README --
|
||||||
144
patches/TESTING.txt
Normal file
144
patches/TESTING.txt
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
VoiceOver Accessibility Patch Series — Testing Evidence
|
||||||
|
=======================================================
|
||||||
|
|
||||||
|
Tester: Martin Sukany
|
||||||
|
Date: 2026-02-28
|
||||||
|
|
||||||
|
Environment
|
||||||
|
-----------
|
||||||
|
Host: CM2D4G-A9635005 (macOS)
|
||||||
|
Base: emacs master (upstream HEAD at time of test)
|
||||||
|
|
||||||
|
1. Patch Application
|
||||||
|
--------------------
|
||||||
|
PASS — All 8 patches applied cleanly via git-am:
|
||||||
|
0001 ns: add accessibility base classes and text extraction
|
||||||
|
0002 ns: implement buffer accessibility element (core protocol)
|
||||||
|
0003 ns: add buffer notification dispatch and mode-line element
|
||||||
|
0004 ns: add interactive span elements for Tab navigation
|
||||||
|
0005 ns: integrate accessibility with EmacsView and redisplay
|
||||||
|
0006 doc: add VoiceOver accessibility section to macOS appendix
|
||||||
|
0007 ns: announce overlay completion candidates for VoiceOver
|
||||||
|
0008 ns: announce child frame completion candidates for VoiceOver
|
||||||
|
|
||||||
|
No conflicts, no warnings.
|
||||||
|
|
||||||
|
2. Build
|
||||||
|
--------
|
||||||
|
PASS — Full NS (Cocoa) build completed successfully:
|
||||||
|
./autogen.sh OK
|
||||||
|
./configure --with-ns OK
|
||||||
|
make -j12 OK
|
||||||
|
make install OK
|
||||||
|
|
||||||
|
No warnings related to accessibility code.
|
||||||
|
|
||||||
|
3. Basic Launch
|
||||||
|
---------------
|
||||||
|
PASS — emacs -Q starts without errors or warnings.
|
||||||
|
|
||||||
|
4. Zoom Cursor Tracking
|
||||||
|
------------------------
|
||||||
|
PASS — UAZoomChangeFocus integration working correctly:
|
||||||
|
- Typing in buffer: cursor tracked, Zoom follows OK
|
||||||
|
- M-x: Zoom moves focus to minibuffer OK
|
||||||
|
- M-x list- TAB M-v: switches to *Completions* buffer,
|
||||||
|
TAB cycles focus across completion candidates OK
|
||||||
|
- C-x 2, C-x 2, C-x 3 (multiple splits), then C-x o
|
||||||
|
cycling: Zoom focus correctly follows between windows OK
|
||||||
|
|
||||||
|
5. Documentation
|
||||||
|
----------------
|
||||||
|
PASS — Texinfo node accessible via C-h i g (emacs)VoiceOver Accessibility.
|
||||||
|
Node correctly linked from macOS appendix menu.
|
||||||
|
|
||||||
|
6. VoiceOver — Basic Navigation
|
||||||
|
--------------------------------
|
||||||
|
PASS — VoiceOver active (Cmd+F5):
|
||||||
|
- Buffer name announced correctly on focus OK
|
||||||
|
- Typing: each character announced as typed OK
|
||||||
|
- Arrow keys / C-n / C-p: line-by-line navigation,
|
||||||
|
current line announced OK
|
||||||
|
- Word navigation: reads full current word OK
|
||||||
|
- M-x: switches to minibuffer, announces "minibuffer" OK
|
||||||
|
|
||||||
|
7. VoiceOver — Completions
|
||||||
|
---------------------------
|
||||||
|
PASS — Completion buffer interaction:
|
||||||
|
- M-x list-* then M-v to switch to *Completions*:
|
||||||
|
buffer content read correctly OK
|
||||||
|
- TAB cycling in *Completions*: announces only the
|
||||||
|
current candidate (interactive span focus) OK
|
||||||
|
|
||||||
|
8. VoiceOver — Window Switching
|
||||||
|
--------------------------------
|
||||||
|
PASS — Multiple windows (C-x 2, C-x 3, C-x o cycling):
|
||||||
|
- Announces current buffer name and content on switch OK
|
||||||
|
- Begins reading buffer content automatically OK
|
||||||
|
- User action (typing, navigation) correctly interrupts
|
||||||
|
reading and announces new action instead OK
|
||||||
|
- Notification priority/preemption working as designed OK
|
||||||
|
|
||||||
|
9. VoiceOver — Full Buffer Reading
|
||||||
|
-----------------------------------
|
||||||
|
PASS — VO+A reads entire buffer including off-screen content.
|
||||||
|
- Cursor synchronization between Emacs and VoiceOver
|
||||||
|
virtual cursor working correctly OK
|
||||||
|
|
||||||
|
10. VoiceOver — Accessibility Tree
|
||||||
|
-----------------------------------
|
||||||
|
PASS — Virtual element tree dynamically maintained:
|
||||||
|
- New AX element created for each open buffer OK
|
||||||
|
- Minibuffer element present and readable OK
|
||||||
|
- Mode-line elements present per buffer, readable via
|
||||||
|
VoiceOver virtual navigation OK
|
||||||
|
- Tree correctly updates when windows are split/closed OK
|
||||||
|
|
||||||
|
11. VoiceOver — Selection
|
||||||
|
--------------------------
|
||||||
|
PASS — C-SPC + cursor movement:
|
||||||
|
- Announces "selected" with region feedback OK
|
||||||
|
|
||||||
|
12. VoiceOver — Org-mode Invisible Text
|
||||||
|
----------------------------------------
|
||||||
|
PASS — Org-mode folding (Tab on headings):
|
||||||
|
- Folded: hidden text NOT read by VoiceOver OK
|
||||||
|
- Unfolded: full content read correctly OK
|
||||||
|
- Invisible text filtering (TEXT_PROP_MEANS_INVISIBLE)
|
||||||
|
working as designed OK
|
||||||
|
|
||||||
|
13. ERT — ns-accessibility-enabled Variable
|
||||||
|
--------------------------------------------
|
||||||
|
PASS — Ran 1 test, 1 result as expected:
|
||||||
|
- ns-accessibility-enabled is bound OK
|
||||||
|
- ns-accessibility-enabled defaults to t OK
|
||||||
|
(ERT 1/1 passed, 2026-02-28 11:45:55 CET)
|
||||||
|
|
||||||
|
14. VoiceOver — Overlay Completion (Patch 0007)
|
||||||
|
------------------------------------------------
|
||||||
|
PASS — Vertico minibuffer overlay completion:
|
||||||
|
- Vertico candidates announced on C-n / C-p navigation OK
|
||||||
|
- Selected candidate face detected (vertico-current) OK
|
||||||
|
- Deduplication: same candidate not re-announced OK
|
||||||
|
- Zoom tracks selected candidate in minibuffer
|
||||||
|
(overlayZoomRect / overlayZoomActive lifecycle) OK
|
||||||
|
- overlayZoomActive cleared on text input OK
|
||||||
|
- hl-line-mode compatibility: cursor movement in dired
|
||||||
|
and read-only buffers correctly announces lines
|
||||||
|
(textDidChange flag decouples modiff branch from
|
||||||
|
cursor-move branch) OK
|
||||||
|
|
||||||
|
15. VoiceOver — Child Frame Completion (Patch 0008)
|
||||||
|
----------------------------------------------------
|
||||||
|
PASS — Corfu child frame completion:
|
||||||
|
- Corfu popup candidates announced via VoiceOver OK
|
||||||
|
- Selected candidate face detected (corfu-current) OK
|
||||||
|
- Zoom tracks selected candidate in child frame
|
||||||
|
(direct UAZoomChangeFocus) OK
|
||||||
|
- No Emacs freeze (re-entrance guard before child frame
|
||||||
|
dispatch, buffer switch with unbind_to on all paths) OK
|
||||||
|
- Focus restored to parent buffer after corfu closes
|
||||||
|
(childFrameCompletionActive flag + FOR_EACH_FRAME
|
||||||
|
visibility check + FocusedUIElementChanged) OK
|
||||||
|
- Non-completion child frames skipped (10KB buffer limit) OK
|
||||||
|
- specpdl_ref type used correctly (not ptrdiff_t) OK
|
||||||
Reference in New Issue
Block a user