summaryrefslogtreecommitdiff
path: root/src/panes.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/panes.zig')
-rw-r--r--src/panes.zig2527
1 files changed, 1 insertions, 2526 deletions
diff --git a/src/panes.zig b/src/panes.zig
index 051470b1..0f203e6f 100644
--- a/src/panes.zig
+++ b/src/panes.zig
@@ -3918,2532 +3918,7 @@ pub const Pdf = struct {
} else struct {};
};
-pub const Terminal = struct {
- pub const enabled = pardes.terminal_panes;
- const ghostty_vt = if (enabled) @import("ghostty-vt") else struct {};
-
- pub const history_max = if (enabled) 64 else 8;
-
- pub const VtSlot = if (enabled) ghostty_vt.Terminal else void;
- pub const StreamSlot = if (enabled) ghostty_vt.Stream(MarkedHandler) else void;
-
- /// The `aid` pardes's injected bash and fish marks carry (host_io.zig).
- pub const prompt_aid = "pardes";
-
- /// ghostty's handler, plus where the shell is in its command cycle as
- /// its OSC 133 marks tell it: ghostty parses D's exit status and drops
- /// it, and /pane/<n>/pty/run waits on exactly that. Only marks carrying
- /// pardes's own `aid` move it, so fish's native marks, a nested shell's
- /// and a stray `133;D` in some file being printed all pass it by.
- pub const MarkedHandler = if (enabled) struct {
- inner: ghostty_vt.TerminalStream.Handler,
- phase: enum { none, prompt, input, running } = .none,
- /// Counts commands started (C at the input phase).
- started: u32 = 0,
- /// Counts prompts drawn (B), so a line the shell refused without
- /// running (a fish syntax error: no C, no D, a new prompt) shows.
- prompts: u32 = 0,
- /// D marks outside a running command: the shell handled a line
- /// without starting it (bash on a syntax error prints no C).
- declined: u32 = 0,
- /// The newest prompt continues an unfinished line (bash's PS2, marked
- /// `k=c`): the shell still holds the start of the command.
- continuation: bool = false,
- /// Where the newest command's output began (its C) and ended (its
- /// D) on the primary screen, pinned so scrolling keeps them.
- out_start: ?*ghostty_vt.Pin = null,
- out_end: ?*ghostty_vt.Pin = null,
- /// The running command's output was erased under it -- a clear, ED 3,
- /// a reset -- so the pins no longer span what it printed: ED 3 moves
- /// a pin to the next page without flagging it `garbage`
- /// (PageList.erasePage).
- lost: bool = false,
-
- /// The newest command to end: which one, and its status if given.
- finished: ?struct { seq: u32, status: ?i32 } = null,
-
- const Action = ghostty_vt.TerminalStream.Action;
-
- /// Move one of the output pins to the cursor. Only the primary screen
- /// is pinned: a command's own full-screen display is not its output.
- fn pinCursor(self: *@This(), slot: *?*ghostty_vt.Pin, now: bool) void {
- const screen = self.inner.terminal.screens.get(.primary) orelse return;
- if (slot.*) |old| screen.pages.untrackPin(old);
- slot.* = null;
- if (!now or self.inner.terminal.screens.active_key != .primary) return;
- slot.* = screen.pages.trackPin(screen.cursor.page_pin.*) catch null;
- }
-
- pub fn vt(self: *@This(), comptime action: Action.Tag, value: Action.Value(action)) void {
- if (comptime action == .semantic_prompt) mark: {
- const aid = value.readOption(.aid) orelse break :mark;
- if (!std.mem.eql(u8, aid, prompt_aid)) break :mark;
- switch (value.action) {
- .fresh_line_new_prompt => {
- self.phase = .prompt;
- self.continuation = value.readOption(.prompt_kind) == .continuation;
- },
- .end_prompt_start_input => {
- self.phase = .input;
- self.prompts +%= 1;
- },
- // bash's DEBUG trap also fires on its own PROMPT_COMMAND,
- // outside the input phase; only a C that ends input counts
- .end_input_start_output => if (self.phase == .input) {
- self.phase = .running;
- self.started +%= 1;
- self.lost = false;
- self.pinCursor(&self.out_start, true);
- self.pinCursor(&self.out_end, false);
- },
- .end_command => if (self.phase == .running) {
- self.phase = .none;
- self.finished = .{ .seq = self.started, .status = value.readOption(.exit_code) };
- self.pinCursor(&self.out_end, true);
- } else {
- self.declined +%= 1;
- },
- else => {},
- }
- }
- if (comptime action == .erase_display_complete or action == .erase_display_scrollback or action == .full_reset) {
- if (self.phase == .running) self.lost = true;
- }
- self.inner.vt(action, value);
- }
-
- pub fn deinit(self: *@This()) void {
- self.inner.deinit();
- }
- } else void;
-
- pub const Replay = struct {
- bytes: [1024 * 1024]u8 = undefined,
- head: usize = 0,
- len: usize = 0,
- };
-
- pub const State = struct {
- vt: VtSlot,
- stream: StreamSlot,
- replay: Replay,
- reply: [256]u8 = undefined,
- reply_len: u16 = 0,
- };
-
- const GColor = ghostty_vt.color;
-
- const FilterPaletteKey = struct {
- bg: GColor.RGB,
- fg: GColor.RGB,
- base: [16]GColor.RGB,
- };
-
- 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 {
- 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: Pane.CharSel,
- };
-
- pub const PendingCommand = struct {
- bytes: []u8 = &.{},
- wait: enum { none, spawn, input } = .none,
- };
-
- pub fn armShellSpawn(pane: *Pane) void {
- if (comptime !enabled) return;
- std.debug.assert(pane.pending_command.bytes.len == 0);
- pane.pending_command.wait = .spawn;
- }
-
- 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;
- pane.greet = false;
- return true;
- }
-
- 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 {
- pane.greet = false;
- releasePendingCommand(p, id, pane, true);
- }
- }
-
- 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 = .{};
- }
-
- pub fn terminalIo() std.Io {
- return if (comptime !pardes.hosted)
- std.Io.failing
- else
- std.Io.Threaded.global_single_threaded.io();
- }
-
- /// 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 state = pane.terminal orelse return .{};
- const sb = state.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 active-area 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 state = pane.terminal orelse return .{};
- const cur = state.vt.screens.active.cursor;
- return .{ .x = @intCast(cur.x), .y = @intCast(cur.y) };
- }
-
- pub fn visibleCursor(pane: *const Pane) ?GridCursor {
- if (comptime !enabled) return null;
- const state = pane.terminal orelse return null;
- if (!state.vt.modes.get(.cursor_visible)) return null;
- const cur = state.vt.screens.active.cursor;
- const sb = scrollbar(pane);
- const row = sb.total - sb.len + cur.y;
- if (row < sb.offset or row - sb.offset >= sb.len) return null;
- return .{ .x = @intCast(cur.x), .y = @intCast(row - sb.offset) };
- }
-
- /// Move the emulator's viewport by `delta` shell rows (negative scrolls back).
- pub fn scrollGrid(pane: *Pane, delta: i32) void {
- if (comptime !enabled) return;
- const state = pane.terminal orelse return;
- state.vt.screens.active.scroll(.{ .delta_row = delta });
- }
-
- /// Is the viewport already showing the last row of output?
- pub fn atBottom(pane: *const Pane) bool {
- if (comptime !enabled) return true;
- const sb = scrollbar(pane);
- return sb.offset + sb.len >= sb.total;
- }
-
- /// Snap the viewport back onto live output.
- pub fn followOutput(pane: *Pane) void {
- if (comptime !enabled) return;
- const state = pane.terminal orelse return;
- state.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;
- const state = pane.terminal orelse return;
- if (cols != pane.cols) for (0..pane.sel.len) |button| {
- pane.clearPointerSelection(button);
- pane.sel[button].state = .none;
- };
- state.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;
- const state = pane.terminal orelse return false;
- return state.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 state = pane.terminal orelse return false;
- const m = &state.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;
- const state = pane.terminal orelse return false;
- return state.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 &.{};
- const state = pane.terminal orelse return &.{};
- return state.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;
- const state = pane.terminal orelse return;
- state.stream.deinit();
- state.vt.deinit(gpa);
- gpa.destroy(state);
- pane.terminal = null;
- }
-
- /// 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 createDoc(gpa, cols, rows);
- errdefer gpa.destroy(pane);
- if (comptime !enabled) return pane;
- const state = try gpa.create(State);
- errdefer gpa.destroy(state);
- state.vt = try ghostty_vt.Terminal.init(terminalIo(), gpa, .{
- .cols = cols,
- .rows = rows,
- .max_scrollback = 16 * 1024 * 1024,
- });
- state.stream = .initAlloc(gpa, .{ .inner = state.vt.vtHandler() });
- state.replay.head = 0;
- state.replay.len = 0;
- state.reply_len = 0;
- state.stream.handler.inner.effects.write_pty = ptyReport;
- state.stream.handler.inner.effects.device_attributes = ptyDeviceAttrs;
- pane.terminal = state;
- pane.tty_filter = true;
- return pane;
- }
-
- pub fn createDoc(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane {
- const pane = try gpa.create(Pane);
- pane.* = .{
- .gpa = gpa,
- .cols = cols,
- .rows = rows,
- };
- return pane;
- }
-
- /// Where a restored terminal's history ends and its new shell begins.
- pub const restored_banner = "\x1b[0m\r\n\x1b[2m\u{2500}\u{2500} restored history \u{2500}\u{2500}\x1b[0m\r\n";
-
- /// Rebuild a dump's terminal emulator from its recorded output. `live`: a
- /// shell will be spawned into it, so the history is marked off from what
- /// that shell prints and the pane waits for it like any new terminal.
- /// 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, live: bool) !*Pane {
- const terminal = src.terminal.?;
- if (comptime !enabled) {
- 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;
- }
- // An older dump's raw output tail cannot come back live: cut off mid
- // redraw, or written at sizes the pane no longer has, it replays as
- // garbage. Its rendered text can.
- const legacy_text = live and !terminal.stream_is_screen and terminal.stream.len > 0;
- const bytes = if (legacy_text)
- try std.mem.replaceOwned(u8, p.scratch.allocator(), terminal.stream, "\n", "\r\n")
- else 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 (live) {
- if (bytes.len > 0) {
- ingest(pane, bytes);
- ingest(pane, restored_banner);
- }
- followOutput(pane);
- // A view left scrolled back stays on what it was showing: the
- // banner's line breaks scrolled a full screen by that many more.
- if (src.scroll > 0) scrollGrid(pane, -@as(i32, @intCast(src.scroll + comptime std.mem.count(u8, restored_banner, "\n"))));
- armShellSpawn(pane);
- } else 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 {
- const state = pane.terminal.?;
- const replay = &state.replay;
- if (bytes.len >= replay.bytes.len) {
- const kept = bytes[bytes.len - replay.bytes.len ..];
- @memcpy(&replay.bytes, kept);
- replay.head = 0;
- replay.len = replay.bytes.len;
- } else {
- const overflow = bytes.len -| (replay.bytes.len - replay.len);
- replay.head = (replay.head + overflow) % replay.bytes.len;
- replay.len -= overflow;
- const tail = (replay.head + replay.len) % replay.bytes.len;
- const first = @min(bytes.len, replay.bytes.len - tail);
- @memcpy(replay.bytes[tail..][0..first], bytes[0..first]);
- @memcpy(replay.bytes[0 .. bytes.len - first], bytes[first..]);
- replay.len += bytes.len;
- }
- state.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 {
- const state = pane.terminal orelse return &.{};
- const replay = &state.replay;
- if (replay.len == 0) return &.{};
- if (replay.head + replay.len <= replay.bytes.len)
- return replay.bytes[replay.head..][0..replay.len];
- const out = try allocator.alloc(u8, replay.len);
- const first = replay.bytes.len - replay.head;
- @memcpy(out[0..first], replay.bytes[replay.head..]);
- @memcpy(out[first..], replay.bytes[0 .. replay.len - first]);
- return out;
- }
-
- test "replay ownership is terminal-only and construction rolls back on allocation failure" {
- const Case = struct {
- fn run(gpa: std.mem.Allocator, terminal: bool) !void {
- const pane = if (terminal) try create(gpa, 40, 12) else try createDoc(gpa, 40, 12);
- defer gpa.destroy(pane);
- defer deinitEmulator(pane, gpa);
- try std.testing.expectEqual(terminal and enabled, pane.terminal != null);
- }
- };
- try std.testing.expect(@sizeOf(Pane) < 128 * 1024);
- try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{false});
- try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{true});
- }
-
- test "document construction allocates only the pane and has inert terminal defaults" {
- var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 1 });
- const allocator = failing.allocator();
- const pane = try createDoc(allocator, 40, 12);
- defer allocator.destroy(pane);
- defer deinitEmulator(pane, allocator);
- try std.testing.expect(pane.terminal == null);
- try std.testing.expectEqual(@as(usize, 1), failing.alloc_index);
- try std.testing.expectEqual(@as(usize, 0), failing.resize_index);
- try std.testing.expect(!failing.has_induced_failure);
- try std.testing.expectEqual(@as(usize, @sizeOf(Pane)), failing.allocated_bytes);
- try std.testing.expectEqual(GridCursor{}, gridCursor(pane));
- try std.testing.expectEqual(Scrollbar{}, scrollbar(pane));
- try std.testing.expect(!bracketedPaste(pane));
- try std.testing.expect(!reportsMouse(pane));
- try std.testing.expect(!mouseFormatSgr(pane));
- try std.testing.expect(!promptInputReady(pane));
- scrollGrid(pane, 100);
- followOutput(pane);
- resizeGrid(pane, allocator, 80, 24);
- try std.testing.expectEqual(GridCursor{}, gridCursor(pane));
- try std.testing.expectEqual(Scrollbar{}, scrollbar(pane));
- try std.testing.expectEqualStrings("", try screenTextAlloc(pane, allocator));
- try std.testing.expectEqualStrings("", try replayBytes(pane, allocator));
- try std.testing.expect(!failing.has_induced_failure);
- }
-
- test "terminal state keeps parser fragments and sends emulator replies through its owner" {
- if (comptime !enabled) return error.SkipZigTest;
- const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
- const pane = p.panes[0].?;
- const state = pane.terminal.?;
- try std.testing.expect(state.stream.handler.inner.terminal == &state.vt);
- p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[" } });
- while (p.nextEffect()) |effect| {
- if (effect == .write) return error.PrematureTerminalReply;
- }
- p.update(.{ .output = .{ .pane = 0, .bytes = "6n" } });
- var replies: usize = 0;
- while (p.nextEffect()) |effect| {
- if (effect != .write) continue;
- try std.testing.expectEqual(@as(u8, 0), effect.write.pane);
- try std.testing.expectEqualStrings("\x1b[1;1R", effect.write.bytes.slice());
- replies += 1;
- }
- try std.testing.expectEqual(@as(usize, 1), replies);
- try std.testing.expectEqual(@as(u16, 0), state.reply_len);
- try std.testing.expectEqualStrings("\x1b[6n", try replayBytes(pane, std.testing.allocator));
- }
-
- test "replay keeps a bounded chronological suffix across large writes and wrapping" {
- if (comptime !enabled) return error.SkipZigTest;
- const gpa = std.testing.allocator;
- const pane = try create(gpa, 40, 12);
- defer gpa.destroy(pane);
- defer deinitEmulator(pane, gpa);
- try std.testing.expectEqualStrings("", try replayBytes(pane, gpa));
- ingest(pane, "hello");
- const small = try replayBytes(pane, gpa);
- try std.testing.expectEqualStrings("hello", small);
- try std.testing.expectEqual(@intFromPtr(&pane.terminal.?.replay.bytes), @intFromPtr(small.ptr));
-
- const capacity = pane.terminal.?.replay.bytes.len;
- const input = try gpa.alloc(u8, capacity + 7);
- defer gpa.free(input);
- @memset(input, 0);
- const ending = "\x1b[31mred\x1b[0m\r\n";
- @memcpy(input[input.len - ending.len ..], ending);
- ingest(pane, input);
- try std.testing.expectEqualSlices(u8, input[7..], try replayBytes(pane, gpa));
- ingest(pane, "next\r\n");
- const wrapped = try replayBytes(pane, gpa);
- defer gpa.free(wrapped);
- try std.testing.expectEqual(capacity, wrapped.len);
- try std.testing.expectEqualSlices(u8, input[7 + "next\r\n".len ..], wrapped[0 .. capacity - "next\r\n".len]);
- try std.testing.expectEqualStrings("next\r\n", wrapped[capacity - "next\r\n".len ..]);
- }
-
- test "replay restores terminal cells from the retained stream" {
- if (comptime !enabled) return error.SkipZigTest;
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
- const pane = p.panes[0].?;
- feedOutput(p, pane, "\x1b[31mred\x1b[0m\r\ncafé\r\n");
- const encoded = try dump.encodeBytes(gpa, try replayBytes(pane, gpa));
- defer gpa.free(encoded);
- const restored = try restore(p, .{
- .kind = .terminal,
- .tag = "",
- .body = "",
- .cols = pane.cols,
- .rows = pane.rows,
- .terminal = .{ .stream_b64 = encoded },
- }, false);
- defer gpa.destroy(restored);
- defer deinitEmulator(restored, gpa);
- const before = try screenTextAlloc(pane, gpa);
- defer gpa.free(before);
- const after = try screenTextAlloc(restored, gpa);
- defer gpa.free(after);
- try std.testing.expectEqualStrings(before, after);
- try std.testing.expectEqualSlices(u8, try replayBytes(pane, gpa), try replayBytes(restored, gpa));
- }
-
- test "a restored terminal keeps its history above a marker and gets a shell where it was" {
- if (comptime !enabled or pardes.isolated) return error.SkipZigTest;
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
- const pane = p.panes[0].?;
- feedOutput(p, pane, "old output\r\n$ ls\r\n");
- try pane.setOwnedCwd("/tmp");
- try p.dumpState();
- const restored = try Pardes.initFromDump(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }, p.dump_out.?);
- defer restored.deinit();
- var spawned = false;
- while (restored.nextEffect()) |effect| switch (effect) {
- .spawn => |sp| spawned = sp.pane == 0 and std.mem.eql(u8, sp.cwd.slice(), "/tmp"),
- else => {},
- };
- try std.testing.expect(spawned);
- const again = restored.panes[0].?;
- try std.testing.expectEqual(.spawn, again.pending_command.wait);
- const text = try screenTextAlloc(again, gpa);
- defer gpa.free(text);
- const old_at = std.mem.indexOf(u8, text, "old output") orelse return error.HistoryLost;
- const mark_at = std.mem.indexOf(u8, text, "restored history") orelse return error.NoMarker;
- try std.testing.expect(old_at < mark_at);
- }
-
- test "an older dump comes back live from its rendered text, not its raw output tail" {
- if (comptime !enabled) return error.SkipZigTest;
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 });
- defer p.deinit();
- // A tail cut mid-redraw: relative moves from a cursor it never set.
- const tail = try dump.encodeBytes(gpa, "\x1b[7A\x1b[2Kgarbage\x1b[3B\x1b[9Gmore");
- defer gpa.free(tail);
- const restored = try restore(p, .{
- .kind = .terminal,
- .tag = "",
- .body = "",
- .cols = 40,
- .rows = 12,
- .terminal = .{ .stream = "$ ls\na.txt b.txt\n$ ", .stream_b64 = tail },
- }, true);
- defer gpa.destroy(restored);
- defer deinitEmulator(restored, gpa);
- defer deinitPendingCommand(restored);
- const text = try screenTextAlloc(restored, gpa);
- defer gpa.free(text);
- try std.testing.expect(std.mem.indexOf(u8, text, "$ ls\na.txt b.txt") != null);
- try std.testing.expect(std.mem.indexOf(u8, text, "garbage") == null);
- }
-
- /// 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;
- if (pane.terminal == null) return;
- const has_positions = pane.ovl != null or pane.cur_pinned or pane.vsel.active or
- pane.msel.active or pane.nsel > 0 or pane.append_at != null or pane.look_at != null or
- pane.ed_undo_len > 0 or pane.ed_redo_len > 0 or pointerRowCount(pane) > 0;
- // Sticky bottom, as every other terminal does it: output drags the
- // viewport down only when the viewport was already on the last row, so
- // a reader scrolled back into the scrollback stays where they scrolled.
- const at_bottom = atBottom(pane);
- if (has_positions) ingestWithPositions(pane, bytes) else 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 and at_bottom) followOutput(pane);
- }
-
- const RowPin = struct { pin: ?*ghostty_vt.Pin = null, offset: i32 = 0 };
- const PositionPin = struct {
- target: *i32,
- row: RowPin,
- overlay: ?*EditBuffer,
- edit_row: ?i32 = null,
- };
-
- fn trackRow(pages: *ghostty_vt.PageList, row: i32, failed: *bool) RowPin {
- if (row < 0) return .{ .offset = row };
- const bounded: usize = @min(@as(usize, @intCast(row)), pages.total_rows - 1);
- var left = pages.total_rows - 1 - bounded;
- var node = pages.pages.last.?;
- while (left >= node.rows()) {
- left -= node.rows();
- node = node.prev.?;
- }
- const pin = pages.trackPin(.{ .node = node, .y = @intCast(node.rows() - 1 - left) }) catch {
- failed.* = true;
- return .{};
- };
- return .{ .pin = pin, .offset = row - @as(i32, @intCast(bounded)) };
- }
-
- fn pinnedRow(pages: ?*ghostty_vt.PageList, row: RowPin) i32 {
- const pin = row.pin orelse return row.offset;
- const alive = pages orelse return 0;
- if (pin.garbage) return 0;
- var after: usize = 0;
- var node = alive.pages.last;
- while (node) |item| : (node = item.prev) {
- if (item == pin.node)
- return @as(i32, @intCast(alive.total_rows - after - item.rows() + pin.y)) +| row.offset;
- after += item.rows();
- }
- return 0;
- }
-
- fn trackPosition(pages: *ghostty_vt.PageList, target: *i32, overlay: ?*EditBuffer, failed: *bool) PositionPin {
- var grid = target.*;
- if (overlay) |edit| {
- const lines: i32 = @intCast(modal.lineCount(edit.text));
- if (grid >= edit.row and grid < edit.row +| lines)
- return .{ .target = target, .row = .{}, .overlay = edit, .edit_row = grid - edit.row };
- if (grid > edit.row) grid = grid -| lines +| edit.rows;
- }
- return .{ .target = target, .row = trackRow(pages, grid, failed), .overlay = overlay };
- }
-
- fn pointerRowCount(pane: *const Pane) usize {
- var count: usize = 0;
- for (pane.sel) |gesture| if (pane.pointerSelection(gesture)) |rows| {
- count += rows.len;
- };
- return count;
- }
-
- fn ingestWithPositions(pane: *Pane, bytes: []const u8) void {
- const screens = &pane.terminal.?.vt.screens;
- const key = screens.active_key;
- const generation = screens.generation(key);
- const pages = &screens.active.pages;
- const Group = struct { overlay: ?*EditBuffer, cursor: *i32, selection: *Pane.CharSel };
- var groups: [history_max * 2 + 1]Group = undefined;
- groups[0] = .{
- .overlay = if (pane.ovl) |*overlay| overlay else null,
- .cursor = &pane.cur_row,
- .selection = &pane.vsel,
- };
- var ngroups: usize = 1;
- for ([_][]Snapshot{ pane.ed_undo[0..pane.ed_undo_len], pane.ed_redo[0..pane.ed_redo_len] }) |history| {
- for (history) |*snapshot| {
- groups[ngroups] = .{
- .overlay = if (snapshot.ovl) |*overlay| overlay else null,
- .cursor = &snapshot.cur_row,
- .selection = &snapshot.vsel,
- };
- ngroups += 1;
- }
- }
- const Span = struct { overlay: *EditBuffer, start: RowPin, end: RowPin };
- var spans: [groups.len]Span = undefined;
- var nspans: usize = 0;
- var positions: [groups.len * 2 + Pane.max_selections * 2 + 4]PositionPin = undefined;
- var npositions: usize = 0;
- var failed = false;
- for (groups[0..ngroups]) |group| {
- if (group.overlay) |overlay| {
- spans[nspans] = .{
- .overlay = overlay,
- .start = trackRow(pages, overlay.row, &failed),
- .end = trackRow(pages, overlay.row +| (overlay.rows - 1), &failed),
- };
- nspans += 1;
- }
- positions[npositions] = trackPosition(pages, group.cursor, group.overlay, &failed);
- npositions += 1;
- if (group.selection.active) {
- positions[npositions] = trackPosition(pages, &group.selection.row, group.overlay, &failed);
- npositions += 1;
- }
- }
- const overlay = groups[0].overlay;
- var extra: [Pane.max_selections * 2 + 4]*i32 = undefined;
- var nextra: usize = 0;
- if (pane.msel.active) {
- extra[nextra] = &pane.msel.r0;
- extra[nextra + 1] = &pane.msel.r1;
- nextra += 2;
- }
- for (pane.sels[0..pane.nsel]) |*selection| {
- extra[nextra] = &selection.row;
- extra[nextra + 1] = &selection.arow;
- nextra += 2;
- }
- if (pane.append_at) |*at| {
- extra[nextra] = &at.row;
- nextra += 1;
- }
- if (pane.look_at) |*at| {
- extra[nextra] = &at.row;
- nextra += 1;
- }
- for (extra[0..nextra]) |target| {
- positions[npositions] = trackPosition(pages, target, overlay, &failed);
- npositions += 1;
- }
- const pointer_positions = pane.gpa.alloc(PositionPin, pointerRowCount(pane)) catch null;
- defer if (pointer_positions) |pins| pane.gpa.free(pins);
- if (pointer_positions) |pins| {
- var index: usize = 0;
- for (pane.sel) |gesture| if (pane.pointerSelection(gesture)) |rows| {
- for (rows) |*row| {
- pins[index] = trackPosition(pages, &row.row, if (row.raw_terminal) null else overlay, &failed);
- index += 1;
- }
- };
- } else failed = true;
- ingest(pane, bytes);
- // RIS can destroy the alternate screen and every pin it owned.
- const alive = if (screens.generation(key) == generation) pages else null;
- var moved = false;
- var pointer_rows_lost = alive == null or pointer_positions == null;
- if (pointer_positions) |pins| for (pins) |position| {
- if (alive != null) if (position.row.pin) |pin| {
- if (pin.garbage) pointer_rows_lost = true;
- };
- };
- for (spans[0..nspans]) |span| {
- const start = pinnedRow(alive, span.start);
- const rows = @max(1, pinnedRow(alive, span.end) -| start +| 1);
- moved = moved or start != span.overlay.row or rows != span.overlay.rows;
- span.overlay.row = start;
- span.overlay.rows = rows;
- if (alive) |owner| {
- if (span.start.pin) |pin| owner.untrackPin(pin);
- if (span.end.pin) |pin| owner.untrackPin(pin);
- }
- }
- for ([_][]PositionPin{ positions[0..npositions], pointer_positions orelse &.{} }) |batch| for (batch) |position| {
- var row = pinnedRow(alive, position.row);
- if (position.overlay) |edit| {
- if (position.edit_row) |relative| {
- row = edit.row +| relative;
- } else if (row > edit.row) {
- row = if (row < edit.row +| edit.rows)
- edit.row
- else
- row -| edit.rows +| @as(i32, @intCast(modal.lineCount(edit.text)));
- }
- }
- moved = moved or row != position.target.*;
- position.target.* = row;
- if (alive) |owner| if (position.row.pin) |pin| owner.untrackPin(pin);
- };
- if (pointer_rows_lost) for (0..pane.sel.len) |button| {
- pane.clearPointerSelection(button);
- pane.sel[button].state = .none;
- };
- if (moved) pane.nsel_snap = 0; // Saved regex-preview offsets name the old flat text.
- if (failed) {
- const message = "terminal edit position reset: out of memory";
- @memcpy(pane.msg[0..message.len], message);
- pane.msg_len = message.len;
- }
- }
-
- test "raw terminal output needs no position tracking allocations" {
- if (comptime !enabled) return error.SkipZigTest;
- var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
- const p = try Pardes.init(failing.allocator(), .{ .tty_only = true, .cols = 80, .rows = 24 });
- defer p.deinit();
- const pane = p.panes[0].?;
- pane.mode = .tty;
- feedOutput(p, pane, "warm");
- const allocations = failing.alloc_index;
- failing.fail_index = allocations;
- const pins = pane.terminal.?.vt.screens.active.pages.countTrackedPins();
- feedOutput(p, pane, " output");
- try std.testing.expectEqual(allocations, failing.alloc_index);
- try std.testing.expect(!failing.has_induced_failure);
- try std.testing.expectEqual(pins, pane.terminal.?.vt.screens.active.pages.countTrackedPins());
- }
-
- pub fn promptInputReady(pane: *const Pane) bool {
- if (comptime !enabled) return false;
- const state = pane.terminal orelse return false;
- return state.vt.screens.active_key != .alternate and
- state.vt.screens.active.cursor.semantic_content == .input;
- }
-
- pub const CommandOutput = struct {
- /// Owned by the caller.
- text: [:0]const u8,
- /// Whether `text` starts where the command did. It does not when that
- /// start is not there to read: cut off by `max_rows`, scrolled out of
- /// the history (ghostty moved the pin to the oldest row it kept and
- /// flagged it `garbage`), erased under it (`MarkedHandler.lost`), or
- /// never pinned (a C or D that came on the alternate screen).
- whole: bool,
- };
-
- /// What the newest finished command printed: the screen text between its
- /// C and D marks, read from at most `max_rows` rows above its end, so a
- /// command that printed the whole history costs no more than its tail.
- pub fn commandOutput(pane: *Pane, gpa: std.mem.Allocator, max_rows: usize) !CommandOutput {
- // Unless there is text to read, whether the nothing it printed is the
- // whole of it.
- const whole = if (comptime enabled) printed: {
- const state = pane.terminal orelse break :printed true;
- const marks = &state.stream.handler;
- const start = marks.out_start orelse break :printed false;
- const end = marks.out_end orelse break :printed false;
- const screen = state.vt.screens.get(.primary) orelse break :printed false;
- if (end.garbage) break :printed false;
- var whole = !marks.lost and !start.garbage;
- // D is where the next prompt begins: the output ends a cell before.
- const last = end.leftWrap(1) orelse break :printed whole;
- if (start.eql(end.*) or last.before(start.*)) break :printed whole;
- var from = start.*;
- if (last.up(max_rows)) |floor| if (from.before(floor)) {
- from = floor;
- from.x = 0;
- whole = false;
- };
- return .{
- .text = try screen.selectionString(gpa, .{ .sel = ghostty_vt.Selection.init(from, last, false) }),
- .whole = whole,
- };
- } else true;
- return .{ .text = try gpa.dupeZ(u8, ""), .whole = whole };
- }
-
- /// At a prompt with nothing typed on its line: no input left of the
- /// cursor and none under it (text typed and then Ctrl-A'd starts at the
- /// cursor). Nothing further right counts: that is where fish draws a
- /// right prompt, and fish suggests nothing on an empty line.
- pub fn promptInputEmpty(pane: *const Pane) bool {
- if (comptime !enabled) return false;
- if (!promptInputReady(pane)) return false;
- const cursor = &pane.terminal.?.vt.screens.active.cursor;
- const row = cursor.page_pin.cells(.all);
- for (row[0..@min(cursor.x + 1, row.len)]) |cell|
- if (cell.semantic_content == .input and !cell.isEmpty()) return false;
- return true;
- }
-
- /// Encode one key for the program that owns a raw terminal and queue its pty
- /// write, respecting the application's keyboard protocol and terminal modes.
- pub fn forwardKey(p: *Pardes, id: usize, key: Key) void {
- if (comptime enabled) {
- const pane = p.panes[id] orelse return;
- const state = pane.terminal orelse return;
- const input = ghostty_vt.input;
- const physical: input.Key = switch (key.cp) {
- Key.enter => .enter,
- Key.backspace => .backspace,
- Key.tab => .tab,
- Key.escape => .escape,
- Key.up => .arrow_up,
- Key.down => .arrow_down,
- Key.left => .arrow_left,
- Key.right => .arrow_right,
- Key.home => .home,
- Key.end => .end,
- Key.page_up => .page_up,
- Key.page_down => .page_down,
- Key.delete => .delete,
- else => if (key.cp < 128) input.Key.fromASCII(@intCast(key.cp)) orelse .unidentified else .unidentified,
- };
- var utf8: [4]u8 = undefined;
- const text_cp = if (key.shift and key.cp >= 'a' and key.cp <= 'z') key.cp - 'a' + 'A' else key.cp;
- const text = if (key.text.len > 0) key.text else if (key.cp >= 32 and key.cp < Key.up and key.cp != Key.backspace)
- utf8[0 .. std.unicode.utf8Encode(text_cp, &utf8) catch return]
- else
- "";
- var buffer: [256]u8 = undefined;
- var writer = std.Io.Writer.fixed(&buffer);
- input.encodeKey(&writer, .{
- .key = physical,
- .mods = .{ .ctrl = key.ctrl, .alt = key.alt, .shift = key.shift },
- .consumed_mods = .{ .shift = key.text.len > 0 and key.shift },
- .utf8 = text,
- .unshifted_codepoint = if (key.cp >= 'A' and key.cp <= 'Z') key.cp - 'A' + 'a' else if (key.cp < Key.up) key.cp else 0,
- }, options: {
- var opts = input.KeyEncodeOptions.fromTerminal(&state.vt);
- // fromTerminal cannot know this one and says so, defaulting to
- // "option is not alt" — right for an app that lets macOS do the
- // unicode translation and hands the encoder the composed text.
- // Every pardes shell resolves the modifier itself and hands the
- // core the BASE key with the alt bit and no text at all (see
- // PardesView.keyDown, and app.mjs doing the same with the same
- // two bits), so by the time this runs alt already means alt.
- // Left at the default, a macOS build drops the ESC prefix on
- // the floor and forwards Alt-n to the child as a bare `n`.
- opts.macos_option_as_alt = .true;
- break :options opts;
- }) catch return;
- if (writer.end > 0) p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(writer.buffered()) } });
- return;
- }
- 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) } });
- }
-
- pub fn enterTty(p: *Pardes, id: usize) void {
- const pane = p.panes[id] orelse return;
- const selected = pane.rawPointerText(pane.sel[@intFromEnum(config.select_button)]) != null;
- if (comptime enabled) if (!selected and pane.terminal != null and pane.cur_pinned and pane.terminal.?.vt.cursorIsAtPrompt()) handoff: {
- const screen = pane.terminal.?.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;
- followOutput(pane); // entering raw mode is a request for the live shell
- pane.msel.active = false;
- pane.vsel.active = false;
- pane.nsel = 0;
- // The raw renderer follows the live cursor. A captured mouse selection
- // keeps its modal endpoint for the next return to editor mode.
- pane.cur_pinned = selected;
- pane.select = false;
- pane.append_at = null;
- pane.sticky_col = -1;
- pane.normal.clear();
- }
-
- // Returned slices remain valid until the next cache rebuild or reset.
- 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;
- }
-
- 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,
- };
-
- 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,
- };
-
- 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 };
- }
-
- pub fn promptPrefixBytes(pane: *Pane, row: i32, raw: []const u8) usize {
- if (comptime !enabled) return 0;
- if (row < 0) return 0;
- const state = pane.terminal orelse return 0;
- const pin = state.vt.screens.active.pages.pin(.{ .screen = .{ .x = 0, .y = @intCast(row) } }) orelse return 0;
- if (pin.rowAndCell().row.semantic_prompt == .none) return 0;
- return switch (promptCut(pin)) {
- .keep => 0,
- .blank => raw.len,
- .cut => |cols| @min(raw.len, File.rawAtDisplay(raw, cols)),
- };
- }
-
- 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];
- 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");
- }
-
- 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;
- }
-
- pub fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 {
- if (comptime !enabled) return &empty_grid;
- if (pane.terminal == null) return &empty_grid;
- const c = &p.shell_rows;
- if (!c.stale and c.pane == pane) return c.rows;
- if (c.pane != null) {
- 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);
- for (0..pane.sel.len) |button| {
- pane.clearPointerSelection(button);
- pane.sel[button].state = .none;
- }
- p.gpa.free(overlay.text);
- overlay.text = new;
- }
-
- /// The shell's screen and scrollback (the primary screen, even under a
- /// full-screen program) as VT that replays to what was shown: styled
- /// text, soft-wrapped lines rejoined so they reflow at another width.
- fn screenVt(pane: *const Pane, arena: std.mem.Allocator) ![]const u8 {
- const screen = pane.terminal.?.vt.screens.get(.primary) orelse return &.{};
- var out: std.Io.Writer.Allocating = .init(arena);
- try ghostty_vt.formatter.ScreenFormatter.init(screen, .{ .emit = .vt, .unwrap = true }).format(&out.writer);
- return out.written();
- }
-
- /// 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 (!enabled or pane.terminal == null) {
- 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,
- .collapsed = pane.collapsed,
- .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.terminal.?.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.terminal.?.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,
- .collapsed = pane.collapsed,
- .terminal = .{
- .cwd = try arena.dupe(u8, pane.cwdSlice()),
- .stream = stream[0..len],
- .stream_b64 = try dump.encodeBytes(arena, try screenVt(pane, arena)),
- .stream_is_screen = true,
- .cursor = .{
- .col = pane.terminal.?.vt.screens.active.cursor.x,
- .row = pane.terminal.?.vt.screens.active.cursor.y,
- },
- },
- };
- }
-
- fn buildRows(alloc: std.mem.Allocator, p: *Pardes, pane: *Pane) !Rows {
- const full = try pane.terminal.?.vt.screens.active.dumpStringAlloc(p.scratch.allocator(), .{ .screen = .{} });
- var pit = pane.terminal.?.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 };
- }
-
- pub fn bodyText(arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
- const vp: []const []const u8 = if (comptime !enabled) &.{} else vp: {
- const state = pane.terminal orelse break :vp &.{};
- const screen = state.vt.screens.active;
- 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 = screen.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;
- }
-
- pub const BodyRow = union(enum) {
- grid: i32,
- edit: struct { line: []const u8, idx: usize },
- };
-
- 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,
- .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;
- }
-
- // Match surviving edit-buffer rows to their original terminal styles.
- 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 first: usize = @intCast(o.row);
- const screen = pane.terminal.?.vt.screens.active;
- const total = screen.pages.scrollbar().total;
- if (first >= total) return .{};
- const covered: usize = @intCast(o.rows);
- const count = @min(covered, total - first);
- const tl = screen.pages.pin(.{ .screen = .{ .y = @intCast(first) } }) orelse return .{};
- const br = screen.pages.pin(.{ .screen = .{
- .x = screen.pages.cols - 1,
- .y = @intCast(first + count - 1),
- } }) orelse return .{};
- const arena = p.scratch.allocator();
- var output: std.Io.Writer.Allocating = .init(arena);
- screen.dumpString(&output.writer, .{ .tl = tl, .br = br, .unwrap = false }) catch return .{};
- const shell = arena.alloc([]const u8, count) catch return .{};
- var raw = std.mem.splitScalar(u8, output.written(), '\n');
- var pins = tl.rowIterator(.right_down, br);
- for (shell) |*row| {
- const text = raw.next() orelse "";
- row.* = if (pins.next()) |pin|
- if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, text) else text
- else
- text;
- }
- const lines = std.mem.count(u8, o.text, "\n") + 1;
- return .{
- .text = o.text,
- .shell = shell,
- .first = first,
- .end = first + count,
- .lines = lines,
- .cursor = first,
- .budget = covered + 4 * lines,
- .active = true,
- };
- }
-
- 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 {
- 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 - a.first])) continue;
- a.cursor = k + 1;
- return .{ .row = @intCast(k) };
- }
- if (a.cursor >= a.end) return null;
- const shell = a.shell[a.cursor - a.first];
- 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;
- if (line.len != p + s and shell.len - (p + s) > shell.len / 2) return null;
- const row = a.cursor;
- if (s > 0 or p >= shell.len) a.cursor += 1;
- return .{ .row = @intCast(row), .prefix = p, .suffix = s, .shell_len = shell.len };
- }
- };
-
- const Anchor = struct {
- /// the row, absolute while it comes from `EditAnchors`, viewport once
- /// `recolorAnsi` has subtracted the walk's offset
- row: i32,
- 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);
- }
- };
-
- 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;
- if (pane.terminal == null) return;
- const body_y = p.bodyTop(r);
- var filtered_storage: FilteredColors = undefined;
- const filtered: ?*FilteredColors = if (pane.tty_filter) blk: {
- 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.terminal.?.vt.modes.get(.reverse_colors);
- const pages = &pane.terminal.?.vt.screens.active.pages;
- 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 = null;
- var walk: BodyWalk = .init(pane);
- 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) {
- .edit => |e| blk: {
- if (anchors == null) anchors = .init(p, pane, pane.ovl.?);
- 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;
- 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,
- };
- };
- if (cut > 0 and text.len == 0) continue;
- var at: usize = 0;
- var sc: u16 = 0;
- var gc: u16 = cut;
- 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;
- const span: u16 = if (sc + 1 < tw and s.at(tx + sc + 1, body_y + vr).len == 0) 2 else 1;
- if (at >= mine_from and at < mine_to) {
- sc += span;
- at = stop;
- continue;
- }
- 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;
- }
- 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);
- if (cell.default and filtered == null) continue;
- cell.default = false;
- cell.style = st;
- };
- sb += covered;
- sc += span;
- at = stop;
- }
- 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;
- 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;
- }
- if (tail) |style| while (sc < tw) : (sc += 1) {
- const cell = s.at(tx + sc, body_y + vr);
- if (cell.default and filtered == null) continue;
- cell.default = false;
- cell.style = style;
- };
- }
- }
- }
-
- fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell, filtered: ?*FilteredColors, 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,
- },
- };
- 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;
- }
-
- 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,
- default_bg: GColor.RGB,
- /// What a foreground too near `default_bg` becomes instead.
- fallback_fg: GColor.RGB,
- default_bg_luminance: f64,
- 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,
- 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.terminal.?.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.terminal.?.vt.colorForXterm(.{ .dynamic = .background });
- var dynamic_fg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .foreground });
- if (pane.terminal.?.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),
- };
- 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;
-
- 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;
- }
-
- 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).?));
- }
-
- 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;
- }
-
- 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;
- }
- };
-
- 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" {
- 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;
- // Raw index assertions require a theme that inherits the ANSI palette.
- const p = try Pardes.init(testing.allocator, .{ .startup_config = "Theme helix\n", .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;
-
- 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.terminal.?.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;
-
- p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]4;1;#ff0000\x1b\\" } });
- const osc_red = pane.terminal.?.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.terminal.?.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.terminal.?.vt.colorForXterm(.{ .dynamic = .foreground }).?;
- const dyn_bg = pane.terminal.?.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);
-
- 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;
- _ = 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, .{ .startup_config = "Theme acme\n", .tty_only = true });
- defer p.deinit();
- 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;
-
- const pane = p.panes[0].?;
- try testing.expectEqual(@as(?GColor.RGB, null), pane.terminal.?.vt.colorForXterm(.{ .dynamic = .foreground }));
- try testing.expectEqual(@as(?GColor.RGB, null), pane.terminal.?.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);
-
- // Native dark/light palettes plus legacy helix/dark/acme, including
- // the null-page fallback that only the legacy dark theme exercises.
- for (0..9) |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));
- }
-
- 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);
-
- 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..9) |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].?;
-
- for (0..9) |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 = .{ .rgb = p.theme().palette.?[1] };
- const blue: pardes.Color = .{ .rgb = p.theme().palette.?[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);
-
- 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 "a prompt row hidden end to end paints nothing at all" {
- const testing = std.testing;
- 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{ .rgb = p.theme().palette.?[1] }, s.at(tx, body_y).style.bg));
- }
-
- 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;
- const state = pane.terminal orelse return;
- if (!state.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(state.vt.screens.active.pages.scrollbar().offset);
- const cursor_abs = pane.surfRow(goff + @as(i32, @intCast(state.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]);
- }
-
- pub fn ptyReport(handler: *ghostty_vt.TerminalStream.Handler, data: [:0]const u8) void {
- const state: *State = @alignCast(@fieldParentPtr("vt", handler.terminal));
- const room = state.reply.len - state.reply_len;
- const n = @min(room, data.len);
- @memcpy(state.reply[state.reply_len..][0..n], data[0..n]);
- state.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 .{};
- }
-};
+pub const Terminal = @import("Terminal.zig");
test {
_ = Pane;