//! A terminal pane: its VT emulator and replay, prompt rows, the edit overlay //! typed over shell rows, and the Filter that recolours output to the theme. const panes = @import("panes.zig"); const std = @import("std"); const pardes = @import("pardes.zig"); const config = @import("config.zig"); const Pardes = pardes.Pardes; const Surface = pardes.Surface; const modal = @import("modal.zig"); const tracy = @import("tracy.zig"); const dump = @import("dump.zig"); const Key = pardes.Key; const Pane = panes.Pane; const EditText = panes.EditText; const File = panes.File; 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//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: panes.Text.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; } /// Remembers a line pardes types into this terminal as the command it /// started, for Kill: it runs from the shell's next C mark to its D. /// ponytail: of several lines queued for a shell not up yet, only the last /// is remembered, as the first of them. pub fn noteCommand(pane: *Pane, line: []const u8) void { if (comptime !enabled) return; const state = pane.terminal orelse return; const word = std.mem.trim(u8, line, " \t")[0 .. std.mem.indexOfAny(u8, std.mem.trim(u8, line, " \t"), " \t") orelse std.mem.trim(u8, line, " \t").len]; var sent: @typeInfo(@FieldType(Pane, "sent_command")).optional.child = .{ .seq = state.stream.handler.started +% 1 }; sent.len = @intCast(@min(word.len, sent.word.len)); @memcpy(sent.word[0..sent.len], word[0..sent.len]); pane.sent_command = sent; } /// Whether the command pardes last typed here is running now. pub fn commandRunning(pane: *const Pane) bool { if (comptime !enabled) return false; const state = pane.terminal orelse return false; const sent = pane.sent_command orelse return false; const marks = &state.stream.handler; return marks.phase == .running and marks.started == sent.seq; } 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 = .{} }); } /// How long `screenTextAlloc` would be, counted without keeping it: the /// length a terminal's body reads as, which its ctl line reports. pub fn screenTextLen(pane: *Pane) usize { if (comptime !enabled) return 0; const state = pane.terminal orelse return 0; const screen = state.vt.screens.active; var buf: [256]u8 = undefined; var counting: std.Io.Writer.Discarding = .init(&buf); screen.dumpString(&counting.writer, .{ .tl = screen.pages.getTopLeft(.screen), .br = screen.pages.getBottomRight(.screen) orelse return 0, .unwrap = false, }) catch return 0; return @intCast(counting.fullCount()); } /// 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 command pane comes back finished, its command not run again" { if (comptime !enabled or pardes.isolated) return error.SkipZigTest; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 60, .rows = 16 }); defer p.deinit(); while (p.nextEffect()) |_| {} const cmd = p.freeSlot().?; _ = try p.newCommand(cmd, "/tmp", "make test"); feedOutput(p, p.panes[cmd].?, "ok\r\n"); p.update(.{ .exited = .{ .pane = @intCast(cmd), .status = 2 } }); try dump.dumpState(p); const restored = try dump.initFromDump(gpa, .{ .tty_only = true, .cols = 60, .rows = 16 }, p.dump_out.?); defer restored.deinit(); var spawns: usize = 0; while (restored.nextEffect()) |effect| switch (effect) { .spawn => spawns += 1, else => {}, }; try std.testing.expectEqual(@as(usize, 1), spawns); // the shell, not the command const again = for (restored.panes) |slot| { if (slot) |pane| if (pane.command != null) break pane; } else return error.CommandPaneLost; try std.testing.expectEqualStrings("make test", again.command.?); try std.testing.expect(again.command_done); try std.testing.expectEqual(@as(?u8, 2), again.command_status); const text = try screenTextAlloc(again, gpa); defer gpa.free(text); try std.testing.expect(std.mem.indexOf(u8, text, "ok") != null); } 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"); pane.shell = try gpa.dupe(u8, "bash"); // `Tty bash`: it comes back as bash try dump.dumpState(p); const restored = try dump.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.expectEqualStrings("bash", again.shell.?); try std.testing.expect(again.command == null); 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.body.cur_pinned or pane.body.vsel.active or pane.body.msel.active or pane.body.nsel > 0 or pane.body.last_edit != null or pane.look_at != null or pane.body.ed_undo_len > 0 or pane.body.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); if (pane.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: *panes.Text.CharSel }; var groups: [history_max * 2 + 1]Group = undefined; groups[0] = .{ .overlay = if (pane.ovl) |*overlay| overlay else null, .cursor = &pane.body.cur_row, .selection = &pane.body.vsel, }; var ngroups: usize = 1; for ([_][]Snapshot{ pane.body.ed_undo[0..pane.body.ed_undo_len], pane.body.ed_redo[0..pane.body.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 + panes.Text.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: [panes.Text.max_selections * 2 + 4]*i32 = undefined; var nextra: usize = 0; if (pane.body.msel.active) { extra[nextra] = &pane.body.msel.r0; extra[nextra + 1] = &pane.body.msel.r1; nextra += 2; } for (pane.body.sels[0..pane.body.nsel]) |*selection| { extra[nextra] = &selection.row; extra[nextra + 1] = &selection.arow; nextra += 2; } if (pane.body.last_edit) |*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.body.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()); } /// A full-screen program has the emulator on its alternate screen. pub fn onAlternateScreen(pane: *const Pane) bool { if (comptime !enabled) return false; const state = pane.terminal orelse return false; return state.vt.screens.active_key == .alternate; } 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. /// The shell exited in the middle of a command (its `exit 3`): no end mark /// will come, so the command's output ends where the cursor is. pub fn endOutputHere(pane: *Pane) void { if (comptime !enabled) return; const state = pane.terminal orelse return; const marks = &state.stream.handler; if (marks.phase == .running and marks.out_end == null) marks.pinCursor(&marks.out_end, true); } 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.body.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.body.cur_row) - goff; if (vp_row < 0) break :handoff; var grid_col: i32 = @max(0, pane.body.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.body.mode = .tty; followOutput(pane); // entering raw mode is a request for the live shell pane.body.msel.active = false; pane.body.vsel.active = false; pane.body.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.body.cur_pinned = selected; pane.body.select = false; pane.body.restore_cursor = false; pane.body.sticky_col = -1; pane.body.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 pardes.edit.retireEditText(p, 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()), .shell = try arena.dupe(u8, pane.shell orelse ""), .command = try arena.dupe(u8, pane.command orelse ""), .status = pane.command_status, .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.body.mode != .tty) prompts.next() else null; if (skip > 0) { skip -= 1; continue; } if (row > 0) { stream[len] = '\n'; len += 1; } if (pane.body.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()), .shell = try arena.dupe(u8, pane.shell orelse ""), .command = try arena.dupe(u8, pane.command orelse ""), .status = pane.command_status, .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.body.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.body.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.body.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, s: *Surface, 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; if (pane.terminal == null) return; const body_y = p.bodyTop(pane, 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.body.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].?; pane.tag_expanded = false; // a one-row tag keeps this small screen's rows p.sync(); 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 = p.bodyTop(pane, r); 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 = p.bodyTop(pane, r); 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 = p.bodyTop(pane, r); 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 = p.bodyTop(pane, r); 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.body.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.body.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.tag_expanded = false; // a 12x8 screen: the body, not the tag p.sync(); pane.tty_filter = false; pane.body.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 = p.bodyTop(pane, r); 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"); } pub fn pushUndo(p: *Pardes, pane: *Pane) void { pane.body.remember(p.gpa, pane.ovl); } pub fn undo(p: *Pardes, pane: *Pane) void { const back_to = pane.body.step(p.gpa, pane.ovl, true) orelse return; if (pane.ovl) |o| p.gpa.free(o.text); pane.ovl = back_to.ovl; pane.ensureCursorVisible(); } pub fn redo(p: *Pardes, pane: *Pane) void { const forward_to = pane.body.step(p.gpa, pane.ovl, false) orelse return; if (pane.ovl) |o| p.gpa.free(o.text); pane.ovl = forward_to.ovl; pane.ensureCursorVisible(); } 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 .{}; }