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