diff options
| -rw-r--r-- | src/Terminal.zig | 2539 | ||||
| -rw-r--r-- | src/fs.zig | 1 | ||||
| -rw-r--r-- | src/panes.zig | 2527 |
3 files changed, 2541 insertions, 2526 deletions
diff --git a/src/Terminal.zig b/src/Terminal.zig new file mode 100644 index 00000000..a8588952 --- /dev/null +++ b/src/Terminal.zig @@ -0,0 +1,2539 @@ +//! 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 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/<n>/pty/run waits on exactly that. Only marks carrying +/// pardes's own `aid` move it, so fish's native marks, a nested shell's +/// and a stray `133;D` in some file being printed all pass it by. +pub const MarkedHandler = if (enabled) struct { + inner: ghostty_vt.TerminalStream.Handler, + phase: enum { none, prompt, input, running } = .none, + /// Counts commands started (C at the input phase). + started: u32 = 0, + /// Counts prompts drawn (B), so a line the shell refused without + /// running (a fish syntax error: no C, no D, a new prompt) shows. + prompts: u32 = 0, + /// D marks outside a running command: the shell handled a line + /// without starting it (bash on a syntax error prints no C). + declined: u32 = 0, + /// The newest prompt continues an unfinished line (bash's PS2, marked + /// `k=c`): the shell still holds the start of the command. + continuation: bool = false, + /// Where the newest command's output began (its C) and ended (its + /// D) on the primary screen, pinned so scrolling keeps them. + out_start: ?*ghostty_vt.Pin = null, + out_end: ?*ghostty_vt.Pin = null, + /// The running command's output was erased under it -- a clear, ED 3, + /// a reset -- so the pins no longer span what it printed: ED 3 moves + /// a pin to the next page without flagging it `garbage` + /// (PageList.erasePage). + lost: bool = false, + + /// The newest command to end: which one, and its status if given. + finished: ?struct { seq: u32, status: ?i32 } = null, + + const Action = ghostty_vt.TerminalStream.Action; + + /// Move one of the output pins to the cursor. Only the primary screen + /// is pinned: a command's own full-screen display is not its output. + fn pinCursor(self: *@This(), slot: *?*ghostty_vt.Pin, now: bool) void { + const screen = self.inner.terminal.screens.get(.primary) orelse return; + if (slot.*) |old| screen.pages.untrackPin(old); + slot.* = null; + if (!now or self.inner.terminal.screens.active_key != .primary) return; + slot.* = screen.pages.trackPin(screen.cursor.page_pin.*) catch null; + } + + pub fn vt(self: *@This(), comptime action: Action.Tag, value: Action.Value(action)) void { + if (comptime action == .semantic_prompt) mark: { + const aid = value.readOption(.aid) orelse break :mark; + if (!std.mem.eql(u8, aid, prompt_aid)) break :mark; + switch (value.action) { + .fresh_line_new_prompt => { + self.phase = .prompt; + self.continuation = value.readOption(.prompt_kind) == .continuation; + }, + .end_prompt_start_input => { + self.phase = .input; + self.prompts +%= 1; + }, + // bash's DEBUG trap also fires on its own PROMPT_COMMAND, + // outside the input phase; only a C that ends input counts + .end_input_start_output => if (self.phase == .input) { + self.phase = .running; + self.started +%= 1; + self.lost = false; + self.pinCursor(&self.out_start, true); + self.pinCursor(&self.out_end, false); + }, + .end_command => if (self.phase == .running) { + self.phase = .none; + self.finished = .{ .seq = self.started, .status = value.readOption(.exit_code) }; + self.pinCursor(&self.out_end, true); + } else { + self.declined +%= 1; + }, + else => {}, + } + } + if (comptime action == .erase_display_complete or action == .erase_display_scrollback or action == .full_reset) { + if (self.phase == .running) self.lost = true; + } + self.inner.vt(action, value); + } + + pub fn deinit(self: *@This()) void { + self.inner.deinit(); + } +} else void; + +pub const Replay = struct { + bytes: [1024 * 1024]u8 = undefined, + head: usize = 0, + len: usize = 0, +}; + +pub const State = struct { + vt: VtSlot, + stream: StreamSlot, + replay: Replay, + reply: [256]u8 = undefined, + reply_len: u16 = 0, +}; + +const GColor = ghostty_vt.color; + +const FilterPaletteKey = struct { + bg: GColor.RGB, + fg: GColor.RGB, + base: [16]GColor.RGB, +}; + +pub const FilterPalette = if (enabled) LivePalette else struct {}; + +const LivePalette = struct { + key: ?FilterPaletteKey = null, + colors: GColor.Palette = GColor.default, + + fn get(self: *LivePalette, theme: *const pardes.Theme) *const GColor.Palette { + const bg = asGhostRgb(theme.bg orelse theme.tag_bg); + const fg = asGhostRgb(theme.fg orelse theme.tag_fg); + var base: [16]GColor.RGB = undefined; + if (theme.palette) |palette| { + for (&base, palette) |*dst, src| dst.* = asGhostRgb(src); + } else { + const synthesized = [16][3]u8{ + theme.bg orelse theme.tag_bg, + theme.kw, + theme.str, + theme.num, + theme.box, + theme.sel_bg, + theme.comment, + theme.fg orelse theme.tag_fg, + theme.lineno, + theme.kw, + theme.str, + theme.num, + theme.scroll_thumb, + theme.sel_fg, + theme.tag_fg, + theme.fg orelse theme.tag_fg, + }; + for (&base, synthesized) |*dst, src| dst.* = asGhostRgb(src); + } + + const key: FilterPaletteKey = .{ .bg = bg, .fg = fg, .base = base }; + if (self.key) |old| if (std.meta.eql(old, key)) return &self.colors; + + var seed = GColor.default; + for (base, 0..) |rgb, i| seed[i] = rgb; + self.colors = GColor.generate256Color(seed, .initEmpty(), bg, fg, false); + self.key = key; + return &self.colors; + } +}; + +fn asGhostRgb(rgb: [3]u8) GColor.RGB { + return .{ .r = rgb[0], .g = rgb[1], .b = rgb[2] }; +} + +fn asPardesColor(rgb: GColor.RGB) pardes.Color { + return .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }; +} + +/// The owned text standing in for `rows` live terminal rows, beginning at +/// absolute surface row `row`. The emulator grid remains untouched underneath. +pub const EditBuffer = struct { + row: i32 = 0, + rows: i32 = 1, + text: []u8 = &.{}, +}; + +/// One whole-state terminal edit boundary. Null means the pane has not yet +/// materialized an edit buffer; non-null snapshots own their text. +pub const Snapshot = struct { + ovl: ?EditBuffer, + cur_row: i32, + cur_col: i32, + vsel: Pane.CharSel, +}; + +pub const PendingCommand = struct { + bytes: []u8 = &.{}, + wait: enum { none, spawn, input } = .none, +}; + +pub fn armShellSpawn(pane: *Pane) void { + if (comptime !enabled) return; + std.debug.assert(pane.pending_command.bytes.len == 0); + pane.pending_command.wait = .spawn; +} + +pub fn queuePendingCommand(pane: *Pane, command: []const u8) !bool { + if (pane.pending_command.wait == .none) return false; + const old_len = pane.pending_command.bytes.len; + const new_len = try std.math.add(usize, old_len, try std.math.add(usize, command.len, 1)); + const bytes = if (old_len == 0) + try pane.gpa.alloc(u8, new_len) + else + try pane.gpa.realloc(pane.pending_command.bytes, new_len); + @memcpy(bytes[old_len..][0..command.len], command); + bytes[new_len - 1] = '\r'; + pane.pending_command.bytes = bytes; + pane.greet = false; + return true; +} + +pub fn shellSpawned(p: *Pardes, id: usize, prompt_marks: bool) void { + const pane = p.panes[id] orelse return; + if (!pane.isTerminal() or pane.pending_command.wait != .spawn) return; + if (prompt_marks) { + pane.pending_command.wait = .input; + releasePendingCommand(p, id, pane, false); + } else { + pane.greet = false; + releasePendingCommand(p, id, pane, true); + } +} + +pub fn releasePendingCommandIfReady(p: *Pardes, id: usize, pane: *Pane) void { + if (pane.pending_command.wait == .input) + releasePendingCommand(p, id, pane, promptInputReady(pane)); +} + +fn releasePendingCommand(p: *Pardes, id: usize, pane: *Pane, ready: bool) void { + if (!ready) return; + const bytes = pane.pending_command.bytes; + pane.pending_command = .{}; + if (bytes.len > 0) { + p.emitWrite(id, bytes); + pane.gpa.free(bytes); + } +} + +pub fn deinitPendingCommand(pane: *Pane) void { + if (pane.pending_command.bytes.len > 0) + pane.gpa.free(pane.pending_command.bytes); + pane.pending_command = .{}; +} + +pub fn terminalIo() std.Io { + return if (comptime !pardes.hosted) + std.Io.failing + else + std.Io.Threaded.global_single_threaded.io(); +} + +/// The three numbers ghostty's scrollbar reports; all zero without an emulator. +pub const Scrollbar = struct { total: usize = 0, offset: usize = 0, len: usize = 0 }; + +pub fn scrollbar(pane: *const Pane) Scrollbar { + if (comptime !enabled) return .{}; + const state = pane.terminal orelse return .{}; + const sb = state.vt.screens.active.pages.scrollbar(); + return .{ .total = sb.total, .offset = sb.offset, .len = sb.len }; +} + +/// The emulator's viewport offset, in SHELL rows: the top of what it shows. +pub fn gridOffset(pane: *const Pane) i32 { + return @intCast(scrollbar(pane).offset); +} + +/// Where the emulator itself puts the cursor, in active-area cells — the origin +/// without one, which is where an empty pane's cursor belongs anyway. +pub const GridCursor = struct { x: u16 = 0, y: u16 = 0 }; + +pub fn gridCursor(pane: *const Pane) GridCursor { + if (comptime !enabled) return .{}; + const state = pane.terminal orelse return .{}; + const cur = state.vt.screens.active.cursor; + return .{ .x = @intCast(cur.x), .y = @intCast(cur.y) }; +} + +pub fn visibleCursor(pane: *const Pane) ?GridCursor { + if (comptime !enabled) return null; + const state = pane.terminal orelse return null; + if (!state.vt.modes.get(.cursor_visible)) return null; + const cur = state.vt.screens.active.cursor; + const sb = scrollbar(pane); + const row = sb.total - sb.len + cur.y; + if (row < sb.offset or row - sb.offset >= sb.len) return null; + return .{ .x = @intCast(cur.x), .y = @intCast(row - sb.offset) }; +} + +/// Move the emulator's viewport by `delta` shell rows (negative scrolls back). +pub fn scrollGrid(pane: *Pane, delta: i32) void { + if (comptime !enabled) return; + const state = pane.terminal orelse return; + state.vt.screens.active.scroll(.{ .delta_row = delta }); +} + +/// Is the viewport already showing the last row of output? +pub fn atBottom(pane: *const Pane) bool { + if (comptime !enabled) return true; + const sb = scrollbar(pane); + return sb.offset + sb.len >= sb.total; +} + +/// Snap the viewport back onto live output. +pub fn followOutput(pane: *Pane) void { + if (comptime !enabled) return; + const state = pane.terminal orelse return; + state.vt.screens.active.scroll(.active); +} + +/// Reflow the grid. A failed reflow keeps the grid it had rather than dropping +/// a scrollback; the next resize retries with the same numbers. +pub fn resizeGrid(pane: *Pane, gpa: std.mem.Allocator, cols: u16, rows: u16) void { + if (comptime !enabled) return; + const state = pane.terminal orelse return; + if (cols != pane.cols) for (0..pane.sel.len) |button| { + pane.clearPointerSelection(button); + pane.sel[button].state = .none; + }; + state.vt.resize(gpa, .{ .cols = cols, .rows = rows }) catch {}; +} + +/// DECSET 2004: the program wants its pastes bracketed. +pub fn bracketedPaste(pane: *const Pane) bool { + if (comptime !enabled) return false; + const state = pane.terminal orelse return false; + return state.vt.modes.get(.bracketed_paste); +} + +/// The program tracks the mouse itself, so a click in its body is its event. +pub fn reportsMouse(pane: *const Pane) bool { + if (comptime !enabled) return false; + const state = pane.terminal orelse return false; + const m = &state.vt.modes; + return m.get(.mouse_event_normal) or m.get(.mouse_event_button) or m.get(.mouse_event_any); +} + +/// ...and wants them in SGR (1006) rather than the legacy X10 bytes. +pub fn mouseFormatSgr(pane: *const Pane) bool { + if (comptime !enabled) return false; + const state = pane.terminal orelse return false; + return state.vt.modes.get(.mouse_format_sgr); +} + +/// The whole scrollback as plain text, `gpa`-owned: what `Save` writes out. +pub fn screenTextAlloc(pane: *Pane, gpa: std.mem.Allocator) ![]const u8 { + if (comptime !enabled) return &.{}; + const state = pane.terminal orelse return &.{}; + return state.vt.screens.active.dumpStringAlloc(gpa, .{ .screen = .{} }); +} + +/// Release the emulator's heap. The Pane allocation itself is the core's. +pub fn deinitEmulator(pane: *Pane, gpa: std.mem.Allocator) void { + if (comptime !enabled) return; + const state = pane.terminal orelse return; + state.stream.deinit(); + state.vt.deinit(gpa); + gpa.destroy(state); + pane.terminal = null; +} + +/// Allocate the live emulator half of a terminal pane. Slot ownership, serial +/// assignment, and spawn effects remain core lifecycle invariants. +pub fn create(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane { + const pane = try createDoc(gpa, cols, rows); + errdefer gpa.destroy(pane); + if (comptime !enabled) return pane; + const state = try gpa.create(State); + errdefer gpa.destroy(state); + state.vt = try ghostty_vt.Terminal.init(terminalIo(), gpa, .{ + .cols = cols, + .rows = rows, + .max_scrollback = 16 * 1024 * 1024, + }); + state.stream = .initAlloc(gpa, .{ .inner = state.vt.vtHandler() }); + state.replay.head = 0; + state.replay.len = 0; + state.reply_len = 0; + state.stream.handler.inner.effects.write_pty = ptyReport; + state.stream.handler.inner.effects.device_attributes = ptyDeviceAttrs; + pane.terminal = state; + pane.tty_filter = true; + return pane; +} + +pub fn createDoc(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane { + const pane = try gpa.create(Pane); + pane.* = .{ + .gpa = gpa, + .cols = cols, + .rows = rows, + }; + return pane; +} + +/// Where a restored terminal's history ends and its new shell begins. +pub const restored_banner = "\x1b[0m\r\n\x1b[2m\u{2500}\u{2500} restored history \u{2500}\u{2500}\x1b[0m\r\n"; + +/// Rebuild a dump's terminal emulator from its recorded output. `live`: a +/// shell will be spawned into it, so the history is marked off from what +/// that shell prints and the pane waits for it like any new terminal. +/// Registration and tag/cwd policy stay with the core; raw VT replay and +/// viewport restoration belong here. +pub fn restore(p: *Pardes, src: dump.Pane, live: bool) !*Pane { + const terminal = src.terminal.?; + if (comptime !enabled) { + const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows)); + errdefer p.gpa.destroy(pane); + if (terminal.stream.len > 0) + pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, terminal.stream) }; + return pane; + } + // An older dump's raw output tail cannot come back live: cut off mid + // redraw, or written at sizes the pane no longer has, it replays as + // garbage. Its rendered text can. + const legacy_text = live and !terminal.stream_is_screen and terminal.stream.len > 0; + const bytes = if (legacy_text) + try std.mem.replaceOwned(u8, p.scratch.allocator(), terminal.stream, "\n", "\r\n") + else if (terminal.stream_b64.len > 0) + try dump.decodeBytes(p.scratch.allocator(), terminal.stream_b64) + else + &.{}; + const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows)); + if (live) { + if (bytes.len > 0) { + ingest(pane, bytes); + ingest(pane, restored_banner); + } + followOutput(pane); + // A view left scrolled back stays on what it was showing: the + // banner's line breaks scrolled a full screen by that many more. + if (src.scroll > 0) scrollGrid(pane, -@as(i32, @intCast(src.scroll + comptime std.mem.count(u8, restored_banner, "\n")))); + armShellSpawn(pane); + } else if (bytes.len > 0) { + ingest(pane, bytes); + followOutput(pane); + if (src.scroll > 0) + scrollGrid(pane, -@as(i32, @intCast(src.scroll))); + } + return pane; +} + +/// Feed the emulator and retain the bounded suffix a dump can replay. Live +/// output and restoration share this byte path, then apply different views. +fn ingest(pane: *Pane, bytes: []const u8) void { + const state = pane.terminal.?; + const replay = &state.replay; + if (bytes.len >= replay.bytes.len) { + const kept = bytes[bytes.len - replay.bytes.len ..]; + @memcpy(&replay.bytes, kept); + replay.head = 0; + replay.len = replay.bytes.len; + } else { + const overflow = bytes.len -| (replay.bytes.len - replay.len); + replay.head = (replay.head + overflow) % replay.bytes.len; + replay.len -= overflow; + const tail = (replay.head + replay.len) % replay.bytes.len; + const first = @min(bytes.len, replay.bytes.len - tail); + @memcpy(replay.bytes[tail..][0..first], bytes[0..first]); + @memcpy(replay.bytes[0 .. bytes.len - first], bytes[first..]); + replay.len += bytes.len; + } + state.stream.nextSlice(bytes); +} + +/// Return the replay ring in chronological order. Wrapped records are copied +/// into `allocator`; contiguous records remain a borrowed slice of the pane. +fn replayBytes(pane: *const Pane, allocator: std.mem.Allocator) ![]const u8 { + const state = pane.terminal orelse return &.{}; + const replay = &state.replay; + if (replay.len == 0) return &.{}; + if (replay.head + replay.len <= replay.bytes.len) + return replay.bytes[replay.head..][0..replay.len]; + const out = try allocator.alloc(u8, replay.len); + const first = replay.bytes.len - replay.head; + @memcpy(out[0..first], replay.bytes[replay.head..]); + @memcpy(out[first..], replay.bytes[0 .. replay.len - first]); + return out; +} + +test "replay ownership is terminal-only and construction rolls back on allocation failure" { + const Case = struct { + fn run(gpa: std.mem.Allocator, terminal: bool) !void { + const pane = if (terminal) try create(gpa, 40, 12) else try createDoc(gpa, 40, 12); + defer gpa.destroy(pane); + defer deinitEmulator(pane, gpa); + try std.testing.expectEqual(terminal and enabled, pane.terminal != null); + } + }; + try std.testing.expect(@sizeOf(Pane) < 128 * 1024); + try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{false}); + try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{true}); +} + +test "document construction allocates only the pane and has inert terminal defaults" { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 1 }); + const allocator = failing.allocator(); + const pane = try createDoc(allocator, 40, 12); + defer allocator.destroy(pane); + defer deinitEmulator(pane, allocator); + try std.testing.expect(pane.terminal == null); + try std.testing.expectEqual(@as(usize, 1), failing.alloc_index); + try std.testing.expectEqual(@as(usize, 0), failing.resize_index); + try std.testing.expect(!failing.has_induced_failure); + try std.testing.expectEqual(@as(usize, @sizeOf(Pane)), failing.allocated_bytes); + try std.testing.expectEqual(GridCursor{}, gridCursor(pane)); + try std.testing.expectEqual(Scrollbar{}, scrollbar(pane)); + try std.testing.expect(!bracketedPaste(pane)); + try std.testing.expect(!reportsMouse(pane)); + try std.testing.expect(!mouseFormatSgr(pane)); + try std.testing.expect(!promptInputReady(pane)); + scrollGrid(pane, 100); + followOutput(pane); + resizeGrid(pane, allocator, 80, 24); + try std.testing.expectEqual(GridCursor{}, gridCursor(pane)); + try std.testing.expectEqual(Scrollbar{}, scrollbar(pane)); + try std.testing.expectEqualStrings("", try screenTextAlloc(pane, allocator)); + try std.testing.expectEqualStrings("", try replayBytes(pane, allocator)); + try std.testing.expect(!failing.has_induced_failure); +} + +test "terminal state keeps parser fragments and sends emulator replies through its owner" { + if (comptime !enabled) return error.SkipZigTest; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + const state = pane.terminal.?; + try std.testing.expect(state.stream.handler.inner.terminal == &state.vt); + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[" } }); + while (p.nextEffect()) |effect| { + if (effect == .write) return error.PrematureTerminalReply; + } + p.update(.{ .output = .{ .pane = 0, .bytes = "6n" } }); + var replies: usize = 0; + while (p.nextEffect()) |effect| { + if (effect != .write) continue; + try std.testing.expectEqual(@as(u8, 0), effect.write.pane); + try std.testing.expectEqualStrings("\x1b[1;1R", effect.write.bytes.slice()); + replies += 1; + } + try std.testing.expectEqual(@as(usize, 1), replies); + try std.testing.expectEqual(@as(u16, 0), state.reply_len); + try std.testing.expectEqualStrings("\x1b[6n", try replayBytes(pane, std.testing.allocator)); +} + +test "replay keeps a bounded chronological suffix across large writes and wrapping" { + if (comptime !enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + const pane = try create(gpa, 40, 12); + defer gpa.destroy(pane); + defer deinitEmulator(pane, gpa); + try std.testing.expectEqualStrings("", try replayBytes(pane, gpa)); + ingest(pane, "hello"); + const small = try replayBytes(pane, gpa); + try std.testing.expectEqualStrings("hello", small); + try std.testing.expectEqual(@intFromPtr(&pane.terminal.?.replay.bytes), @intFromPtr(small.ptr)); + + const capacity = pane.terminal.?.replay.bytes.len; + const input = try gpa.alloc(u8, capacity + 7); + defer gpa.free(input); + @memset(input, 0); + const ending = "\x1b[31mred\x1b[0m\r\n"; + @memcpy(input[input.len - ending.len ..], ending); + ingest(pane, input); + try std.testing.expectEqualSlices(u8, input[7..], try replayBytes(pane, gpa)); + ingest(pane, "next\r\n"); + const wrapped = try replayBytes(pane, gpa); + defer gpa.free(wrapped); + try std.testing.expectEqual(capacity, wrapped.len); + try std.testing.expectEqualSlices(u8, input[7 + "next\r\n".len ..], wrapped[0 .. capacity - "next\r\n".len]); + try std.testing.expectEqualStrings("next\r\n", wrapped[capacity - "next\r\n".len ..]); +} + +test "replay restores terminal cells from the retained stream" { + if (comptime !enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + feedOutput(p, pane, "\x1b[31mred\x1b[0m\r\ncafé\r\n"); + const encoded = try dump.encodeBytes(gpa, try replayBytes(pane, gpa)); + defer gpa.free(encoded); + const restored = try restore(p, .{ + .kind = .terminal, + .tag = "", + .body = "", + .cols = pane.cols, + .rows = pane.rows, + .terminal = .{ .stream_b64 = encoded }, + }, false); + defer gpa.destroy(restored); + defer deinitEmulator(restored, gpa); + const before = try screenTextAlloc(pane, gpa); + defer gpa.free(before); + const after = try screenTextAlloc(restored, gpa); + defer gpa.free(after); + try std.testing.expectEqualStrings(before, after); + try std.testing.expectEqualSlices(u8, try replayBytes(pane, gpa), try replayBytes(restored, gpa)); +} + +test "a restored terminal keeps its history above a marker and gets a shell where it was" { + if (comptime !enabled or pardes.isolated) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + feedOutput(p, pane, "old output\r\n$ ls\r\n"); + try pane.setOwnedCwd("/tmp"); + try p.dumpState(); + const restored = try Pardes.initFromDump(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }, p.dump_out.?); + defer restored.deinit(); + var spawned = false; + while (restored.nextEffect()) |effect| switch (effect) { + .spawn => |sp| spawned = sp.pane == 0 and std.mem.eql(u8, sp.cwd.slice(), "/tmp"), + else => {}, + }; + try std.testing.expect(spawned); + const again = restored.panes[0].?; + try std.testing.expectEqual(.spawn, again.pending_command.wait); + const text = try screenTextAlloc(again, gpa); + defer gpa.free(text); + const old_at = std.mem.indexOf(u8, text, "old output") orelse return error.HistoryLost; + const mark_at = std.mem.indexOf(u8, text, "restored history") orelse return error.NoMarker; + try std.testing.expect(old_at < mark_at); +} + +test "an older dump comes back live from its rendered text, not its raw output tail" { + if (comptime !enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + // A tail cut mid-redraw: relative moves from a cursor it never set. + const tail = try dump.encodeBytes(gpa, "\x1b[7A\x1b[2Kgarbage\x1b[3B\x1b[9Gmore"); + defer gpa.free(tail); + const restored = try restore(p, .{ + .kind = .terminal, + .tag = "", + .body = "", + .cols = 40, + .rows = 12, + .terminal = .{ .stream = "$ ls\na.txt b.txt\n$ ", .stream_b64 = tail }, + }, true); + defer gpa.destroy(restored); + defer deinitEmulator(restored, gpa); + defer deinitPendingCommand(restored); + const text = try screenTextAlloc(restored, gpa); + defer gpa.free(text); + try std.testing.expect(std.mem.indexOf(u8, text, "$ ls\na.txt b.txt") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "garbage") == null); +} + +/// Record and parse one live pty read, invalidate its motion surface, and +/// follow it only when the body (possibly parked under a tag edit) is raw. +pub fn feedOutput(p: *Pardes, pane: *Pane, bytes: []const u8) void { + // There are no pty reads at all without an emulator to parse them into. + if (comptime !enabled) return; + if (pane.terminal == null) return; + const has_positions = pane.ovl != null or pane.cur_pinned or pane.vsel.active or + pane.msel.active or pane.nsel > 0 or pane.append_at != null or pane.look_at != null or + pane.ed_undo_len > 0 or pane.ed_redo_len > 0 or pointerRowCount(pane) > 0; + // Sticky bottom, as every other terminal does it: output drags the + // viewport down only when the viewport was already on the last row, so + // a reader scrolled back into the scrollback stays where they scrolled. + const at_bottom = atBottom(pane); + if (has_positions) ingestWithPositions(pane, bytes) else ingest(pane, bytes); + p.shell_rows.markStale(pane); + const body_mode = if (pane.tag_edit) pane.tag_mode else pane.mode; + if (body_mode == .tty and at_bottom) followOutput(pane); +} + +const RowPin = struct { pin: ?*ghostty_vt.Pin = null, offset: i32 = 0 }; +const PositionPin = struct { + target: *i32, + row: RowPin, + overlay: ?*EditBuffer, + edit_row: ?i32 = null, +}; + +fn trackRow(pages: *ghostty_vt.PageList, row: i32, failed: *bool) RowPin { + if (row < 0) return .{ .offset = row }; + const bounded: usize = @min(@as(usize, @intCast(row)), pages.total_rows - 1); + var left = pages.total_rows - 1 - bounded; + var node = pages.pages.last.?; + while (left >= node.rows()) { + left -= node.rows(); + node = node.prev.?; + } + const pin = pages.trackPin(.{ .node = node, .y = @intCast(node.rows() - 1 - left) }) catch { + failed.* = true; + return .{}; + }; + return .{ .pin = pin, .offset = row - @as(i32, @intCast(bounded)) }; +} + +fn pinnedRow(pages: ?*ghostty_vt.PageList, row: RowPin) i32 { + const pin = row.pin orelse return row.offset; + const alive = pages orelse return 0; + if (pin.garbage) return 0; + var after: usize = 0; + var node = alive.pages.last; + while (node) |item| : (node = item.prev) { + if (item == pin.node) + return @as(i32, @intCast(alive.total_rows - after - item.rows() + pin.y)) +| row.offset; + after += item.rows(); + } + return 0; +} + +fn trackPosition(pages: *ghostty_vt.PageList, target: *i32, overlay: ?*EditBuffer, failed: *bool) PositionPin { + var grid = target.*; + if (overlay) |edit| { + const lines: i32 = @intCast(modal.lineCount(edit.text)); + if (grid >= edit.row and grid < edit.row +| lines) + return .{ .target = target, .row = .{}, .overlay = edit, .edit_row = grid - edit.row }; + if (grid > edit.row) grid = grid -| lines +| edit.rows; + } + return .{ .target = target, .row = trackRow(pages, grid, failed), .overlay = overlay }; +} + +fn pointerRowCount(pane: *const Pane) usize { + var count: usize = 0; + for (pane.sel) |gesture| if (pane.pointerSelection(gesture)) |rows| { + count += rows.len; + }; + return count; +} + +fn ingestWithPositions(pane: *Pane, bytes: []const u8) void { + const screens = &pane.terminal.?.vt.screens; + const key = screens.active_key; + const generation = screens.generation(key); + const pages = &screens.active.pages; + const Group = struct { overlay: ?*EditBuffer, cursor: *i32, selection: *Pane.CharSel }; + var groups: [history_max * 2 + 1]Group = undefined; + groups[0] = .{ + .overlay = if (pane.ovl) |*overlay| overlay else null, + .cursor = &pane.cur_row, + .selection = &pane.vsel, + }; + var ngroups: usize = 1; + for ([_][]Snapshot{ pane.ed_undo[0..pane.ed_undo_len], pane.ed_redo[0..pane.ed_redo_len] }) |history| { + for (history) |*snapshot| { + groups[ngroups] = .{ + .overlay = if (snapshot.ovl) |*overlay| overlay else null, + .cursor = &snapshot.cur_row, + .selection = &snapshot.vsel, + }; + ngroups += 1; + } + } + const Span = struct { overlay: *EditBuffer, start: RowPin, end: RowPin }; + var spans: [groups.len]Span = undefined; + var nspans: usize = 0; + var positions: [groups.len * 2 + Pane.max_selections * 2 + 4]PositionPin = undefined; + var npositions: usize = 0; + var failed = false; + for (groups[0..ngroups]) |group| { + if (group.overlay) |overlay| { + spans[nspans] = .{ + .overlay = overlay, + .start = trackRow(pages, overlay.row, &failed), + .end = trackRow(pages, overlay.row +| (overlay.rows - 1), &failed), + }; + nspans += 1; + } + positions[npositions] = trackPosition(pages, group.cursor, group.overlay, &failed); + npositions += 1; + if (group.selection.active) { + positions[npositions] = trackPosition(pages, &group.selection.row, group.overlay, &failed); + npositions += 1; + } + } + const overlay = groups[0].overlay; + var extra: [Pane.max_selections * 2 + 4]*i32 = undefined; + var nextra: usize = 0; + if (pane.msel.active) { + extra[nextra] = &pane.msel.r0; + extra[nextra + 1] = &pane.msel.r1; + nextra += 2; + } + for (pane.sels[0..pane.nsel]) |*selection| { + extra[nextra] = &selection.row; + extra[nextra + 1] = &selection.arow; + nextra += 2; + } + if (pane.append_at) |*at| { + extra[nextra] = &at.row; + nextra += 1; + } + if (pane.look_at) |*at| { + extra[nextra] = &at.row; + nextra += 1; + } + for (extra[0..nextra]) |target| { + positions[npositions] = trackPosition(pages, target, overlay, &failed); + npositions += 1; + } + const pointer_positions = pane.gpa.alloc(PositionPin, pointerRowCount(pane)) catch null; + defer if (pointer_positions) |pins| pane.gpa.free(pins); + if (pointer_positions) |pins| { + var index: usize = 0; + for (pane.sel) |gesture| if (pane.pointerSelection(gesture)) |rows| { + for (rows) |*row| { + pins[index] = trackPosition(pages, &row.row, if (row.raw_terminal) null else overlay, &failed); + index += 1; + } + }; + } else failed = true; + ingest(pane, bytes); + // RIS can destroy the alternate screen and every pin it owned. + const alive = if (screens.generation(key) == generation) pages else null; + var moved = false; + var pointer_rows_lost = alive == null or pointer_positions == null; + if (pointer_positions) |pins| for (pins) |position| { + if (alive != null) if (position.row.pin) |pin| { + if (pin.garbage) pointer_rows_lost = true; + }; + }; + for (spans[0..nspans]) |span| { + const start = pinnedRow(alive, span.start); + const rows = @max(1, pinnedRow(alive, span.end) -| start +| 1); + moved = moved or start != span.overlay.row or rows != span.overlay.rows; + span.overlay.row = start; + span.overlay.rows = rows; + if (alive) |owner| { + if (span.start.pin) |pin| owner.untrackPin(pin); + if (span.end.pin) |pin| owner.untrackPin(pin); + } + } + for ([_][]PositionPin{ positions[0..npositions], pointer_positions orelse &.{} }) |batch| for (batch) |position| { + var row = pinnedRow(alive, position.row); + if (position.overlay) |edit| { + if (position.edit_row) |relative| { + row = edit.row +| relative; + } else if (row > edit.row) { + row = if (row < edit.row +| edit.rows) + edit.row + else + row -| edit.rows +| @as(i32, @intCast(modal.lineCount(edit.text))); + } + } + moved = moved or row != position.target.*; + position.target.* = row; + if (alive) |owner| if (position.row.pin) |pin| owner.untrackPin(pin); + }; + if (pointer_rows_lost) for (0..pane.sel.len) |button| { + pane.clearPointerSelection(button); + pane.sel[button].state = .none; + }; + if (moved) pane.nsel_snap = 0; // Saved regex-preview offsets name the old flat text. + if (failed) { + const message = "terminal edit position reset: out of memory"; + @memcpy(pane.msg[0..message.len], message); + pane.msg_len = message.len; + } +} + +test "raw terminal output needs no position tracking allocations" { + if (comptime !enabled) return error.SkipZigTest; + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(failing.allocator(), .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + const pane = p.panes[0].?; + pane.mode = .tty; + feedOutput(p, pane, "warm"); + const allocations = failing.alloc_index; + failing.fail_index = allocations; + const pins = pane.terminal.?.vt.screens.active.pages.countTrackedPins(); + feedOutput(p, pane, " output"); + try std.testing.expectEqual(allocations, failing.alloc_index); + try std.testing.expect(!failing.has_induced_failure); + try std.testing.expectEqual(pins, pane.terminal.?.vt.screens.active.pages.countTrackedPins()); +} + +pub fn promptInputReady(pane: *const Pane) bool { + if (comptime !enabled) return false; + const state = pane.terminal orelse return false; + return state.vt.screens.active_key != .alternate and + state.vt.screens.active.cursor.semantic_content == .input; +} + +pub const CommandOutput = struct { + /// Owned by the caller. + text: [:0]const u8, + /// Whether `text` starts where the command did. It does not when that + /// start is not there to read: cut off by `max_rows`, scrolled out of + /// the history (ghostty moved the pin to the oldest row it kept and + /// flagged it `garbage`), erased under it (`MarkedHandler.lost`), or + /// never pinned (a C or D that came on the alternate screen). + whole: bool, +}; + +/// What the newest finished command printed: the screen text between its +/// C and D marks, read from at most `max_rows` rows above its end, so a +/// command that printed the whole history costs no more than its tail. +pub fn commandOutput(pane: *Pane, gpa: std.mem.Allocator, max_rows: usize) !CommandOutput { + // Unless there is text to read, whether the nothing it printed is the + // whole of it. + const whole = if (comptime enabled) printed: { + const state = pane.terminal orelse break :printed true; + const marks = &state.stream.handler; + const start = marks.out_start orelse break :printed false; + const end = marks.out_end orelse break :printed false; + const screen = state.vt.screens.get(.primary) orelse break :printed false; + if (end.garbage) break :printed false; + var whole = !marks.lost and !start.garbage; + // D is where the next prompt begins: the output ends a cell before. + const last = end.leftWrap(1) orelse break :printed whole; + if (start.eql(end.*) or last.before(start.*)) break :printed whole; + var from = start.*; + if (last.up(max_rows)) |floor| if (from.before(floor)) { + from = floor; + from.x = 0; + whole = false; + }; + return .{ + .text = try screen.selectionString(gpa, .{ .sel = ghostty_vt.Selection.init(from, last, false) }), + .whole = whole, + }; + } else true; + return .{ .text = try gpa.dupeZ(u8, ""), .whole = whole }; +} + +/// At a prompt with nothing typed on its line: no input left of the +/// cursor and none under it (text typed and then Ctrl-A'd starts at the +/// cursor). Nothing further right counts: that is where fish draws a +/// right prompt, and fish suggests nothing on an empty line. +pub fn promptInputEmpty(pane: *const Pane) bool { + if (comptime !enabled) return false; + if (!promptInputReady(pane)) return false; + const cursor = &pane.terminal.?.vt.screens.active.cursor; + const row = cursor.page_pin.cells(.all); + for (row[0..@min(cursor.x + 1, row.len)]) |cell| + if (cell.semantic_content == .input and !cell.isEmpty()) return false; + return true; +} + +/// Encode one key for the program that owns a raw terminal and queue its pty +/// write, respecting the application's keyboard protocol and terminal modes. +pub fn forwardKey(p: *Pardes, id: usize, key: Key) void { + if (comptime enabled) { + const pane = p.panes[id] orelse return; + const state = pane.terminal orelse return; + const input = ghostty_vt.input; + const physical: input.Key = switch (key.cp) { + Key.enter => .enter, + Key.backspace => .backspace, + Key.tab => .tab, + Key.escape => .escape, + Key.up => .arrow_up, + Key.down => .arrow_down, + Key.left => .arrow_left, + Key.right => .arrow_right, + Key.home => .home, + Key.end => .end, + Key.page_up => .page_up, + Key.page_down => .page_down, + Key.delete => .delete, + else => if (key.cp < 128) input.Key.fromASCII(@intCast(key.cp)) orelse .unidentified else .unidentified, + }; + var utf8: [4]u8 = undefined; + const text_cp = if (key.shift and key.cp >= 'a' and key.cp <= 'z') key.cp - 'a' + 'A' else key.cp; + const text = if (key.text.len > 0) key.text else if (key.cp >= 32 and key.cp < Key.up and key.cp != Key.backspace) + utf8[0 .. std.unicode.utf8Encode(text_cp, &utf8) catch return] + else + ""; + var buffer: [256]u8 = undefined; + var writer = std.Io.Writer.fixed(&buffer); + input.encodeKey(&writer, .{ + .key = physical, + .mods = .{ .ctrl = key.ctrl, .alt = key.alt, .shift = key.shift }, + .consumed_mods = .{ .shift = key.text.len > 0 and key.shift }, + .utf8 = text, + .unshifted_codepoint = if (key.cp >= 'A' and key.cp <= 'Z') key.cp - 'A' + 'a' else if (key.cp < Key.up) key.cp else 0, + }, options: { + var opts = input.KeyEncodeOptions.fromTerminal(&state.vt); + // fromTerminal cannot know this one and says so, defaulting to + // "option is not alt" — right for an app that lets macOS do the + // unicode translation and hands the encoder the composed text. + // Every pardes shell resolves the modifier itself and hands the + // core the BASE key with the alt bit and no text at all (see + // PardesView.keyDown, and app.mjs doing the same with the same + // two bits), so by the time this runs alt already means alt. + // Left at the default, a macOS build drops the ESC prefix on + // the floor and forwards Alt-n to the child as a bare `n`. + opts.macos_option_as_alt = .true; + break :options opts; + }) catch return; + if (writer.end > 0) p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(writer.buffered()) } }); + return; + } + var control: [1]u8 = undefined; + const bytes: ?[]const u8 = blk: { + if (key.ctrl) { + if (key.cp >= 'a' and key.cp <= 'z') { + control[0] = @intCast(key.cp - 0x60); + break :blk control[0..1]; + } + // ASCII @, A-Z, [, \, ], ^ and _ are one contiguous control range. + if (key.cp >= '@' and key.cp <= '_') { + control[0] = @intCast(key.cp - 0x40); + break :blk control[0..1]; + } + } + if (key.text.len > 0) break :blk key.text; + break :blk switch (key.cp) { + Key.enter => "\r", + Key.backspace => "\x7f", + Key.tab => "\t", + Key.escape => "\x1b", + Key.up => "\x1b[A", + Key.down => "\x1b[B", + Key.right => "\x1b[C", + Key.left => "\x1b[D", + Key.delete => "\x1b[3~", + else => null, + }; + }; + if (bytes) |encoded| + p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(encoded) } }); +} + +pub fn enterTty(p: *Pardes, id: usize) void { + const pane = p.panes[id] orelse return; + const selected = pane.rawPointerText(pane.sel[@intFromEnum(config.select_button)]) != null; + if (comptime enabled) if (!selected and pane.terminal != null and pane.cur_pinned and pane.terminal.?.vt.cursorIsAtPrompt()) handoff: { + const screen = pane.terminal.?.vt.screens.active; + const goff: i32 = @intCast(screen.pages.scrollbar().offset); + const vp_row = pane.gridRow(pane.cur_row) - goff; + if (vp_row < 0) break :handoff; + + var grid_col: i32 = @max(0, pane.cur_col); + if (screen.pages.pin(.{ .viewport = .{ .x = 0, .y = @intCast(vp_row) } })) |row_pin| { + if (row_pin.rowAndCell().row.semantic_prompt != .none) switch (promptCut(row_pin)) { + .cut => |cols| grid_col += @intCast(cols), + .keep, .blank => {}, + }; + } + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intCast(grid_col), .y = @intCast(vp_row) }, + }) orelse break :handoff; + const cursor_pin = screen.cursor.page_pin.*; + var prompts = cursor_pin.promptIterator(.left_up, null); + const prompt_pin = prompts.next() orelse break :handoff; + if (click_pin.before(prompt_pin)) break :handoff; + const moves = screen.promptClickMove(click_pin); + for (0..moves.left) |_| p.emitWrite(id, "\x1b[D"); + for (0..moves.right) |_| p.emitWrite(id, "\x1b[C"); + }; + + pane.mode = .tty; + followOutput(pane); // entering raw mode is a request for the live shell + pane.msel.active = false; + pane.vsel.active = false; + pane.nsel = 0; + // The raw renderer follows the live cursor. A captured mouse selection + // keeps its modal endpoint for the next return to editor mode. + pane.cur_pinned = selected; + pane.select = false; + pane.append_at = null; + pane.sticky_col = -1; + pane.normal.clear(); +} + +// Returned slices remain valid until the next cache rebuild or reset. +pub const RowsCache = struct { + /// whose grid this describes; null = the slot is free + pane: ?*const Pane = null, + /// the rows joined by '\n' — `flatSurface` hands this back verbatim + /// instead of rebuilding the join on every keystroke + text: []const u8 = &.{}, + /// slices INTO `text`, absolute grid rows from 0 + rows: [][]const u8 = &.{}, + /// `text` is a prefix of this: blanking a prompt row shortens the join, + /// and the slack is not worth a second allocation to reclaim + text_alloc: []u8 = &.{}, + stale: bool = false, + + pub fn reset(c: *RowsCache, gpa: std.mem.Allocator) void { + if (c.text_alloc.len > 0) gpa.free(c.text_alloc); + if (c.rows.len > 0) gpa.free(c.rows); + c.* = .{}; + } + + /// Free a stale entry. Called at the top of `update`, and nowhere else. + pub fn sweep(c: *RowsCache, gpa: std.mem.Allocator) void { + if (c.stale) c.reset(gpa); + } + + /// `pane`'s grid moved: the entry no longer describes it. + pub fn markStale(c: *RowsCache, pane: *const Pane) void { + if (c.pane == pane) c.stale = true; + } + + pub fn dropPane(c: *RowsCache, pane: *const Pane) void { + if (c.pane != pane) return; + c.pane = null; + c.stale = true; + } +}; + +const Rows = struct { + text_alloc: []u8, + text: []const u8, + rows: [][]const u8, +}; + +const empty_grid = [1][]const u8{""}; + +/// What LEAVING raw tty mode does to one prompt row, decided from its cells +/// alone. See config.tty_blank for why any of this happens. +const PromptCut = union(enum) { + /// show the row exactly as ghostty dumped it + keep, + /// show nothing at all + blank, + /// drop this many leading COLUMNS — the prompt — and keep the rest, which + /// is what was typed at it + cut: usize, +}; + +fn promptCut(pin: ghostty_vt.Pin) PromptCut { + if (config.tty_blank == .prompt_and_input) return .blank; + const cells = pin.cells(.all); + var cols: usize = 0; + while (cols < cells.len and cells[cols].semantic_content == .prompt) cols += 1; + // Flagged, but with no prompt cells at the FRONT: a right-side prompt, or + // a repaint that has moved on. Nothing here is the prompt, so hide nothing. + if (cols == 0) return .keep; + // ...and all prompt, nothing typed yet: the row is chrome end to end. + if (cols >= cells.len) return .blank; + return .{ .cut = cols }; +} + +pub fn promptPrefixBytes(pane: *Pane, row: i32, raw: []const u8) usize { + if (comptime !enabled) return 0; + if (row < 0) return 0; + const state = pane.terminal orelse return 0; + const pin = state.vt.screens.active.pages.pin(.{ .screen = .{ .x = 0, .y = @intCast(row) } }) orelse return 0; + if (pin.rowAndCell().row.semantic_prompt == .none) return 0; + return switch (promptCut(pin)) { + .keep => 0, + .blank => raw.len, + .cut => |cols| @min(raw.len, File.rawAtDisplay(raw, cols)), + }; +} + +fn promptRow(pin: ghostty_vt.Pin, raw: []const u8) []const u8 { + const cols = switch (promptCut(pin)) { + .keep => return raw, + .blank => return "", + .cut => |n| n, + }; + const cells = pin.cells(.all); + var at: usize = 0; + var col: usize = 0; + while (col < cols and at < raw.len) { + const cell = &cells[col]; + at = @min(raw.len, at + dumpedBytes(pin, cell)); + // the tail cell of a wide glyph spells nothing of its own + col += if (cell.wide == .wide) @as(usize, 2) else 1; + } + return std.mem.trimEnd(u8, raw[at..], " \t"); +} + +fn dumpedBytes(pin: ghostty_vt.Pin, cell: *const ghostty_vt.Cell) usize { + switch (cell.wide) { + .spacer_head, .spacer_tail => return 0, + .narrow, .wide => {}, + } + var n: usize = switch (cell.content_tag) { + .codepoint, .codepoint_grapheme => std.unicode.utf8CodepointSequenceLength( + cell.codepoint(), + ) catch 1, + // A cell carrying only a colour still spells one blank in the dump. + else => 1, + }; + if (cell.content_tag == .codepoint_grapheme) { + if (pin.grapheme(cell)) |extra| for (extra) |cp| { + n += std.unicode.utf8CodepointSequenceLength(cp) catch 1; + }; + } + return n; +} + +pub fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 { + if (comptime !enabled) return &empty_grid; + if (pane.terminal == null) return &empty_grid; + const c = &p.shell_rows; + if (!c.stale and c.pane == pane) return c.rows; + if (c.pane != null) { + c.stale = true; + return (try buildRows(p.scratch.allocator(), p, pane)).rows; + } + const built = try buildRows(p.gpa, p, pane); + c.* = .{ + .pane = pane, + .text = built.text, + .rows = built.rows, + .text_alloc = built.text_alloc, + }; + return c.rows; +} + +/// The full modal motion surface: shell history with the live edit overlay +/// spliced into the rows it covers. +pub fn cursorLines(p: *Pardes, pane: *Pane) ![]const []const u8 { + const rows = try shellRows(p, pane); + if (pane.ovl == null) return rows; + var last = rows.len; + if (pane.ovl) |overlay| + last = @max(last, @as(usize, @intCast(@max(0, overlay.row + overlay.rows)))); + var count = last; + if (pane.ovl) |overlay| { + if (overlay.row >= 0 and @as(usize, @intCast(overlay.row)) < last) + count = count - @min( + @as(usize, @intCast(overlay.rows)), + last - @as(usize, @intCast(overlay.row)), + ) + @max(1, modal.lineCount(overlay.text)); + } + const lines = try p.scratch.allocator().alloc([]const u8, count); + var n: usize = 0; + var grid_row: usize = 0; + while (grid_row < last) : (grid_row += 1) { + if (pane.ovl) |overlay| if (overlay.row >= 0 and grid_row == @as(usize, @intCast(overlay.row))) { + var overlay_lines = std.mem.splitScalar(u8, overlay.text, '\n'); + while (overlay_lines.next()) |line| : (n += 1) lines[n] = line; + grid_row += @intCast(overlay.rows - 1); + continue; + }; + lines[n] = if (grid_row < rows.len) rows[grid_row] else ""; + n += 1; + } + return lines[0..n]; +} + +/// Flatten `cursorLines` without rebuilding the common cached/no-overlay +/// case. Scratch-owned when a join is required. +pub fn flatSurface(p: *Pardes, pane: *Pane, lines: []const []const u8) ![]const u8 { + const cache = &p.shell_rows; + if (!cache.stale and cache.pane == pane and + lines.ptr == cache.rows.ptr and lines.len == cache.rows.len) return cache.text; + var total: usize = if (lines.len > 0) lines.len - 1 else 0; + for (lines) |line| total += line.len; + const text = try p.scratch.allocator().alloc(u8, total); + var at: usize = 0; + for (lines, 0..) |line, i| { + if (i > 0) { + text[at] = '\n'; + at += 1; + } + @memcpy(text[at..][0..line.len], line); + at += line.len; + } + return text; +} + +/// Materialize or extend the terminal edit overlay with one exact allocation. +/// Row slices are scratch-owned/borrowed; only the joined text is installed. +pub fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?EditText { + const want_lo = @max(0, @min(lo, hi)); + const want_hi = @max(want_lo, @max(lo, hi)); + const fresh = pane.ovl == null; + const old: EditBuffer = pane.ovl orelse .{ .row = want_lo, .rows = 1, .text = &.{} }; + const lines: i32 = if (fresh) 1 else @intCast(modal.lineCount(old.text)); + const up = old.row - want_lo; + const down = want_hi - (old.row + lines - 1); + const extending = fresh or up > 0 or down > 0; + var row0 = old.row; + var covered = old.rows; + var text = old.text; + var owned = false; + if (extending) { + const rows = shellRows(p, pane) catch return null; + row0 = old.row - @max(0, up); + covered = old.rows + @max(0, up) + @max(0, down); + const up_len: usize = @intCast(@max(0, up)); + const down_len: usize = @intCast(@max(0, down)); + const parts = p.scratch.allocator().alloc([]const u8, up_len + 1 + down_len) catch return null; + for (parts[0..up_len], 0..) |*part, i| { + const src = @as(usize, @intCast(row0)) + i; + part.* = if (src < rows.len) rows[src] else ""; + } + const middle: usize = @intCast(old.row); + parts[up_len] = if (fresh) + (if (middle < rows.len) rows[middle] else "") + else + old.text; + for (parts[up_len + 1 ..], 0..) |*part, i| { + const src = @as(usize, @intCast(old.row + old.rows)) + i; + part.* = if (src < rows.len) rows[src] else ""; + } + text = std.mem.join(p.gpa, "\n", parts) catch return null; + owned = true; + } + + const row: usize = @intCast(@max(0, want_lo - row0)); + const line_len: i32 = @intCast(modal.lineSlice(text, row).len); + if (col > line_len) { + const spaces = p.scratch.allocator().alloc(u8, @intCast(col - line_len)) catch { + if (owned) p.gpa.free(text); + return null; + }; + @memset(spaces, ' '); + const padded = modal.insertAt(p.gpa, text, .{ .row = row, .col = @intCast(line_len) }, spaces) catch { + if (owned) p.gpa.free(text); + return null; + }; + if (owned) p.gpa.free(text); + text = padded; + owned = true; + } + if (owned) { + if (pane.ovl) |overlay| p.gpa.free(overlay.text); + pane.ovl = .{ .row = row0, .rows = covered, .text = text }; + } + return .{ .text = pane.ovl.?.text, .row0 = pane.ovl.?.row }; +} + +/// Consume a rewritten overlay, freeing the terminal edit text it replaces. +pub fn setEditText(p: *Pardes, pane: *Pane, new: []u8) void { + const overlay = if (pane.ovl) |*value| value else return p.gpa.free(new); + for (0..pane.sel.len) |button| { + pane.clearPointerSelection(button); + pane.sel[button].state = .none; + } + p.gpa.free(overlay.text); + overlay.text = new; +} + +/// The shell's screen and scrollback (the primary screen, even under a +/// full-screen program) as VT that replays to what was shown: styled +/// text, soft-wrapped lines rejoined so they reflow at another width. +fn screenVt(pane: *const Pane, arena: std.mem.Allocator) ![]const u8 { + const screen = pane.terminal.?.vt.screens.get(.primary) orelse return &.{}; + var out: std.Io.Writer.Allocating = .init(arena); + try ghostty_vt.formatter.ScreenFormatter.init(screen, .{ .emit = .vt, .unwrap = true }).format(&out.writer); + return out.written(); +} + +/// Serialize terminal-only state; the core supplies shared pane metadata. +pub fn dumpPane( + pane: *Pane, + arena: std.mem.Allocator, + tag: []const u8, + body: []const u8, + scroll: usize, +) !dump.Pane { + if (!enabled or pane.terminal == null) { + const text = if (pane.ovl) |overlay| overlay.text else ""; + return .{ + .kind = .terminal, + .tag = tag, + .body = body, + .scroll = scroll, + .cols = pane.cols, + .rows = pane.rows, + .vweight = pane.vweight, + .collapsed = pane.collapsed, + .terminal = .{ + .cwd = try arena.dupe(u8, pane.cwdSlice()), + .stream = try arena.dupe(u8, text), + .stream_b64 = &.{}, + .cursor = .{ .col = 0, .row = 0 }, + }, + }; + } + const full = try pane.terminal.?.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} }); + const extra = if (pane.ovl) |overlay| overlay.text.len else 0; + const stream = try arena.alloc(u8, try std.math.add(usize, full.len, extra)); + var len: usize = 0; + var lines = std.mem.splitAny(u8, full, "\n"); + var prompts = pane.terminal.?.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); + var row: i32 = 0; + var skip: i32 = 0; + while (lines.next()) |raw| : (row += 1) { + // Hidden overlay rows still consume prompt pins to keep them aligned. + const prompt = if (pane.mode != .tty) prompts.next() else null; + if (skip > 0) { + skip -= 1; + continue; + } + if (row > 0) { + stream[len] = '\n'; + len += 1; + } + if (pane.mode != .tty) if (pane.ovl) |overlay| if (row == overlay.row) { + @memcpy(stream[len..][0..overlay.text.len], overlay.text); + len += overlay.text.len; + skip = overlay.rows - 1; + continue; + }; + const shown = if (prompt) |pin| + if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, raw) else raw + else + raw; + @memcpy(stream[len..][0..shown.len], shown); + len += shown.len; + } + return .{ + .kind = .terminal, + .tag = tag, + .body = body, + .scroll = scroll, + .cols = pane.cols, + .rows = pane.rows, + .vweight = pane.vweight, + .collapsed = pane.collapsed, + .terminal = .{ + .cwd = try arena.dupe(u8, pane.cwdSlice()), + .stream = stream[0..len], + .stream_b64 = try dump.encodeBytes(arena, try screenVt(pane, arena)), + .stream_is_screen = true, + .cursor = .{ + .col = pane.terminal.?.vt.screens.active.cursor.x, + .row = pane.terminal.?.vt.screens.active.cursor.y, + }, + }, + }; +} + +fn buildRows(alloc: std.mem.Allocator, p: *Pardes, pane: *Pane) !Rows { + const full = try pane.terminal.?.vt.screens.active.dumpStringAlloc(p.scratch.allocator(), .{ .screen = .{} }); + var pit = pane.terminal.?.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); + // split yields one more item than delimiters; the extra final slot is the + // cursor row retained below. + const n_rows = std.mem.count(u8, full, "\n") + 2; + const rows = try alloc.alloc([]const u8, n_rows); + errdefer alloc.free(rows); + // Blanking a prompt row only ever SHORTENS it and the retained cursor row + // adds one separator, so the dump's length plus one bounds the join. + const text = try alloc.alloc(u8, full.len + 1); + errdefer alloc.free(text); + var at: usize = 0; + var n: usize = 0; + var it = std.mem.splitScalar(u8, full, '\n'); + while (it.next()) |raw| { + const shown = if (pit.next()) |pin| + if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, raw) else raw + else + raw; + if (n > 0) { + text[at] = '\n'; + at += 1; + } + @memcpy(text[at..][0..shown.len], shown); + rows[n] = text[at..][0..shown.len]; + at += shown.len; + n += 1; + } + text[at] = '\n'; + at += 1; + rows[n] = text[at..][0..0]; + n += 1; + std.debug.assert(n == n_rows); + return .{ .text_alloc = text, .text = text[0..at], .rows = rows }; +} + +pub fn bodyText(arena: std.mem.Allocator, pane: *Pane) ![]const u8 { + const vp: []const []const u8 = if (comptime !enabled) &.{} else vp: { + const state = pane.terminal orelse break :vp &.{}; + const screen = state.vt.screens.active; + var tl = screen.pages.getTopLeft(.viewport); + tl.x = 0; + const br = screen.pages.getBottomRight(.viewport) orelse return error.UnknownPoint; + var rows_out: std.Io.Writer.Allocating = .init(arena); + try screen.dumpString(&rows_out.writer, .{ .tl = tl, .br = br, .unwrap = false }); + const raw = try rows_out.toOwnedSlice(); + var prompts = screen.pages.rowIterator(.right_down, .{ .viewport = .{} }, null); + const vp = try arena.alloc([]const u8, std.mem.count(u8, raw, "\n") + 1); + var lines = std.mem.splitScalar(u8, raw, '\n'); + var n: usize = 0; + while (lines.next()) |ln| { + vp[n] = if (pane.mode != .tty) + if (prompts.next()) |pin| + if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, ln) else ln + else + ln + else + ln; + n += 1; + } + std.debug.assert(n == vp.len); + break :vp vp; + }; + + const len = fillBody(null, pane, vp); + const out = try arena.alloc(u8, len); + const filled = fillBody(out, pane, vp); + std.debug.assert(filled == out.len); + return out; +} + +pub const BodyRow = union(enum) { + grid: i32, + edit: struct { line: []const u8, idx: usize }, +}; + +const BodyWalk = struct { + pane: *Pane, + goff: i32, + g: i32, + /// the buffer can start above the viewport: drop the lines scrolled past + skip: usize, + n: usize = 0, + lines: ?std.mem.SplitIterator(u8, .scalar) = null, + covered: i32 = 0, + line_idx: usize = 0, + + fn init(pane: *Pane) BodyWalk { + const off = pane.scroll(); + const goff = gridOffset(pane); + return .{ + .pane = pane, + .goff = goff, + .g = if (pane.mode == .tty) goff else pane.gridRow(off), + .skip = if (pane.ovl) |o| @intCast(@max(0, off - pane.surfRow(o.row))) else 0, + }; + } + + fn next(w: *BodyWalk) ?BodyRow { + while (w.n < w.pane.rows) { + if (w.lines) |*it| { + if (it.next()) |line| { + const idx = w.line_idx; + w.line_idx += 1; + // Lines scrolled off the top still count: the index names a + // line of the BUFFER, not of the visible body. + if (w.skip > 0) { + w.skip -= 1; + continue; + } + w.n += 1; + return .{ .edit = .{ .line = line, .idx = idx } }; + } + // The buffer stands in for `rows` shell rows however many lines + // it actually spelled, which is the whole slide. + w.g += w.covered; + w.skip = 0; + w.lines = null; + continue; + } + if (w.pane.mode != .tty) if (w.pane.ovl) |o| if (w.g == o.row) { + w.lines = std.mem.splitScalar(u8, o.text, '\n'); + w.covered = o.rows; + w.line_idx = 0; + continue; + }; + const vi = w.g - w.goff; + w.g += 1; + w.n += 1; + return .{ .grid = vi }; + } + return null; + } +}; + +/// Run the terminal body row walk. A null destination counts bytes; a slice +/// fills the exact allocation made from that count. +fn fillBody(dst: ?[]u8, pane: *Pane, viewport: []const []const u8) usize { + var walk: BodyWalk = .init(pane); + var written: usize = 0; + var first = true; + while (walk.next()) |row| { + if (!first) { + if (dst) |out| out[written] = '\n'; + written += 1; + } + first = false; + const bytes = switch (row) { + .edit => |e| e.line, + .grid => |vi| if (vi >= 0 and @as(usize, @intCast(vi)) < viewport.len) + viewport[@intCast(vi)] + else + "", + }; + if (dst) |out| @memcpy(out[written..][0..bytes.len], bytes); + written += bytes.len; + } + return written; +} + +// Match surviving edit-buffer rows to their original terminal styles. +const EditAnchors = struct { + /// the buffer's own text, walked in order: a line the VIEWPORT skipped still + /// consumes the row it came from, so the lines below it stay aligned + text: []const u8 = &.{}, + at: usize = 0, + shell: []const []const u8 = &.{}, + /// the covered span, absolute grid rows, as `[first, end)` + first: usize = 0, + end: usize = 0, + lines: usize = 0, + /// the line `at` names, and the first row still unclaimed + idx: usize = 0, + cursor: usize = 0, + budget: usize = 0, + active: bool = false, + + fn init(p: *Pardes, pane: *Pane, o: EditBuffer) EditAnchors { + if (o.rows <= 0 or o.row < 0) return .{}; + const first: usize = @intCast(o.row); + const screen = pane.terminal.?.vt.screens.active; + const total = screen.pages.scrollbar().total; + if (first >= total) return .{}; + const covered: usize = @intCast(o.rows); + const count = @min(covered, total - first); + const tl = screen.pages.pin(.{ .screen = .{ .y = @intCast(first) } }) orelse return .{}; + const br = screen.pages.pin(.{ .screen = .{ + .x = screen.pages.cols - 1, + .y = @intCast(first + count - 1), + } }) orelse return .{}; + const arena = p.scratch.allocator(); + var output: std.Io.Writer.Allocating = .init(arena); + screen.dumpString(&output.writer, .{ .tl = tl, .br = br, .unwrap = false }) catch return .{}; + const shell = arena.alloc([]const u8, count) catch return .{}; + var raw = std.mem.splitScalar(u8, output.written(), '\n'); + var pins = tl.rowIterator(.right_down, br); + for (shell) |*row| { + const text = raw.next() orelse ""; + row.* = if (pins.next()) |pin| + if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, text) else text + else + text; + } + const lines = std.mem.count(u8, o.text, "\n") + 1; + return .{ + .text = o.text, + .shell = shell, + .first = first, + .end = first + count, + .lines = lines, + .cursor = first, + .budget = covered + 4 * lines, + .active = true, + }; + } + + fn shellRow(a: *EditAnchors, idx: usize) ?Anchor { + if (!a.active or idx >= a.lines) return null; + var found: ?Anchor = null; + while (a.idx <= idx) : (a.idx += 1) found = a.claim(a.nextLine() orelse return null); + return found; + } + + fn nextLine(a: *EditAnchors) ?[]const u8 { + if (a.at > a.text.len) return null; + const rest = a.text[a.at..]; + if (std.mem.indexOfScalar(u8, rest, '\n')) |n| { + a.at += n + 1; + return rest[0..n]; + } + // The last line has no terminator; one past the end ends the walk. + a.at = a.text.len + 1; + return rest; + } + + /// Where this line still stands over the grid, if anywhere. + fn claim(a: *EditAnchors, line: []const u8) ?Anchor { + const end = if (line.len == 0) @min(a.cursor + 1, a.end) else a.end; + var k = a.cursor; + while (k < end) : (k += 1) { + if (a.budget == 0) return null; + a.budget -= 1; + if (!std.mem.eql(u8, line, a.shell[k - a.first])) continue; + a.cursor = k + 1; + return .{ .row = @intCast(k) }; + } + if (a.cursor >= a.end) return null; + const shell = a.shell[a.cursor - a.first]; + var p: usize = 0; + while (p < line.len and p < shell.len and line[p] == shell[p]) p += 1; + var s: usize = 0; + const room = @min(line.len, shell.len) - p; + while (s < room and line[line.len - 1 - s] == shell[shell.len - 1 - s]) s += 1; + if (p + s == 0) return null; + if (line.len != p + s and shell.len - (p + s) > shell.len / 2) return null; + const row = a.cursor; + if (s > 0 or p >= shell.len) a.cursor += 1; + return .{ .row = @intCast(row), .prefix = p, .suffix = s, .shell_len = shell.len }; + } +}; + +const Anchor = struct { + /// the row, absolute while it comes from `EditAnchors`, viewport once + /// `recolorAnsi` has subtracted the walk's offset + row: i32, + prefix: usize = std.math.maxInt(usize), + suffix: usize = 0, + /// the row's own dumped length — what the suffix is measured from on the + /// GRID side, where the edit may have changed the byte count + shell_len: usize = 0, + + fn whole(an: Anchor) bool { + return an.prefix == std.math.maxInt(usize); + } +}; + +pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, body: []const u8) void { + // No emulator, no ANSI cells: the whole pass — and the 256-colour theme + // projection behind it — is compiled out. + if (comptime !enabled) return; + const s = &p.surface; + if (pane.terminal == null) return; + const body_y = p.bodyTop(r); + var filtered_storage: FilteredColors = undefined; + const filtered: ?*FilteredColors = if (pane.tty_filter) blk: { + const tz_filter = tracy.zone(@src(), "filterInit"); + defer tz_filter.end(); + filtered_storage = FilteredColors.init(p, pane); + break :blk &filtered_storage; + } else null; + // DECSCNM, read once: the filtered palette folds it in itself, the raw + // path needs it per cell. + const scnm = pane.terminal.?.vt.modes.get(.reverse_colors); + const pages = &pane.terminal.?.vt.screens.active.pages; + const vp_rows: i32 = @intCast(scrollbar(pane).len); + // Which buffer lines the user has not actually changed, so a row swallowed + // by a growing buffer keeps the colour it still stands over. + var anchors: ?EditAnchors = null; + var walk: BodyWalk = .init(pane); + var lines = std.mem.splitScalar(u8, body, '\n'); + var vr: u16 = 0; + while (walk.next()) |row| : (vr += 1) { + if (vr >= body_h) break; + // Before any early exit below, or the lines fall out of step with rows. + const text = lines.next() orelse ""; + const anchor: Anchor = switch (row) { + .edit => |e| blk: { + if (anchors == null) anchors = .init(p, pane, pane.ovl.?); + var an = anchors.?.shellRow(e.idx) orelse continue; + an.row -= walk.goff; + break :blk an; + }, + .grid => |v| .{ .row = v }, + }; + const vi = anchor.row; + if (vi < 0 or vi >= vp_rows) continue; + const row_pin = pages.pin(.{ .viewport = .{ .y = @intCast(vi) } }) orelse continue; + const cut: u16 = if (pane.mode == .tty) 0 else cut: { + if (row_pin.rowAndCell().row.semantic_prompt == .none) break :cut 0; + break :cut switch (promptCut(row_pin)) { + .keep => 0, + // Blanked end to end: the row shows nothing of the grid, so + // projecting the prompt's own colours onto it would be a lie. + .blank => continue, + .cut => |n| std.math.cast(u16, n) orelse continue, + }; + }; + if (cut > 0 and text.len == 0) continue; + var at: usize = 0; + var sc: u16 = 0; + var gc: u16 = cut; + var sb: usize = 0; + const mine_from = @min(anchor.prefix, text.len); + const mine_to = text.len - @min(anchor.suffix, text.len); + var crossed = false; + while (at < text.len and sc < tw) { + const stop = modal.nextGrapheme(text, at); + if (stop <= at) break; + const span: u16 = if (sc + 1 < tw and s.at(tx + sc + 1, body_y + vr).len == 0) 2 else 1; + if (at >= mine_from and at < mine_to) { + sc += span; + at = stop; + continue; + } + if (at >= mine_to and !crossed) { + crossed = true; + const upto = anchor.shell_len - @min(anchor.suffix, anchor.shell_len); + while (sb < upto) { + const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break; + sb += dumpedBytes(row_pin, ci.cell); + gc = std.math.add(u16, gc, 1) catch break; + } + } + const want = stop - at; + // Consume every cell that contributed to this grapheme. A cluster + // ghostty split across several cells is still ONE printed glyph. + var covered: usize = 0; + var style: ?pardes.CellStyle = null; + while (covered < want) { + const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break; + if (style == null and ci.cell.wide != .spacer_tail and ci.cell.wide != .spacer_head) + style = cellStyle(p, ci, filtered, scnm); + covered += dumpedBytes(row_pin, ci.cell); + gc = std.math.add(u16, gc, 1) catch break; + // A spacer contributes no bytes; without this the loop would + // spin on a row that ends in one. + if (covered == 0 and gc >= pane.cols) break; + } + while (pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } })) |t| { + if (t.cell.wide != .spacer_tail) break; + gc = std.math.add(u16, gc, 1) catch break; + } + if (style) |st| for (0..span) |k| { + const cell = s.at(tx + sc + @as(u16, @intCast(k)), body_y + vr); + if (cell.default and filtered == null) continue; + cell.default = false; + cell.style = st; + }; + sb += covered; + sc += span; + at = stop; + } + var tail: ?pardes.CellStyle = null; + if (anchor.whole() or anchor.suffix > 0) { + while (sc < tw) { + const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break; + gc = std.math.add(u16, gc, 1) catch break; + if (ci.cell.wide == .spacer_tail) continue; + const cell = s.at(tx + sc, body_y + vr); + sc += 1; + const style = cellStyle(p, ci, filtered, scnm); + tail = style; + if (cell.default and filtered == null) continue; + cell.default = false; + cell.style = style; + } + if (tail) |style| while (sc < tw) : (sc += 1) { + const cell = s.at(tx + sc, body_y + vr); + if (cell.default and filtered == null) continue; + cell.default = false; + cell.style = style; + }; + } + } +} + +fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell, filtered: ?*FilteredColors, scnm: bool) pardes.CellStyle { + const style = ci.style(); + var cs: pardes.CellStyle = .{ + .fg = if (filtered) |colors| colors.fg(style) else ghostColor(p, style.fg_color, scnm), + .bg = if (filtered) |colors| colors.bg(style, ci.cell) else ghostColor(p, style.bg_color, !scnm), + .bold = style.flags.bold, + .dim = style.flags.faint, + .italic = style.flags.italic, + .blink = style.flags.blink, + .reverse = style.flags.inverse, + .invisible = style.flags.invisible, + .strikethrough = style.flags.strikethrough, + .ul = switch (style.flags.underline) { + .none => .off, + .single => .single, + .double => .double, + .curly => .curly, + .dotted => .dotted, + .dashed => .dashed, + }, + }; + if (filtered == null) switch (ci.cell.content_tag) { + .bg_color_palette => cs.bg = palColor(p, ci.cell.content.color_palette.data), + .bg_color_rgb => { + const rgb = ci.cell.content.color_rgb; + cs.bg = .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }; + }, + else => {}, + }; + return cs; +} + +const FilteredColors = struct { + source: GColor.Palette, + target: *const GColor.Palette, + theme_bg: GColor.RGB, + theme_fg: GColor.RGB, + dynamic_bg: ?GColor.RGB, + dynamic_fg: ?GColor.RGB, + default_bg: GColor.RGB, + /// What a foreground too near `default_bg` becomes instead. + fallback_fg: GColor.RGB, + default_bg_luminance: f64, + fg_for_palette: [256]pardes.Color = undefined, + bg_for_palette: [256]pardes.Color = undefined, + /// The two answers for a cell that names no colour of its own. + fg_default: pardes.Color = undefined, + bg_default: pardes.Color = undefined, + cache_rgb: [256]GColor.RGB = undefined, + cache_key: [256]u8 = undefined, + cache_valid: [256]bool = @splat(false), + + fn init(p: *Pardes, pane: *const Pane) FilteredColors { + var source = GColor.default; + for (&source, 0..) |*rgb, i| + rgb.* = pane.terminal.?.vt.colorForXterm(.{ .palette = @intCast(i) }) orelse rgb.*; + const theme = p.theme(); + var theme_bg = asGhostRgb(theme.bg orelse theme.tag_bg); + var theme_fg = asGhostRgb(theme.fg orelse theme.tag_fg); + var dynamic_bg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .background }); + var dynamic_fg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .foreground }); + if (pane.terminal.?.vt.modes.get(.reverse_colors)) { + std.mem.swap(GColor.RGB, &theme_bg, &theme_fg); + std.mem.swap(?GColor.RGB, &dynamic_bg, &dynamic_fg); + } + var self: FilteredColors = .{ + .source = source, + .target = p.tty_filter_palette.get(theme), + .theme_bg = theme_bg, + .theme_fg = theme_fg, + .dynamic_bg = dynamic_bg, + .dynamic_fg = dynamic_fg, + .default_bg = theme_bg, + .fallback_fg = theme_fg, + .default_bg_luminance = luminanceOf(theme_bg), + }; + if (dynamic_bg) |rgb| self.default_bg = self.keyedRgb(rgb); + self.default_bg_luminance = luminanceOf(self.default_bg); + if (self.theme_bg.contrast(self.default_bg) > self.theme_fg.contrast(self.default_bg)) + self.fallback_fg = self.theme_bg; + + self.fg_default = asPardesColor(self.legible( + if (self.dynamic_fg) |rgb| self.keyedRgb(rgb) else self.theme_fg, + )); + self.bg_default = asPardesColor(self.default_bg); + for (&self.source, 0..) |current, i| { + const idx: u8 = @intCast(i); + const mapped = self.paletteRgb(idx, current); + self.fg_for_palette[idx] = asPardesColor(self.legible(mapped)); + self.bg_for_palette[idx] = asPardesColor(mapped); + } + return self; + } + + fn fg(self: *FilteredColors, style: ghostty_vt.Style) pardes.Color { + return switch (style.fg_color) { + .none => self.fg_default, + .palette => |idx| self.fg_for_palette[idx], + .rgb => asPardesColor(self.legible(self.keyedRgb(style.fg(.{ + .default = self.dynamic_fg orelse self.theme_fg, + .palette = &self.source, + .bold = null, + })))), + }; + } + + fn bg(self: *FilteredColors, style: ghostty_vt.Style, cell: *const ghostty_vt.Cell) pardes.Color { + switch (cell.content_tag) { + .bg_color_palette => return self.bg_for_palette[cell.content.color_palette.data], + .bg_color_rgb => {}, + else => switch (style.bg_color) { + .none => return self.bg_default, + .palette => |idx| return self.bg_for_palette[idx], + .rgb => {}, + }, + } + // Truecolour, from either the cell or its style. + return asPardesColor(self.keyedRgb(style.bg(cell, &self.source).?)); + } + + fn legible(self: *const FilteredColors, rgb: GColor.RGB) GColor.RGB { + if (contrastOf(luminanceOf(rgb), self.default_bg_luminance) >= + config.tty_filter_min_contrast) return rgb; + return self.fallback_fg; + } + + fn paletteRgb(self: *FilteredColors, idx: u8, current: GColor.RGB) GColor.RGB { + if (current.eql(GColor.default[idx])) return self.target[idx]; + return self.keyedRgb(current); + } + + fn keyedRgb(self: *FilteredColors, rgb: GColor.RGB) GColor.RGB { + return self.target[self.nearestKey(rgb)]; + } + + fn nearestKey(self: *FilteredColors, rgb: GColor.RGB) u8 { + const rgb24 = (@as(u32, rgb.r) << 16) | (@as(u32, rgb.g) << 8) | rgb.b; + const slot: u8 = @truncate((rgb24 *% 0x9e3779b1) >> 24); + if (self.cache_valid[slot] and self.cache_rgb[slot].eql(rgb)) + return self.cache_key[slot]; + + var best: u8 = 0; + var best_distance: u32 = std.math.maxInt(u32); + for (GColor.default, 0..) |candidate, i| { + const distance = colorDistance(rgb, candidate); + // Strict comparison makes duplicate-colour ties stable at the + // lowest canonical xterm key. + if (distance < best_distance) { + best_distance = distance; + best = @intCast(i); + } + } + self.cache_rgb[slot] = rgb; + self.cache_key[slot] = best; + self.cache_valid[slot] = true; + return best; + } +}; + +const channel_luminance: [256]f64 = blk: { + @setEvalBranchQuota(20000); + var table: [256]f64 = undefined; + for (&table, 0..) |*slot, c| { + const normalized: f64 = @as(f64, @floatFromInt(c)) / 255; + slot.* = if (normalized <= 0.03928) + normalized / 12.92 + else + std.math.pow(f64, (normalized + 0.055) / 1.055, 2.4); + } + break :blk table; +}; + +fn luminanceOf(rgb: GColor.RGB) f64 { + return 0.2126 * channel_luminance[rgb.r] + + 0.7152 * channel_luminance[rgb.g] + + 0.0722 * channel_luminance[rgb.b]; +} + +/// ghostty's `RGB.contrast` with both luminances already in hand. +fn contrastOf(a_luminance: f64, b_luminance: f64) f64 { + const lighter = @max(a_luminance, b_luminance); + const darker = @min(a_luminance, b_luminance); + return (lighter + 0.05) / (darker + 0.05); +} + +fn colorDistance(a: GColor.RGB, b: GColor.RGB) u32 { + const dr = @as(i32, a.r) - @as(i32, b.r); + const dg = @as(i32, a.g) - @as(i32, b.g); + const db = @as(i32, a.b) - @as(i32, b.b); + return @intCast(dr * dr + dg * dg + db * db); +} + +test "the luminance table answers exactly what ghostty computes" { + for (0..256) |i| { + const c: u8 = @intCast(i); + const grey: GColor.RGB = .{ .r = c, .g = c, .b = c }; + try std.testing.expectEqual(grey.luminance(), luminanceOf(grey)); + } + // Channel weights are asymmetric, so a grey ramp alone would not catch a + // transposed coefficient. The palette is what the tables enumerate. + for (GColor.default) |candidate| { + try std.testing.expectEqual(candidate.luminance(), luminanceOf(candidate)); + for (GColor.default) |page| { + try std.testing.expectEqual( + candidate.contrast(page), + contrastOf(luminanceOf(candidate), luminanceOf(page)), + ); + } + } +} + +test "terminal Filter keys indexed truecolor OSC and background-only cells through the theme" { + const testing = std.testing; + // Raw index assertions require a theme that inherits the ANSI palette. + const p = try Pardes.init(testing.allocator, .{ .startup_config = "Theme helix\n", .tty_only = true, .cols = 18, .rows = 6 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + try testing.expect(pane.tty_filter); + pane.tty_filter = false; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mA" ++ + "\x1b[38;5;196mB" ++ + "\x1b[38;2;255;0;0mC" ++ + "\x1b[0;48;5;25mD" ++ + "\x1b[0;48;2;0;95;175mE" ++ + "\x1b[0;1;2;3;4;5;7;8;9mF" ++ + "\x1b[0;48;5;25m\x1b[K" ++ + "\r\n\x1b[0;38;5;2m界" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + + const raw = try p.render(frame.allocator()); + try testing.expectEqual(pardes.Color{ .index = 1 }, raw.at(tx, body_y).style.fg); + try testing.expectEqual(pardes.Color{ .index = 196 }, raw.at(tx + 1, body_y).style.fg); + try testing.expectEqual(pardes.Color{ .rgb = .{ 255, 0, 0 } }, raw.at(tx + 2, body_y).style.fg); + + pane.tty_filter = true; + _ = frame.reset(.retain_capacity); + const filtered = try p.render(frame.allocator()); + var expected_cache: FilterPalette = .{}; + const expected = expected_cache.get(p.theme()); + try testing.expectEqual(asPardesColor(expected[1]), filtered.at(tx, body_y).style.fg); + try testing.expectEqual(asPardesColor(expected[196]), filtered.at(tx + 1, body_y).style.fg); + try testing.expectEqual(filtered.at(tx + 1, body_y).style.fg, filtered.at(tx + 2, body_y).style.fg); + try testing.expectEqual(asPardesColor(expected[25]), filtered.at(tx + 3, body_y).style.bg); + try testing.expectEqual(filtered.at(tx + 3, body_y).style.bg, filtered.at(tx + 4, body_y).style.bg); + + const attrs = filtered.at(tx + 5, body_y).style; + try testing.expect(attrs.bold); + try testing.expect(attrs.dim); + try testing.expect(attrs.italic); + try testing.expect(attrs.blink); + try testing.expect(attrs.reverse); + try testing.expect(attrs.invisible); + try testing.expect(attrs.strikethrough); + try testing.expectEqual(.single, attrs.ul); + + const erased = pane.terminal.?.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = 6, .y = 0 } }).?; + try testing.expectEqual(.bg_color_palette, erased.cell.content_tag); + try testing.expectEqual(asPardesColor(expected[25]), filtered.at(tx + 6, body_y).style.bg); + try testing.expectEqual(asPardesColor(expected[2]), filtered.at(tx, body_y + 1).style.fg); + try testing.expectEqual(filtered.at(tx, body_y + 1).style, filtered.at(tx + 1, body_y + 1).style); + // A filtered terminal never delegates either colour to a backend palette, + // including cells which were empty/default before the pass. + for (0..r.w - config.GUTTER) |col| { + const cell = filtered.at(tx + @as(u16, @intCast(col)), body_y); + try testing.expect(!cell.default); + switch (cell.style.fg) { + .rgb => {}, + else => return error.FilteredForegroundWasNotRgb, + } + switch (cell.style.bg) { + .rgb => {}, + else => return error.FilteredBackgroundWasNotRgb, + } + } + + // Colors remains the global master gate. The pane remembers Filter while + // ANSI projection is dormant, and resumes it without replaying VT bytes. + p.settings.colors = false; + _ = frame.reset(.retain_capacity); + const plain = try p.render(frame.allocator()); + try testing.expect(pane.tty_filter); + try testing.expectEqual(asPardesColor(asGhostRgb(p.theme().fg.?)), plain.at(tx, body_y).style.fg); + p.settings.colors = true; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]4;1;#ff0000\x1b\\" } }); + const osc_red = pane.terminal.?.vt.colorForXterm(.{ .palette = 1 }).?; + try testing.expect(osc_red.eql(.{ .r = 255, .g = 0, .b = 0 })); + pane.tty_filter = false; + pane.tty_filter = true; + try testing.expect(osc_red.eql(pane.terminal.?.vt.colorForXterm(.{ .palette = 1 }).?)); + _ = frame.reset(.retain_capacity); + const osc_palette = try p.render(frame.allocator()); + try testing.expectEqual(asPardesColor(expected[196]), osc_palette.at(tx, body_y).style.fg); + + // Dynamic default foreground/background colours key every default cell, + // including the otherwise blank end of the row. + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]10;#ff0000\x1b\\" ++ + "\x1b]11;#5f5f5f\x1b\\" } }); + const dyn_fg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .foreground }).?; + const dyn_bg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .background }).?; + try testing.expect(dyn_fg.eql(.{ .r = 255, .g = 0, .b = 0 })); + try testing.expect(dyn_bg.eql(.{ .r = 95, .g = 95, .b = 95 })); + _ = frame.reset(.retain_capacity); + const dynamic = try p.render(frame.allocator()); + const blank = dynamic.at(tx + r.w - config.GUTTER - 1, body_y + 2).style; + try testing.expectEqual(asPardesColor(expected[196]), blank.fg); + try testing.expectEqual(asPardesColor(expected[59]), blank.bg); + + const explicit_before_reverse = dynamic.at(tx, body_y).style.fg; + try testing.expectEqual(asPardesColor(expected[196]), explicit_before_reverse); + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } }); + _ = frame.reset(.retain_capacity); + const reversed = try p.render(frame.allocator()); + const reversed_blank = reversed.at(tx + r.w - config.GUTTER - 1, body_y + 2).style; + try testing.expectEqual(asPardesColor(expected[59]), reversed_blank.fg); + try testing.expectEqual(asPardesColor(expected[196]), reversed_blank.bg); + const reversed_explicit = reversed.at(tx, body_y).style; + try testing.expectEqual(asPardesColor(expected[196]), reversed_explicit.bg); + try testing.expectEqual(pardes.Color{ .rgb = p.theme().bg.? }, reversed_explicit.fg); + try testing.expect(!std.meta.eql(reversed_explicit.fg, reversed_explicit.bg)); + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5l" } }); + _ = frame.reset(.retain_capacity); + const unreversed = try p.render(frame.allocator()); + const unreversed_blank = unreversed.at(tx + r.w - config.GUTTER - 1, body_y + 2).style; + try testing.expectEqual(asPardesColor(expected[196]), unreversed_blank.fg); + try testing.expectEqual(asPardesColor(expected[59]), unreversed_blank.bg); + + // The cache is keyed by values, not a theme name. Replacing a custom + // theme in place immediately recolours already-rendered indexed cells. + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]104;1\x1b\\" } }); + var custom = p.theme().*; + custom.name = try p.gpa.dupe(u8, "same-name"); + custom.palette = null; + custom.kw = .{ 1, 2, 3 }; + p.custom_theme = custom; + _ = frame.reset(.retain_capacity); + const custom_first = try p.render(frame.allocator()); + try testing.expectEqual(pardes.Color{ .rgb = .{ 1, 2, 3 } }, custom_first.at(tx, body_y).style.fg); + if (p.custom_theme) |*theme| theme.kw = .{ 4, 5, 6 }; + _ = frame.reset(.retain_capacity); + const custom_second = try p.render(frame.allocator()); + try testing.expectEqual(pardes.Color{ .rgb = .{ 4, 5, 6 } }, custom_second.at(tx, body_y).style.fg); +} + +test "terminal Filter keeps extended keys dark-to-light on a light theme" { + const p = try Pardes.init(std.testing.allocator, .{ .startup_config = "Theme acme\n", .tty_only = true }); + defer p.deinit(); + try std.testing.expectEqualStrings("acme", p.theme().name); + var cache: FilterPalette = .{}; + const palette = cache.get(p.theme()); + try std.testing.expect(palette[16].eql(asGhostRgb(p.theme().fg.?))); + try std.testing.expect(palette[231].eql(asGhostRgb(p.theme().bg.?))); +} + +test "terminal Filter preserves exact palette-null light theme default roles" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 5 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + + var light = p.theme().*; + light.name = try p.gpa.dupe(u8, "filter-light-defaults"); + light.bg = .{ 0xf8, 0xf8, 0xf8 }; + light.fg = .{ 0x38, 0x38, 0x38 }; + light.palette = null; + p.custom_theme = light; + + const pane = p.panes[0].?; + try testing.expectEqual(@as(?GColor.RGB, null), pane.terminal.?.vt.colorForXterm(.{ .dynamic = .foreground })); + try testing.expectEqual(@as(?GColor.RGB, null), pane.terminal.?.vt.colorForXterm(.{ .dynamic = .background })); + pane.tty_filter = true; + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const ordinary = try p.render(frame.allocator()); + try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, ordinary.at(tx, body_y).style.fg); + try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, ordinary.at(tx, body_y).style.bg); + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } }); + _ = frame.reset(.retain_capacity); + const reversed = try p.render(frame.allocator()); + try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, reversed.at(tx, body_y).style.fg); + try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, reversed.at(tx, body_y).style.bg); +} + +test "terminal Filter maps the default roles before it maps anything else" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + try testing.expect(pane.tty_filter); + + // Native dark/light palettes plus legacy helix/dark/acme, including + // the null-page fallback that only the legacy dark theme exercises. + for (0..9) |t| { + p.settings.theme = @intCast(t); + const stage_one = FilteredColors.init(p, pane); + // `dark` declares no background of its own, which is exactly why the + // resolver reads the tag colours as the fallback rather than `.?`. + const theme = p.theme(); + try testing.expect(stage_one.theme_bg.eql(asGhostRgb(theme.bg orelse theme.tag_bg))); + try testing.expect(stage_one.theme_fg.eql(asGhostRgb(theme.fg orelse theme.tag_fg))); + // With no OSC 11 in play the mapped page IS that anchor, and the + // fallback is the other one: a background never contrasts with itself. + try testing.expect(stage_one.default_bg.eql(stage_one.theme_bg)); + try testing.expect(stage_one.fallback_fg.eql(stage_one.theme_fg)); + } + + p.settings.theme = 0; + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]11;#5f5f5f\x1b\\" } }); + var moved = FilteredColors.init(p, pane); + try testing.expect(!moved.default_bg.eql(moved.theme_bg)); + try testing.expect(moved.default_bg.eql(moved.keyedRgb(.{ .r = 0x5f, .g = 0x5f, .b = 0x5f }))); +} + +test "terminal Filter refuses a foreground that would collapse onto the page" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + try testing.expect(pane.tty_filter); + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[38;2;255;255;255mW" ++ + "\x1b[0;30mB" ++ + "\x1b[0;31mR" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + + for (0..9) |t| { + p.settings.theme = @intCast(t); + var fc = FilteredColors.init(p, pane); + const page = asPardesColor(fc.default_bg); + const rescued = asPardesColor(fc.fallback_fg); + _ = frame.reset(.retain_capacity); + const g = try p.render(frame.allocator()); + + // The colour each of the three would have been given with no floor. + const raw_white = fc.keyedRgb(.{ .r = 255, .g = 255, .b = 255 }); + const raw_black = fc.paletteRgb(0, GColor.default[0]); + const raw_red = fc.paletteRgb(1, GColor.default[1]); + + for ([_]struct { at: u16, raw: GColor.RGB }{ + .{ .at = 0, .raw = raw_white }, + .{ .at = 1, .raw = raw_black }, + .{ .at = 2, .raw = raw_red }, + }) |case| { + const cell = g.at(tx + case.at, body_y).style; + try testing.expectEqual(page, cell.bg); + if (case.raw.contrast(fc.default_bg) < config.tty_filter_min_contrast) { + // Refused: the projection's answer is not painted, the anchor is. + try testing.expectEqual(rescued, cell.fg); + try testing.expect(!std.meta.eql(cell.fg, cell.bg)); + } else { + // Cleared the floor, so stage two leaves it exactly alone. + try testing.expectEqual(asPardesColor(case.raw), cell.fg); + } + // Either way a filtered cell delegates neither colour to a backend. + switch (cell.fg) { + .rgb => |ink| try testing.expect(asGhostRgb(ink).contrast(fc.default_bg) >= + config.tty_filter_min_contrast), + else => return error.FilteredForegroundWasNotRgb, + } + } + + // At least one of the three has to have been a real collapse, or this + // theme proved nothing: white on the light theme, black on the dark. + try testing.expect(raw_white.contrast(fc.default_bg) < config.tty_filter_min_contrast or + raw_black.contrast(fc.default_bg) < config.tty_filter_min_contrast); + // A saturated red is never the page on any curated theme. + try testing.expect(raw_red.contrast(fc.default_bg) >= config.tty_filter_min_contrast); + } +} + +test "terminal Filter holds every projected foreground off the page" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + + for (0..9) |t| { + p.settings.theme = @intCast(t); + var fc = FilteredColors.init(p, pane); + var refused: usize = 0; + for (fc.target, 0..) |projected, key| { + const ink = fc.legible(projected); + try testing.expect(ink.contrast(fc.default_bg) >= config.tty_filter_min_contrast); + if (!ink.eql(projected)) { + refused += 1; + try testing.expect(ink.eql(fc.fallback_fg)); + // Only ever refused for being too near the page. + try testing.expect(projected.contrast(fc.default_bg) < config.tty_filter_min_contrast); + } + // The key a background asks for is handed back untouched, including + // the one whose value is the page itself. + try testing.expect(fc.keyedRgb(GColor.default[key]).eql(fc.target[fc.nearestKey(GColor.default[key])])); + } + // Every curated theme owns at least one collapsing key — that is why + // the floor exists — and the floor must not be flattening the palette. + try testing.expect(refused > 0); + try testing.expect(refused < fc.target.len / 8); + } +} + +test "tty ansi colors follow the prompt hug into normal mode" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32mPP\x1b]133;B\x1b\\\x1b[31mR\x1b[34mB\x1b[0m out" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const red: pardes.Color = .{ .rgb = p.theme().palette.?[1] }; + const blue: pardes.Color = .{ .rgb = p.theme().palette.?[4] }; + + // tty mode projects the emulator's ansi colours cell for cell. + pane.mode = .tty; + p.shell_rows.stale = true; + const tty = try p.render(frame.allocator()); + try testing.expectEqual(red, tty.at(tx + 2, body_y).style.fg); + try testing.expectEqual(blue, tty.at(tx + 3, body_y).style.fg); + + pane.mode = .normal; + p.shell_rows.stale = true; + _ = frame.reset(.retain_capacity); + const norm = try p.render(frame.allocator()); + try testing.expectEqualStrings("R", norm.at(tx, body_y).grapheme()); + try testing.expectEqualStrings("B", norm.at(tx + 1, body_y).grapheme()); + try testing.expectEqual(red, norm.at(tx, body_y).style.fg); + try testing.expectEqual(blue, norm.at(tx + 1, body_y).style.fg); +} + +test "a prompt row hidden end to end paints nothing at all" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 8 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + pane.mode = .normal; + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32maaaaaaaaa\x1b]133;B\x1b\\\x1b[41;36m\u{754C}\x1b[0m\r\n" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + p.shell_rows.stale = true; + const s = try p.render(frame.allocator()); + + try testing.expectEqualStrings(" ", s.at(tx, body_y).grapheme()); + try testing.expect(!std.meta.eql(pardes.Color{ .rgb = p.theme().palette.?[1] }, s.at(tx, body_y).style.bg)); +} + +pub fn palColor(p: *Pardes, idx: u8) pardes.Color { + if (p.theme().palette) |pal| if (idx < 16) return .{ .rgb = pal[idx] }; + return .{ .index = idx }; +} + +pub fn ghostColor(p: *Pardes, color: ghostty_vt.Style.Color, is_bg: bool) pardes.Color { + return switch (color) { + .none => blk: { + const t = if (is_bg) p.theme().bg else p.theme().fg; + break :blk if (t) |c| .{ .rgb = c } else .default; + }, + .palette => |idx| palColor(p, idx), + .rgb => |rgb| .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }, + }; +} + +/// executing at a prompt with typed text below it: pad the output area +/// with newlines so the command's output doesn't overwrite the buffer +pub fn padOutputBelowEdits(p: *Pardes, id: usize) void { + // Nothing to pad away from: with no emulator there is no prompt and no + // child whose output could land on top of the edit buffer. + if (comptime !enabled) return; + const pane = p.panes[id] orelse return; + const o = pane.ovl orelse return; + if (!pane.isTerminal()) return; + const state = pane.terminal orelse return; + if (!state.vt.cursorIsAtPrompt()) return; + // the buffer's LAST surface row: its lines may outnumber the shell + // rows it covers, and it is the bottom one output must clear + const max_row = o.row + @as(i32, @intCast(modal.lineCount(o.text))) - 1; + const goff: i32 = @intCast(state.vt.screens.active.pages.scrollbar().offset); + const cursor_abs = pane.surfRow(goff + @as(i32, @intCast(state.vt.screens.active.cursor.y))); + const pad = std.math.clamp(max_row - cursor_abs, 0, @as(i32, pane.rows)); + var i: i32 = 0; + while (i < pad) : (i += 1) p.emitWrite(id, "\r"); +} + +/// the snapshot takes ownership of a COPY of the edit buffer's text +pub fn snap(p: *Pardes, pane: *Pane) ?Snapshot { + var ovl: ?EditBuffer = null; + if (pane.ovl) |o| ovl = .{ .row = o.row, .rows = o.rows, .text = p.gpa.dupe(u8, o.text) catch return null }; + return .{ .ovl = ovl, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .vsel = pane.vsel }; +} + +/// undo/redo restores the selection recorded with the snapshot (helix +/// keeps selections in its history transactions) +pub fn restoreSnap(p: *Pardes, pane: *Pane, s: Snapshot) void { + if (pane.ovl) |o| p.gpa.free(o.text); + pane.ovl = s.ovl; + pane.cur_row = s.cur_row; + pane.cur_col = s.cur_col; + pane.cur_pinned = true; + pane.vsel = s.vsel; + pane.msel.active = false; + pane.ensureCursorVisible(); +} + +fn pushHistory(gpa: std.mem.Allocator, slots: []Snapshot, len: *usize, value: Snapshot) void { + if (len.* == slots.len) { + if (slots[0].ovl) |overlay| gpa.free(overlay.text); + std.mem.copyForwards(Snapshot, slots[0 .. slots.len - 1], slots[1..]); + len.* -= 1; + } + slots[len.*] = value; + len.* += 1; +} + +pub fn pushUndo(p: *Pardes, pane: *Pane) void { + const current = pane.ovl orelse EditBuffer{ .rows = 0 }; + if (pane.ed_undo_len > 0) { + const top = pane.ed_undo[pane.ed_undo_len - 1]; + const same = if (top.ovl) |overlay| pane.ovl != null and overlay.row == current.row and + overlay.rows == current.rows and std.mem.eql(u8, overlay.text, current.text) else pane.ovl == null; + if (same) return; + } + const value = snap(p, pane) orelse return; + pushHistory(p.gpa, &pane.ed_undo, &pane.ed_undo_len, value); + for (pane.ed_redo[0..pane.ed_redo_len]) |item| if (item.ovl) |overlay| p.gpa.free(overlay.text); + pane.ed_redo_len = 0; +} + +pub fn undo(p: *Pardes, pane: *Pane) void { + if (pane.ed_undo_len == 0) return; + const current = snap(p, pane) orelse return; + pushHistory(p.gpa, &pane.ed_redo, &pane.ed_redo_len, current); + pane.ed_undo_len -= 1; + restoreSnap(p, pane, pane.ed_undo[pane.ed_undo_len]); +} + +pub fn redo(p: *Pardes, pane: *Pane) void { + if (pane.ed_redo_len == 0) return; + const current = snap(p, pane) orelse return; + pushHistory(p.gpa, &pane.ed_undo, &pane.ed_undo_len, current); + pane.ed_redo_len -= 1; + restoreSnap(p, pane, pane.ed_redo[pane.ed_redo_len]); +} + +pub fn ptyReport(handler: *ghostty_vt.TerminalStream.Handler, data: [:0]const u8) void { + const state: *State = @alignCast(@fieldParentPtr("vt", handler.terminal)); + const room = state.reply.len - state.reply_len; + const n = @min(room, data.len); + @memcpy(state.reply[state.reply_len..][0..n], data[0..n]); + state.reply_len += @intCast(n); +} + +const DeviceAttrs = @typeInfo(@typeInfo(@typeInfo( + @FieldType(ghostty_vt.TerminalStream.Handler.Effects, "device_attributes"), +).optional.child).pointer.child).@"fn".return_type.?; +pub fn ptyDeviceAttrs(_: *ghostty_vt.TerminalStream.Handler) DeviceAttrs { + return .{}; +} @@ -40,6 +40,7 @@ const source_files = [_]Source{ .{ .path = "src/Output.zig", .contents = @embedFile("Output.zig") }, .{ .path = "src/Mini.zig", .contents = @embedFile("Mini.zig") }, .{ .path = "src/image.zig", .contents = @embedFile("image.zig") }, + .{ .path = "src/Terminal.zig", .contents = @embedFile("Terminal.zig") }, .{ .path = "src/layout.zig", .contents = @embedFile("layout.zig") }, .{ .path = "src/fs.zig", .contents = @embedFile("fs.zig") }, .{ .path = "src/look.zig", .contents = @embedFile("look.zig") }, diff --git a/src/panes.zig b/src/panes.zig index 051470b1..0f203e6f 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -3918,2532 +3918,7 @@ pub const Pdf = struct { } else struct {}; }; -pub const Terminal = struct { - pub const enabled = pardes.terminal_panes; - const ghostty_vt = if (enabled) @import("ghostty-vt") else struct {}; - - pub const history_max = if (enabled) 64 else 8; - - pub const VtSlot = if (enabled) ghostty_vt.Terminal else void; - pub const StreamSlot = if (enabled) ghostty_vt.Stream(MarkedHandler) else void; - - /// The `aid` pardes's injected bash and fish marks carry (host_io.zig). - pub const prompt_aid = "pardes"; - - /// ghostty's handler, plus where the shell is in its command cycle as - /// its OSC 133 marks tell it: ghostty parses D's exit status and drops - /// it, and /pane/<n>/pty/run waits on exactly that. Only marks carrying - /// pardes's own `aid` move it, so fish's native marks, a nested shell's - /// and a stray `133;D` in some file being printed all pass it by. - pub const MarkedHandler = if (enabled) struct { - inner: ghostty_vt.TerminalStream.Handler, - phase: enum { none, prompt, input, running } = .none, - /// Counts commands started (C at the input phase). - started: u32 = 0, - /// Counts prompts drawn (B), so a line the shell refused without - /// running (a fish syntax error: no C, no D, a new prompt) shows. - prompts: u32 = 0, - /// D marks outside a running command: the shell handled a line - /// without starting it (bash on a syntax error prints no C). - declined: u32 = 0, - /// The newest prompt continues an unfinished line (bash's PS2, marked - /// `k=c`): the shell still holds the start of the command. - continuation: bool = false, - /// Where the newest command's output began (its C) and ended (its - /// D) on the primary screen, pinned so scrolling keeps them. - out_start: ?*ghostty_vt.Pin = null, - out_end: ?*ghostty_vt.Pin = null, - /// The running command's output was erased under it -- a clear, ED 3, - /// a reset -- so the pins no longer span what it printed: ED 3 moves - /// a pin to the next page without flagging it `garbage` - /// (PageList.erasePage). - lost: bool = false, - - /// The newest command to end: which one, and its status if given. - finished: ?struct { seq: u32, status: ?i32 } = null, - - const Action = ghostty_vt.TerminalStream.Action; - - /// Move one of the output pins to the cursor. Only the primary screen - /// is pinned: a command's own full-screen display is not its output. - fn pinCursor(self: *@This(), slot: *?*ghostty_vt.Pin, now: bool) void { - const screen = self.inner.terminal.screens.get(.primary) orelse return; - if (slot.*) |old| screen.pages.untrackPin(old); - slot.* = null; - if (!now or self.inner.terminal.screens.active_key != .primary) return; - slot.* = screen.pages.trackPin(screen.cursor.page_pin.*) catch null; - } - - pub fn vt(self: *@This(), comptime action: Action.Tag, value: Action.Value(action)) void { - if (comptime action == .semantic_prompt) mark: { - const aid = value.readOption(.aid) orelse break :mark; - if (!std.mem.eql(u8, aid, prompt_aid)) break :mark; - switch (value.action) { - .fresh_line_new_prompt => { - self.phase = .prompt; - self.continuation = value.readOption(.prompt_kind) == .continuation; - }, - .end_prompt_start_input => { - self.phase = .input; - self.prompts +%= 1; - }, - // bash's DEBUG trap also fires on its own PROMPT_COMMAND, - // outside the input phase; only a C that ends input counts - .end_input_start_output => if (self.phase == .input) { - self.phase = .running; - self.started +%= 1; - self.lost = false; - self.pinCursor(&self.out_start, true); - self.pinCursor(&self.out_end, false); - }, - .end_command => if (self.phase == .running) { - self.phase = .none; - self.finished = .{ .seq = self.started, .status = value.readOption(.exit_code) }; - self.pinCursor(&self.out_end, true); - } else { - self.declined +%= 1; - }, - else => {}, - } - } - if (comptime action == .erase_display_complete or action == .erase_display_scrollback or action == .full_reset) { - if (self.phase == .running) self.lost = true; - } - self.inner.vt(action, value); - } - - pub fn deinit(self: *@This()) void { - self.inner.deinit(); - } - } else void; - - pub const Replay = struct { - bytes: [1024 * 1024]u8 = undefined, - head: usize = 0, - len: usize = 0, - }; - - pub const State = struct { - vt: VtSlot, - stream: StreamSlot, - replay: Replay, - reply: [256]u8 = undefined, - reply_len: u16 = 0, - }; - - const GColor = ghostty_vt.color; - - const FilterPaletteKey = struct { - bg: GColor.RGB, - fg: GColor.RGB, - base: [16]GColor.RGB, - }; - - pub const FilterPalette = if (enabled) LivePalette else struct {}; - - const LivePalette = struct { - key: ?FilterPaletteKey = null, - colors: GColor.Palette = GColor.default, - - fn get(self: *LivePalette, theme: *const pardes.Theme) *const GColor.Palette { - const bg = asGhostRgb(theme.bg orelse theme.tag_bg); - const fg = asGhostRgb(theme.fg orelse theme.tag_fg); - var base: [16]GColor.RGB = undefined; - if (theme.palette) |palette| { - for (&base, palette) |*dst, src| dst.* = asGhostRgb(src); - } else { - const synthesized = [16][3]u8{ - theme.bg orelse theme.tag_bg, - theme.kw, - theme.str, - theme.num, - theme.box, - theme.sel_bg, - theme.comment, - theme.fg orelse theme.tag_fg, - theme.lineno, - theme.kw, - theme.str, - theme.num, - theme.scroll_thumb, - theme.sel_fg, - theme.tag_fg, - theme.fg orelse theme.tag_fg, - }; - for (&base, synthesized) |*dst, src| dst.* = asGhostRgb(src); - } - - const key: FilterPaletteKey = .{ .bg = bg, .fg = fg, .base = base }; - if (self.key) |old| if (std.meta.eql(old, key)) return &self.colors; - - var seed = GColor.default; - for (base, 0..) |rgb, i| seed[i] = rgb; - self.colors = GColor.generate256Color(seed, .initEmpty(), bg, fg, false); - self.key = key; - return &self.colors; - } - }; - - fn asGhostRgb(rgb: [3]u8) GColor.RGB { - return .{ .r = rgb[0], .g = rgb[1], .b = rgb[2] }; - } - - fn asPardesColor(rgb: GColor.RGB) pardes.Color { - return .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }; - } - - /// The owned text standing in for `rows` live terminal rows, beginning at - /// absolute surface row `row`. The emulator grid remains untouched underneath. - pub const EditBuffer = struct { - row: i32 = 0, - rows: i32 = 1, - text: []u8 = &.{}, - }; - - /// One whole-state terminal edit boundary. Null means the pane has not yet - /// materialized an edit buffer; non-null snapshots own their text. - pub const Snapshot = struct { - ovl: ?EditBuffer, - cur_row: i32, - cur_col: i32, - vsel: Pane.CharSel, - }; - - pub const PendingCommand = struct { - bytes: []u8 = &.{}, - wait: enum { none, spawn, input } = .none, - }; - - pub fn armShellSpawn(pane: *Pane) void { - if (comptime !enabled) return; - std.debug.assert(pane.pending_command.bytes.len == 0); - pane.pending_command.wait = .spawn; - } - - pub fn queuePendingCommand(pane: *Pane, command: []const u8) !bool { - if (pane.pending_command.wait == .none) return false; - const old_len = pane.pending_command.bytes.len; - const new_len = try std.math.add(usize, old_len, try std.math.add(usize, command.len, 1)); - const bytes = if (old_len == 0) - try pane.gpa.alloc(u8, new_len) - else - try pane.gpa.realloc(pane.pending_command.bytes, new_len); - @memcpy(bytes[old_len..][0..command.len], command); - bytes[new_len - 1] = '\r'; - pane.pending_command.bytes = bytes; - pane.greet = false; - return true; - } - - pub fn shellSpawned(p: *Pardes, id: usize, prompt_marks: bool) void { - const pane = p.panes[id] orelse return; - if (!pane.isTerminal() or pane.pending_command.wait != .spawn) return; - if (prompt_marks) { - pane.pending_command.wait = .input; - releasePendingCommand(p, id, pane, false); - } else { - pane.greet = false; - releasePendingCommand(p, id, pane, true); - } - } - - pub fn releasePendingCommandIfReady(p: *Pardes, id: usize, pane: *Pane) void { - if (pane.pending_command.wait == .input) - releasePendingCommand(p, id, pane, promptInputReady(pane)); - } - - fn releasePendingCommand(p: *Pardes, id: usize, pane: *Pane, ready: bool) void { - if (!ready) return; - const bytes = pane.pending_command.bytes; - pane.pending_command = .{}; - if (bytes.len > 0) { - p.emitWrite(id, bytes); - pane.gpa.free(bytes); - } - } - - pub fn deinitPendingCommand(pane: *Pane) void { - if (pane.pending_command.bytes.len > 0) - pane.gpa.free(pane.pending_command.bytes); - pane.pending_command = .{}; - } - - pub fn terminalIo() std.Io { - return if (comptime !pardes.hosted) - std.Io.failing - else - std.Io.Threaded.global_single_threaded.io(); - } - - /// The three numbers ghostty's scrollbar reports; all zero without an emulator. - pub const Scrollbar = struct { total: usize = 0, offset: usize = 0, len: usize = 0 }; - - pub fn scrollbar(pane: *const Pane) Scrollbar { - if (comptime !enabled) return .{}; - const state = pane.terminal orelse return .{}; - const sb = state.vt.screens.active.pages.scrollbar(); - return .{ .total = sb.total, .offset = sb.offset, .len = sb.len }; - } - - /// The emulator's viewport offset, in SHELL rows: the top of what it shows. - pub fn gridOffset(pane: *const Pane) i32 { - return @intCast(scrollbar(pane).offset); - } - - /// Where the emulator itself puts the cursor, in active-area cells — the origin - /// without one, which is where an empty pane's cursor belongs anyway. - pub const GridCursor = struct { x: u16 = 0, y: u16 = 0 }; - - pub fn gridCursor(pane: *const Pane) GridCursor { - if (comptime !enabled) return .{}; - const state = pane.terminal orelse return .{}; - const cur = state.vt.screens.active.cursor; - return .{ .x = @intCast(cur.x), .y = @intCast(cur.y) }; - } - - pub fn visibleCursor(pane: *const Pane) ?GridCursor { - if (comptime !enabled) return null; - const state = pane.terminal orelse return null; - if (!state.vt.modes.get(.cursor_visible)) return null; - const cur = state.vt.screens.active.cursor; - const sb = scrollbar(pane); - const row = sb.total - sb.len + cur.y; - if (row < sb.offset or row - sb.offset >= sb.len) return null; - return .{ .x = @intCast(cur.x), .y = @intCast(row - sb.offset) }; - } - - /// Move the emulator's viewport by `delta` shell rows (negative scrolls back). - pub fn scrollGrid(pane: *Pane, delta: i32) void { - if (comptime !enabled) return; - const state = pane.terminal orelse return; - state.vt.screens.active.scroll(.{ .delta_row = delta }); - } - - /// Is the viewport already showing the last row of output? - pub fn atBottom(pane: *const Pane) bool { - if (comptime !enabled) return true; - const sb = scrollbar(pane); - return sb.offset + sb.len >= sb.total; - } - - /// Snap the viewport back onto live output. - pub fn followOutput(pane: *Pane) void { - if (comptime !enabled) return; - const state = pane.terminal orelse return; - state.vt.screens.active.scroll(.active); - } - - /// Reflow the grid. A failed reflow keeps the grid it had rather than dropping - /// a scrollback; the next resize retries with the same numbers. - pub fn resizeGrid(pane: *Pane, gpa: std.mem.Allocator, cols: u16, rows: u16) void { - if (comptime !enabled) return; - const state = pane.terminal orelse return; - if (cols != pane.cols) for (0..pane.sel.len) |button| { - pane.clearPointerSelection(button); - pane.sel[button].state = .none; - }; - state.vt.resize(gpa, .{ .cols = cols, .rows = rows }) catch {}; - } - - /// DECSET 2004: the program wants its pastes bracketed. - pub fn bracketedPaste(pane: *const Pane) bool { - if (comptime !enabled) return false; - const state = pane.terminal orelse return false; - return state.vt.modes.get(.bracketed_paste); - } - - /// The program tracks the mouse itself, so a click in its body is its event. - pub fn reportsMouse(pane: *const Pane) bool { - if (comptime !enabled) return false; - const state = pane.terminal orelse return false; - const m = &state.vt.modes; - return m.get(.mouse_event_normal) or m.get(.mouse_event_button) or m.get(.mouse_event_any); - } - - /// ...and wants them in SGR (1006) rather than the legacy X10 bytes. - pub fn mouseFormatSgr(pane: *const Pane) bool { - if (comptime !enabled) return false; - const state = pane.terminal orelse return false; - return state.vt.modes.get(.mouse_format_sgr); - } - - /// The whole scrollback as plain text, `gpa`-owned: what `Save` writes out. - pub fn screenTextAlloc(pane: *Pane, gpa: std.mem.Allocator) ![]const u8 { - if (comptime !enabled) return &.{}; - const state = pane.terminal orelse return &.{}; - return state.vt.screens.active.dumpStringAlloc(gpa, .{ .screen = .{} }); - } - - /// Release the emulator's heap. The Pane allocation itself is the core's. - pub fn deinitEmulator(pane: *Pane, gpa: std.mem.Allocator) void { - if (comptime !enabled) return; - const state = pane.terminal orelse return; - state.stream.deinit(); - state.vt.deinit(gpa); - gpa.destroy(state); - pane.terminal = null; - } - - /// Allocate the live emulator half of a terminal pane. Slot ownership, serial - /// assignment, and spawn effects remain core lifecycle invariants. - pub fn create(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane { - const pane = try createDoc(gpa, cols, rows); - errdefer gpa.destroy(pane); - if (comptime !enabled) return pane; - const state = try gpa.create(State); - errdefer gpa.destroy(state); - state.vt = try ghostty_vt.Terminal.init(terminalIo(), gpa, .{ - .cols = cols, - .rows = rows, - .max_scrollback = 16 * 1024 * 1024, - }); - state.stream = .initAlloc(gpa, .{ .inner = state.vt.vtHandler() }); - state.replay.head = 0; - state.replay.len = 0; - state.reply_len = 0; - state.stream.handler.inner.effects.write_pty = ptyReport; - state.stream.handler.inner.effects.device_attributes = ptyDeviceAttrs; - pane.terminal = state; - pane.tty_filter = true; - return pane; - } - - pub fn createDoc(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane { - const pane = try gpa.create(Pane); - pane.* = .{ - .gpa = gpa, - .cols = cols, - .rows = rows, - }; - return pane; - } - - /// Where a restored terminal's history ends and its new shell begins. - pub const restored_banner = "\x1b[0m\r\n\x1b[2m\u{2500}\u{2500} restored history \u{2500}\u{2500}\x1b[0m\r\n"; - - /// Rebuild a dump's terminal emulator from its recorded output. `live`: a - /// shell will be spawned into it, so the history is marked off from what - /// that shell prints and the pane waits for it like any new terminal. - /// Registration and tag/cwd policy stay with the core; raw VT replay and - /// viewport restoration belong here. - pub fn restore(p: *Pardes, src: dump.Pane, live: bool) !*Pane { - const terminal = src.terminal.?; - if (comptime !enabled) { - const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows)); - errdefer p.gpa.destroy(pane); - if (terminal.stream.len > 0) - pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, terminal.stream) }; - return pane; - } - // An older dump's raw output tail cannot come back live: cut off mid - // redraw, or written at sizes the pane no longer has, it replays as - // garbage. Its rendered text can. - const legacy_text = live and !terminal.stream_is_screen and terminal.stream.len > 0; - const bytes = if (legacy_text) - try std.mem.replaceOwned(u8, p.scratch.allocator(), terminal.stream, "\n", "\r\n") - else if (terminal.stream_b64.len > 0) - try dump.decodeBytes(p.scratch.allocator(), terminal.stream_b64) - else - &.{}; - const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows)); - if (live) { - if (bytes.len > 0) { - ingest(pane, bytes); - ingest(pane, restored_banner); - } - followOutput(pane); - // A view left scrolled back stays on what it was showing: the - // banner's line breaks scrolled a full screen by that many more. - if (src.scroll > 0) scrollGrid(pane, -@as(i32, @intCast(src.scroll + comptime std.mem.count(u8, restored_banner, "\n")))); - armShellSpawn(pane); - } else if (bytes.len > 0) { - ingest(pane, bytes); - followOutput(pane); - if (src.scroll > 0) - scrollGrid(pane, -@as(i32, @intCast(src.scroll))); - } - return pane; - } - - /// Feed the emulator and retain the bounded suffix a dump can replay. Live - /// output and restoration share this byte path, then apply different views. - fn ingest(pane: *Pane, bytes: []const u8) void { - const state = pane.terminal.?; - const replay = &state.replay; - if (bytes.len >= replay.bytes.len) { - const kept = bytes[bytes.len - replay.bytes.len ..]; - @memcpy(&replay.bytes, kept); - replay.head = 0; - replay.len = replay.bytes.len; - } else { - const overflow = bytes.len -| (replay.bytes.len - replay.len); - replay.head = (replay.head + overflow) % replay.bytes.len; - replay.len -= overflow; - const tail = (replay.head + replay.len) % replay.bytes.len; - const first = @min(bytes.len, replay.bytes.len - tail); - @memcpy(replay.bytes[tail..][0..first], bytes[0..first]); - @memcpy(replay.bytes[0 .. bytes.len - first], bytes[first..]); - replay.len += bytes.len; - } - state.stream.nextSlice(bytes); - } - - /// Return the replay ring in chronological order. Wrapped records are copied - /// into `allocator`; contiguous records remain a borrowed slice of the pane. - fn replayBytes(pane: *const Pane, allocator: std.mem.Allocator) ![]const u8 { - const state = pane.terminal orelse return &.{}; - const replay = &state.replay; - if (replay.len == 0) return &.{}; - if (replay.head + replay.len <= replay.bytes.len) - return replay.bytes[replay.head..][0..replay.len]; - const out = try allocator.alloc(u8, replay.len); - const first = replay.bytes.len - replay.head; - @memcpy(out[0..first], replay.bytes[replay.head..]); - @memcpy(out[first..], replay.bytes[0 .. replay.len - first]); - return out; - } - - test "replay ownership is terminal-only and construction rolls back on allocation failure" { - const Case = struct { - fn run(gpa: std.mem.Allocator, terminal: bool) !void { - const pane = if (terminal) try create(gpa, 40, 12) else try createDoc(gpa, 40, 12); - defer gpa.destroy(pane); - defer deinitEmulator(pane, gpa); - try std.testing.expectEqual(terminal and enabled, pane.terminal != null); - } - }; - try std.testing.expect(@sizeOf(Pane) < 128 * 1024); - try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{false}); - try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{true}); - } - - test "document construction allocates only the pane and has inert terminal defaults" { - var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 1 }); - const allocator = failing.allocator(); - const pane = try createDoc(allocator, 40, 12); - defer allocator.destroy(pane); - defer deinitEmulator(pane, allocator); - try std.testing.expect(pane.terminal == null); - try std.testing.expectEqual(@as(usize, 1), failing.alloc_index); - try std.testing.expectEqual(@as(usize, 0), failing.resize_index); - try std.testing.expect(!failing.has_induced_failure); - try std.testing.expectEqual(@as(usize, @sizeOf(Pane)), failing.allocated_bytes); - try std.testing.expectEqual(GridCursor{}, gridCursor(pane)); - try std.testing.expectEqual(Scrollbar{}, scrollbar(pane)); - try std.testing.expect(!bracketedPaste(pane)); - try std.testing.expect(!reportsMouse(pane)); - try std.testing.expect(!mouseFormatSgr(pane)); - try std.testing.expect(!promptInputReady(pane)); - scrollGrid(pane, 100); - followOutput(pane); - resizeGrid(pane, allocator, 80, 24); - try std.testing.expectEqual(GridCursor{}, gridCursor(pane)); - try std.testing.expectEqual(Scrollbar{}, scrollbar(pane)); - try std.testing.expectEqualStrings("", try screenTextAlloc(pane, allocator)); - try std.testing.expectEqualStrings("", try replayBytes(pane, allocator)); - try std.testing.expect(!failing.has_induced_failure); - } - - test "terminal state keeps parser fragments and sends emulator replies through its owner" { - if (comptime !enabled) return error.SkipZigTest; - const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - const state = pane.terminal.?; - try std.testing.expect(state.stream.handler.inner.terminal == &state.vt); - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[" } }); - while (p.nextEffect()) |effect| { - if (effect == .write) return error.PrematureTerminalReply; - } - p.update(.{ .output = .{ .pane = 0, .bytes = "6n" } }); - var replies: usize = 0; - while (p.nextEffect()) |effect| { - if (effect != .write) continue; - try std.testing.expectEqual(@as(u8, 0), effect.write.pane); - try std.testing.expectEqualStrings("\x1b[1;1R", effect.write.bytes.slice()); - replies += 1; - } - try std.testing.expectEqual(@as(usize, 1), replies); - try std.testing.expectEqual(@as(u16, 0), state.reply_len); - try std.testing.expectEqualStrings("\x1b[6n", try replayBytes(pane, std.testing.allocator)); - } - - test "replay keeps a bounded chronological suffix across large writes and wrapping" { - if (comptime !enabled) return error.SkipZigTest; - const gpa = std.testing.allocator; - const pane = try create(gpa, 40, 12); - defer gpa.destroy(pane); - defer deinitEmulator(pane, gpa); - try std.testing.expectEqualStrings("", try replayBytes(pane, gpa)); - ingest(pane, "hello"); - const small = try replayBytes(pane, gpa); - try std.testing.expectEqualStrings("hello", small); - try std.testing.expectEqual(@intFromPtr(&pane.terminal.?.replay.bytes), @intFromPtr(small.ptr)); - - const capacity = pane.terminal.?.replay.bytes.len; - const input = try gpa.alloc(u8, capacity + 7); - defer gpa.free(input); - @memset(input, 0); - const ending = "\x1b[31mred\x1b[0m\r\n"; - @memcpy(input[input.len - ending.len ..], ending); - ingest(pane, input); - try std.testing.expectEqualSlices(u8, input[7..], try replayBytes(pane, gpa)); - ingest(pane, "next\r\n"); - const wrapped = try replayBytes(pane, gpa); - defer gpa.free(wrapped); - try std.testing.expectEqual(capacity, wrapped.len); - try std.testing.expectEqualSlices(u8, input[7 + "next\r\n".len ..], wrapped[0 .. capacity - "next\r\n".len]); - try std.testing.expectEqualStrings("next\r\n", wrapped[capacity - "next\r\n".len ..]); - } - - test "replay restores terminal cells from the retained stream" { - if (comptime !enabled) return error.SkipZigTest; - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - feedOutput(p, pane, "\x1b[31mred\x1b[0m\r\ncafé\r\n"); - const encoded = try dump.encodeBytes(gpa, try replayBytes(pane, gpa)); - defer gpa.free(encoded); - const restored = try restore(p, .{ - .kind = .terminal, - .tag = "", - .body = "", - .cols = pane.cols, - .rows = pane.rows, - .terminal = .{ .stream_b64 = encoded }, - }, false); - defer gpa.destroy(restored); - defer deinitEmulator(restored, gpa); - const before = try screenTextAlloc(pane, gpa); - defer gpa.free(before); - const after = try screenTextAlloc(restored, gpa); - defer gpa.free(after); - try std.testing.expectEqualStrings(before, after); - try std.testing.expectEqualSlices(u8, try replayBytes(pane, gpa), try replayBytes(restored, gpa)); - } - - test "a restored terminal keeps its history above a marker and gets a shell where it was" { - if (comptime !enabled or pardes.isolated) return error.SkipZigTest; - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - feedOutput(p, pane, "old output\r\n$ ls\r\n"); - try pane.setOwnedCwd("/tmp"); - try p.dumpState(); - const restored = try Pardes.initFromDump(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }, p.dump_out.?); - defer restored.deinit(); - var spawned = false; - while (restored.nextEffect()) |effect| switch (effect) { - .spawn => |sp| spawned = sp.pane == 0 and std.mem.eql(u8, sp.cwd.slice(), "/tmp"), - else => {}, - }; - try std.testing.expect(spawned); - const again = restored.panes[0].?; - try std.testing.expectEqual(.spawn, again.pending_command.wait); - const text = try screenTextAlloc(again, gpa); - defer gpa.free(text); - const old_at = std.mem.indexOf(u8, text, "old output") orelse return error.HistoryLost; - const mark_at = std.mem.indexOf(u8, text, "restored history") orelse return error.NoMarker; - try std.testing.expect(old_at < mark_at); - } - - test "an older dump comes back live from its rendered text, not its raw output tail" { - if (comptime !enabled) return error.SkipZigTest; - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); - defer p.deinit(); - // A tail cut mid-redraw: relative moves from a cursor it never set. - const tail = try dump.encodeBytes(gpa, "\x1b[7A\x1b[2Kgarbage\x1b[3B\x1b[9Gmore"); - defer gpa.free(tail); - const restored = try restore(p, .{ - .kind = .terminal, - .tag = "", - .body = "", - .cols = 40, - .rows = 12, - .terminal = .{ .stream = "$ ls\na.txt b.txt\n$ ", .stream_b64 = tail }, - }, true); - defer gpa.destroy(restored); - defer deinitEmulator(restored, gpa); - defer deinitPendingCommand(restored); - const text = try screenTextAlloc(restored, gpa); - defer gpa.free(text); - try std.testing.expect(std.mem.indexOf(u8, text, "$ ls\na.txt b.txt") != null); - try std.testing.expect(std.mem.indexOf(u8, text, "garbage") == null); - } - - /// Record and parse one live pty read, invalidate its motion surface, and - /// follow it only when the body (possibly parked under a tag edit) is raw. - pub fn feedOutput(p: *Pardes, pane: *Pane, bytes: []const u8) void { - // There are no pty reads at all without an emulator to parse them into. - if (comptime !enabled) return; - if (pane.terminal == null) return; - const has_positions = pane.ovl != null or pane.cur_pinned or pane.vsel.active or - pane.msel.active or pane.nsel > 0 or pane.append_at != null or pane.look_at != null or - pane.ed_undo_len > 0 or pane.ed_redo_len > 0 or pointerRowCount(pane) > 0; - // Sticky bottom, as every other terminal does it: output drags the - // viewport down only when the viewport was already on the last row, so - // a reader scrolled back into the scrollback stays where they scrolled. - const at_bottom = atBottom(pane); - if (has_positions) ingestWithPositions(pane, bytes) else ingest(pane, bytes); - p.shell_rows.markStale(pane); - const body_mode = if (pane.tag_edit) pane.tag_mode else pane.mode; - if (body_mode == .tty and at_bottom) followOutput(pane); - } - - const RowPin = struct { pin: ?*ghostty_vt.Pin = null, offset: i32 = 0 }; - const PositionPin = struct { - target: *i32, - row: RowPin, - overlay: ?*EditBuffer, - edit_row: ?i32 = null, - }; - - fn trackRow(pages: *ghostty_vt.PageList, row: i32, failed: *bool) RowPin { - if (row < 0) return .{ .offset = row }; - const bounded: usize = @min(@as(usize, @intCast(row)), pages.total_rows - 1); - var left = pages.total_rows - 1 - bounded; - var node = pages.pages.last.?; - while (left >= node.rows()) { - left -= node.rows(); - node = node.prev.?; - } - const pin = pages.trackPin(.{ .node = node, .y = @intCast(node.rows() - 1 - left) }) catch { - failed.* = true; - return .{}; - }; - return .{ .pin = pin, .offset = row - @as(i32, @intCast(bounded)) }; - } - - fn pinnedRow(pages: ?*ghostty_vt.PageList, row: RowPin) i32 { - const pin = row.pin orelse return row.offset; - const alive = pages orelse return 0; - if (pin.garbage) return 0; - var after: usize = 0; - var node = alive.pages.last; - while (node) |item| : (node = item.prev) { - if (item == pin.node) - return @as(i32, @intCast(alive.total_rows - after - item.rows() + pin.y)) +| row.offset; - after += item.rows(); - } - return 0; - } - - fn trackPosition(pages: *ghostty_vt.PageList, target: *i32, overlay: ?*EditBuffer, failed: *bool) PositionPin { - var grid = target.*; - if (overlay) |edit| { - const lines: i32 = @intCast(modal.lineCount(edit.text)); - if (grid >= edit.row and grid < edit.row +| lines) - return .{ .target = target, .row = .{}, .overlay = edit, .edit_row = grid - edit.row }; - if (grid > edit.row) grid = grid -| lines +| edit.rows; - } - return .{ .target = target, .row = trackRow(pages, grid, failed), .overlay = overlay }; - } - - fn pointerRowCount(pane: *const Pane) usize { - var count: usize = 0; - for (pane.sel) |gesture| if (pane.pointerSelection(gesture)) |rows| { - count += rows.len; - }; - return count; - } - - fn ingestWithPositions(pane: *Pane, bytes: []const u8) void { - const screens = &pane.terminal.?.vt.screens; - const key = screens.active_key; - const generation = screens.generation(key); - const pages = &screens.active.pages; - const Group = struct { overlay: ?*EditBuffer, cursor: *i32, selection: *Pane.CharSel }; - var groups: [history_max * 2 + 1]Group = undefined; - groups[0] = .{ - .overlay = if (pane.ovl) |*overlay| overlay else null, - .cursor = &pane.cur_row, - .selection = &pane.vsel, - }; - var ngroups: usize = 1; - for ([_][]Snapshot{ pane.ed_undo[0..pane.ed_undo_len], pane.ed_redo[0..pane.ed_redo_len] }) |history| { - for (history) |*snapshot| { - groups[ngroups] = .{ - .overlay = if (snapshot.ovl) |*overlay| overlay else null, - .cursor = &snapshot.cur_row, - .selection = &snapshot.vsel, - }; - ngroups += 1; - } - } - const Span = struct { overlay: *EditBuffer, start: RowPin, end: RowPin }; - var spans: [groups.len]Span = undefined; - var nspans: usize = 0; - var positions: [groups.len * 2 + Pane.max_selections * 2 + 4]PositionPin = undefined; - var npositions: usize = 0; - var failed = false; - for (groups[0..ngroups]) |group| { - if (group.overlay) |overlay| { - spans[nspans] = .{ - .overlay = overlay, - .start = trackRow(pages, overlay.row, &failed), - .end = trackRow(pages, overlay.row +| (overlay.rows - 1), &failed), - }; - nspans += 1; - } - positions[npositions] = trackPosition(pages, group.cursor, group.overlay, &failed); - npositions += 1; - if (group.selection.active) { - positions[npositions] = trackPosition(pages, &group.selection.row, group.overlay, &failed); - npositions += 1; - } - } - const overlay = groups[0].overlay; - var extra: [Pane.max_selections * 2 + 4]*i32 = undefined; - var nextra: usize = 0; - if (pane.msel.active) { - extra[nextra] = &pane.msel.r0; - extra[nextra + 1] = &pane.msel.r1; - nextra += 2; - } - for (pane.sels[0..pane.nsel]) |*selection| { - extra[nextra] = &selection.row; - extra[nextra + 1] = &selection.arow; - nextra += 2; - } - if (pane.append_at) |*at| { - extra[nextra] = &at.row; - nextra += 1; - } - if (pane.look_at) |*at| { - extra[nextra] = &at.row; - nextra += 1; - } - for (extra[0..nextra]) |target| { - positions[npositions] = trackPosition(pages, target, overlay, &failed); - npositions += 1; - } - const pointer_positions = pane.gpa.alloc(PositionPin, pointerRowCount(pane)) catch null; - defer if (pointer_positions) |pins| pane.gpa.free(pins); - if (pointer_positions) |pins| { - var index: usize = 0; - for (pane.sel) |gesture| if (pane.pointerSelection(gesture)) |rows| { - for (rows) |*row| { - pins[index] = trackPosition(pages, &row.row, if (row.raw_terminal) null else overlay, &failed); - index += 1; - } - }; - } else failed = true; - ingest(pane, bytes); - // RIS can destroy the alternate screen and every pin it owned. - const alive = if (screens.generation(key) == generation) pages else null; - var moved = false; - var pointer_rows_lost = alive == null or pointer_positions == null; - if (pointer_positions) |pins| for (pins) |position| { - if (alive != null) if (position.row.pin) |pin| { - if (pin.garbage) pointer_rows_lost = true; - }; - }; - for (spans[0..nspans]) |span| { - const start = pinnedRow(alive, span.start); - const rows = @max(1, pinnedRow(alive, span.end) -| start +| 1); - moved = moved or start != span.overlay.row or rows != span.overlay.rows; - span.overlay.row = start; - span.overlay.rows = rows; - if (alive) |owner| { - if (span.start.pin) |pin| owner.untrackPin(pin); - if (span.end.pin) |pin| owner.untrackPin(pin); - } - } - for ([_][]PositionPin{ positions[0..npositions], pointer_positions orelse &.{} }) |batch| for (batch) |position| { - var row = pinnedRow(alive, position.row); - if (position.overlay) |edit| { - if (position.edit_row) |relative| { - row = edit.row +| relative; - } else if (row > edit.row) { - row = if (row < edit.row +| edit.rows) - edit.row - else - row -| edit.rows +| @as(i32, @intCast(modal.lineCount(edit.text))); - } - } - moved = moved or row != position.target.*; - position.target.* = row; - if (alive) |owner| if (position.row.pin) |pin| owner.untrackPin(pin); - }; - if (pointer_rows_lost) for (0..pane.sel.len) |button| { - pane.clearPointerSelection(button); - pane.sel[button].state = .none; - }; - if (moved) pane.nsel_snap = 0; // Saved regex-preview offsets name the old flat text. - if (failed) { - const message = "terminal edit position reset: out of memory"; - @memcpy(pane.msg[0..message.len], message); - pane.msg_len = message.len; - } - } - - test "raw terminal output needs no position tracking allocations" { - if (comptime !enabled) return error.SkipZigTest; - var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{}); - const p = try Pardes.init(failing.allocator(), .{ .tty_only = true, .cols = 80, .rows = 24 }); - defer p.deinit(); - const pane = p.panes[0].?; - pane.mode = .tty; - feedOutput(p, pane, "warm"); - const allocations = failing.alloc_index; - failing.fail_index = allocations; - const pins = pane.terminal.?.vt.screens.active.pages.countTrackedPins(); - feedOutput(p, pane, " output"); - try std.testing.expectEqual(allocations, failing.alloc_index); - try std.testing.expect(!failing.has_induced_failure); - try std.testing.expectEqual(pins, pane.terminal.?.vt.screens.active.pages.countTrackedPins()); - } - - pub fn promptInputReady(pane: *const Pane) bool { - if (comptime !enabled) return false; - const state = pane.terminal orelse return false; - return state.vt.screens.active_key != .alternate and - state.vt.screens.active.cursor.semantic_content == .input; - } - - pub const CommandOutput = struct { - /// Owned by the caller. - text: [:0]const u8, - /// Whether `text` starts where the command did. It does not when that - /// start is not there to read: cut off by `max_rows`, scrolled out of - /// the history (ghostty moved the pin to the oldest row it kept and - /// flagged it `garbage`), erased under it (`MarkedHandler.lost`), or - /// never pinned (a C or D that came on the alternate screen). - whole: bool, - }; - - /// What the newest finished command printed: the screen text between its - /// C and D marks, read from at most `max_rows` rows above its end, so a - /// command that printed the whole history costs no more than its tail. - pub fn commandOutput(pane: *Pane, gpa: std.mem.Allocator, max_rows: usize) !CommandOutput { - // Unless there is text to read, whether the nothing it printed is the - // whole of it. - const whole = if (comptime enabled) printed: { - const state = pane.terminal orelse break :printed true; - const marks = &state.stream.handler; - const start = marks.out_start orelse break :printed false; - const end = marks.out_end orelse break :printed false; - const screen = state.vt.screens.get(.primary) orelse break :printed false; - if (end.garbage) break :printed false; - var whole = !marks.lost and !start.garbage; - // D is where the next prompt begins: the output ends a cell before. - const last = end.leftWrap(1) orelse break :printed whole; - if (start.eql(end.*) or last.before(start.*)) break :printed whole; - var from = start.*; - if (last.up(max_rows)) |floor| if (from.before(floor)) { - from = floor; - from.x = 0; - whole = false; - }; - return .{ - .text = try screen.selectionString(gpa, .{ .sel = ghostty_vt.Selection.init(from, last, false) }), - .whole = whole, - }; - } else true; - return .{ .text = try gpa.dupeZ(u8, ""), .whole = whole }; - } - - /// At a prompt with nothing typed on its line: no input left of the - /// cursor and none under it (text typed and then Ctrl-A'd starts at the - /// cursor). Nothing further right counts: that is where fish draws a - /// right prompt, and fish suggests nothing on an empty line. - pub fn promptInputEmpty(pane: *const Pane) bool { - if (comptime !enabled) return false; - if (!promptInputReady(pane)) return false; - const cursor = &pane.terminal.?.vt.screens.active.cursor; - const row = cursor.page_pin.cells(.all); - for (row[0..@min(cursor.x + 1, row.len)]) |cell| - if (cell.semantic_content == .input and !cell.isEmpty()) return false; - return true; - } - - /// Encode one key for the program that owns a raw terminal and queue its pty - /// write, respecting the application's keyboard protocol and terminal modes. - pub fn forwardKey(p: *Pardes, id: usize, key: Key) void { - if (comptime enabled) { - const pane = p.panes[id] orelse return; - const state = pane.terminal orelse return; - const input = ghostty_vt.input; - const physical: input.Key = switch (key.cp) { - Key.enter => .enter, - Key.backspace => .backspace, - Key.tab => .tab, - Key.escape => .escape, - Key.up => .arrow_up, - Key.down => .arrow_down, - Key.left => .arrow_left, - Key.right => .arrow_right, - Key.home => .home, - Key.end => .end, - Key.page_up => .page_up, - Key.page_down => .page_down, - Key.delete => .delete, - else => if (key.cp < 128) input.Key.fromASCII(@intCast(key.cp)) orelse .unidentified else .unidentified, - }; - var utf8: [4]u8 = undefined; - const text_cp = if (key.shift and key.cp >= 'a' and key.cp <= 'z') key.cp - 'a' + 'A' else key.cp; - const text = if (key.text.len > 0) key.text else if (key.cp >= 32 and key.cp < Key.up and key.cp != Key.backspace) - utf8[0 .. std.unicode.utf8Encode(text_cp, &utf8) catch return] - else - ""; - var buffer: [256]u8 = undefined; - var writer = std.Io.Writer.fixed(&buffer); - input.encodeKey(&writer, .{ - .key = physical, - .mods = .{ .ctrl = key.ctrl, .alt = key.alt, .shift = key.shift }, - .consumed_mods = .{ .shift = key.text.len > 0 and key.shift }, - .utf8 = text, - .unshifted_codepoint = if (key.cp >= 'A' and key.cp <= 'Z') key.cp - 'A' + 'a' else if (key.cp < Key.up) key.cp else 0, - }, options: { - var opts = input.KeyEncodeOptions.fromTerminal(&state.vt); - // fromTerminal cannot know this one and says so, defaulting to - // "option is not alt" — right for an app that lets macOS do the - // unicode translation and hands the encoder the composed text. - // Every pardes shell resolves the modifier itself and hands the - // core the BASE key with the alt bit and no text at all (see - // PardesView.keyDown, and app.mjs doing the same with the same - // two bits), so by the time this runs alt already means alt. - // Left at the default, a macOS build drops the ESC prefix on - // the floor and forwards Alt-n to the child as a bare `n`. - opts.macos_option_as_alt = .true; - break :options opts; - }) catch return; - if (writer.end > 0) p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(writer.buffered()) } }); - return; - } - var control: [1]u8 = undefined; - const bytes: ?[]const u8 = blk: { - if (key.ctrl) { - if (key.cp >= 'a' and key.cp <= 'z') { - control[0] = @intCast(key.cp - 0x60); - break :blk control[0..1]; - } - // ASCII @, A-Z, [, \, ], ^ and _ are one contiguous control range. - if (key.cp >= '@' and key.cp <= '_') { - control[0] = @intCast(key.cp - 0x40); - break :blk control[0..1]; - } - } - if (key.text.len > 0) break :blk key.text; - break :blk switch (key.cp) { - Key.enter => "\r", - Key.backspace => "\x7f", - Key.tab => "\t", - Key.escape => "\x1b", - Key.up => "\x1b[A", - Key.down => "\x1b[B", - Key.right => "\x1b[C", - Key.left => "\x1b[D", - Key.delete => "\x1b[3~", - else => null, - }; - }; - if (bytes) |encoded| - p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(encoded) } }); - } - - pub fn enterTty(p: *Pardes, id: usize) void { - const pane = p.panes[id] orelse return; - const selected = pane.rawPointerText(pane.sel[@intFromEnum(config.select_button)]) != null; - if (comptime enabled) if (!selected and pane.terminal != null and pane.cur_pinned and pane.terminal.?.vt.cursorIsAtPrompt()) handoff: { - const screen = pane.terminal.?.vt.screens.active; - const goff: i32 = @intCast(screen.pages.scrollbar().offset); - const vp_row = pane.gridRow(pane.cur_row) - goff; - if (vp_row < 0) break :handoff; - - var grid_col: i32 = @max(0, pane.cur_col); - if (screen.pages.pin(.{ .viewport = .{ .x = 0, .y = @intCast(vp_row) } })) |row_pin| { - if (row_pin.rowAndCell().row.semantic_prompt != .none) switch (promptCut(row_pin)) { - .cut => |cols| grid_col += @intCast(cols), - .keep, .blank => {}, - }; - } - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = @intCast(grid_col), .y = @intCast(vp_row) }, - }) orelse break :handoff; - const cursor_pin = screen.cursor.page_pin.*; - var prompts = cursor_pin.promptIterator(.left_up, null); - const prompt_pin = prompts.next() orelse break :handoff; - if (click_pin.before(prompt_pin)) break :handoff; - const moves = screen.promptClickMove(click_pin); - for (0..moves.left) |_| p.emitWrite(id, "\x1b[D"); - for (0..moves.right) |_| p.emitWrite(id, "\x1b[C"); - }; - - pane.mode = .tty; - followOutput(pane); // entering raw mode is a request for the live shell - pane.msel.active = false; - pane.vsel.active = false; - pane.nsel = 0; - // The raw renderer follows the live cursor. A captured mouse selection - // keeps its modal endpoint for the next return to editor mode. - pane.cur_pinned = selected; - pane.select = false; - pane.append_at = null; - pane.sticky_col = -1; - pane.normal.clear(); - } - - // Returned slices remain valid until the next cache rebuild or reset. - pub const RowsCache = struct { - /// whose grid this describes; null = the slot is free - pane: ?*const Pane = null, - /// the rows joined by '\n' — `flatSurface` hands this back verbatim - /// instead of rebuilding the join on every keystroke - text: []const u8 = &.{}, - /// slices INTO `text`, absolute grid rows from 0 - rows: [][]const u8 = &.{}, - /// `text` is a prefix of this: blanking a prompt row shortens the join, - /// and the slack is not worth a second allocation to reclaim - text_alloc: []u8 = &.{}, - stale: bool = false, - - pub fn reset(c: *RowsCache, gpa: std.mem.Allocator) void { - if (c.text_alloc.len > 0) gpa.free(c.text_alloc); - if (c.rows.len > 0) gpa.free(c.rows); - c.* = .{}; - } - - /// Free a stale entry. Called at the top of `update`, and nowhere else. - pub fn sweep(c: *RowsCache, gpa: std.mem.Allocator) void { - if (c.stale) c.reset(gpa); - } - - /// `pane`'s grid moved: the entry no longer describes it. - pub fn markStale(c: *RowsCache, pane: *const Pane) void { - if (c.pane == pane) c.stale = true; - } - - pub fn dropPane(c: *RowsCache, pane: *const Pane) void { - if (c.pane != pane) return; - c.pane = null; - c.stale = true; - } - }; - - const Rows = struct { - text_alloc: []u8, - text: []const u8, - rows: [][]const u8, - }; - - const empty_grid = [1][]const u8{""}; - - /// What LEAVING raw tty mode does to one prompt row, decided from its cells - /// alone. See config.tty_blank for why any of this happens. - const PromptCut = union(enum) { - /// show the row exactly as ghostty dumped it - keep, - /// show nothing at all - blank, - /// drop this many leading COLUMNS — the prompt — and keep the rest, which - /// is what was typed at it - cut: usize, - }; - - fn promptCut(pin: ghostty_vt.Pin) PromptCut { - if (config.tty_blank == .prompt_and_input) return .blank; - const cells = pin.cells(.all); - var cols: usize = 0; - while (cols < cells.len and cells[cols].semantic_content == .prompt) cols += 1; - // Flagged, but with no prompt cells at the FRONT: a right-side prompt, or - // a repaint that has moved on. Nothing here is the prompt, so hide nothing. - if (cols == 0) return .keep; - // ...and all prompt, nothing typed yet: the row is chrome end to end. - if (cols >= cells.len) return .blank; - return .{ .cut = cols }; - } - - pub fn promptPrefixBytes(pane: *Pane, row: i32, raw: []const u8) usize { - if (comptime !enabled) return 0; - if (row < 0) return 0; - const state = pane.terminal orelse return 0; - const pin = state.vt.screens.active.pages.pin(.{ .screen = .{ .x = 0, .y = @intCast(row) } }) orelse return 0; - if (pin.rowAndCell().row.semantic_prompt == .none) return 0; - return switch (promptCut(pin)) { - .keep => 0, - .blank => raw.len, - .cut => |cols| @min(raw.len, File.rawAtDisplay(raw, cols)), - }; - } - - fn promptRow(pin: ghostty_vt.Pin, raw: []const u8) []const u8 { - const cols = switch (promptCut(pin)) { - .keep => return raw, - .blank => return "", - .cut => |n| n, - }; - const cells = pin.cells(.all); - var at: usize = 0; - var col: usize = 0; - while (col < cols and at < raw.len) { - const cell = &cells[col]; - at = @min(raw.len, at + dumpedBytes(pin, cell)); - // the tail cell of a wide glyph spells nothing of its own - col += if (cell.wide == .wide) @as(usize, 2) else 1; - } - return std.mem.trimEnd(u8, raw[at..], " \t"); - } - - fn dumpedBytes(pin: ghostty_vt.Pin, cell: *const ghostty_vt.Cell) usize { - switch (cell.wide) { - .spacer_head, .spacer_tail => return 0, - .narrow, .wide => {}, - } - var n: usize = switch (cell.content_tag) { - .codepoint, .codepoint_grapheme => std.unicode.utf8CodepointSequenceLength( - cell.codepoint(), - ) catch 1, - // A cell carrying only a colour still spells one blank in the dump. - else => 1, - }; - if (cell.content_tag == .codepoint_grapheme) { - if (pin.grapheme(cell)) |extra| for (extra) |cp| { - n += std.unicode.utf8CodepointSequenceLength(cp) catch 1; - }; - } - return n; - } - - pub fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 { - if (comptime !enabled) return &empty_grid; - if (pane.terminal == null) return &empty_grid; - const c = &p.shell_rows; - if (!c.stale and c.pane == pane) return c.rows; - if (c.pane != null) { - c.stale = true; - return (try buildRows(p.scratch.allocator(), p, pane)).rows; - } - const built = try buildRows(p.gpa, p, pane); - c.* = .{ - .pane = pane, - .text = built.text, - .rows = built.rows, - .text_alloc = built.text_alloc, - }; - return c.rows; - } - - /// The full modal motion surface: shell history with the live edit overlay - /// spliced into the rows it covers. - pub fn cursorLines(p: *Pardes, pane: *Pane) ![]const []const u8 { - const rows = try shellRows(p, pane); - if (pane.ovl == null) return rows; - var last = rows.len; - if (pane.ovl) |overlay| - last = @max(last, @as(usize, @intCast(@max(0, overlay.row + overlay.rows)))); - var count = last; - if (pane.ovl) |overlay| { - if (overlay.row >= 0 and @as(usize, @intCast(overlay.row)) < last) - count = count - @min( - @as(usize, @intCast(overlay.rows)), - last - @as(usize, @intCast(overlay.row)), - ) + @max(1, modal.lineCount(overlay.text)); - } - const lines = try p.scratch.allocator().alloc([]const u8, count); - var n: usize = 0; - var grid_row: usize = 0; - while (grid_row < last) : (grid_row += 1) { - if (pane.ovl) |overlay| if (overlay.row >= 0 and grid_row == @as(usize, @intCast(overlay.row))) { - var overlay_lines = std.mem.splitScalar(u8, overlay.text, '\n'); - while (overlay_lines.next()) |line| : (n += 1) lines[n] = line; - grid_row += @intCast(overlay.rows - 1); - continue; - }; - lines[n] = if (grid_row < rows.len) rows[grid_row] else ""; - n += 1; - } - return lines[0..n]; - } - - /// Flatten `cursorLines` without rebuilding the common cached/no-overlay - /// case. Scratch-owned when a join is required. - pub fn flatSurface(p: *Pardes, pane: *Pane, lines: []const []const u8) ![]const u8 { - const cache = &p.shell_rows; - if (!cache.stale and cache.pane == pane and - lines.ptr == cache.rows.ptr and lines.len == cache.rows.len) return cache.text; - var total: usize = if (lines.len > 0) lines.len - 1 else 0; - for (lines) |line| total += line.len; - const text = try p.scratch.allocator().alloc(u8, total); - var at: usize = 0; - for (lines, 0..) |line, i| { - if (i > 0) { - text[at] = '\n'; - at += 1; - } - @memcpy(text[at..][0..line.len], line); - at += line.len; - } - return text; - } - - /// Materialize or extend the terminal edit overlay with one exact allocation. - /// Row slices are scratch-owned/borrowed; only the joined text is installed. - pub fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?EditText { - const want_lo = @max(0, @min(lo, hi)); - const want_hi = @max(want_lo, @max(lo, hi)); - const fresh = pane.ovl == null; - const old: EditBuffer = pane.ovl orelse .{ .row = want_lo, .rows = 1, .text = &.{} }; - const lines: i32 = if (fresh) 1 else @intCast(modal.lineCount(old.text)); - const up = old.row - want_lo; - const down = want_hi - (old.row + lines - 1); - const extending = fresh or up > 0 or down > 0; - var row0 = old.row; - var covered = old.rows; - var text = old.text; - var owned = false; - if (extending) { - const rows = shellRows(p, pane) catch return null; - row0 = old.row - @max(0, up); - covered = old.rows + @max(0, up) + @max(0, down); - const up_len: usize = @intCast(@max(0, up)); - const down_len: usize = @intCast(@max(0, down)); - const parts = p.scratch.allocator().alloc([]const u8, up_len + 1 + down_len) catch return null; - for (parts[0..up_len], 0..) |*part, i| { - const src = @as(usize, @intCast(row0)) + i; - part.* = if (src < rows.len) rows[src] else ""; - } - const middle: usize = @intCast(old.row); - parts[up_len] = if (fresh) - (if (middle < rows.len) rows[middle] else "") - else - old.text; - for (parts[up_len + 1 ..], 0..) |*part, i| { - const src = @as(usize, @intCast(old.row + old.rows)) + i; - part.* = if (src < rows.len) rows[src] else ""; - } - text = std.mem.join(p.gpa, "\n", parts) catch return null; - owned = true; - } - - const row: usize = @intCast(@max(0, want_lo - row0)); - const line_len: i32 = @intCast(modal.lineSlice(text, row).len); - if (col > line_len) { - const spaces = p.scratch.allocator().alloc(u8, @intCast(col - line_len)) catch { - if (owned) p.gpa.free(text); - return null; - }; - @memset(spaces, ' '); - const padded = modal.insertAt(p.gpa, text, .{ .row = row, .col = @intCast(line_len) }, spaces) catch { - if (owned) p.gpa.free(text); - return null; - }; - if (owned) p.gpa.free(text); - text = padded; - owned = true; - } - if (owned) { - if (pane.ovl) |overlay| p.gpa.free(overlay.text); - pane.ovl = .{ .row = row0, .rows = covered, .text = text }; - } - return .{ .text = pane.ovl.?.text, .row0 = pane.ovl.?.row }; - } - - /// Consume a rewritten overlay, freeing the terminal edit text it replaces. - pub fn setEditText(p: *Pardes, pane: *Pane, new: []u8) void { - const overlay = if (pane.ovl) |*value| value else return p.gpa.free(new); - for (0..pane.sel.len) |button| { - pane.clearPointerSelection(button); - pane.sel[button].state = .none; - } - p.gpa.free(overlay.text); - overlay.text = new; - } - - /// The shell's screen and scrollback (the primary screen, even under a - /// full-screen program) as VT that replays to what was shown: styled - /// text, soft-wrapped lines rejoined so they reflow at another width. - fn screenVt(pane: *const Pane, arena: std.mem.Allocator) ![]const u8 { - const screen = pane.terminal.?.vt.screens.get(.primary) orelse return &.{}; - var out: std.Io.Writer.Allocating = .init(arena); - try ghostty_vt.formatter.ScreenFormatter.init(screen, .{ .emit = .vt, .unwrap = true }).format(&out.writer); - return out.written(); - } - - /// Serialize terminal-only state; the core supplies shared pane metadata. - pub fn dumpPane( - pane: *Pane, - arena: std.mem.Allocator, - tag: []const u8, - body: []const u8, - scroll: usize, - ) !dump.Pane { - if (!enabled or pane.terminal == null) { - const text = if (pane.ovl) |overlay| overlay.text else ""; - return .{ - .kind = .terminal, - .tag = tag, - .body = body, - .scroll = scroll, - .cols = pane.cols, - .rows = pane.rows, - .vweight = pane.vweight, - .collapsed = pane.collapsed, - .terminal = .{ - .cwd = try arena.dupe(u8, pane.cwdSlice()), - .stream = try arena.dupe(u8, text), - .stream_b64 = &.{}, - .cursor = .{ .col = 0, .row = 0 }, - }, - }; - } - const full = try pane.terminal.?.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} }); - const extra = if (pane.ovl) |overlay| overlay.text.len else 0; - const stream = try arena.alloc(u8, try std.math.add(usize, full.len, extra)); - var len: usize = 0; - var lines = std.mem.splitAny(u8, full, "\n"); - var prompts = pane.terminal.?.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); - var row: i32 = 0; - var skip: i32 = 0; - while (lines.next()) |raw| : (row += 1) { - // Hidden overlay rows still consume prompt pins to keep them aligned. - const prompt = if (pane.mode != .tty) prompts.next() else null; - if (skip > 0) { - skip -= 1; - continue; - } - if (row > 0) { - stream[len] = '\n'; - len += 1; - } - if (pane.mode != .tty) if (pane.ovl) |overlay| if (row == overlay.row) { - @memcpy(stream[len..][0..overlay.text.len], overlay.text); - len += overlay.text.len; - skip = overlay.rows - 1; - continue; - }; - const shown = if (prompt) |pin| - if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, raw) else raw - else - raw; - @memcpy(stream[len..][0..shown.len], shown); - len += shown.len; - } - return .{ - .kind = .terminal, - .tag = tag, - .body = body, - .scroll = scroll, - .cols = pane.cols, - .rows = pane.rows, - .vweight = pane.vweight, - .collapsed = pane.collapsed, - .terminal = .{ - .cwd = try arena.dupe(u8, pane.cwdSlice()), - .stream = stream[0..len], - .stream_b64 = try dump.encodeBytes(arena, try screenVt(pane, arena)), - .stream_is_screen = true, - .cursor = .{ - .col = pane.terminal.?.vt.screens.active.cursor.x, - .row = pane.terminal.?.vt.screens.active.cursor.y, - }, - }, - }; - } - - fn buildRows(alloc: std.mem.Allocator, p: *Pardes, pane: *Pane) !Rows { - const full = try pane.terminal.?.vt.screens.active.dumpStringAlloc(p.scratch.allocator(), .{ .screen = .{} }); - var pit = pane.terminal.?.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); - // split yields one more item than delimiters; the extra final slot is the - // cursor row retained below. - const n_rows = std.mem.count(u8, full, "\n") + 2; - const rows = try alloc.alloc([]const u8, n_rows); - errdefer alloc.free(rows); - // Blanking a prompt row only ever SHORTENS it and the retained cursor row - // adds one separator, so the dump's length plus one bounds the join. - const text = try alloc.alloc(u8, full.len + 1); - errdefer alloc.free(text); - var at: usize = 0; - var n: usize = 0; - var it = std.mem.splitScalar(u8, full, '\n'); - while (it.next()) |raw| { - const shown = if (pit.next()) |pin| - if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, raw) else raw - else - raw; - if (n > 0) { - text[at] = '\n'; - at += 1; - } - @memcpy(text[at..][0..shown.len], shown); - rows[n] = text[at..][0..shown.len]; - at += shown.len; - n += 1; - } - text[at] = '\n'; - at += 1; - rows[n] = text[at..][0..0]; - n += 1; - std.debug.assert(n == n_rows); - return .{ .text_alloc = text, .text = text[0..at], .rows = rows }; - } - - pub fn bodyText(arena: std.mem.Allocator, pane: *Pane) ![]const u8 { - const vp: []const []const u8 = if (comptime !enabled) &.{} else vp: { - const state = pane.terminal orelse break :vp &.{}; - const screen = state.vt.screens.active; - var tl = screen.pages.getTopLeft(.viewport); - tl.x = 0; - const br = screen.pages.getBottomRight(.viewport) orelse return error.UnknownPoint; - var rows_out: std.Io.Writer.Allocating = .init(arena); - try screen.dumpString(&rows_out.writer, .{ .tl = tl, .br = br, .unwrap = false }); - const raw = try rows_out.toOwnedSlice(); - var prompts = screen.pages.rowIterator(.right_down, .{ .viewport = .{} }, null); - const vp = try arena.alloc([]const u8, std.mem.count(u8, raw, "\n") + 1); - var lines = std.mem.splitScalar(u8, raw, '\n'); - var n: usize = 0; - while (lines.next()) |ln| { - vp[n] = if (pane.mode != .tty) - if (prompts.next()) |pin| - if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, ln) else ln - else - ln - else - ln; - n += 1; - } - std.debug.assert(n == vp.len); - break :vp vp; - }; - - const len = fillBody(null, pane, vp); - const out = try arena.alloc(u8, len); - const filled = fillBody(out, pane, vp); - std.debug.assert(filled == out.len); - return out; - } - - pub const BodyRow = union(enum) { - grid: i32, - edit: struct { line: []const u8, idx: usize }, - }; - - const BodyWalk = struct { - pane: *Pane, - goff: i32, - g: i32, - /// the buffer can start above the viewport: drop the lines scrolled past - skip: usize, - n: usize = 0, - lines: ?std.mem.SplitIterator(u8, .scalar) = null, - covered: i32 = 0, - line_idx: usize = 0, - - fn init(pane: *Pane) BodyWalk { - const off = pane.scroll(); - const goff = gridOffset(pane); - return .{ - .pane = pane, - .goff = goff, - .g = if (pane.mode == .tty) goff else pane.gridRow(off), - .skip = if (pane.ovl) |o| @intCast(@max(0, off - pane.surfRow(o.row))) else 0, - }; - } - - fn next(w: *BodyWalk) ?BodyRow { - while (w.n < w.pane.rows) { - if (w.lines) |*it| { - if (it.next()) |line| { - const idx = w.line_idx; - w.line_idx += 1; - // Lines scrolled off the top still count: the index names a - // line of the BUFFER, not of the visible body. - if (w.skip > 0) { - w.skip -= 1; - continue; - } - w.n += 1; - return .{ .edit = .{ .line = line, .idx = idx } }; - } - // The buffer stands in for `rows` shell rows however many lines - // it actually spelled, which is the whole slide. - w.g += w.covered; - w.skip = 0; - w.lines = null; - continue; - } - if (w.pane.mode != .tty) if (w.pane.ovl) |o| if (w.g == o.row) { - w.lines = std.mem.splitScalar(u8, o.text, '\n'); - w.covered = o.rows; - w.line_idx = 0; - continue; - }; - const vi = w.g - w.goff; - w.g += 1; - w.n += 1; - return .{ .grid = vi }; - } - return null; - } - }; - - /// Run the terminal body row walk. A null destination counts bytes; a slice - /// fills the exact allocation made from that count. - fn fillBody(dst: ?[]u8, pane: *Pane, viewport: []const []const u8) usize { - var walk: BodyWalk = .init(pane); - var written: usize = 0; - var first = true; - while (walk.next()) |row| { - if (!first) { - if (dst) |out| out[written] = '\n'; - written += 1; - } - first = false; - const bytes = switch (row) { - .edit => |e| e.line, - .grid => |vi| if (vi >= 0 and @as(usize, @intCast(vi)) < viewport.len) - viewport[@intCast(vi)] - else - "", - }; - if (dst) |out| @memcpy(out[written..][0..bytes.len], bytes); - written += bytes.len; - } - return written; - } - - // Match surviving edit-buffer rows to their original terminal styles. - const EditAnchors = struct { - /// the buffer's own text, walked in order: a line the VIEWPORT skipped still - /// consumes the row it came from, so the lines below it stay aligned - text: []const u8 = &.{}, - at: usize = 0, - shell: []const []const u8 = &.{}, - /// the covered span, absolute grid rows, as `[first, end)` - first: usize = 0, - end: usize = 0, - lines: usize = 0, - /// the line `at` names, and the first row still unclaimed - idx: usize = 0, - cursor: usize = 0, - budget: usize = 0, - active: bool = false, - - fn init(p: *Pardes, pane: *Pane, o: EditBuffer) EditAnchors { - if (o.rows <= 0 or o.row < 0) return .{}; - const first: usize = @intCast(o.row); - const screen = pane.terminal.?.vt.screens.active; - const total = screen.pages.scrollbar().total; - if (first >= total) return .{}; - const covered: usize = @intCast(o.rows); - const count = @min(covered, total - first); - const tl = screen.pages.pin(.{ .screen = .{ .y = @intCast(first) } }) orelse return .{}; - const br = screen.pages.pin(.{ .screen = .{ - .x = screen.pages.cols - 1, - .y = @intCast(first + count - 1), - } }) orelse return .{}; - const arena = p.scratch.allocator(); - var output: std.Io.Writer.Allocating = .init(arena); - screen.dumpString(&output.writer, .{ .tl = tl, .br = br, .unwrap = false }) catch return .{}; - const shell = arena.alloc([]const u8, count) catch return .{}; - var raw = std.mem.splitScalar(u8, output.written(), '\n'); - var pins = tl.rowIterator(.right_down, br); - for (shell) |*row| { - const text = raw.next() orelse ""; - row.* = if (pins.next()) |pin| - if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, text) else text - else - text; - } - const lines = std.mem.count(u8, o.text, "\n") + 1; - return .{ - .text = o.text, - .shell = shell, - .first = first, - .end = first + count, - .lines = lines, - .cursor = first, - .budget = covered + 4 * lines, - .active = true, - }; - } - - fn shellRow(a: *EditAnchors, idx: usize) ?Anchor { - if (!a.active or idx >= a.lines) return null; - var found: ?Anchor = null; - while (a.idx <= idx) : (a.idx += 1) found = a.claim(a.nextLine() orelse return null); - return found; - } - - fn nextLine(a: *EditAnchors) ?[]const u8 { - if (a.at > a.text.len) return null; - const rest = a.text[a.at..]; - if (std.mem.indexOfScalar(u8, rest, '\n')) |n| { - a.at += n + 1; - return rest[0..n]; - } - // The last line has no terminator; one past the end ends the walk. - a.at = a.text.len + 1; - return rest; - } - - /// Where this line still stands over the grid, if anywhere. - fn claim(a: *EditAnchors, line: []const u8) ?Anchor { - const end = if (line.len == 0) @min(a.cursor + 1, a.end) else a.end; - var k = a.cursor; - while (k < end) : (k += 1) { - if (a.budget == 0) return null; - a.budget -= 1; - if (!std.mem.eql(u8, line, a.shell[k - a.first])) continue; - a.cursor = k + 1; - return .{ .row = @intCast(k) }; - } - if (a.cursor >= a.end) return null; - const shell = a.shell[a.cursor - a.first]; - var p: usize = 0; - while (p < line.len and p < shell.len and line[p] == shell[p]) p += 1; - var s: usize = 0; - const room = @min(line.len, shell.len) - p; - while (s < room and line[line.len - 1 - s] == shell[shell.len - 1 - s]) s += 1; - if (p + s == 0) return null; - if (line.len != p + s and shell.len - (p + s) > shell.len / 2) return null; - const row = a.cursor; - if (s > 0 or p >= shell.len) a.cursor += 1; - return .{ .row = @intCast(row), .prefix = p, .suffix = s, .shell_len = shell.len }; - } - }; - - const Anchor = struct { - /// the row, absolute while it comes from `EditAnchors`, viewport once - /// `recolorAnsi` has subtracted the walk's offset - row: i32, - prefix: usize = std.math.maxInt(usize), - suffix: usize = 0, - /// the row's own dumped length — what the suffix is measured from on the - /// GRID side, where the edit may have changed the byte count - shell_len: usize = 0, - - fn whole(an: Anchor) bool { - return an.prefix == std.math.maxInt(usize); - } - }; - - pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, body: []const u8) void { - // No emulator, no ANSI cells: the whole pass — and the 256-colour theme - // projection behind it — is compiled out. - if (comptime !enabled) return; - const s = &p.surface; - if (pane.terminal == null) return; - const body_y = p.bodyTop(r); - var filtered_storage: FilteredColors = undefined; - const filtered: ?*FilteredColors = if (pane.tty_filter) blk: { - const tz_filter = tracy.zone(@src(), "filterInit"); - defer tz_filter.end(); - filtered_storage = FilteredColors.init(p, pane); - break :blk &filtered_storage; - } else null; - // DECSCNM, read once: the filtered palette folds it in itself, the raw - // path needs it per cell. - const scnm = pane.terminal.?.vt.modes.get(.reverse_colors); - const pages = &pane.terminal.?.vt.screens.active.pages; - const vp_rows: i32 = @intCast(scrollbar(pane).len); - // Which buffer lines the user has not actually changed, so a row swallowed - // by a growing buffer keeps the colour it still stands over. - var anchors: ?EditAnchors = null; - var walk: BodyWalk = .init(pane); - var lines = std.mem.splitScalar(u8, body, '\n'); - var vr: u16 = 0; - while (walk.next()) |row| : (vr += 1) { - if (vr >= body_h) break; - // Before any early exit below, or the lines fall out of step with rows. - const text = lines.next() orelse ""; - const anchor: Anchor = switch (row) { - .edit => |e| blk: { - if (anchors == null) anchors = .init(p, pane, pane.ovl.?); - var an = anchors.?.shellRow(e.idx) orelse continue; - an.row -= walk.goff; - break :blk an; - }, - .grid => |v| .{ .row = v }, - }; - const vi = anchor.row; - if (vi < 0 or vi >= vp_rows) continue; - const row_pin = pages.pin(.{ .viewport = .{ .y = @intCast(vi) } }) orelse continue; - const cut: u16 = if (pane.mode == .tty) 0 else cut: { - if (row_pin.rowAndCell().row.semantic_prompt == .none) break :cut 0; - break :cut switch (promptCut(row_pin)) { - .keep => 0, - // Blanked end to end: the row shows nothing of the grid, so - // projecting the prompt's own colours onto it would be a lie. - .blank => continue, - .cut => |n| std.math.cast(u16, n) orelse continue, - }; - }; - if (cut > 0 and text.len == 0) continue; - var at: usize = 0; - var sc: u16 = 0; - var gc: u16 = cut; - var sb: usize = 0; - const mine_from = @min(anchor.prefix, text.len); - const mine_to = text.len - @min(anchor.suffix, text.len); - var crossed = false; - while (at < text.len and sc < tw) { - const stop = modal.nextGrapheme(text, at); - if (stop <= at) break; - const span: u16 = if (sc + 1 < tw and s.at(tx + sc + 1, body_y + vr).len == 0) 2 else 1; - if (at >= mine_from and at < mine_to) { - sc += span; - at = stop; - continue; - } - if (at >= mine_to and !crossed) { - crossed = true; - const upto = anchor.shell_len - @min(anchor.suffix, anchor.shell_len); - while (sb < upto) { - const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break; - sb += dumpedBytes(row_pin, ci.cell); - gc = std.math.add(u16, gc, 1) catch break; - } - } - const want = stop - at; - // Consume every cell that contributed to this grapheme. A cluster - // ghostty split across several cells is still ONE printed glyph. - var covered: usize = 0; - var style: ?pardes.CellStyle = null; - while (covered < want) { - const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break; - if (style == null and ci.cell.wide != .spacer_tail and ci.cell.wide != .spacer_head) - style = cellStyle(p, ci, filtered, scnm); - covered += dumpedBytes(row_pin, ci.cell); - gc = std.math.add(u16, gc, 1) catch break; - // A spacer contributes no bytes; without this the loop would - // spin on a row that ends in one. - if (covered == 0 and gc >= pane.cols) break; - } - while (pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } })) |t| { - if (t.cell.wide != .spacer_tail) break; - gc = std.math.add(u16, gc, 1) catch break; - } - if (style) |st| for (0..span) |k| { - const cell = s.at(tx + sc + @as(u16, @intCast(k)), body_y + vr); - if (cell.default and filtered == null) continue; - cell.default = false; - cell.style = st; - }; - sb += covered; - sc += span; - at = stop; - } - var tail: ?pardes.CellStyle = null; - if (anchor.whole() or anchor.suffix > 0) { - while (sc < tw) { - const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break; - gc = std.math.add(u16, gc, 1) catch break; - if (ci.cell.wide == .spacer_tail) continue; - const cell = s.at(tx + sc, body_y + vr); - sc += 1; - const style = cellStyle(p, ci, filtered, scnm); - tail = style; - if (cell.default and filtered == null) continue; - cell.default = false; - cell.style = style; - } - if (tail) |style| while (sc < tw) : (sc += 1) { - const cell = s.at(tx + sc, body_y + vr); - if (cell.default and filtered == null) continue; - cell.default = false; - cell.style = style; - }; - } - } - } - - fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell, filtered: ?*FilteredColors, scnm: bool) pardes.CellStyle { - const style = ci.style(); - var cs: pardes.CellStyle = .{ - .fg = if (filtered) |colors| colors.fg(style) else ghostColor(p, style.fg_color, scnm), - .bg = if (filtered) |colors| colors.bg(style, ci.cell) else ghostColor(p, style.bg_color, !scnm), - .bold = style.flags.bold, - .dim = style.flags.faint, - .italic = style.flags.italic, - .blink = style.flags.blink, - .reverse = style.flags.inverse, - .invisible = style.flags.invisible, - .strikethrough = style.flags.strikethrough, - .ul = switch (style.flags.underline) { - .none => .off, - .single => .single, - .double => .double, - .curly => .curly, - .dotted => .dotted, - .dashed => .dashed, - }, - }; - if (filtered == null) switch (ci.cell.content_tag) { - .bg_color_palette => cs.bg = palColor(p, ci.cell.content.color_palette.data), - .bg_color_rgb => { - const rgb = ci.cell.content.color_rgb; - cs.bg = .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }; - }, - else => {}, - }; - return cs; - } - - const FilteredColors = struct { - source: GColor.Palette, - target: *const GColor.Palette, - theme_bg: GColor.RGB, - theme_fg: GColor.RGB, - dynamic_bg: ?GColor.RGB, - dynamic_fg: ?GColor.RGB, - default_bg: GColor.RGB, - /// What a foreground too near `default_bg` becomes instead. - fallback_fg: GColor.RGB, - default_bg_luminance: f64, - fg_for_palette: [256]pardes.Color = undefined, - bg_for_palette: [256]pardes.Color = undefined, - /// The two answers for a cell that names no colour of its own. - fg_default: pardes.Color = undefined, - bg_default: pardes.Color = undefined, - cache_rgb: [256]GColor.RGB = undefined, - cache_key: [256]u8 = undefined, - cache_valid: [256]bool = @splat(false), - - fn init(p: *Pardes, pane: *const Pane) FilteredColors { - var source = GColor.default; - for (&source, 0..) |*rgb, i| - rgb.* = pane.terminal.?.vt.colorForXterm(.{ .palette = @intCast(i) }) orelse rgb.*; - const theme = p.theme(); - var theme_bg = asGhostRgb(theme.bg orelse theme.tag_bg); - var theme_fg = asGhostRgb(theme.fg orelse theme.tag_fg); - var dynamic_bg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .background }); - var dynamic_fg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .foreground }); - if (pane.terminal.?.vt.modes.get(.reverse_colors)) { - std.mem.swap(GColor.RGB, &theme_bg, &theme_fg); - std.mem.swap(?GColor.RGB, &dynamic_bg, &dynamic_fg); - } - var self: FilteredColors = .{ - .source = source, - .target = p.tty_filter_palette.get(theme), - .theme_bg = theme_bg, - .theme_fg = theme_fg, - .dynamic_bg = dynamic_bg, - .dynamic_fg = dynamic_fg, - .default_bg = theme_bg, - .fallback_fg = theme_fg, - .default_bg_luminance = luminanceOf(theme_bg), - }; - if (dynamic_bg) |rgb| self.default_bg = self.keyedRgb(rgb); - self.default_bg_luminance = luminanceOf(self.default_bg); - if (self.theme_bg.contrast(self.default_bg) > self.theme_fg.contrast(self.default_bg)) - self.fallback_fg = self.theme_bg; - - self.fg_default = asPardesColor(self.legible( - if (self.dynamic_fg) |rgb| self.keyedRgb(rgb) else self.theme_fg, - )); - self.bg_default = asPardesColor(self.default_bg); - for (&self.source, 0..) |current, i| { - const idx: u8 = @intCast(i); - const mapped = self.paletteRgb(idx, current); - self.fg_for_palette[idx] = asPardesColor(self.legible(mapped)); - self.bg_for_palette[idx] = asPardesColor(mapped); - } - return self; - } - - fn fg(self: *FilteredColors, style: ghostty_vt.Style) pardes.Color { - return switch (style.fg_color) { - .none => self.fg_default, - .palette => |idx| self.fg_for_palette[idx], - .rgb => asPardesColor(self.legible(self.keyedRgb(style.fg(.{ - .default = self.dynamic_fg orelse self.theme_fg, - .palette = &self.source, - .bold = null, - })))), - }; - } - - fn bg(self: *FilteredColors, style: ghostty_vt.Style, cell: *const ghostty_vt.Cell) pardes.Color { - switch (cell.content_tag) { - .bg_color_palette => return self.bg_for_palette[cell.content.color_palette.data], - .bg_color_rgb => {}, - else => switch (style.bg_color) { - .none => return self.bg_default, - .palette => |idx| return self.bg_for_palette[idx], - .rgb => {}, - }, - } - // Truecolour, from either the cell or its style. - return asPardesColor(self.keyedRgb(style.bg(cell, &self.source).?)); - } - - fn legible(self: *const FilteredColors, rgb: GColor.RGB) GColor.RGB { - if (contrastOf(luminanceOf(rgb), self.default_bg_luminance) >= - config.tty_filter_min_contrast) return rgb; - return self.fallback_fg; - } - - fn paletteRgb(self: *FilteredColors, idx: u8, current: GColor.RGB) GColor.RGB { - if (current.eql(GColor.default[idx])) return self.target[idx]; - return self.keyedRgb(current); - } - - fn keyedRgb(self: *FilteredColors, rgb: GColor.RGB) GColor.RGB { - return self.target[self.nearestKey(rgb)]; - } - - fn nearestKey(self: *FilteredColors, rgb: GColor.RGB) u8 { - const rgb24 = (@as(u32, rgb.r) << 16) | (@as(u32, rgb.g) << 8) | rgb.b; - const slot: u8 = @truncate((rgb24 *% 0x9e3779b1) >> 24); - if (self.cache_valid[slot] and self.cache_rgb[slot].eql(rgb)) - return self.cache_key[slot]; - - var best: u8 = 0; - var best_distance: u32 = std.math.maxInt(u32); - for (GColor.default, 0..) |candidate, i| { - const distance = colorDistance(rgb, candidate); - // Strict comparison makes duplicate-colour ties stable at the - // lowest canonical xterm key. - if (distance < best_distance) { - best_distance = distance; - best = @intCast(i); - } - } - self.cache_rgb[slot] = rgb; - self.cache_key[slot] = best; - self.cache_valid[slot] = true; - return best; - } - }; - - const channel_luminance: [256]f64 = blk: { - @setEvalBranchQuota(20000); - var table: [256]f64 = undefined; - for (&table, 0..) |*slot, c| { - const normalized: f64 = @as(f64, @floatFromInt(c)) / 255; - slot.* = if (normalized <= 0.03928) - normalized / 12.92 - else - std.math.pow(f64, (normalized + 0.055) / 1.055, 2.4); - } - break :blk table; - }; - - fn luminanceOf(rgb: GColor.RGB) f64 { - return 0.2126 * channel_luminance[rgb.r] + - 0.7152 * channel_luminance[rgb.g] + - 0.0722 * channel_luminance[rgb.b]; - } - - /// ghostty's `RGB.contrast` with both luminances already in hand. - fn contrastOf(a_luminance: f64, b_luminance: f64) f64 { - const lighter = @max(a_luminance, b_luminance); - const darker = @min(a_luminance, b_luminance); - return (lighter + 0.05) / (darker + 0.05); - } - - fn colorDistance(a: GColor.RGB, b: GColor.RGB) u32 { - const dr = @as(i32, a.r) - @as(i32, b.r); - const dg = @as(i32, a.g) - @as(i32, b.g); - const db = @as(i32, a.b) - @as(i32, b.b); - return @intCast(dr * dr + dg * dg + db * db); - } - - test "the luminance table answers exactly what ghostty computes" { - for (0..256) |i| { - const c: u8 = @intCast(i); - const grey: GColor.RGB = .{ .r = c, .g = c, .b = c }; - try std.testing.expectEqual(grey.luminance(), luminanceOf(grey)); - } - // Channel weights are asymmetric, so a grey ramp alone would not catch a - // transposed coefficient. The palette is what the tables enumerate. - for (GColor.default) |candidate| { - try std.testing.expectEqual(candidate.luminance(), luminanceOf(candidate)); - for (GColor.default) |page| { - try std.testing.expectEqual( - candidate.contrast(page), - contrastOf(luminanceOf(candidate), luminanceOf(page)), - ); - } - } - } - - test "terminal Filter keys indexed truecolor OSC and background-only cells through the theme" { - const testing = std.testing; - // Raw index assertions require a theme that inherits the ANSI palette. - const p = try Pardes.init(testing.allocator, .{ .startup_config = "Theme helix\n", .tty_only = true, .cols = 18, .rows = 6 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - try testing.expect(pane.tty_filter); - pane.tty_filter = false; - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mA" ++ - "\x1b[38;5;196mB" ++ - "\x1b[38;2;255;0;0mC" ++ - "\x1b[0;48;5;25mD" ++ - "\x1b[0;48;2;0;95;175mE" ++ - "\x1b[0;1;2;3;4;5;7;8;9mF" ++ - "\x1b[0;48;5;25m\x1b[K" ++ - "\r\n\x1b[0;38;5;2m界" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - - const raw = try p.render(frame.allocator()); - try testing.expectEqual(pardes.Color{ .index = 1 }, raw.at(tx, body_y).style.fg); - try testing.expectEqual(pardes.Color{ .index = 196 }, raw.at(tx + 1, body_y).style.fg); - try testing.expectEqual(pardes.Color{ .rgb = .{ 255, 0, 0 } }, raw.at(tx + 2, body_y).style.fg); - - pane.tty_filter = true; - _ = frame.reset(.retain_capacity); - const filtered = try p.render(frame.allocator()); - var expected_cache: FilterPalette = .{}; - const expected = expected_cache.get(p.theme()); - try testing.expectEqual(asPardesColor(expected[1]), filtered.at(tx, body_y).style.fg); - try testing.expectEqual(asPardesColor(expected[196]), filtered.at(tx + 1, body_y).style.fg); - try testing.expectEqual(filtered.at(tx + 1, body_y).style.fg, filtered.at(tx + 2, body_y).style.fg); - try testing.expectEqual(asPardesColor(expected[25]), filtered.at(tx + 3, body_y).style.bg); - try testing.expectEqual(filtered.at(tx + 3, body_y).style.bg, filtered.at(tx + 4, body_y).style.bg); - - const attrs = filtered.at(tx + 5, body_y).style; - try testing.expect(attrs.bold); - try testing.expect(attrs.dim); - try testing.expect(attrs.italic); - try testing.expect(attrs.blink); - try testing.expect(attrs.reverse); - try testing.expect(attrs.invisible); - try testing.expect(attrs.strikethrough); - try testing.expectEqual(.single, attrs.ul); - - const erased = pane.terminal.?.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = 6, .y = 0 } }).?; - try testing.expectEqual(.bg_color_palette, erased.cell.content_tag); - try testing.expectEqual(asPardesColor(expected[25]), filtered.at(tx + 6, body_y).style.bg); - try testing.expectEqual(asPardesColor(expected[2]), filtered.at(tx, body_y + 1).style.fg); - try testing.expectEqual(filtered.at(tx, body_y + 1).style, filtered.at(tx + 1, body_y + 1).style); - // A filtered terminal never delegates either colour to a backend palette, - // including cells which were empty/default before the pass. - for (0..r.w - config.GUTTER) |col| { - const cell = filtered.at(tx + @as(u16, @intCast(col)), body_y); - try testing.expect(!cell.default); - switch (cell.style.fg) { - .rgb => {}, - else => return error.FilteredForegroundWasNotRgb, - } - switch (cell.style.bg) { - .rgb => {}, - else => return error.FilteredBackgroundWasNotRgb, - } - } - - // Colors remains the global master gate. The pane remembers Filter while - // ANSI projection is dormant, and resumes it without replaying VT bytes. - p.settings.colors = false; - _ = frame.reset(.retain_capacity); - const plain = try p.render(frame.allocator()); - try testing.expect(pane.tty_filter); - try testing.expectEqual(asPardesColor(asGhostRgb(p.theme().fg.?)), plain.at(tx, body_y).style.fg); - p.settings.colors = true; - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]4;1;#ff0000\x1b\\" } }); - const osc_red = pane.terminal.?.vt.colorForXterm(.{ .palette = 1 }).?; - try testing.expect(osc_red.eql(.{ .r = 255, .g = 0, .b = 0 })); - pane.tty_filter = false; - pane.tty_filter = true; - try testing.expect(osc_red.eql(pane.terminal.?.vt.colorForXterm(.{ .palette = 1 }).?)); - _ = frame.reset(.retain_capacity); - const osc_palette = try p.render(frame.allocator()); - try testing.expectEqual(asPardesColor(expected[196]), osc_palette.at(tx, body_y).style.fg); - - // Dynamic default foreground/background colours key every default cell, - // including the otherwise blank end of the row. - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]10;#ff0000\x1b\\" ++ - "\x1b]11;#5f5f5f\x1b\\" } }); - const dyn_fg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .foreground }).?; - const dyn_bg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .background }).?; - try testing.expect(dyn_fg.eql(.{ .r = 255, .g = 0, .b = 0 })); - try testing.expect(dyn_bg.eql(.{ .r = 95, .g = 95, .b = 95 })); - _ = frame.reset(.retain_capacity); - const dynamic = try p.render(frame.allocator()); - const blank = dynamic.at(tx + r.w - config.GUTTER - 1, body_y + 2).style; - try testing.expectEqual(asPardesColor(expected[196]), blank.fg); - try testing.expectEqual(asPardesColor(expected[59]), blank.bg); - - const explicit_before_reverse = dynamic.at(tx, body_y).style.fg; - try testing.expectEqual(asPardesColor(expected[196]), explicit_before_reverse); - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } }); - _ = frame.reset(.retain_capacity); - const reversed = try p.render(frame.allocator()); - const reversed_blank = reversed.at(tx + r.w - config.GUTTER - 1, body_y + 2).style; - try testing.expectEqual(asPardesColor(expected[59]), reversed_blank.fg); - try testing.expectEqual(asPardesColor(expected[196]), reversed_blank.bg); - const reversed_explicit = reversed.at(tx, body_y).style; - try testing.expectEqual(asPardesColor(expected[196]), reversed_explicit.bg); - try testing.expectEqual(pardes.Color{ .rgb = p.theme().bg.? }, reversed_explicit.fg); - try testing.expect(!std.meta.eql(reversed_explicit.fg, reversed_explicit.bg)); - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5l" } }); - _ = frame.reset(.retain_capacity); - const unreversed = try p.render(frame.allocator()); - const unreversed_blank = unreversed.at(tx + r.w - config.GUTTER - 1, body_y + 2).style; - try testing.expectEqual(asPardesColor(expected[196]), unreversed_blank.fg); - try testing.expectEqual(asPardesColor(expected[59]), unreversed_blank.bg); - - // The cache is keyed by values, not a theme name. Replacing a custom - // theme in place immediately recolours already-rendered indexed cells. - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]104;1\x1b\\" } }); - var custom = p.theme().*; - custom.name = try p.gpa.dupe(u8, "same-name"); - custom.palette = null; - custom.kw = .{ 1, 2, 3 }; - p.custom_theme = custom; - _ = frame.reset(.retain_capacity); - const custom_first = try p.render(frame.allocator()); - try testing.expectEqual(pardes.Color{ .rgb = .{ 1, 2, 3 } }, custom_first.at(tx, body_y).style.fg); - if (p.custom_theme) |*theme| theme.kw = .{ 4, 5, 6 }; - _ = frame.reset(.retain_capacity); - const custom_second = try p.render(frame.allocator()); - try testing.expectEqual(pardes.Color{ .rgb = .{ 4, 5, 6 } }, custom_second.at(tx, body_y).style.fg); - } - - test "terminal Filter keeps extended keys dark-to-light on a light theme" { - const p = try Pardes.init(std.testing.allocator, .{ .startup_config = "Theme acme\n", .tty_only = true }); - defer p.deinit(); - try std.testing.expectEqualStrings("acme", p.theme().name); - var cache: FilterPalette = .{}; - const palette = cache.get(p.theme()); - try std.testing.expect(palette[16].eql(asGhostRgb(p.theme().fg.?))); - try std.testing.expect(palette[231].eql(asGhostRgb(p.theme().bg.?))); - } - - test "terminal Filter preserves exact palette-null light theme default roles" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 5 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - - var light = p.theme().*; - light.name = try p.gpa.dupe(u8, "filter-light-defaults"); - light.bg = .{ 0xf8, 0xf8, 0xf8 }; - light.fg = .{ 0x38, 0x38, 0x38 }; - light.palette = null; - p.custom_theme = light; - - const pane = p.panes[0].?; - try testing.expectEqual(@as(?GColor.RGB, null), pane.terminal.?.vt.colorForXterm(.{ .dynamic = .foreground })); - try testing.expectEqual(@as(?GColor.RGB, null), pane.terminal.?.vt.colorForXterm(.{ .dynamic = .background })); - pane.tty_filter = true; - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - const ordinary = try p.render(frame.allocator()); - try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, ordinary.at(tx, body_y).style.fg); - try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, ordinary.at(tx, body_y).style.bg); - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } }); - _ = frame.reset(.retain_capacity); - const reversed = try p.render(frame.allocator()); - try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, reversed.at(tx, body_y).style.fg); - try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, reversed.at(tx, body_y).style.bg); - } - - test "terminal Filter maps the default roles before it maps anything else" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - try testing.expect(pane.tty_filter); - - // Native dark/light palettes plus legacy helix/dark/acme, including - // the null-page fallback that only the legacy dark theme exercises. - for (0..9) |t| { - p.settings.theme = @intCast(t); - const stage_one = FilteredColors.init(p, pane); - // `dark` declares no background of its own, which is exactly why the - // resolver reads the tag colours as the fallback rather than `.?`. - const theme = p.theme(); - try testing.expect(stage_one.theme_bg.eql(asGhostRgb(theme.bg orelse theme.tag_bg))); - try testing.expect(stage_one.theme_fg.eql(asGhostRgb(theme.fg orelse theme.tag_fg))); - // With no OSC 11 in play the mapped page IS that anchor, and the - // fallback is the other one: a background never contrasts with itself. - try testing.expect(stage_one.default_bg.eql(stage_one.theme_bg)); - try testing.expect(stage_one.fallback_fg.eql(stage_one.theme_fg)); - } - - p.settings.theme = 0; - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]11;#5f5f5f\x1b\\" } }); - var moved = FilteredColors.init(p, pane); - try testing.expect(!moved.default_bg.eql(moved.theme_bg)); - try testing.expect(moved.default_bg.eql(moved.keyedRgb(.{ .r = 0x5f, .g = 0x5f, .b = 0x5f }))); - } - - test "terminal Filter refuses a foreground that would collapse onto the page" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - try testing.expect(pane.tty_filter); - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[38;2;255;255;255mW" ++ - "\x1b[0;30mB" ++ - "\x1b[0;31mR" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - - for (0..9) |t| { - p.settings.theme = @intCast(t); - var fc = FilteredColors.init(p, pane); - const page = asPardesColor(fc.default_bg); - const rescued = asPardesColor(fc.fallback_fg); - _ = frame.reset(.retain_capacity); - const g = try p.render(frame.allocator()); - - // The colour each of the three would have been given with no floor. - const raw_white = fc.keyedRgb(.{ .r = 255, .g = 255, .b = 255 }); - const raw_black = fc.paletteRgb(0, GColor.default[0]); - const raw_red = fc.paletteRgb(1, GColor.default[1]); - - for ([_]struct { at: u16, raw: GColor.RGB }{ - .{ .at = 0, .raw = raw_white }, - .{ .at = 1, .raw = raw_black }, - .{ .at = 2, .raw = raw_red }, - }) |case| { - const cell = g.at(tx + case.at, body_y).style; - try testing.expectEqual(page, cell.bg); - if (case.raw.contrast(fc.default_bg) < config.tty_filter_min_contrast) { - // Refused: the projection's answer is not painted, the anchor is. - try testing.expectEqual(rescued, cell.fg); - try testing.expect(!std.meta.eql(cell.fg, cell.bg)); - } else { - // Cleared the floor, so stage two leaves it exactly alone. - try testing.expectEqual(asPardesColor(case.raw), cell.fg); - } - // Either way a filtered cell delegates neither colour to a backend. - switch (cell.fg) { - .rgb => |ink| try testing.expect(asGhostRgb(ink).contrast(fc.default_bg) >= - config.tty_filter_min_contrast), - else => return error.FilteredForegroundWasNotRgb, - } - } - - // At least one of the three has to have been a real collapse, or this - // theme proved nothing: white on the light theme, black on the dark. - try testing.expect(raw_white.contrast(fc.default_bg) < config.tty_filter_min_contrast or - raw_black.contrast(fc.default_bg) < config.tty_filter_min_contrast); - // A saturated red is never the page on any curated theme. - try testing.expect(raw_red.contrast(fc.default_bg) >= config.tty_filter_min_contrast); - } - } - - test "terminal Filter holds every projected foreground off the page" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - - for (0..9) |t| { - p.settings.theme = @intCast(t); - var fc = FilteredColors.init(p, pane); - var refused: usize = 0; - for (fc.target, 0..) |projected, key| { - const ink = fc.legible(projected); - try testing.expect(ink.contrast(fc.default_bg) >= config.tty_filter_min_contrast); - if (!ink.eql(projected)) { - refused += 1; - try testing.expect(ink.eql(fc.fallback_fg)); - // Only ever refused for being too near the page. - try testing.expect(projected.contrast(fc.default_bg) < config.tty_filter_min_contrast); - } - // The key a background asks for is handed back untouched, including - // the one whose value is the page itself. - try testing.expect(fc.keyedRgb(GColor.default[key]).eql(fc.target[fc.nearestKey(GColor.default[key])])); - } - // Every curated theme owns at least one collapsing key — that is why - // the floor exists — and the floor must not be flattening the palette. - try testing.expect(refused > 0); - try testing.expect(refused < fc.target.len / 8); - } - } - - test "tty ansi colors follow the prompt hug into normal mode" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32mPP\x1b]133;B\x1b\\\x1b[31mR\x1b[34mB\x1b[0m out" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - const red: pardes.Color = .{ .rgb = p.theme().palette.?[1] }; - const blue: pardes.Color = .{ .rgb = p.theme().palette.?[4] }; - - // tty mode projects the emulator's ansi colours cell for cell. - pane.mode = .tty; - p.shell_rows.stale = true; - const tty = try p.render(frame.allocator()); - try testing.expectEqual(red, tty.at(tx + 2, body_y).style.fg); - try testing.expectEqual(blue, tty.at(tx + 3, body_y).style.fg); - - pane.mode = .normal; - p.shell_rows.stale = true; - _ = frame.reset(.retain_capacity); - const norm = try p.render(frame.allocator()); - try testing.expectEqualStrings("R", norm.at(tx, body_y).grapheme()); - try testing.expectEqualStrings("B", norm.at(tx + 1, body_y).grapheme()); - try testing.expectEqual(red, norm.at(tx, body_y).style.fg); - try testing.expectEqual(blue, norm.at(tx + 1, body_y).style.fg); - } - - test "a prompt row hidden end to end paints nothing at all" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 8 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - pane.mode = .normal; - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32maaaaaaaaa\x1b]133;B\x1b\\\x1b[41;36m\u{754C}\x1b[0m\r\n" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - p.shell_rows.stale = true; - const s = try p.render(frame.allocator()); - - try testing.expectEqualStrings(" ", s.at(tx, body_y).grapheme()); - try testing.expect(!std.meta.eql(pardes.Color{ .rgb = p.theme().palette.?[1] }, s.at(tx, body_y).style.bg)); - } - - pub fn palColor(p: *Pardes, idx: u8) pardes.Color { - if (p.theme().palette) |pal| if (idx < 16) return .{ .rgb = pal[idx] }; - return .{ .index = idx }; - } - - pub fn ghostColor(p: *Pardes, color: ghostty_vt.Style.Color, is_bg: bool) pardes.Color { - return switch (color) { - .none => blk: { - const t = if (is_bg) p.theme().bg else p.theme().fg; - break :blk if (t) |c| .{ .rgb = c } else .default; - }, - .palette => |idx| palColor(p, idx), - .rgb => |rgb| .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }, - }; - } - - /// executing at a prompt with typed text below it: pad the output area - /// with newlines so the command's output doesn't overwrite the buffer - pub fn padOutputBelowEdits(p: *Pardes, id: usize) void { - // Nothing to pad away from: with no emulator there is no prompt and no - // child whose output could land on top of the edit buffer. - if (comptime !enabled) return; - const pane = p.panes[id] orelse return; - const o = pane.ovl orelse return; - if (!pane.isTerminal()) return; - const state = pane.terminal orelse return; - if (!state.vt.cursorIsAtPrompt()) return; - // the buffer's LAST surface row: its lines may outnumber the shell - // rows it covers, and it is the bottom one output must clear - const max_row = o.row + @as(i32, @intCast(modal.lineCount(o.text))) - 1; - const goff: i32 = @intCast(state.vt.screens.active.pages.scrollbar().offset); - const cursor_abs = pane.surfRow(goff + @as(i32, @intCast(state.vt.screens.active.cursor.y))); - const pad = std.math.clamp(max_row - cursor_abs, 0, @as(i32, pane.rows)); - var i: i32 = 0; - while (i < pad) : (i += 1) p.emitWrite(id, "\r"); - } - - /// the snapshot takes ownership of a COPY of the edit buffer's text - pub fn snap(p: *Pardes, pane: *Pane) ?Snapshot { - var ovl: ?EditBuffer = null; - if (pane.ovl) |o| ovl = .{ .row = o.row, .rows = o.rows, .text = p.gpa.dupe(u8, o.text) catch return null }; - return .{ .ovl = ovl, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .vsel = pane.vsel }; - } - - /// undo/redo restores the selection recorded with the snapshot (helix - /// keeps selections in its history transactions) - pub fn restoreSnap(p: *Pardes, pane: *Pane, s: Snapshot) void { - if (pane.ovl) |o| p.gpa.free(o.text); - pane.ovl = s.ovl; - pane.cur_row = s.cur_row; - pane.cur_col = s.cur_col; - pane.cur_pinned = true; - pane.vsel = s.vsel; - pane.msel.active = false; - pane.ensureCursorVisible(); - } - - fn pushHistory(gpa: std.mem.Allocator, slots: []Snapshot, len: *usize, value: Snapshot) void { - if (len.* == slots.len) { - if (slots[0].ovl) |overlay| gpa.free(overlay.text); - std.mem.copyForwards(Snapshot, slots[0 .. slots.len - 1], slots[1..]); - len.* -= 1; - } - slots[len.*] = value; - len.* += 1; - } - - pub fn pushUndo(p: *Pardes, pane: *Pane) void { - const current = pane.ovl orelse EditBuffer{ .rows = 0 }; - if (pane.ed_undo_len > 0) { - const top = pane.ed_undo[pane.ed_undo_len - 1]; - const same = if (top.ovl) |overlay| pane.ovl != null and overlay.row == current.row and - overlay.rows == current.rows and std.mem.eql(u8, overlay.text, current.text) else pane.ovl == null; - if (same) return; - } - const value = snap(p, pane) orelse return; - pushHistory(p.gpa, &pane.ed_undo, &pane.ed_undo_len, value); - for (pane.ed_redo[0..pane.ed_redo_len]) |item| if (item.ovl) |overlay| p.gpa.free(overlay.text); - pane.ed_redo_len = 0; - } - - pub fn undo(p: *Pardes, pane: *Pane) void { - if (pane.ed_undo_len == 0) return; - const current = snap(p, pane) orelse return; - pushHistory(p.gpa, &pane.ed_redo, &pane.ed_redo_len, current); - pane.ed_undo_len -= 1; - restoreSnap(p, pane, pane.ed_undo[pane.ed_undo_len]); - } - - pub fn redo(p: *Pardes, pane: *Pane) void { - if (pane.ed_redo_len == 0) return; - const current = snap(p, pane) orelse return; - pushHistory(p.gpa, &pane.ed_undo, &pane.ed_undo_len, current); - pane.ed_redo_len -= 1; - restoreSnap(p, pane, pane.ed_redo[pane.ed_redo_len]); - } - - pub fn ptyReport(handler: *ghostty_vt.TerminalStream.Handler, data: [:0]const u8) void { - const state: *State = @alignCast(@fieldParentPtr("vt", handler.terminal)); - const room = state.reply.len - state.reply_len; - const n = @min(room, data.len); - @memcpy(state.reply[state.reply_len..][0..n], data[0..n]); - state.reply_len += @intCast(n); - } - - const DeviceAttrs = @typeInfo(@typeInfo(@typeInfo( - @FieldType(ghostty_vt.TerminalStream.Handler.Effects, "device_attributes"), - ).optional.child).pointer.child).@"fn".return_type.?; - pub fn ptyDeviceAttrs(_: *ghostty_vt.TerminalStream.Handler) DeviceAttrs { - return .{}; - } -}; +pub const Terminal = @import("Terminal.zig"); test { _ = Pane; |
