VoiceOver: fix the link crash, and take language, links, text styles and rotors from scope settings - #88
Conversation
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>
24385cc to
aee485d
Compare
|
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 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. Precedent is already in the tree: Ask: a test for Notes, non-blocking
Generated by Claude Code |
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:accessibilityLanguageauto,none(also when unset), or a BCP 47 tagautoidentifies the language with the NaturalLanguage framework, one run per script within each sentenceaccessibilityLink(+accessibilityLinkTitleTransformation,accessibilityLinkURLTransformation)symbolTransformationsyntaxaccessibilityTextStylebold italic underline strikethroughaccessibilityRotor(+…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 oneWith no settings, behaviour is unchanged: nothing is tagged, exactly as VoiceOver experiences TextMate today.
Everything lives in one buffer hook,
ng::accessibility_t, built likesymbols_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
English.lproj, TextMate reportsAXPreferredLanguage = "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 asen.lprojwithCFBundleDevelopmentRegionto match; no second localization.NSAccessibilityMarkedMisspelledTextAttribute).symbols.cc’s private transform type becomesng::symbol_transform_t, so the settings above can use it.AXFocused,AXEnabled, … throughAXUIElementCopyMultipleAttributeValues, which reaches the legacyaccessibilityAttributeValue:without consulting the attribute list, andOakAccessibleLinkraisedNSAccessibilityExceptionthere. AppKit would catch it, but theNSExceptionHandlerdelegate 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 aTODO)."AXNaturalLanguageText"(2013, beforeNSAccessibilityLanguageTextAttributeexisted in 10.13); nothing recognises that key, so VoiceOver never knew the language of any text. Nowns::identify_languagessplits a line into sentences and script runs and asksNLLanguageRecognizer; 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 inFrameworks/ns/tests/t_language.mm.markup.underline.linkand always had a nil URL. Behaviour change: plain URLs needaccessibilityLinkonmarkup.underline.link— see the Text bundle PR.OakAssert.mm: AppKit’s accessibility entry points catch these themselves.Companion bundle PRs (the defaults)
autofortext,nonefor links and raw; URLs are links; styles formarkup.bold/italic/underline/strikethroughautofor comments and strings only[text](url)The embedded Text and Source pins would need bumping once those are merged.
Testing (macOS 27.0, VoiceOver on)
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 throughAXValueand checking links, rotors and languages follow.mainon a 6,120-line Markdown file, medians of three rounds:AXAttributedStringForRangeper 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_testspass;buffer_testhas the same 3 spell-checking failures asmainon this machine.Found along the way, not addressed here: when
~/Library/Application Support/TextMate/Bundlesis created after the first launch, the cachedBundlesIndex.binarynever 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