From 29ac9be75fdcafbd7d05c15aa9eb8490d74caa98 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 26 Aug 2026 18:58:37 -0300 Subject: An edited row keeps its colours, four copies of forkShell become one, and Esc stops recentring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## A terminal row's ANSI colours survive being edited The loudest colour bug this editor had: one keystroke anywhere in a coloured shell row turned EVERY column of it grey. `EditAnchors` anchored a buffer line only when it was BYTE-IDENTICAL to the shell row it stood over, so a single differing byte dropped the whole row's colour projection. Worst shape is invisible: append past the pane's right edge, where the text is clipped, and the row looks the same and only its colour goes. Anchoring is byte-level now. An edit leaves the row's own bytes at both ends, and being the same bytes they keep the same colours; only what was typed has no cell under it, so only that takes none. Live, on real `fastfetch`: a 32-column blue run split into 6 + 26 around one typed character. Three defects underneath it, all found by machinery rather than by reading: * A JOIN removes a buffer line while the buffer's covered span grows, so `lines == covered` and both aligned guesses — Nth line over the Nth covered row, and the same counted from the bottom — resolved to the SAME wrong row. Every untouched row below a join went plain. Anchoring is now a streaming monotone matching: one shell-row cursor that only ever moves forward, advanced once per buffer line, linear in the buffer where the version before it was quadratic. * An EMPTY line is not evidence. Splitting a row makes one, it equals every blank row in the span, and left free to look ahead it claimed the blank row below the last output and took every coloured row in between out of reach of the lines that owned them. * Reflow under a scrolled viewport. `PageList.getTopLeft(.viewport)` returns the viewport pin verbatim, x and all, while `PageList.pin` forces x to 0 — so after a reflow remapped a tracked pin into the middle of a row, the text pass dumped row 0 from that column while the colour pass paired the fragment with the row's FIRST cells. Row 0 wore its left half's colours until the pane snapped back to live output. `bodyText` dumps from column zero now, which is also what ghostty's own renderer draws. Also here: DECSCNM (reverse video) was silently dropped whenever `tty_filter` was off, because the raw path resolved a `.none` colour by role and never consulted the mode. The test that found the first two is the one worth keeping: random editing against an ABSOLUTE oracle — every row's own text names the colour it must have — because the differential oracle it replaced was blind by construction. It skipped the edited row, which is the row the user is complaining about. ## Esc returns to a pane without moving its view Esc in body normal mode runs `Last`, "the pane you were in before this one", and that went through `focusPaneLine`, which recentred a file on the target line unconditionally. So returning to a buffer repainted the whole screen to show a line that was already on it. `focusPaneLine` takes a landing now: `.center` for the three callers going somewhere you have not been (a look target, a path a pane already holds, `@pN:LINE:COL`), `.keep` for Esc. `.keep` leaves the view alone and lets `ensureCursorVisible` — which already existed and already scrolls by the minimum into the `scroll_off` band — be the only thing that may move anything. Not `line = 0`, which `focusPaneLine` already understands as "focus and touch nothing": a background pane's view can move while you are away, because the wheel scrolls the pane under the POINTER and a resize reveals no cursor, so the recorded cursor plus a minimal nudge is what actually gets you back. Ctrl-o and Ctrl-i keep centring, and the asymmetry is structural rather than arbitrary: `Last` only ever CROSSES panes, so the pane it lands on already holds the view you left it with, while `jumpBy` can land in the SAME pane, where a long in-file jump would arrive on the very top or bottom row with `scroll_off` lines of context on one side. Helix splits the same pair the same way — its jumplist centres, its buffer switch does not. One deliberate consequence: under `.keep` a PDF's page is not restored AT ALL, because a page reveal IS that pane's view and a reveal of the page you are already on still snaps `document_scroll_y` to that page's start, discarding where you had read to. When something moved the pane while you were away — the wheel again — Esc leaves it where the wheel left it, and Ctrl-o is how you reach the recorded page. ## host_io.zig: the machine-local half of a host, once `host.zig` is the seam. The part of the answer that is identical on every host with an operating system under it — fork a pane's shell, put bytes on a disk — was written FOUR times: in tty.zig, gui.zig, macos.zig and detached/server.zig. What those copies had in common says what they were for: all four were missing FD_CLOEXEC on the pty master, so in every shell pardes has shipped, a program in one pane could read another pane's terminal. One copy now, and the wire got smaller for it: `ServerMsg.spawn` is gone. A frontend never asked the server to fork anything — the server has an operating system under it and forks through `host_io` like every other host — and `decodeClient` lost the scratch buffer that message needed. --- src/term_pane.zig | 1632 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 1569 insertions(+), 63 deletions(-) (limited to 'src/term_pane.zig') 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); +} -- cgit v1.3