Skip to content

macos: fix AXVisibleCharacterRange and UTF-16 indexing#12881

Open
jparise wants to merge 3 commits into
ghostty-org:mainfrom
jparise:ax-viewport-range
Open

macos: fix AXVisibleCharacterRange and UTF-16 indexing#12881
jparise wants to merge 3 commits into
ghostty-org:mainfrom
jparise:ax-viewport-range

Conversation

@jparise

@jparise jparise commented May 31, 2026

Copy link
Copy Markdown
Contributor

Several accessibility methods on SurfaceView returned values in the wrong coordinate system, which made the visible-range and selection APIs unusable across screen readers, translation tools, and AI autocomplete apps:

  • accessibilityVisibleCharacterRange returned the full scrollback as the visible range. AX clients would fetch hundreds of KB to translate a single sentence on screen.
  • accessibilityNumberOfCharacters reported grapheme count rather than UTF-16 code units. Any range derived from it indexed into the wrong bytes when the buffer contained emoji.
  • accessibilityLine(for:) treated the parameter as a grapheme index, same problem.
  • accessibilitySelectedTextRange returned viewport-cell-linear offsets (y*cols+x), which are unrelated to UTF-16 NSRange semantics. The selection's range and text disagreed for non-ASCII content.

The main idea is a self-consistent screen-text snapshot. The Zig core adds Screen.screenText which uses ScreenFormatter directly to emit the full screen along with a per-byte Pin array, then binary-searches the array for the viewport's start/end byte offsets. The C API exports this as ghostty_surface_read_screen / ghostty_screen_text_s.

The macOS layer caches a Ghostty.SurfaceView.ScreenText snapshot that holds the text, the viewport NSRange in UTF-16, the total UTF-16 length, and a precomputed lineStarts table. The AX overrides read from this single cache so all reported lengths and ranges live in the same coordinate system.

accessibilitySelectedTextRange searches the cached text for the selection's bytes and returns the unique match, or NSNotFound when the selection appears more than once. A wrong-but-valid range would mislead AX clients more than NSNotFound.

The preexisting cachedScreenContents and cachedVisibleContents stay in place because GetTerminalDetailsIntent still reads them. Folding those into the new cache can be a separate cleanup.

The overall result was verified using some small AX scripts to confirm correct AXVisibleCharacterRange, AXNumberOfCharacters, AXStringForRange, and AXSelectedTextRange behavior.

Note that AXRangeForPositionand AXBoundsForRange are still not yet implemented. Those need a little more foundational work and can come in follow-up changes.

See: #9932


AI Disclosure: I did the initial investigation using Claude Opus 4.7, which was helpful in generating the test cases and planning the overall phased approach.

@jparise jparise requested review from a team as code owners May 31, 2026 20:45
@jparise jparise added os/macos a11y Issues related to accessibility and accessible APIs labels May 31, 2026
Comment thread src/terminal/Screen.zig
Comment on lines +2559 to +2581
// pins.items is monotonic in Pin order (the formatter emits in
// document order). Pin.before is O(pages) for cross-node
// comparisons, so we use binary search rather than a linear scan.
const start = std.sort.partitionPoint(
Pin,
pins.items,
self.pages.getTopLeft(.viewport),
struct {
fn pred(ctx: Pin, pin: Pin) bool {
return pin.before(ctx);
}
}.pred,
);
const end = start + std.sort.partitionPoint(
Pin,
pins.items[start..],
self.pages.getBottomRight(.viewport).?,
struct {
fn pred(ctx: Pin, pin: Pin) bool {
return !ctx.before(pin);
}
}.pred,
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This approach could be a lot more efficient if we didn't need to allocate and search through the entire pin_map.

I separately implemented a new pin_offsets formatter capability (#12882) that would makes this operation much cheaper.

@jparise jparise added this to the 1.4.0 milestone May 31, 2026
Comment thread macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift Outdated
Comment thread macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift Outdated
@jparise jparise requested a review from bo2themax May 31, 2026 22:36
@jparise jparise force-pushed the ax-viewport-range branch from af68b58 to 7dda884 Compare June 1, 2026 01:17
@jparise jparise changed the title macos: fix AXVisibleCharacterRange and UTF-16 indexing in AX overrides macos: fix AXVisibleCharacterRange and UTF-16 indexing Jun 1, 2026

@bo2themax bo2themax left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

macOS part looks good to me 👍

jparise added 3 commits June 1, 2026 10:37
Several accessibility methods on SurfaceView returned values in the
wrong coordinate system, which made the visible-range and selection
APIs unusable across screen readers, translation tools, and AI
autocomplete apps:

- accessibilityVisibleCharacterRange returned the full scrollback as
  the visible range. AX clients would fetch hundreds of KB to translate
  a single sentence on screen.
- accessibilityNumberOfCharacters reported grapheme count rather than
  UTF-16 code units. Any range derived from it indexed into the wrong
  bytes when the buffer contained emoji.
- accessibilityLine(for:) treated the parameter as a grapheme index,
  same problem.
- accessibilitySelectedTextRange returned viewport-cell-linear offsets
  (y*cols+x), which are unrelated to UTF-16 NSRange semantics. The
  selection's range and text disagreed for non-ASCII content.

The main idea is a self-consistent screen-text snapshot. The Zig core
adds Screen.screenText which uses ScreenFormatter directly to emit
the full screen along with a per-byte Pin array, then binary-searches
the array for the viewport's start/end byte offsets. The C API exports
this as ghostty_surface_read_screen / ghostty_screen_text_s.

The macOS layer caches a Ghostty.SurfaceView.ScreenText snapshot that
holds the text, the viewport NSRange in UTF-16, the total UTF-16
length, and a precomputed lineStarts table. The AX overrides read from
this single cache so all reported lengths and ranges live in the same
coordinate system.

accessibilitySelectedTextRange searches the cached text for the
selection's bytes and returns the unique match, or NSNotFound when the
selection appears more than once. A wrong-but-valid range would mislead
AX clients more than NSNotFound.

The preexisting cachedScreenContents and cachedVisibleContents stay in
place because GetTerminalDetailsIntent still reads them. Folding those
into the new cache can be a separate cleanup.

The overall result was verified using some small AX scripts to confirm
correct AXVisibleCharacterRange, AXNumberOfCharacters, AXStringForRange,
and AXSelectedTextRange behavior.

Note that AXRangeForPosition and AXBoundsForRange are still not yet
implemented. Those need a little more foundational work and can come in
follow-up changes.
This allows it to be shared with a future iOS implementation.
Extend ScreenText to include the active selection's UTF-8 byte offsets
alongside the text and viewport. The selection range is computed as part
of the same formatter pass as the text, so they're guaranteed to be in
sync.

accessibilitySelectedTextRange now reads the cached selection range
directly, and we now return NSNotFound when there's no selection per the
NSAccessibility convention for read-only content.
@jparise jparise force-pushed the ax-viewport-range branch from e778459 to 359d3d3 Compare June 1, 2026 14:37

@mitchellh mitchellh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome, thanks for looking at this. I think the only sus part is the Screen.zig changes. I get what you're going for and why formatters directly don't provide this,but I want to spend more time thinking about if there is a better way to expose this.

@jparise

jparise commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

Awesome, thanks for looking at this. I think the only sus part is the Screen.zig changes. I get what you're going for and why formatters directly don't provide this,but I want to spend more time thinking about if there is a better way to expose this.

Assume you mean this bit, or something else?

while (r.end > r.start and text[r.end - 1] == '\n') r.end -= 1;

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

Labels

a11y Issues related to accessibility and accessible APIs os/macos

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants