summaryrefslogtreecommitdiff
path: root/src/term_pane.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/term_pane.zig')
-rw-r--r--src/term_pane.zig1632
1 files changed, 1569 insertions, 63 deletions
diff --git a/src/term_pane.zig b/src/term_pane.zig
index 624a68a1..75229e4e 100644
--- a/src/term_pane.zig
+++ b/src/term_pane.zig
@@ -787,15 +787,45 @@ fn promptRow(pin: ghostty_vt.Pin, raw: []const u8) []const u8 {
var col: usize = 0;
while (col < cols and at < raw.len) {
const cell = &cells[col];
- var cps: usize = 1;
- if (pin.grapheme(cell)) |extra| cps += extra.len;
- for (0..cps) |_| at = modal.nextGrapheme(raw, at);
+ // Step the dump by exactly what THIS CELL contributed to it. The
+ // tempting walk — one `modal.nextGrapheme` per cell — assumes the two
+ // sides agree on where a cluster ends, and they do not: ghostty keeps a
+ // ZWJ family emoji in three cells and spells each one separately, while
+ // pardes' iterator joins the whole sequence into one grapheme. That walk
+ // then consumed three graphemes for one cell's worth of bytes and ate
+ // the first characters of what was typed at the prompt.
+ at = @min(raw.len, at + dumpedBytes(pin, cell));
// the tail cell of a wide glyph spells nothing of its own
col += if (cell.wide == .wide) @as(usize, 2) else 1;
}
return std.mem.trimEnd(u8, raw[at..], " \t");
}
+/// How many bytes `cell` contributed to `pin`'s dumped row.
+///
+/// `ScreenFormatter` writes a cell's codepoint followed by the grapheme
+/// codepoints stored with it, and writes NOTHING for either spacer, so this is
+/// the dump's own arithmetic rather than a guess about clustering.
+fn dumpedBytes(pin: ghostty_vt.Pin, cell: *const ghostty_vt.Cell) usize {
+ switch (cell.wide) {
+ .spacer_head, .spacer_tail => return 0,
+ .narrow, .wide => {},
+ }
+ var n: usize = switch (cell.content_tag) {
+ .codepoint, .codepoint_grapheme => std.unicode.utf8CodepointSequenceLength(
+ cell.codepoint(),
+ ) catch 1,
+ // A cell carrying only a colour still spells one blank in the dump.
+ else => 1,
+ };
+ if (cell.content_tag == .codepoint_grapheme) {
+ if (pin.grapheme(cell)) |extra| for (extra) |cp| {
+ n += std.unicode.utf8CodepointSequenceLength(cp) catch 1;
+ };
+ }
+ return n;
+}
+
/// A terminal's shell rows as the surface sees them: the WHOLE
/// history+active grid, prompt rows blanked (OSC 133), absolute grid rows
/// from 0. The raw material the motion surface is composed from — the
@@ -1086,7 +1116,25 @@ pub fn bodyText(arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
// buffer's and is shared. With no emulator the viewport is simply empty,
// and `fillBody` renders the overlay against blank rows.
const vp: []const []const u8 = if (comptime !enabled) &.{} else vp: {
- const raw = try pane.vt.plainString(arena);
+ const screen = pane.vt.screens.active;
+ // The dump has to start at COLUMN ZERO of the viewport's first row.
+ // `Terminal.plainString` cannot: it goes through `getTopLeft(.viewport)`,
+ // which hands back the viewport pin verbatim, x and all, while
+ // `PageList.pin` — how the colour pass finds that same row — forces x to
+ // 0. Reflow can leave a tracked viewport pin in the MIDDLE of a row
+ // (narrow the pane until a line wraps, scroll back onto the
+ // continuation, widen it again): from then on this pass dumped row 0
+ // from that column while the colour pass paired the fragment with the
+ // row's first cells, so the row lost its left half and wore the wrong
+ // colours — every frame, until the pane snapped back to live output.
+ // Ghostty's own renderer walks rows and ignores that x, so column zero
+ // is also what the terminal itself draws.
+ var tl = screen.pages.getTopLeft(.viewport);
+ tl.x = 0;
+ const br = screen.pages.getBottomRight(.viewport) orelse return error.UnknownPoint;
+ var rows_out: std.Io.Writer.Allocating = .init(arena);
+ try screen.dumpString(&rows_out.writer, .{ .tl = tl, .br = br, .unwrap = false });
+ const raw = try rows_out.toOwnedSlice();
var prompts = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .viewport = .{} }, null);
const vp = try arena.alloc([]const u8, std.mem.count(u8, raw, "\n") + 1);
var lines = std.mem.splitScalar(u8, raw, '\n');
@@ -1112,56 +1160,317 @@ pub fn bodyText(arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
return out;
}
+/// Where ONE body row's content comes from. The text pass copies bytes for it
+/// and the colour pass projects the emulator's styles onto it, so handing both
+/// the same answer is what keeps a colour on the row its text landed on.
+pub const BodyRow = union(enum) {
+ /// A shell row, as a VIEWPORT index. Out-of-range values are yielded rather
+ /// than filtered: each consumer knows its own bound (the text pass has the
+ /// dumped rows, the colour pass has the live viewport) and a row nobody can
+ /// source is a blank row, not a skipped one.
+ grid: i32,
+ /// One line of the edit buffer, and WHICH line it is. A line the user never
+ /// changed still stands over the shell row it was seeded from, so the index
+ /// is what lets the colour pass find that row again (see `EditAnchors`).
+ edit: struct { line: []const u8, idx: usize },
+};
+
+/// THE body row walk, shared. Both passes stepping the same iterator is what
+/// makes them agree by CONSTRUCTION rather than by two copies of the same
+/// arithmetic agreeing: `Pane.gridRow` and this walk disagree whenever
+/// `modal.lineCount` and `splitScalar` disagree about how many rows a buffer
+/// occupies (they do, for empty text: 0 against 1), and re-deriving a row's
+/// anchor from `gridRow` per row instead of stepping it here put colours one
+/// row off below an emptied edit buffer.
+const BodyWalk = struct {
+ pane: *Pane,
+ goff: i32,
+ g: i32,
+ /// the buffer can start above the viewport: drop the lines scrolled past
+ skip: usize,
+ n: usize = 0,
+ lines: ?std.mem.SplitIterator(u8, .scalar) = null,
+ covered: i32 = 0,
+ line_idx: usize = 0,
+
+ fn init(pane: *Pane) BodyWalk {
+ const off = pane.scroll();
+ const goff = gridOffset(pane);
+ return .{
+ .pane = pane,
+ .goff = goff,
+ // tty mode does not apply the edit buffer, so it must not be moved
+ // by one either. `Pane.gridRow` and `Pane.surfRow` are NOT inverses
+ // for a row strictly inside the buffer's covered span (surfRow
+ // clamps to the buffer's last line, gridRow collapses the whole
+ // span onto its first shell row), so a buffer left behind by
+ // `enterTty` — which clears every other modal remnant but not this
+ // one — straddling the viewport top used to start this walk ABOVE
+ // the viewport and slide the entire body down.
+ .g = if (pane.mode == .tty) goff else pane.gridRow(off),
+ .skip = if (pane.ovl) |o| @intCast(@max(0, off - pane.surfRow(o.row))) else 0,
+ };
+ }
+
+ fn next(w: *BodyWalk) ?BodyRow {
+ while (w.n < w.pane.rows) {
+ if (w.lines) |*it| {
+ if (it.next()) |line| {
+ const idx = w.line_idx;
+ w.line_idx += 1;
+ // Lines scrolled off the top still count: the index names a
+ // line of the BUFFER, not of the visible body.
+ if (w.skip > 0) {
+ w.skip -= 1;
+ continue;
+ }
+ w.n += 1;
+ return .{ .edit = .{ .line = line, .idx = idx } };
+ }
+ // The buffer stands in for `rows` shell rows however many lines
+ // it actually spelled, which is the whole slide.
+ w.g += w.covered;
+ w.skip = 0;
+ w.lines = null;
+ continue;
+ }
+ if (w.pane.mode != .tty) if (w.pane.ovl) |o| if (w.g == o.row) {
+ w.lines = std.mem.splitScalar(u8, o.text, '\n');
+ w.covered = o.rows;
+ w.line_idx = 0;
+ continue;
+ };
+ const vi = w.g - w.goff;
+ w.g += 1;
+ w.n += 1;
+ return .{ .grid = vi };
+ }
+ return null;
+ }
+};
+
/// Run the terminal body row walk. A null destination counts bytes; a slice
/// fills the exact allocation made from that count.
fn fillBody(dst: ?[]u8, pane: *Pane, viewport: []const []const u8) usize {
- const goff: i32 = gridOffset(pane);
- const off = pane.scroll();
- var g: i32 = pane.gridRow(off);
- // the buffer can start above the viewport: drop the lines scrolled past
- var skip: usize = if (pane.ovl) |o| @intCast(@max(0, off - pane.surfRow(o.row))) else 0;
+ var walk: BodyWalk = .init(pane);
var written: usize = 0;
- var n: usize = 0;
- while (n < pane.rows) {
- if (pane.mode != .tty) if (pane.ovl) |o| if (g == o.row) {
- var bit = std.mem.splitScalar(u8, o.text, '\n');
- var k: usize = 0;
- while (bit.next()) |ln| : (k += 1) {
- if (k < skip) continue;
- if (n >= pane.rows) break;
- if (n > 0) {
- if (dst) |out| out[written] = '\n';
- written += 1;
- }
- if (dst) |out| @memcpy(out[written..][0..ln.len], ln);
- written += ln.len;
- n += 1;
- }
- skip = 0;
- g += o.rows;
- continue;
- };
- if (n > 0) {
+ var first = true;
+ while (walk.next()) |row| {
+ if (!first) {
if (dst) |out| out[written] = '\n';
written += 1;
}
- const vi = g - goff;
- if (vi >= 0 and @as(usize, @intCast(vi)) < viewport.len) {
- const line = viewport[@intCast(vi)];
- if (dst) |out| @memcpy(out[written..][0..line.len], line);
- written += line.len;
- }
- n += 1;
- g += 1;
+ first = false;
+ const bytes = switch (row) {
+ .edit => |e| e.line,
+ .grid => |vi| if (vi >= 0 and @as(usize, @intCast(vi)) < viewport.len)
+ viewport[@intCast(vi)]
+ else
+ "",
+ };
+ if (dst) |out| @memcpy(out[written..][0..bytes.len], bytes);
+ written += bytes.len;
}
return written;
}
+/// WHICH edit-buffer lines still stand over a shell row.
+///
+/// The buffer only ever GROWS: it starts at the row first typed on and stretches
+/// to cover every row an edit since has touched, so after a few edits it spans
+/// rows the user never altered. Those lines are still byte-identical to the
+/// shell rows they were seeded from, and their anchor is therefore still known —
+/// so they keep their colours, and only lines that actually differ go plain.
+///
+/// The buffer's text is DERIVED from the rows it covers, so the untouched lines
+/// appear in the same ORDER as the rows they came from. The answer is therefore
+/// a MONOTONE MATCHING, and that is what this streams: one shell-row cursor
+/// which only ever moves forward, advanced once per buffer line. A line claims
+/// the first row at or after the cursor that its bytes equal; matching bytes is
+/// the whole proof. A line that matches nothing was typed by the user, so it
+/// claims no row and leaves the rows beneath it to the lines below.
+///
+/// Two ALIGNED guesses — the Nth line over the Nth covered row, and the same
+/// counted from the bottom — are not enough, and the counterexample is one
+/// keystroke. Join two rows (backspace at column 0): the buffer loses a line
+/// and gains covered rows, the two counts cancel at `lines == covered`, and both
+/// guesses resolve to the SAME row, one short of where the lines below actually
+/// live. Every untouched row under the join went plain. Nor is a leading and a
+/// trailing RUN enough: a run stops at the first divergence, so two separate
+/// edits drained the colour of every untouched line BETWEEN them.
+///
+/// Cost is linear in the buffer, which the quadratic version this replaced was
+/// not (walking to the Nth line per line: 35 ms a frame at a few thousand
+/// lines). Every successful claim moves the cursor, so all of them together
+/// scan the covered span once; only a typed line can scan without moving it,
+/// and `budget` is what stops a buffer full of typed lines from paying that
+/// scan per line. Exhausting it costs colour on rows further down, never
+/// correctness.
+const EditAnchors = struct {
+ /// the buffer's own text, walked in order: a line the VIEWPORT skipped still
+ /// consumes the row it came from, so the lines below it stay aligned
+ text: []const u8 = &.{},
+ at: usize = 0,
+ shell: []const []const u8 = &.{},
+ /// the covered span, absolute grid rows, as `[first, end)`
+ first: usize = 0,
+ end: usize = 0,
+ lines: usize = 0,
+ /// the line `at` names, and the first row still unclaimed
+ idx: usize = 0,
+ cursor: usize = 0,
+ budget: usize = 0,
+ active: bool = false,
+
+ fn init(p: *Pardes, pane: *Pane, o: EditBuffer) EditAnchors {
+ if (o.rows <= 0 or o.row < 0) return .{};
+ const shell = shellRows(p, pane) catch return .{};
+ const first: usize = @intCast(o.row);
+ if (first >= shell.len) return .{};
+ const covered: usize = @intCast(o.rows);
+ const lines = std.mem.count(u8, o.text, "\n") + 1;
+ return .{
+ .text = o.text,
+ .shell = shell,
+ .first = first,
+ .end = @min(first + covered, shell.len),
+ .lines = lines,
+ .cursor = first,
+ .budget = covered + 4 * lines,
+ .active = true,
+ };
+ }
+
+ /// Where buffer line `idx` still stands over the grid, if anywhere. `idx`
+ /// only ever grows — both passes step `BodyWalk` from the top — so catching
+ /// up to it is amortised O(1) per visible row.
+ fn shellRow(a: *EditAnchors, idx: usize) ?Anchor {
+ if (!a.active or idx >= a.lines) return null;
+ var found: ?Anchor = null;
+ while (a.idx <= idx) : (a.idx += 1) found = a.claim(a.nextLine() orelse return null);
+ return found;
+ }
+
+ fn nextLine(a: *EditAnchors) ?[]const u8 {
+ if (a.at > a.text.len) return null;
+ const rest = a.text[a.at..];
+ if (std.mem.indexOfScalar(u8, rest, '\n')) |n| {
+ a.at += n + 1;
+ return rest[0..n];
+ }
+ // The last line has no terminator; one past the end ends the walk.
+ a.at = a.text.len + 1;
+ return rest;
+ }
+
+ /// Where this line still stands over the grid, if anywhere.
+ fn claim(a: *EditAnchors, line: []const u8) ?Anchor {
+ // An EXACT row is the best evidence there is, so look for one first and
+ // look anywhere ahead: a line that merely RESEMBLES the row alignment
+ // offers is often the row two below, unchanged and unedited.
+ //
+ // Scanning past the cursor crosses rows that were deleted or joined
+ // away, and the line's bytes are what justify the crossing — so an
+ // EMPTY line may not do it. Empty is not evidence: it equals every
+ // blank row in the span, and splitting a row makes exactly that. Two
+ // keystrokes (Home, Enter) used to hand the blank row below the last
+ // output to the new empty line and take every coloured row in between
+ // out of reach of the lines that owned them.
+ const end = if (line.len == 0) @min(a.cursor + 1, a.end) else a.end;
+ var k = a.cursor;
+ while (k < end) : (k += 1) {
+ if (a.budget == 0) return null;
+ a.budget -= 1;
+ if (!std.mem.eql(u8, line, a.shell[k])) continue;
+ a.cursor = k + 1;
+ return .{ .row = @intCast(k) };
+ }
+ // No row spells this line, so it is either the row the alignment offers
+ // WITH AN EDIT IN IT, or text typed from nothing. The bytes shared at
+ // the two ends decide which — and, when it is an edit, exactly how much
+ // of the row's colour the line still has a right to.
+ if (a.cursor >= a.end) return null;
+ const shell = a.shell[a.cursor];
+ var p: usize = 0;
+ while (p < line.len and p < shell.len and line[p] == shell[p]) p += 1;
+ var s: usize = 0;
+ const room = @min(line.len, shell.len) - p;
+ while (s < room and line[line.len - 1 - s] == shell[shell.len - 1 - s]) s += 1;
+ if (p + s == 0) return null;
+ // Accept when the row accounts for the whole LINE (nothing was typed;
+ // the line is a piece of the row, which is the top half of a split), or
+ // when most of the ROW survived in it (an ordinary edit). Otherwise this
+ // is new text that happens to share an edge with its neighbour, and
+ // colouring it would hand it a colour that was never its own.
+ if (line.len != p + s and shell.len - (p + s) > shell.len / 2) return null;
+ const row = a.cursor;
+ // A line that stopped short of the row's END leaves the rest of that row
+ // to the NEXT line. Splitting a row in two is exactly that, and it is
+ // why the bottom half can still find its colours: they are in the tail
+ // of the row the top half only partly covered.
+ if (s > 0 or p >= shell.len) a.cursor += 1;
+ return .{ .row = @intCast(row), .prefix = p, .suffix = s, .shell_len = shell.len };
+ }
+};
+
+/// WHERE a body row's colours come from, and HOW MUCH of the row they cover.
+///
+/// A row whose text is the grid's own takes the grid's colours end to end. A
+/// row the user has EDITED still holds the row's own bytes at its two ends —
+/// they are the same bytes, provably — and those keep their colours; only what
+/// was typed between them has no cell under it and so takes none. Dropping the
+/// whole row instead was the loudest colour bug in the editor: one keystroke
+/// that changed one character's case turned every column of a coloured row
+/// grey.
+const Anchor = struct {
+ /// the row, absolute while it comes from `EditAnchors`, viewport once
+ /// `recolorAnsi` has subtracted the walk's offset
+ row: i32,
+ /// bytes at the START of the line that are still the row's own, and bytes at
+ /// its END. The default says ALL of it: an exact match, or a `.grid` row,
+ /// which is the grid's text by construction.
+ prefix: usize = std.math.maxInt(usize),
+ suffix: usize = 0,
+ /// the row's own dumped length — what the suffix is measured from on the
+ /// GRID side, where the edit may have changed the byte count
+ shell_len: usize = 0,
+
+ fn whole(an: Anchor) bool {
+ return an.prefix == std.math.maxInt(usize);
+ }
+};
+
/// tty colors: recolor each visible body cell from the emulator's own style so
-/// raw output keeps its ansi colors. Runs ONLY in tty mode (the caller gates
-/// it) and reads the live viewport row for row: normal/insert editing shows
-/// plain text, so nothing an edit does can move a shell row's colour.
-pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16) void {
+/// raw output keeps its ansi colors — in EVERY mode, not just `.tty`, because a
+/// body row's colour has the same origin its text does and `BodyWalk` already
+/// knows it.
+///
+/// Editing moves shell rows around: the edit buffer's lines stand in for the
+/// rows it covers, so everything below slides, and `promptRow` left-hugs a
+/// prompt row so what was typed starts at column 0. A colour therefore needs
+/// exactly two translations, and takes each from the pass that made it:
+///
+/// * ROW — step `BodyWalk`, the same iterator `fillBody` steps. A `.grid` row
+/// names the viewport row whose bytes were drawn; an `.edit` row is the
+/// user's own text with no shell row underneath, so it keeps the body style.
+/// Sharing the walk is load-bearing: deriving the anchor independently (from
+/// `Pane.gridRow`) put colours one row off wherever that arithmetic and this
+/// walk disagreed about a buffer's height.
+/// * COLUMN — pair the PRINTED graphemes with the grid cells that spelled them,
+/// starting at the cell `promptCut` says the hug dropped to. Not `cut + c`:
+/// the two sides disagree about how many columns a cluster is worth (ghostty
+/// splits `👨‍👩‍👧` across three wide cells and spells it once; this surface
+/// prints that one grapheme two columns wide), so column arithmetic walks off
+/// the glyph it means and every cell after it wears a neighbour's colour.
+/// `body` is the very text the caller just printed, which is what makes the
+/// pairing exact rather than a second guess at clustering.
+///
+/// In tty mode the buffer is not applied and no prompt is hugged, so the row
+/// anchor collapses to the viewport row. That is not quite "as it always did":
+/// the walk starts at `Pane.gridRow(pane.scroll())` like `fillBody`, so where a
+/// stale buffer skews that start, the colours now follow the text instead of
+/// silently disagreeing with it.
+pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, body: []const u8) void {
// No emulator, no ANSI cells: the whole pass — and the 256-colour theme
// projection behind it — is compiled out.
if (comptime !enabled) return;
@@ -1172,29 +1481,215 @@ pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, bo
filtered_storage = FilteredColors.init(p, pane);
break :blk &filtered_storage;
} else null;
+ // DECSCNM, read once: the filtered palette folds it in itself, the raw
+ // path needs it per cell.
+ const scnm = pane.vt.modes.get(.reverse_colors);
+ const pages = &pane.vt.screens.active.pages;
+ // The text pass bounds its rows by the dump it was handed; this one has the
+ // live viewport, so it bounds by the viewport's own height. Both bounds
+ // exist for the same reason and NEITHER is `pin`: `PageList.pin` resolves a
+ // viewport row by walking DOWN the pagelist, so a viewport scrolled back
+ // answers happily for rows below its bottom edge — which painted the
+ // scrollback's colours onto rows the text pass had left blank.
+ const vp_rows: i32 = @intCast(scrollbar(pane).len);
+ // Which buffer lines the user has not actually changed, so a row swallowed
+ // by a growing buffer keeps the colour it still stands over.
+ var anchors: EditAnchors = if (pane.mode != .tty)
+ if (pane.ovl) |o| .init(p, pane, o) else .{}
+ else
+ .{};
+ var walk: BodyWalk = .init(pane);
+ // The printed body, one line per body row, stepped ONCE per row alongside
+ // the walk. Asking for the Nth line per row instead re-scanned the whole
+ // body every time, which made a tall pane's render superlinear.
+ var lines = std.mem.splitScalar(u8, body, '\n');
var vr: u16 = 0;
- while (vr < body_h and vr < pane.rows) : (vr += 1) {
- var c: u16 = 0;
- while (c < tw) : (c += 1) {
- const ci = pane.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(c), .y = @intCast(vr) } }) orelse continue;
- // Ghostty gives a wide glyph's spacer tail the head's style id;
- // ordinary projection leaves it, a filter must repaint it too.
- if (ci.cell.wide == .spacer_tail and filtered == null) continue;
- const cell = s.at(tx + c, body_y + vr);
- // sparse projection: bodyText already painted every glyph, so only
- // a filter (which theme-keys blank/default cells too) touches these.
- if (cell.default and filtered == null) continue;
- cell.default = false;
- cell.style = cellStyle(p, ci, filtered);
+ while (walk.next()) |row| : (vr += 1) {
+ if (vr >= body_h) break;
+ // Before any early exit below, or the lines fall out of step with rows.
+ const text = lines.next() orelse "";
+ const anchor: Anchor = switch (row) {
+ // A line the user typed from nothing has no cell under it; one they
+ // only had swallowed, or edited a piece of, still names the row its
+ // bytes came from and how much of it is still that row's.
+ .edit => |e| blk: {
+ var an = anchors.shellRow(e.idx) orelse continue;
+ an.row -= walk.goff;
+ break :blk an;
+ },
+ .grid => |v| .{ .row = v },
+ };
+ const vi = anchor.row;
+ if (vi < 0 or vi >= vp_rows) continue;
+ const row_pin = pages.pin(.{ .viewport = .{ .y = @intCast(vi) } }) orelse continue;
+ // The prompt the text pass dropped, added back as a starting CELL.
+ // Gated on the ROW FLAG first, exactly as `bodyText` gates `promptRow`:
+ // `promptCut` answers for the whole row under
+ // `config.tty_blank == .prompt_and_input`, so asking it about a row the
+ // text pass never asked about would blank colours nobody hid.
+ const cut: u16 = if (pane.mode == .tty) 0 else cut: {
+ if (row_pin.rowAndCell().row.semantic_prompt == .none) break :cut 0;
+ break :cut switch (promptCut(row_pin)) {
+ .keep => 0,
+ // Blanked end to end: the row shows nothing of the grid, so
+ // projecting the prompt's own colours onto it would be a lie.
+ .blank => continue,
+ .cut => |n| std.math.cast(u16, n) orelse continue,
+ };
+ };
+ // The text this row printed is walked grapheme by grapheme alongside the
+ // cells that spelled it. Both walks are driven by real data — the
+ // printed bytes and the cells' own dumped byte counts — so neither has
+ // to guess how many columns the other gives a cluster.
+ // The hug can empty a row outright: a prompt whose command did not fit
+ // leaves ghostty a styled spacer and nothing printable. The row DRAWS
+ // nothing, so nothing on it may take the grid's colour — the same
+ // reasoning as `.blank` above, reached by a different route.
+ if (cut > 0 and text.len == 0) continue;
+ var at: usize = 0;
+ var sc: u16 = 0;
+ var gc: u16 = cut;
+ // Shell bytes crossed so far, which is how the row's TAIL is found again
+ // after an edit: the printed text and the grid agree byte for byte over
+ // `prefix` and over `suffix`, and nowhere in between.
+ var sb: usize = 0;
+ const mine_from = @min(anchor.prefix, text.len);
+ const mine_to = text.len - @min(anchor.suffix, text.len);
+ var crossed = false;
+ while (at < text.len and sc < tw) {
+ const stop = modal.nextGrapheme(text, at);
+ if (stop <= at) break;
+ // What the glyph occupies HERE: `print` leaves an empty cell under a
+ // double-width one, and `fill` writes a space, so a zero-length cell
+ // is a spacer and nothing else. It is a property of the SURFACE, so
+ // it is known before any cell is consumed — which is what lets the
+ // user's own text spend its columns without spending the row's.
+ //
+ // This rule assumes the printed text holds no `\t` and no `\r`:
+ // `Surface.print` expands a tab into `config.tab_width` cells and
+ // draws nothing at all for a carriage return, either of which would
+ // slide every later colour on the row. The assumption is ghostty's,
+ // not ours — its row dump expands tabs to real spaces and replaces
+ // undecodable bytes with U+FFFD — so it holds for anything sourced
+ // from the grid, and an anchor only ever covers bytes that ARE such
+ // a row's. Feed this text from anywhere else and the span rule is
+ // the thing that breaks first.
+ const span: u16 = if (sc + 1 < tw and s.at(tx + sc + 1, body_y + vr).len == 0) 2 else 1;
+ // Between the row's own two ends lie the bytes the user typed. No
+ // cell spelled them, so they take no colour and spend no grid
+ // column: the row's tail then still lines up with the line's tail.
+ if (at >= mine_from and at < mine_to) {
+ sc += span;
+ at = stop;
+ continue;
+ }
+ // Crossing back into the row's own bytes: step over the cells whose
+ // bytes the edit replaced. `shell_len - suffix` is where the row's
+ // own tail starts on the GRID side, which is not where it starts in
+ // the line whenever the edit changed the byte count.
+ if (at >= mine_to and !crossed) {
+ crossed = true;
+ const upto = anchor.shell_len - @min(anchor.suffix, anchor.shell_len);
+ while (sb < upto) {
+ const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break;
+ sb += dumpedBytes(row_pin, ci.cell);
+ gc = std.math.add(u16, gc, 1) catch break;
+ }
+ }
+ const want = stop - at;
+ // Consume every cell that contributed to this grapheme. A cluster
+ // ghostty split across several cells is still ONE printed glyph.
+ var covered: usize = 0;
+ var style: ?pardes.CellStyle = null;
+ while (covered < want) {
+ const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break;
+ if (style == null and ci.cell.wide != .spacer_tail and ci.cell.wide != .spacer_head)
+ style = cellStyle(p, ci, filtered, scnm);
+ covered += dumpedBytes(row_pin, ci.cell);
+ gc = std.math.add(u16, gc, 1) catch break;
+ // A spacer contributes no bytes; without this the loop would
+ // spin on a row that ends in one.
+ if (covered == 0 and gc >= pane.cols) break;
+ }
+ // A wide cell's tail contributes NO bytes, so the loop above stops
+ // on it rather than past it. Step over any tail now: leaving `gc` on
+ // one pairs the next surface column with the cell before it, which
+ // left an unpainted hole beside a row-final CJK glyph and pushed
+ // every colour after it one column right.
+ while (pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } })) |t| {
+ if (t.cell.wide != .spacer_tail) break;
+ gc = std.math.add(u16, gc, 1) catch break;
+ }
+ if (style) |st| for (0..span) |k| {
+ const cell = s.at(tx + sc + @as(u16, @intCast(k)), body_y + vr);
+ // sparse projection: bodyText already painted every glyph, so
+ // only a filter (which theme-keys blank/default cells too)
+ // touches these.
+ if (cell.default and filtered == null) continue;
+ cell.default = false;
+ cell.style = st;
+ };
+ sb += covered;
+ sc += span;
+ at = stop;
+ }
+ // Past the text: the row's remaining cells carry colour but no glyph
+ // (an erase-to-end-of-line under a background). One cell, one column
+ // from here, with two exceptions on the grid side.
+ //
+ // Only a row that ENDS in the row's own bytes may ask what lies past
+ // them. Where the user's own text runs to the end of the line, the next
+ // cells still spell bytes the edit removed, and painting the line's
+ // margin from those would dress it in the colours of text that is no
+ // longer there.
+ var tail: ?pardes.CellStyle = null;
+ if (anchor.whole() or anchor.suffix > 0) {
+ while (sc < tw) {
+ const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break;
+ gc = std.math.add(u16, gc, 1) catch break;
+ // A TAIL spells nothing and owns no column of its own, so it
+ // moves the grid on without spending a surface column. A HEAD
+ // does own its column — it is the gap ghostty leaves where a
+ // wide glyph would not fit, and it carries the row's background
+ // — so it is painted like any other cell. Skipping it left the
+ // last column of a coloured row bare, because a head is by
+ // construction that row's final cell.
+ if (ci.cell.wide == .spacer_tail) continue;
+ const cell = s.at(tx + sc, body_y + vr);
+ sc += 1;
+ const style = cellStyle(p, ci, filtered, scnm);
+ tail = style;
+ if (cell.default and filtered == null) continue;
+ cell.default = false;
+ cell.style = style;
+ }
+ // The grid can run out before the surface does: a cluster ghostty
+ // spends four cells on may print in two columns here, so a row
+ // ending in one has columns with no cell left to ask. The row's
+ // background does reach its edge on the grid, so carry the last
+ // cell's answer across rather than leaving a notch of pane colour at
+ // the margin.
+ if (tail) |style| while (sc < tw) : (sc += 1) {
+ const cell = s.at(tx + sc, body_y + vr);
+ if (cell.default and filtered == null) continue;
+ cell.default = false;
+ cell.style = style;
+ };
}
}
}
-fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell, filtered: ?*FilteredColors) pardes.CellStyle {
+/// `scnm` is DECSCNM (`\x1b[?5h`), which swaps only the terminal's DEFAULT
+/// colour roles — explicit SGR colours stay explicit. `FilteredColors` applies
+/// it by swapping the theme's two defaults; the raw path resolves a `.none`
+/// colour through `ghostColor`, whose `is_bg` argument chooses which default it
+/// means, so flipping that argument is the same swap. Without it reverse video
+/// simply vanished whenever `tty_filter` was off.
+fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell, filtered: ?*FilteredColors, scnm: bool) pardes.CellStyle {
const style = ci.style();
var cs: pardes.CellStyle = .{
- .fg = if (filtered) |colors| colors.fg(style) else ghostColor(p, style.fg_color, false),
- .bg = if (filtered) |colors| colors.bg(style, ci.cell) else ghostColor(p, style.bg_color, true),
+ .fg = if (filtered) |colors| colors.fg(style) else ghostColor(p, style.fg_color, scnm),
+ .bg = if (filtered) |colors| colors.bg(style, ci.cell) else ghostColor(p, style.bg_color, !scnm),
.bold = style.flags.bold,
.dim = style.flags.faint,
.italic = style.flags.italic,
@@ -1534,7 +2029,7 @@ test "terminal Filter preserves exact palette-null light theme default roles" {
try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, reversed.at(tx, body_y).style.fg);
try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, reversed.at(tx, body_y).style.bg);
}
-test "tty ansi colors render only in tty mode, never in normal mode" {
+test "tty ansi colors follow the prompt hug into normal mode" {
const testing = std.testing;
const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 });
defer p.deinit();
@@ -1559,15 +2054,777 @@ test "tty ansi colors render only in tty mode, never in normal mode" {
try testing.expectEqual(red, tty.at(tx + 2, body_y).style.fg);
try testing.expectEqual(blue, tty.at(tx + 3, body_y).style.fg);
- // normal mode is a plain editing view: no ansi projection at all, so an
- // edit made here cannot change what tty mode renders.
+ // Normal mode hugs the prompt away, so `R` starts at column 0 — and its
+ // colour comes with it. The two cells the prompt occupied are the COLUMN
+ // anchor `promptCut` hands back, which is the only reason the red lands on
+ // the R the user can see instead of two cells to the right of it.
pane.mode = .normal;
p.shell_rows.stale = true;
_ = frame.reset(.retain_capacity);
const norm = try p.render(frame.allocator());
try testing.expectEqualStrings("R", norm.at(tx, body_y).grapheme());
- try testing.expect(!std.meta.eql(red, norm.at(tx, body_y).style.fg));
- try testing.expect(!std.meta.eql(blue, norm.at(tx + 1, body_y).style.fg));
+ try testing.expectEqualStrings("B", norm.at(tx + 1, body_y).grapheme());
+ try testing.expectEqual(red, norm.at(tx, body_y).style.fg);
+ try testing.expectEqual(blue, norm.at(tx + 1, body_y).style.fg);
+}
+
+test "an edit buffer slides shell rows and their colors together" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .normal;
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mAAA\x1b[0m\r\n\x1b[32mBBB\x1b[0m\r\n\x1b[34mCCC\x1b[0m" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const red: pardes.Color = .{ .index = 1 };
+ const green: pardes.Color = .{ .index = 2 };
+ const blue: pardes.Color = .{ .index = 4 };
+
+ p.shell_rows.stale = true;
+ const before = try p.render(frame.allocator());
+ try testing.expectEqual(red, before.at(tx, body_y).style.fg);
+ try testing.expectEqual(green, before.at(tx, body_y + 1).style.fg);
+ try testing.expectEqual(blue, before.at(tx, body_y + 2).style.fg);
+
+ // Four lines of typed text standing in for the ONE shell row `AAA` was:
+ // every row below slides down by three, and `surfRow` is the arithmetic
+ // that says so. The colours have to take the same three rows, or `BBB`
+ // would be painted green three rows above where it is now drawn.
+ pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, "e\nd\ni\nt") };
+ p.shell_rows.stale = true;
+ _ = frame.reset(.retain_capacity);
+ const after = try p.render(frame.allocator());
+
+ try testing.expectEqualStrings("B", after.at(tx, body_y + 4).grapheme());
+ try testing.expectEqualStrings("C", after.at(tx, body_y + 5).grapheme());
+ try testing.expectEqual(green, after.at(tx, body_y + 4).style.fg);
+ try testing.expectEqual(blue, after.at(tx, body_y + 5).style.fg);
+
+ // ...and the rows the user typed are the user's own text: no shell row
+ // sits under them, so nothing projects a colour onto them.
+ for (0..4) |i| {
+ const cell = after.at(tx, body_y + @as(u16, @intCast(i)));
+ try testing.expect(!std.meta.eql(red, cell.style.fg));
+ try testing.expect(!std.meta.eql(green, cell.style.fg));
+ try testing.expect(!std.meta.eql(blue, cell.style.fg));
+ }
+}
+
+test "a combining mark in the prompt keeps the command and its colors aligned" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .normal;
+
+ // A ONE-cell prompt carrying a combining mark — an NFD `e` — then `ABC`
+ // typed at it. The cell walk that finds the prompt's end must step ONE
+ // grapheme for that cell, not one per stored codepoint: stepping twice ate
+ // the `A`, and left every colour a cell to the left of its glyph with the
+ // last one stranded on a blank.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32me\u{301}\x1b]133;B\x1b\\\x1b[31mA\x1b[34mB\x1b[35mC" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+
+ p.shell_rows.stale = true;
+ const s = try p.render(frame.allocator());
+ try testing.expectEqualStrings("A", s.at(tx, body_y).grapheme());
+ try testing.expectEqualStrings("B", s.at(tx + 1, body_y).grapheme());
+ try testing.expectEqualStrings("C", s.at(tx + 2, body_y).grapheme());
+ try testing.expectEqual(pardes.Color{ .index = 1 }, s.at(tx, body_y).style.fg);
+ try testing.expectEqual(pardes.Color{ .index = 4 }, s.at(tx + 1, body_y).style.fg);
+ try testing.expectEqual(pardes.Color{ .index = 5 }, s.at(tx + 2, body_y).style.fg);
+ // ...and no colour past the end of what the row actually says
+ try testing.expect(!std.meta.eql(pardes.Color{ .index = 5 }, s.at(tx + 3, body_y).style.fg));
+}
+
+test "colors are never taken from shell rows below the viewport" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 14 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ // Raw palette, so a leaked background reads back as `.index` — the theme
+ // filter would repaint every blank cell and hide the evidence.
+ pane.tty_filter = false;
+ pane.mode = .normal;
+
+ // Sixty rows, each a distinct background, so a leaked colour names the row
+ // it leaked from.
+ for (0..60) |i| {
+ var buf: [32]u8 = undefined;
+ const bytes = std.fmt.bufPrint(&buf, "\x1b[4{d}mL{d:0>2}\x1b[0m\r\n", .{ (i % 6) + 1, i }) catch unreachable;
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ }
+ p.shell_rows.stale = true;
+ scrollGrid(pane, -20);
+
+ // ONE buffer line standing in for SIX shell rows: everything below slides
+ // UP five, so the last rows of the body resolve past the viewport's bottom
+ // edge. `PageList.pin` answers for those rows anyway — it walks down the
+ // pagelist, not the viewport — so without a bound of its own this pass
+ // painted the scrollback's colours onto rows the text pass left blank.
+ const anchor = gridOffset(pane);
+ pane.ovl = .{ .row = anchor, .rows = 6, .text = try p.gpa.dupe(u8, "one") };
+ p.shell_rows.stale = true;
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const body_h = r.h - pardes.BOX_H;
+
+ p.shell_rows.stale = true;
+ const s = try p.render(frame.allocator());
+ // A body row the text pass left blank has no shell row under it, so no
+ // ANSI background may have reached it. Every colour in the payload above is
+ // an indexed one, so a leak is exactly an `.index` background on a blank row.
+ var vr: u16 = 0;
+ while (vr < body_h) : (vr += 1) {
+ var blank = true;
+ var c: u16 = 0;
+ while (c < r.w -| config.GUTTER) : (c += 1) {
+ if (!std.mem.eql(u8, " ", s.at(tx + c, body_y + vr).grapheme())) blank = false;
+ }
+ if (!blank) continue;
+ c = 0;
+ while (c < r.w -| config.GUTTER) : (c += 1) {
+ const bg = s.at(tx + c, body_y + vr).style.bg;
+ try testing.expect(std.meta.activeTag(bg) != .index);
+ }
+ }
+}
+
+test "a row the edit buffer only swallowed keeps its color" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .normal;
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mAAA\x1b[0m\r\n\x1b[32mBBB\x1b[0m\r\n\x1b[34mCCC\x1b[0m" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const red: pardes.Color = .{ .index = 1 };
+ const green: pardes.Color = .{ .index = 2 };
+ const blue: pardes.Color = .{ .index = 4 };
+
+ // The buffer only ever grows, so after a few edits it covers rows nobody
+ // touched. Here it spans all three and only the MIDDLE line differs: the
+ // first and last are still byte-identical to the shell rows they were
+ // seeded from, so they still stand over them and keep their colours.
+ pane.ovl = .{ .row = 0, .rows = 3, .text = try p.gpa.dupe(u8, "AAA\nXXX\nCCC") };
+ p.shell_rows.stale = true;
+ const s = try p.render(frame.allocator());
+
+ try testing.expectEqualStrings("A", s.at(tx, body_y).grapheme());
+ try testing.expectEqualStrings("X", s.at(tx, body_y + 1).grapheme());
+ try testing.expectEqualStrings("C", s.at(tx, body_y + 2).grapheme());
+ try testing.expectEqual(red, s.at(tx, body_y).style.fg);
+ try testing.expectEqual(blue, s.at(tx, body_y + 2).style.fg);
+ // ...and the line that actually changed is the user's own text now
+ try testing.expect(!std.meta.eql(green, s.at(tx, body_y + 1).style.fg));
+}
+
+test "an edit buffer reaching past the dumped rows colors nothing from row zero" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .normal;
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mAAA\x1b[0m\r\n\x1b[32mBBB\x1b[0m\r\n\x1b[34mCCC\x1b[0m" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const red: pardes.Color = .{ .index = 1 };
+
+ // Covers far more rows than the grid was ever dumped for, so the anchor
+ // table cannot be built and answers "no shell row" for every line. The
+ // zeroed table must not read as "the last line sits on the buffer's first
+ // row", which claimed row zero's colour and underflowed on every line after.
+ pane.ovl = .{ .row = 1, .rows = 50, .text = try p.gpa.dupe(u8, "p\nq\nr") };
+ p.shell_rows.stale = true;
+ const s = try p.render(frame.allocator());
+
+ try testing.expectEqualStrings("p", s.at(tx, body_y + 1).grapheme());
+ var i: u16 = 1;
+ while (i <= 3) : (i += 1) {
+ try testing.expect(!std.meta.eql(red, s.at(tx, body_y + i).style.fg));
+ }
+}
+
+/// TTY MODE IS THE ORACLE. It paints the viewport row for row and column for
+/// column, so whatever it shows on a glyph is what that glyph's colour IS.
+/// Normal mode may move a glyph LEFT (the prompt hug) but must never change its
+/// colour, so the comparison aligns by glyph rather than by column: for each
+/// row the shift is recovered by finding where normal mode's glyph run sits in
+/// tty mode's, without asking the code under test what it did.
+///
+/// Returns the number of cells whose style disagrees; `note` labels the report.
+fn modeStyleDiffs(p: *Pardes, pane: *Pane, gpa: std.mem.Allocator, note: []const u8) !usize {
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const rows: usize = r.h - pardes.BOX_H;
+ const cols: usize = r.w -| config.GUTTER;
+
+ const Snap = struct { text: [][7]u8, len: []u8, style: []pardes.CellStyle };
+ const glyphAt = struct {
+ fn f(sn: Snap, i: usize) []const u8 {
+ return sn.text[i][0..sn.len[i]];
+ }
+ }.f;
+ var shot: [2]Snap = undefined;
+ for (&shot) |*sn| {
+ sn.text = try gpa.alloc([7]u8, rows * cols);
+ sn.len = try gpa.alloc(u8, rows * cols);
+ sn.style = try gpa.alloc(pardes.CellStyle, rows * cols);
+ }
+ defer for (&shot) |*sn| {
+ gpa.free(sn.text);
+ gpa.free(sn.len);
+ gpa.free(sn.style);
+ };
+
+ for ([_]pardes.Mode{ .tty, .normal }, 0..) |mode, i| {
+ pane.mode = mode;
+ p.shell_rows.stale = true;
+ _ = frame.reset(.retain_capacity);
+ const s = try p.render(frame.allocator());
+ for (0..rows) |row| for (0..cols) |col| {
+ const cell = s.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(row)));
+ shot[i].text[row * cols + col] = cell.text;
+ shot[i].len[row * cols + col] = cell.len;
+ shot[i].style[row * cols + col] = cell.style;
+ };
+ }
+
+ var diffs: usize = 0;
+ for (0..rows) |row| {
+ const base = row * cols;
+ // The glyph run normal mode shows, and where it ends.
+ var last: ?usize = null;
+ for (0..cols) |col| {
+ if (!std.mem.eql(u8, glyphAt(shot[1], base + col), " ")) last = col;
+ }
+ const end = last orelse continue; // blank row: nothing to align
+
+ // Recover the shift: the first offset at which tty mode spells the same
+ // run. Zero for every row no prompt was hugged out of.
+ const shift = shift: {
+ var s: usize = 0;
+ while (s + end < cols) : (s += 1) {
+ var all = true;
+ for (0..end + 1) |col| {
+ if (!std.mem.eql(u8, glyphAt(shot[1], base + col), glyphAt(shot[0], base + col + s))) {
+ all = false;
+ break;
+ }
+ }
+ if (all) break :shift s;
+ }
+ var tty_row: [256]u8 = undefined;
+ var nrm_row: [256]u8 = undefined;
+ var tn: usize = 0;
+ var nn: usize = 0;
+ for (0..cols) |col| {
+ const tg = glyphAt(shot[0], base + col);
+ const ng = glyphAt(shot[1], base + col);
+ if (tn + tg.len < tty_row.len) {
+ @memcpy(tty_row[tn..][0..tg.len], tg);
+ tn += tg.len;
+ }
+ if (nn + ng.len < nrm_row.len) {
+ @memcpy(nrm_row[nn..][0..ng.len], ng);
+ nn += ng.len;
+ }
+ }
+ std.debug.print("\n[{s}] row {d} unalignable\n tty: '{s}'\nnormal: '{s}'\n", .{ note, row, tty_row[0..tn], nrm_row[0..nn] });
+ diffs += 1;
+ break :shift null;
+ } orelse continue;
+
+ // Every column the shift can reach, not just the ones holding a glyph:
+ // a cell with a background and no text (`\x1b[41m\x1b[K`, a padded
+ // table cell) carries colour too, and is exactly what a shell paints
+ // most of.
+ for (0..cols - shift) |col| {
+ const want = shot[0].style[base + col + shift];
+ const got = shot[1].style[base + col];
+ if (std.meta.eql(want, got)) continue;
+ if (diffs < 6) std.debug.print(
+ "\n[{s}] row {d} col {d} (shift {d}) glyph '{s}': tty fg={any} bg={any} rev={} ul={any} | normal fg={any} bg={any} rev={} ul={any}",
+ .{ note, row, col, shift, glyphAt(shot[1], base + col), want.fg, want.bg, want.reverse, want.ul, got.fg, got.bg, got.reverse, got.ul },
+ );
+ diffs += 1;
+ }
+ }
+ if (diffs > 0) std.debug.print("\n[{s}] {d} style mismatches\n", .{ note, diffs });
+ return diffs;
+}
+
+test "a prompted session keeps every glyph's color in normal mode" {
+ const testing = std.testing;
+ const payload =
+ "\x1b]133;A\x1b\\\x1b[32muser\x1b[34m@host\x1b[35m ~/dir\x1b[0m$ \x1b]133;B\x1b\\\x1b[36mls \x1b[33m-la\x1b[0m\r\n" ++
+ "\x1b[34mdir1\x1b[0m \x1b[32mexec\x1b[0m plain.txt\r\n" ++
+ "\x1b[31merror: nope\x1b[0m\r\n" ++
+ "\x1b]133;A\x1b\\\x1b[32muser\x1b[34m@host\x1b[35m ~/dir\x1b[0m$ \x1b]133;B\x1b\\\x1b[36mecho \x1b[1;37mhi\x1b[0m\r\n" ++
+ "\x1b[38;5;208mhi\x1b[0m\r\n";
+
+ for ([_]bool{ false, true }) |filter| {
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 44, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = filter;
+ p.update(.{ .output = .{ .pane = 0, .bytes = payload } });
+ const diffs = try modeStyleDiffs(p, pane, testing.allocator, if (filter) "session filter=on" else "session filter=off");
+ try testing.expectEqual(@as(usize, 0), diffs);
+ }
+}
+
+test "an emoji prompt neither eats the command nor slides its colors" {
+ const testing = std.testing;
+ // ABSOLUTE assertions, not a tty/normal comparison: ghostty and this
+ // surface can BOTH be wrong about a cluster's width, and then a differential
+ // agrees with itself while the user sees the wrong thing. What is typed at
+ // the prompt is what must appear, each character wearing its own colour.
+ //
+ // Ghostty splits these clusters across cells and spells each one in the row
+ // dump, so the cell walk and the byte walk only agree if the byte walk is
+ // driven by what each CELL contributed. `👨‍💻` is two wide cells, `👨‍👩‍👧`
+ // three, `🇺🇸` two, `👍🏽` two, while all of them print as one glyph here.
+ const prompts = [_][]const u8{
+ "plain",
+ "\u{1F468}\u{200D}\u{1F4BB}", // technologist
+ "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}", // family
+ "\u{1F1FA}\u{1F1F8}", // flag
+ "\u{1F44D}\u{1F3FD}", // thumbs up, skin tone
+ "\u{2764}\u{FE0F}", // heart, VS16
+ "\u{0031}\u{FE0F}\u{20E3}", // keycap
+ "\u{754C}", // CJK wide
+ "e\u{301}", // NFD
+ };
+ for (prompts) |prompt| {
+ for ([_]bool{ false, true }) |filter| {
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 24, .rows = 6 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = filter;
+ pane.mode = .normal;
+
+ var buf: [256]u8 = undefined;
+ const bytes = try std.fmt.bufPrint(
+ &buf,
+ "\x1b]133;A\x1b\\\x1b[32m{s}$ \x1b]133;B\x1b\\\x1b[31mab\x1b[34mcd\x1b[0m",
+ .{prompt},
+ );
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ p.shell_rows.stale = true;
+ const s = try p.render(frame.allocator());
+
+ for ([_][]const u8{ "a", "b", "c", "d" }, 0..) |want, i| {
+ const cell = s.at(tx + @as(u16, @intCast(i)), body_y);
+ testing.expectEqualStrings(want, cell.grapheme()) catch |err| {
+ std.debug.print("\nprompt '{s}' filter={}: col {d}\n", .{ prompt, filter, i });
+ return err;
+ };
+ }
+ // `ab` was printed red and `cd` blue, so whatever the theme does
+ // with those two runs, the pair boundary has to fall between `b`
+ // and `c`. A prompt that cost the row a character shows up here as
+ // the boundary sliding onto the wrong glyph.
+ const fg = [_]pardes.Color{
+ s.at(tx, body_y).style.fg,
+ s.at(tx + 1, body_y).style.fg,
+ s.at(tx + 2, body_y).style.fg,
+ s.at(tx + 3, body_y).style.fg,
+ };
+ errdefer std.debug.print("\nprompt '{s}' filter={}: fg {any}\n", .{ prompt, filter, fg });
+ try testing.expect(std.meta.eql(fg[0], fg[1]));
+ try testing.expect(std.meta.eql(fg[2], fg[3]));
+ try testing.expect(!std.meta.eql(fg[1], fg[2]));
+ if (!filter) {
+ try testing.expectEqual(pardes.Color{ .index = 1 }, fg[0]);
+ try testing.expectEqual(pardes.Color{ .index = 4 }, fg[2]);
+ }
+ }
+ }
+}
+
+test "background-only cells keep their color through the prompt hug" {
+ const testing = std.testing;
+ // Backgrounds with no glyph under them are most of what a shell paints:
+ // erase-to-end-of-line after a colour is set, padded table cells, and a
+ // selected row. They have no text to align on, so they are the cells a
+ // column translation is most likely to lose.
+ const payload =
+ "\x1b]133;A\x1b\\\x1b[32mp\x1b[0m$ \x1b]133;B\x1b\\cmd\x1b[41m\x1b[K\r\n" ++
+ "\x1b[44mblue-bg\x1b[K\x1b[0m\r\n" ++
+ "a\x1b[42m \x1b[0mb\r\n" ++
+ "\x1b[100;97mbright-on-grey\x1b[0m\r\n" ++
+ "\x1b]133;A\x1b\\\x1b[35m>>\x1b[0m \x1b]133;B\x1b\\\x1b[48;5;19mrun\x1b[K\x1b[0m\r\n" ++
+ "\x1b[48;2;90;10;10mtruecolor-bg\x1b[K\x1b[0m\r\n";
+
+ for ([_]bool{ false, true }) |filter| {
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 30, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = filter;
+ p.update(.{ .output = .{ .pane = 0, .bytes = payload } });
+ const diffs = try modeStyleDiffs(p, pane, testing.allocator, if (filter) "bg filter=on" else "bg filter=off");
+ try testing.expectEqual(@as(usize, 0), diffs);
+ }
+}
+
+test "a leftover edit buffer does not move what tty mode shows" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 14 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .tty;
+
+ for (0..60) |i| {
+ var buf: [40]u8 = undefined;
+ const bytes = std.fmt.bufPrint(&buf, "\x1b[3{d}mL{d:0>2}\x1b[0m\r\n", .{ (i % 6) + 1, i }) catch unreachable;
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ }
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const rows: usize = r.h - pardes.BOX_H;
+ const cols: usize = r.w -| config.GUTTER;
+
+ // What tty mode shows with nothing left behind: the reference.
+ p.shell_rows.stale = true;
+ const clean = try p.render(frame.allocator());
+ const want_text = try testing.allocator.alloc([7]u8, rows * cols);
+ defer testing.allocator.free(want_text);
+ const want_fg = try testing.allocator.alloc(pardes.Color, rows * cols);
+ defer testing.allocator.free(want_fg);
+ for (0..rows) |row| for (0..cols) |col| {
+ const cell = clean.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(row)));
+ want_text[row * cols + col] = cell.text;
+ want_fg[row * cols + col] = cell.style.fg;
+ };
+
+ // `enterTty` clears every other modal remnant but leaves the edit buffer, so
+ // a buffer whose covered span STRADDLES the viewport top is an ordinary
+ // state. tty mode does not apply the buffer, so it must not be moved by one
+ // either — and `surfRow`/`gridRow` are not inverses across that span.
+ const anchor = gridOffset(pane);
+ pane.ovl = .{ .row = anchor - 1, .rows = 4, .text = try p.gpa.dupe(u8, "one\ntwo") };
+ p.shell_rows.stale = true;
+ _ = frame.reset(.retain_capacity);
+ const after = try p.render(frame.allocator());
+
+ for (0..rows) |row| for (0..cols) |col| {
+ const cell = after.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(row)));
+ try testing.expectEqualStrings(
+ std.mem.sliceTo(&want_text[row * cols + col], 0),
+ std.mem.sliceTo(&cell.text, 0),
+ );
+ try testing.expectEqual(want_fg[row * cols + col], cell.style.fg);
+ };
+}
+
+test "a background after a row-final wide glyph lands on the right columns" {
+ const testing = std.testing;
+ // A CJK glyph then a coloured erase-to-end-of-line, with a second colour
+ // partway. The glyph's grid tail spells no bytes, so the pairing walk used
+ // to stop ON it and pair every later column with the cell before it: an
+ // unpainted hole beside the glyph and every boundary one column right.
+ //
+ // ABSOLUTE assertions: both modes were wrong identically here, so a
+ // tty/normal differential says nothing.
+ for ([_]bool{ false, true }) |filter| {
+ for ([_]pardes.Mode{ .tty, .normal }) |mode| {
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 8 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = filter;
+ pane.mode = mode;
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[32m\u{754C}\x1b[41m\x1b[K\x1b[7G\x1b[44m\x1b[K\r\n" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ p.shell_rows.stale = true;
+ const s = try p.render(frame.allocator());
+
+ try testing.expectEqualStrings("\u{754C}", s.at(tx, body_y).grapheme());
+ // The glyph covers columns 0-1; red runs from 2 up to the second
+ // erase at column 6 (1-based 7), blue from there to the edge.
+ const red = s.at(tx + 3, body_y).style.bg;
+ const blue = s.at(tx + 9, body_y).style.bg;
+ errdefer std.debug.print("\nmode={any} filter={}: red={any} blue={any} col2={any}\n", .{ mode, filter, red, blue, s.at(tx + 2, body_y).style.bg });
+ try testing.expect(!std.meta.eql(red, blue));
+ for (2..6) |c| try testing.expectEqual(red, s.at(tx + @as(u16, @intCast(c)), body_y).style.bg);
+ for (6..10) |c| try testing.expectEqual(blue, s.at(tx + @as(u16, @intCast(c)), body_y).style.bg);
+ }
+ }
+}
+
+test "a colored row reaches its last column when a wide glyph did not fit" {
+ const testing = std.testing;
+ // Thirteen cells of red background, then a wide glyph with one column left:
+ // ghostty leaves a `spacer_head` in that last column, carrying the row's
+ // background, and wraps the glyph to the next row. A head OWNS its column,
+ // so skipping it the way a tail is skipped left the row's final column bare.
+ for ([_]pardes.Mode{ .tty, .normal }) |mode| {
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 16, .rows = 8 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = mode;
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[41mzzzzzzzzzzzzz\u{754C}\x1b[0m\r\n" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ p.shell_rows.stale = true;
+ const s = try p.render(frame.allocator());
+
+ const red: pardes.Color = .{ .index = 1 };
+ var c: u16 = 0;
+ while (c < r.w -| config.GUTTER) : (c += 1) {
+ errdefer std.debug.print("\nmode={any} col {d} bg={any}\n", .{ mode, c, s.at(tx + c, body_y).style.bg });
+ try testing.expectEqual(red, s.at(tx + c, body_y).style.bg);
+ }
+ }
+}
+
+test "tty colours survive a scrollback deeper than the pane" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 30, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .tty;
+ for (0..40) |i| {
+ var buf: [64]u8 = undefined;
+ const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mline-{d:0>2}\x1b[0m\r\n", .{ 20 + i, i }) catch unreachable;
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ }
+ p.shell_rows.stale = true;
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const s = try p.render(frame.allocator());
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+
+ var bad: usize = 0;
+ for (0..r.h -| pardes.BOX_H) |vr| {
+ var buf: [16]u8 = undefined;
+ var n: usize = 0;
+ for (0..10) |c| {
+ const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme();
+ if (g.len != 1) break;
+ buf[n] = g[0];
+ n += 1;
+ }
+ const txt = buf[0..n];
+ if (!std.mem.startsWith(u8, txt, "line-")) continue;
+ const num = std.fmt.parseInt(usize, std.mem.trim(u8, txt[5..], " "), 10) catch continue;
+ const want = pardes.Color{ .index = @intCast(20 + num) };
+ const got = s.at(tx, body_y + @as(u16, @intCast(vr))).style.fg;
+ if (!std.meta.eql(want, got)) {
+ bad += 1;
+ std.debug.print("row {d}: text {s} want {any} got {any}\n", .{ vr, txt, want, got });
+ }
+ }
+ try testing.expectEqual(@as(usize, 0), bad);
+}
+
+test "reverse video swaps the default colors with the filter off too" {
+ const testing = std.testing;
+ // DECSCNM is a property of the terminal, not of a cell's SGR, so it has to
+ // be honoured on BOTH colour paths. The theme filter folds it into its own
+ // palette; the raw path resolves a `.none` colour by role, and simply
+ // dropped reverse video altogether.
+ for ([_]bool{ false, true }) |filter| {
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 20, .rows = 6 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = filter;
+ pane.mode = .normal;
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "plain text\r\n" } });
+ p.shell_rows.stale = true;
+ const before = try p.render(frame.allocator());
+ const plain = before.at(tx, body_y).style;
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } });
+ p.shell_rows.stale = true;
+ _ = frame.reset(.retain_capacity);
+ const after = try p.render(frame.allocator());
+ const reversed = after.at(tx, body_y).style;
+
+ errdefer std.debug.print("\nfilter={}: plain fg={any} bg={any} | reversed fg={any} bg={any}\n", .{ filter, plain.fg, plain.bg, reversed.fg, reversed.bg });
+ try testing.expectEqual(plain.fg, reversed.bg);
+ try testing.expectEqual(plain.bg, reversed.fg);
+ }
+}
+
+test "untouched lines between two edits keep their colors" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 24, .rows = 14 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .normal;
+
+ for (0..6) |i| {
+ var buf: [40]u8 = undefined;
+ const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d:0>2}\x1b[0m\r\n", .{ 16 + i, i }) catch unreachable;
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ }
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+
+ // The state two ordinary edits reach: one at the bottom, one that split a
+ // line further up. The buffer now spans rows 2..6 and diverges at BOTH
+ // ends, with three untouched lines in the middle. Matching a leading and a
+ // trailing run stops at the first divergence and drains exactly those three;
+ // each line carries its own evidence, so each is anchored on its own.
+ pane.ovl = .{ .row = 2, .rows = 5, .text = try p.gpa.dupe(u8, "r\now-02\nrow-03\nrow-04\nrow-05\nZ") };
+ p.shell_rows.stale = true;
+ const s = try p.render(frame.allocator());
+
+ // body row 2+k shows buffer line k; lines 2..4 are `row-03`..`row-05`
+ for (0..3) |k| {
+ const vr = @as(u16, @intCast(4 + k));
+ var buf: [8]u8 = undefined;
+ const want_text = std.fmt.bufPrint(&buf, "row-{d:0>2}", .{3 + k}) catch unreachable;
+ const cell = s.at(tx, body_y + vr);
+ errdefer std.debug.print("\nbody row {d}: glyph '{s}' fg {any}\n", .{ vr, cell.grapheme(), cell.style.fg });
+ try testing.expectEqualStrings(want_text[0..1], cell.grapheme());
+ try testing.expectEqual(pardes.Color{ .index = @intCast(19 + k) }, cell.style.fg);
+ }
+}
+
+test "a prompt row hidden end to end paints nothing at all" {
+ const testing = std.testing;
+ // The command's first glyph is wide with one column left, so ghostty leaves
+ // a spacer_head carrying the command's background and wraps the glyph to
+ // the next row. `promptRow` renders this row EMPTY, so no cell of it may
+ // take a colour — a spacer owns no column of its own.
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 8 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .normal;
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32maaaaaaaaa\x1b]133;B\x1b\\\x1b[41;36m\u{754C}\x1b[0m\r\n" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ p.shell_rows.stale = true;
+ const s = try p.render(frame.allocator());
+
+ try testing.expectEqualStrings(" ", s.at(tx, body_y).grapheme());
+ try testing.expect(!std.meta.eql(pardes.Color{ .index = 1 }, s.at(tx, body_y).style.bg));
+}
+
+test "an emptied edit buffer does not shift the colors below it" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .normal;
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31m000\x1b[0m\r\n\x1b[32m111\x1b[0m\r\n\r\n\x1b[34m333\x1b[0m\r\n\x1b[35m444\x1b[0m" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+
+ // The state three keystrokes reach on any blank shell row: type a character
+ // and delete it, and the buffer holds NO text while still standing in for
+ // the row. `modal.lineCount("")` is 0 while `splitScalar("")` yields one
+ // line, so anything deriving the slide from the former puts every colour
+ // below here one row too far down — and drops the bottom row's entirely.
+ pane.ovl = .{ .row = 2, .rows = 1, .text = try p.gpa.dupe(u8, "") };
+ p.shell_rows.stale = true;
+ const s = try p.render(frame.allocator());
+
+ try testing.expectEqualStrings("3", s.at(tx, body_y + 3).grapheme());
+ try testing.expectEqualStrings("4", s.at(tx, body_y + 4).grapheme());
+ try testing.expectEqual(pardes.Color{ .index = 4 }, s.at(tx, body_y + 3).style.fg);
+ try testing.expectEqual(pardes.Color{ .index = 5 }, s.at(tx, body_y + 4).style.fg);
+ // ...and the user's own empty line takes no colour from the row beneath it
+ try testing.expect(!std.meta.eql(pardes.Color{ .index = 4 }, s.at(tx, body_y + 2).style.fg));
}
test "an edit overlay never changes tty-mode ansi colors" {
@@ -1716,3 +2973,252 @@ const DeviceAttrs = @typeInfo(@typeInfo(@typeInfo(
pub fn ptyDeviceAttrs(_: *ghostty_vt.TerminalStream.Handler) DeviceAttrs {
return .{};
}
+
+test "an edited row keeps the colours of the bytes the edit did not touch" {
+ const testing = std.testing;
+ // The loudest colour bug this editor had: one keystroke anywhere in a
+ // coloured row turned EVERY column of it grey, because an anchor was all or
+ // nothing. The row's own bytes survive at both ends of what was typed, and
+ // being the same bytes they keep the same colours; only the typed character
+ // has no cell under it and so takes none.
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 30, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .normal;
+ for (0..6) |i| {
+ var buf: [64]u8 = undefined;
+ const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d}-abcdefgh\x1b[0m\r\n", .{ 30 + i, i }) catch unreachable;
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ }
+ // One `Z` typed into the middle of row 3's own text.
+ pane.ovl = .{ .row = 3, .rows = 1, .text = try p.gpa.dupe(u8, "row-3-abcZdefgh") };
+ p.shell_rows.stale = true;
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+
+ const s = try p.render(frame.allocator());
+ const want = pardes.Color{ .index = 33 };
+ var seen = false;
+ for (0..@as(usize, r.h -| pardes.BOX_H)) |vr| {
+ var buf: [15]u8 = undefined;
+ for (0..15) |c| {
+ const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme();
+ buf[c] = if (g.len == 1) g[0] else '?';
+ }
+ if (!std.mem.eql(u8, &buf, "row-3-abcZdefgh")) continue;
+ seen = true;
+ for (0..15) |c| {
+ const got = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).style.fg;
+ errdefer std.debug.print("\nedited row col {d} ('{c}') fg={any}\n", .{ c, buf[c], got });
+ // Column 9 is the typed `Z`; every other column is row 3's own.
+ if (c == 9) try testing.expect(!std.meta.eql(want, got)) else try testing.expectEqual(want, got);
+ }
+ }
+ try testing.expect(seen);
+}
+
+test "joining two rows leaves the rows below them their colours" {
+ const testing = std.testing;
+ // A join removes a buffer line while the buffer's covered span GROWS, so the
+ // two counts cancel at `lines == covered`. Anchoring that only counts down
+ // from the buffer's top and up from its bottom then resolves both ways to
+ // the SAME row, one short of where the lines below live, and every untouched
+ // row under the join went plain. This is the state four keystrokes reach
+ // (Enter, then a backspace two rows up), taken from the fuzzer that found it.
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 34, .rows = 14 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .normal;
+ for (0..26) |i| {
+ var buf: [64]u8 = undefined;
+ const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d:0>2}-xyzzy\x1b[0m\r\n", .{ 20 + i, i }) catch unreachable;
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ }
+ pane.ovl = .{
+ .row = 23,
+ .rows = 4,
+ .text = try p.gpa.dupe(u8, "row-23-xyzzyrow-24-xyzzy\nrow-25-xyzzy\n\n"),
+ };
+ p.shell_rows.stale = true;
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+
+ const s = try p.render(frame.allocator());
+ var seen = false;
+ for (0..@as(usize, r.h -| pardes.BOX_H)) |vr| {
+ var buf: [12]u8 = undefined;
+ for (0..12) |c| {
+ const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme();
+ buf[c] = if (g.len == 1) g[0] else '?';
+ }
+ if (!std.mem.eql(u8, &buf, "row-25-xyzzy")) continue;
+ seen = true;
+ // The join is above it and its own text is untouched, so every column
+ // still carries row 25's own colour.
+ for (0..12) |c| {
+ const got = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).style.fg;
+ errdefer std.debug.print("\nrow-25 col {d} fg={any}\n", .{ c, got });
+ try testing.expectEqual(pardes.Color{ .index = 45 }, got);
+ }
+ }
+ try testing.expect(seen);
+}
+
+test "an untouched row always carries the colour its own text names" {
+ const testing = std.testing;
+ // Random editing, absolute oracle: every row's own text names the colour it
+ // must have, so no sequence of keystrokes may leave an UNTOUCHED row wearing
+ // anything else. This is what found the join above, and the empty line that
+ // claimed a blank row far below it and took every coloured row in between
+ // out of reach of the lines that owned them.
+ var seed: u64 = 0;
+ while (seed < 40) : (seed += 1) {
+ var prng = std.Random.DefaultPrng.init(seed);
+ const rand = prng.random();
+
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 34, .rows = 14 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ // The raw palette, so a row's text names its exact colour instead of one
+ // this test would have to re-derive from the theme.
+ pane.tty_filter = false;
+ pane.mode = .normal;
+ for (0..26) |i| {
+ var buf: [64]u8 = undefined;
+ const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d:0>2}-xyzzy\x1b[0m\r\n", .{ 20 + i, i }) catch unreachable;
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ }
+ p.shell_rows.stale = true;
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const body_h = r.h -| pardes.BOX_H;
+
+ var step: usize = 0;
+ while (step < 12) : (step += 1) {
+ _ = frame.reset(.retain_capacity);
+ const s = try p.render(frame.allocator());
+ for (0..body_h) |vr| {
+ var buf: [24]u8 = undefined;
+ for (0..24) |c| {
+ const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme();
+ buf[c] = if (g.len == 1) g[0] else '?';
+ }
+ const txt = std.mem.trimEnd(u8, buf[0..24], " ");
+ if (txt.len != 12) continue;
+ if (!std.mem.startsWith(u8, txt, "row-") or !std.mem.endsWith(u8, txt, "-xyzzy")) continue;
+ const num = std.fmt.parseInt(usize, txt[4..6], 10) catch continue;
+ const want = pardes.Color{ .index = @intCast(20 + num) };
+ for (0..txt.len) |c| {
+ const got = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).style.fg;
+ errdefer std.debug.print("\nseed {d} step {d}: untouched '{s}' col {d} fg={any}\n", .{ seed, step, txt, c, got });
+ try testing.expectEqual(want, got);
+ }
+ }
+
+ switch (rand.intRangeAtMost(u8, 0, 10)) {
+ 0 => p.update(.{ .key = .{ .cp = pardes.Key.up } }),
+ 1 => p.update(.{ .key = .{ .cp = pardes.Key.down } }),
+ 2 => p.update(.{ .key = .{ .cp = pardes.Key.left } }),
+ 3 => p.update(.{ .key = .{ .cp = pardes.Key.right } }),
+ 4 => {
+ p.update(.{ .key = .{ .cp = 'i', .text = "i" } });
+ p.update(.{ .key = .{ .cp = 'Q', .text = "Q" } });
+ p.update(.{ .key = .{ .cp = pardes.Key.escape } });
+ },
+ 5 => {
+ p.update(.{ .key = .{ .cp = 'i', .text = "i" } });
+ p.update(.{ .key = .{ .cp = pardes.Key.enter } });
+ p.update(.{ .key = .{ .cp = pardes.Key.escape } });
+ },
+ 6 => {
+ p.update(.{ .key = .{ .cp = 'i', .text = "i" } });
+ p.update(.{ .key = .{ .cp = pardes.Key.backspace } });
+ p.update(.{ .key = .{ .cp = pardes.Key.escape } });
+ },
+ 7 => {
+ p.update(.{ .key = .{ .cp = 'i', .text = "i" } });
+ p.update(.{ .key = .{ .cp = 'W', .text = "W" } });
+ p.update(.{ .key = .{ .cp = 'W', .text = "W" } });
+ p.update(.{ .key = .{ .cp = pardes.Key.escape } });
+ },
+ 8 => p.update(.{ .key = .{ .cp = pardes.Key.home } }),
+ 9 => p.update(.{ .key = .{ .cp = pardes.Key.end } }),
+ else => {
+ p.update(.{ .key = .{ .cp = 'i', .text = "i" } });
+ p.update(.{ .key = .{ .cp = pardes.Key.delete } });
+ p.update(.{ .key = .{ .cp = pardes.Key.escape } });
+ },
+ }
+ while (p.nextEffect()) |_| {}
+ }
+ }
+}
+
+test "a new empty line does not take the colours of the rows below it" {
+ const testing = std.testing;
+ // Splitting a row makes an EMPTY buffer line, and empty equals every blank
+ // row in the buffer's span - including the one under the last output. Left
+ // free to look ahead for a row spelling the same bytes, that line claimed
+ // the blank row far below and put every coloured row in between out of
+ // reach of the lines that owned them. Two keystrokes (Home, Enter) got here.
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 34, .rows = 14 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .normal;
+ for (0..26) |i| {
+ var buf: [64]u8 = undefined;
+ const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d:0>2}-xyzzy\x1b[0m\r\n", .{ 20 + i, i }) catch unreachable;
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ }
+ // A newline typed at column 0 of row 24, and `WW` typed on the blank row
+ // below the output: the span covers rows 24, 25 and that blank row.
+ pane.ovl = .{
+ .row = 24,
+ .rows = 3,
+ .text = try p.gpa.dupe(u8, "\nrow-24-xyzzy\nrow-25-xyzzy\nWW"),
+ };
+ p.shell_rows.stale = true;
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+
+ const s = try p.render(frame.allocator());
+ var seen: usize = 0;
+ for (0..@as(usize, r.h -| pardes.BOX_H)) |vr| {
+ var buf: [12]u8 = undefined;
+ for (0..12) |c| {
+ const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme();
+ buf[c] = if (g.len == 1) g[0] else '?';
+ }
+ if (!std.mem.startsWith(u8, &buf, "row-2")) continue;
+ const num = std.fmt.parseInt(usize, buf[4..6], 10) catch continue;
+ if (num != 24 and num != 25) continue;
+ seen += 1;
+ const got = s.at(tx, body_y + @as(u16, @intCast(vr))).style.fg;
+ errdefer std.debug.print("\nrow-{d} fg={any}\n", .{ num, got });
+ try testing.expectEqual(pardes.Color{ .index = @intCast(20 + num) }, got);
+ }
+ try testing.expectEqual(@as(usize, 2), seen);
+}