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.zig3525
1 files changed, 0 insertions, 3525 deletions
diff --git a/src/term_pane.zig b/src/term_pane.zig
deleted file mode 100644
index 17e50c88..00000000
--- a/src/term_pane.zig
+++ /dev/null
@@ -1,3525 +0,0 @@
-//! Terminal panes: everything a Pane does BECAUSE it owns a ghostty-vt
-//! emulator — constructing and replaying the emulator, reading its grid back
-//! out as text (for motions and for the body), translating its cell styles
-//! into ours, handling the pty replies it hands back through a callback, and
-//! keeping the edit-buffer undo snapshots that only exist because a terminal's
-//! "content" is a live grid rather than a []u8.
-//!
-//! Shared modal edit semantics and the pane-wide screen/grid coordinate
-//! invariants remain on Pane in pardes.zig. Terminal-only projection, history
-//! snapshots, and cell styling live here, so the core does not need to know
-//! how a live terminal becomes an editable text surface.
-//!
-//! ...and because it does not, this is the only CORE file that ever holds a
-//! ghostty-vt VALUE: pardes.zig no longer imports the emulator at all, and
-//! image.zig's import exists solely to comptime-check a colour table against
-//! it. (src/gui/gui.zig and the test/ snapshot harness import it too — both
-//! are backends, and neither is in the esp32p4 graph.) `pardes.terminal_panes` says
-//! whether a build has an emulator at all; the two Pane slots and the
-//! accessors under "the emulator, as the core is allowed to see it" are the
-//! whole seam, and `!enabled` answers every one of them with the empty grid.
-//! See pardes.terminal_panes for why the P4 firmware has none.
-const std = @import("std");
-const pardes = @import("pardes.zig");
-const Pardes = pardes.Pardes;
-const Pane = pardes.Pane;
-const Key = pardes.Key;
-const EditText = pardes.EditText;
-const modal = @import("modal.zig");
-const config = @import("config.zig");
-const dump = @import("dump.zig");
-const tracy = @import("tracy.zig"); // no-op unless -Dtracy names a checkout
-
-/// `pardes.terminal_panes`, re-exported so every gate in this file reads one
-/// local name. When false the import below is a DEAD comptime branch, so
-/// build.zig need not resolve the ghostty dependency at all.
-pub const enabled = pardes.terminal_panes;
-const ghostty_vt = if (enabled) @import("ghostty-vt") else struct {};
-
-/// EDIT-BUFFER BOUNDARIES REMEMBERED PER PANE. Snapshots copy the whole edit
-/// buffer, so keep this tighter than files.
-///
-/// A CAPACITY, not a presence: without an emulator `pane.ovl` is not a typed
-/// overlay on a live grid, it is the pane's ENTIRE content (see `create` and
-/// `restore` below), so undo on it matters more here, not less. But each entry
-/// is a gpa copy of that content, and 64 of them is 2.5 KiB of `Pane` plus 64
-/// heap copies — on a board with a 384 KiB heap the ring would run out of
-/// memory long before it ran out of slots. `pushHistory` evicts and frees the
-/// oldest once full, so the shorter ring loses only the deepest undo steps.
-pub const history_max = if (enabled) 64 else 8;
-
-/// The emulator and its VT parser as PANE FIELDS — the `PdfSlot` pattern from
-/// pardes.zig, zero-sized where there are no terminal panes. Declared here
-/// rather than there so the emulator's type never has to be named by the core.
-pub const VtSlot = if (enabled) ghostty_vt.Terminal else void;
-pub const StreamSlot = if (enabled) ghostty_vt.TerminalStream else void;
-
-const GColor = ghostty_vt.color;
-
-/// The inputs which completely determine a filtered terminal palette. Theme
-/// names and indices are intentionally absent: ThemeFile may replace a theme
-/// in place under the same name, while equal colour values need no rebuild.
-const FilterPaletteKey = struct {
- bg: GColor.RGB,
- fg: GColor.RGB,
- base: [16]GColor.RGB,
-};
-
-/// Cached 256-colour projection of the current Pardes theme. Ghostty owns the
-/// interpolation: its CIELAB cube and greyscale ramp give every xterm key a
-/// theme-derived RGB value while retaining the conventional dark-to-light
-/// index orientation on light themes (`harmonious = false`).
-///
-/// Zero-sized without an emulator — there are no ANSI cells to reproject, so
-/// `Pardes.tty_filter_palette` costs the core nothing but keeps its `.{}`.
-pub const FilterPalette = if (enabled) LivePalette else struct {};
-
-const LivePalette = struct {
- key: ?FilterPaletteKey = null,
- colors: GColor.Palette = GColor.default,
-
- fn get(self: *LivePalette, theme: *const pardes.Theme) *const GColor.Palette {
- const bg = asGhostRgb(theme.bg orelse theme.tag_bg);
- const fg = asGhostRgb(theme.fg orelse theme.tag_fg);
- var base: [16]GColor.RGB = undefined;
- if (theme.palette) |palette| {
- for (&base, palette) |*dst, src| dst.* = asGhostRgb(src);
- } else {
- // A theme without an ANSI table still supplies every key. The
- // first eight keep the usual semantic families; their bright
- // partners use the same accents plus the theme's lighter chrome.
- const synthesized = [16][3]u8{
- theme.bg orelse theme.tag_bg,
- theme.kw,
- theme.str,
- theme.num,
- theme.box,
- theme.sel_bg,
- theme.comment,
- theme.fg orelse theme.tag_fg,
- theme.lineno,
- theme.kw,
- theme.str,
- theme.num,
- theme.scroll_thumb,
- theme.sel_fg,
- theme.tag_fg,
- theme.fg orelse theme.tag_fg,
- };
- for (&base, synthesized) |*dst, src| dst.* = asGhostRgb(src);
- }
-
- const key: FilterPaletteKey = .{ .bg = bg, .fg = fg, .base = base };
- if (self.key) |old| if (std.meta.eql(old, key)) return &self.colors;
-
- var seed = GColor.default;
- for (base, 0..) |rgb, i| seed[i] = rgb;
- self.colors = GColor.generate256Color(seed, .initEmpty(), bg, fg, false);
- self.key = key;
- return &self.colors;
- }
-};
-
-fn asGhostRgb(rgb: [3]u8) GColor.RGB {
- return .{ .r = rgb[0], .g = rgb[1], .b = rgb[2] };
-}
-
-fn asPardesColor(rgb: GColor.RGB) pardes.Color {
- return .{ .rgb = .{ rgb.r, rgb.g, rgb.b } };
-}
-
-/// The owned text standing in for `rows` live terminal rows, beginning at
-/// absolute surface row `row`. The emulator grid remains untouched underneath.
-pub const EditBuffer = struct {
- row: i32 = 0,
- rows: i32 = 1,
- text: []u8 = &.{},
-};
-
-/// One whole-state terminal edit boundary. Null means the pane has not yet
-/// materialized an edit buffer; non-null snapshots own their text.
-pub const Snapshot = struct {
- ovl: ?EditBuffer,
- cur_row: i32,
- cur_col: i32,
- vsel: pardes.CharSel,
-};
-
-/// Command bytes aimed at a shell whose prompt does not exist yet. The host
-/// resolves the actual executable after the core has already queued `.spawn`,
-/// so `spawn` is deliberately an UNKNOWN phase: shells with prompt integration
-/// advance to `input` and wait for OSC 133 B; unadorned/unsupported shells are
-/// opened immediately by `shellSpawned` and let the pty buffer input until the
-/// child reads it.
-///
-/// This is pane state, not a Pardes-wide job table. A reused pane slot can
-/// therefore never inherit a command intended for the shell it replaced.
-pub const PendingCommand = struct {
- bytes: []u8 = &.{},
- wait: enum { none, spawn, input } = .none,
-};
-
-/// A terminal constructed by `newShell` cannot safely receive a command until
-/// the native host has at least completed forkpty. Dump-replay terminals do
-/// not call this: they are dead grids, not half-spawned children.
-pub fn armShellSpawn(pane: *Pane) void {
- // With no emulator there is no fork to wait on and no OSC 133 that could
- // ever arrive, so the gate stays open: `queuePendingCommand` declines and
- // the command leaves as an ordinary write, rather than waiting forever.
- if (comptime !enabled) return;
- std.debug.assert(pane.pending_command.bytes.len == 0);
- pane.pending_command.wait = .spawn;
-}
-
-/// Own `command` until this pane's new child can accept it. False means the
-/// gate is already open and the caller should emit in the ordinary way.
-/// Multiple gestures before the prompt appears retain their byte order in one
-/// flat allocation; each command gets exactly the CR execute normally emits.
-pub fn queuePendingCommand(pane: *Pane, command: []const u8) !bool {
- if (pane.pending_command.wait == .none) return false;
- const old_len = pane.pending_command.bytes.len;
- const new_len = try std.math.add(usize, old_len, try std.math.add(usize, command.len, 1));
- const bytes = if (old_len == 0)
- try pane.gpa.alloc(u8, new_len)
- else
- try pane.gpa.realloc(pane.pending_command.bytes, new_len);
- @memcpy(bytes[old_len..][0..command.len], command);
- bytes[new_len - 1] = '\r';
- pane.pending_command.bytes = bytes;
- // An explicit command replaces the automatic greeting. Otherwise both
- // would be released by the same first prompt and `ls` would follow what
- // the user actually asked to run.
- pane.greet = false;
- return true;
-}
-
-/// The host successfully forked `pane`. `prompt_marks` describes the argv it
-/// ACTUALLY used, not the configured shell name: a staged-rc failure or an
-/// unsupported family is an honest unmarked shell and must not wait forever
-/// for an OSC sequence it cannot produce.
-pub fn shellSpawned(p: *Pardes, id: usize, prompt_marks: bool) void {
- const pane = p.panes[id] orelse return;
- if (!pane.isTerminal() or pane.pending_command.wait != .spawn) return;
- if (prompt_marks) {
- pane.pending_command.wait = .input;
- releasePendingCommand(p, id, pane, false);
- } else {
- // There is no semantic event on which an automatic greeting can be
- // safely based. Explicit commands still release below (the pty owns
- // their buffering); silently omit the cosmetic `ls` rather than race
- // an unknown shell's startup and possibly type into its rc program.
- pane.greet = false;
- releasePendingCommand(p, id, pane, true);
- }
-}
-
-/// Called from the ordinary sync after terminal output has been parsed. The
-/// semantic cursor is ghostty-vt's parsed OSC state, so this and the greeting
-/// share the exact same definition of "readline owns input".
-pub fn releasePendingCommandIfReady(p: *Pardes, id: usize, pane: *Pane) void {
- if (pane.pending_command.wait == .input)
- releasePendingCommand(p, id, pane, promptInputReady(pane));
-}
-
-fn releasePendingCommand(p: *Pardes, id: usize, pane: *Pane, ready: bool) void {
- if (!ready) return;
- const bytes = pane.pending_command.bytes;
- pane.pending_command = .{};
- if (bytes.len > 0) {
- p.emitWrite(id, bytes);
- pane.gpa.free(bytes);
- }
-}
-
-pub fn deinitPendingCommand(pane: *Pane) void {
- if (pane.pending_command.bytes.len > 0)
- pane.gpa.free(pane.pending_command.bytes);
- pane.pending_command = .{};
-}
-
-/// The emulator stores an Io value for optional kitty-image work. The browser
-/// has no host IO and must not instantiate std.Io.Threaded's POSIX backend
-/// merely to construct a replay-only terminal.
-pub fn terminalIo() std.Io {
- return if (comptime !pardes.hosted)
- std.Io.failing
- else
- std.Io.Threaded.global_single_threaded.io();
-}
-
-// ---- the emulator, as the core is allowed to see it ----
-//
-// Every question pardes.zig used to answer by walking `pane.vt.screens.active`
-// for itself, named. That is the boundary this file's header always claimed,
-// and naming them is what lets a build with no emulator answer ALL of them at
-// comptime with the empty grid, instead of scattering one platform test
-// through the core's scroll, cursor, mouse, resize and render paths.
-
-/// The three numbers ghostty's scrollbar reports; all zero without an emulator.
-pub const Scrollbar = struct { total: usize = 0, offset: usize = 0, len: usize = 0 };
-
-pub fn scrollbar(pane: *const Pane) Scrollbar {
- if (comptime !enabled) return .{};
- const sb = pane.vt.screens.active.pages.scrollbar();
- return .{ .total = sb.total, .offset = sb.offset, .len = sb.len };
-}
-
-/// The emulator's viewport offset, in SHELL rows: the top of what it shows.
-pub fn gridOffset(pane: *const Pane) i32 {
- return @intCast(scrollbar(pane).offset);
-}
-
-/// Where the emulator itself puts the cursor, in viewport cells — the origin
-/// without one, which is where an empty pane's cursor belongs anyway.
-pub const GridCursor = struct { x: u16 = 0, y: u16 = 0 };
-
-pub fn gridCursor(pane: *const Pane) GridCursor {
- if (comptime !enabled) return .{};
- const cur = pane.vt.screens.active.cursor;
- return .{ .x = @intCast(cur.x), .y = @intCast(cur.y) };
-}
-
-/// Move the emulator's viewport by `delta` shell rows (negative scrolls back).
-pub fn scrollGrid(pane: *Pane, delta: i32) void {
- if (comptime !enabled) return;
- pane.vt.screens.active.scroll(.{ .delta_row = delta });
-}
-
-/// Snap the viewport back onto live output.
-pub fn followOutput(pane: *Pane) void {
- if (comptime !enabled) return;
- pane.vt.screens.active.scroll(.active);
-}
-
-/// Reflow the grid. A failed reflow keeps the grid it had rather than dropping
-/// a scrollback; the next resize retries with the same numbers.
-pub fn resizeGrid(pane: *Pane, gpa: std.mem.Allocator, cols: u16, rows: u16) void {
- if (comptime !enabled) return;
- pane.vt.resize(gpa, .{ .cols = cols, .rows = rows }) catch {};
-}
-
-/// DECSET 2004: the program wants its pastes bracketed.
-pub fn bracketedPaste(pane: *const Pane) bool {
- if (comptime !enabled) return false;
- return pane.vt.modes.get(.bracketed_paste);
-}
-
-/// The program tracks the mouse itself, so a click in its body is its event.
-pub fn reportsMouse(pane: *const Pane) bool {
- if (comptime !enabled) return false;
- const m = &pane.vt.modes;
- return m.get(.mouse_event_normal) or m.get(.mouse_event_button) or m.get(.mouse_event_any);
-}
-
-/// ...and wants them in SGR (1006) rather than the legacy X10 bytes.
-pub fn mouseFormatSgr(pane: *const Pane) bool {
- if (comptime !enabled) return false;
- return pane.vt.modes.get(.mouse_format_sgr);
-}
-
-/// The whole scrollback as plain text, `gpa`-owned: what `Save` writes out.
-pub fn screenTextAlloc(pane: *Pane, gpa: std.mem.Allocator) ![]const u8 {
- if (comptime !enabled) return &.{};
- return pane.vt.screens.active.dumpStringAlloc(gpa, .{ .screen = .{} });
-}
-
-/// Release the emulator's heap. The Pane allocation itself is the core's.
-pub fn deinitEmulator(pane: *Pane, gpa: std.mem.Allocator) void {
- if (comptime !enabled) return;
- pane.stream.deinit();
- pane.vt.deinit(gpa);
-}
-
-/// Allocate the live emulator half of a terminal pane. Slot ownership, serial
-/// assignment, and spawn effects remain core lifecycle invariants.
-pub fn create(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane {
- const pane = try gpa.create(Pane);
- errdefer gpa.destroy(pane);
- if (comptime !enabled) {
- // No emulator: the pane is a plain text surface whose whole content is
- // its edit buffer. `tty_filter` stays off — there are no ANSI cells to
- // reproject and `recolorAnsi` is compiled out entirely.
- pane.* = .{ .vt = {}, .stream = {}, .gpa = gpa, .cols = cols, .rows = rows };
- return pane;
- }
- pane.* = .{
- .vt = try ghostty_vt.Terminal.init(terminalIo(), gpa, .{
- .cols = cols,
- .rows = rows,
- .max_scrollback = 16 * 1024 * 1024,
- }),
- .stream = undefined,
- .gpa = gpa,
- .cols = cols,
- .rows = rows,
- // Real terminals start theme-keyed. Document panes use the separate
- // 1x1 stub constructor and retain Pane's inert false default.
- .tty_filter = true,
- };
- pane.stream = pane.vt.vtStream();
- // Answer terminal queries (DSR/DA/kitty) back into the pty, else
- // crossterm apps (nushell, helix, fzf) block on the reply forever.
- pane.stream.handler.effects.write_pty = ptyReport;
- pane.stream.handler.effects.device_attributes = ptyDeviceAttrs;
- return pane;
-}
-
-/// A doc pane (file/image/PDF): no pty and no spawn, and a stub 1x1 emulator
-/// only because the shared pane machinery touches its allocator-owned bits.
-/// Slot registration stays with the core, as for `create`.
-pub fn createDoc(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane {
- const pane = try gpa.create(Pane);
- errdefer gpa.destroy(pane);
- pane.* = .{
- .vt = if (comptime enabled)
- try ghostty_vt.Terminal.init(terminalIo(), gpa, .{ .cols = 1, .rows = 1 })
- else {},
- .stream = undefined,
- .gpa = gpa,
- .cols = cols,
- .rows = rows,
- };
- if (comptime enabled) pane.stream = pane.vt.vtStream();
- return pane;
-}
-
-/// Rebuild a dump's dead terminal emulator. Registration and tag/cwd policy
-/// stay with the core; raw VT replay and viewport restoration belong here.
-pub fn restore(p: *Pardes, src: dump.Pane) !*Pane {
- const terminal = src.terminal.?;
- if (comptime !enabled) {
- // Nothing to replay the recorded VT bytes INTO. The dump also carries
- // the rendered text of that grid, so it becomes the pane's edit buffer
- // — the one content a build with no emulator can show at all.
- const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows));
- errdefer p.gpa.destroy(pane);
- if (terminal.stream.len > 0)
- pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, terminal.stream) };
- return pane;
- }
- const bytes = if (terminal.stream_b64.len > 0)
- try dump.decodeBytes(p.scratch.allocator(), terminal.stream_b64)
- else
- &.{};
- const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows));
- if (bytes.len > 0) {
- ingest(pane, bytes);
- followOutput(pane);
- if (src.scroll > 0)
- scrollGrid(pane, -@as(i32, @intCast(src.scroll)));
- }
- return pane;
-}
-
-/// Feed the emulator and retain the bounded suffix a dump can replay. Live
-/// output and restoration share this byte path, then apply different views.
-fn ingest(pane: *Pane, bytes: []const u8) void {
- if (bytes.len >= pane.tty_stream.len) {
- const kept = bytes[bytes.len - pane.tty_stream.len ..];
- @memcpy(pane.tty_stream[0..], kept);
- pane.tty_stream_head = 0;
- pane.tty_stream_len = pane.tty_stream.len;
- } else {
- const overflow = bytes.len -| (pane.tty_stream.len - pane.tty_stream_len);
- pane.tty_stream_head = (pane.tty_stream_head + overflow) % pane.tty_stream.len;
- pane.tty_stream_len -= overflow;
- const tail = (pane.tty_stream_head + pane.tty_stream_len) % pane.tty_stream.len;
- const first = @min(bytes.len, pane.tty_stream.len - tail);
- @memcpy(pane.tty_stream[tail..][0..first], bytes[0..first]);
- @memcpy(pane.tty_stream[0 .. bytes.len - first], bytes[first..]);
- pane.tty_stream_len += bytes.len;
- }
- pane.stream.nextSlice(bytes);
-}
-
-/// Return the replay ring in chronological order. Wrapped records are copied
-/// into `allocator`; contiguous records remain a borrowed slice of the pane.
-fn replayBytes(pane: *const Pane, allocator: std.mem.Allocator) ![]const u8 {
- if (pane.tty_stream_len == 0) return &.{};
- if (pane.tty_stream_head + pane.tty_stream_len <= pane.tty_stream.len)
- return pane.tty_stream[pane.tty_stream_head..][0..pane.tty_stream_len];
- const out = try allocator.alloc(u8, pane.tty_stream_len);
- const first = pane.tty_stream.len - pane.tty_stream_head;
- @memcpy(out[0..first], pane.tty_stream[pane.tty_stream_head..]);
- @memcpy(out[first..], pane.tty_stream[0 .. pane.tty_stream_len - first]);
- return out;
-}
-
-/// Record and parse one live pty read, invalidate its motion surface, and
-/// follow it only when the body (possibly parked under a tag edit) is raw.
-pub fn feedOutput(p: *Pardes, pane: *Pane, bytes: []const u8) void {
- // There are no pty reads at all without an emulator to parse them into.
- if (comptime !enabled) return;
- ingest(pane, bytes);
- p.shell_rows.markStale(pane);
- const body_mode = if (pane.tag_edit) pane.tag_mode else pane.mode;
- if (body_mode == .tty) followOutput(pane);
-}
-
-/// True only after OSC 133 B ended the prompt and handed the cursor to shell
-/// input. `cursorIsAtPrompt` deliberately also accepts OSC A's `.prompt`
-/// phase; that is right for navigation but too early to inject a greeting —
-/// readline may not own echo yet and would leave `ls` on an unmarked row.
-pub fn promptInputReady(pane: *const Pane) bool {
- if (comptime !enabled) return false;
- return pane.vt.screens.active_key != .alternate and
- pane.vt.screens.active.cursor.semantic_content == .input;
-}
-
-test "fresh-shell greeting waits for OSC 133 B input phase" {
- if (pardes.platform == .web) return;
- const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
- defer p.deinit();
- const pane = p.panes[0].?;
- while (p.nextEffect()) |_| {} // initial spawn
-
- p.update(.{ .resize = .{ .cols = 80, .rows = 24 } });
- while (p.nextEffect()) |effect| switch (effect) {
- .write => return error.GreetedBeforeOutput,
- else => {},
- };
- p.update(.{ .output = .{ .pane = 0, .bytes = "startup banner\r\n" } });
- while (p.nextEffect()) |effect| switch (effect) {
- .write => return error.GreetedBeforePrompt,
- else => {},
- };
- p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x07prompt$ " } });
- try std.testing.expect(!promptInputReady(pane));
- while (p.nextEffect()) |effect| switch (effect) {
- .write => return error.GreetedDuringPrompt,
- else => {},
- };
-
- p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;B\x07" } });
- try std.testing.expect(promptInputReady(pane));
- var greeted = false;
- while (p.nextEffect()) |effect| switch (effect) {
- .write => |write| greeted = greeted or std.mem.eql(u8, write.bytes.slice(), "ls\r"),
- else => {},
- };
- try std.testing.expect(greeted);
- try std.testing.expect(!pane.greet);
-}
-
-test "fresh-shell commands preserve order and wait for OSC 133 B" {
- if (pardes.platform == .web) return;
- const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
- defer p.deinit();
- while (p.nextEffect()) |_| {} // the host has not acknowledged spawn yet
-
- try std.testing.expectEqual(@as(?usize, 0), p.execute(0, "echo first"));
- try std.testing.expectEqual(@as(?usize, 0), p.execute(0, "echo second"));
- while (p.nextEffect()) |effect| switch (effect) {
- .write => return error.CommandEscapedBeforeFork,
- else => {},
- };
-
- p.acknowledgeShell(0, "/bin/bash", true);
- while (p.nextEffect()) |effect| switch (effect) {
- .write => return error.CommandEscapedBeforePrompt,
- else => {},
- };
- p.update(.{ .output = .{ .pane = 0, .bytes = "startup\r\n\x1b]133;A\x07prompt$ " } });
- while (p.nextEffect()) |effect| switch (effect) {
- .write => return error.CommandEscapedDuringPrompt,
- else => {},
- };
-
- p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;B\x07" } });
- var sent: [64]u8 = undefined;
- var sent_len: usize = 0;
- while (p.nextEffect()) |effect| switch (effect) {
- .write => |write| {
- const bytes = write.bytes.slice();
- @memcpy(sent[sent_len..][0..bytes.len], bytes);
- sent_len += bytes.len;
- },
- else => {},
- };
- try std.testing.expectEqualStrings("echo first\recho second\r", sent[0..sent_len]);
- try std.testing.expectEqual(.none, p.panes[0].?.pending_command.wait);
-}
-
-test "unmarked fresh shells omit the automatic greeting" {
- if (pardes.platform == .web) return;
- const p = try Pardes.init(std.testing.allocator, .{});
- defer p.deinit();
- const pane = p.panes[0].?;
- while (p.nextEffect()) |_| {}
- try std.testing.expect(pane.greet);
-
- p.acknowledgeShell(0, "/bin/sh", false);
- try std.testing.expect(!pane.greet);
- try std.testing.expectEqual(.none, pane.pending_command.wait);
- p.update(.{ .output = .{ .pane = 0, .bytes = "plain prompt$ " } });
- while (p.nextEffect()) |effect| switch (effect) {
- .write => return error.UnmarkedGreetingEscaped,
- else => {},
- };
-}
-
-/// Encode one key for the program that owns a raw terminal and queue its pty
-/// write. Global chords and mode routing have already been handled by core.
-pub fn forwardKey(p: *Pardes, id: usize, key: Key) void {
- var control: [1]u8 = undefined;
- const bytes: ?[]const u8 = blk: {
- if (key.ctrl) {
- if (key.cp >= 'a' and key.cp <= 'z') {
- control[0] = @intCast(key.cp - 0x60);
- break :blk control[0..1];
- }
- // ASCII @, A-Z, [, \, ], ^ and _ are one contiguous control range.
- if (key.cp >= '@' and key.cp <= '_') {
- control[0] = @intCast(key.cp - 0x40);
- break :blk control[0..1];
- }
- }
- if (key.text.len > 0) break :blk key.text;
- break :blk switch (key.cp) {
- Key.enter => "\r",
- Key.backspace => "\x7f",
- Key.tab => "\t",
- Key.escape => "\x1b",
- Key.up => "\x1b[A",
- Key.down => "\x1b[B",
- Key.right => "\x1b[C",
- Key.left => "\x1b[D",
- Key.delete => "\x1b[3~",
- else => null,
- };
- };
- if (bytes) |encoded|
- p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(encoded) } });
-}
-
-/// Enter raw tty, handing a pinned modal cursor back to the shell prompt when
-/// OSC 133 marks one. The visible prompt row is left-hugged outside tty mode,
-/// so translate its column through the hidden prompt before asking ghostty for
-/// the arrow-key movement the child understands.
-pub fn enterTty(p: *Pardes, id: usize) void {
- const pane = p.panes[id] orelse return;
- // Only the PROMPT HANDOFF needs the emulator; the mode switch below is
- // plain pane state, so a build without one still has a raw mode — it just
- // has no prompt to translate a pinned cursor back onto.
- if (comptime enabled) if (pane.cur_pinned and pane.vt.cursorIsAtPrompt()) handoff: {
- const screen = pane.vt.screens.active;
- const goff: i32 = @intCast(screen.pages.scrollbar().offset);
- const vp_row = pane.gridRow(pane.cur_row) - goff;
- if (vp_row < 0) break :handoff;
-
- var grid_col: i32 = @max(0, pane.cur_col);
- if (screen.pages.pin(.{ .viewport = .{ .x = 0, .y = @intCast(vp_row) } })) |row_pin| {
- if (row_pin.rowAndCell().row.semantic_prompt != .none) switch (promptCut(row_pin)) {
- .cut => |cols| grid_col += @intCast(cols),
- .keep, .blank => {},
- };
- }
- const click_pin = screen.pages.pin(.{
- .viewport = .{ .x = @intCast(grid_col), .y = @intCast(vp_row) },
- }) orelse break :handoff;
- const cursor_pin = screen.cursor.page_pin.*;
- var prompts = cursor_pin.promptIterator(.left_up, null);
- const prompt_pin = prompts.next() orelse break :handoff;
- if (click_pin.before(prompt_pin)) break :handoff;
- const moves = screen.promptClickMove(click_pin);
- for (0..moves.left) |_| p.emitWrite(id, "\x1b[D");
- for (0..moves.right) |_| p.emitWrite(id, "\x1b[C");
- };
-
- pane.mode = .tty;
- pane.msel.active = false;
- pane.vsel.active = false;
- pane.nsel = 0;
- // A pinned row scrolls away. Raw mode must follow the program's live
- // cursor, and Last must not restore a stale modal spot on the way back.
- pane.cur_pinned = false;
- pane.select = false;
- pane.append_at = null;
- pane.sticky_col = -1;
- pane.pending = 0;
-}
-
-test "raw terminal keys encode text controls and special sequences" {
- const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
-
- const Case = struct { key: Key, expected: ?[]const u8 };
- const cases = [_]Case{
- .{ .key = .{ .cp = 'é', .text = "é" }, .expected = "é" },
- .{ .key = .{ .cp = 'c', .text = "c", .ctrl = true }, .expected = "\x03" },
- .{ .key = .{ .cp = 'C', .text = "C", .ctrl = true }, .expected = "\x03" },
- .{ .key = .{ .cp = '@', .text = "@", .ctrl = true }, .expected = "\x00" },
- .{ .key = .{ .cp = '_', .text = "_", .ctrl = true }, .expected = "\x1f" },
- .{ .key = .{ .cp = '1', .text = "1", .ctrl = true }, .expected = "1" },
- .{ .key = .{ .cp = Key.up, .alt = true, .shift = true }, .expected = "\x1b[A" },
- .{ .key = .{ .cp = Key.delete }, .expected = "\x1b[3~" },
- .{ .key = .{ .cp = Key.home }, .expected = null },
- };
- for (cases) |case| {
- forwardKey(p, 0, case.key);
- const expected = case.expected orelse {
- try std.testing.expect(p.nextEffect() == null);
- continue;
- };
- const effect = p.nextEffect() orelse return error.MissingWriteEffect;
- switch (effect) {
- .write => |write| {
- try std.testing.expectEqual(@as(u8, 0), write.pane);
- try std.testing.expectEqualStrings(expected, write.bytes.slice());
- },
- else => return error.UnexpectedEffect,
- }
- try std.testing.expect(p.nextEffect() == null);
- }
-}
-
-/// The memo behind `shellRows`. ONE entry for the editor, because the motion
-/// surface is built for the pane the cursor is in and a second pane asking
-/// would only double a multi-megabyte buffer for a slot it is about to lose
-/// again. gpa-owned rather than scratch-arena: the whole point is to outlive
-/// the update that built it.
-///
-/// LIFETIME, the part that would rot silently: `rows` is handed out to
-/// callers, so the buffers are freed in exactly two places — `sweep`, at the
-/// TOP of an update before any handler can be holding them, and `reset` when
-/// the editor goes away. Everything that notices the entry has gone bad
-/// (output arrived, the grid reflowed, the pane died, another pane wants the
-/// slot) only marks it `stale`; nothing frees mid-update. That is the same
-/// guarantee the scratch arena gave, spelled out.
-pub const RowsCache = struct {
- /// whose grid this describes; null = the slot is free
- pane: ?*const Pane = null,
- /// the rows joined by '\n' — `flatSurface` hands this back verbatim
- /// instead of rebuilding the join on every keystroke
- text: []const u8 = &.{},
- /// slices INTO `text`, absolute grid rows from 0
- rows: [][]const u8 = &.{},
- /// `text` is a prefix of this: blanking a prompt row shortens the join,
- /// and the slack is not worth a second allocation to reclaim
- text_alloc: []u8 = &.{},
- stale: bool = false,
-
- pub fn reset(c: *RowsCache, gpa: std.mem.Allocator) void {
- if (c.text_alloc.len > 0) gpa.free(c.text_alloc);
- if (c.rows.len > 0) gpa.free(c.rows);
- c.* = .{};
- }
-
- /// Free a stale entry. Called at the top of `update`, and nowhere else.
- pub fn sweep(c: *RowsCache, gpa: std.mem.Allocator) void {
- if (c.stale) c.reset(gpa);
- }
-
- /// `pane`'s grid moved: the entry no longer describes it.
- pub fn markStale(c: *RowsCache, pane: *const Pane) void {
- if (c.pane == pane) c.stale = true;
- }
-
- /// `pane` is being destroyed. Drop the pointer now — a freed pane's
- /// address can come back from the allocator as a different pane, and an
- /// entry still naming it would answer for the wrong grid — but leave the
- /// buffers to the next sweep, as ever.
- pub fn dropPane(c: *RowsCache, pane: *const Pane) void {
- if (c.pane != pane) return;
- c.pane = null;
- c.stale = true;
- }
-};
-
-const Rows = struct {
- text_alloc: []u8,
- text: []const u8,
- rows: [][]const u8,
-};
-
-/// The motion surface of a pane with no emulator behind it: exactly the one
-/// blank row `buildRows` retains from a real grid, so surface row 0 exists and
-/// every motion, edit and undo path measures the same thing it always did.
-const empty_grid = [1][]const u8{""};
-
-/// What LEAVING raw tty mode does to one prompt row, decided from its cells
-/// alone. See config.tty_blank for why any of this happens.
-const PromptCut = union(enum) {
- /// show the row exactly as ghostty dumped it
- keep,
- /// show nothing at all
- blank,
- /// drop this many leading COLUMNS — the prompt — and keep the rest, which
- /// is what was typed at it
- cut: usize,
-};
-
-/// The prompt and the command typed at it share a grid row, and OSC 133 marks
-/// them apart CELL by cell (`Cell.semantic_content` is output / input /
-/// prompt). The row flag every caller tests first is only ghostty's "some cell
-/// in here is a prompt cell" index; taking the row on that flag alone is what
-/// used to throw the command away with the prompt.
-fn promptCut(pin: ghostty_vt.Pin) PromptCut {
- if (config.tty_blank == .prompt_and_input) return .blank;
- const cells = pin.cells(.all);
- var cols: usize = 0;
- while (cols < cells.len and cells[cols].semantic_content == .prompt) cols += 1;
- // Flagged, but with no prompt cells at the FRONT: a right-side prompt, or
- // a repaint that has moved on. Nothing here is the prompt, so hide nothing.
- if (cols == 0) return .keep;
- // ...and all prompt, nothing typed yet: the row is chrome end to end.
- if (cols >= cells.len) return .blank;
- return .{ .cut = cols };
-}
-
-/// That decision applied to `raw`, the line ghostty dumped for `pin`'s row.
-/// Always a slice OF `raw` — dropping the prompt is a left-hug, so the command
-/// starts at column 0 with no run of blanks in front of it where the prompt
-/// used to be, and there is nothing to allocate or copy anywhere.
-///
-/// Walking the dump rather than rebuilding the row out of cells keeps ghostty
-/// the single authority on how a cell spells itself — wide glyphs, combining
-/// marks and all. One non-spacer cell is one dumped grapheme, and that is what
-/// makes the cell walk and the byte walk stay in step.
-fn promptRow(pin: ghostty_vt.Pin, raw: []const u8) []const u8 {
- const cols = switch (promptCut(pin)) {
- .keep => return raw,
- .blank => return "",
- .cut => |n| n,
- };
- const cells = pin.cells(.all);
- var at: usize = 0;
- var col: usize = 0;
- while (col < cols and at < raw.len) {
- const cell = &cells[col];
- // 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
-/// edit buffer is NOT applied here, so it is also what seeding the buffer
-/// reads.
-/// ghostty's dump trims the grid's trailing blank rows; ONE of them is
-/// kept back, the row the cursor sits on below the last line of output.
-/// That row is a file's final newline: without it the surface would have
-/// one line fewer than the same text in a document, and every motion and
-/// linewise edit at the bottom would diverge.
-///
-/// Building it is O(scrollback) — a dump of the whole history — and a
-/// keystroke asks for it once or twice, so the result is memoized against the
-/// pane until its grid changes. A pane sitting on 16 MiB of agent transcript
-/// paid that dump per press of `j` before the cache; now it pays it once per
-/// chunk of output.
-pub fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 {
- // With no emulator there is no history to dump, and no cache to keep it
- // in: one empty row, which is the same row `buildRows` keeps back from
- // ghostty's trimmed dump — a file's final newline. Everything above the
- // grid (the edit overlay, its undo stacks, every motion) works unchanged
- // over it, so a pane on the board is an ordinary scratch buffer.
- if (comptime !enabled) return &empty_grid;
- const c = &p.shell_rows;
- if (!c.stale and c.pane == pane) return c.rows;
- if (c.pane != null) {
- // Another pane holds the slot. Take it for the NEXT update (the sweep
- // frees what is there) and answer this one from scratch: whoever owns
- // the live entry may still be holding the rows it handed out.
- c.stale = true;
- return (try buildRows(p.scratch.allocator(), p, pane)).rows;
- }
- const built = try buildRows(p.gpa, p, pane);
- c.* = .{
- .pane = pane,
- .text = built.text,
- .rows = built.rows,
- .text_alloc = built.text_alloc,
- };
- return c.rows;
-}
-
-/// The full modal motion surface: shell history with the live edit overlay
-/// spliced into the rows it covers.
-pub fn cursorLines(p: *Pardes, pane: *Pane) ![]const []const u8 {
- const rows = try shellRows(p, pane);
- if (pane.ovl == null) return rows;
- var last = rows.len;
- if (pane.ovl) |overlay|
- last = @max(last, @as(usize, @intCast(@max(0, overlay.row + overlay.rows))));
- var count = last;
- if (pane.ovl) |overlay| {
- if (overlay.row >= 0 and @as(usize, @intCast(overlay.row)) < last)
- count = count - @min(
- @as(usize, @intCast(overlay.rows)),
- last - @as(usize, @intCast(overlay.row)),
- ) + @max(1, modal.lineCount(overlay.text));
- }
- const lines = try p.scratch.allocator().alloc([]const u8, count);
- var n: usize = 0;
- var grid_row: usize = 0;
- while (grid_row < last) : (grid_row += 1) {
- if (pane.ovl) |overlay| if (overlay.row >= 0 and grid_row == @as(usize, @intCast(overlay.row))) {
- var overlay_lines = std.mem.splitScalar(u8, overlay.text, '\n');
- while (overlay_lines.next()) |line| : (n += 1) lines[n] = line;
- grid_row += @intCast(overlay.rows - 1);
- continue;
- };
- lines[n] = if (grid_row < rows.len) rows[grid_row] else "";
- n += 1;
- }
- return lines[0..n];
-}
-
-/// Flatten `cursorLines` without rebuilding the common cached/no-overlay
-/// case. Scratch-owned when a join is required.
-pub fn flatSurface(p: *Pardes, pane: *Pane, lines: []const []const u8) ![]const u8 {
- const cache = &p.shell_rows;
- if (!cache.stale and cache.pane == pane and
- lines.ptr == cache.rows.ptr and lines.len == cache.rows.len) return cache.text;
- var total: usize = if (lines.len > 0) lines.len - 1 else 0;
- for (lines) |line| total += line.len;
- const text = try p.scratch.allocator().alloc(u8, total);
- var at: usize = 0;
- for (lines, 0..) |line, i| {
- if (i > 0) {
- text[at] = '\n';
- at += 1;
- }
- @memcpy(text[at..][0..line.len], line);
- at += line.len;
- }
- return text;
-}
-
-/// Materialize or extend the terminal edit overlay with one exact allocation.
-/// Row slices are scratch-owned/borrowed; only the joined text is installed.
-pub fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?EditText {
- const want_lo = @max(0, @min(lo, hi));
- const want_hi = @max(want_lo, @max(lo, hi));
- const fresh = pane.ovl == null;
- const old: EditBuffer = pane.ovl orelse .{ .row = want_lo, .rows = 1, .text = &.{} };
- const lines: i32 = if (fresh) 1 else @intCast(modal.lineCount(old.text));
- const up = old.row - want_lo;
- const down = want_hi - (old.row + lines - 1);
- const extending = fresh or up > 0 or down > 0;
- var row0 = old.row;
- var covered = old.rows;
- var text = old.text;
- var owned = false;
- if (extending) {
- const rows = shellRows(p, pane) catch return null;
- row0 = old.row - @max(0, up);
- covered = old.rows + @max(0, up) + @max(0, down);
- const up_len: usize = @intCast(@max(0, up));
- const down_len: usize = @intCast(@max(0, down));
- const parts = p.scratch.allocator().alloc([]const u8, up_len + 1 + down_len) catch return null;
- for (parts[0..up_len], 0..) |*part, i| {
- const src = @as(usize, @intCast(row0)) + i;
- part.* = if (src < rows.len) rows[src] else "";
- }
- const middle: usize = @intCast(old.row);
- parts[up_len] = if (fresh)
- (if (middle < rows.len) rows[middle] else "")
- else
- old.text;
- for (parts[up_len + 1 ..], 0..) |*part, i| {
- const src = @as(usize, @intCast(old.row + old.rows)) + i;
- part.* = if (src < rows.len) rows[src] else "";
- }
- text = std.mem.join(p.gpa, "\n", parts) catch return null;
- owned = true;
- }
-
- const row: usize = @intCast(@max(0, want_lo - row0));
- const line_len: i32 = @intCast(modal.lineSlice(text, row).len);
- if (col > line_len) {
- const spaces = p.scratch.allocator().alloc(u8, @intCast(col - line_len)) catch {
- if (owned) p.gpa.free(text);
- return null;
- };
- @memset(spaces, ' ');
- const padded = modal.insertAt(p.gpa, text, .{ .row = row, .col = @intCast(line_len) }, spaces) catch {
- if (owned) p.gpa.free(text);
- return null;
- };
- if (owned) p.gpa.free(text);
- text = padded;
- owned = true;
- }
- if (owned) {
- if (pane.ovl) |overlay| p.gpa.free(overlay.text);
- pane.ovl = .{ .row = row0, .rows = covered, .text = text };
- }
- return .{ .text = pane.ovl.?.text, .row0 = pane.ovl.?.row };
-}
-
-/// Consume a rewritten overlay, freeing the terminal edit text it replaces.
-pub fn setEditText(p: *Pardes, pane: *Pane, new: []u8) void {
- const overlay = if (pane.ovl) |*value| value else return p.gpa.free(new);
- p.gpa.free(overlay.text);
- overlay.text = new;
-}
-
-/// Serialize terminal-only state; the core supplies shared pane metadata.
-pub fn dumpPane(
- pane: *Pane,
- arena: std.mem.Allocator,
- tag: []const u8,
- body: []const u8,
- scroll: usize,
-) !dump.Pane {
- if (comptime !enabled) {
- // A pane with no emulator has no grid to serialize and no VT bytes to
- // record — its edit buffer IS its content, so that is what the dump
- // carries, and `restore` reads it straight back into a fresh buffer.
- const text = if (pane.ovl) |overlay| overlay.text else "";
- return .{
- .kind = .terminal,
- .tag = tag,
- .body = body,
- .scroll = scroll,
- .cols = pane.cols,
- .rows = pane.rows,
- .vweight = pane.vweight,
- .terminal = .{
- .cwd = try arena.dupe(u8, pane.cwdSlice()),
- .stream = try arena.dupe(u8, text),
- .stream_b64 = &.{},
- .cursor = .{ .col = 0, .row = 0 },
- },
- };
- }
- const full = try pane.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} });
- const extra = if (pane.ovl) |overlay| overlay.text.len else 0;
- const stream = try arena.alloc(u8, try std.math.add(usize, full.len, extra));
- var len: usize = 0;
- var lines = std.mem.splitAny(u8, full, "\n");
- var prompts = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null);
- var row: i32 = 0;
- var skip: i32 = 0;
- while (lines.next()) |raw| : (row += 1) {
- // Hidden overlay rows still consume prompt pins to keep them aligned.
- const prompt = if (pane.mode != .tty) prompts.next() else null;
- if (skip > 0) {
- skip -= 1;
- continue;
- }
- if (row > 0) {
- stream[len] = '\n';
- len += 1;
- }
- if (pane.mode != .tty) if (pane.ovl) |overlay| if (row == overlay.row) {
- @memcpy(stream[len..][0..overlay.text.len], overlay.text);
- len += overlay.text.len;
- skip = overlay.rows - 1;
- continue;
- };
- const shown = if (prompt) |pin|
- if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, raw) else raw
- else
- raw;
- @memcpy(stream[len..][0..shown.len], shown);
- len += shown.len;
- }
- return .{
- .kind = .terminal,
- .tag = tag,
- .body = body,
- .scroll = scroll,
- .cols = pane.cols,
- .rows = pane.rows,
- .vweight = pane.vweight,
- .terminal = .{
- .cwd = try arena.dupe(u8, pane.cwdSlice()),
- .stream = stream[0..len],
- .stream_b64 = try dump.encodeBytes(arena, try replayBytes(pane, arena)),
- .cursor = .{
- .col = pane.vt.screens.active.cursor.x,
- .row = pane.vt.screens.active.cursor.y,
- },
- },
- };
-}
-
-fn buildRows(alloc: std.mem.Allocator, p: *Pardes, pane: *Pane) !Rows {
- const full = try pane.vt.screens.active.dumpStringAlloc(p.scratch.allocator(), .{ .screen = .{} });
- var pit = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null);
- // split yields one more item than delimiters; the extra final slot is the
- // cursor row retained below.
- const n_rows = std.mem.count(u8, full, "\n") + 2;
- const rows = try alloc.alloc([]const u8, n_rows);
- errdefer alloc.free(rows);
- // Blanking a prompt row only ever SHORTENS it and the retained cursor row
- // adds one separator, so the dump's length plus one bounds the join.
- const text = try alloc.alloc(u8, full.len + 1);
- errdefer alloc.free(text);
- var at: usize = 0;
- var n: usize = 0;
- var it = std.mem.splitScalar(u8, full, '\n');
- while (it.next()) |raw| {
- const shown = if (pit.next()) |pin|
- if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, raw) else raw
- else
- raw;
- if (n > 0) {
- text[at] = '\n';
- at += 1;
- }
- @memcpy(text[at..][0..shown.len], shown);
- rows[n] = text[at..][0..shown.len];
- at += shown.len;
- n += 1;
- }
- text[at] = '\n';
- at += 1;
- rows[n] = text[at..][0..0];
- n += 1;
- std.debug.assert(n == n_rows);
- return .{ .text_alloc = text, .text = text[0..at], .rows = rows };
-}
-
-/// The body a terminal renders: the viewport's shell rows (prompt rows blanked
-/// outside tty mode) with the edit buffer's lines standing in for the rows it
-/// covers, so what you see is what the motions move over.
-pub fn bodyText(arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
- // The VIEWPORT half is the emulator's; the row walk below is the edit
- // 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 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');
- var n: usize = 0;
- while (lines.next()) |ln| {
- vp[n] = if (pane.mode != .tty)
- if (prompts.next()) |pin|
- if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, ln) else ln
- else
- ln
- else
- ln;
- n += 1;
- }
- std.debug.assert(n == vp.len);
- break :vp vp;
- };
-
- const len = fillBody(null, pane, vp);
- const out = try arena.alloc(u8, len);
- const filled = fillBody(out, pane, vp);
- std.debug.assert(filled == out.len);
- 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 {
- var walk: BodyWalk = .init(pane);
- var written: usize = 0;
- var first = true;
- while (walk.next()) |row| {
- if (!first) {
- if (dst) |out| out[written] = '\n';
- written += 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 — 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;
- const s = &p.surface;
- const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
- var filtered_storage: FilteredColors = undefined;
- const filtered: ?*FilteredColors = if (pane.tty_filter) blk: {
- // Enumerating the indexed answers is the whole cost of the filter that
- // is NOT per cell, so it gets to be visible on its own: this is the
- // number that says whether the tables should be cached across frames
- // rather than rebuilt per pass. Measured at 2.9 us warm against a
- // 117 us `paneRecolor`, which is why they are not.
- const tz_filter = tracy.zone(@src(), "filterInit");
- defer tz_filter.end();
- 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 (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;
- };
- }
- }
-}
-
-/// `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, 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,
- .blink = style.flags.blink,
- .reverse = style.flags.inverse,
- .invisible = style.flags.invisible,
- .strikethrough = style.flags.strikethrough,
- .ul = switch (style.flags.underline) {
- .none => .off,
- .single => .single,
- .double => .double,
- .curly => .curly,
- .dotted => .dotted,
- .dashed => .dashed,
- },
- };
- // Style.bg above already resolves Ghostty's color-only cell variants for
- // the filtered path. Preserve the established direct translation outside
- // it, where indexed colours are intentionally allowed to reach the host.
- if (filtered == null) switch (ci.cell.content_tag) {
- .bg_color_palette => cs.bg = palColor(p, ci.cell.content.color_palette.data),
- .bg_color_rgb => {
- const rgb = ci.cell.content.color_rgb;
- cs.bg = .{ .rgb = .{ rgb.r, rgb.g, rgb.b } };
- },
- else => {},
- };
- return cs;
-}
-
-/// Per-render resolver, in two stages. The source palette is materialized
-/// through Ghostty's public xterm API, so OSC 4 changes participate without
-/// reaching into the emulator's private state.
-///
-/// STAGE ONE is the default foreground and background roles, because they are
-/// the anchors: Ghostty generates the whole 256-colour projection from them,
-/// and `default_bg` below is the page every other colour is judged against.
-///
-/// STAGE TWO is everything else. Truecolour and visually overridden entries are
-/// reduced to the nearest canonical Ghostty palette key; the key then indexes
-/// the theme palette. Repeated RGBs pay that search only once per frame. A
-/// FOREGROUND additionally has to clear `config.tty_filter_min_contrast`
-/// against `default_bg` — the reduction is an RGB distance and knows nothing
-/// about the page, and the projection's cube corners are the anchors
-/// themselves, so without the gate the nearest key to a truecolour extreme is
-/// the background and the text is painted in the colour of the page.
-const FilteredColors = struct {
- source: GColor.Palette,
- target: *const GColor.Palette,
- theme_bg: GColor.RGB,
- theme_fg: GColor.RGB,
- dynamic_bg: ?GColor.RGB,
- dynamic_fg: ?GColor.RGB,
- /// Stage one's background, mapped: exactly what `bg` answers for a cell
- /// that names no colour of its own, and therefore the page a foreground
- /// has to stay legible against.
- default_bg: GColor.RGB,
- /// What a foreground too near `default_bg` becomes instead.
- fallback_fg: GColor.RGB,
- /// `default_bg`'s luminance, computed once. `legible` runs per CELL and
- /// asks for the contrast ratio against this same colour every time; the
- /// half of the ratio that belongs to the background never changes.
- default_bg_luminance: f64,
- /// Every answer the INDEXED path can give, resolved before the first
- /// cell is read.
- ///
- /// A cell that names a palette colour has 256 possible inputs, and this
- /// filter is a pure function of them: the OSC 4 comparison, the theme
- /// projection and the contrast gate all depend only on the index and on
- /// state that is fixed for the whole pass. So the per-cell chain
- /// collapses to one array read, and `legible` - six libm `pow` calls
- /// through `RGB.contrast`, which profiling put at 12 of 43 draw samples
- /// - stops being a per-cell cost entirely.
- ///
- /// Only TRUECOLOUR still searches: it carries arbitrary RGB, so its
- /// answers cannot be enumerated and the direct-mapped cache below is
- /// what keeps it cheap.
- fg_for_palette: [256]pardes.Color = undefined,
- bg_for_palette: [256]pardes.Color = undefined,
- /// The two answers for a cell that names no colour of its own.
- fg_default: pardes.Color = undefined,
- bg_default: pardes.Color = undefined,
- // Direct-mapped rather than append-only: a frame which encounters more
- // than the cache's capacity must not strand every later (and repeated)
- // colour on the 256-entry nearest-key scan. The RGB hash spreads the
- // common 6x6x6 cube values instead of keying on their low bits.
- cache_rgb: [256]GColor.RGB = undefined,
- cache_key: [256]u8 = undefined,
- cache_valid: [256]bool = @splat(false),
-
- fn init(p: *Pardes, pane: *const Pane) FilteredColors {
- var source = GColor.default;
- for (&source, 0..) |*rgb, i|
- rgb.* = pane.vt.colorForXterm(.{ .palette = @intCast(i) }) orelse rgb.*;
- const theme = p.theme();
- var theme_bg = asGhostRgb(theme.bg orelse theme.tag_bg);
- var theme_fg = asGhostRgb(theme.fg orelse theme.tag_fg);
- var dynamic_bg = pane.vt.colorForXterm(.{ .dynamic = .background });
- var dynamic_fg = pane.vt.colorForXterm(.{ .dynamic = .foreground });
- // DECSCNM swaps only the terminal's default color roles; explicit SGR
- // colors stay explicit. Reuse Ghostty's parsed mode instead of trying
- // to infer the escape from cells, just as its renderer does.
- if (pane.vt.modes.get(.reverse_colors)) {
- std.mem.swap(GColor.RGB, &theme_bg, &theme_fg);
- std.mem.swap(?GColor.RGB, &dynamic_bg, &dynamic_fg);
- }
- var self: FilteredColors = .{
- .source = source,
- .target = p.tty_filter_palette.get(theme),
- .theme_bg = theme_bg,
- .theme_fg = theme_fg,
- .dynamic_bg = dynamic_bg,
- .dynamic_fg = dynamic_fg,
- .default_bg = theme_bg,
- .fallback_fg = theme_fg,
- .default_bg_luminance = luminanceOf(theme_bg),
- };
- // Stage one, finished before a single other colour is mapped. OSC 11
- // moves the page, so the floor moves with it; the anchor that survives
- // as the fallback is then whichever of the theme's own pair can still
- // be seen on it, which on an untouched terminal is always the theme's
- // foreground (a background has no contrast with itself).
- if (dynamic_bg) |rgb| self.default_bg = self.keyedRgb(rgb);
- self.default_bg_luminance = luminanceOf(self.default_bg);
- if (self.theme_bg.contrast(self.default_bg) > self.theme_fg.contrast(self.default_bg))
- self.fallback_fg = self.theme_bg;
-
- // Stage two, ENUMERATED rather than answered per cell. Everything the
- // indexed path needs is now fixed, and its input is a u8, so every
- // answer it can ever give is computed here - once for the pass, not
- // once for each of the tens of thousands of cells that will ask.
- self.fg_default = asPardesColor(self.legible(
- if (self.dynamic_fg) |rgb| self.keyedRgb(rgb) else self.theme_fg,
- ));
- self.bg_default = asPardesColor(self.default_bg);
- for (&self.source, 0..) |current, i| {
- const idx: u8 = @intCast(i);
- const mapped = self.paletteRgb(idx, current);
- self.fg_for_palette[idx] = asPardesColor(self.legible(mapped));
- self.bg_for_palette[idx] = asPardesColor(mapped);
- }
- return self;
- }
-
- /// One array read for every colour a cell can NAME. Only truecolour,
- /// whose 16.7M inputs cannot be enumerated, reaches the reduction - and
- /// `style.fg` is now asked only on that path, because the other two
- /// answers no longer depend on it.
- fn fg(self: *FilteredColors, style: ghostty_vt.Style) pardes.Color {
- return switch (style.fg_color) {
- .none => self.fg_default,
- .palette => |idx| self.fg_for_palette[idx],
- .rgb => asPardesColor(self.legible(self.keyedRgb(style.fg(.{
- .default = self.dynamic_fg orelse self.theme_fg,
- .palette = &self.source,
- .bold = null,
- })))),
- };
- }
-
- fn bg(self: *FilteredColors, style: ghostty_vt.Style, cell: *const ghostty_vt.Cell) pardes.Color {
- switch (cell.content_tag) {
- .bg_color_palette => return self.bg_for_palette[cell.content.color_palette.data],
- .bg_color_rgb => {},
- else => switch (style.bg_color) {
- .none => return self.bg_default,
- .palette => |idx| return self.bg_for_palette[idx],
- .rgb => {},
- },
- }
- // Truecolour, from either the cell or its style.
- return asPardesColor(self.keyedRgb(style.bg(cell, &self.source).?));
- }
-
- /// Stage two's only rule, and a FOREGROUND rule: a background IS the page
- /// for whatever is drawn over it, so holding one away from itself would be
- /// meaningless. An ANSI black on a dark theme and a truecolour white on a
- /// light one both reduce to the key whose projected value is the page —
- /// ratio 1.000, invisible text — and both land here instead.
- fn legible(self: *const FilteredColors, rgb: GColor.RGB) GColor.RGB {
- if (contrastOf(luminanceOf(rgb), self.default_bg_luminance) >=
- config.tty_filter_min_contrast) return rgb;
- return self.fallback_fg;
- }
-
- /// Preserve an ordinary indexed colour's semantic key. A value changed by
- /// OSC 4 instead carries arbitrary RGB intent, so key that RGB the same way
- /// as truecolour. Setting an entry to its exact original value is visually
- /// indistinguishable and correctly takes this fast path.
- fn paletteRgb(self: *FilteredColors, idx: u8, current: GColor.RGB) GColor.RGB {
- if (current.eql(GColor.default[idx])) return self.target[idx];
- return self.keyedRgb(current);
- }
-
- fn keyedRgb(self: *FilteredColors, rgb: GColor.RGB) GColor.RGB {
- return self.target[self.nearestKey(rgb)];
- }
-
- fn nearestKey(self: *FilteredColors, rgb: GColor.RGB) u8 {
- const rgb24 = (@as(u32, rgb.r) << 16) | (@as(u32, rgb.g) << 8) | rgb.b;
- const slot: u8 = @truncate((rgb24 *% 0x9e3779b1) >> 24);
- if (self.cache_valid[slot] and self.cache_rgb[slot].eql(rgb))
- return self.cache_key[slot];
-
- var best: u8 = 0;
- var best_distance: u32 = std.math.maxInt(u32);
- for (GColor.default, 0..) |candidate, i| {
- const distance = colorDistance(rgb, candidate);
- // Strict comparison makes duplicate-colour ties stable at the
- // lowest canonical xterm key.
- if (distance < best_distance) {
- best_distance = distance;
- best = @intCast(i);
- }
- }
- self.cache_rgb[slot] = rgb;
- self.cache_key[slot] = best;
- self.cache_valid[slot] = true;
- return best;
- }
-};
-
-/// W3C relative luminance per 8-bit channel, precomputed.
-///
-/// ghostty's `RGB.componentLuminance` ends in `std.math.pow(f64, x, 2.4)`
-/// (color.zig:474), `luminance` calls it three times, and `contrast` calls
-/// `luminance` for BOTH colours — so `legible`'s single `rgb.contrast(bg)` is
-/// up to six libm `pow` calls, per cell, per frame. Profiling the AppKit shell
-/// put `cellStyle -> FilteredColors.legible -> RGB.contrast` at 12 of 43 draw
-/// samples; the whole rest of `recolorAnsi` was 3.
-///
-/// The input is a `u8`. There are 256 possible answers. This is the table.
-///
-/// Bit-identical to ghostty's function by construction — same expression,
-/// evaluated at comptime — so the filter's decisions do not move. The
-/// equivalence test below pins that.
-const channel_luminance: [256]f64 = blk: {
- @setEvalBranchQuota(20000);
- var table: [256]f64 = undefined;
- for (&table, 0..) |*slot, c| {
- const normalized: f64 = @as(f64, @floatFromInt(c)) / 255;
- slot.* = if (normalized <= 0.03928)
- normalized / 12.92
- else
- std.math.pow(f64, (normalized + 0.055) / 1.055, 2.4);
- }
- break :blk table;
-};
-
-fn luminanceOf(rgb: GColor.RGB) f64 {
- return 0.2126 * channel_luminance[rgb.r] +
- 0.7152 * channel_luminance[rgb.g] +
- 0.0722 * channel_luminance[rgb.b];
-}
-
-/// ghostty's `RGB.contrast` with both luminances already in hand.
-fn contrastOf(a_luminance: f64, b_luminance: f64) f64 {
- const lighter = @max(a_luminance, b_luminance);
- const darker = @min(a_luminance, b_luminance);
- return (lighter + 0.05) / (darker + 0.05);
-}
-
-fn colorDistance(a: GColor.RGB, b: GColor.RGB) u32 {
- const dr = @as(i32, a.r) - @as(i32, b.r);
- const dg = @as(i32, a.g) - @as(i32, b.g);
- const db = @as(i32, a.b) - @as(i32, b.b);
- return @intCast(dr * dr + dg * dg + db * db);
-}
-
-test "the luminance table answers exactly what ghostty computes" {
- // The filter's decisions are a threshold comparison on these numbers, so
- // "close enough" is not enough: one ULP either side of
- // `tty_filter_min_contrast` is a different colour on screen. Every
- // channel value, and the pairs a real pass actually asks about.
- for (0..256) |i| {
- const c: u8 = @intCast(i);
- const grey: GColor.RGB = .{ .r = c, .g = c, .b = c };
- try std.testing.expectEqual(grey.luminance(), luminanceOf(grey));
- }
- // Channel weights are asymmetric, so a grey ramp alone would not catch a
- // transposed coefficient. The palette is what the tables enumerate.
- for (GColor.default) |candidate| {
- try std.testing.expectEqual(candidate.luminance(), luminanceOf(candidate));
- for (GColor.default) |page| {
- try std.testing.expectEqual(
- candidate.contrast(page),
- contrastOf(luminanceOf(candidate), luminanceOf(page)),
- );
- }
- }
-}
-
-test "terminal Filter keys indexed truecolor OSC and background-only cells through the theme" {
- 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].?;
- try testing.expect(pane.tty_filter);
- pane.tty_filter = false;
-
- // 1: ANSI base key; 196: extended key; true red exactly matches canonical
- // key 196. The two backgrounds repeat key 25 as indexed and truecolour.
- // Erase-to-EOL under that background makes Ghostty color-only cells.
- p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mA" ++
- "\x1b[38;5;196mB" ++
- "\x1b[38;2;255;0;0mC" ++
- "\x1b[0;48;5;25mD" ++
- "\x1b[0;48;2;0;95;175mE" ++
- "\x1b[0;1;2;3;4;5;7;8;9mF" ++
- "\x1b[0;48;5;25m\x1b[K" ++
- "\r\n\x1b[0;38;5;2m界" } });
-
- 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 raw = try p.render(frame.allocator());
- try testing.expectEqual(pardes.Color{ .index = 1 }, raw.at(tx, body_y).style.fg);
- try testing.expectEqual(pardes.Color{ .index = 196 }, raw.at(tx + 1, body_y).style.fg);
- try testing.expectEqual(pardes.Color{ .rgb = .{ 255, 0, 0 } }, raw.at(tx + 2, body_y).style.fg);
-
- pane.tty_filter = true;
- _ = frame.reset(.retain_capacity);
- const filtered = try p.render(frame.allocator());
- var expected_cache: FilterPalette = .{};
- const expected = expected_cache.get(p.theme());
- try testing.expectEqual(asPardesColor(expected[1]), filtered.at(tx, body_y).style.fg);
- try testing.expectEqual(asPardesColor(expected[196]), filtered.at(tx + 1, body_y).style.fg);
- try testing.expectEqual(filtered.at(tx + 1, body_y).style.fg, filtered.at(tx + 2, body_y).style.fg);
- try testing.expectEqual(asPardesColor(expected[25]), filtered.at(tx + 3, body_y).style.bg);
- try testing.expectEqual(filtered.at(tx + 3, body_y).style.bg, filtered.at(tx + 4, body_y).style.bg);
-
- const attrs = filtered.at(tx + 5, body_y).style;
- try testing.expect(attrs.bold);
- try testing.expect(attrs.dim);
- try testing.expect(attrs.italic);
- try testing.expect(attrs.blink);
- try testing.expect(attrs.reverse);
- try testing.expect(attrs.invisible);
- try testing.expect(attrs.strikethrough);
- try testing.expectEqual(.single, attrs.ul);
-
- const erased = pane.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = 6, .y = 0 } }).?;
- try testing.expectEqual(.bg_color_palette, erased.cell.content_tag);
- try testing.expectEqual(asPardesColor(expected[25]), filtered.at(tx + 6, body_y).style.bg);
- try testing.expectEqual(asPardesColor(expected[2]), filtered.at(tx, body_y + 1).style.fg);
- try testing.expectEqual(filtered.at(tx, body_y + 1).style, filtered.at(tx + 1, body_y + 1).style);
- // A filtered terminal never delegates either colour to a backend palette,
- // including cells which were empty/default before the pass.
- for (0..r.w - config.GUTTER) |col| {
- const cell = filtered.at(tx + @as(u16, @intCast(col)), body_y);
- try testing.expect(!cell.default);
- switch (cell.style.fg) {
- .rgb => {},
- else => return error.FilteredForegroundWasNotRgb,
- }
- switch (cell.style.bg) {
- .rgb => {},
- else => return error.FilteredBackgroundWasNotRgb,
- }
- }
-
- // Colors remains the global master gate. The pane remembers Filter while
- // ANSI projection is dormant, and resumes it without replaying VT bytes.
- p.settings.colors = false;
- _ = frame.reset(.retain_capacity);
- const plain = try p.render(frame.allocator());
- try testing.expect(pane.tty_filter);
- try testing.expectEqual(asPardesColor(asGhostRgb(p.theme().fg.?)), plain.at(tx, body_y).style.fg);
- p.settings.colors = true;
-
- // OSC 4 changes the value behind an existing indexed cell. Filter treats
- // that arbitrary value like truecolour, while toggling remains purely a
- // presentation operation and cannot alter Ghostty's query answer.
- p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]4;1;#ff0000\x1b\\" } });
- const osc_red = pane.vt.colorForXterm(.{ .palette = 1 }).?;
- try testing.expect(osc_red.eql(.{ .r = 255, .g = 0, .b = 0 }));
- pane.tty_filter = false;
- pane.tty_filter = true;
- try testing.expect(osc_red.eql(pane.vt.colorForXterm(.{ .palette = 1 }).?));
- _ = frame.reset(.retain_capacity);
- const osc_palette = try p.render(frame.allocator());
- try testing.expectEqual(asPardesColor(expected[196]), osc_palette.at(tx, body_y).style.fg);
-
- // Dynamic default foreground/background colours key every default cell,
- // including the otherwise blank end of the row.
- p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]10;#ff0000\x1b\\" ++
- "\x1b]11;#5f5f5f\x1b\\" } });
- const dyn_fg = pane.vt.colorForXterm(.{ .dynamic = .foreground }).?;
- const dyn_bg = pane.vt.colorForXterm(.{ .dynamic = .background }).?;
- try testing.expect(dyn_fg.eql(.{ .r = 255, .g = 0, .b = 0 }));
- try testing.expect(dyn_bg.eql(.{ .r = 95, .g = 95, .b = 95 }));
- _ = frame.reset(.retain_capacity);
- const dynamic = try p.render(frame.allocator());
- const blank = dynamic.at(tx + r.w - config.GUTTER - 1, body_y + 2).style;
- try testing.expectEqual(asPardesColor(expected[196]), blank.fg);
- try testing.expectEqual(asPardesColor(expected[59]), blank.bg);
-
- // Ghostty owns DEC reverse-screen parsing. Filter follows that mode for
- // the dynamic/default roles, and here the swap turns this cell into a
- // COLLISION: its explicit ANSI foreground is the OSC 4 red keyed to 196,
- // and reverse video has just made that same red the page. Stage two
- // refuses the mapping rather than painting red on red, so the ink becomes
- // the anchor still visible on it — under the swap, the theme's own
- // background colour. Unreversed, the very same cell keeps key 196.
- const explicit_before_reverse = dynamic.at(tx, body_y).style.fg;
- try testing.expectEqual(asPardesColor(expected[196]), explicit_before_reverse);
- p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } });
- _ = frame.reset(.retain_capacity);
- const reversed = try p.render(frame.allocator());
- const reversed_blank = reversed.at(tx + r.w - config.GUTTER - 1, body_y + 2).style;
- try testing.expectEqual(asPardesColor(expected[59]), reversed_blank.fg);
- try testing.expectEqual(asPardesColor(expected[196]), reversed_blank.bg);
- const reversed_explicit = reversed.at(tx, body_y).style;
- try testing.expectEqual(asPardesColor(expected[196]), reversed_explicit.bg);
- try testing.expectEqual(pardes.Color{ .rgb = p.theme().bg.? }, reversed_explicit.fg);
- try testing.expect(!std.meta.eql(reversed_explicit.fg, reversed_explicit.bg));
-
- p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5l" } });
- _ = frame.reset(.retain_capacity);
- const unreversed = try p.render(frame.allocator());
- const unreversed_blank = unreversed.at(tx + r.w - config.GUTTER - 1, body_y + 2).style;
- try testing.expectEqual(asPardesColor(expected[196]), unreversed_blank.fg);
- try testing.expectEqual(asPardesColor(expected[59]), unreversed_blank.bg);
-
- // The cache is keyed by values, not a theme name. Replacing a custom
- // theme in place immediately recolours already-rendered indexed cells.
- p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]104;1\x1b\\" } });
- var custom = p.theme().*;
- custom.name = try p.gpa.dupe(u8, "same-name");
- custom.palette = null;
- custom.kw = .{ 1, 2, 3 };
- p.custom_theme = custom;
- p.custom_theme_active = true;
- _ = frame.reset(.retain_capacity);
- const custom_first = try p.render(frame.allocator());
- try testing.expectEqual(pardes.Color{ .rgb = .{ 1, 2, 3 } }, custom_first.at(tx, body_y).style.fg);
- if (p.custom_theme) |*theme| theme.kw = .{ 4, 5, 6 };
- _ = frame.reset(.retain_capacity);
- const custom_second = try p.render(frame.allocator());
- try testing.expectEqual(pardes.Color{ .rgb = .{ 4, 5, 6 } }, custom_second.at(tx, body_y).style.fg);
-}
-
-test "terminal Filter keeps extended keys dark-to-light on a light theme" {
- const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
- defer p.deinit();
- // Curated order is a public theme contract: helix, dark, acme.
- p.settings.theme = 2;
- try std.testing.expectEqualStrings("acme", p.theme().name);
- var cache: FilterPalette = .{};
- const palette = cache.get(p.theme());
- try std.testing.expect(palette[16].eql(asGhostRgb(p.theme().fg.?)));
- try std.testing.expect(palette[231].eql(asGhostRgb(p.theme().bg.?)));
-}
-
-test "terminal Filter preserves exact palette-null light theme default roles" {
- const testing = std.testing;
- const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 5 });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
-
- var light = p.theme().*;
- light.name = try p.gpa.dupe(u8, "filter-light-defaults");
- light.bg = .{ 0xf8, 0xf8, 0xf8 };
- light.fg = .{ 0x38, 0x38, 0x38 };
- light.palette = null;
- p.custom_theme = light;
- p.custom_theme_active = true;
-
- const pane = p.panes[0].?;
- try testing.expectEqual(@as(?GColor.RGB, null), pane.vt.colorForXterm(.{ .dynamic = .foreground }));
- try testing.expectEqual(@as(?GColor.RGB, null), pane.vt.colorForXterm(.{ .dynamic = .background }));
- pane.tty_filter = 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 ordinary = try p.render(frame.allocator());
- try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, ordinary.at(tx, body_y).style.fg);
- try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, ordinary.at(tx, body_y).style.bg);
-
- p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } });
- _ = frame.reset(.retain_capacity);
- const reversed = try p.render(frame.allocator());
- 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 "terminal Filter maps the default roles before it maps anything else" {
- 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].?;
- try testing.expect(pane.tty_filter);
-
- // Stage one is the two anchors, and they are taken from the theme WHOLE:
- // a cell that names no colour of its own is not routed through the
- // projection at all, so the page and the ink are exactly the theme's.
- for (0..3) |t| {
- p.settings.theme = @intCast(t);
- const stage_one = FilteredColors.init(p, pane);
- // `dark` declares no background of its own, which is exactly why the
- // resolver reads the tag colours as the fallback rather than `.?`.
- const theme = p.theme();
- try testing.expect(stage_one.theme_bg.eql(asGhostRgb(theme.bg orelse theme.tag_bg)));
- try testing.expect(stage_one.theme_fg.eql(asGhostRgb(theme.fg orelse theme.tag_fg)));
- // With no OSC 11 in play the mapped page IS that anchor, and the
- // fallback is the other one: a background never contrasts with itself.
- try testing.expect(stage_one.default_bg.eql(stage_one.theme_bg));
- try testing.expect(stage_one.fallback_fg.eql(stage_one.theme_fg));
- }
-
- // OSC 11 moves the page, and stage one moves with it: the reference the
- // floor is measured against becomes the PROJECTED dynamic background, not
- // the theme's, because that is what `bg` paints behind a default cell.
- p.settings.theme = 0;
- p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]11;#5f5f5f\x1b\\" } });
- var moved = FilteredColors.init(p, pane);
- try testing.expect(!moved.default_bg.eql(moved.theme_bg));
- try testing.expect(moved.default_bg.eql(moved.keyedRgb(.{ .r = 0x5f, .g = 0x5f, .b = 0x5f })));
-}
-
-test "terminal Filter refuses a foreground that would collapse onto the page" {
- 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].?;
- try testing.expect(pane.tty_filter);
-
- // Two ways to land on the page, one per theme orientation. On the light
- // theme the projection's white corner IS the paper, so a truecolour white
- // reduces to it; on a dark theme the same is true of ANSI black, which a
- // shell reaches for with a bare `\x1b[30m` and which takes the semantic
- // fast path rather than the nearest-key scan. Both used to render text in
- // the colour of the page under it.
- p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[38;2;255;255;255mW" ++
- "\x1b[0;30mB" ++
- "\x1b[0;31mR" } });
-
- 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;
-
- for (0..3) |t| {
- p.settings.theme = @intCast(t);
- var fc = FilteredColors.init(p, pane);
- const page = asPardesColor(fc.default_bg);
- const rescued = asPardesColor(fc.fallback_fg);
- _ = frame.reset(.retain_capacity);
- const g = try p.render(frame.allocator());
-
- // The colour each of the three would have been given with no floor.
- const raw_white = fc.keyedRgb(.{ .r = 255, .g = 255, .b = 255 });
- const raw_black = fc.paletteRgb(0, GColor.default[0]);
- const raw_red = fc.paletteRgb(1, GColor.default[1]);
-
- for ([_]struct { at: u16, raw: GColor.RGB }{
- .{ .at = 0, .raw = raw_white },
- .{ .at = 1, .raw = raw_black },
- .{ .at = 2, .raw = raw_red },
- }) |case| {
- const cell = g.at(tx + case.at, body_y).style;
- try testing.expectEqual(page, cell.bg);
- if (case.raw.contrast(fc.default_bg) < config.tty_filter_min_contrast) {
- // Refused: the projection's answer is not painted, the anchor is.
- try testing.expectEqual(rescued, cell.fg);
- try testing.expect(!std.meta.eql(cell.fg, cell.bg));
- } else {
- // Cleared the floor, so stage two leaves it exactly alone.
- try testing.expectEqual(asPardesColor(case.raw), cell.fg);
- }
- // Either way a filtered cell delegates neither colour to a backend.
- switch (cell.fg) {
- .rgb => |ink| try testing.expect(asGhostRgb(ink).contrast(fc.default_bg) >=
- config.tty_filter_min_contrast),
- else => return error.FilteredForegroundWasNotRgb,
- }
- }
-
- // At least one of the three has to have been a real collapse, or this
- // theme proved nothing: white on the light theme, black on the dark.
- try testing.expect(raw_white.contrast(fc.default_bg) < config.tty_filter_min_contrast or
- raw_black.contrast(fc.default_bg) < config.tty_filter_min_contrast);
- // A saturated red is never the page on any curated theme.
- try testing.expect(raw_red.contrast(fc.default_bg) >= config.tty_filter_min_contrast);
- }
-}
-
-test "terminal Filter holds every projected foreground off the page" {
- 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].?;
-
- // The invariant over the WHOLE projection rather than a sampled colour:
- // whatever key a foreground reduces to, what stage two hands back clears
- // the floor. A background is exempt by construction and must stay so —
- // `bg` is what the floor is measured against.
- for (0..3) |t| {
- p.settings.theme = @intCast(t);
- var fc = FilteredColors.init(p, pane);
- var refused: usize = 0;
- for (fc.target, 0..) |projected, key| {
- const ink = fc.legible(projected);
- try testing.expect(ink.contrast(fc.default_bg) >= config.tty_filter_min_contrast);
- if (!ink.eql(projected)) {
- refused += 1;
- try testing.expect(ink.eql(fc.fallback_fg));
- // Only ever refused for being too near the page.
- try testing.expect(projected.contrast(fc.default_bg) < config.tty_filter_min_contrast);
- }
- // The key a background asks for is handed back untouched, including
- // the one whose value is the page itself.
- try testing.expect(fc.keyedRgb(GColor.default[key]).eql(fc.target[fc.nearestKey(GColor.default[key])]));
- }
- // Every curated theme owns at least one collapsing key — that is why
- // the floor exists — and the floor must not be flattening the palette.
- try testing.expect(refused > 0);
- try testing.expect(refused < fc.target.len / 8);
- }
-}
-
-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();
- while (p.nextEffect()) |_| {}
- const pane = p.panes[0].?;
- pane.tty_filter = false;
-
- p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32mPP\x1b]133;B\x1b\\\x1b[31mR\x1b[34mB\x1b[0m out" } });
-
- 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 blue: pardes.Color = .{ .index = 4 };
-
- // tty mode projects the emulator's ansi colours cell for cell.
- pane.mode = .tty;
- p.shell_rows.stale = true;
- const tty = try p.render(frame.allocator());
- 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 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.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" {
- 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 = .tty;
-
- 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 blue: pardes.Color = .{ .index = 4 };
-
- p.shell_rows.stale = true;
- const before = try p.render(frame.allocator());
- try testing.expectEqualStrings("C", before.at(tx, body_y + 2).grapheme());
- try testing.expectEqual(blue, before.at(tx, body_y + 2).style.fg);
-
- // A lingering multi-line edit overlay must not move any shell row's colour.
- 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("C", after.at(tx, body_y + 2).grapheme());
- try testing.expectEqual(blue, after.at(tx, body_y + 2).style.fg);
-}
-
-pub fn palColor(p: *Pardes, idx: u8) pardes.Color {
- if (p.theme().palette) |pal| if (idx < 16) return .{ .rgb = pal[idx] };
- return .{ .index = idx };
-}
-
-pub fn ghostColor(p: *Pardes, color: ghostty_vt.Style.Color, is_bg: bool) pardes.Color {
- return switch (color) {
- .none => blk: {
- const t = if (is_bg) p.theme().bg else p.theme().fg;
- break :blk if (t) |c| .{ .rgb = c } else .default;
- },
- .palette => |idx| palColor(p, idx),
- .rgb => |rgb| .{ .rgb = .{ rgb.r, rgb.g, rgb.b } },
- };
-}
-
-/// executing at a prompt with typed text below it: pad the output area
-/// with newlines so the command's output doesn't overwrite the buffer
-pub fn padOutputBelowEdits(p: *Pardes, id: usize) void {
- // Nothing to pad away from: with no emulator there is no prompt and no
- // child whose output could land on top of the edit buffer.
- if (comptime !enabled) return;
- const pane = p.panes[id] orelse return;
- const o = pane.ovl orelse return;
- if (!pane.isTerminal()) return;
- if (!pane.vt.cursorIsAtPrompt()) return;
- // the buffer's LAST surface row: its lines may outnumber the shell
- // rows it covers, and it is the bottom one output must clear
- const max_row = o.row + @as(i32, @intCast(modal.lineCount(o.text))) - 1;
- const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset);
- const cursor_abs = pane.surfRow(goff + @as(i32, @intCast(pane.vt.screens.active.cursor.y)));
- const pad = std.math.clamp(max_row - cursor_abs, 0, @as(i32, pane.rows));
- var i: i32 = 0;
- while (i < pad) : (i += 1) p.emitWrite(id, "\r");
-}
-
-/// the snapshot takes ownership of a COPY of the edit buffer's text
-pub fn snap(p: *Pardes, pane: *Pane) ?Snapshot {
- var ovl: ?EditBuffer = null;
- if (pane.ovl) |o| ovl = .{ .row = o.row, .rows = o.rows, .text = p.gpa.dupe(u8, o.text) catch return null };
- return .{ .ovl = ovl, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .vsel = pane.vsel };
-}
-
-/// undo/redo restores the selection recorded with the snapshot (helix
-/// keeps selections in its history transactions)
-pub fn restoreSnap(p: *Pardes, pane: *Pane, s: Snapshot) void {
- if (pane.ovl) |o| p.gpa.free(o.text);
- pane.ovl = s.ovl;
- pane.cur_row = s.cur_row;
- pane.cur_col = s.cur_col;
- pane.cur_pinned = true;
- pane.vsel = s.vsel;
- pane.msel.active = false;
- pane.ensureCursorVisible();
-}
-
-fn pushHistory(gpa: std.mem.Allocator, slots: []Snapshot, len: *usize, value: Snapshot) void {
- if (len.* == slots.len) {
- if (slots[0].ovl) |overlay| gpa.free(overlay.text);
- std.mem.copyForwards(Snapshot, slots[0 .. slots.len - 1], slots[1..]);
- len.* -= 1;
- }
- slots[len.*] = value;
- len.* += 1;
-}
-
-pub fn pushUndo(p: *Pardes, pane: *Pane) void {
- const current = pane.ovl orelse EditBuffer{ .rows = 0 };
- if (pane.ed_undo_len > 0) {
- const top = pane.ed_undo[pane.ed_undo_len - 1];
- const same = if (top.ovl) |overlay| pane.ovl != null and overlay.row == current.row and
- overlay.rows == current.rows and std.mem.eql(u8, overlay.text, current.text) else pane.ovl == null;
- if (same) return;
- }
- const value = snap(p, pane) orelse return;
- pushHistory(p.gpa, &pane.ed_undo, &pane.ed_undo_len, value);
- for (pane.ed_redo[0..pane.ed_redo_len]) |item| if (item.ovl) |overlay| p.gpa.free(overlay.text);
- pane.ed_redo_len = 0;
-}
-
-pub fn undo(p: *Pardes, pane: *Pane) void {
- if (pane.ed_undo_len == 0) return;
- const current = snap(p, pane) orelse return;
- pushHistory(p.gpa, &pane.ed_redo, &pane.ed_redo_len, current);
- pane.ed_undo_len -= 1;
- restoreSnap(p, pane, pane.ed_undo[pane.ed_undo_len]);
-}
-
-pub fn redo(p: *Pardes, pane: *Pane) void {
- if (pane.ed_redo_len == 0) return;
- const current = snap(p, pane) orelse return;
- pushHistory(p.gpa, &pane.ed_undo, &pane.ed_undo_len, current);
- pane.ed_redo_len -= 1;
- restoreSnap(p, pane, pane.ed_redo[pane.ed_redo_len]);
-}
-
-// ghostty calls this with a reply (cursor-position report, DA, ...) to send
-// back to the child as if it typed it. The handler's `terminal` is our Pane.vt
-// field; recover the Pane and stash the bytes — sync() drains them into write
-// effects (the callback has no path to the effect queue).
-pub fn ptyReport(handler: *ghostty_vt.TerminalStream.Handler, data: [:0]const u8) void {
- const pane: *Pane = @alignCast(@fieldParentPtr("vt", handler.terminal));
- const room = pane.reply.len - pane.reply_len;
- const n = @min(room, data.len);
- @memcpy(pane.reply[pane.reply_len..][0..n], data[0..n]);
- pane.reply_len += @intCast(n);
-}
-
-const DeviceAttrs = @typeInfo(@typeInfo(@typeInfo(
- @FieldType(ghostty_vt.TerminalStream.Handler.Effects, "device_attributes"),
-).optional.child).pointer.child).@"fn".return_type.?;
-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);
-}