diff --git a/src/apprt/a11y_offsets.zig b/src/apprt/a11y_offsets.zig new file mode 100644 index 0000000000..2fb441784b --- /dev/null +++ b/src/apprt/a11y_offsets.zig @@ -0,0 +1,1370 @@ +//! Offset math over the flat accessibility text snapshot produced by +//! `a11y_text.build`. +//! +//! Where `a11y_text` 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"); + +/// Byte length of a UTF-8 codepoint given its leading byte. +/// +/// ASCII and stray continuation bytes both advance 1, so a scan over +/// malformed input still makes forward progress instead of looping. That +/// only ever matters as a safety net: the snapshots this module reads are +/// built codepoint by codepoint and are always well-formed. +pub fn utf8CpLen(b: u8) usize { + if (b < 0xC0) return 1; + if (b < 0xE0) return 2; + if (b < 0xF0) return 3; + return 4; +} + +/// Count UTF-8 codepoints in `s`. +pub fn utf8CpCount(s: []const u8) usize { + var i: usize = 0; + var n: usize = 0; + while (i < s.len) : (n += 1) i += utf8CpLen(s[i]); + return n; +} + +/// 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; +} + +/// Byte offset of the first byte that does not begin a valid UTF-8 +/// sequence, or null when `slice` validates cleanly. +/// +/// Callers use this purely for diagnostics: our own snapshots are sliced +/// on codepoint boundaries, so a hit means the buffer itself is corrupt +/// and the offset identifies where. +pub fn firstInvalidUtf8(slice: []const u8) ?usize { + if (std.unicode.utf8ValidateSlice(slice)) return null; + + var bad: usize = 0; + while (bad < slice.len) { + const len = utf8CpLen(slice[bad]); + if (bad + len > slice.len) break; + if (!std.unicode.utf8ValidateSlice(slice[bad..][0..len])) break; + bad += len; + } + return bad; +} + +/// 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 `a11y_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 "utf8: firstInvalidUtf8 locates the bad byte" { + try testing.expectEqual(@as(?usize, null), firstInvalidUtf8("hello " ++ box_v)); + try testing.expectEqual(@as(?usize, null), firstInvalidUtf8("")); + + // Lone continuation byte after three valid ASCII bytes. + try testing.expectEqual(@as(?usize, 3), firstInvalidUtf8("abc\x80def")); + + // Truncated three-byte sequence at the end of the buffer. + try testing.expectEqual(@as(?usize, 2), firstInvalidUtf8("ab\xE2\x94")); +} + +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/apprt/a11y_text.zig b/src/apprt/a11y_text.zig new file mode 100644 index 0000000000..98d63671c2 --- /dev/null +++ b/src/apprt/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 single 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.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. `a11y_offsets` 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 +//! `a11y_offsets.utf8CpCount` before handing it out. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const terminal = @import("../terminal/main.zig"); +const offsets = @import("a11y_offsets.zig"); + +pub const Options = struct { + /// Where to record how many columns each emitted codepoint occupies + /// (`a11y_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/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/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..f53734cc7f --- /dev/null +++ b/src/apprt/gtk/class/surface_a11y.zig @@ -0,0 +1,635 @@ +//! 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 `apprt.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_text = @import("../../a11y_text.zig"); +const a11y_offsets = @import("../../a11y_offsets.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. Invalid UTF-8 becomes an empty result, logged: +/// glib would otherwise substitute "[Invalid UTF-8]", which Orca speaks. +fn bytesNulTerm(slice: []const u8) *glib.Bytes { + if (std.unicode.utf8ValidateSlice(slice)) { + const alloc = Application.default().allocator(); + const buf = alloc.alloc(u8, slice.len + 1) catch return emptyBytes(); + defer alloc.free(buf); + @memcpy(buf[0..slice.len], slice); + buf[slice.len] = 0; + // g_bytes_new copies, so `buf` can be freed when this returns. + return glib.Bytes.new(buf.ptr, slice.len + 1); + } + + // We slice on codepoint boundaries, so getting here means the + // buffer itself holds broken UTF-8; log the first bad byte with + // 16 bytes of hex context each side so the source is identifiable. + const bad = a11y_offsets.firstInvalidUtf8(slice) orelse slice.len; + const ctx_start = bad -| 16; + const ctx_end = @min(slice.len, bad + 16); + var hex_buf: [64 * 3]u8 = undefined; + var hex_len: usize = 0; + for (slice[ctx_start..ctx_end]) |b| { + const written = std.fmt.bufPrint(hex_buf[hex_len..], "{x:0>2} ", .{b}) catch break; + hex_len += written.len; + } + log.warn( + "accessibility: invalid UTF-8 at byte {d} of {d}; context [{s}]", + .{ bad, slice.len, hex_buf[0..hex_len] }, + ); + return emptyBytes(); +} + +// 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/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| { 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/benchmark/A11yText.zig b/src/benchmark/A11yText.zig new file mode 100644 index 0000000000..68c8dda995 --- /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 (`apprt.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_text = @import("../apprt/a11y_text.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"); diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index 86cfecc05a..1d91c4cf3a 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" }, };