Skip to content

VoiceOver: fix the link crash, and take language, links, text styles and rotors from scope settings - #88

Open
AllenChen-Xingan wants to merge 9 commits into
textmatelives:mainfrom
AllenChen-Xingan:voiceover-accessibility
Open

AllenChen-Xingan wants to merge 9 commits into
textmatelives:mainfrom
AllenChen-Xingan:voiceover-accessibility

Conversation

@AllenChen-Xingan

@AllenChen-Xingan AllenChen-Xingan commented Sep 23, 2026 •

Copy link
Copy Markdown

VoiceOver support in the editor, rebuilt on scope settings. I use TextMate full time with VoiceOver, mostly on documents that mix Chinese and English; these are the changes that make that work, written so that they fit TextMate’s design rather than special-casing my languages.

Revision 2 (force-pushed): fixes a crash that also affects the release build, moves the work into a buffer hook next to the symbols, and takes the review comments on the first version. Details at the end.

The idea: accessibility follows scopes

Where the spelling checker looks is already a scope setting (spellChecking), and what goes into the symbol list is too (showInSymbolList). The editor’s accessibility information now works the same way. TextMate itself hard-codes no language, file type or scope; it only reads these settings, and bundles provide the defaults next to their existing spell-checking settings:

Setting Values Effect
accessibilityLanguage auto, none (also when unset), or a BCP 47 tag Which voice VoiceOver reads the text in. auto identifies the language with the NaturalLanguage framework, one run per script within each sentence
accessibilityLink (+ accessibilityLinkTitleTransformation, accessibilityLinkURLTransformation) bool + symbolTransformation syntax What is a link, what VoiceOver reads as its title, and the URL it opens
accessibilityTextStyle any of bold italic underline strikethrough What the text means, independent of whether the theme draws it that way
accessibilityRotor (+ …Transformation, …Extent, …Order) heading, link, list, table, image, bold, italic, … for one of VoiceOver’s own rotors, or a name (optionally localized) for a custom one A VoiceOver rotor (VO-U) listing the matching text

With no settings, behaviour is unchanged: nothing is tagged, exactly as VoiceOver experiences TextMate today.

Everything lives in one buffer hook, ng::accessibility_t, built like symbols_t: settings are read once per scope and cached (cleared when bundles change), links and rotor items sit in indexed maps that shift with edits, and edits and parsing only widen a range that the next accessibility query brings up to date. Nothing is computed while no assistive client asks.

Commits

  1. Install the app’s localization as en.lproj. With only English.lproj, TextMate reports AXPreferredLanguage = "English" where every other app reports "en". VoiceOver cannot resolve it and falls back to a voice without Chinese phonemes, so any Chinese announcement made while TextMate is frontmost is silent. The existing sources are installed as en.lproj with CFBundleDevelopmentRegion to match; no second localization.
  2. Use the documented name of the marked-misspelled attribute (NSAccessibilityMarkedMisspelledTextAttribute).
  3. Compile symbol transformations once and reuse them. symbols.cc’s private transform type becomes ng::symbol_transform_t, so the settings above can use it.
  4. Expose links through NSAccessibilityElement. Fixes a crash in the release build: VoiceOver on macOS 27 asks every element for AXFocused, AXEnabled, … through AXUIElementCopyMultipleAttributeValues, which reaches the legacy accessibilityAttributeValue: without consulting the attribute list, and OakAccessibleLink raised NSAccessibilityException there. AppKit would catch it, but the NSExceptionHandler delegate aborts first, so opening a Markdown file with one bare URL while VoiceOver runs crashes 2.3.0-undead on launch. The element is rebuilt on the current protocol; frames and the activation point are computed from the range on demand; pressing opens the URL (the action was a TODO).
  5. Add grammar-defined VoiceOver rotors. Introduces the buffer hook.
  6. Take the accessibility language of text from its scope. The text view tagged the whole buffer with the spelling language under "AXNaturalLanguageText" (2013, before NSAccessibilityLanguageTextAttribute existed in 10.13); nothing recognises that key, so VoiceOver never knew the language of any text. Now ns::identify_languages splits a line into sentences and script runs and asks NLLanguageRecognizer; the user’s preferred languages settle what is uncertain (a few characters of Chinese are as often taken for Traditional as for Simplified). Results are cached per line. Unit tests in Frameworks/ns/tests/t_language.mm.
  7. Take accessibility links from scope settings. Links were limited to the hard-coded markup.underline.link and always had a nil URL. Behaviour change: plain URLs need accessibilityLink on markup.underline.link — see the Text bundle PR.
  8. Report text styles to accessibility from scope settings.
  9. Do not abort on NSAccessibilityException. Generalises the existing NSMenu exemption in OakAssert.mm: AppKit’s accessibility entry points catch these themselves.

Companion bundle PRs (the defaults)

The embedded Text and Source pins would need bumping once those are merged.

Testing (macOS 27.0, VoiceOver on)

  • Accessibility-API probes on a Markdown file mixing Chinese, English and a Python block: links (role, title, URL, AXEnabled, activation point, hit test, AXPress), styles, per-sentence languages (The coaching session went well. 客户说他很满意。Next steps are clear. → en / zh-Hans / en; identifiers untagged, comments tagged), 14 rotors; an empty document; replacing the whole text through AXValue and checking links, rotors and languages follow.
  • The untouched release build crashes on the same file under the same probes; this branch does not.
  • Performance, interleaved with a Release build of main on a 6,120-line Markdown file, medians of three rounds: AXAttributedStringForRange per line 0.33 ms → 0.40 ms (first read of a line identifies its language; later reads hit the cache); edit then read 3.9–4.1 ms → 2.6–2.8 ms (the old link index rescanned the whole document after every parse); rotors plus children 0.06 ms → 0.13 ms (14 rotors instead of one).
  • ns_tests pass; buffer_test has the same 3 spell-checking failures as main on this machine.

Found along the way, not addressed here: when ~/Library/Application Support/TextMate/Bundles is created after the first launch, the cached BundlesIndex.binary never picks it up; deleting the cache fixes it.

The patches are contributed under the repository’s license (GPL-3.0-or-later).

🤖 Generated with Claude Code

AllenChen-Xingan and others added 9 commits September 23, 2026 18:45
The app's only localization is built into English.lproj, so the bundle
reports “English” as its preferred localization and AppKit passes that
on to assistive clients as AXPreferredLanguage. VoiceOver expects a
language tag there. It cannot interpret “English”, falls back to a voice
that cannot pronounce other languages, and announcements in those
languages, such as a translator speaking Chinese while TextMate is
frontmost, are silent. Other applications report “en”.

Keep the sources where they are and install them as en.lproj instead,
with CFBundleDevelopmentRegion set to match, so the bundle has a single
localization named by its language tag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
NSAccessibilityMarkedMisspelledTextAttribute has been the name of
“AXMarkedMisspelled” since macOS 10.4.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The transformation applied to symbols was a type private to symbols.cc.
Other consumers of ‘symbolTransformation’-style settings would have to
copy it, so make it ng::symbol_transform_t, compiled once per scope and
expanded per symbol as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
VoiceOver on macOS 27 asks every element it reaches for attributes such
as AXFocused, AXEnabled and AXDescription, whether or not the element
lists them, and it asks through AXUIElementCopyMultipleAttributeValues,
where AppKit passes the request straight to accessibilityAttributeValue:
without consulting accessibilityAttributeNames first.

OakAccessibleLink implemented the legacy NSAccessibility protocol and
raised NSAccessibilityException for any attribute it did not know. AppKit
would catch that, but TextMate’s NSExceptionHandler delegate sees the
exception first and aborts, so opening a document that contains a link
while VoiceOver is running crashed TextMate on launch. The release build
of 2.3.0-undead crashes this way on a Markdown file with one bare URL.

Rebuild the link on NSAccessibilityElement and the current protocol,
where AppKit answers for whatever the element does not define. The
link’s frame and activation point are computed from its range when
asked, so they stay right as the view scrolls, wraps or changes font,
and pressing a link opens its URL, which was a TODO.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Scopes with the setting ‘accessibilityRotor’ appear in a rotor, the way
‘showInSymbolList’ puts them in the symbol list:

- accessibilityRotor: one of heading, heading1–heading6, link, list,
  table, image, bold, italic, underline, landmark or annotation for a
  rotor of VoiceOver’s own, named and placed by VoiceOver; any other
  string for a rotor of that name. The name, and the transformation, may
  be a dictionary keyed by language tag, of which the entry matching the
  user’s preferred languages is used.
- accessibilityRotorTransformation: ‘symbolTransformation’ rules for the
  label.
- accessibilityRotorExtent: run (each match is an item), line (one item
  per line) or block (consecutive lines are one item).
- accessibilityRotorOrder: position among the rotors, lowest first.

Items live in a buffer hook, ng::accessibility_t, next to the symbols:
edits and parsing only widen a range that the next query brings up to
date, so nothing is computed until an assistive client asks, and then
only for what changed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
VoiceOver reads each run of text in the voice for the language given by
NSAccessibilityLanguageTextAttribute (AXLanguage). The text view instead
tagged the whole buffer with the spelling language under the key
“AXNaturalLanguageText”, which predates the public constant (added in
macOS 10.13) and is not recognised by anything in the system, so
VoiceOver never learned the language of any text: a document mixing
Chinese and English was read with a single voice.

A document-wide spelling language could not express that anyway. The
language is now a scope setting, the way ‘spellChecking’ decides where
the spelling checker looks:

- accessibilityLanguage = auto: identified by the NaturalLanguage
  framework, one run per script within each sentence.
- accessibilityLanguage = none, or unset: no attribute, so without
  bundle settings VoiceOver behaves as before.
- accessibilityLanguage = a BCP 47 tag: the text is in that language.

Grammars opt in where text is prose (text.*, comments, strings) and
leave source code alone, so identifiers are not read in whatever
language they resemble.

A short run rarely identifies with confidence, and variants of one
language even less so (a few characters of Chinese are as often taken
for Traditional as for Simplified), so the user’s preferred languages
settle what is uncertain: the identified language in the user’s variant
when it is one of theirs, otherwise a confident identification as is,
otherwise the first of their languages among the hypotheses.

The runs of a line are identified once and kept in the buffer hook
until the line changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The text view exposed links to VoiceOver only for the hard-coded
selector markup.underline.link, and every link had a nil URL. A grammar
had no way to say that, say, Markdown’s [text](url) is a link, or what
its URL is.

Links are now a scope setting, the way ‘showInSymbolList’ picks symbols:

- accessibilityLink: true for scopes that are links.
- accessibilityLinkTitleTransformation: what VoiceOver reads as the
  title.
- accessibilityLinkURLTransformation: extracts the URL from the text.

Both transformations use the ‘symbolTransformation’ syntax. Adjacent link
scopes form one link rather than one per scope, as adjacent symbol
scopes form one symbol. The links live in the buffer hook and are
updated for the range that changed, where the old index was rebuilt
over the whole document after every parse.

The previous behaviour needs accessibilityLink set for
markup.underline.link, which belongs in the Text bundle next to its
spellChecking settings for the same scope.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
VoiceOver learns that text is bold, italic, underlined or struck through
only from the theme’s drawing of it, so with a theme that does not draw
markup.bold in bold the text is not reported as bold. What text means
belongs to its scope, not to how a theme happens to paint it.

The scope setting ‘accessibilityTextStyle’ (any of bold, italic,
underline and strikethrough, space separated) now adds those styles to
the attributed string VoiceOver reads, on top of what the theme draws.
Bold and italic are expressed as the matching font, which is how
VoiceOver reads font traits.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
AppKit’s accessibility entry points catch NSAccessibilityException
themselves; the legacy NSAccessibility protocol raises it for
unsupported attributes and actions, which assistive clients ask for
routinely. The NSExceptionHandler delegate observes the exception before
AppKit catches it, so treating it as fatal turns any such request from
VoiceOver into a crash. Extend the existing NSMenu exemption to cover
every NSAccessibilityException.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@AllenChen-Xingan AllenChen-Xingan changed the title VoiceOver: take language, links, text styles and rotors from scope settings VoiceOver: fix the link crash, and take language, links, text styles and rotors from scope settings Sep 24, 2026

Copy link
Copy Markdown
Collaborator

Thanks @AllenChen-Xingan — this is a substantial and well-argued piece of work, and Revision 2 is a clear step up: the buffer hook with dirty-range updates, transforms compiled once per scope, the link rebuilt on NSAccessibilityElement, and the ns_tests. The AXUIElementCopyMultipleAttributeValues crash is a genuine bug in the release build and I'm glad it surfaced.

I've kicked off CI (first contributions need a manual approval) — results will land on the PR. I'll do a full pass later this week; one item below needs to change before this can merge, the rest are asks and notes.

Blocking: link press needs a URL scheme allowlist.
accessibilityPerformPress opens whatever accessibilityURL holds, and updateAccessibilityChildren only requires URL.scheme to be non-nil. The Markdown grammar's inline-link pattern accepts the URL as (<?)(.*?)(>?) with no scheme constraint, and the title and URL come from two independent transformations — so a document can announce "Read the setup guide" and open file:///…/Terminal.app, x-apple.systempreferences:…, or any third-party handler (vscode://, shortcuts://run-shortcut?…, …) on VO-Space. Before this PR the press action was a no-op; this is the first path in OakTextView where document content selects a system action with one keystroke, and it's built for exactly the users who can't inspect the target first.

Precedent is already in the tree: AboutWindowController.mm:247 refuses file before calling openURL:. Please allow http, https and mailto (and route txmt: through handleTxMtURL: the way OakHTMLOutputView does, if you want it), and treat anything else as a nil URL — the current no-op. That keeps the feature intact for every legitimate link and closes the door on the rest.

Ask: a test for accessibility_t.
t_language.mm covers identification, but the hook is where the logic lives: the dirty-range widening in update_items, links and rotor items surviving edits before and after them, and bundles_did_change forcing a rebuild. buffer_test already has the harness — a t_accessibility.cc driving a buffer_t with a couple of scope settings would give us real coverage of the incremental path.

Notes, non-blocking

  • OakAssert.mm: fine by me. After this PR the only remaining NSAccessibilityException thrower is SearchField.mm:7, so the exemption still guards a live path, and matching on the exact name keeps it narrow.
  • languages_for_line hands NaturalLanguage the whole line. On a minified one-line file with auto in strings and comments (the Source bundle default), that's one very large pass per edit to that line. A per-line byte cap above which auto is skipped would be cheap insurance.
  • The en.lproj rename is consistent within the app bundle (checked). The QuickLook generator and the Dialog plug-ins still install English.lproj — separate bundles, so no mixing, but worth a follow-up for consistency.
  • Sequencing: this needs Add accessibility settings for text text.tmbundle#2 to land with it, or bare-URL links disappear for VoiceOver users in between. I'll take the four bundle PRs together with this one and bump the embedded pins after.

Generated by Claude Code

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants