From 35b3d70388d21a845082e26347ef1f2cb5ca944f Mon Sep 17 00:00:00 2001 From: Alexander Epaneshnikov Date: Wed, 12 Aug 2026 18:16:12 +0300 Subject: [PATCH 1/4] a11y: add the accessibility text snapshot and its offset math MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two GTK-free modules that the GTK apprt will build on. They live in their own src/a11y package rather than in the apprt so the viewport walk and the offset arithmetic can be unit tested directly, without standing up a live surface and an AT-SPI bus. `text.zig` walks a terminal viewport into a flat UTF-8 buffer: one line per row, rows joined by '\n', trailing blanks on a row dropped and interior blanks kept as spaces so columns still line up. Alongside the text it records how many screen columns each codepoint occupies, which is the only point where that is knowable — a double-width character is one codepoint across two cells and a combining mark is a codepoint across none, and neither can be recovered from the text afterwards without guessing at the terminal's configured grapheme-width method. `offsets.zig` navigates that snapshot: byte/codepoint conversion, grid and widget-space mapping, and the minimal diff between two snapshots. Every offset an AT client sees is in codepoints rather than bytes, which is the source of every historical crash in this area, so the arithmetic lives in one tested place and the C integer types stay at the boundary. Codepoint counting goes through a new simdutf `count_utf8` binding in src/simd, 44-59x faster than a scalar loop on viewport-sized buffers, with a scalar fallback for builds without SIMD. The diff is the load-bearing part. An AT client does not re-read the buffer when it changes; it applies the insert/remove events to the copy it already holds. So the property that matters is not that each event is plausible but that replaying them reproduces the new text exactly, and that is what the round-trip tests assert — by name for the shapes we emit deliberately, and over 3000 generated viewports containing multi-byte rows, characters that share leading bytes, and emoji. Co-Authored-By: Claude Opus 5 Co-Authored-By: Claude Fable 5 --- src/a11y/main.zig | 11 + src/a11y/offsets.zig | 1337 ++++++++++++++++++++++++++++++++++++++ src/a11y/text.zig | 438 +++++++++++++ src/build/SharedDeps.zig | 1 + src/simd/main.zig | 1 + src/simd/utf8_count.cpp | 9 + src/simd/utf8_count.zig | 35 + 7 files changed, 1832 insertions(+) create mode 100644 src/a11y/main.zig create mode 100644 src/a11y/offsets.zig create mode 100644 src/a11y/text.zig create mode 100644 src/simd/utf8_count.cpp create mode 100644 src/simd/utf8_count.zig diff --git a/src/a11y/main.zig b/src/a11y/main.zig new file mode 100644 index 0000000000..fe589fc3f9 --- /dev/null +++ b/src/a11y/main.zig @@ -0,0 +1,11 @@ +//! The a11y package contains accessibility logic that is independent of +//! any application runtime: building the flat UTF-8 text snapshot of a +//! terminal viewport that assistive technology clients read, and the +//! offset math for navigating it. + +pub const offsets = @import("offsets.zig"); +pub const text = @import("text.zig"); + +test { + @import("std").testing.refAllDecls(@This()); +} diff --git a/src/a11y/offsets.zig b/src/a11y/offsets.zig new file mode 100644 index 0000000000..73ad0b426e --- /dev/null +++ b/src/a11y/offsets.zig @@ -0,0 +1,1337 @@ +//! Offset math over the flat accessibility text snapshot produced by +//! `text.build`. +//! +//! Where `text.zig` produces the snapshot, this module navigates it: +//! converting between byte offsets, UTF-8 codepoint offsets, terminal +//! grid positions and widget-space points, and computing the minimal +//! diff between two snapshots. +//! +//! It is deliberately free of GTK and of any surface state. Every +//! function takes the snapshot text and returns numbers, so the offset +//! arithmetic can be tested directly. That matters more here than +//! anywhere else in the accessibility code: AT-SPI offsets are in +//! codepoints, not bytes, and every historical crash in this subsystem +//! has been a boundary or unit mix-up. Handing an orphan UTF-8 +//! continuation byte to the AT-SPI bridge makes `g_variant_new_string` +//! return NULL and SIGSEGVs inside `g_variant_builder_add_value`; being +//! off by a codepoint in the other direction silently truncates what a +//! screen reader announces. +//! +//! Two units appear throughout and are never interchangeable: +//! +//! - `usize` is an offset *into the snapshot*, either bytes or +//! codepoints. Parameter and field names say which (`..._byte`, +//! `..._cp`); an unsuffixed offset in a public signature is a +//! codepoint offset, because that is what AT-SPI speaks. +//! - `u32` is a *terminal grid* coordinate: a row or a column, counted +//! in cells. +//! +//! Nothing here uses C integer types. The apprt casts at the boundary +//! where it hands numbers to GTK, which keeps that conversion in one +//! reviewable place instead of spread across the arithmetic. + +const std = @import("std"); +const simd = @import("../simd/main.zig"); + +/// Byte length of a UTF-8 codepoint given its leading byte. A total +/// wrapper over `std.unicode.utf8ByteSequenceLength`: stray continuation +/// bytes advance 1 so a scan over malformed input can't stall. These +/// helpers are total (rather than using e.g. +/// `std.unicode.utf8CountCodepoints`) because their call sites are AT +/// callbacks with no useful error path, and the snapshots they read are +/// built codepoint by codepoint and always well-formed. +pub fn utf8CpLen(b: u8) usize { + return std.unicode.utf8ByteSequenceLength(b) catch 1; +} + +/// Count UTF-8 codepoints in `s`, via simdutf when the build has SIMD +/// enabled. +pub fn utf8CpCount(s: []const u8) usize { + return simd.countUtf8(s); +} + +/// Byte offset of the `cp_idx`-th codepoint in `s`. Clamps at `s.len`. +pub fn utf8CpToByte(s: []const u8, cp_idx: usize) usize { + var i: usize = 0; + var c: usize = 0; + while (i < s.len and c < cp_idx) : (c += 1) i += utf8CpLen(s[i]); + return i; +} + +/// The unchanged ends of two snapshots: the leading `prefix_len` bytes +/// and the trailing `suffix_len` bytes are common to both, and everything +/// between them was replaced. Both lengths land on UTF-8 codepoint +/// boundaries in either snapshot. +pub const PrefixSuffixDiff = struct { + prefix_len: usize, + suffix_len: usize, + + /// Bytes each side has to be told about: what leaves `old` plus what + /// arrives from `new`. + pub fn cost(self: PrefixSuffixDiff, old_len: usize, new_len: usize) usize { + const unchanged = self.prefix_len + self.suffix_len; + return (old_len - unchanged) + (new_len - unchanged); + } +}; + +/// Compute the byte-wise common prefix and suffix of `old` and `new`, +/// then pull both offsets back to UTF-8 codepoint boundaries. +/// +/// Alignment matters because two multi-byte codepoints can share a +/// leading byte (e.g. `│` and `├`, both starting with 0xE2 0x94), and a +/// byte-wise compare can land inside a character. +/// +/// Aligning against `old_text` alone also aligns `new_text`: the bytes at +/// the two boundaries are equal by construction, and in well-formed UTF-8 +/// a non-continuation byte can only ever begin a codepoint. So a boundary +/// that is valid in one snapshot is valid in the other. +pub fn prefixSuffix(old_text: []const u8, new_text: []const u8) PrefixSuffixDiff { + var prefix: usize = 0; + const min_len = @min(old_text.len, new_text.len); + while (prefix < min_len and old_text[prefix] == new_text[prefix]) : (prefix += 1) {} + + // Cap the suffix so it can't overlap the prefix (that would make the + // remove/insert ranges go negative). + var suffix: usize = 0; + const max_suffix = @min(old_text.len - prefix, new_text.len - prefix); + while (suffix < max_suffix and + old_text[old_text.len - 1 - suffix] == new_text[new_text.len - 1 - suffix]) : (suffix += 1) + {} + + // Retreat each end onto a codepoint boundary. Both only ever shrink, + // so the no-overlap cap above still holds afterwards. + // + // The `prefix < old_text.len` guard matters when `old_text` is a + // prefix of `new_text`: `prefix == old_text.len` and indexing would go + // out of bounds, but end-of-buffer is already a codepoint boundary. + while (prefix > 0 and prefix < old_text.len and + isContinuation(old_text[prefix])) : (prefix -= 1) + {} + while (suffix > 0 and isContinuation(old_text[old_text.len - suffix])) : (suffix -= 1) {} + + return .{ .prefix_len = prefix, .suffix_len = suffix }; +} + +/// Whether `b` is a UTF-8 continuation byte, i.e. a byte that cannot +/// begin a codepoint. +fn isContinuation(b: u8) bool { + return b & 0xC0 == 0x80; +} + +/// Look for a whole-line shift: a K > 0 at a `\n` boundary of `old` such +/// that `new[0..|old|-K] == old[K..]`. Returns 0 if no such K exists. +/// Since `\n` is ASCII, every candidate K is already a UTF-8 codepoint +/// boundary. +/// +/// As written this finds an *upward* shift: K bytes left the top and the +/// remainder moved up. The downward shift is the same relation with the +/// snapshots exchanged, so `chooseDiff` finds it by calling this with the +/// arguments swapped rather than by duplicating the scan. +pub fn scrollK(old_text: []const u8, new_text: []const u8) usize { + var search: usize = 0; + while (std.mem.indexOfScalarPos(u8, old_text, search, '\n')) |i| { + search = i + 1; + const boundary = i + 1; + const remainder = old_text.len - boundary; + // A zero-byte remainder matches trivially at every trailing `\n` + // and would mask the prefix/suffix diff for every change where + // old_text ends in `\n`. Require a non-trivial middle. + if (remainder == 0) continue; + // Early rows have a remainder too long to fit in `new_text`. The + // remainder shrinks as the scan advances, so this is a skip and + // not a stop. + if (remainder > new_text.len) continue; + if (std.mem.eql(u8, new_text[0..remainder], old_text[boundary..])) { + // The first match is the smallest K, which is the one that + // describes the change in the fewest bytes. + return boundary; + } + } + return 0; +} + +/// How to describe a viewport change to an AT client. +pub const Diff = union(enum) { + /// Nothing to announce. + none, + + /// The text shifted up by whole lines: `k` bytes left the top of the + /// viewport, everything below moved up to fill the gap, and new + /// content arrived at the bottom. This is the shape of command + /// output, and of scrolling forwards towards the prompt. + shift_up: usize, + + /// The text shifted down by whole lines: `j` bytes of new content + /// arrived at the top and the tail fell off the bottom. This is the + /// shape of scrolling back into the scrollback. + shift_down: usize, + + /// Anything else: replace the bytes between the common prefix and the + /// common suffix. + replace: PrefixSuffixDiff, +}; + +/// Pick the cheapest description of the change from `old_text` to +/// `new_text`, measured in bytes the AT client has to be told about. +/// +/// Cheapest-wins is what keeps the three shapes from poaching each +/// other's cases. On a real whole-line shift, prefix/suffix covers nearly +/// the whole viewport (every row moved), so a shift wins by a wide +/// margin. But shift detection also matches spuriously on typing echo: +/// typing `x` at a `$ ` prompt when earlier rows also end in `$ ` makes +/// the last row of `old` a prefix of `new`, and there the shift is the +/// expensive one. Neither side needs to know about the other; the byte +/// count separates them. +/// +/// A shift only wins by being strictly cheaper, so an exact tie with +/// prefix/suffix takes the replacement. Between the two shift directions +/// a tie goes to `shift_up`, which is by far the common case, since a terminal +/// spends most of its life scrolling forwards. +pub fn chooseDiff(old_text: []const u8, new_text: []const u8) Diff { + const ps = prefixSuffix(old_text, new_text); + const ps_total = ps.cost(old_text.len, new_text.len); + + // Bytes off the top, plus whatever `new` grew past what survived. + // + // The subtractions below cannot underflow: a non-zero K from + // `scrollK(a, b)` carries the guarantee that `a.len - K <= b.len`, + // which is exactly what each one needs. + const k = scrollK(old_text, new_text); + const up_total = if (k > 0) + k + (new_text.len - (old_text.len - k)) + else + std.math.maxInt(usize); + + // The mirror image: `j` bytes onto the top of `new`, plus whatever of + // `old` fell off the bottom. + const j = scrollK(new_text, old_text); + const down_total = if (j > 0) + j + (old_text.len - (new_text.len - j)) + else + std.math.maxInt(usize); + + if (k > 0 and up_total < ps_total and up_total <= down_total) { + return .{ .shift_up = k }; + } + if (j > 0 and down_total < ps_total) return .{ .shift_down = j }; + if (ps_total > 0) return .{ .replace = ps }; + return .none; +} + +/// How many terminal columns each codepoint of a snapshot occupies. +/// +/// The snapshot holds one codepoint per *character*, but a terminal row is +/// a grid of *cells*, and the two do not correspond: a double-width +/// character (CJK, an emoji in emoji presentation) is one codepoint across +/// two columns, while the combining marks of a grapheme cluster are +/// codepoints across no columns of their own. Equating codepoint index +/// with column, which every function here used to do, puts a caret, a +/// click, or a highlight one column left per wide character preceding it +/// on the row. +/// +/// Nothing in the text records that, so `text.build` records it here +/// as it walks the cells. The widths therefore come from the same cell +/// data the renderer draws from, rather than from a width table this +/// module would have to keep in sync with the terminal's own idea of how +/// wide a character rendered, which depends on the configured +/// grapheme-width method and cannot be re-derived from the text alone. +pub const CellWidths = struct { + /// Columns occupied by the i-th codepoint of the snapshot. Indices + /// past the end read as 1, so a short or absent slice degrades to one + /// column per codepoint instead of going out of bounds. + per_cp: []const u8 = &.{}, + + /// Widths for a snapshot whose cell data we don't have. Exact for + /// all-single-width text and off by a column per wide character + /// otherwise, so pass it only where an approximate answer beats no + /// answer at all. + pub const uniform: CellWidths = .{}; + + pub fn at(self: CellWidths, cp_idx: usize) u32 { + if (cp_idx >= self.per_cp.len) return 1; + return self.per_cp[cp_idx]; + } +}; + +/// Where a row begins, in both units we need to walk it: bytes to index +/// `text`, codepoints to index `CellWidths`. +const RowStart = struct { + byte: usize, + cp: usize, +}; + +/// Locate the start of `row`, or null when the snapshot has fewer rows. +fn rowStart(text: []const u8, row: u32) ?RowStart { + if (row == 0) return .{ .byte = 0, .cp = 0 }; + + var byte: usize = 0; + var cp: usize = 0; + var seen: u32 = 0; + while (byte < text.len) { + const is_newline = text[byte] == '\n'; + byte += utf8CpLen(text[byte]); + cp += 1; + if (!is_newline) continue; + seen += 1; + if (seen == row) return .{ .byte = byte, .cp = cp }; + } + return null; +} + +/// Start of the last row in the snapshot. A snapshot ending in '\n' has an +/// empty last row, and this returns its (past-the-end) start. +fn lastRowStart(text: []const u8) RowStart { + var out: RowStart = .{ .byte = 0, .cp = 0 }; + var byte: usize = 0; + var cp: usize = 0; + while (byte < text.len) { + const is_newline = text[byte] == '\n'; + byte += utf8CpLen(text[byte]); + cp += 1; + if (is_newline) out = .{ .byte = byte, .cp = cp }; + } + return out; +} + +/// Codepoint index of the character occupying column `col` of the row +/// starting at `start`. +/// +/// A column past the row's last cell resolves to one past its last +/// codepoint, which is where a caret at end of line belongs. +fn cpAtColumn( + text: []const u8, + widths: CellWidths, + start: RowStart, + col: u32, +) usize { + var byte = start.byte; + var cp = start.cp; + var column: u32 = 0; + while (byte < text.len and text[byte] != '\n') { + const w = widths.at(cp); + // Zero-width codepoints (the combining marks of a grapheme + // cluster) live in the cell their base character opened, so a + // column resolves to that base and never to one of them. + if (w > 0 and col < column + w) return cp; + column += w; + byte += utf8CpLen(text[byte]); + cp += 1; + } + return cp; +} + +/// Column at which codepoint `cp_idx` sits, counted from the start of its +/// own row. `cp_idx` must be at or after `start`. +/// +/// A zero-width codepoint reports the column of the cell it shares rather +/// than the next one along, so it stays the inverse of `cpAtColumn`: that +/// resolves a column to the base character, and this resolves the base's +/// marks back to the same column. Running off the end of the row instead +/// reports the row's full width, which is where a caret at end of line +/// belongs. +fn columnOfCp( + text: []const u8, + widths: CellWidths, + start: RowStart, + cp_idx: usize, +) u32 { + var byte = start.byte; + var cp = start.cp; + var column: u32 = 0; + // Column at which the cell currently being filled began. + var cell_start: u32 = 0; + while (byte < text.len and text[byte] != '\n' and cp < cp_idx) { + const w = widths.at(cp); + if (w > 0) cell_start = column; + column += w; + byte += utf8CpLen(text[byte]); + cp += 1; + } + + const in_row = byte < text.len and text[byte] != '\n'; + if (in_row and widths.at(cp_idx) == 0) return cell_start; + return column; +} + +/// Map (row, col) in the snapshot to a codepoint offset. Returns null +/// when `row` is past the last row in `text`. +/// +/// Contrast `offsetAtGrid`, which clamps to the last row instead. The +/// difference is deliberate: a selection anchored to a row that scrolled +/// out of the viewport has no offset and must be dropped, whereas a +/// pointer event below the last row should still resolve to something. +/// +/// Either cell of a double-width character resolves to that character. +pub fn rowColToCp( + text: []const u8, + widths: CellWidths, + row: u32, + col: u32, +) ?usize { + const start = rowStart(text, row) orelse return null; + return cpAtColumn(text, widths, start, col); +} + +/// Map (row, col) to a codepoint offset, clamping a row past the end of +/// the snapshot to the last row and a column past end-of-row to the row +/// length. Always resolves; see `rowColToCp` for the variant that fails. +pub fn offsetAtGrid( + text: []const u8, + widths: CellWidths, + want_row: u32, + col: u32, +) usize { + const start = rowStart(text, want_row) orelse lastRowStart(text); + return cpAtColumn(text, widths, start, col); +} + +/// Everything a single forward walk can learn about a codepoint offset: +/// where it sits in bytes, which row it lands on, and where that row +/// begins. +const Location = struct { + /// `cp_idx` clamped to the end of the text. + cp: usize, + byte: usize, + row: u32, + row_start: RowStart, +}; + +/// Walk to codepoint `cp_idx`, or to the end of `text` if it is shorter. +/// +/// This exists so the callers below make one pass instead of several. +/// They used to derive the same four numbers independently: a codepoint +/// count here, a newline count there, a backwards scan for the row start. +/// That is a handful of full scans of the snapshot per call, and +/// `extentsCells` is called once per row every time a screen reader +/// rebuilds flat review. +fn locate(text: []const u8, cp_idx: usize) Location { + var byte: usize = 0; + var cp: usize = 0; + var row: u32 = 0; + var row_start: RowStart = .{ .byte = 0, .cp = 0 }; + while (byte < text.len and cp < cp_idx) { + const is_newline = text[byte] == '\n'; + byte += utf8CpLen(text[byte]); + cp += 1; + // A newline ends its row, so the row below starts just past it. + if (is_newline) { + row += 1; + row_start = .{ .byte = byte, .cp = cp }; + } + } + return .{ .cp = cp, .byte = byte, .row = row, .row_start = row_start }; +} + +/// Map a codepoint offset back to the grid position it occupies. This is the +/// inverse of `rowColToCp`, and the one conversion both the caret click +/// and `extentsCells` are built on. +/// +/// An offset past the end of the snapshot lands at the end of the last +/// row rather than failing. +pub fn cpToGrid(text: []const u8, widths: CellWidths, cp_idx: usize) GridPos { + const loc = locate(text, cp_idx); + return .{ + .row = loc.row, + .col = columnOfCp(text, widths, loc.row_start, loc.cp), + }; +} + +/// A position on the terminal grid, in cells. +pub const GridPos = struct { + row: u32, + col: u32, +}; + +/// Convert a widget-space point to a grid position. +/// +/// Negative and non-finite inputs clamp to (0, 0) so the float-to-int +/// conversion stays in range; `max_cells` guards against absurd +/// magnitudes. Callers clamp the result against the actual text bounds. +pub fn pointToGrid(x: f32, y: f32, cell_w: f32, cell_h: f32) GridPos { + const max_cells: f32 = 1_000_000; + const row_f = y / cell_h; + const col_f = x / cell_w; + const row_clamped: f32 = if (std.math.isFinite(row_f)) + @max(0, @min(row_f, max_cells)) + else + 0; + const col_clamped: f32 = if (std.math.isFinite(col_f)) + @max(0, @min(col_f, max_cells)) + else + 0; + return .{ + .row = @intFromFloat(row_clamped), + .col = @intFromFloat(col_clamped), + }; +} + +/// The grid rectangle covered by a codepoint range. Height is always one +/// row: AT clients get one rect per line, and a range spanning rows is +/// reported as its first row only. +pub const GridRect = struct { + row: u32, + col: u32, + width_cols: u32, +}; + +/// Grid rectangle for the codepoint range `[start, end)`. +/// +/// Rows must come out distinct per line: a screen reader's flat review +/// collapses to a single line if every row reports the same Y. +pub fn extentsCells( + text: []const u8, + widths: CellWidths, + start_cp: usize, + end_cp: usize, +) GridRect { + // `locate` clamps `start_cp`, and the walk below stops at the end of + // the text, so neither bound needs clamping against a codepoint count. + const loc = locate(text, start_cp); + + // Width in cells: the columns taken by the codepoints from the start + // up to `end_cp`, stopping at the row end. A zero here means the range + // covered nothing that occupies a cell (an empty range, or combining + // marks alone), and AT clients need a rect they can point at. + var width_cols: u32 = 0; + var byte = loc.byte; + var cp = loc.cp; + while (cp < end_cp and byte < text.len and text[byte] != '\n') { + width_cols += widths.at(cp); + byte += utf8CpLen(text[byte]); + cp += 1; + } + if (width_cols == 0) width_cols = 1; + + return .{ + .row = loc.row, + .col = columnOfCp(text, widths, loc.row_start, loc.cp), + .width_cols = width_cols, + }; +} + +/// Text granularities we resolve. GTK's enum also carries `sentence` and +/// `paragraph`; both map to `line` for a terminal, where a visual row is +/// the only meaningful unit above a word. +pub const Granularity = enum { + character, + word, + line, +}; + +/// A resolved granularity range: codepoint bounds plus the matching +/// slice of the input text. +pub const Contents = struct { + start_cp: usize, + end_cp: usize, + bytes: []const u8, +}; + +/// Resolve the `granularity`-sized run of text containing codepoint +/// `offset_cp`. +/// +/// `offset_cp` arrives from AT-SPI as a codepoint index. Boundary +/// scanning runs on bytes (fast, simple) and converts back to codepoint +/// indices before returning, so callers never see a byte offset. +pub fn contentsAt( + text: []const u8, + offset_cp: usize, + granularity: Granularity, +) Contents { + const text_cp_count = utf8CpCount(text); + const off_cp = @min(offset_cp, text_cp_count); + const off_byte = utf8CpToByte(text, off_cp); + + switch (granularity) { + .character => { + if (off_cp >= text_cp_count) return .{ + .start_cp = text_cp_count, + .end_cp = text_cp_count, + .bytes = text[text.len..], + }; + const end_byte = off_byte + utf8CpLen(text[off_byte]); + return .{ + .start_cp = off_cp, + .end_cp = off_cp + 1, + .bytes = text[off_byte..end_byte], + }; + }, + .word => { + var ws_byte: usize = off_byte; + while (ws_byte > 0 and + text[ws_byte - 1] != ' ' and + text[ws_byte - 1] != '\n') : (ws_byte -= 1) + {} + var we_byte: usize = off_byte; + while (we_byte < text.len and + text[we_byte] != ' ' and + text[we_byte] != '\n') : (we_byte += 1) + {} + return .{ + .start_cp = utf8CpCount(text[0..ws_byte]), + .end_cp = utf8CpCount(text[0..we_byte]), + .bytes = text[ws_byte..we_byte], + }; + }, + .line => { + var ls_byte: usize = off_byte; + while (ls_byte > 0 and text[ls_byte - 1] != '\n') : (ls_byte -= 1) {} + var le_byte: usize = off_byte; + while (le_byte < text.len and text[le_byte] != '\n') : (le_byte += 1) {} + if (le_byte < text.len) le_byte += 1; // include the newline + return .{ + .start_cp = utf8CpCount(text[0..ls_byte]), + .end_cp = utf8CpCount(text[0..le_byte]), + .bytes = text[ls_byte..le_byte], + }; + }, + } +} + +const testing = std.testing; + +// A three-byte codepoint whose leading two bytes are shared with `├` +// (0xE2 0x94 0x82 vs 0xE2 0x94 0x9C). This pair is what makes a +// byte-wise diff land mid-character. +const box_v = "│"; +const box_t = "├"; + +test "utf8: codepoint length, count and index" { + try testing.expectEqual(@as(usize, 1), utf8CpLen('a')); + try testing.expectEqual(@as(usize, 2), utf8CpLen("é"[0])); + try testing.expectEqual(@as(usize, 3), utf8CpLen(box_v[0])); + try testing.expectEqual(@as(usize, 4), utf8CpLen("😀"[0])); + + // Continuation bytes advance by one so a malformed scan terminates. + try testing.expectEqual(@as(usize, 1), utf8CpLen(0x80)); + + const s = "a" ++ box_v ++ "b😀"; + try testing.expectEqual(@as(usize, 4), utf8CpCount(s)); + try testing.expectEqual(@as(usize, 9), s.len); + + try testing.expectEqual(@as(usize, 0), utf8CpToByte(s, 0)); + try testing.expectEqual(@as(usize, 1), utf8CpToByte(s, 1)); + try testing.expectEqual(@as(usize, 4), utf8CpToByte(s, 2)); + try testing.expectEqual(@as(usize, 5), utf8CpToByte(s, 3)); + // Past the end clamps rather than overruns. + try testing.expectEqual(s.len, utf8CpToByte(s, 99)); +} + +test "diff: prefixSuffix on a plain single-character edit" { + const old_text = "hello world"; + const new_text = "hello Xorld"; + const d = prefixSuffix(old_text, new_text); + try testing.expectEqual(@as(usize, 6), d.prefix_len); + try testing.expectEqual(@as(usize, 4), d.suffix_len); +} + +test "diff: prefixSuffix pulls back off a shared multi-byte lead" { + // Both sides start 0xE2 0x94, so a byte-wise prefix lands 2 bytes + // into the character. The result must retreat to the boundary or the + // AT-SPI bridge receives an orphan continuation byte. + const old_text = "a" ++ box_v ++ "z"; + const new_text = "a" ++ box_t ++ "z"; + + const d = prefixSuffix(old_text, new_text); + try testing.expectEqual(@as(usize, 1), d.prefix_len); + try testing.expect(std.unicode.utf8ValidateSlice(old_text[0..d.prefix_len])); + try testing.expect(std.unicode.utf8ValidateSlice(old_text[d.prefix_len .. old_text.len - d.suffix_len])); + try testing.expect(std.unicode.utf8ValidateSlice(new_text[0..d.prefix_len])); +} + +test "diff: prefixSuffix when old is a prefix of new" { + // `p` reaches old_text.len; indexing at `p` would be out of bounds. + const old_text = "abc"; + const new_text = "abcdef"; + const d = prefixSuffix(old_text, new_text); + try testing.expectEqual(@as(usize, 3), d.prefix_len); + try testing.expectEqual(@as(usize, 0), d.suffix_len); +} + +test "diff: prefixSuffix on identical and empty inputs" { + const same = prefixSuffix("abc", "abc"); + try testing.expectEqual(@as(usize, 3), same.prefix_len); + try testing.expectEqual(@as(usize, 0), same.suffix_len); + + const empty = prefixSuffix("", ""); + try testing.expectEqual(@as(usize, 0), empty.prefix_len); + try testing.expectEqual(@as(usize, 0), empty.suffix_len); + + const from_empty = prefixSuffix("", "new"); + try testing.expectEqual(@as(usize, 0), from_empty.prefix_len); + try testing.expectEqual(@as(usize, 0), from_empty.suffix_len); +} + +test "diff: prefix and suffix never overlap" { + // Without the cap, the common prefix and suffix of "aaaa"/"aa" would + // both claim the same bytes and the replaced range would go negative. + const old_text = "aaaa"; + const new_text = "aa"; + const d = prefixSuffix(old_text, new_text); + try testing.expect(d.prefix_len + d.suffix_len <= old_text.len); + try testing.expect(d.prefix_len + d.suffix_len <= new_text.len); +} + +test "diff: scrollK detects a whole-line shift" { + const old_text = "line1\nline2\nline3\n"; + const new_text = "line2\nline3\nline4\n"; + // One line scrolled off: K is the byte just past the first newline. + try testing.expectEqual(@as(usize, 6), scrollK(old_text, new_text)); +} + +test "diff: scrollK rejects a trailing-newline-only match" { + // The zero-length remainder at the final `\n` matches trivially; if + // that were accepted every edit to a newline-terminated buffer would + // be reported as a scroll. + const old_text = "abc\n"; + const new_text = "abz\n"; + try testing.expectEqual(@as(usize, 0), scrollK(old_text, new_text)); +} + +test "diff: scrollK returns 0 with no newline or no match" { + try testing.expectEqual(@as(usize, 0), scrollK("", "anything")); + try testing.expectEqual(@as(usize, 0), scrollK("no newlines", "still none")); + try testing.expectEqual(@as(usize, 0), scrollK("a\nb\n", "totally different")); +} + +test "diff: scrollK detects a downward shift with the arguments swapped" { + // Scrolling back into the scrollback: `line0` arrives at the top and + // `line3` falls off the bottom. Nothing about this matches the upward + // scan, which is why `chooseDiff` runs the scan both ways. + const old_text = "line1\nline2\nline3\n"; + const new_text = "line0\nline1\nline2\n"; + + try testing.expectEqual(@as(usize, 0), scrollK(old_text, new_text)); + try testing.expectEqual(@as(usize, 6), scrollK(new_text, old_text)); +} + +test "diff: choose prefers a shift over replacing the viewport" { + const old_text = "line1\nline2\nline3\n"; + const new_text = "line2\nline3\nline4\n"; + try testing.expectEqual(Diff{ .shift_up = 6 }, chooseDiff(old_text, new_text)); +} + +test "diff: choose reports scrolling back as a downward shift" { + // The regression this whole path exists for. A one-line scroll back + // changes row 0 and drops the last row, so prefix/suffix keeps almost + // nothing and ends up rewriting the viewport twice over, which Orca + // reads out in full instead of announcing the one new line. + const old_text = "line1\nline2\nline3\n"; + const new_text = "line0\nline1\nline2\n"; + + const ps_total = prefixSuffix(old_text, new_text).cost(old_text.len, new_text.len); + const down_total = 6 + (old_text.len - (new_text.len - 6)); + try testing.expect(down_total < ps_total); + + try testing.expectEqual(Diff{ .shift_down = 6 }, chooseDiff(old_text, new_text)); +} + +test "diff: choose handles a multi-line scroll back" { + const old_text = "c\nd\ne\nf\n"; + const new_text = "a\nb\nc\nd\n"; + // Two rows in at the top, two rows off the bottom. + try testing.expectEqual(Diff{ .shift_down = 4 }, chooseDiff(old_text, new_text)); +} + +test "diff: choose keeps typing echo as a replacement" { + // Repeated `$ ` prompts make the last old row a prefix of new, so the + // upward shift scan fires spuriously. Cheapest-wins has to reject it: + // one appended character must not be announced as a scroll. + const old_text = "$ \n$ \n$ "; + const new_text = "$ \n$ \n$ x"; + + const diff = chooseDiff(old_text, new_text); + try testing.expect(diff == .replace); + try testing.expectEqual(@as(usize, 8), diff.replace.prefix_len); + try testing.expectEqual(@as(usize, 0), diff.replace.suffix_len); +} + +test "diff: choose says nothing for identical snapshots" { + try testing.expectEqual(Diff.none, chooseDiff("a\nb\n", "a\nb\n")); + try testing.expectEqual(Diff.none, chooseDiff("", "")); +} + +test "diff: choose survives a viewport with no overlap at all" { + // A full-page jump shares no rows in either direction, so both shift + // scans come up empty and the replacement path has to carry it. + const old_text = "a\nb\nc\n"; + const new_text = "x\ny\nz\n"; + const diff = chooseDiff(old_text, new_text); + try testing.expect(diff == .replace); +} + +test "diff: shift offsets stay on codepoint boundaries" { + // The shift amounts index into the snapshots to build codepoint + // counts, so a multi-byte row must not leave them mid-character. + const old_text = "héllo\n日本語\nend\n"; + const new_text = "日本語\nend\ntail\n"; + + const diff = chooseDiff(old_text, new_text); + try testing.expect(diff == .shift_up); + const k = diff.shift_up; + try testing.expect(std.unicode.utf8ValidateSlice(old_text[0..k])); + try testing.expect(std.unicode.utf8ValidateSlice(old_text[k..])); + + const back = chooseDiff(new_text, old_text); + try testing.expect(back == .shift_down); + const j = back.shift_down; + try testing.expect(std.unicode.utf8ValidateSlice(old_text[0..j])); + try testing.expect(std.unicode.utf8ValidateSlice(old_text[j..])); +} + +test "diff: a downward shift describes a reachable edit" { + // Replay what `axEmitShiftDown` emits and check it reconstructs `new`: + // remove `old[kept..]`, then insert `new[0..j]` at the front. + const old_text = "line1\nline2\nline3\n"; + const new_text = "line0\nline1\nline2\n"; + + const diff = chooseDiff(old_text, new_text); + const j = diff.shift_down; + const kept = new_text.len - j; + try testing.expect(kept <= old_text.len); + + var buf: [64]u8 = undefined; + const rebuilt = try std.fmt.bufPrint(&buf, "{s}{s}", .{ + new_text[0..j], + old_text[0..kept], + }); + try testing.expectEqualStrings(new_text, rebuilt); +} + +test "diff: an upward shift describes a reachable edit" { + const old_text = "line1\nline2\nline3\n"; + const new_text = "line2\nline3\nline4\n"; + + const diff = chooseDiff(old_text, new_text); + const k = diff.shift_up; + + var buf: [64]u8 = undefined; + const rebuilt = try std.fmt.bufPrint(&buf, "{s}{s}", .{ + old_text[k..], + new_text[old_text.len - k ..], + }); + try testing.expectEqualStrings(new_text, rebuilt); +} + +test "grid: rowColToCp counts codepoints, not bytes" { + const text = box_v ++ box_v ++ "\nabc"; + // Row 1 begins 7 bytes in but only 3 codepoints in (two box drawing + // characters plus the newline, which is itself a codepoint). Counting + // bytes here would report 7 and push every offset off the end. + try testing.expectEqual(@as(?usize, 3), rowColToCp(text, .uniform, 1, 0)); + try testing.expectEqual(@as(?usize, 5), rowColToCp(text, .uniform, 1, 2)); + try testing.expectEqual(@as(?usize, 1), rowColToCp(text, .uniform, 0, 1)); +} + +test "grid: rowColToCp clamps column, fails past the last row" { + const text = "ab\ncd"; + try testing.expectEqual(@as(?usize, 5), rowColToCp(text, .uniform, 1, 99)); + try testing.expectEqual(@as(?usize, null), rowColToCp(text, .uniform, 7, 0)); +} + +test "grid: offsetAtGrid clamps instead of failing" { + const text = "ab\ncd"; + // Same in-range answers as rowColToCp... + try testing.expectEqual(@as(usize, 3), offsetAtGrid(text, .uniform, 1, 0)); + // ...but a row past the end resolves into the last row rather than + // returning nothing. + try testing.expectEqual(@as(usize, 3), offsetAtGrid(text, .uniform, 7, 0)); + try testing.expectEqual(@as(usize, 5), offsetAtGrid(text, .uniform, 7, 99)); +} + +// Three double-width characters followed by ASCII: one codepoint each, +// two columns each, so codepoint index and column part ways at the very +// first character. +const wide_row = "日本語ab"; +const wide_widths: CellWidths = .{ .per_cp = &.{ 2, 2, 2, 1, 1 } }; + +test "grid: a wide character spans two columns" { + // 'a' is codepoint 3 but column 6. Reading the codepoint index as a + // column is the bug this exists to prevent: it lands three columns + // to the left, on 語. + try testing.expectEqual(@as(?usize, 3), rowColToCp(wide_row, wide_widths, 0, 6)); + try testing.expectEqual(@as(u32, 6), cpToGrid(wide_row, wide_widths, 3).col); + + // Both cells of a wide character resolve to that character, so a + // click on either half routes to the same place. + try testing.expectEqual(@as(?usize, 1), rowColToCp(wide_row, wide_widths, 0, 2)); + try testing.expectEqual(@as(?usize, 1), rowColToCp(wide_row, wide_widths, 0, 3)); + + // Round trip: every codepoint's column maps back to itself. + for (0..5) |cp| { + const col = cpToGrid(wide_row, wide_widths, cp).col; + try testing.expectEqual(@as(?usize, cp), rowColToCp(wide_row, wide_widths, 0, col)); + } +} + +test "grid: widths are per row, and rows keep their own columns" { + const text = "日本\nab"; + const widths: CellWidths = .{ .per_cp = &.{ 2, 2, 0, 1, 1 } }; + + // Row 0: column 2 is the second wide character. + try testing.expectEqual(@as(?usize, 1), rowColToCp(text, widths, 0, 2)); + // Row 1 starts its column count over, and its offsets are unaffected + // by the wide characters above it. + try testing.expectEqual(@as(?usize, 3), rowColToCp(text, widths, 1, 0)); + try testing.expectEqual(@as(?usize, 4), rowColToCp(text, widths, 1, 1)); + try testing.expectEqual(GridPos{ .row = 1, .col = 1 }, cpToGrid(text, widths, 4)); +} + +test "grid: zero-width codepoints share their base character's cell" { + // "e" + a combining acute: two codepoints, one column. + const text = "e\u{0301}x"; + const widths: CellWidths = .{ .per_cp = &.{ 1, 0, 1 } }; + + // Column 0 resolves to the base character, never to the mark. + try testing.expectEqual(@as(?usize, 0), rowColToCp(text, widths, 0, 0)); + // 'x' follows in the next column even though it is codepoint 2. + try testing.expectEqual(@as(?usize, 2), rowColToCp(text, widths, 0, 1)); + try testing.expectEqual(@as(u32, 1), cpToGrid(text, widths, 2).col); + // The mark itself reports its base character's column. + try testing.expectEqual(@as(u32, 0), cpToGrid(text, widths, 1).col); +} + +test "grid: missing widths degrade to one column per codepoint" { + // Short slices and empty ones read as width 1 past their end, which + // is the pre-widths behaviour rather than an out-of-bounds read. + const short: CellWidths = .{ .per_cp = &.{2} }; + try testing.expectEqual(@as(?usize, 1), rowColToCp(wide_row, short, 0, 2)); + try testing.expectEqual(@as(?usize, 2), rowColToCp(wide_row, short, 0, 3)); + try testing.expectEqual(@as(?usize, 3), rowColToCp(wide_row, .uniform, 0, 3)); +} + +test "grid: cpToGrid clamps an offset past the end" { + const text = "ab\ncd"; + // Past-the-end lands at the end of the last row, not out of bounds. + try testing.expectEqual(GridPos{ .row = 1, .col = 2 }, cpToGrid(text, .uniform, 99)); +} + +test "grid: a newline belongs to the row it terminates" { + // The separator is a codepoint of its own, and the offset *at* it must + // still report the row above; the row below only begins one past it. + // Getting this backwards shifts every extent by a row and puts flat + // review one line out of step with the text it reads. + const text = "ab\ncd\nef"; + try testing.expectEqual(GridPos{ .row = 0, .col = 2 }, cpToGrid(text, .uniform, 2)); + try testing.expectEqual(GridPos{ .row = 1, .col = 0 }, cpToGrid(text, .uniform, 3)); + try testing.expectEqual(GridPos{ .row = 1, .col = 2 }, cpToGrid(text, .uniform, 5)); + try testing.expectEqual(GridPos{ .row = 2, .col = 0 }, cpToGrid(text, .uniform, 6)); + + // `extentsCells` shares the same walk, so it agrees. + try testing.expectEqual(@as(u32, 0), extentsCells(text, .uniform, 2, 3).row); + try testing.expectEqual(@as(u32, 1), extentsCells(text, .uniform, 3, 4).row); +} + +test "grid: pointToGrid survives negative and non-finite input" { + const cw: f32 = 10; + const ch: f32 = 20; + + try testing.expectEqual(GridPos{ .row = 2, .col = 3 }, pointToGrid(35, 55, cw, ch)); + + // Negatives clamp to the origin rather than wrapping through + // @intFromFloat. + try testing.expectEqual(GridPos{ .row = 0, .col = 0 }, pointToGrid(-500, -500, cw, ch)); + + // NaN and both infinities are non-finite, so they take the origin + // fallback rather than the magnitude guard. + const nan = std.math.nan(f32); + const inf = std.math.inf(f32); + try testing.expectEqual(GridPos{ .row = 0, .col = 0 }, pointToGrid(nan, nan, cw, ch)); + try testing.expectEqual(GridPos{ .row = 0, .col = 0 }, pointToGrid(-inf, -inf, cw, ch)); + try testing.expectEqual(GridPos{ .row = 0, .col = 0 }, pointToGrid(inf, inf, cw, ch)); + + // A finite but absurd magnitude saturates at the guard instead of + // overflowing the float-to-int conversion. + const huge = pointToGrid(1e30, 1e30, cw, ch); + try testing.expectEqual(@as(u32, 1_000_000), huge.row); + try testing.expectEqual(@as(u32, 1_000_000), huge.col); +} + +test "extents: each row reports a distinct row index" { + const text = "row0\nrow1\nrow2"; + // Flat review collapses to one line if these ever coincide. + try testing.expectEqual(@as(u32, 0), extentsCells(text, .uniform, 0, 1).row); + try testing.expectEqual(@as(u32, 1), extentsCells(text, .uniform, 5, 6).row); + try testing.expectEqual(@as(u32, 2), extentsCells(text, .uniform, 10, 11).row); +} + +test "extents: column and width are in cells, not bytes" { + const text = box_v ++ box_v ++ "abc"; + // Third codepoint sits at column 2 even though it is byte 6. + const r = extentsCells(text, .uniform, 2, 5); + try testing.expectEqual(@as(u32, 0), r.row); + try testing.expectEqual(@as(u32, 2), r.col); + try testing.expectEqual(@as(u32, 3), r.width_cols); +} + +test "extents: a wide character is two cells wide and shifts what follows" { + // Highlighting 語 must cover both its columns, and 'a' after it must + // start at column 6, or a screen reader's highlight sits a + // character behind the text it is reading. + const wide = extentsCells(wide_row, wide_widths, 2, 3); + try testing.expectEqual(@as(u32, 4), wide.col); + try testing.expectEqual(@as(u32, 2), wide.width_cols); + + const after = extentsCells(wide_row, wide_widths, 3, 5); + try testing.expectEqual(@as(u32, 6), after.col); + try testing.expectEqual(@as(u32, 2), after.width_cols); + + // The whole row: three wide characters plus two narrow ones. + try testing.expectEqual(@as(u32, 8), extentsCells(wide_row, wide_widths, 0, 5).width_cols); +} + +test "extents: width stops at the row end and never reports zero" { + const text = "ab\ncdef"; + // Range spans the newline; width covers only the first row. + const spanning = extentsCells(text, .uniform, 0, 6); + try testing.expectEqual(@as(u32, 2), spanning.width_cols); + + // An empty range still reports one cell so the rect is visible. + const empty = extentsCells(text, .uniform, 1, 1); + try testing.expectEqual(@as(u32, 1), empty.width_cols); + + // So does a range covering only zero-width codepoints: a combining + // mark alone sums to no columns, but an AT client still needs a rect. + const marks: CellWidths = .{ .per_cp = &.{ 1, 0, 1 } }; + try testing.expectEqual(@as(u32, 1), extentsCells("e\u{0301}x", marks, 1, 2).width_cols); +} + +test "contents: character granularity returns one whole codepoint" { + const text = "a" ++ box_v ++ "b"; + const c = contentsAt(text, 1, .character); + try testing.expectEqual(@as(usize, 1), c.start_cp); + try testing.expectEqual(@as(usize, 2), c.end_cp); + try testing.expectEqualStrings(box_v, c.bytes); + try testing.expect(std.unicode.utf8ValidateSlice(c.bytes)); +} + +test "contents: character granularity past the end is empty" { + const text = "abc"; + const c = contentsAt(text, 99, .character); + try testing.expectEqual(@as(usize, 3), c.start_cp); + try testing.expectEqual(@as(usize, 3), c.end_cp); + try testing.expectEqualStrings("", c.bytes); +} + +test "contents: word granularity stops at spaces and newlines" { + const text = "alpha beta\ngamma"; + + const mid = contentsAt(text, 7, .word); + try testing.expectEqualStrings("beta", mid.bytes); + try testing.expectEqual(@as(usize, 6), mid.start_cp); + try testing.expectEqual(@as(usize, 10), mid.end_cp); + + // A word bounded by a newline rather than a space. + const after_nl = contentsAt(text, 12, .word); + try testing.expectEqualStrings("gamma", after_nl.bytes); + try testing.expectEqual(@as(usize, 11), after_nl.start_cp); + + // Sitting on the separator itself walks back to the start of the + // preceding word and stops immediately going forward, so the offset + // resolves to the word to its left rather than to an empty range. + // Pinning this down because it is the boundary case an AT client hits + // when stepping word-by-word across a line. + const on_space = contentsAt(text, 5, .word); + try testing.expectEqualStrings("alpha", on_space.bytes); + try testing.expectEqual(@as(usize, 0), on_space.start_cp); + try testing.expectEqual(@as(usize, 5), on_space.end_cp); +} + +test "contents: line granularity includes the trailing newline" { + const text = "first\nsecond\nthird"; + + const first = contentsAt(text, 2, .line); + try testing.expectEqualStrings("first\n", first.bytes); + try testing.expectEqual(@as(usize, 0), first.start_cp); + try testing.expectEqual(@as(usize, 6), first.end_cp); + + // The final line has no newline to include. + const last = contentsAt(text, 14, .line); + try testing.expectEqualStrings("third", last.bytes); + try testing.expectEqual(@as(usize, 13), last.start_cp); + try testing.expectEqual(@as(usize, 18), last.end_cp); +} + +test "contents: line granularity with multi-byte rows" { + const text = box_v ++ box_v ++ "\nabc"; + const second = contentsAt(text, 3, .line); + try testing.expectEqualStrings("abc", second.bytes); + // Row 1 begins at codepoint 3 (two box chars plus the newline). + try testing.expectEqual(@as(usize, 3), second.start_cp); + try testing.expectEqual(@as(usize, 6), second.end_cp); +} + +test "contents: every granularity yields valid UTF-8 on multi-byte text" { + // The bridge substitutes the literal "[Invalid UTF-8]" for anything + // that fails validation, and a screen reader then speaks it. + const text = box_v ++ " " ++ box_t ++ "x\n😀 tail"; + const cp_count = utf8CpCount(text); + + var off: usize = 0; + while (off <= cp_count) : (off += 1) { + for ([_]Granularity{ .character, .word, .line }) |g| { + const c = contentsAt(text, off, g); + try testing.expect(std.unicode.utf8ValidateSlice(c.bytes)); + try testing.expect(c.start_cp <= c.end_cp); + try testing.expect(c.end_cp <= cp_count); + } + } +} + +/// A test-only model of the AT client's copy of our text. +/// +/// An AT client does not re-read the whole buffer when it changes; it +/// applies the `remove`/`insert` events we emit to the copy it already +/// has. So the property that actually matters is not "is each event +/// plausible" but "does replaying them reproduce the new text exactly". +/// If it does not, the client's copy silently drifts from ours and stays +/// wrong until something makes it rebuild from scratch -- which, in Orca, +/// is a focus change. +const EventModel = struct { + buf: []u8, + + fn init(alloc: std.mem.Allocator, text: []const u8) !EventModel { + return .{ .buf = try alloc.dupe(u8, text) }; + } + + fn deinit(self: *EventModel, alloc: std.mem.Allocator) void { + alloc.free(self.buf); + } + + /// `updateContents(.remove, start_cp, end_cp)`. + fn remove( + self: *EventModel, + alloc: std.mem.Allocator, + start_cp: usize, + end_cp: usize, + ) !void { + const s = utf8CpToByte(self.buf, start_cp); + const e = utf8CpToByte(self.buf, end_cp); + const out = try alloc.alloc(u8, self.buf.len - (e - s)); + @memcpy(out[0..s], self.buf[0..s]); + @memcpy(out[s..], self.buf[e..]); + alloc.free(self.buf); + self.buf = out; + } + + /// `updateContents(.insert, start_cp, end_cp)`. The client reads the + /// inserted bytes back out of us with `axGetContents`, which serves + /// the post-change text -- so the content comes from `new_text` at the + /// same codepoint range the event names. + fn insert( + self: *EventModel, + alloc: std.mem.Allocator, + start_cp: usize, + end_cp: usize, + new_text: []const u8, + ) !void { + const cs = utf8CpToByte(new_text, start_cp); + const ce = utf8CpToByte(new_text, end_cp); + const at = utf8CpToByte(self.buf, start_cp); + const out = try alloc.alloc(u8, self.buf.len + (ce - cs)); + @memcpy(out[0..at], self.buf[0..at]); + @memcpy(out[at..][0 .. ce - cs], new_text[cs..ce]); + @memcpy(out[at + (ce - cs) ..], self.buf[at..]); + alloc.free(self.buf); + self.buf = out; + } +}; + +/// Replay the events `surface.zig` would emit for `chooseDiff(old, new)`. +/// +/// This mirrors `axEmitShiftUp` / `axEmitShiftDown` / `axEmitPrefixSuffix` +/// exactly, including which ranges are skipped when empty. Keep the two in +/// step: if an emit function changes, change this and the fuzz test below +/// will tell you whether the change still round-trips. +fn replayDiff( + alloc: std.mem.Allocator, + old_text: []const u8, + new_text: []const u8, +) ![]u8 { + var model = try EventModel.init(alloc, old_text); + errdefer model.deinit(alloc); + + switch (chooseDiff(old_text, new_text)) { + .none => {}, + + .shift_up => |k| { + const removed_cp = utf8CpCount(old_text[0..k]); + const tail_cp = utf8CpCount(old_text[k..]); + const new_end_cp = utf8CpCount(new_text); + try model.remove(alloc, 0, removed_cp); + if (new_end_cp > tail_cp) { + try model.insert(alloc, tail_cp, new_end_cp, new_text); + } + }, + + .shift_down => |j| { + const kept = new_text.len - j; + const kept_cp = utf8CpCount(old_text[0..kept]); + const old_end_cp = utf8CpCount(old_text); + const inserted_cp = utf8CpCount(new_text[0..j]); + if (old_end_cp > kept_cp) { + try model.remove(alloc, kept_cp, old_end_cp); + } + try model.insert(alloc, 0, inserted_cp, new_text); + }, + + .replace => |ps| { + const p = ps.prefix_len; + const s = ps.suffix_len; + const start_cp = utf8CpCount(old_text[0..p]); + if (old_text.len - p - s != 0) { + try model.remove( + alloc, + start_cp, + utf8CpCount(old_text[0 .. old_text.len - s]), + ); + } + if (new_text.len - p - s != 0) { + try model.insert( + alloc, + start_cp, + utf8CpCount(new_text[0 .. new_text.len - s]), + new_text, + ); + } + }, + } + + return model.buf; +} + +fn expectRoundTrip(old_text: []const u8, new_text: []const u8) !void { + const alloc = testing.allocator; + const got = try replayDiff(alloc, old_text, new_text); + defer alloc.free(got); + testing.expectEqualStrings(new_text, got) catch |err| { + std.debug.print( + "round-trip failed\n old: '{s}'\n new: '{s}'\n got: '{s}'\n diff: {any}\n", + .{ old_text, new_text, got, chooseDiff(old_text, new_text) }, + ); + return err; + }; +} + +test "diff: events round-trip on the shapes we emit by name" { + // Command output: rows leave the top, new rows arrive at the bottom. + try expectRoundTrip("a\nb\nc\nd", "c\nd\ne\nf"); + // Scrolling back: rows arrive at the top, the tail falls off. + try expectRoundTrip("c\nd\ne\nf", "a\nb\nc\nd"); + // Typing echo at the prompt. + try expectRoundTrip("a\nb\n$ ", "a\nb\n$ x"); + // A row rewritten in place (a progress bar). + try expectRoundTrip("a\n[--]\nc", "a\n[##]\nc"); + // Nothing moved. + try expectRoundTrip("a\nb\nc", "a\nb\nc"); + // Empty in both directions. + try expectRoundTrip("", "a\nb"); + try expectRoundTrip("a\nb", ""); + // Multi-byte content shifting, including a row that is pure emoji -- + // the case where a byte-wise prefix/suffix would cut mid-codepoint. + try expectRoundTrip("日本\nx\n😀", "x\n😀\n日本"); + try expectRoundTrip("│a\n├b", "├b\n│a"); +} + +test "diff: events round-trip over generated viewports" { + const alloc = testing.allocator; + // Deliberately includes characters that share leading bytes (│ and ├) + // and a 4-byte codepoint, since the prefix/suffix scan walks bytes and + // then backs up to a boundary. + const glyphs = [_][]const u8{ "a", "b", " ", "$", "é", "日", "│", "├", "😀" }; + + var prng = std.Random.DefaultPrng.init(0x9057e11); + const rand = prng.random(); + + var buf_old: std.ArrayList(u8) = .empty; + defer buf_old.deinit(alloc); + var buf_new: std.ArrayList(u8) = .empty; + defer buf_new.deinit(alloc); + + const genRow = struct { + fn f( + a: std.mem.Allocator, + out: *std.ArrayList(u8), + r: std.Random, + gs: []const []const u8, + ) !void { + const n = r.uintLessThan(usize, 7); + for (0..n) |_| try out.appendSlice(a, gs[r.uintLessThan(usize, gs.len)]); + } + }.f; + + var iter: usize = 0; + while (iter < 3000) : (iter += 1) { + buf_old.clearRetainingCapacity(); + buf_new.clearRetainingCapacity(); + + const rows = 1 + rand.uintLessThan(usize, 10); + var row_starts: [11]usize = undefined; + for (0..rows) |i| { + if (i > 0) try buf_old.append(alloc, '\n'); + row_starts[i] = buf_old.items.len; + try genRow(alloc, &buf_old, rand, &glyphs); + } + const old_text = buf_old.items; + + switch (rand.uintLessThan(u8, 6)) { + // Identical. + 0 => try buf_new.appendSlice(alloc, old_text), + + // Shift up: drop the first `s` rows, append `s` fresh ones. + 1 => { + const s = 1 + rand.uintLessThan(usize, @max(1, rows - 1)); + if (s < rows) try buf_new.appendSlice(alloc, old_text[row_starts[s]..]); + for (0..s) |_| { + if (buf_new.items.len > 0) try buf_new.append(alloc, '\n'); + try genRow(alloc, &buf_new, rand, &glyphs); + } + }, + + // Shift down: prepend `s` fresh rows, drop the last `s`. + 2 => { + const s = 1 + rand.uintLessThan(usize, @max(1, rows - 1)); + for (0..s) |_| { + try genRow(alloc, &buf_new, rand, &glyphs); + try buf_new.append(alloc, '\n'); + } + if (s < rows) { + const end = if (rows - s < rows) row_starts[rows - s] else old_text.len; + const keep = if (end > 0) end - 1 else 0; + try buf_new.appendSlice(alloc, old_text[0..keep]); + } + }, + + // Rewrite one row in place. + 3 => { + const target = rand.uintLessThan(usize, rows); + for (0..rows) |i| { + if (i > 0) try buf_new.append(alloc, '\n'); + if (i == target) { + try genRow(alloc, &buf_new, rand, &glyphs); + } else { + const start = row_starts[i]; + const end = if (i + 1 < rows) row_starts[i + 1] - 1 else old_text.len; + try buf_new.appendSlice(alloc, old_text[start..end]); + } + } + }, + + // Typing echo: extend the last row. + 4 => { + try buf_new.appendSlice(alloc, old_text); + try genRow(alloc, &buf_new, rand, &glyphs); + }, + + // An unrelated screen. + else => { + const n = 1 + rand.uintLessThan(usize, 10); + for (0..n) |i| { + if (i > 0) try buf_new.append(alloc, '\n'); + try genRow(alloc, &buf_new, rand, &glyphs); + } + }, + } + + try expectRoundTrip(old_text, buf_new.items); + } +} diff --git a/src/a11y/text.zig b/src/a11y/text.zig new file mode 100644 index 0000000000..afa88a3dc4 --- /dev/null +++ b/src/a11y/text.zig @@ -0,0 +1,438 @@ +//! Builds the flat UTF-8 text snapshot of a terminal viewport that +//! accessibility clients (AT-SPI on Linux) read. +//! +//! This lives outside of any apprt implementation so the viewport walk +//! can be unit tested and benchmarked without standing up a live GTK +//! surface and an AT-SPI bus. `src/benchmark/A11yText.zig` drives it +//! directly; `apprt/gtk/class/surface_a11y.zig` drives it under the +//! renderer mutex on every rendered frame while an AT client is attached. +//! +//! The snapshot is a plain grid render: one line per viewport row, rows +//! joined by `\n`, trailing blanks on a row dropped and interior blanks +//! kept as spaces so columns still line up. `offsets.zig` is the +//! companion module that navigates the result. +//! +//! IMPORTANT: every offset produced here that is destined for an AT +//! client is in UTF-8 *codepoints*, not bytes. `Result.cursor_byte` is +//! the one exception and is explicitly named as such; convert it with +//! `offsets.utf8CpCount` before handing it out. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const terminal = @import("../terminal/main.zig"); +const offsets = @import("offsets.zig"); + +pub const Options = struct { + /// Where to record how many columns each emitted codepoint occupies + /// (`offsets.CellWidths`). One entry is appended per codepoint + /// appended to the text, so the two stay index-aligned: 2 for a + /// double-width cell, 0 for a combining mark that shares its base + /// character's cell, 1 otherwise. + /// + /// This is the only place the mapping is knowable: the cell walk is + /// what sees `cell.wide`, and the text alone cannot be re-measured + /// afterwards without guessing at the terminal's grapheme-width + /// method. Callers that only need the text (the per-frame change + /// probe, the benchmark) leave it null and pay nothing. + /// + /// Must be empty when `build` is called; `build` only appends. + widths: ?*std.ArrayList(u8) = null, +}; + +pub const Result = struct { + /// Byte offset into the built buffer where the cursor sits, or null + /// if the cursor row wasn't part of the viewport. Callers anchor a + /// null at end-of-text. + cursor_byte: ?usize, + + /// Total codepoints appended to the buffer by this walk. + cp_count: usize, +}; + +/// The snapshot under construction: the text, the per-codepoint column +/// widths that must stay index-aligned with it, and the running codepoint +/// count. +/// +/// Every append goes through here so the three cannot drift apart. They +/// used to be advanced by hand at each append site, which is four places +/// that all had to agree, and a drift silently shifts every column lookup +/// after the offending codepoint. That is the failure mode that put a routed +/// caret inside the character to the left of the one it named. +const Snapshot = struct { + alloc: Allocator, + text: *std.ArrayList(u8), + widths: ?*std.ArrayList(u8), + cp_count: usize = 0, + + /// Byte offset of the next codepoint to be appended. + fn end(self: Snapshot) usize { + return self.text.items.len; + } + + /// Append `n` copies of an ASCII byte, each occupying `columns` + /// screen columns. + fn addAscii( + self: *Snapshot, + byte: u8, + columns: u8, + n: usize, + ) Allocator.Error!void { + try self.text.appendNTimes(self.alloc, byte, n); + try self.advance(columns, n); + } + + /// Append one character: a base codepoint occupying `columns` screen + /// columns, plus any combining marks that share its cell. + fn addCharacter( + self: *Snapshot, + base: u21, + graphemes: ?[]const u21, + columns: u8, + ) Allocator.Error!void { + var buf: [4]u8 = undefined; + const n = std.unicode.utf8Encode(base, &buf) catch { + // Shouldn't happen, since the terminal stores validated + // scalars, but if one does reach us it still has to occupy + // its cell, + // or every offset after it shifts. Its combining marks go + // with it; they have nothing left to combine with. + return self.addAscii('?', columns, 1); + }; + try self.text.appendSlice(self.alloc, buf[0..n]); + try self.advance(columns, 1); + + for (graphemes orelse return) |cp| { + const gn = std.unicode.utf8Encode(cp, &buf) catch continue; + try self.text.appendSlice(self.alloc, buf[0..gn]); + // Part of the base character's cell: extra codepoints, no + // extra columns. + try self.advance(0, 1); + } + } + + fn advance(self: *Snapshot, columns: u8, n: usize) Allocator.Error!void { + if (self.widths) |w| try w.appendNTimes(self.alloc, columns, n); + self.cp_count += n; + } +}; + +/// Walk the viewport of `screen` and append its text to `buffer`. +/// +/// `buffer` is appended to, not cleared, so callers reusing a buffer must +/// clear it themselves. +pub fn build( + alloc: Allocator, + buffer: *std.ArrayList(u8), + screen: *terminal.Screen, + opts: Options, +) Allocator.Error!Result { + const pages = &screen.pages; + const viewport_rows: usize = pages.rows; + + if (opts.widths) |w| std.debug.assert(w.items.len == 0); + var snap: Snapshot = .{ + .alloc = alloc, + .text = buffer, + .widths = opts.widths, + }; + + // Where the AT-SPI caret belongs. Null until the walk reaches the + // cursor's row, and still null afterwards if that row never came out + // of the iterator. + var cursor_byte: ?usize = null; + const cursor_x = screen.cursor.x; + const cursor_y = screen.cursor.y; + + const tl_pin = pages.getTopLeft(.viewport); + var row_it = tl_pin.rowIterator(.right_down, null); + var row_idx: usize = 0; + while (row_idx < viewport_rows) : (row_idx += 1) { + if (row_idx > 0) { + // A row separator, not a cell: it occupies no column, and + // column math never crosses it. + try snap.addAscii('\n', 0, 1); + } + + const pin = row_it.next() orelse continue; + const is_cursor_row = row_idx == cursor_y; + + const cells = pin.cells(.all); + + // A cursor on the tail half of a wide character belongs to the + // character itself, which occupies the preceding column. A spacer + // has no text and is skipped without accumulating a blank, so + // leaving it to the deferred blank-run path below would never + // resolve it and the caret would fall through to end-of-row. + // `spacer_head` is deliberately not folded in: its wide character + // lives on the *next* row, so end-of-row is the right answer there. + const row_cursor_x = if (is_cursor_row and + cursor_x > 0 and + cursor_x < cells.len and + cells[cursor_x].wide == .spacer_tail) + cursor_x - 1 + else + cursor_x; + + // Accumulate empty cells so runs of trailing empties drop off the + // end of the row, but intermediate gaps still get emitted as + // spaces to preserve column positions. This matches what + // `ScreenFormatter` does for non-trailing blanks. + var blank_cells: usize = 0; + // Pending cursor position when the cursor lands on a blank cell: + // index into the current blank run where the cursor sits. + // Resolved to an absolute byte offset either when the blanks + // flush (inside the about-to-be-emitted space run) or at + // end-of-row when the blanks get eaten as trailing (= end of the + // emitted text). + var cursor_blank_idx: ?usize = null; + for (0..cells.len) |col| { + const cell = &cells[col]; + + // Record the cursor position before writing the cell at + // `cursor_x`, so the offset points AT that cell. For blank + // cells we defer: capture the blank-run index and resolve on + // flush or end-of-row. + if (is_cursor_row and col == row_cursor_x) { + if (cell.hasText()) { + cursor_byte = snap.end(); + } else { + cursor_blank_idx = blank_cells; + } + } + + switch (cell.wide) { + .spacer_tail, .spacer_head => continue, + .narrow, .wide => {}, + } + + if (!cell.hasText()) { + blank_cells += 1; + continue; + } + + // Flush accumulated blanks as spaces, one column each. + if (blank_cells > 0) { + // Resolve a cursor that landed inside this blank run to + // its column within the about-to-be-emitted spaces. + if (cursor_blank_idx) |idx| { + cursor_byte = snap.end() + idx; + cursor_blank_idx = null; + } + try snap.addAscii(' ', 1, blank_cells); + blank_cells = 0; + } + + // Columns this cell covers on screen. `.wide` cells are + // followed by a `.spacer_tail` that the switch above skipped, + // so the character is one codepoint standing in for two + // columns, which is the whole reason widths cannot be recovered from + // the text later. + const cell_columns: u8 = if (cell.wide == .wide) 2 else 1; + + try snap.addCharacter( + cell.codepoint(), + if (cell.hasGrapheme()) pin.grapheme(cell) else null, + cell_columns, + ); + } + + // If the cursor is past the last column we emitted (trailing + // blanks, or a cursor beyond the row end), anchor to end-of-row. + if (is_cursor_row and row_cursor_x >= cells.len) { + cursor_byte = snap.end(); + } + + // The cursor landed inside a blank run that never flushed, so those + // cells are trailing and got eaten. Anchor to the end of the text + // emitted on this row. + if (cursor_blank_idx != null) { + cursor_byte = snap.end(); + cursor_blank_idx = null; + } + } + + // `Snapshot` keeps these in step by construction; this catches an + // append that went around it. + if (opts.widths) |w| std.debug.assert(w.items.len == snap.cp_count); + + return .{ + .cursor_byte = cursor_byte, + .cp_count = snap.cp_count, + }; +} + +const testing = std.testing; + +test "a11y text: rows joined by newline, trailing blanks trimmed" { + const alloc = testing.allocator; + var t = try terminal.Terminal.init(testing.io, alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + try t.printString("hello"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("world"); + + var buffer: std.ArrayList(u8) = .empty; + defer buffer.deinit(alloc); + + const result = try build(alloc, &buffer, t.screens.active, .{}); + + // Row 2 is empty, so it contributes its leading '\n' and nothing else. + try testing.expectEqualStrings("hello\nworld\n", buffer.items); + try testing.expectEqual(@as(usize, 12), result.cp_count); +} + +test "a11y text: widths report the columns each codepoint covers" { + const alloc = testing.allocator; + var t = try terminal.Terminal.init(testing.io, alloc, .{ .cols = 20, .rows = 2 }); + defer t.deinit(alloc); + + // Three double-width characters between narrow ones. The wide cells + // each get a spacer the walk skips, so the text is one codepoint per + // character while the row is 5 + 6 + 5 columns wide. + try t.printString("wide 日本語 tail"); + + var buffer: std.ArrayList(u8) = .empty; + defer buffer.deinit(alloc); + var widths: std.ArrayList(u8) = .empty; + defer widths.deinit(alloc); + + const result = try build( + alloc, + &buffer, + t.screens.active, + .{ .widths = &widths }, + ); + + // One width per codepoint, or every lookup past the drift is wrong. + try testing.expectEqual(result.cp_count, widths.items.len); + + // "wide " then 日本語 then " tail", plus the row separator. + try testing.expectEqualSlices( + u8, + &.{ 1, 1, 1, 1, 1, 2, 2, 2, 1, 1, 1, 1, 1, 0 }, + widths.items, + ); + + // The payoff: 't' of "tail" is codepoint 9 but column 12. Reading the + // codepoint index as a column is what put a routed caret three cells + // to the left, inside 語. + const t_byte = std.mem.indexOf(u8, buffer.items, "tail").?; + const cp_idx = offsets.utf8CpCount(buffer.items[0..t_byte]); + try testing.expectEqual(@as(usize, 9), cp_idx); + try testing.expectEqual( + @as(u32, 12), + offsets.cpToGrid(buffer.items, .{ .per_cp = widths.items }, cp_idx).col, + ); +} + +test "a11y text: recording widths does not change the text" { + // The per-frame change probe builds without widths and compares the + // result against the last notified snapshot, which was built *with* + // them. That gate is only valid if the two paths agree byte for byte. + const alloc = testing.allocator; + var t = try terminal.Terminal.init(testing.io, alloc, .{ .cols = 20, .rows = 3 }); + defer t.deinit(alloc); + + try t.printString("héllo 日本語"); + t.carriageReturn(); + try t.linefeed(); + try t.printString(" spaced out"); + + var with: std.ArrayList(u8) = .empty; + defer with.deinit(alloc); + var widths: std.ArrayList(u8) = .empty; + defer widths.deinit(alloc); + const with_result = try build(alloc, &with, t.screens.active, .{ .widths = &widths }); + + var without: std.ArrayList(u8) = .empty; + defer without.deinit(alloc); + const without_result = try build(alloc, &without, t.screens.active, .{}); + + try testing.expectEqualStrings(with.items, without.items); + try testing.expectEqual(with_result.cp_count, without_result.cp_count); + try testing.expectEqual(with_result.cursor_byte, without_result.cursor_byte); +} + +test "a11y text: intermediate blanks preserved as spaces" { + const alloc = testing.allocator; + var t = try terminal.Terminal.init(testing.io, alloc, .{ .cols = 10, .rows = 1 }); + defer t.deinit(alloc); + + try t.printString("ab"); + t.setCursorPos(1, 6); + try t.printString("cd"); + + var buffer: std.ArrayList(u8) = .empty; + defer buffer.deinit(alloc); + + _ = try build(alloc, &buffer, t.screens.active, .{}); + try testing.expectEqualStrings("ab cd", buffer.items); +} + +test "a11y text: cursor anchors past trailing blanks" { + const alloc = testing.allocator; + var t = try terminal.Terminal.init(testing.io, alloc, .{ .cols = 10, .rows = 2 }); + defer t.deinit(alloc); + + try t.printString("hi"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("abc"); + + var buffer: std.ArrayList(u8) = .empty; + defer buffer.deinit(alloc); + + const result = try build(alloc, &buffer, t.screens.active, .{}); + + // "hi\nabc", with the cursor on the blank after "abc". That is + // trailing, so it anchors at end-of-text. + try testing.expectEqualStrings("hi\nabc", buffer.items); + try testing.expectEqual(@as(usize, 6), result.cursor_byte.?); +} + +test "a11y text: cursor inside an interior blank run lands on its column" { + const alloc = testing.allocator; + var t = try terminal.Terminal.init(testing.io, alloc, .{ .cols = 10, .rows = 1 }); + defer t.deinit(alloc); + + try t.printString("ab"); + t.setCursorPos(1, 7); + try t.printString("cd"); + // Park the cursor on column 4 (0-based 3), inside the blank gap that + // gets flushed as spaces rather than eaten as trailing. + t.setCursorPos(1, 4); + + var buffer: std.ArrayList(u8) = .empty; + defer buffer.deinit(alloc); + + const result = try build(alloc, &buffer, t.screens.active, .{}); + + try testing.expectEqualStrings("ab cd", buffer.items); + // Byte 3 is the second of the four spaces, the cell the cursor is + // on, not the start or the end of the run. + try testing.expectEqual(@as(usize, 3), result.cursor_byte.?); +} + +test "a11y text: cursor on a wide character's spacer lands on the character" { + const alloc = testing.allocator; + var t = try terminal.Terminal.init(testing.io, alloc, .{ .cols = 10, .rows = 1 }); + defer t.deinit(alloc); + + // 日 occupies columns 2 and 3; column 3 is its spacer tail. + try t.printString("ab日cd"); + t.setCursorPos(1, 4); + + var buffer: std.ArrayList(u8) = .empty; + defer buffer.deinit(alloc); + + const result = try build(alloc, &buffer, t.screens.active, .{}); + + try testing.expectEqualStrings("ab日cd", buffer.items); + // The cursor is visually sitting on 日, so it must resolve to that + // character's own offset. A spacer has no text and is skipped without + // accumulating a blank, so the deferred blank-run path can never + // resolve it and it would otherwise fall through to end-of-row. + try testing.expectEqual(@as(usize, 2), result.cursor_byte.?); +} diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index 86cfecc05a..afae0eea3a 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -1003,6 +1003,7 @@ pub fn addSimd( "src/simd/base64.cpp", "src/simd/codepoint_width.cpp", "src/simd/index_of.cpp", + "src/simd/utf8_count.cpp", "src/simd/vt.cpp", }, .flags = flags.items, diff --git a/src/simd/main.zig b/src/simd/main.zig index 8a3f00670a..aea6b9aab0 100644 --- a/src/simd/main.zig +++ b/src/simd/main.zig @@ -8,6 +8,7 @@ pub const base64 = @import("base64.zig"); pub const index_of = @import("index_of.zig"); pub const vt = @import("vt.zig"); pub const codepointWidth = codepoint_width.codepointWidth; +pub const countUtf8 = @import("utf8_count.zig").countUtf8; /// The number of vector lanes to use for manually vectorized hot /// loops operating on elements of type T, or null if the target has diff --git a/src/simd/utf8_count.cpp b/src/simd/utf8_count.cpp new file mode 100644 index 0000000000..16c153f5ac --- /dev/null +++ b/src/simd/utf8_count.cpp @@ -0,0 +1,9 @@ +#include + +extern "C" { + +size_t ghostty_simd_count_utf8(const char* input, size_t length) { + return simdutf::count_utf8(input, length); +} + +} // extern "C" diff --git a/src/simd/utf8_count.zig b/src/simd/utf8_count.zig new file mode 100644 index 0000000000..7431cda18b --- /dev/null +++ b/src/simd/utf8_count.zig @@ -0,0 +1,35 @@ +const std = @import("std"); +const options = @import("build_options"); + +// utf8_count.cpp +extern "c" fn ghostty_simd_count_utf8([*]const u8, usize) usize; + +/// Count UTF-8 codepoints in `s`, which must be valid UTF-8. On +/// malformed input both paths still terminate, but their counts can +/// differ, so validate first when the source is untrusted. +pub fn countUtf8(s: []const u8) usize { + if (comptime options.simd) return ghostty_simd_count_utf8(s.ptr, s.len); + return countUtf8Scalar(s); +} + +fn countUtf8Scalar(s: []const u8) usize { + var i: usize = 0; + var n: usize = 0; + while (i < s.len) : (n += 1) i += std.unicode.utf8ByteSequenceLength(s[i]) catch 1; + return n; +} + +test "countUtf8 simd and scalar agree" { + const testing = std.testing; + const cases = [_][]const u8{ + "", + "hello world", + "a│b├😀 é plain mix", + "─" ** 200, + "😀" ** 50, + }; + for (cases) |s| { + try testing.expectEqual(countUtf8Scalar(s), countUtf8(s)); + try testing.expectEqual(std.unicode.utf8CountCodepoints(s) catch unreachable, countUtf8(s)); + } +} From 756be6dc6c8cd5f8e48587823718250fbce8074b Mon Sep 17 00:00:00 2001 From: Alexander Epaneshnikov Date: Wed, 12 Aug 2026 18:16:21 +0300 Subject: [PATCH 2/4] bench: measure the accessibility snapshot walk The GTK apprt rebuilds this snapshot while holding the renderer mutex, so like ScreenClone it is a lock holder that costs the IO thread directly. Two modes mirror the two real per-frame paths: `probe` is the change gate every rendered frame pays, and `text` is the full rebuild only a frame that actually changed pays. `noop` iterates the same rows building nothing, to separate iteration overhead from the work. On this machine, ReleaseFast, best of three: probe 4.0us at 30x80 12.3us at 60x200 text 5.4us at 30x80 15.6us at 60x200 Co-Authored-By: Claude Opus 5 --- src/benchmark/A11yText.zig | 262 +++++++++++++++++++++++++++++++++++++ src/benchmark/cli.zig | 2 + src/benchmark/main.zig | 1 + 3 files changed, 265 insertions(+) create mode 100644 src/benchmark/A11yText.zig diff --git a/src/benchmark/A11yText.zig b/src/benchmark/A11yText.zig new file mode 100644 index 0000000000..a204f258d2 --- /dev/null +++ b/src/benchmark/A11yText.zig @@ -0,0 +1,262 @@ +//! This benchmark tests the performance of building the accessibility +//! text snapshot of a viewport (`a11y.text.build`). +//! +//! This matters because the GTK apprt walks the viewport on every +//! rendered frame while an AT client (Orca) is attached, and it does so +//! while holding `renderer_state.mutex`, so like `ScreenClone` this is +//! a lock holder that directly impacts IO throughput. +//! +//! The two modes that matter mirror the two real per-frame paths: +//! `probe` is `A11y.probeChanged`, which every rendered frame pays, and +//! `text` is `A11y.refreshCache`, which only a frame that actually changed +//! pays. +//! +//! What this does NOT measure: the AT-SPI D-Bus traffic that follows a +//! change, which needs a live GTK app and bus. The numbers here are a +//! floor on the real per-frame cost, not the whole of it. +const A11yText = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const terminalpkg = @import("../terminal/main.zig"); +const a11y = @import("../a11y/main.zig"); +const Benchmark = @import("Benchmark.zig"); +const options = @import("options.zig"); +const Terminal = terminalpkg.Terminal; +const global = @import("../global.zig"); + +const log = std.log.scoped(.@"a11y-text-bench"); + +opts: Options, +alloc: Allocator, +terminal: Terminal, +/// Retained scratch + reference snapshot for `probe` mode, mirroring +/// `probe_buf` and `last_snapshot` on the GTK surface a11y state. +probe_buf: std.ArrayList(u8) = .empty, +probe_snapshot: std.ArrayList(u8) = .empty, +/// Per-codepoint cell widths, mirroring `cached_widths`. Only the +/// `text` mode fills this, because only `A11y.refreshCache` asks for it. +widths: std.ArrayList(u8) = .empty, + +pub const Options = struct { + /// The type of snapshot work to perform. + mode: Mode = .text, + + /// Multiplier on the number of iterations each step runs. This is + /// useful to make a benchmark run long enough for profiling. + loops: u32 = 1, + + /// The size of the terminal. The snapshot walk is proportional to + /// the viewport cell count, so this is the primary scaling knob. + @"terminal-rows": u16 = 80, + @"terminal-cols": u16 = 120, + + /// The data to read as a filepath. If this is "-" then we will read + /// stdin. If this is unset, we synthesize a viewport containing + /// styled text and wide characters. The time to read this is not + /// part of the benchmark. + data: ?[]const u8 = null, +}; + +pub const Mode = enum { + /// Baseline: iterate the same viewport rows without building + /// anything. Isolates iteration overhead from the snapshot work. + noop, + + /// The per-frame change probe: text into a retained scratch buffer, + /// then a memcmp against the previous snapshot. No allocation, no + /// widths. This is what an *unchanged* frame costs once the change + /// gate is in place, and it is the number that matters most, because most + /// frames don't change. + probe, + + /// The full rebuild: the viewport walk with cell widths recorded, + /// plus the NUL-terminated dup that gets cached. This is what a + /// frame that actually changed costs. + text, +}; + +pub fn create( + alloc: Allocator, + opts: Options, +) !*A11yText { + const ptr = try alloc.create(A11yText); + errdefer alloc.destroy(ptr); + + ptr.* = .{ + .opts = opts, + .alloc = alloc, + .terminal = try .init(global.io(), alloc, .{ + .rows = opts.@"terminal-rows", + .cols = opts.@"terminal-cols", + }), + }; + + return ptr; +} + +pub fn destroy(self: *A11yText, alloc: Allocator) void { + self.probe_buf.deinit(alloc); + self.probe_snapshot.deinit(alloc); + self.widths.deinit(alloc); + self.terminal.deinit(alloc); + alloc.destroy(self); +} + +pub fn benchmark(self: *A11yText) Benchmark { + return .init(self, .{ + .stepFn = switch (self.opts.mode) { + .noop => stepNoop, + .probe => stepProbe, + .text => stepText, + }, + .setupFn = setup, + }); +} + +fn setup(ptr: *anyopaque) Benchmark.Error!void { + const self: *A11yText = @ptrCast(@alignCast(ptr)); + + // Always reset our terminal state + self.terminal.fullReset(); + + const data_f: ?std.Io.File = options.dataFile( + self.opts.data, + ) catch |err| { + log.warn("error opening data file err={}", .{err}); + return error.BenchmarkFailed; + }; + + if (data_f) |f| { + defer f.close(global.io()); + + var stream = self.terminal.vtStream(); + defer stream.deinit(); + + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; + var f_reader = f.reader(global.io(), &read_buf); + const r = &f_reader.interface; + + var buf: [4096]u8 = undefined; + while (true) { + const n = r.readSliceShort(&buf) catch { + log.warn("error reading data file err={?}", .{f_reader.err}); + return error.BenchmarkFailed; + }; + if (n == 0) break; // EOF reached + stream.nextSlice(buf[0..n]); + } + } else { + // No data file: synthesize a viewport that exercises every branch + // of the walk: a styled run, double-width cells, an interior + // blank gap and trailing blanks. + var s = self.terminal.vtStream(); + defer s.deinit(); + for (0..self.terminal.rows) |i| { + if (i > 0) s.nextSlice("\r\n"); + s.nextSlice("\x1b[38;2;200;100;50mstyled\x1b[0m plain "); + s.nextSlice("日本語 wide gap"); + } + } + + // Reference snapshot for `probe` mode, built out here so the timed + // loop measures only the probe itself. + self.probe_snapshot.clearRetainingCapacity(); + _ = a11y.text.build( + self.alloc, + &self.probe_snapshot, + self.terminal.screens.active, + .{}, + ) catch |err| { + log.warn("error building probe snapshot err={}", .{err}); + return error.BenchmarkFailed; + }; +} + +/// Iterations per step. The walk is proportional to the viewport cell +/// count, so this is far lower than the cheap per-call benchmarks. +fn iterations(self: *const A11yText) u64 { + return 100 * @as(u64, self.opts.loops); +} + +fn stepNoop(ptr: *anyopaque) Benchmark.Error!void { + const self: *A11yText = @ptrCast(@alignCast(ptr)); + + for (0..iterations(self)) |_| { + const screen: *terminalpkg.Screen = self.terminal.screens.active; + const pages = &screen.pages; + const tl_pin = pages.getTopLeft(.viewport); + var row_it = tl_pin.rowIterator(.right_down, null); + var row_idx: usize = 0; + while (row_idx < pages.rows) : (row_idx += 1) { + const pin = row_it.next() orelse continue; + const cells = pin.cells(.all); + std.mem.doNotOptimizeAway(cells.len); + } + } +} + +/// Models `A11y.probeChanged`: retained scratch buffer, text only, then the +/// memcmp against the last notified snapshot. This is the gate that lets +/// an unchanged frame skip the rebuild entirely. +fn stepProbe(ptr: *anyopaque) Benchmark.Error!void { + const self: *A11yText = @ptrCast(@alignCast(ptr)); + + for (0..iterations(self)) |_| { + self.probe_buf.clearRetainingCapacity(); + + const result = a11y.text.build( + self.alloc, + &self.probe_buf, + self.terminal.screens.active, + .{}, + ) catch |err| { + log.warn("error probing a11y text err={}", .{err}); + return error.BenchmarkFailed; + }; + + const changed = !std.mem.eql( + u8, + self.probe_snapshot.items, + self.probe_buf.items, + ); + + std.mem.doNotOptimizeAway(changed); + std.mem.doNotOptimizeAway(result.cp_count); + } +} + +/// Mirrors what `A11y.refreshCache` does on a frame that changed: a fresh +/// buffer, the viewport walk with widths recorded, and the final +/// NUL-terminated dup that gets cached. The allocation churn is +/// intentional: the real path pays it too. +fn stepText(ptr: *anyopaque) Benchmark.Error!void { + const self: *A11yText = @ptrCast(@alignCast(ptr)); + + for (0..iterations(self)) |_| { + var buffer: std.ArrayList(u8) = .empty; + defer buffer.deinit(self.alloc); + self.widths.clearRetainingCapacity(); + + const result = a11y.text.build( + self.alloc, + &buffer, + self.terminal.screens.active, + .{ .widths = &self.widths }, + ) catch |err| { + log.warn("error building a11y text err={}", .{err}); + return error.BenchmarkFailed; + }; + + const text = self.alloc.dupeZ(u8, buffer.items) catch |err| { + log.warn("error duplicating a11y text err={}", .{err}); + return error.BenchmarkFailed; + }; + defer self.alloc.free(text); + + std.mem.doNotOptimizeAway(result.cp_count); + std.mem.doNotOptimizeAway(text.len); + std.mem.doNotOptimizeAway(self.widths.items.len); + } +} diff --git a/src/benchmark/cli.zig b/src/benchmark/cli.zig index f76dfc4b70..e2290d426d 100644 --- a/src/benchmark/cli.zig +++ b/src/benchmark/cli.zig @@ -7,6 +7,7 @@ const global = @import("../global.zig"); /// benchmarks. View docs for each individual one in the predictably /// named files. pub const Action = enum { + @"a11y-text", @"apc-parser", @"codepoint-width", @"grapheme-break", @@ -32,6 +33,7 @@ pub const Action = enum { /// See TerminalStream for an example. pub fn Struct(comptime action: Action) type { return switch (action) { + .@"a11y-text" => @import("A11yText.zig"), .@"apc-parser" => @import("ApcParser.zig"), .@"hyperlink-map" => @import("HyperlinkMap.zig"), .@"screen-clone" => @import("ScreenClone.zig"), diff --git a/src/benchmark/main.zig b/src/benchmark/main.zig index 2c1f31f522..fa7f46d747 100644 --- a/src/benchmark/main.zig +++ b/src/benchmark/main.zig @@ -1,6 +1,7 @@ pub const cli = @import("cli.zig"); pub const Benchmark = @import("Benchmark.zig"); pub const CApi = @import("CApi.zig"); +pub const A11yText = @import("A11yText.zig"); pub const TerminalStream = @import("TerminalStream.zig"); pub const CodepointWidth = @import("CodepointWidth.zig"); pub const GraphemeBreak = @import("GraphemeBreak.zig"); From 9e5dd8f0bc769e97250357d680105cbd0ec26ae3 Mon Sep 17 00:00:00 2001 From: Alexander Epaneshnikov Date: Wed, 12 Aug 2026 18:16:22 +0300 Subject: [PATCH 3/4] gtk: identify the application and window to AT-SPI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent reasons a screen reader currently cannot find a Ghostty window at all. The AT-SPI bridge reads g_get_prgname() for the application object's Name property and g_get_application_name() for its Description. We set neither, so we appear as "Unnamed" and cannot be located by name. Set both before any GTK or libadwaita initialization, since g_set_application_name is one-shot and the earliest setter wins. Separately, the accessible role has to be passed as a construct-time property. Setting it with gtk_widget_class_set_accessible_role does not propagate to the AT context, which goes on reporting the default `widget` role — AT-SPI "filler". Screen readers locate a window by looking for a frame, so a filler at the top of the tree leaves them nothing to descend into. Both are visible in accerciser without a screen reader installed. Co-Authored-By: Claude Opus 5 --- src/apprt/gtk/class/application.zig | 19 +++++++++++++++++++ src/apprt/gtk/class/window.zig | 8 ++++++++ 2 files changed, 27 insertions(+) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 0b630b6e63..a13b83087e 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -261,6 +261,21 @@ pub const Application = extern struct { // logging system rather than just getting dumped directly to stderr. _ = glib.logSetWriterFunc(glibLogWriterFunction, null, null); + // Seed the program name and application name before any GTK / + // libadwaita initialization. The AT-SPI bridge reads + // `g_get_prgname()` for the application object's "Name" property + // (`gtkatspiroot.c`: `g_variant_new_string (g_get_prgname () ? + // g_get_prgname () : "Unnamed")`) and `g_get_application_name()` + // for "Description". Without prgname we appear to screen readers + // and to accerciser as "Unnamed", and can't be located by name. + // + // On X11, `winproto/x11.zig` may override prgname later to derive + // WM_CLASS, which is fine; on Wayland this stays as "Ghostty". + // `g_set_application_name` is one-shot, so setting it this early + // also guarantees it wins over anything GTK would otherwise pick. + glib.setPrgname("Ghostty"); + glib.setApplicationName("Ghostty"); + // Log our GTK versions gtk_version.logVersion(); adw_version.logVersion(); @@ -3161,6 +3176,10 @@ const Action = struct { const win = gobject.ext.newInstance(Window, .{ .application = self, .@"quick-terminal" = true, + // Seed the accessible role for the same reason `Window.new` + // does; this path builds its instance directly and would + // otherwise get the default `.widget` role. + .@"accessible-role" = gtk.AccessibleRole.window, }); assert(win.isQuickTerminal()); initAndShowWindow(self, win, null, .none); diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index 287f4f44cd..fe9d8bd0d2 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -294,8 +294,16 @@ pub const Window = extern struct { pub const none: @This() = .{}; }, ) *Self { + // Pass `accessible-role` as a construct-time property so the AT + // context GTK creates for this instance is seeded with the correct + // role. Setting it via `gtk_widget_class_set_accessible_role` does + // NOT propagate to the AT context here: the context still reports + // `.widget`, which maps to the AT-SPI role "filler". Screen readers + // locate a window by looking for a frame, so a filler at the top of + // the tree leaves them with nothing to descend into. const win = gobject.ext.newInstance(Self, .{ .application = app, + .@"accessible-role" = gtk.AccessibleRole.window, }); if (overrides.title) |title| { From cc283a830a03e2c3b6d95d90ee76203210b40f70 Mon Sep 17 00:00:00 2001 From: Alexander Epaneshnikov Date: Wed, 12 Aug 2026 18:16:23 +0300 Subject: [PATCH 4/4] gtk: expose terminal contents via GtkAccessibleText Implement the read side of GtkAccessibleText on GhosttySurface, which is what lets Orca and other AT-SPI clients read a Ghostty terminal at all. The surface serves its viewport as text, reports where the cursor is, resolves points to offsets and offsets to on-screen rectangles, and emits change events as the terminal produces output. Focus and key handling move from the inner GLArea onto GhosttySurface. A screen reader's flat review reads the focused object, and the bare GLArea has role `panel` and carries no text interface, so focusing it gave clients nothing to read. Moving focus up means the focused object is the one with role `terminal` and the text interface on it. This is the part of the change most likely to affect sighted users and wants a look from someone who can see the pointer: click-to-focus, click-drag selection, middle-click paste, and focusing a split by clicking it. Change events are gated twice, because getting this wrong is the difference between a usable terminal and an unusable one. A cheap probe rebuilds only the text and compares it against the last notified snapshot, so an unchanged frame does no work beyond that; and when something did change, the diff is reduced to the smallest remove/insert pair that describes it. Emitting the whole viewport per frame makes a screen reader spin in a read-interrupt-read loop and never finish a sentence, and emitting it per keystroke makes Orca's terminal script read typing echo as command output. Extents are reported per row in widget coordinates. Flat review groups zones into lines by their Y coordinate, so rows that report the same Y collapse into one line; and Ghostty draws in device pixels, so on a scaled display the numbers have to be converted or every row claims to be larger than it is and rows fall outside the widget and get dropped. Selection, text attributes and links are not implemented here. GTK's default_init installs implementations for those slots that decline, and the two it does not cover are null-checked by their public wrappers, so they read to a client as "this object has no selection / no attributes" rather than crashing the bridge. Co-Authored-By: Claude Opus 5 --- src/apprt/gtk/class/surface.zig | 70 ++- src/apprt/gtk/class/surface_a11y.zig | 617 +++++++++++++++++++++++++++ src/apprt/gtk/ui/1.2/surface.blp | 31 +- src/build/SharedDeps.zig | 1 + 4 files changed, 694 insertions(+), 25 deletions(-) create mode 100644 src/apprt/gtk/class/surface_a11y.zig diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index cc77266176..db87ce7a7f 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -22,6 +22,7 @@ const gresource = @import("../build/gresource.zig"); const ext = @import("../ext.zig"); const gsettings = @import("../gsettings.zig"); const gtk_key = @import("../key.zig"); +const gtk_version = @import("../gtk_version.zig"); const ApprtSurface = @import("../Surface.zig"); const Common = @import("../class.zig").Common; const Application = @import("application.zig").Application; @@ -29,6 +30,7 @@ const Config = @import("config.zig").Config; const ResizeOverlay = @import("resize_overlay.zig").ResizeOverlay; const SearchOverlay = @import("search_overlay.zig").SearchOverlay; const KeyStateOverlay = @import("key_state_overlay.zig").KeyStateOverlay; +const A11y = @import("surface_a11y.zig"); const ChildExited = @import("surface_child_exited.zig").SurfaceChildExited; const ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig").ClipboardConfirmationDialog; const TitleDialog = @import("title_dialog.zig").TitleDialog; @@ -37,7 +39,6 @@ const InspectorWindow = @import("inspector_window.zig").InspectorWindow; const SplitTree = @import("split_tree.zig").SplitTree; const i18n = @import("../../../os/i18n.zig"); const global = @import("../../../global.zig"); -const gtk_version = @import("../gtk_version.zig"); const log = std.log.scoped(.gtk_ghostty_surface); @@ -45,7 +46,11 @@ pub const Surface = extern struct { const Self = @This(); parent_instance: Parent, pub const Parent = adw.Bin; - pub const Implements = [_]type{gtk.Scrollable}; + pub const Implements = [_]type{ + gtk.Scrollable, + gtk.Accessible, + gtk.AccessibleText, + }; pub const getGObjectType = gobject.ext.defineClass(Self, .{ .name = "GhosttySurface", .instanceInit = &init, @@ -54,6 +59,17 @@ pub const Surface = extern struct { .private = .{ .Type = Private, .offset = &Private.offset }, .implements = &.{ gobject.ext.implement(gtk.Scrollable, .{}), + // Re-implement GtkAccessible (already provided by GtkWidget + // ancestry) so we can override `get_first_accessible_child` + // and present as a single text-bearing object. The iface_init + // receives the parent's already-populated vtable and only + // overrides that one child-walk hook. + gobject.ext.implement(gtk.Accessible, .{ + .init = &A11y.initAccessibleInterface, + }), + gobject.ext.implement(gtk.AccessibleText, .{ + .init = &A11y.initAccessibleTextInterface, + }), }, }); @@ -695,6 +711,9 @@ pub const Surface = extern struct { action_group: ?*gio.SimpleActionGroup = null, + // Accessibility state for GtkAccessibleText; see surface_a11y.zig. + a11y: A11y = .{}, + // Gtk.Scrollable interface adjustments hadj: ?*gtk.Adjustment = null, vadj: ?*gtk.Adjustment = null, @@ -768,6 +787,11 @@ pub const Surface = extern struct { return priv.core_surface; } + /// The accessibility state for this surface. + pub fn a11y(self: *Self) *A11y { + return &self.private().a11y; + } + pub fn rt(self: *Self) *ApprtSurface { const priv = self.private(); return &priv.rt_surface; @@ -1755,8 +1779,11 @@ pub const Surface = extern struct { /// Focus this surface. This properly focuses the input part of /// our surface. pub fn grabFocus(self: *Self) void { - const priv = self.private(); - _ = priv.gl_area.as(gtk.Widget).grabFocus(); + // Focus the GhosttySurface itself (not the inner GLArea) so the + // focused object exposes role=terminal and GtkAccessibleText to + // AT-SPI. The EventControllerKey attached to the template root + // receives key events from this focus target. + _ = self.as(gtk.Widget).grabFocus(); } pub fn sendDesktopNotification(self: *Self, title: [:0]const u8, body: [:0]const u8) void { @@ -1814,6 +1841,7 @@ pub const Surface = extern struct { priv.mapped = false; priv.size = .{ .width = 0, .height = 0 }; priv.vadj_signal_group = null; + priv.a11y = .{}; // If our configuration is null then we get the configuration // from the application. @@ -2002,6 +2030,8 @@ pub const Surface = extern struct { for (priv.key_tables.items) |s| alloc.free(s); priv.key_tables.deinit(alloc); + priv.a11y.deinit(alloc); + gobject.Object.virtual_methods.finalize.call( Class.parent, self.as(Parent), @@ -2848,11 +2878,12 @@ pub const Surface = extern struct { const priv = self.private(); const core_surface = priv.core_surface orelse return; - // If we don't have focus, grab it. - const gl_area_widget = priv.gl_area.as(gtk.Widget); - const had_focus = gl_area_widget.hasFocus() != 0; + // If we don't have focus, grab it. Focus is tracked on the + // GhosttySurface itself (see `grabFocus`), not the GLArea. + const widget = self.as(gtk.Widget); + const had_focus = widget.hasFocus() != 0; if (!had_focus) { - _ = gl_area_widget.grabFocus(); + _ = widget.grabFocus(); } // Report the event @@ -2985,13 +3016,14 @@ pub const Surface = extern struct { @abs(priv.cursor_pos.y - pos.y) < 1; if (is_cursor_still) return; - // If we don't have focus, and we want it, grab it. + // If we don't have focus, and we want it, grab it. Focus lives + // on the GhosttySurface itself, not the inner GLArea. if (priv.config) |config| { - const gl_area_widget = priv.gl_area.as(gtk.Widget); - if (gl_area_widget.hasFocus() == 0 and + const widget = self.as(gtk.Widget); + if (widget.hasFocus() == 0 and config.get().@"focus-follows-mouse") { - _ = gl_area_widget.grabFocus(); + _ = widget.grabFocus(); } } @@ -3425,6 +3457,11 @@ pub const Surface = extern struct { return 0; }; + // Notify AT-SPI clients of any content change, gated so that an + // unchanged frame emits nothing and an AT client is never + // interrupted mid-read by a cursor blink. See `A11y.frameRendered`. + priv.a11y.frameRendered(self); + return 1; } @@ -3496,6 +3533,7 @@ pub const Surface = extern struct { fn initSurface(self: *Self) InitError!void { const priv: *Private = self.private(); assert(priv.core_surface == null); + const gl_area = priv.gl_area; // We need to make the context current so we can call GL functions. @@ -3639,7 +3677,7 @@ pub const Surface = extern struct { _ = surface.performBindingAction(.end_search) catch |err| { log.warn("unable to perform end_search action err={}", .{err}); }; - _ = self.private().gl_area.as(gtk.Widget).grabFocus(); + _ = self.as(gtk.Widget).grabFocus(); } fn searchChanged(_: *SearchOverlay, needle: ?[*:0]const u8, self: *Self) callconv(.c) void { @@ -3852,6 +3890,12 @@ pub const Surface = extern struct { gobject.ext.ensureType(SearchOverlay); gobject.ext.ensureType(KeyStateOverlay); gobject.ext.ensureType(ChildExited); + + // Set the accessible role to terminal so screen readers + // like Orca know how to handle this widget. This matches + // GTK_ACCESSIBLE_ROLE_TERMINAL added in GTK 4.14. + gtk.WidgetClass.setAccessibleRole(class.as(gtk.Widget.Class), .terminal); + gtk.Widget.Class.setTemplateFromResource( class.as(gtk.Widget.Class), comptime gresource.blueprint(.{ diff --git a/src/apprt/gtk/class/surface_a11y.zig b/src/apprt/gtk/class/surface_a11y.zig new file mode 100644 index 0000000000..9038b125e1 --- /dev/null +++ b/src/apprt/gtk/class/surface_a11y.zig @@ -0,0 +1,617 @@ +//! Accessibility support for the Surface class: the GtkAccessibleText +//! implementation and its state, embedded in the Surface's private data. +//! +//! Every offset crossing this boundary is a UTF-8 codepoint index, never +//! a byte index, per the AT-SPI Text contract. `a11y.offsets` owns that +//! arithmetic; the viewport walk lives in `a11y.text`. +const A11y = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const glib = @import("glib"); +const gobject = @import("gobject"); +const graphene = @import("graphene"); +const gtk = @import("gtk"); + +const a11y = @import("../../../a11y/main.zig"); +const global = @import("../../../global.zig"); +const terminal = @import("../../../terminal/main.zig"); +const gtk_version = @import("../gtk_version.zig"); +const Application = @import("application.zig").Application; +const Surface = @import("surface.zig").Surface; + +const log = std.log.scoped(.gtk_ghostty_surface_a11y); + +/// Viewport text served to AT reads. Served until a rendered frame +/// marks it stale, so offsets stay consistent across a multi-call read. +cached_text: ?[:0]const u8 = null, + +/// Columns occupied by each codepoint of `cached_text`, filled by the +/// same walk that builds it. Read through `cellWidths`. +cached_widths: std.ArrayList(u8) = .empty, + +/// Caret offset in codepoints within `cached_text`. +cached_cursor_offset: c_uint = 0, + +/// Latched on the first AT query, never cleared: GTK has no "the last +/// AT client went away" signal. Gates the per-frame change probe. +active: bool = false, + +/// Viewport text at the last change notification. Change events are +/// diffed against this so clients see minimal insert/remove ranges +/// rather than a whole-viewport replacement on every keystroke. +last_snapshot: ?[:0]const u8 = null, + +/// Caret offset at the last caret notification. +last_notified_caret: c_uint = 0, + +/// Set on every rendered frame; cleared when the cache is confirmed +/// to still match the viewport. +cache_stale: bool = false, + +/// Retained scratch for `probeChanged` so the per-frame probe does +/// not allocate once it has grown to viewport size. +probe_buf: std.ArrayList(u8) = .empty, + +pub fn deinit(self: *A11y, alloc: Allocator) void { + self.probe_buf.deinit(alloc); + self.cached_widths.deinit(alloc); + if (self.cached_text) |v| alloc.free(v); + if (self.last_snapshot) |v| alloc.free(v); + self.* = .{}; +} + +/// Called by the surface after every rendered frame: mark the cache +/// stale and, if an AT client has ever queried us, emit change events. +/// +/// Narrower gates than `active` are wrong: focus would silence +/// unfocused splits, and `org.a11y.Status.IsEnabled` is a hint ATs +/// write, not proof one is listening. The latch costs one probe per +/// rendered frame (6.7us at 30x80, 17.6us at 60x200, per +/// `ghostty-bench +a11y-text --mode=probe`); idle surfaces pay nothing. +pub fn frameRendered(self: *A11y, surface: *Surface) void { + self.cache_stale = true; + if (self.active) self.notifyIfChanged(surface); +} + +//--------------------------------------------------------------- +// GtkAccessible interface override + +pub fn initAccessibleInterface(iface: *gtk.AccessibleInterface) callconv(.c) void { + iface.f_get_first_accessible_child = &getFirstAccessibleChild; +} + +/// Return no accessible children: GTK's default walks the widget tree +/// and exposes the GLArea and every template descendant to AT-SPI. The +/// surface presents as a single text-bearing object instead. +fn getFirstAccessibleChild( + _: *gtk.Accessible, +) callconv(.c) ?*gtk.Accessible { + return null; +} + +//--------------------------------------------------------------- +// GtkAccessibleText interface implementation + +/// Install the parts of GtkAccessibleText this surface implements. +/// Slots left alone keep GTK's `default_init` implementations, which +/// decline gracefully (no selection, no attributes). +pub fn initAccessibleTextInterface(iface: *gtk.AccessibleTextInterface) callconv(.c) void { + // Present since 4.14, which is the oldest GTK we run against. + iface.f_get_contents = &getContents; + iface.f_get_contents_at = &getContentsAt; + iface.f_get_caret_position = &getCaretPosition; + + // `get_extents` and `get_offset` are 4.16 additions. + // While they are accessible with current bindings, we need to make sure + // we do not access invalid memory when running with older GTK versions + // by writing to fields that don't yet exist. + if (gtk_version.runtimeAtLeast(4, 16, 0)) { + iface.f_get_extents = &getExtents; + iface.f_get_offset = &getOffset; + } +} + +/// Return the extents of a text range, in widget coordinates (see +/// `deviceScale`). Orca's flat review groups text into lines by Y +/// coordinate, so each viewport row needs a distinct rect: +/// Y = row * cell_height. +fn getExtents( + accessible: *gtk.AccessibleText, + start: c_uint, + end: c_uint, + extents: *graphene.Rect, +) callconv(.c) c_int { + const surface = gobject.ext.cast(Surface, accessible) orelse return 0; + const self = surface.a11y(); + const core_surface = surface.core() orelse return 0; + + const text = self.refreshCache(surface) orelse return 0; + + const cell_w: f32 = @floatFromInt(core_surface.size.cell.width); + const cell_h: f32 = @floatFromInt(core_surface.size.cell.height); + if (cell_w <= 0 or cell_h <= 0) return 0; + + const rect = a11y.offsets.extentsCells(text, self.cellWidths(), start, end); + const scale = deviceScale(surface); + + // Cell (0,0) is not at widget-local (0,0): the renderer leaves a + // padding gutter that has to be added back here. + const pad_left: f32 = @floatFromInt(core_surface.size.padding.left); + const pad_top: f32 = @floatFromInt(core_surface.size.padding.top); + + extents.f_origin.f_x = + (pad_left + @as(f32, @floatFromInt(rect.col)) * cell_w) / scale; + extents.f_origin.f_y = + (pad_top + @as(f32, @floatFromInt(rect.row)) * cell_h) / scale; + extents.f_size.f_width = + @as(f32, @floatFromInt(rect.width_cols)) * cell_w / scale; + extents.f_size.f_height = cell_h / scale; + return 1; +} + +/// Device pixels per widget pixel: `core_surface.size` is in device +/// pixels, GTK accessibility coordinates are widget space. Not +/// `getContentScale`, which folds in the gtk-xft-dpi font scale. +fn deviceScale(surface: *Surface) f32 { + const scale = surface.as(gtk.Widget).getScaleFactor(); + if (scale <= 0) return 1.0; + return @floatFromInt(scale); +} + +/// Map a widget-space point to a codepoint offset within the cached +/// text. Inverse of `getExtents`. Out-of-range points clamp, so the +/// AT client always gets an in-range offset when we return TRUE. +fn getOffset( + accessible: *gtk.AccessibleText, + point: *const graphene.Point, + out_offset: *c_uint, +) callconv(.c) c_int { + const surface = gobject.ext.cast(Surface, accessible) orelse { + out_offset.* = 0; + return 0; + }; + const self = surface.a11y(); + const core_surface = surface.core() orelse { + out_offset.* = 0; + return 0; + }; + + const text = self.refreshCache(surface) orelse { + out_offset.* = 0; + return 0; + }; + + const cell_w: f32 = @floatFromInt(core_surface.size.cell.width); + const cell_h: f32 = @floatFromInt(core_surface.size.cell.height); + if (cell_w <= 0 or cell_h <= 0) { + out_offset.* = 0; + return 0; + } + + // Exact inverse of `getExtents`: back into device pixels, then out + // of the renderer's gutter. `pointToGrid` clamps negatives to cell + // (0,0), so a point inside the padding lands on the first cell. + const scale = deviceScale(surface); + const pad_left: f32 = @floatFromInt(core_surface.size.padding.left); + const pad_top: f32 = @floatFromInt(core_surface.size.padding.top); + const grid = a11y.offsets.pointToGrid( + point.f_x * scale - pad_left, + point.f_y * scale - pad_top, + cell_w, + cell_h, + ); + out_offset.* = @intCast(a11y.offsets.offsetAtGrid( + text, + self.cellWidths(), + grid.row, + grid.col, + )); + return 1; +} + +/// Cell widths for the current `cached_text`, for converting between +/// codepoint offsets and grid positions. Only meaningful after +/// `refreshCache` has returned text; empty reads as one column per +/// codepoint. +fn cellWidths(self: *A11y) a11y.offsets.CellWidths { + return .{ .per_cp = self.cached_widths.items }; +} + +/// Return the cached viewport text, rebuilding it first if a rendered +/// frame marked it stale. Deliberately not a TTL: that could refresh +/// mid-read, shifting offsets under the client. The text is one visual +/// row per `\n`-delimited line, including blank rows, so every row has +/// a range flat review can point at. +fn refreshCache(self: *A11y, surface: *Surface) ?[:0]const u8 { + // Mark that an AT client is actively querying us. + self.active = true; + + // A frame rendered since this cache was built and nothing has + // since confirmed it still matches, so drop it. On a focused + // surface `notifyIfChanged` normally clears the mark first; this + // path keeps an unfocused surface's on-demand reads fresh. + const alloc = Application.default().allocator(); + if (self.cache_stale) { + if (self.cached_text) |old| { + alloc.free(old); + self.cached_text = null; + } + self.cache_stale = false; + } + + if (self.cached_text != null) return self.cached_text; + + const core_surface = surface.core() orelse return null; + + // Widths are rebuilt in lockstep with the text below; `build` + // requires an empty list and only appends. + self.cached_widths.clearRetainingCapacity(); + + var buffer: std.ArrayList(u8) = .empty; + defer buffer.deinit(alloc); + + // The renderer mutex covers only the walk; everything after this + // point reads `buffer`, which is our own memory. + const result = result: { + core_surface.renderer_state.mutex.lockUncancelable(global.io()); + defer core_surface.renderer_state.mutex.unlock(global.io()); + + const screen: *terminal.Screen = + core_surface.renderer_state.terminal.screens.active; + + break :result a11y.text.build( + alloc, + &buffer, + screen, + .{ .widths = &self.cached_widths }, + ) catch |err| { + log.warn("ax text build failed: {}", .{err}); + return null; + }; + }; + + const text = alloc.dupeZ(u8, buffer.items) catch return null; + self.cached_text = text; + + // AT-SPI wants the caret in codepoints; the walk reports a byte + // position. A cursor row outside the viewport has no byte offset + // and anchors at end-of-text. + const cursor_byte = @min(result.cursor_byte orelse text.len, text.len); + self.cached_cursor_offset = @intCast(utf8CpCount(text[0..cursor_byte])); + + return self.cached_text; +} + +/// Cheap per-frame check for "could anything an AT client cares about +/// have changed?": build only the text into the retained scratch +/// buffer and compare it and the caret against the last notification. +/// Conservative: may answer true on a frame the full path then finds +/// nothing to emit for, never false when something moved. +fn probeChanged(self: *A11y, surface: *Surface) bool { + const core_surface = surface.core() orelse return false; + + // No snapshot yet means we have never notified; take the full + // path so the first frame establishes one. + const old_text: []const u8 = self.last_snapshot orelse return true; + + const alloc = Application.default().allocator(); + self.probe_buf.clearRetainingCapacity(); + + const result = result: { + core_surface.renderer_state.mutex.lockUncancelable(global.io()); + defer core_surface.renderer_state.mutex.unlock(global.io()); + + const screen: *terminal.Screen = + core_surface.renderer_state.terminal.screens.active; + + break :result a11y.text.build( + alloc, + &self.probe_buf, + screen, + .{}, + ) catch |err| { + // Probing failed; fall back to the full path rather than + // risk swallowing a change. + log.warn("ax probe build failed: {}", .{err}); + return true; + }; + }; + + const text = self.probe_buf.items; + if (!std.mem.eql(u8, old_text, text)) return true; + + // Text is identical, so the caret's byte offset converts against + // the same bytes the cache was built from. + const cursor_byte = @min(result.cursor_byte orelse text.len, text.len); + const caret_cp: c_uint = @intCast(utf8CpCount(text[0..cursor_byte])); + return caret_cp != self.last_notified_caret; +} + +/// Emit AT-SPI change events if the viewport text or caret moved since +/// the last notification. We diff against the last notified snapshot +/// and emit the smallest remove/insert pair: firing unconditionally +/// would make an AT client restart reading on every rendered frame, +/// and Orca's terminal script classifies events by `any_data` length, +/// so a one-character insert reads as typing echo, not command output. +fn notifyIfChanged(self: *A11y, surface: *Surface) void { + const alloc = Application.default().allocator(); + + // On an unchanged frame the cache still describes the viewport: + // retract this frame's staleness mark rather than making the next + // AT read pay for a rebuild. + if (!self.probeChanged(surface)) { + self.cache_stale = false; + return; + } + + // Drop the cache so `refreshCache` rebuilds from the live + // viewport; the cache is the sole source of truth for AT reads. + if (self.cached_text) |old| { + alloc.free(old); + self.cached_text = null; + } + const new_text = self.refreshCache(surface) orelse return; + const old_text: []const u8 = self.last_snapshot orelse ""; + + const text_changed = !std.mem.eql(u8, old_text, new_text); + const caret_changed = self.cached_cursor_offset != self.last_notified_caret; + + // First notification against an empty viewport: `text_changed` is + // false because both sides are "", but the probe keys off having a + // snapshot at all. Record one here or every later frame takes the + // full rebuild path. + if (self.last_snapshot == null and !text_changed) { + self.last_snapshot = alloc.dupeZ(u8, new_text) catch null; + } + + if (!text_changed and !caret_changed) return; + + if (text_changed) { + self.emitTextDiff(surface, old_text, new_text); + + // Keep our own copy rather than aliasing `cached_text`: the + // cache is freed and rewritten on every refresh. + if (self.last_snapshot) |prev| alloc.free(prev); + self.last_snapshot = alloc.dupeZ(u8, new_text) catch null; + } + if (caret_changed) { + self.last_notified_caret = self.cached_cursor_offset; + gtk.AccessibleText.updateCaretPosition(surface.as(gtk.AccessibleText)); + } +} + +/// Cache state displaced by `aliasOldSnapshot`. +const AliasedCache = struct { + text: ?[:0]const u8, + cursor: c_uint, + widths: std.ArrayList(u8), +}; + +/// Point the cache at the pre-change snapshot for the duration of a +/// `.remove` emit: GTK's bridge fills `any_data` by calling back into +/// `getContents` synchronously, and for a remove the client expects +/// the *deleted* substring, which the cache no longer holds. The +/// displaced widths go empty (one column per codepoint); nothing +/// inside the emit can rebuild the cache behind our back. +fn aliasOldSnapshot(self: *A11y) AliasedCache { + const saved: AliasedCache = .{ + .text = self.cached_text, + .cursor = self.cached_cursor_offset, + .widths = self.cached_widths, + }; + self.cached_text = self.last_snapshot; + self.cached_cursor_offset = self.last_notified_caret; + self.cached_widths = .empty; + return saved; +} + +fn restoreCache(self: *A11y, saved: AliasedCache) void { + self.cached_text = saved.text; + self.cached_cursor_offset = saved.cursor; + self.cached_widths = saved.widths; +} + +/// Prefix/suffix diff: bytes `[0..p)` and `[len-s..)` are unchanged, +/// the rest was replaced, with both cuts on codepoint boundaries. +const PrefixSuffixDiff = a11y.offsets.PrefixSuffixDiff; + +/// Fire the AT-SPI events for the change from `old_text` to +/// `new_text`. `chooseDiff` owns the shape choice (pure arithmetic, +/// unit-tested). A line shift is a `.remove` at one end plus an +/// `.insert` at the other, matching what VTE exposes to Orca; a +/// whole-viewport replacement would be re-read in full on every scroll. +fn emitTextDiff(self: *A11y, surface: *Surface, old_text: []const u8, new_text: []const u8) void { + switch (a11y.offsets.chooseDiff(old_text, new_text)) { + .none => {}, + .shift_up => |k| self.emitShiftUp(surface, old_text, new_text, k), + .shift_down => |j| self.emitShiftDown(surface, old_text, new_text, j), + .replace => |ps| self.emitPrefixSuffix(surface, old_text, new_text, ps), + } +} + +/// Emit `.remove(0, K_cp)` + `.insert(tail_cp, |new|_cp)` for an +/// upward line shift. `scroll_k` is > 0 and lands on a `\n` boundary +/// of `old` (guaranteed by `chooseDiff`). +fn emitShiftUp( + self: *A11y, + surface: *Surface, + old_text: []const u8, + new_text: []const u8, + scroll_k: usize, +) void { + const accessible = surface.as(gtk.AccessibleText); + + const removed_cp: c_uint = @intCast(utf8CpCount(old_text[0..scroll_k])); + const tail_cp: c_uint = @intCast(utf8CpCount(old_text[scroll_k..])); + const new_end_cp: c_uint = @intCast(utf8CpCount(new_text)); + + // The bridge reads the deleted range back out of us synchronously, + // so the cache must point at the pre-remove text for the call. + { + const saved = self.aliasOldSnapshot(); + defer self.restoreCache(saved); + gtk.AccessibleText.updateContents(accessible, .remove, 0, removed_cp); + } + + if (new_end_cp > tail_cp) { + gtk.AccessibleText.updateContents(accessible, .insert, tail_cp, new_end_cp); + } +} + +/// Emit `.remove(kept_cp, |old|_cp)` + `.insert(0, J_cp)` for a +/// downward line shift (scrolling back through output). Remove first: +/// after it the exposed text is exactly `new[scroll_j..]`, so the +/// insert offset needs no adjustment. +fn emitShiftDown( + self: *A11y, + surface: *Surface, + old_text: []const u8, + new_text: []const u8, + scroll_j: usize, +) void { + const accessible = surface.as(gtk.AccessibleText); + + // Bytes of `old` that survive the shift. `chooseDiff` guarantees + // `old_text[0..kept] == new_text[scroll_j..]`. + const kept = new_text.len - scroll_j; + const kept_cp: c_uint = @intCast(utf8CpCount(old_text[0..kept])); + const old_end_cp: c_uint = @intCast(utf8CpCount(old_text)); + const inserted_cp: c_uint = @intCast(utf8CpCount(new_text[0..scroll_j])); + + if (old_end_cp > kept_cp) { + const saved = self.aliasOldSnapshot(); + defer self.restoreCache(saved); + gtk.AccessibleText.updateContents(accessible, .remove, kept_cp, old_end_cp); + } + + gtk.AccessibleText.updateContents(accessible, .insert, 0, inserted_cp); +} + +/// Emit a single remove+insert pair covering the region between the +/// common prefix and common suffix of `old` and `new`. +fn emitPrefixSuffix( + self: *A11y, + surface: *Surface, + old_text: []const u8, + new_text: []const u8, + diff: PrefixSuffixDiff, +) void { + const p = diff.prefix_len; + const s = diff.suffix_len; + const removed_len = old_text.len - p - s; + const inserted_len = new_text.len - p - s; + + const accessible = surface.as(gtk.AccessibleText); + const start_cp: c_uint = @intCast(utf8CpCount(old_text[0..p])); + if (removed_len != 0) { + const saved = self.aliasOldSnapshot(); + defer self.restoreCache(saved); + const end_cp: c_uint = @intCast(utf8CpCount(old_text[0..(old_text.len - s)])); + gtk.AccessibleText.updateContents(accessible, .remove, start_cp, end_cp); + } + if (inserted_len != 0) { + const end_cp: c_uint = @intCast(utf8CpCount(new_text[0..(new_text.len - s)])); + gtk.AccessibleText.updateContents(accessible, .insert, start_cp, end_cp); + } +} + +fn getContents( + accessible: *gtk.AccessibleText, + start: c_uint, + end: c_uint, +) callconv(.c) *glib.Bytes { + const surface = gobject.ext.cast(Surface, accessible) orelse return emptyBytes(); + const self = surface.a11y(); + const text = self.refreshCache(surface) orelse return emptyBytes(); + + // `start`/`end` are codepoint indices per the AT-SPI Text + // contract; convert to byte offsets before slicing so the result + // is valid UTF-8. + const byte_start = utf8CpToByte(text, @intCast(start)); + const byte_end = utf8CpToByte(text, @intCast(end)); + if (byte_start >= byte_end) return emptyBytes(); + + return bytesNulTerm(text[byte_start..byte_end]); +} + +/// A NUL-terminated empty `GBytes`. A string literal points at static +/// storage; `&[_:0]u8{}` looks equivalent but is only valid for the +/// enclosing scope and can dangle by the time `g_bytes_new` copies it. +fn emptyBytes() *glib.Bytes { + return glib.Bytes.new("", 1); +} + +/// Wrap `slice` in a freshly-allocated, NUL-terminated `GBytes`. +/// GTK's bridge hands the payload to `g_variant_new_string`, which +/// needs NUL termination (despite the `get_contents` docs) or it reads +/// past the allocation, and returns NULL on invalid UTF-8, crashing the +/// AT-SPI bridge — so broken input becomes an empty result instead. +fn bytesNulTerm(slice: []const u8) *glib.Bytes { + if (!std.unicode.utf8ValidateSlice(slice)) { + log.warn("accessibility text is not valid UTF-8, ignoring", .{}); + return emptyBytes(); + } + + const alloc = Application.default().allocator(); + const buf = alloc.dupeSentinel(u8, slice, 0) catch return emptyBytes(); + defer alloc.free(buf); + // g_bytes_new copies, so `buf` can be freed when this returns. + return glib.Bytes.new(buf.ptr, slice.len + 1); +} + +// UTF-8 codepoint offset helpers; `a11y.offsets` owns the arithmetic +// and the tests that pin it down. +const utf8CpCount = a11y.offsets.utf8CpCount; +const utf8CpToByte = a11y.offsets.utf8CpToByte; + +fn getContentsAt( + accessible: *gtk.AccessibleText, + offset: c_uint, + granularity: gtk.AccessibleTextGranularity, + out_start: *c_uint, + out_end: *c_uint, +) callconv(.c) *glib.Bytes { + const surface = gobject.ext.cast(Surface, accessible) orelse { + out_start.* = 0; + out_end.* = 0; + return emptyBytes(); + }; + const self = surface.a11y(); + const text = self.refreshCache(surface) orelse { + out_start.* = 0; + out_end.* = 0; + return emptyBytes(); + }; + + // A terminal has no sentences or paragraphs distinct from its + // visual rows, so both fold into `.line`. Unknown values (the + // non-exhaustive `_`) get an empty range at the requested offset. + const g: a11y.offsets.Granularity = switch (granularity) { + .character => .character, + .word => .word, + .line, .paragraph, .sentence => .line, + _ => { + const text_cp_count: c_uint = @intCast(utf8CpCount(text)); + const off_cp = @min(offset, text_cp_count); + out_start.* = off_cp; + out_end.* = off_cp; + return emptyBytes(); + }, + }; + + const contents = a11y.offsets.contentsAt(text, offset, g); + out_start.* = @intCast(contents.start_cp); + out_end.* = @intCast(contents.end_cp); + if (contents.bytes.len == 0) return emptyBytes(); + return bytesNulTerm(contents.bytes); +} + +fn getCaretPosition( + accessible: *gtk.AccessibleText, +) callconv(.c) c_uint { + const surface = gobject.ext.cast(Surface, accessible) orelse return 0; + const self = surface.a11y(); + _ = self.refreshCache(surface); + return self.cached_cursor_offset; +} diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index 010798ae57..29e595766b 100644 --- a/src/apprt/gtk/ui/1.2/surface.blp +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -30,8 +30,8 @@ Overlay terminal_page { resize => $gl_resize(); hexpand: true; vexpand: true; - focusable: true; - focus-on-click: true; + focusable: false; + focus-on-click: false; has-stencil-buffer: false; has-depth-buffer: false; allowed-apis: gl; @@ -45,16 +45,6 @@ Overlay terminal_page { has-arrow: false; } - EventControllerFocus { - enter => $focus_enter(); - leave => $focus_leave(); - } - - EventControllerKey { - key-pressed => $key_pressed(); - key-released => $key_released(); - } - EventControllerScroll { scroll => $scroll_vertical(); scroll-begin => $scroll_vertical_begin(); @@ -292,6 +282,23 @@ template $GhosttySurface: Adw.Bin { notify::mouse-hidden => $notify_mouse_hidden(); notify::mouse-shape => $notify_mouse_shape(); notify::vadjustment => $notify_vadjustment(); + // Focus lands on GhosttySurface (not the inner GLArea) so the focused + // object carries our role=terminal and GtkAccessibleText interface. + // Orca's restricted flat review only looks at the focused object, so + // focusing the bare GLArea (role=panel) gave it nothing to read. + focusable: true; + focus-on-click: true; + + EventControllerFocus { + enter => $focus_enter(); + leave => $focus_leave(); + } + + EventControllerKey { + key-pressed => $key_pressed(); + key-released => $key_released(); + } + // Some history: we used to use a Stack here and swap between the // terminal and error pages as needed. But a Stack doesn't play nice // with our SplitTree and Gtk.Paned usage[^1]. Replacing this with diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index afae0eea3a..f133816aab 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -721,6 +721,7 @@ fn addGtkNg( .{ "glib", "glib2" }, .{ "glibunix", "glibunix2" }, .{ "gobject", "gobject2" }, + .{ "graphene", "graphene1" }, .{ "gtk", "gtk4" }, .{ "xlib", "xlib2" }, };