From 04b047fb366ea2c595708fb62467f690fce4b9ec Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 20:56:14 -0300 Subject: Terminal.zig is terminal.zig: a file of functions and no fields takes a namespace's lowercase name The last of the deferred renames, now that the tty and theme agents have landed: panes.terminal at its importers, the alias lines in Text.zig and File.zig and panes.zig's own uses following. dump.zig's Terminal struct, a dump record, is not this. The served sources list and docs/design.typ name the new file. test/perf.zig's references change, so the three perf baselines take its new harness id with their numbers as recorded. No behaviour changes. Co-Authored-By: Claude Opus 5.5 --- src/File.zig | 6 +- src/Terminal.zig | 2670 -------------------------------------------------- src/Text.zig | 18 +- src/body_layer.zig | 16 +- src/builtins.zig | 2 +- src/draw.zig | 8 +- src/dump.zig | 4 +- src/edit.zig | 26 +- src/exec.zig | 34 +- src/fs.zig | 2 +- src/host_io.zig | 2 +- src/layout.zig | 4 +- src/mouse.zig | 6 +- src/ninep/ctl.zig | 2 +- src/ninep/pane.zig | 4 +- src/ninep/pty.zig | 24 +- src/ninep/screen.zig | 4 +- src/panes.zig | 20 +- src/pardes.zig | 106 +- src/terminal.zig | 2670 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/tty/tty.zig | 2 +- 21 files changed, 2815 insertions(+), 2815 deletions(-) delete mode 100644 src/Terminal.zig create mode 100644 src/terminal.zig (limited to 'src') diff --git a/src/File.zig b/src/File.zig index c4040863..8babb27d 100644 --- a/src/File.zig +++ b/src/File.zig @@ -17,7 +17,7 @@ const locations = @import("locations.zig"); const Pane = panes.Pane; const Output = panes.Output; const Mini = panes.mini; -const Terminal = panes.Terminal; +const terminal = panes.terminal; const Text = panes.Text; const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2; @@ -782,7 +782,7 @@ pub fn open(p: *Pardes, id: usize, path: []const u8, line: usize) !*Pane { std.debug.assert(p.panes[id] == null and !p.reserved_slots[id]); const path_copy = try p.gpa.dupe(u8, path); errdefer p.gpa.free(path_copy); - const pane = try Terminal.createDoc(p.gpa, p.screen_w, p.screen_h); + const pane = try terminal.createDoc(p.gpa, p.screen_w, p.screen_h); errdefer p.gpa.destroy(pane); const history = try History.create(p.gpa); errdefer p.gpa.destroy(history); @@ -1239,7 +1239,7 @@ fn fillBody(dst: ?[]u8, pane: *Pane, f: *State, width: usize, record_wrap: bool) pub fn drawGutter(p: *Pardes, s: *Surface, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, active: bool) void { const prefix_width = gutterWidth(pane); const goff = pane.scroll(); - const gcur = Terminal.gridCursor(pane); + const gcur = terminal.gridCursor(pane); const gcrow = if (pane.body.cur_pinned) pane.body.cur_row else @as(i32, gcur.y) + goff; const typing_body = pane.focus == .body and (pane.prompt == .none or pane.prompt == .del_side or pane.prompt == .repl_choice); const cur_line: i32 = if (active and typing_body) gcrow else std.math.minInt(i32); diff --git a/src/Terminal.zig b/src/Terminal.zig deleted file mode 100644 index c99a92a6..00000000 --- a/src/Terminal.zig +++ /dev/null @@ -1,2670 +0,0 @@ -//! A terminal pane: its VT emulator and replay, prompt rows, the edit overlay -//! typed over shell rows, and the Filter that recolours output to the theme. -const panes = @import("panes.zig"); -const std = @import("std"); -const pardes = @import("pardes.zig"); -const config = @import("config.zig"); -const Pardes = pardes.Pardes; -const Surface = pardes.Surface; -const modal = @import("modal.zig"); -const tracy = @import("tracy.zig"); -const dump = @import("dump.zig"); -const Key = pardes.Key; -const Pane = panes.Pane; -const EditText = panes.EditText; -const File = panes.File; - -pub const enabled = pardes.terminal_panes; -const ghostty_vt = if (enabled) @import("ghostty-vt") else struct {}; - -pub const history_max = if (enabled) 64 else 8; - -pub const VtSlot = if (enabled) ghostty_vt.Terminal else void; -pub const StreamSlot = if (enabled) ghostty_vt.Stream(MarkedHandler) else void; - -/// The `aid` pardes's injected bash and fish marks carry (host_io.zig). -pub const prompt_aid = "pardes"; - -/// ghostty's handler, plus where the shell is in its command cycle as -/// its OSC 133 marks tell it: ghostty parses D's exit status and drops -/// it, and /pane//pty/run waits on exactly that. Only marks carrying -/// pardes's own `aid` move it, so fish's native marks, a nested shell's -/// and a stray `133;D` in some file being printed all pass it by. -pub const MarkedHandler = if (enabled) struct { - inner: ghostty_vt.TerminalStream.Handler, - phase: enum { none, prompt, input, running } = .none, - /// Counts commands started (C at the input phase). - started: u32 = 0, - /// Counts prompts drawn (B), so a line the shell refused without - /// running (a fish syntax error: no C, no D, a new prompt) shows. - prompts: u32 = 0, - /// D marks outside a running command: the shell handled a line - /// without starting it (bash on a syntax error prints no C). - declined: u32 = 0, - /// The newest prompt continues an unfinished line (bash's PS2, marked - /// `k=c`): the shell still holds the start of the command. - continuation: bool = false, - /// Where the newest command's output began (its C) and ended (its - /// D) on the primary screen, pinned so scrolling keeps them. - out_start: ?*ghostty_vt.Pin = null, - out_end: ?*ghostty_vt.Pin = null, - /// The running command's output was erased under it -- a clear, ED 3, - /// a reset -- so the pins no longer span what it printed: ED 3 moves - /// a pin to the next page without flagging it `garbage` - /// (PageList.erasePage). - lost: bool = false, - - /// The newest command to end: which one, and its status if given. - finished: ?struct { seq: u32, status: ?i32 } = null, - - const Action = ghostty_vt.TerminalStream.Action; - - /// Move one of the output pins to the cursor. Only the primary screen - /// is pinned: a command's own full-screen display is not its output. - fn pinCursor(self: *@This(), slot: *?*ghostty_vt.Pin, now: bool) void { - const screen = self.inner.terminal.screens.get(.primary) orelse return; - if (slot.*) |old| screen.pages.untrackPin(old); - slot.* = null; - if (!now or self.inner.terminal.screens.active_key != .primary) return; - slot.* = screen.pages.trackPin(screen.cursor.page_pin.*) catch null; - } - - pub fn vt(self: *@This(), comptime action: Action.Tag, value: Action.Value(action)) void { - if (comptime action == .semantic_prompt) mark: { - const aid = value.readOption(.aid) orelse break :mark; - if (!std.mem.eql(u8, aid, prompt_aid)) break :mark; - switch (value.action) { - .fresh_line_new_prompt => { - self.phase = .prompt; - self.continuation = value.readOption(.prompt_kind) == .continuation; - }, - .end_prompt_start_input => { - self.phase = .input; - self.prompts +%= 1; - }, - // bash's DEBUG trap also fires on its own PROMPT_COMMAND, - // outside the input phase; only a C that ends input counts - .end_input_start_output => if (self.phase == .input) { - self.phase = .running; - self.started +%= 1; - self.lost = false; - self.pinCursor(&self.out_start, true); - self.pinCursor(&self.out_end, false); - }, - .end_command => if (self.phase == .running) { - self.phase = .none; - self.finished = .{ .seq = self.started, .status = value.readOption(.exit_code) }; - self.pinCursor(&self.out_end, true); - } else { - self.declined +%= 1; - }, - else => {}, - } - } - if (comptime action == .erase_display_complete or action == .erase_display_scrollback or action == .full_reset) { - if (self.phase == .running) self.lost = true; - } - self.inner.vt(action, value); - } - - pub fn deinit(self: *@This()) void { - self.inner.deinit(); - } -} else void; - -pub const Replay = struct { - bytes: [1024 * 1024]u8 = undefined, - head: usize = 0, - len: usize = 0, -}; - -pub const State = struct { - vt: VtSlot, - stream: StreamSlot, - replay: Replay, - reply: [256]u8 = undefined, - reply_len: u16 = 0, -}; - -const GColor = ghostty_vt.color; - -const FilterPaletteKey = struct { - bg: GColor.RGB, - fg: GColor.RGB, - base: [16]GColor.RGB, -}; - -pub const FilterPalette = if (enabled) LivePalette else struct {}; - -const LivePalette = struct { - key: ?FilterPaletteKey = null, - colors: GColor.Palette = GColor.default, - - fn get(self: *LivePalette, theme: *const pardes.Theme) *const GColor.Palette { - const bg = asGhostRgb(theme.bg orelse theme.tag_bg); - const fg = asGhostRgb(theme.fg orelse theme.tag_fg); - var base: [16]GColor.RGB = undefined; - if (theme.palette) |palette| { - for (&base, palette) |*dst, src| dst.* = asGhostRgb(src); - } else { - const synthesized = [16][3]u8{ - theme.bg orelse theme.tag_bg, - theme.kw, - theme.str, - theme.num, - theme.box, - theme.sel_bg, - theme.comment, - theme.fg orelse theme.tag_fg, - theme.lineno, - theme.kw, - theme.str, - theme.num, - theme.scroll_thumb, - theme.sel_fg, - theme.tag_fg, - theme.fg orelse theme.tag_fg, - }; - for (&base, synthesized) |*dst, src| dst.* = asGhostRgb(src); - } - - const key: FilterPaletteKey = .{ .bg = bg, .fg = fg, .base = base }; - if (self.key) |old| if (std.meta.eql(old, key)) return &self.colors; - - var seed = GColor.default; - for (base, 0..) |rgb, i| seed[i] = rgb; - self.colors = GColor.generate256Color(seed, .initEmpty(), bg, fg, false); - self.key = key; - return &self.colors; - } -}; - -fn asGhostRgb(rgb: [3]u8) GColor.RGB { - return .{ .r = rgb[0], .g = rgb[1], .b = rgb[2] }; -} - -fn asPardesColor(rgb: GColor.RGB) pardes.Color { - return .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }; -} - -/// The owned text standing in for `rows` live terminal rows, beginning at -/// absolute surface row `row`. The emulator grid remains untouched underneath. -pub const EditBuffer = struct { - row: i32 = 0, - rows: i32 = 1, - text: []u8 = &.{}, -}; - -/// One whole-state terminal edit boundary. Null means the pane has not yet -/// materialized an edit buffer; non-null snapshots own their text. -pub const Snapshot = struct { - ovl: ?EditBuffer, - cur_row: i32, - cur_col: i32, - vsel: panes.Text.CharSel, -}; - -pub const PendingCommand = struct { - bytes: []u8 = &.{}, - wait: enum { none, spawn, input } = .none, -}; - -pub fn armShellSpawn(pane: *Pane) void { - if (comptime !enabled) return; - std.debug.assert(pane.pending_command.bytes.len == 0); - pane.pending_command.wait = .spawn; -} - -/// Remembers a line pardes types into this terminal as the command it -/// started, for Kill: it runs from the shell's next C mark to its D. -/// ponytail: of several lines queued for a shell not up yet, only the last -/// is remembered, as the first of them. -pub fn noteCommand(pane: *Pane, line: []const u8) void { - if (comptime !enabled) return; - const state = pane.terminal orelse return; - const word = std.mem.trim(u8, line, " \t")[0 .. std.mem.indexOfAny(u8, std.mem.trim(u8, line, " \t"), " \t") orelse std.mem.trim(u8, line, " \t").len]; - var sent: @typeInfo(@FieldType(Pane, "sent_command")).optional.child = .{ .seq = state.stream.handler.started +% 1 }; - sent.len = @intCast(@min(word.len, sent.word.len)); - @memcpy(sent.word[0..sent.len], word[0..sent.len]); - pane.sent_command = sent; -} - -/// Whether the command pardes last typed here is running now. -pub fn commandRunning(pane: *const Pane) bool { - if (comptime !enabled) return false; - const state = pane.terminal orelse return false; - const sent = pane.sent_command orelse return false; - const marks = &state.stream.handler; - return marks.phase == .running and marks.started == sent.seq; -} - -pub fn queuePendingCommand(pane: *Pane, command: []const u8) !bool { - if (pane.pending_command.wait == .none) return false; - const old_len = pane.pending_command.bytes.len; - const new_len = try std.math.add(usize, old_len, try std.math.add(usize, command.len, 1)); - const bytes = if (old_len == 0) - try pane.gpa.alloc(u8, new_len) - else - try pane.gpa.realloc(pane.pending_command.bytes, new_len); - @memcpy(bytes[old_len..][0..command.len], command); - bytes[new_len - 1] = '\r'; - pane.pending_command.bytes = bytes; - pane.greet = false; - return true; -} - -pub fn shellSpawned(p: *Pardes, id: usize, prompt_marks: bool) void { - const pane = p.panes[id] orelse return; - if (!pane.isTerminal() or pane.pending_command.wait != .spawn) return; - if (prompt_marks) { - pane.pending_command.wait = .input; - releasePendingCommand(p, id, pane, false); - } else { - pane.greet = false; - releasePendingCommand(p, id, pane, true); - } -} - -pub fn releasePendingCommandIfReady(p: *Pardes, id: usize, pane: *Pane) void { - if (pane.pending_command.wait == .input) - releasePendingCommand(p, id, pane, promptInputReady(pane)); -} - -fn releasePendingCommand(p: *Pardes, id: usize, pane: *Pane, ready: bool) void { - if (!ready) return; - const bytes = pane.pending_command.bytes; - pane.pending_command = .{}; - if (bytes.len > 0) { - p.emitWrite(id, bytes); - pane.gpa.free(bytes); - } -} - -pub fn deinitPendingCommand(pane: *Pane) void { - if (pane.pending_command.bytes.len > 0) - pane.gpa.free(pane.pending_command.bytes); - pane.pending_command = .{}; -} - -pub fn terminalIo() std.Io { - return if (comptime !pardes.hosted) - std.Io.failing - else - std.Io.Threaded.global_single_threaded.io(); -} - -/// The three numbers ghostty's scrollbar reports; all zero without an emulator. -pub const Scrollbar = struct { total: usize = 0, offset: usize = 0, len: usize = 0 }; - -pub fn scrollbar(pane: *const Pane) Scrollbar { - if (comptime !enabled) return .{}; - const state = pane.terminal orelse return .{}; - const sb = state.vt.screens.active.pages.scrollbar(); - return .{ .total = sb.total, .offset = sb.offset, .len = sb.len }; -} - -/// The emulator's viewport offset, in SHELL rows: the top of what it shows. -pub fn gridOffset(pane: *const Pane) i32 { - return @intCast(scrollbar(pane).offset); -} - -/// Where the emulator itself puts the cursor, in active-area cells — the origin -/// without one, which is where an empty pane's cursor belongs anyway. -pub const GridCursor = struct { x: u16 = 0, y: u16 = 0 }; - -pub fn gridCursor(pane: *const Pane) GridCursor { - if (comptime !enabled) return .{}; - const state = pane.terminal orelse return .{}; - const cur = state.vt.screens.active.cursor; - return .{ .x = @intCast(cur.x), .y = @intCast(cur.y) }; -} - -pub fn visibleCursor(pane: *const Pane) ?GridCursor { - if (comptime !enabled) return null; - const state = pane.terminal orelse return null; - if (!state.vt.modes.get(.cursor_visible)) return null; - const cur = state.vt.screens.active.cursor; - const sb = scrollbar(pane); - const row = sb.total - sb.len + cur.y; - if (row < sb.offset or row - sb.offset >= sb.len) return null; - return .{ .x = @intCast(cur.x), .y = @intCast(row - sb.offset) }; -} - -/// Move the emulator's viewport by `delta` shell rows (negative scrolls back). -pub fn scrollGrid(pane: *Pane, delta: i32) void { - if (comptime !enabled) return; - const state = pane.terminal orelse return; - state.vt.screens.active.scroll(.{ .delta_row = delta }); -} - -/// Is the viewport already showing the last row of output? -pub fn atBottom(pane: *const Pane) bool { - if (comptime !enabled) return true; - const sb = scrollbar(pane); - return sb.offset + sb.len >= sb.total; -} - -/// Snap the viewport back onto live output. -pub fn followOutput(pane: *Pane) void { - if (comptime !enabled) return; - const state = pane.terminal orelse return; - state.vt.screens.active.scroll(.active); -} - -/// Reflow the grid. A failed reflow keeps the grid it had rather than dropping -/// a scrollback; the next resize retries with the same numbers. -pub fn resizeGrid(pane: *Pane, gpa: std.mem.Allocator, cols: u16, rows: u16) void { - if (comptime !enabled) return; - const state = pane.terminal orelse return; - if (cols != pane.cols) for (0..pane.sel.len) |button| { - pane.clearPointerSelection(button); - pane.sel[button].state = .none; - }; - state.vt.resize(gpa, .{ .cols = cols, .rows = rows }) catch {}; -} - -/// DECSET 2004: the program wants its pastes bracketed. -pub fn bracketedPaste(pane: *const Pane) bool { - if (comptime !enabled) return false; - const state = pane.terminal orelse return false; - return state.vt.modes.get(.bracketed_paste); -} - -/// The program tracks the mouse itself, so a click in its body is its event. -pub fn reportsMouse(pane: *const Pane) bool { - if (comptime !enabled) return false; - const state = pane.terminal orelse return false; - const m = &state.vt.modes; - return m.get(.mouse_event_normal) or m.get(.mouse_event_button) or m.get(.mouse_event_any); -} - -/// ...and wants them in SGR (1006) rather than the legacy X10 bytes. -pub fn mouseFormatSgr(pane: *const Pane) bool { - if (comptime !enabled) return false; - const state = pane.terminal orelse return false; - return state.vt.modes.get(.mouse_format_sgr); -} - -/// The whole scrollback as plain text, `gpa`-owned: what a terminal's body -/// reads and `Save` writes out. Logical lines: rows the terminal wrapped -/// are joined again (ghostty's wrap flags, as pty/run's output is), and -/// the last line ends with a newline. -pub fn screenTextAlloc(pane: *Pane, gpa: std.mem.Allocator) ![]const u8 { - if (comptime !enabled) return &.{}; - var out: std.Io.Writer.Allocating = .init(gpa); - errdefer out.deinit(); - try writeScreenText(pane, &out.writer); - return out.toOwnedSlice(); -} - -fn writeScreenText(pane: *Pane, w: *std.Io.Writer) !void { - if (comptime !enabled) return; - const state = pane.terminal orelse return; - const screen = state.vt.screens.active; - const br = screen.pages.getBottomRight(.screen) orelse return; - var both: TeeWriter = .{ .a = w }; - try screen.dumpString(&both.writer, .{ .tl = screen.pages.getTopLeft(.screen), .br = br, .unwrap = true }); - if (both.count > 0 and both.final != '\n') try w.writeByte('\n'); -} - -/// Passes bytes through, remembering how many and the last one. -const TeeWriter = struct { - a: *std.Io.Writer, - count: usize = 0, - final: u8 = 0, - writer: std.Io.Writer = .{ .vtable = &.{ .drain = drain }, .buffer = &.{} }, - - fn drain(w: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize { - const t: *TeeWriter = @alignCast(@fieldParentPtr("writer", w)); - var n: usize = 0; - for (data[0 .. data.len - 1]) |bytes| { - try t.a.writeAll(bytes); - t.note(bytes); - n += bytes.len; - } - const pattern = data[data.len - 1]; - for (0..splat) |_| { - try t.a.writeAll(pattern); - t.note(pattern); - n += pattern.len; - } - return n; - } - - fn note(t: *TeeWriter, bytes: []const u8) void { - if (bytes.len == 0) return; - t.count += bytes.len; - t.final = bytes[bytes.len - 1]; - } -}; - -/// How long `screenTextAlloc` would be, counted without keeping it: the -/// length a terminal's body reads as, which its ctl line reports. -pub fn screenTextLen(pane: *Pane) usize { - if (comptime !enabled) return 0; - var buf: [256]u8 = undefined; - var counting: std.Io.Writer.Discarding = .init(&buf); - writeScreenText(pane, &counting.writer) catch return 0; - return @intCast(counting.fullCount()); -} - -/// Release the emulator's heap. The Pane allocation itself is the core's. -pub fn deinitEmulator(pane: *Pane, gpa: std.mem.Allocator) void { - if (comptime !enabled) return; - const state = pane.terminal orelse return; - state.stream.deinit(); - state.vt.deinit(gpa); - gpa.destroy(state); - pane.terminal = null; -} - -/// Allocate the live emulator half of a terminal pane. Slot ownership, serial -/// assignment, and spawn effects remain core lifecycle invariants. -pub fn create(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane { - const pane = try createDoc(gpa, cols, rows); - errdefer gpa.destroy(pane); - if (comptime !enabled) return pane; - const state = try gpa.create(State); - errdefer gpa.destroy(state); - state.vt = try ghostty_vt.Terminal.init(terminalIo(), gpa, .{ - .cols = cols, - .rows = rows, - .max_scrollback = 16 * 1024 * 1024, - }); - state.stream = .initAlloc(gpa, .{ .inner = state.vt.vtHandler() }); - state.replay.head = 0; - state.replay.len = 0; - state.reply_len = 0; - state.stream.handler.inner.effects.write_pty = ptyReport; - state.stream.handler.inner.effects.device_attributes = ptyDeviceAttrs; - pane.terminal = state; - pane.tty_filter = true; - return pane; -} - -pub fn createDoc(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane { - const pane = try gpa.create(Pane); - pane.* = .{ - .gpa = gpa, - .cols = cols, - .rows = rows, - }; - return pane; -} - -/// Where a restored terminal's history ends and its new shell begins. -pub const restored_banner = "\x1b[0m\r\n\x1b[2m\u{2500}\u{2500} restored history \u{2500}\u{2500}\x1b[0m\r\n"; - -/// Rebuild a dump's terminal emulator from its recorded output. `live`: a -/// shell will be spawned into it, so the history is marked off from what -/// that shell prints and the pane waits for it like any new terminal. -/// Registration and tag/cwd policy stay with the core; raw VT replay and -/// viewport restoration belong here. -pub fn restore(p: *Pardes, src: dump.Pane, live: bool) !*Pane { - const terminal = src.terminal.?; - if (comptime !enabled) { - const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows)); - errdefer p.gpa.destroy(pane); - if (terminal.stream.len > 0) - pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, terminal.stream) }; - return pane; - } - // An older dump's raw output tail cannot come back live: cut off mid - // redraw, or written at sizes the pane no longer has, it replays as - // garbage. Its rendered text can. - const legacy_text = live and !terminal.stream_is_screen and terminal.stream.len > 0; - const bytes = if (legacy_text) - try std.mem.replaceOwned(u8, p.scratch.allocator(), terminal.stream, "\n", "\r\n") - else if (terminal.stream_b64.len > 0) - try dump.decodeBytes(p.scratch.allocator(), terminal.stream_b64) - else - &.{}; - const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows)); - if (live) { - if (bytes.len > 0) { - ingest(pane, bytes); - ingest(pane, restored_banner); - } - followOutput(pane); - // A view left scrolled back stays on what it was showing: the - // banner's line breaks scrolled a full screen by that many more. - if (src.scroll > 0) scrollGrid(pane, -@as(i32, @intCast(src.scroll + comptime std.mem.count(u8, restored_banner, "\n")))); - armShellSpawn(pane); - } else if (bytes.len > 0) { - ingest(pane, bytes); - followOutput(pane); - if (src.scroll > 0) - scrollGrid(pane, -@as(i32, @intCast(src.scroll))); - } - return pane; -} - -/// Feed the emulator and retain the bounded suffix a dump can replay. Live -/// output and restoration share this byte path, then apply different views. -fn ingest(pane: *Pane, bytes: []const u8) void { - const state = pane.terminal.?; - const replay = &state.replay; - if (bytes.len >= replay.bytes.len) { - const kept = bytes[bytes.len - replay.bytes.len ..]; - @memcpy(&replay.bytes, kept); - replay.head = 0; - replay.len = replay.bytes.len; - } else { - const overflow = bytes.len -| (replay.bytes.len - replay.len); - replay.head = (replay.head + overflow) % replay.bytes.len; - replay.len -= overflow; - const tail = (replay.head + replay.len) % replay.bytes.len; - const first = @min(bytes.len, replay.bytes.len - tail); - @memcpy(replay.bytes[tail..][0..first], bytes[0..first]); - @memcpy(replay.bytes[0 .. bytes.len - first], bytes[first..]); - replay.len += bytes.len; - } - state.stream.nextSlice(bytes); -} - -/// Return the replay ring in chronological order. Wrapped records are copied -/// into `allocator`; contiguous records remain a borrowed slice of the pane. -fn replayBytes(pane: *const Pane, allocator: std.mem.Allocator) ![]const u8 { - const state = pane.terminal orelse return &.{}; - const replay = &state.replay; - if (replay.len == 0) return &.{}; - if (replay.head + replay.len <= replay.bytes.len) - return replay.bytes[replay.head..][0..replay.len]; - const out = try allocator.alloc(u8, replay.len); - const first = replay.bytes.len - replay.head; - @memcpy(out[0..first], replay.bytes[replay.head..]); - @memcpy(out[first..], replay.bytes[0 .. replay.len - first]); - return out; -} - -test "replay ownership is terminal-only and construction rolls back on allocation failure" { - const Case = struct { - fn run(gpa: std.mem.Allocator, terminal: bool) !void { - const pane = if (terminal) try create(gpa, 40, 12) else try createDoc(gpa, 40, 12); - defer gpa.destroy(pane); - defer deinitEmulator(pane, gpa); - try std.testing.expectEqual(terminal and enabled, pane.terminal != null); - } - }; - try std.testing.expect(@sizeOf(Pane) < 128 * 1024); - try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{false}); - try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{true}); -} - -test "document construction allocates only the pane and has inert terminal defaults" { - var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 1 }); - const allocator = failing.allocator(); - const pane = try createDoc(allocator, 40, 12); - defer allocator.destroy(pane); - defer deinitEmulator(pane, allocator); - try std.testing.expect(pane.terminal == null); - try std.testing.expectEqual(@as(usize, 1), failing.alloc_index); - try std.testing.expectEqual(@as(usize, 0), failing.resize_index); - try std.testing.expect(!failing.has_induced_failure); - try std.testing.expectEqual(@as(usize, @sizeOf(Pane)), failing.allocated_bytes); - try std.testing.expectEqual(GridCursor{}, gridCursor(pane)); - try std.testing.expectEqual(Scrollbar{}, scrollbar(pane)); - try std.testing.expect(!bracketedPaste(pane)); - try std.testing.expect(!reportsMouse(pane)); - try std.testing.expect(!mouseFormatSgr(pane)); - try std.testing.expect(!promptInputReady(pane)); - scrollGrid(pane, 100); - followOutput(pane); - resizeGrid(pane, allocator, 80, 24); - try std.testing.expectEqual(GridCursor{}, gridCursor(pane)); - try std.testing.expectEqual(Scrollbar{}, scrollbar(pane)); - try std.testing.expectEqualStrings("", try screenTextAlloc(pane, allocator)); - try std.testing.expectEqualStrings("", try replayBytes(pane, allocator)); - try std.testing.expect(!failing.has_induced_failure); -} - -test "terminal state keeps parser fragments and sends emulator replies through its owner" { - if (comptime !enabled) return error.SkipZigTest; - const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - const state = pane.terminal.?; - try std.testing.expect(state.stream.handler.inner.terminal == &state.vt); - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[" } }); - while (p.nextEffect()) |effect| { - if (effect == .write) return error.PrematureTerminalReply; - } - p.update(.{ .output = .{ .pane = 0, .bytes = "6n" } }); - var replies: usize = 0; - while (p.nextEffect()) |effect| { - if (effect != .write) continue; - try std.testing.expectEqual(@as(u8, 0), effect.write.pane); - try std.testing.expectEqualStrings("\x1b[1;1R", effect.write.bytes.slice()); - replies += 1; - } - try std.testing.expectEqual(@as(usize, 1), replies); - try std.testing.expectEqual(@as(u16, 0), state.reply_len); - try std.testing.expectEqualStrings("\x1b[6n", try replayBytes(pane, std.testing.allocator)); -} - -test "replay keeps a bounded chronological suffix across large writes and wrapping" { - if (comptime !enabled) return error.SkipZigTest; - const gpa = std.testing.allocator; - const pane = try create(gpa, 40, 12); - defer gpa.destroy(pane); - defer deinitEmulator(pane, gpa); - try std.testing.expectEqualStrings("", try replayBytes(pane, gpa)); - ingest(pane, "hello"); - const small = try replayBytes(pane, gpa); - try std.testing.expectEqualStrings("hello", small); - try std.testing.expectEqual(@intFromPtr(&pane.terminal.?.replay.bytes), @intFromPtr(small.ptr)); - - const capacity = pane.terminal.?.replay.bytes.len; - const input = try gpa.alloc(u8, capacity + 7); - defer gpa.free(input); - @memset(input, 0); - const ending = "\x1b[31mred\x1b[0m\r\n"; - @memcpy(input[input.len - ending.len ..], ending); - ingest(pane, input); - try std.testing.expectEqualSlices(u8, input[7..], try replayBytes(pane, gpa)); - ingest(pane, "next\r\n"); - const wrapped = try replayBytes(pane, gpa); - defer gpa.free(wrapped); - try std.testing.expectEqual(capacity, wrapped.len); - try std.testing.expectEqualSlices(u8, input[7 + "next\r\n".len ..], wrapped[0 .. capacity - "next\r\n".len]); - try std.testing.expectEqualStrings("next\r\n", wrapped[capacity - "next\r\n".len ..]); -} - -test "replay restores terminal cells from the retained stream" { - if (comptime !enabled) return error.SkipZigTest; - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - feedOutput(p, pane, "\x1b[31mred\x1b[0m\r\ncafé\r\n"); - const encoded = try dump.encodeBytes(gpa, try replayBytes(pane, gpa)); - defer gpa.free(encoded); - const restored = try restore(p, .{ - .kind = .terminal, - .tag = "", - .body = "", - .cols = pane.cols, - .rows = pane.rows, - .terminal = .{ .stream_b64 = encoded }, - }, false); - defer gpa.destroy(restored); - defer deinitEmulator(restored, gpa); - const before = try screenTextAlloc(pane, gpa); - defer gpa.free(before); - const after = try screenTextAlloc(restored, gpa); - defer gpa.free(after); - try std.testing.expectEqualStrings(before, after); - try std.testing.expectEqualSlices(u8, try replayBytes(pane, gpa), try replayBytes(restored, gpa)); -} - -test "a restored command pane comes back finished, its command not run again" { - if (comptime !enabled or pardes.isolated) return error.SkipZigTest; - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 60, .rows = 16 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const cmd = p.freeSlot().?; - _ = try p.newCommand(cmd, "/tmp", "make test"); - feedOutput(p, p.panes[cmd].?, "ok\r\n"); - p.update(.{ .exited = .{ .pane = @intCast(cmd), .status = 2 } }); - try dump.dumpState(p); - const restored = try dump.initFromDump(gpa, .{ .tty_only = true, .cols = 60, .rows = 16 }, p.dump_out.?); - defer restored.deinit(); - var spawns: usize = 0; - while (restored.nextEffect()) |effect| switch (effect) { - .spawn => spawns += 1, - else => {}, - }; - try std.testing.expectEqual(@as(usize, 1), spawns); // the shell, not the command - const again = for (restored.panes) |slot| { - if (slot) |pane| if (pane.command != null) break pane; - } else return error.CommandPaneLost; - try std.testing.expectEqualStrings("make test", again.command.?); - try std.testing.expect(again.command_done); - try std.testing.expectEqual(@as(?u8, 2), again.command_status); - const text = try screenTextAlloc(again, gpa); - defer gpa.free(text); - try std.testing.expect(std.mem.indexOf(u8, text, "ok") != null); -} - -test "a restored terminal keeps its history above a marker and gets a shell where it was" { - if (comptime !enabled or pardes.isolated) return error.SkipZigTest; - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - feedOutput(p, pane, "old output\r\n$ ls\r\n"); - try pane.setOwnedCwd("/tmp"); - pane.shell = try gpa.dupe(u8, "bash"); // `Tty bash`: it comes back as bash - try dump.dumpState(p); - const restored = try dump.initFromDump(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }, p.dump_out.?); - defer restored.deinit(); - var spawned = false; - while (restored.nextEffect()) |effect| switch (effect) { - .spawn => |sp| spawned = sp.pane == 0 and std.mem.eql(u8, sp.cwd.slice(), "/tmp"), - else => {}, - }; - try std.testing.expect(spawned); - const again = restored.panes[0].?; - try std.testing.expectEqualStrings("bash", again.shell.?); - try std.testing.expect(again.command == null); - try std.testing.expectEqual(.spawn, again.pending_command.wait); - const text = try screenTextAlloc(again, gpa); - defer gpa.free(text); - const old_at = std.mem.indexOf(u8, text, "old output") orelse return error.HistoryLost; - const mark_at = std.mem.indexOf(u8, text, "restored history") orelse return error.NoMarker; - try std.testing.expect(old_at < mark_at); -} - -test "an older dump comes back live from its rendered text, not its raw output tail" { - if (comptime !enabled) return error.SkipZigTest; - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); - defer p.deinit(); - // A tail cut mid-redraw: relative moves from a cursor it never set. - const tail = try dump.encodeBytes(gpa, "\x1b[7A\x1b[2Kgarbage\x1b[3B\x1b[9Gmore"); - defer gpa.free(tail); - const restored = try restore(p, .{ - .kind = .terminal, - .tag = "", - .body = "", - .cols = 40, - .rows = 12, - .terminal = .{ .stream = "$ ls\na.txt b.txt\n$ ", .stream_b64 = tail }, - }, true); - defer gpa.destroy(restored); - defer deinitEmulator(restored, gpa); - defer deinitPendingCommand(restored); - const text = try screenTextAlloc(restored, gpa); - defer gpa.free(text); - try std.testing.expect(std.mem.indexOf(u8, text, "$ ls\na.txt b.txt") != null); - try std.testing.expect(std.mem.indexOf(u8, text, "garbage") == null); -} - -/// Record and parse one live pty read, invalidate its motion surface, and -/// follow it only when the body (possibly parked under a tag edit) is raw. -pub fn feedOutput(p: *Pardes, pane: *Pane, bytes: []const u8) void { - // There are no pty reads at all without an emulator to parse them into. - if (comptime !enabled) return; - if (pane.terminal == null) return; - const has_positions = pane.ovl != null or pane.body.cur_pinned or pane.body.vsel.active or - pane.body.msel.active or pane.body.nsel > 0 or pane.body.last_edit != null or pane.look_at != null or - pane.body.ed_undo_len > 0 or pane.body.ed_redo_len > 0 or pointerRowCount(pane) > 0; - // Sticky bottom, as every other terminal does it: output drags the - // viewport down only when the viewport was already on the last row, so - // a reader scrolled back into the scrollback stays where they scrolled. - const at_bottom = atBottom(pane); - if (has_positions) ingestWithPositions(pane, bytes) else ingest(pane, bytes); - p.shell_rows.markStale(pane); - if (pane.body.mode == .tty and at_bottom) followOutput(pane); -} - -const RowPin = struct { pin: ?*ghostty_vt.Pin = null, offset: i32 = 0 }; -const PositionPin = struct { - target: *i32, - row: RowPin, - overlay: ?*EditBuffer, - edit_row: ?i32 = null, -}; - -fn trackRow(pages: *ghostty_vt.PageList, row: i32, failed: *bool) RowPin { - if (row < 0) return .{ .offset = row }; - const bounded: usize = @min(@as(usize, @intCast(row)), pages.total_rows - 1); - var left = pages.total_rows - 1 - bounded; - var node = pages.pages.last.?; - while (left >= node.rows()) { - left -= node.rows(); - node = node.prev.?; - } - const pin = pages.trackPin(.{ .node = node, .y = @intCast(node.rows() - 1 - left) }) catch { - failed.* = true; - return .{}; - }; - return .{ .pin = pin, .offset = row - @as(i32, @intCast(bounded)) }; -} - -fn pinnedRow(pages: ?*ghostty_vt.PageList, row: RowPin) i32 { - const pin = row.pin orelse return row.offset; - const alive = pages orelse return 0; - if (pin.garbage) return 0; - var after: usize = 0; - var node = alive.pages.last; - while (node) |item| : (node = item.prev) { - if (item == pin.node) - return @as(i32, @intCast(alive.total_rows - after - item.rows() + pin.y)) +| row.offset; - after += item.rows(); - } - return 0; -} - -fn trackPosition(pages: *ghostty_vt.PageList, target: *i32, overlay: ?*EditBuffer, failed: *bool) PositionPin { - var grid = target.*; - if (overlay) |edit| { - const lines: i32 = @intCast(modal.lineCount(edit.text)); - if (grid >= edit.row and grid < edit.row +| lines) - return .{ .target = target, .row = .{}, .overlay = edit, .edit_row = grid - edit.row }; - if (grid > edit.row) grid = grid -| lines +| edit.rows; - } - return .{ .target = target, .row = trackRow(pages, grid, failed), .overlay = overlay }; -} - -fn pointerRowCount(pane: *const Pane) usize { - var count: usize = 0; - for (pane.sel) |gesture| if (pane.pointerSelection(gesture)) |rows| { - count += rows.len; - }; - return count; -} - -fn ingestWithPositions(pane: *Pane, bytes: []const u8) void { - const screens = &pane.terminal.?.vt.screens; - const key = screens.active_key; - const generation = screens.generation(key); - const pages = &screens.active.pages; - const Group = struct { overlay: ?*EditBuffer, cursor: *i32, selection: *panes.Text.CharSel }; - var groups: [history_max * 2 + 1]Group = undefined; - groups[0] = .{ - .overlay = if (pane.ovl) |*overlay| overlay else null, - .cursor = &pane.body.cur_row, - .selection = &pane.body.vsel, - }; - var ngroups: usize = 1; - for ([_][]Snapshot{ pane.body.ed_undo[0..pane.body.ed_undo_len], pane.body.ed_redo[0..pane.body.ed_redo_len] }) |history| { - for (history) |*snapshot| { - groups[ngroups] = .{ - .overlay = if (snapshot.ovl) |*overlay| overlay else null, - .cursor = &snapshot.cur_row, - .selection = &snapshot.vsel, - }; - ngroups += 1; - } - } - const Span = struct { overlay: *EditBuffer, start: RowPin, end: RowPin }; - var spans: [groups.len]Span = undefined; - var nspans: usize = 0; - var positions: [groups.len * 2 + panes.Text.max_selections * 2 + 4]PositionPin = undefined; - var npositions: usize = 0; - var failed = false; - for (groups[0..ngroups]) |group| { - if (group.overlay) |overlay| { - spans[nspans] = .{ - .overlay = overlay, - .start = trackRow(pages, overlay.row, &failed), - .end = trackRow(pages, overlay.row +| (overlay.rows - 1), &failed), - }; - nspans += 1; - } - positions[npositions] = trackPosition(pages, group.cursor, group.overlay, &failed); - npositions += 1; - if (group.selection.active) { - positions[npositions] = trackPosition(pages, &group.selection.row, group.overlay, &failed); - npositions += 1; - } - } - const overlay = groups[0].overlay; - var extra: [panes.Text.max_selections * 2 + 4]*i32 = undefined; - var nextra: usize = 0; - if (pane.body.msel.active) { - extra[nextra] = &pane.body.msel.r0; - extra[nextra + 1] = &pane.body.msel.r1; - nextra += 2; - } - for (pane.body.sels[0..pane.body.nsel]) |*selection| { - extra[nextra] = &selection.row; - extra[nextra + 1] = &selection.arow; - nextra += 2; - } - if (pane.body.last_edit) |*at| { - extra[nextra] = &at.row; - nextra += 1; - } - if (pane.look_at) |*at| { - extra[nextra] = &at.row; - nextra += 1; - } - for (extra[0..nextra]) |target| { - positions[npositions] = trackPosition(pages, target, overlay, &failed); - npositions += 1; - } - const pointer_positions = pane.gpa.alloc(PositionPin, pointerRowCount(pane)) catch null; - defer if (pointer_positions) |pins| pane.gpa.free(pins); - if (pointer_positions) |pins| { - var index: usize = 0; - for (pane.sel) |gesture| if (pane.pointerSelection(gesture)) |rows| { - for (rows) |*row| { - pins[index] = trackPosition(pages, &row.row, if (row.raw_terminal) null else overlay, &failed); - index += 1; - } - }; - } else failed = true; - ingest(pane, bytes); - // RIS can destroy the alternate screen and every pin it owned. - const alive = if (screens.generation(key) == generation) pages else null; - var moved = false; - var pointer_rows_lost = alive == null or pointer_positions == null; - if (pointer_positions) |pins| for (pins) |position| { - if (alive != null) if (position.row.pin) |pin| { - if (pin.garbage) pointer_rows_lost = true; - }; - }; - for (spans[0..nspans]) |span| { - const start = pinnedRow(alive, span.start); - const rows = @max(1, pinnedRow(alive, span.end) -| start +| 1); - moved = moved or start != span.overlay.row or rows != span.overlay.rows; - span.overlay.row = start; - span.overlay.rows = rows; - if (alive) |owner| { - if (span.start.pin) |pin| owner.untrackPin(pin); - if (span.end.pin) |pin| owner.untrackPin(pin); - } - } - for ([_][]PositionPin{ positions[0..npositions], pointer_positions orelse &.{} }) |batch| for (batch) |position| { - var row = pinnedRow(alive, position.row); - if (position.overlay) |edit| { - if (position.edit_row) |relative| { - row = edit.row +| relative; - } else if (row > edit.row) { - row = if (row < edit.row +| edit.rows) - edit.row - else - row -| edit.rows +| @as(i32, @intCast(modal.lineCount(edit.text))); - } - } - moved = moved or row != position.target.*; - position.target.* = row; - if (alive) |owner| if (position.row.pin) |pin| owner.untrackPin(pin); - }; - if (pointer_rows_lost) for (0..pane.sel.len) |button| { - pane.clearPointerSelection(button); - pane.sel[button].state = .none; - }; - if (moved) pane.nsel_snap = 0; // Saved regex-preview offsets name the old flat text. - if (failed) { - const message = "terminal edit position reset: out of memory"; - @memcpy(pane.msg[0..message.len], message); - pane.msg_len = message.len; - } -} - -test "raw terminal output needs no position tracking allocations" { - if (comptime !enabled) return error.SkipZigTest; - var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{}); - const p = try Pardes.init(failing.allocator(), .{ .tty_only = true, .cols = 80, .rows = 24 }); - defer p.deinit(); - const pane = p.panes[0].?; - pane.body.mode = .tty; - feedOutput(p, pane, "warm"); - const allocations = failing.alloc_index; - failing.fail_index = allocations; - const pins = pane.terminal.?.vt.screens.active.pages.countTrackedPins(); - feedOutput(p, pane, " output"); - try std.testing.expectEqual(allocations, failing.alloc_index); - try std.testing.expect(!failing.has_induced_failure); - try std.testing.expectEqual(pins, pane.terminal.?.vt.screens.active.pages.countTrackedPins()); -} - -/// A full-screen program has the emulator on its alternate screen. -pub fn onAlternateScreen(pane: *const Pane) bool { - if (comptime !enabled) return false; - const state = pane.terminal orelse return false; - return state.vt.screens.active_key == .alternate; -} - -/// Whether a new terminal's greeting may be typed now: only at the shell's -/// first prompt (pardes's own marks: no command started; a prompt drawn -/// again for a resize is the same first prompt), with nothing -/// typed at it and no program on the alternate screen. Any later moment is `.never`, for good: something has run or -/// been typed first, and an `ls` then would go into it. A shell pardes -/// cannot mark has no first prompt to find, and is never greeted. -pub fn greeting(pane: *const Pane) enum { wait, now, never } { - if (comptime !enabled) return .never; - if (pane.fs.unmarked) return .never; - // Text typed into the edit buffer that stands in for the prompt row. - if (pane.ovl) |o| if (o.text.len > 0) return .never; - const state = pane.terminal orelse return .wait; - const marks = &state.stream.handler; - // (Not `declined`: bash's first prompt comes after a D of its own.) - if (marks.started > 0) return .never; - // At a prompt's input (ghostty's semantic marks, pardes's or the - // shell's own), before any command pardes's marks saw run. - if (!promptInputReady(pane)) return .wait; - return if (promptInputEmpty(pane)) .now else .never; -} - -pub fn promptInputReady(pane: *const Pane) bool { - if (comptime !enabled) return false; - const state = pane.terminal orelse return false; - return state.vt.screens.active_key != .alternate and - state.vt.screens.active.cursor.semantic_content == .input; -} - -pub const CommandOutput = struct { - /// Owned by the caller. - text: [:0]const u8, - /// Whether `text` starts where the command did. It does not when that - /// start is not there to read: cut off by `max_rows`, scrolled out of - /// the history (ghostty moved the pin to the oldest row it kept and - /// flagged it `garbage`), erased under it (`MarkedHandler.lost`), or - /// never pinned (a C or D that came on the alternate screen). - whole: bool, -}; - -/// What the newest finished command printed: the screen text between its -/// C and D marks, read from at most `max_rows` rows above its end, so a -/// command that printed the whole history costs no more than its tail. -/// The shell exited in the middle of a command (its `exit 3`): no end mark -/// will come, so the command's output ends where the cursor is. -pub fn endOutputHere(pane: *Pane) void { - if (comptime !enabled) return; - const state = pane.terminal orelse return; - const marks = &state.stream.handler; - if (marks.phase == .running and marks.out_end == null) marks.pinCursor(&marks.out_end, true); -} - -pub fn commandOutput(pane: *Pane, gpa: std.mem.Allocator, max_rows: usize) !CommandOutput { - // Unless there is text to read, whether the nothing it printed is the - // whole of it. - const whole = if (comptime enabled) printed: { - const state = pane.terminal orelse break :printed true; - const marks = &state.stream.handler; - const start = marks.out_start orelse break :printed false; - const end = marks.out_end orelse break :printed false; - const screen = state.vt.screens.get(.primary) orelse break :printed false; - if (end.garbage) break :printed false; - var whole = !marks.lost and !start.garbage; - // D is where the next prompt begins: the output ends a cell before. - const last = end.leftWrap(1) orelse break :printed whole; - if (start.eql(end.*) or last.before(start.*)) break :printed whole; - var from = start.*; - if (last.up(max_rows)) |floor| if (from.before(floor)) { - from = floor; - from.x = 0; - whole = false; - }; - return .{ - .text = try screen.selectionString(gpa, .{ .sel = ghostty_vt.Selection.init(from, last, false) }), - .whole = whole, - }; - } else true; - return .{ .text = try gpa.dupeZ(u8, ""), .whole = whole }; -} - -/// At a prompt with nothing typed on its line: no input left of the -/// cursor and none under it (text typed and then Ctrl-A'd starts at the -/// cursor). Nothing further right counts: that is where fish draws a -/// right prompt, and fish suggests nothing on an empty line. -pub fn promptInputEmpty(pane: *const Pane) bool { - if (comptime !enabled) return false; - if (!promptInputReady(pane)) return false; - const cursor = &pane.terminal.?.vt.screens.active.cursor; - const row = cursor.page_pin.cells(.all); - for (row[0..@min(cursor.x + 1, row.len)]) |cell| - if (cell.semantic_content == .input and !cell.isEmpty()) return false; - return true; -} - -/// Encode one key for the program that owns a raw terminal and queue its pty -/// write, respecting the application's keyboard protocol and terminal modes. -pub fn forwardKey(p: *Pardes, id: usize, key: Key) void { - if (comptime enabled) { - const pane = p.panes[id] orelse return; - const state = pane.terminal orelse return; - const input = ghostty_vt.input; - const function_keys = comptime keys: { - var keys: [Key.f25 - Key.f1 + 1]input.Key = undefined; - for (&keys, 1..) |*k, n| k.* = @field(input.Key, std.fmt.comptimePrint("f{d}", .{n})); - break :keys keys; - }; - 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, - Key.insert => .insert, - Key.print_screen => .print_screen, - Key.scroll_lock => .scroll_lock, - Key.pause => .pause, - Key.menu => .context_menu, - Key.f1...Key.f25 => function_keys[key.cp - Key.f1], - 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; - // A key with no text of its own types its code point only when that - // is a character: the kitty keyboard protocol numbers function and - // modifier keys in the private use area (F3 is U+E016), and a shell - // was typed those as junk. - const text = if (key.text.len > 0) key.text else if (key.cp >= 32 and key.cp < Key.up and key.cp != Key.backspace and !(key.cp >= 0xE000 and key.cp <= 0xF8FF)) - utf8[0 .. std.unicode.utf8Encode(text_cp, &utf8) catch return] - else - ""; - var buffer: [256]u8 = undefined; - var writer = std.Io.Writer.fixed(&buffer); - input.encodeKey(&writer, .{ - .key = physical, - .mods = .{ .ctrl = key.ctrl, .alt = key.alt, .shift = key.shift }, - .consumed_mods = .{ .shift = key.text.len > 0 and key.shift }, - .utf8 = text, - .unshifted_codepoint = if (key.cp >= 'A' and key.cp <= 'Z') key.cp - 'A' + 'a' else if (key.cp < Key.up) key.cp else 0, - }, options: { - var opts = input.KeyEncodeOptions.fromTerminal(&state.vt); - // fromTerminal cannot know this one and says so, defaulting to - // "option is not alt" — right for an app that lets macOS do the - // unicode translation and hands the encoder the composed text. - // Every pardes shell resolves the modifier itself and hands the - // core the BASE key with the alt bit and no text at all (see - // PardesView.keyDown, and app.mjs doing the same with the same - // two bits), so by the time this runs alt already means alt. - // Left at the default, a macOS build drops the ESC prefix on - // the floor and forwards Alt-n to the child as a bare `n`. - opts.macos_option_as_alt = .true; - break :options opts; - }) catch return; - if (writer.end > 0) p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(writer.buffered()) } }); - return; - } - var control: [1]u8 = undefined; - const bytes: ?[]const u8 = blk: { - if (key.ctrl) { - if (key.cp >= 'a' and key.cp <= 'z') { - control[0] = @intCast(key.cp - 0x60); - break :blk control[0..1]; - } - // ASCII @, A-Z, [, \, ], ^ and _ are one contiguous control range. - if (key.cp >= '@' and key.cp <= '_') { - control[0] = @intCast(key.cp - 0x40); - break :blk control[0..1]; - } - } - if (key.text.len > 0) break :blk key.text; - break :blk switch (key.cp) { - Key.enter => "\r", - Key.backspace => "\x7f", - Key.tab => "\t", - Key.escape => "\x1b", - Key.up => "\x1b[A", - Key.down => "\x1b[B", - Key.right => "\x1b[C", - Key.left => "\x1b[D", - Key.delete => "\x1b[3~", - else => null, - }; - }; - if (bytes) |encoded| - p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(encoded) } }); -} - -pub fn enterTty(p: *Pardes, id: usize) void { - const pane = p.panes[id] orelse return; - const selected = pane.rawPointerText(pane.sel[@intFromEnum(config.select_button)]) != null; - if (comptime enabled) if (!selected and pane.terminal != null and pane.body.cur_pinned and pane.terminal.?.vt.cursorIsAtPrompt()) handoff: { - const screen = pane.terminal.?.vt.screens.active; - const goff: i32 = @intCast(screen.pages.scrollbar().offset); - const vp_row = pane.gridRow(pane.body.cur_row) - goff; - if (vp_row < 0) break :handoff; - - var grid_col: i32 = @max(0, pane.body.cur_col); - if (screen.pages.pin(.{ .viewport = .{ .x = 0, .y = @intCast(vp_row) } })) |row_pin| { - if (row_pin.rowAndCell().row.semantic_prompt != .none) switch (promptCut(row_pin)) { - .cut => |cols| grid_col += @intCast(cols), - .keep, .blank => {}, - }; - } - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = @intCast(grid_col), .y = @intCast(vp_row) }, - }) orelse break :handoff; - const cursor_pin = screen.cursor.page_pin.*; - var prompts = cursor_pin.promptIterator(.left_up, null); - const prompt_pin = prompts.next() orelse break :handoff; - if (click_pin.before(prompt_pin)) break :handoff; - const moves = screen.promptClickMove(click_pin); - for (0..moves.left) |_| p.emitWrite(id, "\x1b[D"); - for (0..moves.right) |_| p.emitWrite(id, "\x1b[C"); - }; - - pane.body.mode = .tty; - followOutput(pane); // entering raw mode is a request for the live shell - pane.body.msel.active = false; - pane.body.vsel.active = false; - pane.body.nsel = 0; - // The raw renderer follows the live cursor. A captured mouse selection - // keeps its modal endpoint for the next return to editor mode. - pane.body.cur_pinned = selected; - pane.body.select = false; - pane.body.restore_cursor = false; - pane.body.sticky_col = -1; - pane.body.normal.clear(); -} - -// Returned slices remain valid until the next cache rebuild or reset. -pub const RowsCache = struct { - /// whose grid this describes; null = the slot is free - pane: ?*const Pane = null, - /// the rows joined by '\n' — `flatSurface` hands this back verbatim - /// instead of rebuilding the join on every keystroke - text: []const u8 = &.{}, - /// slices INTO `text`, absolute grid rows from 0 - rows: [][]const u8 = &.{}, - /// `text` is a prefix of this: blanking a prompt row shortens the join, - /// and the slack is not worth a second allocation to reclaim - text_alloc: []u8 = &.{}, - stale: bool = false, - - pub fn reset(c: *RowsCache, gpa: std.mem.Allocator) void { - if (c.text_alloc.len > 0) gpa.free(c.text_alloc); - if (c.rows.len > 0) gpa.free(c.rows); - c.* = .{}; - } - - /// Free a stale entry. Called at the top of `update`, and nowhere else. - pub fn sweep(c: *RowsCache, gpa: std.mem.Allocator) void { - if (c.stale) c.reset(gpa); - } - - /// `pane`'s grid moved: the entry no longer describes it. - pub fn markStale(c: *RowsCache, pane: *const Pane) void { - if (c.pane == pane) c.stale = true; - } - - pub fn dropPane(c: *RowsCache, pane: *const Pane) void { - if (c.pane != pane) return; - c.pane = null; - c.stale = true; - } -}; - -const Rows = struct { - text_alloc: []u8, - text: []const u8, - rows: [][]const u8, -}; - -const empty_grid = [1][]const u8{""}; - -/// What LEAVING raw tty mode does to one prompt row, decided from its cells -/// alone. See config.tty_blank for why any of this happens. -const PromptCut = union(enum) { - /// show the row exactly as ghostty dumped it - keep, - /// show nothing at all - blank, - /// drop this many leading COLUMNS — the prompt — and keep the rest, which - /// is what was typed at it - cut: usize, -}; - -fn promptCut(pin: ghostty_vt.Pin) PromptCut { - if (config.tty_blank == .prompt_and_input) return .blank; - const cells = pin.cells(.all); - var cols: usize = 0; - while (cols < cells.len and cells[cols].semantic_content == .prompt) cols += 1; - // Flagged, but with no prompt cells at the FRONT: a right-side prompt, or - // a repaint that has moved on. Nothing here is the prompt, so hide nothing. - if (cols == 0) return .keep; - // ...and all prompt, nothing typed yet: the row is chrome end to end. - if (cols >= cells.len) return .blank; - return .{ .cut = cols }; -} - -pub fn promptPrefixBytes(pane: *Pane, row: i32, raw: []const u8) usize { - if (comptime !enabled) return 0; - if (row < 0) return 0; - const state = pane.terminal orelse return 0; - const pin = state.vt.screens.active.pages.pin(.{ .screen = .{ .x = 0, .y = @intCast(row) } }) orelse return 0; - if (pin.rowAndCell().row.semantic_prompt == .none) return 0; - return switch (promptCut(pin)) { - .keep => 0, - .blank => raw.len, - .cut => |cols| @min(raw.len, File.rawAtDisplay(raw, cols)), - }; -} - -fn promptRow(pin: ghostty_vt.Pin, raw: []const u8) []const u8 { - const cols = switch (promptCut(pin)) { - .keep => return raw, - .blank => return "", - .cut => |n| n, - }; - const cells = pin.cells(.all); - var at: usize = 0; - var col: usize = 0; - while (col < cols and at < raw.len) { - const cell = &cells[col]; - at = @min(raw.len, at + dumpedBytes(pin, cell)); - // the tail cell of a wide glyph spells nothing of its own - col += if (cell.wide == .wide) @as(usize, 2) else 1; - } - return std.mem.trimEnd(u8, raw[at..], " \t"); -} - -fn dumpedBytes(pin: ghostty_vt.Pin, cell: *const ghostty_vt.Cell) usize { - switch (cell.wide) { - .spacer_head, .spacer_tail => return 0, - .narrow, .wide => {}, - } - var n: usize = switch (cell.content_tag) { - .codepoint, .codepoint_grapheme => std.unicode.utf8CodepointSequenceLength( - cell.codepoint(), - ) catch 1, - // A cell carrying only a colour still spells one blank in the dump. - else => 1, - }; - if (cell.content_tag == .codepoint_grapheme) { - if (pin.grapheme(cell)) |extra| for (extra) |cp| { - n += std.unicode.utf8CodepointSequenceLength(cp) catch 1; - }; - } - return n; -} - -pub fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 { - if (comptime !enabled) return &empty_grid; - if (pane.terminal == null) return &empty_grid; - const c = &p.shell_rows; - if (!c.stale and c.pane == pane) return c.rows; - if (c.pane != null) { - c.stale = true; - return (try buildRows(p.scratch.allocator(), p, pane)).rows; - } - const built = try buildRows(p.gpa, p, pane); - c.* = .{ - .pane = pane, - .text = built.text, - .rows = built.rows, - .text_alloc = built.text_alloc, - }; - return c.rows; -} - -/// The full modal motion surface: shell history with the live edit overlay -/// spliced into the rows it covers. -pub fn cursorLines(p: *Pardes, pane: *Pane) ![]const []const u8 { - const rows = try shellRows(p, pane); - if (pane.ovl == null) return rows; - var last = rows.len; - if (pane.ovl) |overlay| - last = @max(last, @as(usize, @intCast(@max(0, overlay.row + overlay.rows)))); - var count = last; - if (pane.ovl) |overlay| { - if (overlay.row >= 0 and @as(usize, @intCast(overlay.row)) < last) - count = count - @min( - @as(usize, @intCast(overlay.rows)), - last - @as(usize, @intCast(overlay.row)), - ) + @max(1, modal.lineCount(overlay.text)); - } - const lines = try p.scratch.allocator().alloc([]const u8, count); - var n: usize = 0; - var grid_row: usize = 0; - while (grid_row < last) : (grid_row += 1) { - if (pane.ovl) |overlay| if (overlay.row >= 0 and grid_row == @as(usize, @intCast(overlay.row))) { - var overlay_lines = std.mem.splitScalar(u8, overlay.text, '\n'); - while (overlay_lines.next()) |line| : (n += 1) lines[n] = line; - grid_row += @intCast(overlay.rows - 1); - continue; - }; - lines[n] = if (grid_row < rows.len) rows[grid_row] else ""; - n += 1; - } - return lines[0..n]; -} - -/// Flatten `cursorLines` without rebuilding the common cached/no-overlay -/// case. Scratch-owned when a join is required. -pub fn flatSurface(p: *Pardes, pane: *Pane, lines: []const []const u8) ![]const u8 { - const cache = &p.shell_rows; - if (!cache.stale and cache.pane == pane and - lines.ptr == cache.rows.ptr and lines.len == cache.rows.len) return cache.text; - var total: usize = if (lines.len > 0) lines.len - 1 else 0; - for (lines) |line| total += line.len; - const text = try p.scratch.allocator().alloc(u8, total); - var at: usize = 0; - for (lines, 0..) |line, i| { - if (i > 0) { - text[at] = '\n'; - at += 1; - } - @memcpy(text[at..][0..line.len], line); - at += line.len; - } - return text; -} - -/// Materialize or extend the terminal edit overlay with one exact allocation. -/// Row slices are scratch-owned/borrowed; only the joined text is installed. -pub fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?EditText { - const want_lo = @max(0, @min(lo, hi)); - const want_hi = @max(want_lo, @max(lo, hi)); - const fresh = pane.ovl == null; - const old: EditBuffer = pane.ovl orelse .{ .row = want_lo, .rows = 1, .text = &.{} }; - const lines: i32 = if (fresh) 1 else @intCast(modal.lineCount(old.text)); - const up = old.row - want_lo; - const down = want_hi - (old.row + lines - 1); - const extending = fresh or up > 0 or down > 0; - var row0 = old.row; - var covered = old.rows; - var text = old.text; - var owned = false; - if (extending) { - const rows = shellRows(p, pane) catch return null; - row0 = old.row - @max(0, up); - covered = old.rows + @max(0, up) + @max(0, down); - const up_len: usize = @intCast(@max(0, up)); - const down_len: usize = @intCast(@max(0, down)); - const parts = p.scratch.allocator().alloc([]const u8, up_len + 1 + down_len) catch return null; - for (parts[0..up_len], 0..) |*part, i| { - const src = @as(usize, @intCast(row0)) + i; - part.* = if (src < rows.len) rows[src] else ""; - } - const middle: usize = @intCast(old.row); - parts[up_len] = if (fresh) - (if (middle < rows.len) rows[middle] else "") - else - old.text; - for (parts[up_len + 1 ..], 0..) |*part, i| { - const src = @as(usize, @intCast(old.row + old.rows)) + i; - part.* = if (src < rows.len) rows[src] else ""; - } - text = std.mem.join(p.gpa, "\n", parts) catch return null; - owned = true; - } - - const row: usize = @intCast(@max(0, want_lo - row0)); - const line_len: i32 = @intCast(modal.lineSlice(text, row).len); - if (col > line_len) { - const spaces = p.scratch.allocator().alloc(u8, @intCast(col - line_len)) catch { - if (owned) p.gpa.free(text); - return null; - }; - @memset(spaces, ' '); - const padded = modal.insertAt(p.gpa, text, .{ .row = row, .col = @intCast(line_len) }, spaces) catch { - if (owned) p.gpa.free(text); - return null; - }; - if (owned) p.gpa.free(text); - text = padded; - owned = true; - } - if (owned) { - if (pane.ovl) |overlay| p.gpa.free(overlay.text); - pane.ovl = .{ .row = row0, .rows = covered, .text = text }; - } - return .{ .text = pane.ovl.?.text, .row0 = pane.ovl.?.row }; -} - -/// Consume a rewritten overlay, freeing the terminal edit text it replaces. -pub fn setEditText(p: *Pardes, pane: *Pane, new: []u8) void { - const overlay = if (pane.ovl) |*value| value else return pardes.edit.retireEditText(p, new); - for (0..pane.sel.len) |button| { - pane.clearPointerSelection(button); - pane.sel[button].state = .none; - } - p.gpa.free(overlay.text); - overlay.text = new; -} - -/// The shell's screen and scrollback (the primary screen, even under a -/// full-screen program) as VT that replays to what was shown: styled -/// text, soft-wrapped lines rejoined so they reflow at another width. -fn screenVt(pane: *const Pane, arena: std.mem.Allocator) ![]const u8 { - const screen = pane.terminal.?.vt.screens.get(.primary) orelse return &.{}; - var out: std.Io.Writer.Allocating = .init(arena); - try ghostty_vt.formatter.ScreenFormatter.init(screen, .{ .emit = .vt, .unwrap = true }).format(&out.writer); - return out.written(); -} - -/// Serialize terminal-only state; the core supplies shared pane metadata. -pub fn dumpPane( - pane: *Pane, - arena: std.mem.Allocator, - tag: []const u8, - body: []const u8, - scroll: usize, -) !dump.Pane { - if (!enabled or pane.terminal == null) { - const text = if (pane.ovl) |overlay| overlay.text else ""; - return .{ - .kind = .terminal, - .tag = tag, - .body = body, - .scroll = scroll, - .cols = pane.cols, - .rows = pane.rows, - .vweight = pane.vweight, - .collapsed = pane.collapsed, - .terminal = .{ - .cwd = try arena.dupe(u8, pane.cwdSlice()), - .shell = try arena.dupe(u8, pane.shell orelse ""), - .command = try arena.dupe(u8, pane.command orelse ""), - .status = pane.command_status, - .stream = try arena.dupe(u8, text), - .stream_b64 = &.{}, - .cursor = .{ .col = 0, .row = 0 }, - }, - }; - } - const full = try pane.terminal.?.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} }); - const extra = if (pane.ovl) |overlay| overlay.text.len else 0; - const stream = try arena.alloc(u8, try std.math.add(usize, full.len, extra)); - var len: usize = 0; - var lines = std.mem.splitAny(u8, full, "\n"); - var prompts = pane.terminal.?.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); - var row: i32 = 0; - var skip: i32 = 0; - while (lines.next()) |raw| : (row += 1) { - // Hidden overlay rows still consume prompt pins to keep them aligned. - const prompt = if (pane.body.mode != .tty) prompts.next() else null; - if (skip > 0) { - skip -= 1; - continue; - } - if (row > 0) { - stream[len] = '\n'; - len += 1; - } - if (pane.body.mode != .tty) if (pane.ovl) |overlay| if (row == overlay.row) { - @memcpy(stream[len..][0..overlay.text.len], overlay.text); - len += overlay.text.len; - skip = overlay.rows - 1; - continue; - }; - const shown = if (prompt) |pin| - if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, raw) else raw - else - raw; - @memcpy(stream[len..][0..shown.len], shown); - len += shown.len; - } - return .{ - .kind = .terminal, - .tag = tag, - .body = body, - .scroll = scroll, - .cols = pane.cols, - .rows = pane.rows, - .vweight = pane.vweight, - .collapsed = pane.collapsed, - .terminal = .{ - .cwd = try arena.dupe(u8, pane.cwdSlice()), - .shell = try arena.dupe(u8, pane.shell orelse ""), - .command = try arena.dupe(u8, pane.command orelse ""), - .status = pane.command_status, - .stream = stream[0..len], - .stream_b64 = try dump.encodeBytes(arena, try screenVt(pane, arena)), - .stream_is_screen = true, - .cursor = .{ - .col = pane.terminal.?.vt.screens.active.cursor.x, - .row = pane.terminal.?.vt.screens.active.cursor.y, - }, - }, - }; -} - -fn buildRows(alloc: std.mem.Allocator, p: *Pardes, pane: *Pane) !Rows { - const full = try pane.terminal.?.vt.screens.active.dumpStringAlloc(p.scratch.allocator(), .{ .screen = .{} }); - var pit = pane.terminal.?.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); - // split yields one more item than delimiters; the extra final slot is the - // cursor row retained below. - const n_rows = std.mem.count(u8, full, "\n") + 2; - const rows = try alloc.alloc([]const u8, n_rows); - errdefer alloc.free(rows); - // Blanking a prompt row only ever SHORTENS it and the retained cursor row - // adds one separator, so the dump's length plus one bounds the join. - const text = try alloc.alloc(u8, full.len + 1); - errdefer alloc.free(text); - var at: usize = 0; - var n: usize = 0; - var it = std.mem.splitScalar(u8, full, '\n'); - while (it.next()) |raw| { - const shown = if (pit.next()) |pin| - if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, raw) else raw - else - raw; - if (n > 0) { - text[at] = '\n'; - at += 1; - } - @memcpy(text[at..][0..shown.len], shown); - rows[n] = text[at..][0..shown.len]; - at += shown.len; - n += 1; - } - text[at] = '\n'; - at += 1; - rows[n] = text[at..][0..0]; - n += 1; - std.debug.assert(n == n_rows); - return .{ .text_alloc = text, .text = text[0..at], .rows = rows }; -} - -pub fn bodyText(arena: std.mem.Allocator, pane: *Pane) ![]const u8 { - const vp: []const []const u8 = if (comptime !enabled) &.{} else vp: { - const state = pane.terminal orelse break :vp &.{}; - const screen = state.vt.screens.active; - var tl = screen.pages.getTopLeft(.viewport); - tl.x = 0; - const br = screen.pages.getBottomRight(.viewport) orelse return error.UnknownPoint; - var rows_out: std.Io.Writer.Allocating = .init(arena); - try screen.dumpString(&rows_out.writer, .{ .tl = tl, .br = br, .unwrap = false }); - const raw = try rows_out.toOwnedSlice(); - var prompts = screen.pages.rowIterator(.right_down, .{ .viewport = .{} }, null); - const vp = try arena.alloc([]const u8, std.mem.count(u8, raw, "\n") + 1); - var lines = std.mem.splitScalar(u8, raw, '\n'); - var n: usize = 0; - while (lines.next()) |ln| { - vp[n] = if (pane.body.mode != .tty) - if (prompts.next()) |pin| - if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, ln) else ln - else - ln - else - ln; - n += 1; - } - std.debug.assert(n == vp.len); - break :vp vp; - }; - - const len = fillBody(null, pane, vp); - const out = try arena.alloc(u8, len); - const filled = fillBody(out, pane, vp); - std.debug.assert(filled == out.len); - return out; -} - -pub const BodyRow = union(enum) { - grid: i32, - edit: struct { line: []const u8, idx: usize }, -}; - -const BodyWalk = struct { - pane: *Pane, - goff: i32, - g: i32, - /// the buffer can start above the viewport: drop the lines scrolled past - skip: usize, - n: usize = 0, - lines: ?std.mem.SplitIterator(u8, .scalar) = null, - covered: i32 = 0, - line_idx: usize = 0, - - fn init(pane: *Pane) BodyWalk { - const off = pane.scroll(); - const goff = gridOffset(pane); - return .{ - .pane = pane, - .goff = goff, - .g = if (pane.body.mode == .tty) goff else pane.gridRow(off), - .skip = if (pane.ovl) |o| @intCast(@max(0, off - pane.surfRow(o.row))) else 0, - }; - } - - fn next(w: *BodyWalk) ?BodyRow { - while (w.n < w.pane.rows) { - if (w.lines) |*it| { - if (it.next()) |line| { - const idx = w.line_idx; - w.line_idx += 1; - // Lines scrolled off the top still count: the index names a - // line of the BUFFER, not of the visible body. - if (w.skip > 0) { - w.skip -= 1; - continue; - } - w.n += 1; - return .{ .edit = .{ .line = line, .idx = idx } }; - } - // The buffer stands in for `rows` shell rows however many lines - // it actually spelled, which is the whole slide. - w.g += w.covered; - w.skip = 0; - w.lines = null; - continue; - } - if (w.pane.body.mode != .tty) if (w.pane.ovl) |o| if (w.g == o.row) { - w.lines = std.mem.splitScalar(u8, o.text, '\n'); - w.covered = o.rows; - w.line_idx = 0; - continue; - }; - const vi = w.g - w.goff; - w.g += 1; - w.n += 1; - return .{ .grid = vi }; - } - return null; - } -}; - -/// Run the terminal body row walk. A null destination counts bytes; a slice -/// fills the exact allocation made from that count. -fn fillBody(dst: ?[]u8, pane: *Pane, viewport: []const []const u8) usize { - var walk: BodyWalk = .init(pane); - var written: usize = 0; - var first = true; - while (walk.next()) |row| { - if (!first) { - if (dst) |out| out[written] = '\n'; - written += 1; - } - first = false; - const bytes = switch (row) { - .edit => |e| e.line, - .grid => |vi| if (vi >= 0 and @as(usize, @intCast(vi)) < viewport.len) - viewport[@intCast(vi)] - else - "", - }; - if (dst) |out| @memcpy(out[written..][0..bytes.len], bytes); - written += bytes.len; - } - return written; -} - -// Match surviving edit-buffer rows to their original terminal styles. -const EditAnchors = struct { - /// the buffer's own text, walked in order: a line the VIEWPORT skipped still - /// consumes the row it came from, so the lines below it stay aligned - text: []const u8 = &.{}, - at: usize = 0, - shell: []const []const u8 = &.{}, - /// the covered span, absolute grid rows, as `[first, end)` - first: usize = 0, - end: usize = 0, - lines: usize = 0, - /// the line `at` names, and the first row still unclaimed - idx: usize = 0, - cursor: usize = 0, - budget: usize = 0, - active: bool = false, - - fn init(p: *Pardes, pane: *Pane, o: EditBuffer) EditAnchors { - if (o.rows <= 0 or o.row < 0) return .{}; - const first: usize = @intCast(o.row); - const screen = pane.terminal.?.vt.screens.active; - const total = screen.pages.scrollbar().total; - if (first >= total) return .{}; - const covered: usize = @intCast(o.rows); - const count = @min(covered, total - first); - const tl = screen.pages.pin(.{ .screen = .{ .y = @intCast(first) } }) orelse return .{}; - const br = screen.pages.pin(.{ .screen = .{ - .x = screen.pages.cols - 1, - .y = @intCast(first + count - 1), - } }) orelse return .{}; - const arena = p.scratch.allocator(); - var output: std.Io.Writer.Allocating = .init(arena); - screen.dumpString(&output.writer, .{ .tl = tl, .br = br, .unwrap = false }) catch return .{}; - const shell = arena.alloc([]const u8, count) catch return .{}; - var raw = std.mem.splitScalar(u8, output.written(), '\n'); - var pins = tl.rowIterator(.right_down, br); - for (shell) |*row| { - const text = raw.next() orelse ""; - row.* = if (pins.next()) |pin| - if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, text) else text - else - text; - } - const lines = std.mem.count(u8, o.text, "\n") + 1; - return .{ - .text = o.text, - .shell = shell, - .first = first, - .end = first + count, - .lines = lines, - .cursor = first, - .budget = covered + 4 * lines, - .active = true, - }; - } - - fn shellRow(a: *EditAnchors, idx: usize) ?Anchor { - if (!a.active or idx >= a.lines) return null; - var found: ?Anchor = null; - while (a.idx <= idx) : (a.idx += 1) found = a.claim(a.nextLine() orelse return null); - return found; - } - - fn nextLine(a: *EditAnchors) ?[]const u8 { - if (a.at > a.text.len) return null; - const rest = a.text[a.at..]; - if (std.mem.indexOfScalar(u8, rest, '\n')) |n| { - a.at += n + 1; - return rest[0..n]; - } - // The last line has no terminator; one past the end ends the walk. - a.at = a.text.len + 1; - return rest; - } - - /// Where this line still stands over the grid, if anywhere. - fn claim(a: *EditAnchors, line: []const u8) ?Anchor { - const end = if (line.len == 0) @min(a.cursor + 1, a.end) else a.end; - var k = a.cursor; - while (k < end) : (k += 1) { - if (a.budget == 0) return null; - a.budget -= 1; - if (!std.mem.eql(u8, line, a.shell[k - a.first])) continue; - a.cursor = k + 1; - return .{ .row = @intCast(k) }; - } - if (a.cursor >= a.end) return null; - const shell = a.shell[a.cursor - a.first]; - var p: usize = 0; - while (p < line.len and p < shell.len and line[p] == shell[p]) p += 1; - var s: usize = 0; - const room = @min(line.len, shell.len) - p; - while (s < room and line[line.len - 1 - s] == shell[shell.len - 1 - s]) s += 1; - if (p + s == 0) return null; - if (line.len != p + s and shell.len - (p + s) > shell.len / 2) return null; - const row = a.cursor; - if (s > 0 or p >= shell.len) a.cursor += 1; - return .{ .row = @intCast(row), .prefix = p, .suffix = s, .shell_len = shell.len }; - } -}; - -const Anchor = struct { - /// the row, absolute while it comes from `EditAnchors`, viewport once - /// `recolorAnsi` has subtracted the walk's offset - row: i32, - prefix: usize = std.math.maxInt(usize), - suffix: usize = 0, - /// the row's own dumped length — what the suffix is measured from on the - /// GRID side, where the edit may have changed the byte count - shell_len: usize = 0, - - fn whole(an: Anchor) bool { - return an.prefix == std.math.maxInt(usize); - } -}; - -pub fn recolorAnsi(p: *Pardes, s: *Surface, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, body: []const u8) void { - // No emulator, no ANSI cells: the whole pass — and the 256-colour theme - // projection behind it — is compiled out. - if (comptime !enabled) return; - if (pane.terminal == null) return; - const body_y = p.bodyTop(pane, r); - var filtered_storage: FilteredColors = undefined; - const filtered: ?*FilteredColors = if (pane.tty_filter) blk: { - const tz_filter = tracy.zone(@src(), "filterInit"); - defer tz_filter.end(); - filtered_storage = FilteredColors.init(p, pane); - break :blk &filtered_storage; - } else null; - // DECSCNM, read once: the filtered palette folds it in itself, the raw - // path needs it per cell. - const scnm = pane.terminal.?.vt.modes.get(.reverse_colors); - const pages = &pane.terminal.?.vt.screens.active.pages; - const vp_rows: i32 = @intCast(scrollbar(pane).len); - // Which buffer lines the user has not actually changed, so a row swallowed - // by a growing buffer keeps the colour it still stands over. - var anchors: ?EditAnchors = null; - var walk: BodyWalk = .init(pane); - var lines = std.mem.splitScalar(u8, body, '\n'); - var vr: u16 = 0; - while (walk.next()) |row| : (vr += 1) { - if (vr >= body_h) break; - // Before any early exit below, or the lines fall out of step with rows. - const text = lines.next() orelse ""; - const anchor: Anchor = switch (row) { - .edit => |e| blk: { - if (anchors == null) anchors = .init(p, pane, pane.ovl.?); - var an = anchors.?.shellRow(e.idx) orelse continue; - an.row -= walk.goff; - break :blk an; - }, - .grid => |v| .{ .row = v }, - }; - const vi = anchor.row; - if (vi < 0 or vi >= vp_rows) continue; - const row_pin = pages.pin(.{ .viewport = .{ .y = @intCast(vi) } }) orelse continue; - const cut: u16 = if (pane.body.mode == .tty) 0 else cut: { - if (row_pin.rowAndCell().row.semantic_prompt == .none) break :cut 0; - break :cut switch (promptCut(row_pin)) { - .keep => 0, - // Blanked end to end: the row shows nothing of the grid, so - // projecting the prompt's own colours onto it would be a lie. - .blank => continue, - .cut => |n| std.math.cast(u16, n) orelse continue, - }; - }; - if (cut > 0 and text.len == 0) continue; - var at: usize = 0; - var sc: u16 = 0; - var gc: u16 = cut; - var sb: usize = 0; - const mine_from = @min(anchor.prefix, text.len); - const mine_to = text.len - @min(anchor.suffix, text.len); - var crossed = false; - while (at < text.len and sc < tw) { - const stop = modal.nextGrapheme(text, at); - if (stop <= at) break; - const span: u16 = if (sc + 1 < tw and s.at(tx + sc + 1, body_y + vr).len == 0) 2 else 1; - if (at >= mine_from and at < mine_to) { - sc += span; - at = stop; - continue; - } - if (at >= mine_to and !crossed) { - crossed = true; - const upto = anchor.shell_len - @min(anchor.suffix, anchor.shell_len); - while (sb < upto) { - const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break; - sb += dumpedBytes(row_pin, ci.cell); - gc = std.math.add(u16, gc, 1) catch break; - } - } - const want = stop - at; - // Consume every cell that contributed to this grapheme. A cluster - // ghostty split across several cells is still ONE printed glyph. - var covered: usize = 0; - var style: ?pardes.CellStyle = null; - while (covered < want) { - const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break; - if (style == null and ci.cell.wide != .spacer_tail and ci.cell.wide != .spacer_head) - style = cellStyle(p, ci, filtered, scnm); - covered += dumpedBytes(row_pin, ci.cell); - gc = std.math.add(u16, gc, 1) catch break; - // A spacer contributes no bytes; without this the loop would - // spin on a row that ends in one. - if (covered == 0 and gc >= pane.cols) break; - } - while (pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } })) |t| { - if (t.cell.wide != .spacer_tail) break; - gc = std.math.add(u16, gc, 1) catch break; - } - if (style) |st| for (0..span) |k| { - const cell = s.at(tx + sc + @as(u16, @intCast(k)), body_y + vr); - if (cell.default and filtered == null) continue; - cell.default = false; - cell.style = st; - }; - sb += covered; - sc += span; - at = stop; - } - var tail: ?pardes.CellStyle = null; - if (anchor.whole() or anchor.suffix > 0) { - while (sc < tw) { - const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break; - gc = std.math.add(u16, gc, 1) catch break; - if (ci.cell.wide == .spacer_tail) continue; - const cell = s.at(tx + sc, body_y + vr); - sc += 1; - const style = cellStyle(p, ci, filtered, scnm); - tail = style; - if (cell.default and filtered == null) continue; - cell.default = false; - cell.style = style; - } - if (tail) |style| while (sc < tw) : (sc += 1) { - const cell = s.at(tx + sc, body_y + vr); - if (cell.default and filtered == null) continue; - cell.default = false; - cell.style = style; - }; - } - } -} - -fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell, filtered: ?*FilteredColors, scnm: bool) pardes.CellStyle { - const style = ci.style(); - var cs: pardes.CellStyle = .{ - .fg = if (filtered) |colors| colors.fg(style) else ghostColor(p, style.fg_color, scnm), - .bg = if (filtered) |colors| colors.bg(style, ci.cell) else ghostColor(p, style.bg_color, !scnm), - .bold = style.flags.bold, - .dim = style.flags.faint, - .italic = style.flags.italic, - .blink = style.flags.blink, - .reverse = style.flags.inverse, - .invisible = style.flags.invisible, - .strikethrough = style.flags.strikethrough, - .ul = switch (style.flags.underline) { - .none => .off, - .single => .single, - .double => .double, - .curly => .curly, - .dotted => .dotted, - .dashed => .dashed, - }, - }; - if (filtered == null) switch (ci.cell.content_tag) { - .bg_color_palette => cs.bg = palColor(p, ci.cell.content.color_palette.data), - .bg_color_rgb => { - const rgb = ci.cell.content.color_rgb; - cs.bg = .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }; - }, - else => {}, - }; - return cs; -} - -const FilteredColors = struct { - source: GColor.Palette, - target: *const GColor.Palette, - theme_bg: GColor.RGB, - theme_fg: GColor.RGB, - dynamic_bg: ?GColor.RGB, - dynamic_fg: ?GColor.RGB, - default_bg: GColor.RGB, - /// What a foreground too near `default_bg` becomes instead. - fallback_fg: GColor.RGB, - default_bg_luminance: f64, - fg_for_palette: [256]pardes.Color = undefined, - bg_for_palette: [256]pardes.Color = undefined, - /// The two answers for a cell that names no colour of its own. - fg_default: pardes.Color = undefined, - bg_default: pardes.Color = undefined, - cache_rgb: [256]GColor.RGB = undefined, - cache_key: [256]u8 = undefined, - cache_valid: [256]bool = @splat(false), - - fn init(p: *Pardes, pane: *const Pane) FilteredColors { - var source = GColor.default; - for (&source, 0..) |*rgb, i| - rgb.* = pane.terminal.?.vt.colorForXterm(.{ .palette = @intCast(i) }) orelse rgb.*; - const theme = p.theme(); - var theme_bg = asGhostRgb(theme.bg orelse theme.tag_bg); - var theme_fg = asGhostRgb(theme.fg orelse theme.tag_fg); - var dynamic_bg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .background }); - var dynamic_fg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .foreground }); - if (pane.terminal.?.vt.modes.get(.reverse_colors)) { - std.mem.swap(GColor.RGB, &theme_bg, &theme_fg); - std.mem.swap(?GColor.RGB, &dynamic_bg, &dynamic_fg); - } - var self: FilteredColors = .{ - .source = source, - .target = p.tty_filter_palette.get(theme), - .theme_bg = theme_bg, - .theme_fg = theme_fg, - .dynamic_bg = dynamic_bg, - .dynamic_fg = dynamic_fg, - .default_bg = theme_bg, - .fallback_fg = theme_fg, - .default_bg_luminance = luminanceOf(theme_bg), - }; - if (dynamic_bg) |rgb| self.default_bg = self.keyedRgb(rgb); - self.default_bg_luminance = luminanceOf(self.default_bg); - if (self.theme_bg.contrast(self.default_bg) > self.theme_fg.contrast(self.default_bg)) - self.fallback_fg = self.theme_bg; - - self.fg_default = asPardesColor(self.legible( - if (self.dynamic_fg) |rgb| self.keyedRgb(rgb) else self.theme_fg, - )); - self.bg_default = asPardesColor(self.default_bg); - for (&self.source, 0..) |current, i| { - const idx: u8 = @intCast(i); - const mapped = self.paletteRgb(idx, current); - self.fg_for_palette[idx] = asPardesColor(self.legible(mapped)); - self.bg_for_palette[idx] = asPardesColor(mapped); - } - return self; - } - - fn fg(self: *FilteredColors, style: ghostty_vt.Style) pardes.Color { - return switch (style.fg_color) { - .none => self.fg_default, - .palette => |idx| self.fg_for_palette[idx], - .rgb => asPardesColor(self.legible(self.keyedRgb(style.fg(.{ - .default = self.dynamic_fg orelse self.theme_fg, - .palette = &self.source, - .bold = null, - })))), - }; - } - - fn bg(self: *FilteredColors, style: ghostty_vt.Style, cell: *const ghostty_vt.Cell) pardes.Color { - switch (cell.content_tag) { - .bg_color_palette => return self.bg_for_palette[cell.content.color_palette.data], - .bg_color_rgb => {}, - else => switch (style.bg_color) { - .none => return self.bg_default, - .palette => |idx| return self.bg_for_palette[idx], - .rgb => {}, - }, - } - // Truecolour, from either the cell or its style. - return asPardesColor(self.keyedRgb(style.bg(cell, &self.source).?)); - } - - fn legible(self: *const FilteredColors, rgb: GColor.RGB) GColor.RGB { - if (contrastOf(luminanceOf(rgb), self.default_bg_luminance) >= - config.tty_filter_min_contrast) return rgb; - return self.fallback_fg; - } - - fn paletteRgb(self: *FilteredColors, idx: u8, current: GColor.RGB) GColor.RGB { - if (current.eql(GColor.default[idx])) return self.target[idx]; - return self.keyedRgb(current); - } - - fn keyedRgb(self: *FilteredColors, rgb: GColor.RGB) GColor.RGB { - return self.target[self.nearestKey(rgb)]; - } - - fn nearestKey(self: *FilteredColors, rgb: GColor.RGB) u8 { - const rgb24 = (@as(u32, rgb.r) << 16) | (@as(u32, rgb.g) << 8) | rgb.b; - const slot: u8 = @truncate((rgb24 *% 0x9e3779b1) >> 24); - if (self.cache_valid[slot] and self.cache_rgb[slot].eql(rgb)) - return self.cache_key[slot]; - - var best: u8 = 0; - var best_distance: u32 = std.math.maxInt(u32); - for (GColor.default, 0..) |candidate, i| { - const distance = colorDistance(rgb, candidate); - // Strict comparison makes duplicate-colour ties stable at the - // lowest canonical xterm key. - if (distance < best_distance) { - best_distance = distance; - best = @intCast(i); - } - } - self.cache_rgb[slot] = rgb; - self.cache_key[slot] = best; - self.cache_valid[slot] = true; - return best; - } -}; - -const channel_luminance: [256]f64 = blk: { - @setEvalBranchQuota(20000); - var table: [256]f64 = undefined; - for (&table, 0..) |*slot, c| { - const normalized: f64 = @as(f64, @floatFromInt(c)) / 255; - slot.* = if (normalized <= 0.03928) - normalized / 12.92 - else - std.math.pow(f64, (normalized + 0.055) / 1.055, 2.4); - } - break :blk table; -}; - -fn luminanceOf(rgb: GColor.RGB) f64 { - return 0.2126 * channel_luminance[rgb.r] + - 0.7152 * channel_luminance[rgb.g] + - 0.0722 * channel_luminance[rgb.b]; -} - -/// ghostty's `RGB.contrast` with both luminances already in hand. -fn contrastOf(a_luminance: f64, b_luminance: f64) f64 { - const lighter = @max(a_luminance, b_luminance); - const darker = @min(a_luminance, b_luminance); - return (lighter + 0.05) / (darker + 0.05); -} - -fn colorDistance(a: GColor.RGB, b: GColor.RGB) u32 { - const dr = @as(i32, a.r) - @as(i32, b.r); - const dg = @as(i32, a.g) - @as(i32, b.g); - const db = @as(i32, a.b) - @as(i32, b.b); - return @intCast(dr * dr + dg * dg + db * db); -} - -test "the luminance table answers exactly what ghostty computes" { - for (0..256) |i| { - const c: u8 = @intCast(i); - const grey: GColor.RGB = .{ .r = c, .g = c, .b = c }; - try std.testing.expectEqual(grey.luminance(), luminanceOf(grey)); - } - // Channel weights are asymmetric, so a grey ramp alone would not catch a - // transposed coefficient. The palette is what the tables enumerate. - for (GColor.default) |candidate| { - try std.testing.expectEqual(candidate.luminance(), luminanceOf(candidate)); - for (GColor.default) |page| { - try std.testing.expectEqual( - candidate.contrast(page), - contrastOf(luminanceOf(candidate), luminanceOf(page)), - ); - } - } -} - -test "terminal Filter keys indexed truecolor OSC and background-only cells through the theme" { - const testing = std.testing; - // Raw index assertions require a theme that inherits the ANSI palette. - const p = try Pardes.init(testing.allocator, .{ .startup_config = "Theme helix\n", .tty_only = true, .cols = 18, .rows = 6 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tag_expanded = false; // a one-row tag keeps this small screen's rows - p.sync(); - try testing.expect(pane.tty_filter); - pane.tty_filter = false; - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mA" ++ - "\x1b[38;5;196mB" ++ - "\x1b[38;2;255;0;0mC" ++ - "\x1b[0;48;5;25mD" ++ - "\x1b[0;48;2;0;95;175mE" ++ - "\x1b[0;1;2;3;4;5;7;8;9mF" ++ - "\x1b[0;48;5;25m\x1b[K" ++ - "\r\n\x1b[0;38;5;2m界" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = p.bodyTop(pane, r); - - const raw = try p.render(frame.allocator()); - try testing.expectEqual(pardes.Color{ .index = 1 }, raw.at(tx, body_y).style.fg); - try testing.expectEqual(pardes.Color{ .index = 196 }, raw.at(tx + 1, body_y).style.fg); - try testing.expectEqual(pardes.Color{ .rgb = .{ 255, 0, 0 } }, raw.at(tx + 2, body_y).style.fg); - - pane.tty_filter = true; - _ = frame.reset(.retain_capacity); - const filtered = try p.render(frame.allocator()); - var expected_cache: FilterPalette = .{}; - const expected = expected_cache.get(p.theme()); - try testing.expectEqual(asPardesColor(expected[1]), filtered.at(tx, body_y).style.fg); - try testing.expectEqual(asPardesColor(expected[196]), filtered.at(tx + 1, body_y).style.fg); - try testing.expectEqual(filtered.at(tx + 1, body_y).style.fg, filtered.at(tx + 2, body_y).style.fg); - try testing.expectEqual(asPardesColor(expected[25]), filtered.at(tx + 3, body_y).style.bg); - try testing.expectEqual(filtered.at(tx + 3, body_y).style.bg, filtered.at(tx + 4, body_y).style.bg); - - const attrs = filtered.at(tx + 5, body_y).style; - try testing.expect(attrs.bold); - try testing.expect(attrs.dim); - try testing.expect(attrs.italic); - try testing.expect(attrs.blink); - try testing.expect(attrs.reverse); - try testing.expect(attrs.invisible); - try testing.expect(attrs.strikethrough); - try testing.expectEqual(.single, attrs.ul); - - const erased = pane.terminal.?.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = 6, .y = 0 } }).?; - try testing.expectEqual(.bg_color_palette, erased.cell.content_tag); - try testing.expectEqual(asPardesColor(expected[25]), filtered.at(tx + 6, body_y).style.bg); - try testing.expectEqual(asPardesColor(expected[2]), filtered.at(tx, body_y + 1).style.fg); - try testing.expectEqual(filtered.at(tx, body_y + 1).style, filtered.at(tx + 1, body_y + 1).style); - // A filtered terminal never delegates either colour to a backend palette, - // including cells which were empty/default before the pass. - for (0..r.w - config.GUTTER) |col| { - const cell = filtered.at(tx + @as(u16, @intCast(col)), body_y); - try testing.expect(!cell.default); - switch (cell.style.fg) { - .rgb => {}, - else => return error.FilteredForegroundWasNotRgb, - } - switch (cell.style.bg) { - .rgb => {}, - else => return error.FilteredBackgroundWasNotRgb, - } - } - - // Colors remains the global master gate. The pane remembers Filter while - // ANSI projection is dormant, and resumes it without replaying VT bytes. - p.settings.colors = false; - _ = frame.reset(.retain_capacity); - const plain = try p.render(frame.allocator()); - try testing.expect(pane.tty_filter); - try testing.expectEqual(asPardesColor(asGhostRgb(p.theme().fg.?)), plain.at(tx, body_y).style.fg); - p.settings.colors = true; - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]4;1;#ff0000\x1b\\" } }); - const osc_red = pane.terminal.?.vt.colorForXterm(.{ .palette = 1 }).?; - try testing.expect(osc_red.eql(.{ .r = 255, .g = 0, .b = 0 })); - pane.tty_filter = false; - pane.tty_filter = true; - try testing.expect(osc_red.eql(pane.terminal.?.vt.colorForXterm(.{ .palette = 1 }).?)); - _ = frame.reset(.retain_capacity); - const osc_palette = try p.render(frame.allocator()); - try testing.expectEqual(asPardesColor(expected[196]), osc_palette.at(tx, body_y).style.fg); - - // Dynamic default foreground/background colours key every default cell, - // including the otherwise blank end of the row. - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]10;#ff0000\x1b\\" ++ - "\x1b]11;#5f5f5f\x1b\\" } }); - const dyn_fg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .foreground }).?; - const dyn_bg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .background }).?; - try testing.expect(dyn_fg.eql(.{ .r = 255, .g = 0, .b = 0 })); - try testing.expect(dyn_bg.eql(.{ .r = 95, .g = 95, .b = 95 })); - _ = frame.reset(.retain_capacity); - const dynamic = try p.render(frame.allocator()); - const blank = dynamic.at(tx + r.w - config.GUTTER - 1, body_y + 2).style; - try testing.expectEqual(asPardesColor(expected[196]), blank.fg); - try testing.expectEqual(asPardesColor(expected[59]), blank.bg); - - const explicit_before_reverse = dynamic.at(tx, body_y).style.fg; - try testing.expectEqual(asPardesColor(expected[196]), explicit_before_reverse); - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } }); - _ = frame.reset(.retain_capacity); - const reversed = try p.render(frame.allocator()); - const reversed_blank = reversed.at(tx + r.w - config.GUTTER - 1, body_y + 2).style; - try testing.expectEqual(asPardesColor(expected[59]), reversed_blank.fg); - try testing.expectEqual(asPardesColor(expected[196]), reversed_blank.bg); - const reversed_explicit = reversed.at(tx, body_y).style; - try testing.expectEqual(asPardesColor(expected[196]), reversed_explicit.bg); - try testing.expectEqual(pardes.Color{ .rgb = p.theme().bg.? }, reversed_explicit.fg); - try testing.expect(!std.meta.eql(reversed_explicit.fg, reversed_explicit.bg)); - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5l" } }); - _ = frame.reset(.retain_capacity); - const unreversed = try p.render(frame.allocator()); - const unreversed_blank = unreversed.at(tx + r.w - config.GUTTER - 1, body_y + 2).style; - try testing.expectEqual(asPardesColor(expected[196]), unreversed_blank.fg); - try testing.expectEqual(asPardesColor(expected[59]), unreversed_blank.bg); - - // The cache is keyed by values, not a theme name. Replacing a custom - // theme in place immediately recolours already-rendered indexed cells. - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]104;1\x1b\\" } }); - var custom = p.theme().*; - custom.name = try p.gpa.dupe(u8, "same-name"); - custom.palette = null; - custom.kw = .{ 1, 2, 3 }; - p.custom_theme = custom; - _ = frame.reset(.retain_capacity); - const custom_first = try p.render(frame.allocator()); - try testing.expectEqual(pardes.Color{ .rgb = .{ 1, 2, 3 } }, custom_first.at(tx, body_y).style.fg); - if (p.custom_theme) |*theme| theme.kw = .{ 4, 5, 6 }; - _ = frame.reset(.retain_capacity); - const custom_second = try p.render(frame.allocator()); - try testing.expectEqual(pardes.Color{ .rgb = .{ 4, 5, 6 } }, custom_second.at(tx, body_y).style.fg); -} - -test "terminal Filter keeps extended keys dark-to-light on a light theme" { - const p = try Pardes.init(std.testing.allocator, .{ .startup_config = "Theme acme\n", .tty_only = true }); - defer p.deinit(); - try std.testing.expectEqualStrings("acme", p.theme().name); - var cache: FilterPalette = .{}; - const palette = cache.get(p.theme()); - try std.testing.expect(palette[16].eql(asGhostRgb(p.theme().fg.?))); - try std.testing.expect(palette[231].eql(asGhostRgb(p.theme().bg.?))); -} - -test "terminal Filter preserves exact palette-null light theme default roles" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 5 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - - var light = p.theme().*; - light.name = try p.gpa.dupe(u8, "filter-light-defaults"); - light.bg = .{ 0xf8, 0xf8, 0xf8 }; - light.fg = .{ 0x38, 0x38, 0x38 }; - light.palette = null; - p.custom_theme = light; - - const pane = p.panes[0].?; - try testing.expectEqual(@as(?GColor.RGB, null), pane.terminal.?.vt.colorForXterm(.{ .dynamic = .foreground })); - try testing.expectEqual(@as(?GColor.RGB, null), pane.terminal.?.vt.colorForXterm(.{ .dynamic = .background })); - pane.tty_filter = true; - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = p.bodyTop(pane, r); - const ordinary = try p.render(frame.allocator()); - try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, ordinary.at(tx, body_y).style.fg); - try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, ordinary.at(tx, body_y).style.bg); - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } }); - _ = frame.reset(.retain_capacity); - const reversed = try p.render(frame.allocator()); - try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, reversed.at(tx, body_y).style.fg); - try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, reversed.at(tx, body_y).style.bg); -} - -test "terminal Filter maps the default roles before it maps anything else" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - try testing.expect(pane.tty_filter); - - // Native dark/light palettes plus legacy helix/dark/acme, including - // the null-page fallback that only the legacy dark theme exercises. - for (0..9) |t| { - p.settings.theme = @intCast(t); - const stage_one = FilteredColors.init(p, pane); - // `dark` declares no background of its own, which is exactly why the - // resolver reads the tag colours as the fallback rather than `.?`. - const theme = p.theme(); - try testing.expect(stage_one.theme_bg.eql(asGhostRgb(theme.bg orelse theme.tag_bg))); - try testing.expect(stage_one.theme_fg.eql(asGhostRgb(theme.fg orelse theme.tag_fg))); - // With no OSC 11 in play the mapped page IS that anchor, and the - // fallback is the other one: a background never contrasts with itself. - try testing.expect(stage_one.default_bg.eql(stage_one.theme_bg)); - try testing.expect(stage_one.fallback_fg.eql(stage_one.theme_fg)); - } - - p.settings.theme = 0; - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]11;#5f5f5f\x1b\\" } }); - var moved = FilteredColors.init(p, pane); - try testing.expect(!moved.default_bg.eql(moved.theme_bg)); - try testing.expect(moved.default_bg.eql(moved.keyedRgb(.{ .r = 0x5f, .g = 0x5f, .b = 0x5f }))); -} - -test "terminal Filter refuses a foreground that would collapse onto the page" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - try testing.expect(pane.tty_filter); - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[38;2;255;255;255mW" ++ - "\x1b[0;30mB" ++ - "\x1b[0;31mR" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = p.bodyTop(pane, r); - - for (0..9) |t| { - p.settings.theme = @intCast(t); - var fc = FilteredColors.init(p, pane); - const page = asPardesColor(fc.default_bg); - const rescued = asPardesColor(fc.fallback_fg); - _ = frame.reset(.retain_capacity); - const g = try p.render(frame.allocator()); - - // The colour each of the three would have been given with no floor. - const raw_white = fc.keyedRgb(.{ .r = 255, .g = 255, .b = 255 }); - const raw_black = fc.paletteRgb(0, GColor.default[0]); - const raw_red = fc.paletteRgb(1, GColor.default[1]); - - for ([_]struct { at: u16, raw: GColor.RGB }{ - .{ .at = 0, .raw = raw_white }, - .{ .at = 1, .raw = raw_black }, - .{ .at = 2, .raw = raw_red }, - }) |case| { - const cell = g.at(tx + case.at, body_y).style; - try testing.expectEqual(page, cell.bg); - if (case.raw.contrast(fc.default_bg) < config.tty_filter_min_contrast) { - // Refused: the projection's answer is not painted, the anchor is. - try testing.expectEqual(rescued, cell.fg); - try testing.expect(!std.meta.eql(cell.fg, cell.bg)); - } else { - // Cleared the floor, so stage two leaves it exactly alone. - try testing.expectEqual(asPardesColor(case.raw), cell.fg); - } - // Either way a filtered cell delegates neither colour to a backend. - switch (cell.fg) { - .rgb => |ink| try testing.expect(asGhostRgb(ink).contrast(fc.default_bg) >= - config.tty_filter_min_contrast), - else => return error.FilteredForegroundWasNotRgb, - } - } - - // At least one of the three has to have been a real collapse, or this - // theme proved nothing: white on the light theme, black on the dark. - try testing.expect(raw_white.contrast(fc.default_bg) < config.tty_filter_min_contrast or - raw_black.contrast(fc.default_bg) < config.tty_filter_min_contrast); - // A saturated red is never the page on any curated theme. - try testing.expect(raw_red.contrast(fc.default_bg) >= config.tty_filter_min_contrast); - } -} - -test "terminal Filter holds every projected foreground off the page" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - - for (0..9) |t| { - p.settings.theme = @intCast(t); - var fc = FilteredColors.init(p, pane); - var refused: usize = 0; - for (fc.target, 0..) |projected, key| { - const ink = fc.legible(projected); - try testing.expect(ink.contrast(fc.default_bg) >= config.tty_filter_min_contrast); - if (!ink.eql(projected)) { - refused += 1; - try testing.expect(ink.eql(fc.fallback_fg)); - // Only ever refused for being too near the page. - try testing.expect(projected.contrast(fc.default_bg) < config.tty_filter_min_contrast); - } - // The key a background asks for is handed back untouched, including - // the one whose value is the page itself. - try testing.expect(fc.keyedRgb(GColor.default[key]).eql(fc.target[fc.nearestKey(GColor.default[key])])); - } - // Every curated theme owns at least one collapsing key — that is why - // the floor exists — and the floor must not be flattening the palette. - try testing.expect(refused > 0); - try testing.expect(refused < fc.target.len / 8); - } -} - -test "tty ansi colors follow the prompt hug into normal mode" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32mPP\x1b]133;B\x1b\\\x1b[31mR\x1b[34mB\x1b[0m out" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = p.bodyTop(pane, r); - const red: pardes.Color = .{ .rgb = p.theme().palette.?[1] }; - const blue: pardes.Color = .{ .rgb = p.theme().palette.?[4] }; - - // tty mode projects the emulator's ansi colours cell for cell. - pane.body.mode = .tty; - p.shell_rows.stale = true; - const tty = try p.render(frame.allocator()); - try testing.expectEqual(red, tty.at(tx + 2, body_y).style.fg); - try testing.expectEqual(blue, tty.at(tx + 3, body_y).style.fg); - - pane.body.mode = .normal; - p.shell_rows.stale = true; - _ = frame.reset(.retain_capacity); - const norm = try p.render(frame.allocator()); - try testing.expectEqualStrings("R", norm.at(tx, body_y).grapheme()); - try testing.expectEqualStrings("B", norm.at(tx + 1, body_y).grapheme()); - try testing.expectEqual(red, norm.at(tx, body_y).style.fg); - try testing.expectEqual(blue, norm.at(tx + 1, body_y).style.fg); -} - -test "a prompt row hidden end to end paints nothing at all" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 8 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tag_expanded = false; // a 12x8 screen: the body, not the tag - p.sync(); - pane.tty_filter = false; - pane.body.mode = .normal; - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32maaaaaaaaa\x1b]133;B\x1b\\\x1b[41;36m\u{754C}\x1b[0m\r\n" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = p.bodyTop(pane, r); - p.shell_rows.stale = true; - const s = try p.render(frame.allocator()); - - try testing.expectEqualStrings(" ", s.at(tx, body_y).grapheme()); - try testing.expect(!std.meta.eql(pardes.Color{ .rgb = p.theme().palette.?[1] }, s.at(tx, body_y).style.bg)); -} - -pub fn palColor(p: *Pardes, idx: u8) pardes.Color { - if (p.theme().palette) |pal| if (idx < 16) return .{ .rgb = pal[idx] }; - return .{ .index = idx }; -} - -pub fn ghostColor(p: *Pardes, color: ghostty_vt.Style.Color, is_bg: bool) pardes.Color { - return switch (color) { - .none => blk: { - const t = if (is_bg) p.theme().bg else p.theme().fg; - break :blk if (t) |c| .{ .rgb = c } else .default; - }, - .palette => |idx| palColor(p, idx), - .rgb => |rgb| .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }, - }; -} - -/// executing at a prompt with typed text below it: pad the output area -/// with newlines so the command's output doesn't overwrite the buffer -pub fn padOutputBelowEdits(p: *Pardes, id: usize) void { - // Nothing to pad away from: with no emulator there is no prompt and no - // child whose output could land on top of the edit buffer. - if (comptime !enabled) return; - const pane = p.panes[id] orelse return; - const o = pane.ovl orelse return; - if (!pane.isTerminal()) return; - const state = pane.terminal orelse return; - if (!state.vt.cursorIsAtPrompt()) return; - // the buffer's LAST surface row: its lines may outnumber the shell - // rows it covers, and it is the bottom one output must clear - const max_row = o.row + @as(i32, @intCast(modal.lineCount(o.text))) - 1; - const goff: i32 = @intCast(state.vt.screens.active.pages.scrollbar().offset); - const cursor_abs = pane.surfRow(goff + @as(i32, @intCast(state.vt.screens.active.cursor.y))); - const pad = std.math.clamp(max_row - cursor_abs, 0, @as(i32, pane.rows)); - var i: i32 = 0; - while (i < pad) : (i += 1) p.emitWrite(id, "\r"); -} - -pub fn pushUndo(p: *Pardes, pane: *Pane) void { - pane.body.remember(p.gpa, pane.ovl); -} - -pub fn undo(p: *Pardes, pane: *Pane) void { - const back_to = pane.body.step(p.gpa, pane.ovl, true) orelse return; - if (pane.ovl) |o| p.gpa.free(o.text); - pane.ovl = back_to.ovl; - pane.ensureCursorVisible(); -} - -pub fn redo(p: *Pardes, pane: *Pane) void { - const forward_to = pane.body.step(p.gpa, pane.ovl, false) orelse return; - if (pane.ovl) |o| p.gpa.free(o.text); - pane.ovl = forward_to.ovl; - pane.ensureCursorVisible(); -} - -pub fn ptyReport(handler: *ghostty_vt.TerminalStream.Handler, data: [:0]const u8) void { - const state: *State = @alignCast(@fieldParentPtr("vt", handler.terminal)); - const room = state.reply.len - state.reply_len; - const n = @min(room, data.len); - @memcpy(state.reply[state.reply_len..][0..n], data[0..n]); - state.reply_len += @intCast(n); -} - -const DeviceAttrs = @typeInfo(@typeInfo(@typeInfo( - @FieldType(ghostty_vt.TerminalStream.Handler.Effects, "device_attributes"), -).optional.child).pointer.child).@"fn".return_type.?; -pub fn ptyDeviceAttrs(_: *ghostty_vt.TerminalStream.Handler) DeviceAttrs { - return .{}; -} diff --git a/src/Text.zig b/src/Text.zig index ab23bf8e..cef76b3b 100644 --- a/src/Text.zig +++ b/src/Text.zig @@ -14,7 +14,7 @@ const config = @import("config.zig"); const memory = @import("memory.zig"); const Pane = panes.Pane; const File = panes.File; -const Terminal = panes.Terminal; +const terminal = panes.terminal; const Text = @This(); pub const Mode = enum { normal, insert, tty }; @@ -84,9 +84,9 @@ normal: modal.Normal.State = .{}, /// last f/F/t/T motion, for Alt-. repeat find_op: u8 = 0, find_ch: u21 = 0, -ed_undo: [Terminal.history_max]Terminal.Snapshot = undefined, +ed_undo: [terminal.history_max]terminal.Snapshot = undefined, ed_undo_len: usize = 0, -ed_redo: [Terminal.history_max]Terminal.Snapshot = undefined, +ed_redo: [terminal.history_max]terminal.Snapshot = undefined, ed_redo_len: usize = 0, /// Set when an edit would have changed characters this text does not own /// (a tag's computed prefix) and was refused; the key that tried it puts @@ -706,10 +706,10 @@ pub fn clampCursor(t: *Text, text: []const u8, row0: i32) void { t.show(); } -fn pushHistory(gpa: std.mem.Allocator, slots: []Terminal.Snapshot, len: *usize, value: Terminal.Snapshot) void { +fn pushHistory(gpa: std.mem.Allocator, slots: []terminal.Snapshot, len: *usize, value: terminal.Snapshot) void { if (len.* == slots.len) { if (slots[0].ovl) |overlay| gpa.free(overlay.text); - std.mem.copyForwards(Terminal.Snapshot, slots[0 .. slots.len - 1], slots[1..]); + std.mem.copyForwards(terminal.Snapshot, slots[0 .. slots.len - 1], slots[1..]); len.* -= 1; } slots[len.*] = value; @@ -719,14 +719,14 @@ fn pushHistory(gpa: std.mem.Allocator, slots: []Terminal.Snapshot, len: *usize, /// Record an edit buffer (a terminal's overlay, a tag's own text) as it /// stands before an edit, once per change of it, and forget what could have /// been redone. Null is a buffer not made yet. -pub fn remember(t: *Text, gpa: std.mem.Allocator, current: ?Terminal.EditBuffer) void { +pub fn remember(t: *Text, gpa: std.mem.Allocator, current: ?terminal.EditBuffer) void { if (t.ed_undo_len > 0) { const top = t.ed_undo[t.ed_undo_len - 1]; const same = if (top.ovl) |overlay| if (current) |now| overlay.row == now.row and overlay.rows == now.rows and std.mem.eql(u8, overlay.text, now.text) else false else current == null; if (same) return; } - const copy: ?Terminal.EditBuffer = if (current) |now| .{ .row = now.row, .rows = now.rows, .text = gpa.dupe(u8, now.text) catch return } else null; + const copy: ?terminal.EditBuffer = if (current) |now| .{ .row = now.row, .rows = now.rows, .text = gpa.dupe(u8, now.text) catch return } else null; pushHistory(gpa, &t.ed_undo, &t.ed_undo_len, .{ .ovl = copy, .cur_row = t.cur_row, .cur_col = t.cur_col, .vsel = t.vsel }); for (t.ed_redo[0..t.ed_redo_len]) |item| if (item.ovl) |overlay| gpa.free(overlay.text); t.ed_redo_len = 0; @@ -736,13 +736,13 @@ pub fn remember(t: *Text, gpa: std.mem.Allocator, current: ?Terminal.EditBuffer) /// other history and the state to return to comes back, its cursor and /// selection already restored here. The caller installs its buffer, which /// it then owns. Null when there is nothing to step to. -pub fn step(t: *Text, gpa: std.mem.Allocator, current: ?Terminal.EditBuffer, back: bool) ?Terminal.Snapshot { +pub fn step(t: *Text, gpa: std.mem.Allocator, current: ?terminal.EditBuffer, back: bool) ?terminal.Snapshot { const from, const from_len, const to, const to_len = if (back) .{ &t.ed_undo, &t.ed_undo_len, &t.ed_redo, &t.ed_redo_len } else .{ &t.ed_redo, &t.ed_redo_len, &t.ed_undo, &t.ed_undo_len }; if (from_len.* == 0) return null; - const copy: ?Terminal.EditBuffer = if (current) |now| .{ .row = now.row, .rows = now.rows, .text = gpa.dupe(u8, now.text) catch return null } else null; + const copy: ?terminal.EditBuffer = if (current) |now| .{ .row = now.row, .rows = now.rows, .text = gpa.dupe(u8, now.text) catch return null } else null; pushHistory(gpa, to, to_len, .{ .ovl = copy, .cur_row = t.cur_row, .cur_col = t.cur_col, .vsel = t.vsel }); from_len.* -= 1; const back_to = from[from_len.*]; diff --git a/src/body_layer.zig b/src/body_layer.zig index 6e1fe8f1..cb26c1f2 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -51,7 +51,7 @@ fn paintTerminalSelection(p: *Pardes, s: *Surface, pane: *Pane, r: Rect, rows: [ var visible: i32 = 0; while (lines.next()) |line| : (visible += 1) { if (visible >= r.h -| pane.tag_rows) break; - const source_row = if (raw) panes.Terminal.gridOffset(pane) + visible else pane.wrapAt(visible).line; + const source_row = if (raw) panes.terminal.gridOffset(pane) + visible else pane.wrapAt(visible).line; for (rows) |row| { const target = if (row.raw_terminal) (if (raw) row.row else pane.surfRow(row.row)) @@ -64,7 +64,7 @@ fn paintTerminalSelection(p: *Pardes, s: *Surface, pane: *Pane, r: Rect, rows: [ lo -|= row.prompt_bytes; hi -|= row.prompt_bytes; } else if (!row.raw_terminal and raw) { - const prefix = panes.Terminal.promptPrefixBytes(pane, source_row, line); + const prefix = panes.terminal.promptPrefixBytes(pane, source_row, line); lo += prefix; hi += prefix; } @@ -85,7 +85,7 @@ fn paintTerminalSelection(p: *Pardes, s: *Surface, pane: *Pane, r: Rect, rows: [ fn paintSourceSelection(p: *Pardes, s: *Surface, pane: *Pane, r: Rect, rows: []const Pane.PointerRow, bg: [3]u8, fg: ?[3]u8) void { if (pane.isTerminal()) return paintTerminalSelection(p, s, pane, r, rows, bg, fg); - const terminal_lines = if (pane.file == null) panes.Terminal.cursorLines(p, pane) catch return else &.{}; + const terminal_lines = if (pane.file == null) panes.terminal.cursorLines(p, pane) catch return else &.{}; const tx = r.x + config.GUTTER; const width = r.w -| config.GUTTER; const body_y = p.bodyTop(pane, r); @@ -291,7 +291,7 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane panes.File.recolorSyntax(p, s, pane, f, r, tx, tw, body_h); panes.File.drawWrapMarkers(p, s, pane, r, tx, tw, body_h, pane_bg); } else if (pane.isTerminal() and p.settings.colors) { - panes.Terminal.recolorAnsi(p, s, pane, r, tx, tw, body_h, body); + panes.terminal.recolorAnsi(p, s, pane, r, tx, tw, body_h, body); } tz_color.end(); @@ -440,8 +440,8 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane // (the tag's or a prompt's cursor wins while that is being typed into) if (active and pane.focus == .body and (pane.prompt == .none or pane.prompt == .del_side or pane.prompt == .repl_choice)) { if (pane.body.mode != .tty) { - const cur = panes.Terminal.gridCursor(pane); - const goff = panes.Terminal.gridOffset(pane); + const cur = panes.terminal.gridCursor(pane); + const goff = panes.terminal.gridOffset(pane); const crow = if (pane.body.cur_pinned) pane.body.cur_row else pane.surfRow(@as(i32, @intCast(cur.y)) + goff); const ccol = if (pane.body.cur_pinned) pane.body.cur_col else @as(i32, @intCast(cur.x)); const cwp = pane.wrapRow(crow, ccol); @@ -460,7 +460,7 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane ccol; if (prow >= pane.tag_rows and cx >= 0 and prow < body_bottom and cx < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(cx)), .y = body_y + @as(u16, @intCast(prow - pane.tag_rows)), .bar = pane.body.mode == .insert }; - } else if (panes.Terminal.visibleCursor(pane)) |cur| { + } else if (panes.terminal.visibleCursor(pane)) |cur| { if (cur.y + pane.tag_rows < body_bottom and cur.x < tw) s.cursor = .{ .x = tx + cur.x, .y = body_y + cur.y }; } @@ -492,5 +492,5 @@ pub fn bodyText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]const u8 { unreachable; } if (pane.file) |*f| return panes.File.bodyText(arena, pane, f, p.settings.wrap); - return panes.Terminal.bodyText(arena, pane); + return panes.terminal.bodyText(arena, pane); } diff --git a/src/builtins.zig b/src/builtins.zig index cc86067e..c549b17f 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -625,7 +625,7 @@ pub const Kill = struct { stopped = true; continue; }; - if (!panes.Terminal.commandRunning(pane)) continue; + if (!panes.terminal.commandRunning(pane)) continue; const sent = pane.sent_command.?; if (names.len > 0) { var words = std.mem.tokenizeAny(u8, names, " \t"); diff --git a/src/draw.zig b/src/draw.zig index 10303117..ad24153f 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -63,7 +63,7 @@ pub fn place(p: *Pardes, s: *Surface) void { // A folded pane is its tag rows and nothing else. if (pane.collapsed) continue; const body_h = r.h -| pane.tag_rows; - const line: i32 = if (pane.file) |f| @intCast(@min(f.scroll, std.math.maxInt(i32))) else if (pane.isTerminal()) panes.Terminal.gridOffset(pane) else 0; + const line: i32 = if (pane.file) |f| @intCast(@min(f.scroll, std.math.maxInt(i32))) else if (pane.isTerminal()) panes.terminal.gridOffset(pane) else 0; s.addRegion(.{ .kind = .body, .owner = owner, .serial = pane.serial, .active = active, .line = line, .rect = .{ .x = r.x + config.GUTTER, .y = body_y, .w = r.w - config.GUTTER, .h = body_h } }); var rail: Region = .{ .kind = .rail, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x, .y = body_y, .w = config.GUTTER, .h = body_h } }; // An image's rail has no thumb; a native PDF's is measured as its @@ -78,7 +78,7 @@ pub fn place(p: *Pardes, s: *Surface) void { .offset = page, .len = 1, } else blk: { - const gsb = panes.Terminal.scrollbar(pane); + const gsb = panes.terminal.scrollbar(pane); break :blk .{ .total = gsb.total, .offset = gsb.offset, .len = gsb.len }; }; const track_h: usize = body_h; @@ -241,7 +241,7 @@ fn glideCursor(p: *Pardes, s: *Surface) void { if (inside) frame = paneFrame(s, pane.serial, rect); const origin: [2]f32 = .{ @floatFromInt(rect.x), @floatFromInt(rect.y) }; const view = p.cursor_view; - const line: i64 = if (pane.file) |f| @intCast(f.scroll) else if (pane.isTerminal()) panes.Terminal.gridOffset(pane) else 0; + const line: i64 = if (pane.file) |f| @intCast(f.scroll) else if (pane.isTerminal()) panes.terminal.gridOffset(pane) else 0; if (view.serial == pane.serial and view.inside == inside) { // The pane moved in the layout: the springs move with it (its // own cursor's; one in a column's tag stays). @@ -719,7 +719,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { sb_off = page; sb_total = if (comptime pdf_enabled) at.pdf.?.page_count else 0; } else { - const sb = panes.Terminal.scrollbar(at); + const sb = panes.terminal.scrollbar(at); sb_off = sb.offset; sb_total = sb.total; } diff --git a/src/dump.zig b/src/dump.zig index 1eabdae8..73d4e4ed 100644 --- a/src/dump.zig +++ b/src/dump.zig @@ -731,7 +731,7 @@ pub fn dumpState(p: *Pardes) !void { } else if (pane.image) |iv| try pardes.panes.Image.dumpPane(p, arena, pane, tag, body, scroll, iv.path, iv.raw) else - try pardes.panes.Terminal.dumpPane(pane, arena, tag, body, scroll); + try pardes.panes.terminal.dumpPane(pane, arena, tag, body, scroll); dp.tag_tail = pane.tag.own; if (dp.file) |*df| { const dot = ctlfs.pane.dotOf(pane); @@ -914,7 +914,7 @@ fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previo // and how it ended: its command is not run again. const live = comptime (terminal_panes and platform != .web and !isolated); const revive = live and t.cwd.len <= effect_path_cap and t.command.len == 0; - const restored = try pardes.panes.Terminal.restore(p, src, revive); + const restored = try pardes.panes.terminal.restore(p, src, revive); p.installPane(i, restored); try restored.setOwnedCwd(t.cwd); if (t.shell.len > 0) restored.shell = try gpa.dupe(u8, t.shell); diff --git a/src/edit.zig b/src/edit.zig index 26062029..98c60efb 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -113,7 +113,7 @@ pub fn clipRequest(p: *Pardes, id: usize, mode: @FieldType(ClipRequest, "mode")) pub fn typeToTty(p: *Pardes, id: usize, pane: *const Pane, text: []const u8) void { if (text.len == 0) return; - if (panes.Terminal.bracketedPaste(pane)) { + if (panes.terminal.bracketedPaste(pane)) { p.emitWrite(id, "\x1b[200~"); p.emitWrite(id, text); p.emitWrite(id, "\x1b[201~"); @@ -225,7 +225,7 @@ pub fn capturePointerSelection(p: *Pardes, pane: *Pane, slot: usize) !void { // Refresh the source map before translating the rendered rectangle. const raw = pane.isTerminal() and pane.body.mode == .tty; const body = try body_layer.bodyText(p, p.scratch.allocator(), pane); - const terminal_lines = if (pane.file == null and !raw) try panes.Terminal.cursorLines(p, pane) else &.{}; + const terminal_lines = if (pane.file == null and !raw) try panes.terminal.cursorLines(p, pane) else &.{}; const first = @max(0, @min(gesture.r0, gesture.r1) - pane.tag_rows); const logical_rows = if (pane.body_rows > 0) pane.body_rows else pane.rows; const last = @min(@as(i32, logical_rows) - 1, @max(gesture.r0, gesture.r1) - pane.tag_rows); @@ -242,7 +242,7 @@ pub fn capturePointerSelection(p: *Pardes, pane: *Pane, slot: usize) !void { const left: i32 = if (cols.first) cols.lo else prefix; const right: i32 = cols.hi orelse std.math.maxInt(i32) / 2; var source = pane.wrapAt(visible); - if (raw) source = .{ .line = panes.Terminal.gridOffset(pane) + visible, .at = 0 }; + if (raw) source = .{ .line = panes.terminal.gridOffset(pane) + visible, .at = 0 }; const line = if (raw) modal.lineSlice(body, @intCast(visible)) else pointerSourceLine(pane, terminal_lines, source.line); var next = pane.wrapAt(visible + 1); if (raw) next = .{ .line = source.line + 1, .at = 0 }; @@ -256,7 +256,7 @@ pub fn capturePointerSelection(p: *Pardes, pane: *Pane, slot: usize) !void { .hi = if (right < prefix) lo else if (cols.hi == null) end else @min(end, modal.nextGrapheme(line, at)), .to_edge = cols.hi == null, .raw_terminal = raw, - .prompt_bytes = if (raw) panes.Terminal.promptPrefixBytes(pane, source.line, line) else 0, + .prompt_bytes = if (raw) panes.terminal.promptPrefixBytes(pane, source.line, line) else 0, }; const rendered = modal.lineSlice(body, @intCast(visible)); const prefix_end = @min(rendered.len, @as(usize, @intCast(@min(prefix, right + 1)))); @@ -276,7 +276,7 @@ pub fn selectionText(p: *Pardes, pane: *Pane, sl: Pane.Sel) ![]const u8 { const arena = p.scratch.allocator(); if (pane.rawPointerText(sl)) |text| return arena.dupe(u8, text); if (pane.pointerSelection(sl)) |rows| { - const terminal_lines = if (pane.file == null) try panes.Terminal.cursorLines(p, pane) else &.{}; + const terminal_lines = if (pane.file == null) try panes.terminal.cursorLines(p, pane) else &.{}; var total: usize = rows.len -| 1; for (rows) |row| { const line = pointerSourceLine(pane, terminal_lines, row.row); @@ -402,7 +402,7 @@ pub fn paneCursorLines(p: *Pardes, t: *Text) ![]const []const u8 { if (comptime pdf_enabled) return panes.Pdf.textLines(&pane.pdf.?, p.pdf_gpa, arena); unreachable; } - return panes.Terminal.cursorLines(p, pane); + return panes.terminal.cursorLines(p, pane); } pub fn paneByteAtDisplay(p: *Pardes, t: *Text, row: i32, from_raw: i32, display_col: i32) i32 { @@ -448,8 +448,8 @@ pub fn flatSurface(p: *Pardes, t: *Text) ![]const u8 { if (comptime pdf_enabled) return pane.pdf.?.ensureText(p.pdf_gpa); unreachable; } - const lines = try panes.Terminal.cursorLines(p, pane); - return panes.Terminal.flatSurface(p, pane, lines); + const lines = try panes.terminal.cursorLines(p, pane); + return panes.terminal.flatSurface(p, pane, lines); } pub fn paneWrapWidth(p: *const Pardes, t: *Text) usize { @@ -474,7 +474,7 @@ fn editText(p: *Pardes, t: *Text, lo: i32, hi: i32, col: i32) ?panes.EditText { const pane = t.pane() orelse return null; if (pane.file) |f| return .{ .text = f.content, .row0 = 0 }; if (pane.image != null or pane.hasPdf()) return null; - return panes.Terminal.editText(p, pane, lo, hi, col); + return panes.terminal.editText(p, pane, lo, hi, col); } fn editTextEol(p: *Pardes, t: *Text, b: Bounds) ?panes.EditText { @@ -535,7 +535,7 @@ pub fn setEditTextEnd(p: *Pardes, t: *Text, new: []u8, known_end: ?modal.Cursor) }; t.last_edit = .{ .row = @as(i32, @intCast(end.row)) + row0, .col = @intCast(end.col) }; if (pane.file) |*f| return panes.File.setContent(p, f, new); - panes.Terminal.setEditText(p, pane, new); + panes.terminal.setEditText(p, pane, new); } /// Take a text an edit made that nothing keeps, holding it until the next @@ -2057,7 +2057,7 @@ pub fn pushUndo(p: *Pardes, t: *Text) void { } const pane = t.pane() orelse return; if (pane.file != null) return panes.File.pushUndo(p, pane); - panes.Terminal.pushUndo(p, pane); + panes.terminal.pushUndo(p, pane); } pub fn doUndo(p: *Pardes, t: *Text) void { @@ -2065,7 +2065,7 @@ pub fn doUndo(p: *Pardes, t: *Text) void { if (t.what != .body) return; const pane = t.pane() orelse return; if (pane.file != null) return panes.File.undo(p, pane); - panes.Terminal.undo(p, pane); + panes.terminal.undo(p, pane); } pub fn doRedo(p: *Pardes, t: *Text) void { @@ -2073,7 +2073,7 @@ pub fn doRedo(p: *Pardes, t: *Text) void { if (t.what != .body) return; const pane = t.pane() orelse return; if (pane.file != null) return panes.File.redo(p, pane); - panes.Terminal.redo(p, pane); + panes.terminal.redo(p, pane); } /// Undo or redo an edit of a text's own characters; stepping back past its diff --git a/src/exec.zig b/src/exec.zig index 2c42389c..221de105 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -42,11 +42,11 @@ pub fn takesCommandLine(p: *const Pardes, id: usize) bool { // idle, EMPTY prompt is typed at; otherwise a new pane runs the line. // (Text in pardes's own edit buffer is not the shell's yet: an exec of // it, or of anything, types only what it runs.) - if (panes.Terminal.promptInputReady(pane) and !panes.Terminal.promptInputEmpty(pane)) return false; + if (panes.terminal.promptInputReady(pane) and !panes.terminal.promptInputEmpty(pane)) return false; // A mounted shell sits behind sudo's process supervisor. Its OSC 133 // prompt marks identify input readiness; the launcher's executable is // not the shell executable used by the ordinary process heuristic. - if (pane.v9fs_on_spawn) return panes.Terminal.promptInputReady(pane); + if (pane.v9fs_on_spawn) return panes.terminal.promptInputReady(pane); return !p.hostTtyTaken(id); } @@ -111,7 +111,7 @@ pub fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand const visible = clicked.r0 - @as(i32, pane.tag_rows); const wrapped = pane.wrapAt(visible); const raw = pane.isTerminal() and pane.body.mode == .tty and clicked.r0 >= pane.tag_rows; - const row = if (raw) panes.Terminal.gridOffset(pane) + visible else wrapped.line; + const row = if (raw) panes.terminal.gridOffset(pane) + visible else wrapped.line; const raw_line = if (raw) modal.lineSlice(body_layer.bodyText(p, p.scratch.allocator(), pane) catch "", @intCast(@max(0, visible))) else ""; const col = if (raw) @as(i32, @intCast(panes.File.rawAtDisplay(raw_line, @intCast(@max(0, clicked.c0))))) @@ -143,7 +143,7 @@ pub fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand lo -|= span.prompt_bytes; hi -|= span.prompt_bytes; } else if (!span.raw_terminal and raw) { - const prefix = panes.Terminal.promptPrefixBytes(pane, row, raw_line); + const prefix = panes.terminal.promptPrefixBytes(pane, row, raw_line); lo += prefix; hi += prefix; } @@ -429,8 +429,8 @@ pub fn evictLonePristineTty(p: *Pardes, col: usize, keep_id: usize) void { const tt = p.panes[tty_id] orelse return; // No typing, cursor on the first prompt line, no scrollback: this is // the throwaway boot placeholder a document may replace. - if (tt.ovl != null or panes.Terminal.gridCursor(tt).y != 0 or - panes.Terminal.scrollbar(tt).total > tt.rows) return; + if (tt.ovl != null or panes.terminal.gridCursor(tt).y != 0 or + panes.terminal.scrollbar(tt).total > tt.rows) return; p.deinitPane(tt) catch |err| return p.reportError(tty_id, "close", err); layout.compute(p); // a just-stacked doc has no rect yet; absorb snaps to rows _ = layout.absorbVWeight(p, tty_id, null); @@ -588,7 +588,7 @@ pub fn sendToRepl(p: *Pardes, from: usize, to: usize, text: []const u8) void { var said: [128]u8 = undefined; return p.setMessage(from, std.fmt.bufPrint(&said, "Repl: {d} bytes is more than can be sent at once; send less", .{n}) catch "Repl: too long to send"); } - const pasted = panes.Terminal.bracketedPaste(dst); + const pasted = panes.terminal.bracketedPaste(dst); edit.typeToTty(p, to, dst, clean[0..n]); // A Python block whose last line is indented, or a bare header, is not // over at one Enter, pasted into 3.13's REPL or typed into the old one: @@ -611,7 +611,7 @@ pub fn sendToRepl(p: *Pardes, from: usize, to: usize, text: []const u8) void { dst.repl_enter_due_ns = clockNow(p) + repl_enter_wait_ns; } else for (0..enters) |_| p.emitWrite(to, "\r"); // Line by line, a blank line inside a Python block ends the block. - if (!panes.Terminal.bracketedPaste(dst) and !dst.repl_warned and (if (dst.repl) |r| r.lang == python_lang else false) and + if (!panes.terminal.bracketedPaste(dst) and !dst.repl_warned and (if (dst.repl) |r| r.lang == python_lang else false) and std.mem.indexOfScalar(u8, text, '\n') != null) { dst.repl_warned = true; @@ -671,9 +671,9 @@ pub fn executeFrom(p: *Pardes, id: usize, txt: []const u8, from_body: bool) ?usi return null; } if (!takesCommandLine(p, id)) return runCommand(p, id, cmd); - panes.Terminal.padOutputBelowEdits(p, id); - panes.Terminal.noteCommand(pane, cmd); - if (panes.Terminal.queuePendingCommand(pane, cmd) catch |err| { + panes.terminal.padOutputBelowEdits(p, id); + panes.terminal.noteCommand(pane, cmd); + if (panes.terminal.queuePendingCommand(pane, cmd) catch |err| { p.reportError(id, "queue command", err); return id; }) return id; @@ -733,8 +733,8 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize { // saved cursor), mouse reports and bracketed paste off, the cursor // shown, colours reset. (Not DECSTR: ghostty's stream does not // implement it.) - if (panes.Terminal.onAlternateScreen(pane)) panes.Terminal.feedOutput(p, pane, "\x1b[?1049l"); - panes.Terminal.feedOutput(p, pane, "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l\x1b[?25h\x1b[0m"); + if (panes.terminal.onAlternateScreen(pane)) panes.terminal.feedOutput(p, pane, "\x1b[?1049l"); + panes.terminal.feedOutput(p, pane, "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l\x1b[?25h\x1b[0m"); echoCommand(p, pane, line); p.emitSpawn(id, pane.serial, pane.cwdSlice()); noteRun(p, pane, "run", line); @@ -760,10 +760,10 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize { fn echoCommand(p: *Pardes, pane: *Pane, line: []const u8) void { var buf: [2 * command_max + 8]u8 = undefined; var w = std.Io.Writer.fixed(&buf); - w.writeAll(if (panes.Terminal.gridCursor(pane).x != 0) "\r\n% " else "% ") catch {}; + w.writeAll(if (panes.terminal.gridCursor(pane).x != 0) "\r\n% " else "% ") catch {}; for (line) |c| (if (c == '\n') w.writeAll("\r\n") else w.writeByte(c)) catch {}; w.writeAll("\r\n") catch {}; - panes.Terminal.feedOutput(p, pane, w.buffered()); + panes.terminal.feedOutput(p, pane, w.buffered()); } /// `run ` or `exit ` in the log. @@ -788,8 +788,8 @@ pub fn commandDone(p: *Pardes, id: usize, status: ?u8) void { var buf: [16]u8 = undefined; const code = if (status) |n| std.fmt.bufPrint(&buf, "{d}", .{n}) catch "?" else "?"; var said: [32]u8 = undefined; - const lead = if (panes.Terminal.gridCursor(pane).x != 0) "\r\n" else ""; - panes.Terminal.feedOutput(p, pane, std.fmt.bufPrint(&said, "{s}exit {s}\r\n", .{ lead, code }) catch "exit ?\r\n"); + const lead = if (panes.terminal.gridCursor(pane).x != 0) "\r\n" else ""; + panes.terminal.feedOutput(p, pane, std.fmt.bufPrint(&said, "{s}exit {s}\r\n", .{ lead, code }) catch "exit ?\r\n"); noteRun(p, pane, "exit", code); p.needs_frame = true; } diff --git a/src/fs.zig b/src/fs.zig index a2e8daf8..fc3eb98f 100644 --- a/src/fs.zig +++ b/src/fs.zig @@ -57,7 +57,7 @@ const source_files = [_]Source{ .{ .path = "src/Output.zig", .contents = @embedFile("Output.zig") }, .{ .path = "src/mini.zig", .contents = @embedFile("mini.zig") }, .{ .path = "src/image.zig", .contents = @embedFile("image.zig") }, - .{ .path = "src/Terminal.zig", .contents = @embedFile("Terminal.zig") }, + .{ .path = "src/terminal.zig", .contents = @embedFile("terminal.zig") }, .{ .path = "src/pdf_view.zig", .contents = @embedFile("pdf_view.zig") }, .{ .path = "src/layout.zig", .contents = @embedFile("layout.zig") }, .{ .path = "src/fs.zig", .contents = @embedFile("fs.zig") }, diff --git a/src/host_io.zig b/src/host_io.zig index 9e3fbf79..e7be28cd 100644 --- a/src/host_io.zig +++ b/src/host_io.zig @@ -1293,7 +1293,7 @@ pub fn forkShell( var command: std.Io.Writer.Allocating = .init(c.gpa); defer command.deinit(); try @import("linux/v9fs.zig").writeLaunchCommand(&command.writer, path, std.mem.span(socket), &spawn.argv); - if (!try pardes.panes.Terminal.queuePendingCommand(pn, command.written())) return error.ShellAlreadyStarted; + if (!try pardes.panes.terminal.queuePendingCommand(pn, command.written())) return error.ShellAlreadyStarted; } // Built here and not after the fork: between fork and exec the child may // not call setenv, whose malloc can deadlock against a pty reader thread diff --git a/src/layout.zig b/src/layout.zig index 28f567bc..28f3d270 100644 --- a/src/layout.zig +++ b/src/layout.zig @@ -607,7 +607,7 @@ fn blankRows(p: *const Pardes, id: usize) u16 { // The empty line after a final newline shows nothing. (panes.File.nlines(p.gpa, f) -| @intFromBool(std.mem.endsWith(u8, f.content, "\n"))) -| f.scroll else if (pane.isTerminal()) - @as(usize, panes.Terminal.gridCursor(pane).y) + 1 + @as(usize, panes.terminal.gridCursor(pane).y) + 1 else rows; return rows -| @as(u16, @intCast(@min(used, rows))); @@ -742,7 +742,7 @@ pub fn splitBelow(p: *Pardes, src_id: usize, nw: *Pane) void { // Its tag may wrap to more than one row: the rows under it are its body. const tag_h = @max(BOX_H, src.tag_rows); const body: u16 = if (src_h > tag_h) src_h - tag_h else 1; - const cur: u16 = if (!src.isTerminal()) body / 2 else panes.Terminal.gridCursor(src).y + 1; + const cur: u16 = if (!src.isTerminal()) body / 2 else panes.terminal.gridCursor(src).y + 1; // cap keep so a content-full source still leaves the new pane a tag + // a few body rows (an Alt-n from a full shell was born 0 rows tall) // ...and the source min_body_rows of its own (exec.placeNew), when it diff --git a/src/mouse.zig b/src/mouse.zig index 1f2648b0..cb0378de 100644 --- a/src/mouse.zig +++ b/src/mouse.zig @@ -821,7 +821,7 @@ fn mirrorTtySelection(p: *Pardes, pane: *Pane) void { const forward = gesture.r1 > gesture.r0 or (gesture.r1 == gesture.r0 and gesture.c1 >= gesture.c0); const endpoint = rows[if (forward) rows.len - 1 else 0]; const body = body_layer.bodyText(p, p.scratch.allocator(), pane) catch return; - const line = modal.lineSlice(body, @intCast(@max(0, endpoint.row - panes.Terminal.gridOffset(pane)))); + const line = modal.lineSlice(body, @intCast(@max(0, endpoint.row - panes.terminal.gridOffset(pane)))); const byte = if (forward) modal.prevGrapheme(line, @min(endpoint.hi, line.len)) else endpoint.lo; pane.body.cur_row = pane.surfRow(endpoint.row); var shown = std.mem.trimEnd(u8, line[@min(endpoint.prompt_bytes, line.len)..], " \t"); @@ -1137,11 +1137,11 @@ fn chordCutPaste(p: *Pardes, cut: bool) void { if (cut) return; const y = p.registers.text(p.gpa, pardes.Registers.default) orelse return; if (y.len == 0) return; - if (panes.Terminal.reportsMouse(pane)) { + if (panes.terminal.reportsMouse(pane)) { const col: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].c1 + 1, 1, 9999)); const row: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].r1 - @as(i32, pane.tag_rows) + 1, 1, 9999)); var mb: [32]u8 = undefined; - if (panes.Terminal.mouseFormatSgr(pane)) { + if (panes.terminal.mouseFormatSgr(pane)) { p.emitWrite(s.id, std.fmt.bufPrint(&mb, "\x1b[<0;{d};{d}M\x1b[<0;{d};{d}m", .{ col, row, col, row }) catch return); } else { // ponytail: legacy X10 bytes; add utf8/urxvt formats if an app ever wants them diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 5bf354eb..060e0d6c 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -344,7 +344,7 @@ pub fn paneText(p: *Pardes, pane: *Pane, buf: []u8) []const u8 { pane.serial, pane_files.tagOf(p, pane).len, // A terminal's body is its history, read whole on an open's first read. - if (pane.isTerminal()) panes.Terminal.screenTextLen(pane) else pane_files.bodyOf(pane).len, + if (pane.isTerminal()) panes.terminal.screenTextLen(pane) else pane_files.bodyOf(pane).len, @as(u32, 0), @intFromBool(pane_files.dirtyOf(pane)), pane.cols, diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index 3c1e181e..3fad1fae 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -258,7 +258,7 @@ pub fn fileSize(p: *Pardes, id: usize, f: PaneFile) u64 { const pf = &pane.fs; return switch (f) { // A terminal's body reads its screen and history, as text. - .body => if (pane.file == null and pane.isTerminal()) panes.Terminal.screenTextLen(pane) else if (pdfText(p, pane)) |t| t.len else bodyOf(pane).len, + .body => if (pane.file == null and pane.isTerminal()) panes.terminal.screenTextLen(pane) else if (pdfText(p, pane)) |t| t.len else bodyOf(pane).len, .data, .xdata => bodyOf(pane).len, .tag => tagOf(p, pane).len, .name => nameOf(pane).len + 1, @@ -399,7 +399,7 @@ fn readBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { return tree.stagedReply(p, req); } if (req.handle != 0 and pane.isTerminal()) return screen.readSnapshot(p, req, pane); - const text = panes.Terminal.screenTextAlloc(pane, p.gpa) catch + const text = panes.terminal.screenTextAlloc(pane, p.gpa) catch return Reply.fail(req.tag, E.NOMEM); defer p.gpa.free(text); const out = p.fs.stage(p.gpa); diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig index 8ba9f4df..0f5202cc 100644 --- a/src/ninep/pty.zig +++ b/src/ninep/pty.zig @@ -139,9 +139,9 @@ pub fn readStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { // or a line is typed at the prompt. Before the shell's first prompt a // run waits for it rather than answer busy, so that is not busy. // A build with no terminal panes (the board) has no marks to read. - const marks = if (comptime !pardes.panes.Terminal.enabled) null else if (pane.terminal) |state| &state.stream.handler else null; + const marks = if (comptime !pardes.panes.terminal.enabled) null else if (pane.terminal) |state| &state.stream.handler else null; const busy = p.hostTtyTaken(id) or (!pane.fs.unmarked and (waitingRun(p, pane) != null or - (if (marks) |m| m.prompts > 0 and (m.phase != .input or !pardes.panes.Terminal.promptInputEmpty(pane)) else false))); + (if (marks) |m| m.prompts > 0 and (m.phase != .input or !pardes.panes.terminal.promptInputEmpty(pane)) else false))); const out = p.fs.stage(p.gpa); const size = winsize(pane); out.print(p.gpa, "{d:>11} {d:>11} {d:>11}\n", .{ size[0], size[1], @intFromBool(busy) }) catch {}; @@ -222,7 +222,7 @@ pub fn writeRun(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { if (std.mem.trim(u8, line, " \t").len == 0) return tree.failText(req.tag, E.INVAL, e_bad_line); if (line.len == 0) return tree.failText(req.tag, E.INVAL, e_bad_line); for (line) |c| if (c < ' ' and c != '\t') return tree.failText(req.tag, E.INVAL, e_bad_line); - if (comptime !pardes.panes.Terminal.enabled) return tree.failText(req.tag, E.INVAL, e_bad_line); + if (comptime !pardes.panes.terminal.enabled) return tree.failText(req.tag, E.INVAL, e_bad_line); const pf = &pane.fs; const state = pane.terminal orelse return tree.failText(req.tag, E.INVAL, e_bad_line); const marks = &state.stream.handler; @@ -235,7 +235,7 @@ pub fn writeRun(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { } else if (pf.unmarked) { answer(p, slot, "error no prompt marks", .{}); } else if (waitingRun(p, pane) != null or (marks.prompts > 0 and (marks.phase != .input or - !pardes.panes.Terminal.promptInputEmpty(pane) or p.hostTtyTaken(id)))) + !pardes.panes.terminal.promptInputEmpty(pane) or p.hostTtyTaken(id)))) { // Something is running or someone has typed at the prompt: sending // now would type into the middle of it. The phase is pardes's own @@ -288,7 +288,7 @@ pub fn readRun(p: *Pardes, req: Req) Reply { /// for the prompt means whoever reads the answer can send the next command /// at once instead of finding the shell still drawing it. pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void { - if (comptime !pardes.panes.Terminal.enabled) return; + if (comptime !pardes.panes.terminal.enabled) return; const state = pane.terminal orelse return; const pf = &pane.fs; const slot = waitingRun(p, pane) orelse return; @@ -299,13 +299,13 @@ pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void { pf.run = null; return; } - if (marks.phase != .input or !pardes.panes.Terminal.promptInputEmpty(pane) or p.hostTtyTaken(id)) return; + if (marks.phase != .input or !pardes.panes.terminal.promptInputEmpty(pane) or p.hostTtyTaken(id)) return; slot.want = marks.started +% 1; slot.prompts = marks.prompts; slot.declined = marks.declined; p.emitWrite(id, slot.line); p.emitWrite(id, "\r"); - pardes.panes.Terminal.noteCommand(pane, slot.line); + pardes.panes.terminal.noteCommand(pane, slot.line); slot.phase = .sent; return; } @@ -319,7 +319,7 @@ pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void { // typed into an empty prompt, so throw it away with Ctrl-C, which // drops the whole pending command, and answer at the prompt after // that, so the next line finds the shell clear. - const empty = pardes.panes.Terminal.promptInputEmpty(pane); + const empty = pardes.panes.terminal.promptInputEmpty(pane); switch (slot.cleared) { .no => { if (marks.continuation) { @@ -360,7 +360,7 @@ pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void { /// Answers a run whose command ended with `status`: the header and the /// output it printed. fn finish(p: *Pardes, slot: *Run, pane: *Pane, status_code: ?i32) void { - const printed = pardes.panes.Terminal.commandOutput(pane, p.gpa, output_rows) catch + const printed = pardes.panes.terminal.commandOutput(pane, p.gpa, output_rows) catch return answer(p, slot, "error out of memory", .{}); defer p.gpa.free(printed.text); // Keep the tail, cut at a line start so the first line kept is whole. @@ -400,7 +400,7 @@ pub fn shellExited(p: *Pardes, pane: *Pane, status: ?u8) void { const code = status orelse return; if (waitingRun(p, pane)) |slot| if (slot.phase == .sent) { pane.fs.run = null; - pardes.panes.Terminal.endOutputHere(pane); + pardes.panes.terminal.endOutputHere(pane); finish(p, slot, pane, code); }; var buf: [4]u8 = undefined; @@ -963,10 +963,10 @@ test "a run's answer says cut when its output's start is gone, and reads a bound // A resize between its start and end reflows the pins with the text. h = sh.send(p, node); sh.put(p, c ++ "one\r\ntwo\r\n"); - pardes.panes.Terminal.resizeGrid(pane, gpa, 50, 20); + pardes.panes.terminal.resizeGrid(pane, gpa, 50, 20); sh.put(p, d ++ prompt); try testing.expectEqualStrings("exit 0\none\ntwo\n", sh.answered(p, node, h, into)); - pardes.panes.Terminal.resizeGrid(pane, gpa, pane.cols, pane.rows); + pardes.panes.terminal.resizeGrid(pane, gpa, pane.cols, pane.rows); // A full-screen program leaves nothing on the primary screen, and a D // with no status is not a success. diff --git a/src/ninep/screen.zig b/src/ninep/screen.zig index f496da03..b991c3e4 100644 --- a/src/ninep/screen.zig +++ b/src/ninep/screen.zig @@ -47,7 +47,7 @@ pub fn readSnapshot(p: *Pardes, req: Req, pane: ?*Pane) Reply { const snapshot = &(tree.openOf(p, req) orelse return Reply.fail(req.tag, E.INVAL)).what.snapshot; if (snapshot.* == null) { const terminal = pane orelse return Reply.fail(req.tag, E.INVAL); - snapshot.* = panes.Terminal.screenTextAlloc(terminal, p.gpa) catch return Reply.fail(req.tag, E.NOMEM); + snapshot.* = panes.terminal.screenTextAlloc(terminal, p.gpa) catch return Reply.fail(req.tag, E.NOMEM); } const bytes = snapshot.*.?; const off = @min(req.off, bytes.len); @@ -129,7 +129,7 @@ test "terminal body handles keep one history snapshot across fragmented reads" { const node = Node.of(serialOf(p), .body); p.update(.{ .output = .{ .pane = 0, .bytes = "old caf\xc3\xa9\r\nold tail" } }); while (p.nextEffect()) |_| {} - const original = try panes.Terminal.screenTextAlloc(p.panes[0].?, gpa); + const original = try panes.terminal.screenTextAlloc(p.panes[0].?, gpa); defer gpa.free(original); const opened = call(p, .{ .tag = 1, .op = .open, .node = node }); try testing.expectEqual(Status.ok, opened.reply.status); diff --git a/src/panes.zig b/src/panes.zig index b6fdc31a..0380f89f 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -89,7 +89,7 @@ pub const Pane = struct { pub const Cwd = union(enum) { none, inherited: *Pane, owned: []u8 }; pub const Focus = enum { body, tag }; - terminal: ?*Terminal.State = null, + terminal: ?*terminal.State = null, gpa: std.mem.Allocator, serial: u32 = 0, /// What the control filesystem keeps of this pane -- addr and limit, the @@ -116,7 +116,7 @@ pub const Pane = struct { /// A shell has run in it (Pardes.acknowledgeShell): a Tty whose first /// shell fails to start closes, one started again keeps its pane. had_shell: bool = false, - pending_command: Terminal.PendingCommand = .{}, + pending_command: terminal.PendingCommand = .{}, /// The last command line pardes typed into this terminal (an exec, a /// middle click, a pty/run): its first word, which Kill matches, and /// the shell's command count its start (C mark) reaches. @@ -227,7 +227,7 @@ pub const Pane = struct { next_pointer_selection: u64 = 0, /// terminals only: the typed-text buffer standing in for shell rows - ovl: ?Terminal.EditBuffer = null, + ovl: ?terminal.EditBuffer = null, tty_filter: bool = false, msg: [256]u8 = undefined, msg_len: u16 = 0, @@ -486,7 +486,7 @@ pub const Pane = struct { pub fn scroll(pane: *Pane) i32 { if (pane.file) |f| return @intCast(f.scroll); if (comptime Pdf.enabled) if (pane.pdf) |pv| return @intCast(pv.text_scroll); - return pane.surfRow(Terminal.gridOffset(pane)); + return pane.surfRow(terminal.gridOffset(pane)); } pub fn wrapAt(pane: *Pane, vr: i32) struct { line: i32, at: i32 } { @@ -531,8 +531,8 @@ pub const Pane = struct { } } else { // the vt scrolls in SHELL rows; convert through the edit buffer - const off = Terminal.gridOffset(pane); - Terminal.scrollGrid(pane, pane.gridRow(pane.surfRow(off) + delta) - off); + const off = terminal.gridOffset(pane); + terminal.scrollGrid(pane, pane.gridRow(pane.surfRow(off) + delta) - off); } } @@ -611,8 +611,8 @@ pub const Pane = struct { pane.body.cur_row = pane.scroll(); pane.body.cur_col = 0; } else { - const cur = Terminal.gridCursor(pane); - pane.body.cur_row = pane.surfRow(@as(i32, cur.y) + Terminal.gridOffset(pane)); + const cur = terminal.gridCursor(pane); + pane.body.cur_row = pane.surfRow(@as(i32, cur.y) + terminal.gridOffset(pane)); pane.body.cur_col = @intCast(cur.x); } pane.body.cur_pinned = true; @@ -642,7 +642,7 @@ pub const Image = @import("image.zig"); pub const Pdf = @import("pdf_view.zig"); -pub const Terminal = @import("Terminal.zig"); +pub const terminal = @import("terminal.zig"); test { _ = Pane; @@ -652,5 +652,5 @@ test { _ = mini; _ = Image; _ = Pdf; - _ = Terminal; + _ = terminal; } diff --git a/src/pardes.zig b/src/pardes.zig index 141b214e..623af3c9 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -1195,9 +1195,9 @@ test "Tty9p marks only the new Linux terminal for a mounted shell" { try std.testing.expect(pane.v9fs_on_spawn); try std.testing.expect(!pane.greet); try std.testing.expect(!exec.takesCommandLine(p, p.active)); - panes.Terminal.feedOutput(p, pane, "\x1b]133;A\x07$ \x1b]133;B\x07"); + panes.terminal.feedOutput(p, pane, "\x1b]133;A\x07$ \x1b]133;B\x07"); try std.testing.expect(exec.takesCommandLine(p, p.active)); - panes.Terminal.feedOutput(p, pane, "\x1b]133;C\x07"); + panes.terminal.feedOutput(p, pane, "\x1b]133;C\x07"); try std.testing.expect(!exec.takesCommandLine(p, p.active)); } @@ -1558,7 +1558,7 @@ test "a command of several lines keeps its tag one row, and its echo does not st _ = exec.execute(p, 1, two); while (p.nextEffect()) |_| {} _ = try p.render(frame.allocator()); - const body = try panes.Terminal.screenTextAlloc(pane, gpa); + const body = try panes.terminal.screenTextAlloc(pane, gpa); defer gpa.free(body); try std.testing.expect(std.mem.indexOf(u8, body, "% def greet(name):\n return name") != null); } @@ -1878,7 +1878,7 @@ test "a command pane shows how its command ended, and the next command there run const pane = p.panes[dst].?; // Its first command is echoed as every later one is. { - const first = try panes.Terminal.screenTextAlloc(pane, gpa); + const first = try panes.terminal.screenTextAlloc(pane, gpa); defer gpa.free(first); try std.testing.expect(std.mem.startsWith(u8, first, "% make -j8")); } @@ -1909,16 +1909,16 @@ test "a command pane shows how its command ended, and the next command there run // A program that crashed out of its full screen leaves the emulator // in its modes; the next command starts clear of them. p.update(.{ .output = .{ .pane = @intCast(dst), .bytes = "\x1b[?1049h\x1b[?2004h\x1b[?1000h" } }); - try std.testing.expect(panes.Terminal.bracketedPaste(pane)); + try std.testing.expect(panes.terminal.bracketedPaste(pane)); // Done, the directory's next command runs there, below what it showed. try std.testing.expectEqual(@as(?usize, dst), exec.execute(p, 1, "make test")); - try std.testing.expect(!panes.Terminal.bracketedPaste(pane)); + try std.testing.expect(!panes.terminal.bracketedPaste(pane)); var respawned = false; while (p.nextEffect()) |effect| if (effect == .spawn and effect.spawn.pane == dst) { respawned = true; }; try std.testing.expect(respawned); - const body = try panes.Terminal.screenTextAlloc(pane, gpa); + const body = try panes.terminal.screenTextAlloc(pane, gpa); defer gpa.free(body); const said = std.mem.indexOf(u8, body, "compiled") orelse return error.OutputLost; const ended = std.mem.indexOf(u8, body, "exit 2") orelse return error.ExitLost; @@ -4394,7 +4394,7 @@ pub const Pardes = struct { settings: config.Runtime = .{ .font = .{ .tagline_percent = config.gui_tagline_font_percent } }, locations_config: locations.Config = .{}, locations_cache: locations.Cache = .{}, - tty_filter_palette: panes.Terminal.FilterPalette = .{}, + tty_filter_palette: panes.terminal.FilterPalette = .{}, font_request_taken: bool = false, custom_theme: ?Theme = null, /// bodyChrome's: the theme it was worked out for, and it. @@ -4580,8 +4580,8 @@ pub const Pardes = struct { /// that still own their loop pass their own arena to `render` instead. frame_arena: std.heap.ArenaAllocator, /// the terminal motion surface, memoized against the pane it was built - /// for — see panes.Terminal.RowsCache for the lifetime rule - shell_rows: panes.Terminal.RowsCache = .{}, + /// for — see panes.terminal.RowsCache for the lifetime rule + shell_rows: panes.terminal.RowsCache = .{}, /// The message row's entry points, called from everywhere as /// `p.setMessage(...)`; they live with the rest of it in Messages.zig. @@ -4944,7 +4944,7 @@ pub const Pardes = struct { } p.shell_rows.dropPane(pane); for (0..pane.pointer_selections.len) |slot| pane.clearPointerSelection(slot); - panes.Terminal.deinitPendingCommand(pane); + panes.terminal.deinitPendingCommand(pane); if (pane.command) |line| p.gpa.free(line); if (pane.shell) |bin| p.gpa.free(bin); if (pane.image) |*iv| { @@ -4957,7 +4957,7 @@ pub const Pardes = struct { pane.tag.deinit(p.gpa); pane.input.deinit(p.gpa); pane.jump.deinit(p.gpa); - panes.Terminal.deinitEmulator(pane, p.gpa); + panes.terminal.deinitEmulator(pane, p.gpa); pane.clearCwd(); pane.fs.deinit(p.gpa); p.gpa.destroy(pane); @@ -4986,12 +4986,12 @@ pub const Pardes = struct { pub fn newShell(p: *Pardes, id: usize, cwd: []const u8) !*Pane { std.debug.assert(p.panes[id] == null); if (cwd.len > limits.host_path_cap) return error.PathTooLong; - const pane = try panes.Terminal.create(p.gpa, p.screen_w, p.screen_h); + const pane = try panes.terminal.create(p.gpa, p.screen_w, p.screen_h); // Named by the directory it is started in from the first, so the // shell saying it is there is no rename (`new N /` then `rename`); // out of memory for it, the shell names it when it says. if (cwd.len > 0) pane.setOwnedCwd(cwd) catch {}; - panes.Terminal.armShellSpawn(pane); + panes.terminal.armShellSpawn(pane); p.installPane(id, pane); p.emitSpawn(id, pane.serial, cwd); return pane; @@ -5017,7 +5017,7 @@ pub const Pardes = struct { if (cwd.len > limits.host_path_cap) return error.PathTooLong; const owned = try p.gpa.dupe(u8, line); errdefer p.gpa.free(owned); - const pane = try panes.Terminal.create(p.gpa, p.screen_w, p.screen_h); + const pane = try panes.terminal.create(p.gpa, p.screen_w, p.screen_h); pane.command = owned; pane.command_pty = true; pane.body.mode = .tty; @@ -5031,7 +5031,7 @@ pub const Pardes = struct { pub fn newDocPane(p: *Pardes, id: usize) !*Pane { std.debug.assert(p.panes[id] == null); - const pane = try panes.Terminal.createDoc(p.gpa, p.screen_w, p.screen_h); + const pane = try panes.terminal.createDoc(p.gpa, p.screen_w, p.screen_h); p.installPane(id, pane); return pane; } @@ -5223,7 +5223,7 @@ pub const Pardes = struct { } else |_| {} } }; - if (id < MAX_PANES) panes.Terminal.shellSpawned(p, id, prompt_marks); + if (id < MAX_PANES) panes.terminal.shellSpawned(p, id, prompt_marks); if (p.settings.shell.effective.set(executable)) p.settings.shell.pending = false; } @@ -5358,7 +5358,7 @@ pub const Pardes = struct { /// The pane a session word runs with in an empty window. pub fn standIn(p: *Pardes) ?*Pane { - if (p.stand_in == null) p.stand_in = panes.Terminal.createDoc(p.gpa, p.screen_w, p.screen_h) catch null; + if (p.stand_in == null) p.stand_in = panes.terminal.createDoc(p.gpa, p.screen_w, p.screen_h) catch null; return p.stand_in; } @@ -5627,7 +5627,7 @@ pub const Pardes = struct { return; } if (!pane.isTerminal()) return; - const text = panes.Terminal.screenTextAlloc(pane, p.gpa) catch return; + const text = panes.terminal.screenTextAlloc(pane, p.gpa) catch return; defer p.gpa.free(text); p.hostWriteFile(st.pane, st.path.slice(), text); }, @@ -5839,7 +5839,7 @@ pub const Pardes = struct { const pane = p.panes[o.pane] orelse return; pane.shell_spoke = true; ctlfs.events.notePtyOutput(p, o.pane, o.bytes); - panes.Terminal.feedOutput(p, pane, o.bytes); + panes.terminal.feedOutput(p, pane, o.bytes); ctlfs.pty.noteMarks(p, o.pane, pane); exec.flushReplEnter(p, o.pane, true); }, @@ -5944,7 +5944,7 @@ pub const Pardes = struct { .normal => edit.enterInsert(p, &pane.body, .at, 1), .insert => { edit.exitInsert(p, &pane.body); - if (pane.isTerminal()) panes.Terminal.enterTty(p, id); + if (pane.isTerminal()) panes.terminal.enterTty(p, id); }, } } @@ -5960,7 +5960,7 @@ pub const Pardes = struct { if (pane.body.mode == .tty) { pane.body.mode = .normal; pane.body.normal.clear(); - } else panes.Terminal.enterTty(p, id); + } else panes.terminal.enterTty(p, id); } /// Type `keys` again (a macro, `.`): through the modal handling as @@ -6079,7 +6079,7 @@ pub const Pardes = struct { return exec.runBuiltin(p, .Last, p.active, "", null); if (key.cp == Key.escape and !key.ctrl and !key.alt and !key.shift and exec.takesCommandLine(p, p.active)) return exec.runBuiltin(p, .Last, p.active, "", null); - panes.Terminal.followOutput(pane); // typing snaps back to live output + panes.terminal.followOutput(pane); // typing snaps back to live output // Paste stays the window's, the one exception to forwarding a raw // tty's keys: Ctrl-V types the register at the program and // Ctrl-Shift-V asks the desktop for its clipboard first. Forwarded, @@ -6094,7 +6094,7 @@ pub const Pardes = struct { // Ctrl-V a black hole in every full-screen application. if (p.registers.text(p.gpa, Registers.default)) |text| return edit.typeToTty(p, p.active, pane, text); } - return panes.Terminal.forwardKey(p, p.active, key); + return panes.terminal.forwardKey(p, p.active, key); } if (p.leader_on) return p.leaderKey(key); if (p.ctrl_w_pending) { @@ -6247,7 +6247,7 @@ pub const Pardes = struct { edit.exitInsert(p, t) else edit.handleInsert(p, t, key), - .tty => panes.Terminal.forwardKey(p, p.active, key), + .tty => panes.terminal.forwardKey(p, p.active, key), } // `.`'s log: the keys of the normal command that entered insert // mode, then everything typed until it is left @@ -6738,7 +6738,7 @@ pub const Pardes = struct { errdefer p.gpa.free(path); const history = try panes.File.History.create(p.gpa); errdefer p.gpa.destroy(history); - const pane = try panes.Terminal.createDoc(p.gpa, p.screen_w, p.screen_h); + const pane = try panes.terminal.createDoc(p.gpa, p.screen_w, p.screen_h); errdefer p.gpa.destroy(pane); try p.deinitPane(p.panes[0].?); p.panes[0] = null; @@ -7086,7 +7086,7 @@ pub const Pardes = struct { // doc panes have no pty/emulator grid to reflow; just record // the size so bodyText renders the right number of rows if (pane.isTerminal()) { - panes.Terminal.resizeGrid(pane, p.gpa, cols, rows); + panes.terminal.resizeGrid(pane, p.gpa, cols, rows); p.shell_rows.markStale(pane); // reflow moved every row p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } }); pane.fs.winsize = null; @@ -7095,12 +7095,12 @@ pub const Pardes = struct { pane.rows = rows; if (pane.file) |*file| file.syntax_dirty = true; } - panes.Terminal.releasePendingCommandIfReady(p, id, pane); + panes.terminal.releasePendingCommandIfReady(p, id, pane); // The greeting `ls` of a new terminal in a directory is typed at // its shell's FIRST prompt, or never: typed later it would land // in whatever runs then, or in the middle of a line someone - // typed (panes.Terminal.greeting). - if (pane.greet and pane.isTerminal() and p.resize_count > 0) switch (panes.Terminal.greeting(pane)) { + // typed (panes.terminal.greeting). + if (pane.greet and pane.isTerminal() and p.resize_count > 0) switch (panes.terminal.greeting(pane)) { .wait => {}, .never => pane.greet = false, .now => { @@ -7381,7 +7381,7 @@ test "argv naming nothing boots an errors pane rather than failing the launch" { } test "raw terminal cursor obeys visibility without hiding modal and tag cursors" { - if (comptime !panes.Terminal.enabled) return error.SkipZigTest; + if (comptime !panes.terminal.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(); @@ -7426,7 +7426,7 @@ test "raw terminal cursor obeys visibility without hiding modal and tag cursors" } test "raw terminal cursor follows its active row only while that row is visible" { - if (comptime !panes.Terminal.enabled) return error.SkipZigTest; + if (comptime !panes.terminal.enabled) return error.SkipZigTest; const gpa = std.testing.allocator; for ([_]bool{ false, true }) |tag_bottom| { const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); @@ -7445,13 +7445,13 @@ test "raw terminal cursor follows its active row only while that row is visible" try std.testing.expectEqual(rect.x + config.GUTTER + 3, live.x); try std.testing.expectEqual(body_y + 2, live.y); - panes.Terminal.scrollGrid(pane, -1); + panes.terminal.scrollGrid(pane, -1); const scrolled = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; try std.testing.expectEqual(live.x, scrolled.x); try std.testing.expectEqual(live.y + 1, scrolled.y); - panes.Terminal.scrollGrid(pane, -@as(i32, pane.rows)); + panes.terminal.scrollGrid(pane, -@as(i32, pane.rows)); try std.testing.expect((try p.render(frame.allocator())).cursor == null); - panes.Terminal.followOutput(pane); + panes.terminal.followOutput(pane); const returned = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; try std.testing.expectEqual(live.x, returned.x); try std.testing.expectEqual(live.y, returned.y); @@ -7895,31 +7895,31 @@ test "tty output follows only from the bottom" { p.update(.{ .output = .{ .pane = @intCast(shell), .bytes = std.fmt.bufPrint(&buf, "line {d}\r\n", .{i}) catch unreachable } }); } p.sync(); - const bottom = panes.Terminal.scrollbar(sp).offset; + const bottom = panes.terminal.scrollbar(sp).offset; try std.testing.expect(bottom > 0); // Scrolled back, the reader stays put however much the shell prints. sp.scrollBy(-10); p.sync(); - const parked = panes.Terminal.scrollbar(sp).offset; + const parked = panes.terminal.scrollbar(sp).offset; try std.testing.expect(parked < bottom); for (60..70) |i| { var buf: [32]u8 = undefined; p.update(.{ .output = .{ .pane = @intCast(shell), .bytes = std.fmt.bufPrint(&buf, "line {d}\r\n", .{i}) catch unreachable } }); } p.sync(); - try std.testing.expectEqual(parked, panes.Terminal.scrollbar(sp).offset); + try std.testing.expectEqual(parked, panes.terminal.scrollbar(sp).offset); // Typing snaps back to live output, so nobody types blind. p.update(.{ .key = .{ .cp = 'x', .text = "x" } }); p.sync(); - try std.testing.expect(panes.Terminal.scrollbar(sp).offset > parked); + try std.testing.expect(panes.terminal.scrollbar(sp).offset > parked); // ...and from there output follows again. - const live = panes.Terminal.scrollbar(sp).offset; + const live = panes.terminal.scrollbar(sp).offset; p.update(.{ .output = .{ .pane = @intCast(shell), .bytes = "tail\r\n" } }); p.sync(); - try std.testing.expect(panes.Terminal.scrollbar(sp).offset > live); + try std.testing.expect(panes.terminal.scrollbar(sp).offset > live); } test "Esc back into a file leaves its view where it was" { @@ -8135,7 +8135,7 @@ test "an unasked desktop paste reaches a tty pane's program, not its buffer" { _ = drainWrites(p, &buf); const pane = p.panes[0].?; - panes.Terminal.enterTty(p, 0); + panes.terminal.enterTty(p, 0); // No request behind it: the window manager's own paste, or SDL answering a // Ctrl-Shift-V the desktop handled. It still has to reach the shell. p.update(.{ .paste = "ls -la" }); @@ -8153,7 +8153,7 @@ test "a paste larger than the effect ring reaches the program whole and in order const buf = try gpa.alloc(u8, 1 << 20); defer gpa.free(buf); _ = drainWrites(p, buf); - panes.Terminal.enterTty(p, 0); + panes.terminal.enterTty(p, 0); const text = try gpa.alloc(u8, 300 * 1024); defer gpa.free(text); @@ -8166,7 +8166,7 @@ test "a paste larger than the effect ring reaches the program whole and in order test "a bracketed paste larger than the ring still closes its bracket" { if (platform == .web) return; - if (comptime !panes.Terminal.enabled) return; + if (comptime !panes.terminal.enabled) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); @@ -8174,10 +8174,10 @@ test "a bracketed paste larger than the ring still closes its bracket" { const buf = try gpa.alloc(u8, 1 << 20); defer gpa.free(buf); _ = drainWrites(p, buf); - panes.Terminal.enterTty(p, 0); + panes.terminal.enterTty(p, 0); p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?2004h" } }); - try std.testing.expect(panes.Terminal.bracketedPaste(p.panes[0].?)); + try std.testing.expect(panes.terminal.bracketedPaste(p.panes[0].?)); const text = try gpa.alloc(u8, 300 * 1024); defer gpa.free(text); @memset(text, 'z'); @@ -8197,7 +8197,7 @@ test "pasted bytes still queued at shutdown are freed, not leaked" { const buf = try gpa.alloc(u8, 1 << 20); defer gpa.free(buf); _ = drainWrites(p, buf); - panes.Terminal.enterTty(p, 0); + panes.terminal.enterTty(p, 0); const text = try gpa.alloc(u8, 300 * 1024); defer gpa.free(text); @@ -8227,14 +8227,14 @@ test "leaving tty hides the prompt and keeps the command typed at it" { const rowOf = struct { fn at(pp: *Pardes, pane: *Pane, needle: []const u8) ?[]const u8 { - const rows = panes.Terminal.shellRows(pp, pane) catch return null; + const rows = panes.terminal.shellRows(pp, pane) catch return null; for (rows) |r| if (std.mem.indexOf(u8, r, needle) != null) return r; return null; } }.at; const bodyRowOf = struct { fn at(pp: *Pardes, pane: *Pane, needle: []const u8) ?[]const u8 { - const body = panes.Terminal.bodyText(pp.scratch.allocator(), pane) catch return null; + const body = panes.terminal.bodyText(pp.scratch.allocator(), pane) catch return null; var it = std.mem.splitScalar(u8, body, '\n'); while (it.next()) |r| if (std.mem.indexOf(u8, r, needle) != null) return r; return null; @@ -8290,7 +8290,7 @@ test "entering tty walks the shell cursor to the column clicked past the prompt" p.shell_rows.stale = true; // The command shows LEFT-HUGGED, so its column 3 is the '3'... - const rows = try panes.Terminal.shellRows(p, sp); + const rows = try panes.terminal.shellRows(p, sp); var row: i32 = 0; const at = for (rows, 0..) |r, i| { if (std.mem.indexOf(u8, r, "0123456789") != null) break i; @@ -8304,7 +8304,7 @@ test "entering tty walks the shell cursor to the column clicked past the prompt" sp.body.cur_col = 3; sp.body.cur_pinned = true; while (p.nextEffect()) |_| {} - panes.Terminal.enterTty(p, shell); + panes.terminal.enterTty(p, shell); // The walk is arrow keys the shell understands. Seven lefts: readline's // cursor sits past the '9' and the click was on the '3'. @@ -8347,7 +8347,7 @@ test "board heap: every inline ring the board pays for still fits its budget" { const effect_ring = 128 * @sizeOf(Effect); // ...and the undo history, the largest in `Pane` once the terminal is out: // 16 snapshots on the board against 256 on a desktop. - const undo_history = 2 * 16 * @sizeOf(panes.Terminal.Snapshot); + const undo_history = 2 * 16 * @sizeOf(panes.terminal.Snapshot); const grid = @as(usize, board_cols) * board_rows * (2 * @sizeOf(Cell) + @sizeOf(PanelCellDiff)); // Each of the three separately, so a failure names the one that grew @@ -8475,7 +8475,7 @@ test "jump history terminal rectangle follows output and clears on reflow" { try std.testing.expectEqualStrings("alpha", try edit.selectionText(p, pane, pane.sel[0])); for (0..40) |_| p.update(.{ .output = .{ .pane = 0, .bytes = "more output\r\n" } }); try std.testing.expectEqualStrings("alpha", try edit.selectionText(p, pane, pane.sel[0])); - panes.Terminal.resizeGrid(pane, p.gpa, pane.cols - 1, pane.rows); + panes.terminal.resizeGrid(pane, p.gpa, pane.cols - 1, pane.rows); try std.testing.expect(pane.pointerSelection(pane.sel[0]) == null); try std.testing.expectEqual(.none, pane.sel[0].state); } diff --git a/src/terminal.zig b/src/terminal.zig new file mode 100644 index 00000000..c99a92a6 --- /dev/null +++ b/src/terminal.zig @@ -0,0 +1,2670 @@ +//! A terminal pane: its VT emulator and replay, prompt rows, the edit overlay +//! typed over shell rows, and the Filter that recolours output to the theme. +const panes = @import("panes.zig"); +const std = @import("std"); +const pardes = @import("pardes.zig"); +const config = @import("config.zig"); +const Pardes = pardes.Pardes; +const Surface = pardes.Surface; +const modal = @import("modal.zig"); +const tracy = @import("tracy.zig"); +const dump = @import("dump.zig"); +const Key = pardes.Key; +const Pane = panes.Pane; +const EditText = panes.EditText; +const File = panes.File; + +pub const enabled = pardes.terminal_panes; +const ghostty_vt = if (enabled) @import("ghostty-vt") else struct {}; + +pub const history_max = if (enabled) 64 else 8; + +pub const VtSlot = if (enabled) ghostty_vt.Terminal else void; +pub const StreamSlot = if (enabled) ghostty_vt.Stream(MarkedHandler) else void; + +/// The `aid` pardes's injected bash and fish marks carry (host_io.zig). +pub const prompt_aid = "pardes"; + +/// ghostty's handler, plus where the shell is in its command cycle as +/// its OSC 133 marks tell it: ghostty parses D's exit status and drops +/// it, and /pane//pty/run waits on exactly that. Only marks carrying +/// pardes's own `aid` move it, so fish's native marks, a nested shell's +/// and a stray `133;D` in some file being printed all pass it by. +pub const MarkedHandler = if (enabled) struct { + inner: ghostty_vt.TerminalStream.Handler, + phase: enum { none, prompt, input, running } = .none, + /// Counts commands started (C at the input phase). + started: u32 = 0, + /// Counts prompts drawn (B), so a line the shell refused without + /// running (a fish syntax error: no C, no D, a new prompt) shows. + prompts: u32 = 0, + /// D marks outside a running command: the shell handled a line + /// without starting it (bash on a syntax error prints no C). + declined: u32 = 0, + /// The newest prompt continues an unfinished line (bash's PS2, marked + /// `k=c`): the shell still holds the start of the command. + continuation: bool = false, + /// Where the newest command's output began (its C) and ended (its + /// D) on the primary screen, pinned so scrolling keeps them. + out_start: ?*ghostty_vt.Pin = null, + out_end: ?*ghostty_vt.Pin = null, + /// The running command's output was erased under it -- a clear, ED 3, + /// a reset -- so the pins no longer span what it printed: ED 3 moves + /// a pin to the next page without flagging it `garbage` + /// (PageList.erasePage). + lost: bool = false, + + /// The newest command to end: which one, and its status if given. + finished: ?struct { seq: u32, status: ?i32 } = null, + + const Action = ghostty_vt.TerminalStream.Action; + + /// Move one of the output pins to the cursor. Only the primary screen + /// is pinned: a command's own full-screen display is not its output. + fn pinCursor(self: *@This(), slot: *?*ghostty_vt.Pin, now: bool) void { + const screen = self.inner.terminal.screens.get(.primary) orelse return; + if (slot.*) |old| screen.pages.untrackPin(old); + slot.* = null; + if (!now or self.inner.terminal.screens.active_key != .primary) return; + slot.* = screen.pages.trackPin(screen.cursor.page_pin.*) catch null; + } + + pub fn vt(self: *@This(), comptime action: Action.Tag, value: Action.Value(action)) void { + if (comptime action == .semantic_prompt) mark: { + const aid = value.readOption(.aid) orelse break :mark; + if (!std.mem.eql(u8, aid, prompt_aid)) break :mark; + switch (value.action) { + .fresh_line_new_prompt => { + self.phase = .prompt; + self.continuation = value.readOption(.prompt_kind) == .continuation; + }, + .end_prompt_start_input => { + self.phase = .input; + self.prompts +%= 1; + }, + // bash's DEBUG trap also fires on its own PROMPT_COMMAND, + // outside the input phase; only a C that ends input counts + .end_input_start_output => if (self.phase == .input) { + self.phase = .running; + self.started +%= 1; + self.lost = false; + self.pinCursor(&self.out_start, true); + self.pinCursor(&self.out_end, false); + }, + .end_command => if (self.phase == .running) { + self.phase = .none; + self.finished = .{ .seq = self.started, .status = value.readOption(.exit_code) }; + self.pinCursor(&self.out_end, true); + } else { + self.declined +%= 1; + }, + else => {}, + } + } + if (comptime action == .erase_display_complete or action == .erase_display_scrollback or action == .full_reset) { + if (self.phase == .running) self.lost = true; + } + self.inner.vt(action, value); + } + + pub fn deinit(self: *@This()) void { + self.inner.deinit(); + } +} else void; + +pub const Replay = struct { + bytes: [1024 * 1024]u8 = undefined, + head: usize = 0, + len: usize = 0, +}; + +pub const State = struct { + vt: VtSlot, + stream: StreamSlot, + replay: Replay, + reply: [256]u8 = undefined, + reply_len: u16 = 0, +}; + +const GColor = ghostty_vt.color; + +const FilterPaletteKey = struct { + bg: GColor.RGB, + fg: GColor.RGB, + base: [16]GColor.RGB, +}; + +pub const FilterPalette = if (enabled) LivePalette else struct {}; + +const LivePalette = struct { + key: ?FilterPaletteKey = null, + colors: GColor.Palette = GColor.default, + + fn get(self: *LivePalette, theme: *const pardes.Theme) *const GColor.Palette { + const bg = asGhostRgb(theme.bg orelse theme.tag_bg); + const fg = asGhostRgb(theme.fg orelse theme.tag_fg); + var base: [16]GColor.RGB = undefined; + if (theme.palette) |palette| { + for (&base, palette) |*dst, src| dst.* = asGhostRgb(src); + } else { + const synthesized = [16][3]u8{ + theme.bg orelse theme.tag_bg, + theme.kw, + theme.str, + theme.num, + theme.box, + theme.sel_bg, + theme.comment, + theme.fg orelse theme.tag_fg, + theme.lineno, + theme.kw, + theme.str, + theme.num, + theme.scroll_thumb, + theme.sel_fg, + theme.tag_fg, + theme.fg orelse theme.tag_fg, + }; + for (&base, synthesized) |*dst, src| dst.* = asGhostRgb(src); + } + + const key: FilterPaletteKey = .{ .bg = bg, .fg = fg, .base = base }; + if (self.key) |old| if (std.meta.eql(old, key)) return &self.colors; + + var seed = GColor.default; + for (base, 0..) |rgb, i| seed[i] = rgb; + self.colors = GColor.generate256Color(seed, .initEmpty(), bg, fg, false); + self.key = key; + return &self.colors; + } +}; + +fn asGhostRgb(rgb: [3]u8) GColor.RGB { + return .{ .r = rgb[0], .g = rgb[1], .b = rgb[2] }; +} + +fn asPardesColor(rgb: GColor.RGB) pardes.Color { + return .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }; +} + +/// The owned text standing in for `rows` live terminal rows, beginning at +/// absolute surface row `row`. The emulator grid remains untouched underneath. +pub const EditBuffer = struct { + row: i32 = 0, + rows: i32 = 1, + text: []u8 = &.{}, +}; + +/// One whole-state terminal edit boundary. Null means the pane has not yet +/// materialized an edit buffer; non-null snapshots own their text. +pub const Snapshot = struct { + ovl: ?EditBuffer, + cur_row: i32, + cur_col: i32, + vsel: panes.Text.CharSel, +}; + +pub const PendingCommand = struct { + bytes: []u8 = &.{}, + wait: enum { none, spawn, input } = .none, +}; + +pub fn armShellSpawn(pane: *Pane) void { + if (comptime !enabled) return; + std.debug.assert(pane.pending_command.bytes.len == 0); + pane.pending_command.wait = .spawn; +} + +/// Remembers a line pardes types into this terminal as the command it +/// started, for Kill: it runs from the shell's next C mark to its D. +/// ponytail: of several lines queued for a shell not up yet, only the last +/// is remembered, as the first of them. +pub fn noteCommand(pane: *Pane, line: []const u8) void { + if (comptime !enabled) return; + const state = pane.terminal orelse return; + const word = std.mem.trim(u8, line, " \t")[0 .. std.mem.indexOfAny(u8, std.mem.trim(u8, line, " \t"), " \t") orelse std.mem.trim(u8, line, " \t").len]; + var sent: @typeInfo(@FieldType(Pane, "sent_command")).optional.child = .{ .seq = state.stream.handler.started +% 1 }; + sent.len = @intCast(@min(word.len, sent.word.len)); + @memcpy(sent.word[0..sent.len], word[0..sent.len]); + pane.sent_command = sent; +} + +/// Whether the command pardes last typed here is running now. +pub fn commandRunning(pane: *const Pane) bool { + if (comptime !enabled) return false; + const state = pane.terminal orelse return false; + const sent = pane.sent_command orelse return false; + const marks = &state.stream.handler; + return marks.phase == .running and marks.started == sent.seq; +} + +pub fn queuePendingCommand(pane: *Pane, command: []const u8) !bool { + if (pane.pending_command.wait == .none) return false; + const old_len = pane.pending_command.bytes.len; + const new_len = try std.math.add(usize, old_len, try std.math.add(usize, command.len, 1)); + const bytes = if (old_len == 0) + try pane.gpa.alloc(u8, new_len) + else + try pane.gpa.realloc(pane.pending_command.bytes, new_len); + @memcpy(bytes[old_len..][0..command.len], command); + bytes[new_len - 1] = '\r'; + pane.pending_command.bytes = bytes; + pane.greet = false; + return true; +} + +pub fn shellSpawned(p: *Pardes, id: usize, prompt_marks: bool) void { + const pane = p.panes[id] orelse return; + if (!pane.isTerminal() or pane.pending_command.wait != .spawn) return; + if (prompt_marks) { + pane.pending_command.wait = .input; + releasePendingCommand(p, id, pane, false); + } else { + pane.greet = false; + releasePendingCommand(p, id, pane, true); + } +} + +pub fn releasePendingCommandIfReady(p: *Pardes, id: usize, pane: *Pane) void { + if (pane.pending_command.wait == .input) + releasePendingCommand(p, id, pane, promptInputReady(pane)); +} + +fn releasePendingCommand(p: *Pardes, id: usize, pane: *Pane, ready: bool) void { + if (!ready) return; + const bytes = pane.pending_command.bytes; + pane.pending_command = .{}; + if (bytes.len > 0) { + p.emitWrite(id, bytes); + pane.gpa.free(bytes); + } +} + +pub fn deinitPendingCommand(pane: *Pane) void { + if (pane.pending_command.bytes.len > 0) + pane.gpa.free(pane.pending_command.bytes); + pane.pending_command = .{}; +} + +pub fn terminalIo() std.Io { + return if (comptime !pardes.hosted) + std.Io.failing + else + std.Io.Threaded.global_single_threaded.io(); +} + +/// The three numbers ghostty's scrollbar reports; all zero without an emulator. +pub const Scrollbar = struct { total: usize = 0, offset: usize = 0, len: usize = 0 }; + +pub fn scrollbar(pane: *const Pane) Scrollbar { + if (comptime !enabled) return .{}; + const state = pane.terminal orelse return .{}; + const sb = state.vt.screens.active.pages.scrollbar(); + return .{ .total = sb.total, .offset = sb.offset, .len = sb.len }; +} + +/// The emulator's viewport offset, in SHELL rows: the top of what it shows. +pub fn gridOffset(pane: *const Pane) i32 { + return @intCast(scrollbar(pane).offset); +} + +/// Where the emulator itself puts the cursor, in active-area cells — the origin +/// without one, which is where an empty pane's cursor belongs anyway. +pub const GridCursor = struct { x: u16 = 0, y: u16 = 0 }; + +pub fn gridCursor(pane: *const Pane) GridCursor { + if (comptime !enabled) return .{}; + const state = pane.terminal orelse return .{}; + const cur = state.vt.screens.active.cursor; + return .{ .x = @intCast(cur.x), .y = @intCast(cur.y) }; +} + +pub fn visibleCursor(pane: *const Pane) ?GridCursor { + if (comptime !enabled) return null; + const state = pane.terminal orelse return null; + if (!state.vt.modes.get(.cursor_visible)) return null; + const cur = state.vt.screens.active.cursor; + const sb = scrollbar(pane); + const row = sb.total - sb.len + cur.y; + if (row < sb.offset or row - sb.offset >= sb.len) return null; + return .{ .x = @intCast(cur.x), .y = @intCast(row - sb.offset) }; +} + +/// Move the emulator's viewport by `delta` shell rows (negative scrolls back). +pub fn scrollGrid(pane: *Pane, delta: i32) void { + if (comptime !enabled) return; + const state = pane.terminal orelse return; + state.vt.screens.active.scroll(.{ .delta_row = delta }); +} + +/// Is the viewport already showing the last row of output? +pub fn atBottom(pane: *const Pane) bool { + if (comptime !enabled) return true; + const sb = scrollbar(pane); + return sb.offset + sb.len >= sb.total; +} + +/// Snap the viewport back onto live output. +pub fn followOutput(pane: *Pane) void { + if (comptime !enabled) return; + const state = pane.terminal orelse return; + state.vt.screens.active.scroll(.active); +} + +/// Reflow the grid. A failed reflow keeps the grid it had rather than dropping +/// a scrollback; the next resize retries with the same numbers. +pub fn resizeGrid(pane: *Pane, gpa: std.mem.Allocator, cols: u16, rows: u16) void { + if (comptime !enabled) return; + const state = pane.terminal orelse return; + if (cols != pane.cols) for (0..pane.sel.len) |button| { + pane.clearPointerSelection(button); + pane.sel[button].state = .none; + }; + state.vt.resize(gpa, .{ .cols = cols, .rows = rows }) catch {}; +} + +/// DECSET 2004: the program wants its pastes bracketed. +pub fn bracketedPaste(pane: *const Pane) bool { + if (comptime !enabled) return false; + const state = pane.terminal orelse return false; + return state.vt.modes.get(.bracketed_paste); +} + +/// The program tracks the mouse itself, so a click in its body is its event. +pub fn reportsMouse(pane: *const Pane) bool { + if (comptime !enabled) return false; + const state = pane.terminal orelse return false; + const m = &state.vt.modes; + return m.get(.mouse_event_normal) or m.get(.mouse_event_button) or m.get(.mouse_event_any); +} + +/// ...and wants them in SGR (1006) rather than the legacy X10 bytes. +pub fn mouseFormatSgr(pane: *const Pane) bool { + if (comptime !enabled) return false; + const state = pane.terminal orelse return false; + return state.vt.modes.get(.mouse_format_sgr); +} + +/// The whole scrollback as plain text, `gpa`-owned: what a terminal's body +/// reads and `Save` writes out. Logical lines: rows the terminal wrapped +/// are joined again (ghostty's wrap flags, as pty/run's output is), and +/// the last line ends with a newline. +pub fn screenTextAlloc(pane: *Pane, gpa: std.mem.Allocator) ![]const u8 { + if (comptime !enabled) return &.{}; + var out: std.Io.Writer.Allocating = .init(gpa); + errdefer out.deinit(); + try writeScreenText(pane, &out.writer); + return out.toOwnedSlice(); +} + +fn writeScreenText(pane: *Pane, w: *std.Io.Writer) !void { + if (comptime !enabled) return; + const state = pane.terminal orelse return; + const screen = state.vt.screens.active; + const br = screen.pages.getBottomRight(.screen) orelse return; + var both: TeeWriter = .{ .a = w }; + try screen.dumpString(&both.writer, .{ .tl = screen.pages.getTopLeft(.screen), .br = br, .unwrap = true }); + if (both.count > 0 and both.final != '\n') try w.writeByte('\n'); +} + +/// Passes bytes through, remembering how many and the last one. +const TeeWriter = struct { + a: *std.Io.Writer, + count: usize = 0, + final: u8 = 0, + writer: std.Io.Writer = .{ .vtable = &.{ .drain = drain }, .buffer = &.{} }, + + fn drain(w: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize { + const t: *TeeWriter = @alignCast(@fieldParentPtr("writer", w)); + var n: usize = 0; + for (data[0 .. data.len - 1]) |bytes| { + try t.a.writeAll(bytes); + t.note(bytes); + n += bytes.len; + } + const pattern = data[data.len - 1]; + for (0..splat) |_| { + try t.a.writeAll(pattern); + t.note(pattern); + n += pattern.len; + } + return n; + } + + fn note(t: *TeeWriter, bytes: []const u8) void { + if (bytes.len == 0) return; + t.count += bytes.len; + t.final = bytes[bytes.len - 1]; + } +}; + +/// How long `screenTextAlloc` would be, counted without keeping it: the +/// length a terminal's body reads as, which its ctl line reports. +pub fn screenTextLen(pane: *Pane) usize { + if (comptime !enabled) return 0; + var buf: [256]u8 = undefined; + var counting: std.Io.Writer.Discarding = .init(&buf); + writeScreenText(pane, &counting.writer) catch return 0; + return @intCast(counting.fullCount()); +} + +/// Release the emulator's heap. The Pane allocation itself is the core's. +pub fn deinitEmulator(pane: *Pane, gpa: std.mem.Allocator) void { + if (comptime !enabled) return; + const state = pane.terminal orelse return; + state.stream.deinit(); + state.vt.deinit(gpa); + gpa.destroy(state); + pane.terminal = null; +} + +/// Allocate the live emulator half of a terminal pane. Slot ownership, serial +/// assignment, and spawn effects remain core lifecycle invariants. +pub fn create(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane { + const pane = try createDoc(gpa, cols, rows); + errdefer gpa.destroy(pane); + if (comptime !enabled) return pane; + const state = try gpa.create(State); + errdefer gpa.destroy(state); + state.vt = try ghostty_vt.Terminal.init(terminalIo(), gpa, .{ + .cols = cols, + .rows = rows, + .max_scrollback = 16 * 1024 * 1024, + }); + state.stream = .initAlloc(gpa, .{ .inner = state.vt.vtHandler() }); + state.replay.head = 0; + state.replay.len = 0; + state.reply_len = 0; + state.stream.handler.inner.effects.write_pty = ptyReport; + state.stream.handler.inner.effects.device_attributes = ptyDeviceAttrs; + pane.terminal = state; + pane.tty_filter = true; + return pane; +} + +pub fn createDoc(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane { + const pane = try gpa.create(Pane); + pane.* = .{ + .gpa = gpa, + .cols = cols, + .rows = rows, + }; + return pane; +} + +/// Where a restored terminal's history ends and its new shell begins. +pub const restored_banner = "\x1b[0m\r\n\x1b[2m\u{2500}\u{2500} restored history \u{2500}\u{2500}\x1b[0m\r\n"; + +/// Rebuild a dump's terminal emulator from its recorded output. `live`: a +/// shell will be spawned into it, so the history is marked off from what +/// that shell prints and the pane waits for it like any new terminal. +/// Registration and tag/cwd policy stay with the core; raw VT replay and +/// viewport restoration belong here. +pub fn restore(p: *Pardes, src: dump.Pane, live: bool) !*Pane { + const terminal = src.terminal.?; + if (comptime !enabled) { + const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows)); + errdefer p.gpa.destroy(pane); + if (terminal.stream.len > 0) + pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, terminal.stream) }; + return pane; + } + // An older dump's raw output tail cannot come back live: cut off mid + // redraw, or written at sizes the pane no longer has, it replays as + // garbage. Its rendered text can. + const legacy_text = live and !terminal.stream_is_screen and terminal.stream.len > 0; + const bytes = if (legacy_text) + try std.mem.replaceOwned(u8, p.scratch.allocator(), terminal.stream, "\n", "\r\n") + else if (terminal.stream_b64.len > 0) + try dump.decodeBytes(p.scratch.allocator(), terminal.stream_b64) + else + &.{}; + const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows)); + if (live) { + if (bytes.len > 0) { + ingest(pane, bytes); + ingest(pane, restored_banner); + } + followOutput(pane); + // A view left scrolled back stays on what it was showing: the + // banner's line breaks scrolled a full screen by that many more. + if (src.scroll > 0) scrollGrid(pane, -@as(i32, @intCast(src.scroll + comptime std.mem.count(u8, restored_banner, "\n")))); + armShellSpawn(pane); + } else if (bytes.len > 0) { + ingest(pane, bytes); + followOutput(pane); + if (src.scroll > 0) + scrollGrid(pane, -@as(i32, @intCast(src.scroll))); + } + return pane; +} + +/// Feed the emulator and retain the bounded suffix a dump can replay. Live +/// output and restoration share this byte path, then apply different views. +fn ingest(pane: *Pane, bytes: []const u8) void { + const state = pane.terminal.?; + const replay = &state.replay; + if (bytes.len >= replay.bytes.len) { + const kept = bytes[bytes.len - replay.bytes.len ..]; + @memcpy(&replay.bytes, kept); + replay.head = 0; + replay.len = replay.bytes.len; + } else { + const overflow = bytes.len -| (replay.bytes.len - replay.len); + replay.head = (replay.head + overflow) % replay.bytes.len; + replay.len -= overflow; + const tail = (replay.head + replay.len) % replay.bytes.len; + const first = @min(bytes.len, replay.bytes.len - tail); + @memcpy(replay.bytes[tail..][0..first], bytes[0..first]); + @memcpy(replay.bytes[0 .. bytes.len - first], bytes[first..]); + replay.len += bytes.len; + } + state.stream.nextSlice(bytes); +} + +/// Return the replay ring in chronological order. Wrapped records are copied +/// into `allocator`; contiguous records remain a borrowed slice of the pane. +fn replayBytes(pane: *const Pane, allocator: std.mem.Allocator) ![]const u8 { + const state = pane.terminal orelse return &.{}; + const replay = &state.replay; + if (replay.len == 0) return &.{}; + if (replay.head + replay.len <= replay.bytes.len) + return replay.bytes[replay.head..][0..replay.len]; + const out = try allocator.alloc(u8, replay.len); + const first = replay.bytes.len - replay.head; + @memcpy(out[0..first], replay.bytes[replay.head..]); + @memcpy(out[first..], replay.bytes[0 .. replay.len - first]); + return out; +} + +test "replay ownership is terminal-only and construction rolls back on allocation failure" { + const Case = struct { + fn run(gpa: std.mem.Allocator, terminal: bool) !void { + const pane = if (terminal) try create(gpa, 40, 12) else try createDoc(gpa, 40, 12); + defer gpa.destroy(pane); + defer deinitEmulator(pane, gpa); + try std.testing.expectEqual(terminal and enabled, pane.terminal != null); + } + }; + try std.testing.expect(@sizeOf(Pane) < 128 * 1024); + try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{false}); + try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{true}); +} + +test "document construction allocates only the pane and has inert terminal defaults" { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 1 }); + const allocator = failing.allocator(); + const pane = try createDoc(allocator, 40, 12); + defer allocator.destroy(pane); + defer deinitEmulator(pane, allocator); + try std.testing.expect(pane.terminal == null); + try std.testing.expectEqual(@as(usize, 1), failing.alloc_index); + try std.testing.expectEqual(@as(usize, 0), failing.resize_index); + try std.testing.expect(!failing.has_induced_failure); + try std.testing.expectEqual(@as(usize, @sizeOf(Pane)), failing.allocated_bytes); + try std.testing.expectEqual(GridCursor{}, gridCursor(pane)); + try std.testing.expectEqual(Scrollbar{}, scrollbar(pane)); + try std.testing.expect(!bracketedPaste(pane)); + try std.testing.expect(!reportsMouse(pane)); + try std.testing.expect(!mouseFormatSgr(pane)); + try std.testing.expect(!promptInputReady(pane)); + scrollGrid(pane, 100); + followOutput(pane); + resizeGrid(pane, allocator, 80, 24); + try std.testing.expectEqual(GridCursor{}, gridCursor(pane)); + try std.testing.expectEqual(Scrollbar{}, scrollbar(pane)); + try std.testing.expectEqualStrings("", try screenTextAlloc(pane, allocator)); + try std.testing.expectEqualStrings("", try replayBytes(pane, allocator)); + try std.testing.expect(!failing.has_induced_failure); +} + +test "terminal state keeps parser fragments and sends emulator replies through its owner" { + if (comptime !enabled) return error.SkipZigTest; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + const state = pane.terminal.?; + try std.testing.expect(state.stream.handler.inner.terminal == &state.vt); + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[" } }); + while (p.nextEffect()) |effect| { + if (effect == .write) return error.PrematureTerminalReply; + } + p.update(.{ .output = .{ .pane = 0, .bytes = "6n" } }); + var replies: usize = 0; + while (p.nextEffect()) |effect| { + if (effect != .write) continue; + try std.testing.expectEqual(@as(u8, 0), effect.write.pane); + try std.testing.expectEqualStrings("\x1b[1;1R", effect.write.bytes.slice()); + replies += 1; + } + try std.testing.expectEqual(@as(usize, 1), replies); + try std.testing.expectEqual(@as(u16, 0), state.reply_len); + try std.testing.expectEqualStrings("\x1b[6n", try replayBytes(pane, std.testing.allocator)); +} + +test "replay keeps a bounded chronological suffix across large writes and wrapping" { + if (comptime !enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + const pane = try create(gpa, 40, 12); + defer gpa.destroy(pane); + defer deinitEmulator(pane, gpa); + try std.testing.expectEqualStrings("", try replayBytes(pane, gpa)); + ingest(pane, "hello"); + const small = try replayBytes(pane, gpa); + try std.testing.expectEqualStrings("hello", small); + try std.testing.expectEqual(@intFromPtr(&pane.terminal.?.replay.bytes), @intFromPtr(small.ptr)); + + const capacity = pane.terminal.?.replay.bytes.len; + const input = try gpa.alloc(u8, capacity + 7); + defer gpa.free(input); + @memset(input, 0); + const ending = "\x1b[31mred\x1b[0m\r\n"; + @memcpy(input[input.len - ending.len ..], ending); + ingest(pane, input); + try std.testing.expectEqualSlices(u8, input[7..], try replayBytes(pane, gpa)); + ingest(pane, "next\r\n"); + const wrapped = try replayBytes(pane, gpa); + defer gpa.free(wrapped); + try std.testing.expectEqual(capacity, wrapped.len); + try std.testing.expectEqualSlices(u8, input[7 + "next\r\n".len ..], wrapped[0 .. capacity - "next\r\n".len]); + try std.testing.expectEqualStrings("next\r\n", wrapped[capacity - "next\r\n".len ..]); +} + +test "replay restores terminal cells from the retained stream" { + if (comptime !enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + feedOutput(p, pane, "\x1b[31mred\x1b[0m\r\ncafé\r\n"); + const encoded = try dump.encodeBytes(gpa, try replayBytes(pane, gpa)); + defer gpa.free(encoded); + const restored = try restore(p, .{ + .kind = .terminal, + .tag = "", + .body = "", + .cols = pane.cols, + .rows = pane.rows, + .terminal = .{ .stream_b64 = encoded }, + }, false); + defer gpa.destroy(restored); + defer deinitEmulator(restored, gpa); + const before = try screenTextAlloc(pane, gpa); + defer gpa.free(before); + const after = try screenTextAlloc(restored, gpa); + defer gpa.free(after); + try std.testing.expectEqualStrings(before, after); + try std.testing.expectEqualSlices(u8, try replayBytes(pane, gpa), try replayBytes(restored, gpa)); +} + +test "a restored command pane comes back finished, its command not run again" { + if (comptime !enabled or pardes.isolated) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 60, .rows = 16 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const cmd = p.freeSlot().?; + _ = try p.newCommand(cmd, "/tmp", "make test"); + feedOutput(p, p.panes[cmd].?, "ok\r\n"); + p.update(.{ .exited = .{ .pane = @intCast(cmd), .status = 2 } }); + try dump.dumpState(p); + const restored = try dump.initFromDump(gpa, .{ .tty_only = true, .cols = 60, .rows = 16 }, p.dump_out.?); + defer restored.deinit(); + var spawns: usize = 0; + while (restored.nextEffect()) |effect| switch (effect) { + .spawn => spawns += 1, + else => {}, + }; + try std.testing.expectEqual(@as(usize, 1), spawns); // the shell, not the command + const again = for (restored.panes) |slot| { + if (slot) |pane| if (pane.command != null) break pane; + } else return error.CommandPaneLost; + try std.testing.expectEqualStrings("make test", again.command.?); + try std.testing.expect(again.command_done); + try std.testing.expectEqual(@as(?u8, 2), again.command_status); + const text = try screenTextAlloc(again, gpa); + defer gpa.free(text); + try std.testing.expect(std.mem.indexOf(u8, text, "ok") != null); +} + +test "a restored terminal keeps its history above a marker and gets a shell where it was" { + if (comptime !enabled or pardes.isolated) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + feedOutput(p, pane, "old output\r\n$ ls\r\n"); + try pane.setOwnedCwd("/tmp"); + pane.shell = try gpa.dupe(u8, "bash"); // `Tty bash`: it comes back as bash + try dump.dumpState(p); + const restored = try dump.initFromDump(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }, p.dump_out.?); + defer restored.deinit(); + var spawned = false; + while (restored.nextEffect()) |effect| switch (effect) { + .spawn => |sp| spawned = sp.pane == 0 and std.mem.eql(u8, sp.cwd.slice(), "/tmp"), + else => {}, + }; + try std.testing.expect(spawned); + const again = restored.panes[0].?; + try std.testing.expectEqualStrings("bash", again.shell.?); + try std.testing.expect(again.command == null); + try std.testing.expectEqual(.spawn, again.pending_command.wait); + const text = try screenTextAlloc(again, gpa); + defer gpa.free(text); + const old_at = std.mem.indexOf(u8, text, "old output") orelse return error.HistoryLost; + const mark_at = std.mem.indexOf(u8, text, "restored history") orelse return error.NoMarker; + try std.testing.expect(old_at < mark_at); +} + +test "an older dump comes back live from its rendered text, not its raw output tail" { + if (comptime !enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + // A tail cut mid-redraw: relative moves from a cursor it never set. + const tail = try dump.encodeBytes(gpa, "\x1b[7A\x1b[2Kgarbage\x1b[3B\x1b[9Gmore"); + defer gpa.free(tail); + const restored = try restore(p, .{ + .kind = .terminal, + .tag = "", + .body = "", + .cols = 40, + .rows = 12, + .terminal = .{ .stream = "$ ls\na.txt b.txt\n$ ", .stream_b64 = tail }, + }, true); + defer gpa.destroy(restored); + defer deinitEmulator(restored, gpa); + defer deinitPendingCommand(restored); + const text = try screenTextAlloc(restored, gpa); + defer gpa.free(text); + try std.testing.expect(std.mem.indexOf(u8, text, "$ ls\na.txt b.txt") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "garbage") == null); +} + +/// Record and parse one live pty read, invalidate its motion surface, and +/// follow it only when the body (possibly parked under a tag edit) is raw. +pub fn feedOutput(p: *Pardes, pane: *Pane, bytes: []const u8) void { + // There are no pty reads at all without an emulator to parse them into. + if (comptime !enabled) return; + if (pane.terminal == null) return; + const has_positions = pane.ovl != null or pane.body.cur_pinned or pane.body.vsel.active or + pane.body.msel.active or pane.body.nsel > 0 or pane.body.last_edit != null or pane.look_at != null or + pane.body.ed_undo_len > 0 or pane.body.ed_redo_len > 0 or pointerRowCount(pane) > 0; + // Sticky bottom, as every other terminal does it: output drags the + // viewport down only when the viewport was already on the last row, so + // a reader scrolled back into the scrollback stays where they scrolled. + const at_bottom = atBottom(pane); + if (has_positions) ingestWithPositions(pane, bytes) else ingest(pane, bytes); + p.shell_rows.markStale(pane); + if (pane.body.mode == .tty and at_bottom) followOutput(pane); +} + +const RowPin = struct { pin: ?*ghostty_vt.Pin = null, offset: i32 = 0 }; +const PositionPin = struct { + target: *i32, + row: RowPin, + overlay: ?*EditBuffer, + edit_row: ?i32 = null, +}; + +fn trackRow(pages: *ghostty_vt.PageList, row: i32, failed: *bool) RowPin { + if (row < 0) return .{ .offset = row }; + const bounded: usize = @min(@as(usize, @intCast(row)), pages.total_rows - 1); + var left = pages.total_rows - 1 - bounded; + var node = pages.pages.last.?; + while (left >= node.rows()) { + left -= node.rows(); + node = node.prev.?; + } + const pin = pages.trackPin(.{ .node = node, .y = @intCast(node.rows() - 1 - left) }) catch { + failed.* = true; + return .{}; + }; + return .{ .pin = pin, .offset = row - @as(i32, @intCast(bounded)) }; +} + +fn pinnedRow(pages: ?*ghostty_vt.PageList, row: RowPin) i32 { + const pin = row.pin orelse return row.offset; + const alive = pages orelse return 0; + if (pin.garbage) return 0; + var after: usize = 0; + var node = alive.pages.last; + while (node) |item| : (node = item.prev) { + if (item == pin.node) + return @as(i32, @intCast(alive.total_rows - after - item.rows() + pin.y)) +| row.offset; + after += item.rows(); + } + return 0; +} + +fn trackPosition(pages: *ghostty_vt.PageList, target: *i32, overlay: ?*EditBuffer, failed: *bool) PositionPin { + var grid = target.*; + if (overlay) |edit| { + const lines: i32 = @intCast(modal.lineCount(edit.text)); + if (grid >= edit.row and grid < edit.row +| lines) + return .{ .target = target, .row = .{}, .overlay = edit, .edit_row = grid - edit.row }; + if (grid > edit.row) grid = grid -| lines +| edit.rows; + } + return .{ .target = target, .row = trackRow(pages, grid, failed), .overlay = overlay }; +} + +fn pointerRowCount(pane: *const Pane) usize { + var count: usize = 0; + for (pane.sel) |gesture| if (pane.pointerSelection(gesture)) |rows| { + count += rows.len; + }; + return count; +} + +fn ingestWithPositions(pane: *Pane, bytes: []const u8) void { + const screens = &pane.terminal.?.vt.screens; + const key = screens.active_key; + const generation = screens.generation(key); + const pages = &screens.active.pages; + const Group = struct { overlay: ?*EditBuffer, cursor: *i32, selection: *panes.Text.CharSel }; + var groups: [history_max * 2 + 1]Group = undefined; + groups[0] = .{ + .overlay = if (pane.ovl) |*overlay| overlay else null, + .cursor = &pane.body.cur_row, + .selection = &pane.body.vsel, + }; + var ngroups: usize = 1; + for ([_][]Snapshot{ pane.body.ed_undo[0..pane.body.ed_undo_len], pane.body.ed_redo[0..pane.body.ed_redo_len] }) |history| { + for (history) |*snapshot| { + groups[ngroups] = .{ + .overlay = if (snapshot.ovl) |*overlay| overlay else null, + .cursor = &snapshot.cur_row, + .selection = &snapshot.vsel, + }; + ngroups += 1; + } + } + const Span = struct { overlay: *EditBuffer, start: RowPin, end: RowPin }; + var spans: [groups.len]Span = undefined; + var nspans: usize = 0; + var positions: [groups.len * 2 + panes.Text.max_selections * 2 + 4]PositionPin = undefined; + var npositions: usize = 0; + var failed = false; + for (groups[0..ngroups]) |group| { + if (group.overlay) |overlay| { + spans[nspans] = .{ + .overlay = overlay, + .start = trackRow(pages, overlay.row, &failed), + .end = trackRow(pages, overlay.row +| (overlay.rows - 1), &failed), + }; + nspans += 1; + } + positions[npositions] = trackPosition(pages, group.cursor, group.overlay, &failed); + npositions += 1; + if (group.selection.active) { + positions[npositions] = trackPosition(pages, &group.selection.row, group.overlay, &failed); + npositions += 1; + } + } + const overlay = groups[0].overlay; + var extra: [panes.Text.max_selections * 2 + 4]*i32 = undefined; + var nextra: usize = 0; + if (pane.body.msel.active) { + extra[nextra] = &pane.body.msel.r0; + extra[nextra + 1] = &pane.body.msel.r1; + nextra += 2; + } + for (pane.body.sels[0..pane.body.nsel]) |*selection| { + extra[nextra] = &selection.row; + extra[nextra + 1] = &selection.arow; + nextra += 2; + } + if (pane.body.last_edit) |*at| { + extra[nextra] = &at.row; + nextra += 1; + } + if (pane.look_at) |*at| { + extra[nextra] = &at.row; + nextra += 1; + } + for (extra[0..nextra]) |target| { + positions[npositions] = trackPosition(pages, target, overlay, &failed); + npositions += 1; + } + const pointer_positions = pane.gpa.alloc(PositionPin, pointerRowCount(pane)) catch null; + defer if (pointer_positions) |pins| pane.gpa.free(pins); + if (pointer_positions) |pins| { + var index: usize = 0; + for (pane.sel) |gesture| if (pane.pointerSelection(gesture)) |rows| { + for (rows) |*row| { + pins[index] = trackPosition(pages, &row.row, if (row.raw_terminal) null else overlay, &failed); + index += 1; + } + }; + } else failed = true; + ingest(pane, bytes); + // RIS can destroy the alternate screen and every pin it owned. + const alive = if (screens.generation(key) == generation) pages else null; + var moved = false; + var pointer_rows_lost = alive == null or pointer_positions == null; + if (pointer_positions) |pins| for (pins) |position| { + if (alive != null) if (position.row.pin) |pin| { + if (pin.garbage) pointer_rows_lost = true; + }; + }; + for (spans[0..nspans]) |span| { + const start = pinnedRow(alive, span.start); + const rows = @max(1, pinnedRow(alive, span.end) -| start +| 1); + moved = moved or start != span.overlay.row or rows != span.overlay.rows; + span.overlay.row = start; + span.overlay.rows = rows; + if (alive) |owner| { + if (span.start.pin) |pin| owner.untrackPin(pin); + if (span.end.pin) |pin| owner.untrackPin(pin); + } + } + for ([_][]PositionPin{ positions[0..npositions], pointer_positions orelse &.{} }) |batch| for (batch) |position| { + var row = pinnedRow(alive, position.row); + if (position.overlay) |edit| { + if (position.edit_row) |relative| { + row = edit.row +| relative; + } else if (row > edit.row) { + row = if (row < edit.row +| edit.rows) + edit.row + else + row -| edit.rows +| @as(i32, @intCast(modal.lineCount(edit.text))); + } + } + moved = moved or row != position.target.*; + position.target.* = row; + if (alive) |owner| if (position.row.pin) |pin| owner.untrackPin(pin); + }; + if (pointer_rows_lost) for (0..pane.sel.len) |button| { + pane.clearPointerSelection(button); + pane.sel[button].state = .none; + }; + if (moved) pane.nsel_snap = 0; // Saved regex-preview offsets name the old flat text. + if (failed) { + const message = "terminal edit position reset: out of memory"; + @memcpy(pane.msg[0..message.len], message); + pane.msg_len = message.len; + } +} + +test "raw terminal output needs no position tracking allocations" { + if (comptime !enabled) return error.SkipZigTest; + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(failing.allocator(), .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + const pane = p.panes[0].?; + pane.body.mode = .tty; + feedOutput(p, pane, "warm"); + const allocations = failing.alloc_index; + failing.fail_index = allocations; + const pins = pane.terminal.?.vt.screens.active.pages.countTrackedPins(); + feedOutput(p, pane, " output"); + try std.testing.expectEqual(allocations, failing.alloc_index); + try std.testing.expect(!failing.has_induced_failure); + try std.testing.expectEqual(pins, pane.terminal.?.vt.screens.active.pages.countTrackedPins()); +} + +/// A full-screen program has the emulator on its alternate screen. +pub fn onAlternateScreen(pane: *const Pane) bool { + if (comptime !enabled) return false; + const state = pane.terminal orelse return false; + return state.vt.screens.active_key == .alternate; +} + +/// Whether a new terminal's greeting may be typed now: only at the shell's +/// first prompt (pardes's own marks: no command started; a prompt drawn +/// again for a resize is the same first prompt), with nothing +/// typed at it and no program on the alternate screen. Any later moment is `.never`, for good: something has run or +/// been typed first, and an `ls` then would go into it. A shell pardes +/// cannot mark has no first prompt to find, and is never greeted. +pub fn greeting(pane: *const Pane) enum { wait, now, never } { + if (comptime !enabled) return .never; + if (pane.fs.unmarked) return .never; + // Text typed into the edit buffer that stands in for the prompt row. + if (pane.ovl) |o| if (o.text.len > 0) return .never; + const state = pane.terminal orelse return .wait; + const marks = &state.stream.handler; + // (Not `declined`: bash's first prompt comes after a D of its own.) + if (marks.started > 0) return .never; + // At a prompt's input (ghostty's semantic marks, pardes's or the + // shell's own), before any command pardes's marks saw run. + if (!promptInputReady(pane)) return .wait; + return if (promptInputEmpty(pane)) .now else .never; +} + +pub fn promptInputReady(pane: *const Pane) bool { + if (comptime !enabled) return false; + const state = pane.terminal orelse return false; + return state.vt.screens.active_key != .alternate and + state.vt.screens.active.cursor.semantic_content == .input; +} + +pub const CommandOutput = struct { + /// Owned by the caller. + text: [:0]const u8, + /// Whether `text` starts where the command did. It does not when that + /// start is not there to read: cut off by `max_rows`, scrolled out of + /// the history (ghostty moved the pin to the oldest row it kept and + /// flagged it `garbage`), erased under it (`MarkedHandler.lost`), or + /// never pinned (a C or D that came on the alternate screen). + whole: bool, +}; + +/// What the newest finished command printed: the screen text between its +/// C and D marks, read from at most `max_rows` rows above its end, so a +/// command that printed the whole history costs no more than its tail. +/// The shell exited in the middle of a command (its `exit 3`): no end mark +/// will come, so the command's output ends where the cursor is. +pub fn endOutputHere(pane: *Pane) void { + if (comptime !enabled) return; + const state = pane.terminal orelse return; + const marks = &state.stream.handler; + if (marks.phase == .running and marks.out_end == null) marks.pinCursor(&marks.out_end, true); +} + +pub fn commandOutput(pane: *Pane, gpa: std.mem.Allocator, max_rows: usize) !CommandOutput { + // Unless there is text to read, whether the nothing it printed is the + // whole of it. + const whole = if (comptime enabled) printed: { + const state = pane.terminal orelse break :printed true; + const marks = &state.stream.handler; + const start = marks.out_start orelse break :printed false; + const end = marks.out_end orelse break :printed false; + const screen = state.vt.screens.get(.primary) orelse break :printed false; + if (end.garbage) break :printed false; + var whole = !marks.lost and !start.garbage; + // D is where the next prompt begins: the output ends a cell before. + const last = end.leftWrap(1) orelse break :printed whole; + if (start.eql(end.*) or last.before(start.*)) break :printed whole; + var from = start.*; + if (last.up(max_rows)) |floor| if (from.before(floor)) { + from = floor; + from.x = 0; + whole = false; + }; + return .{ + .text = try screen.selectionString(gpa, .{ .sel = ghostty_vt.Selection.init(from, last, false) }), + .whole = whole, + }; + } else true; + return .{ .text = try gpa.dupeZ(u8, ""), .whole = whole }; +} + +/// At a prompt with nothing typed on its line: no input left of the +/// cursor and none under it (text typed and then Ctrl-A'd starts at the +/// cursor). Nothing further right counts: that is where fish draws a +/// right prompt, and fish suggests nothing on an empty line. +pub fn promptInputEmpty(pane: *const Pane) bool { + if (comptime !enabled) return false; + if (!promptInputReady(pane)) return false; + const cursor = &pane.terminal.?.vt.screens.active.cursor; + const row = cursor.page_pin.cells(.all); + for (row[0..@min(cursor.x + 1, row.len)]) |cell| + if (cell.semantic_content == .input and !cell.isEmpty()) return false; + return true; +} + +/// Encode one key for the program that owns a raw terminal and queue its pty +/// write, respecting the application's keyboard protocol and terminal modes. +pub fn forwardKey(p: *Pardes, id: usize, key: Key) void { + if (comptime enabled) { + const pane = p.panes[id] orelse return; + const state = pane.terminal orelse return; + const input = ghostty_vt.input; + const function_keys = comptime keys: { + var keys: [Key.f25 - Key.f1 + 1]input.Key = undefined; + for (&keys, 1..) |*k, n| k.* = @field(input.Key, std.fmt.comptimePrint("f{d}", .{n})); + break :keys keys; + }; + 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, + Key.insert => .insert, + Key.print_screen => .print_screen, + Key.scroll_lock => .scroll_lock, + Key.pause => .pause, + Key.menu => .context_menu, + Key.f1...Key.f25 => function_keys[key.cp - Key.f1], + 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; + // A key with no text of its own types its code point only when that + // is a character: the kitty keyboard protocol numbers function and + // modifier keys in the private use area (F3 is U+E016), and a shell + // was typed those as junk. + const text = if (key.text.len > 0) key.text else if (key.cp >= 32 and key.cp < Key.up and key.cp != Key.backspace and !(key.cp >= 0xE000 and key.cp <= 0xF8FF)) + utf8[0 .. std.unicode.utf8Encode(text_cp, &utf8) catch return] + else + ""; + var buffer: [256]u8 = undefined; + var writer = std.Io.Writer.fixed(&buffer); + input.encodeKey(&writer, .{ + .key = physical, + .mods = .{ .ctrl = key.ctrl, .alt = key.alt, .shift = key.shift }, + .consumed_mods = .{ .shift = key.text.len > 0 and key.shift }, + .utf8 = text, + .unshifted_codepoint = if (key.cp >= 'A' and key.cp <= 'Z') key.cp - 'A' + 'a' else if (key.cp < Key.up) key.cp else 0, + }, options: { + var opts = input.KeyEncodeOptions.fromTerminal(&state.vt); + // fromTerminal cannot know this one and says so, defaulting to + // "option is not alt" — right for an app that lets macOS do the + // unicode translation and hands the encoder the composed text. + // Every pardes shell resolves the modifier itself and hands the + // core the BASE key with the alt bit and no text at all (see + // PardesView.keyDown, and app.mjs doing the same with the same + // two bits), so by the time this runs alt already means alt. + // Left at the default, a macOS build drops the ESC prefix on + // the floor and forwards Alt-n to the child as a bare `n`. + opts.macos_option_as_alt = .true; + break :options opts; + }) catch return; + if (writer.end > 0) p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(writer.buffered()) } }); + return; + } + var control: [1]u8 = undefined; + const bytes: ?[]const u8 = blk: { + if (key.ctrl) { + if (key.cp >= 'a' and key.cp <= 'z') { + control[0] = @intCast(key.cp - 0x60); + break :blk control[0..1]; + } + // ASCII @, A-Z, [, \, ], ^ and _ are one contiguous control range. + if (key.cp >= '@' and key.cp <= '_') { + control[0] = @intCast(key.cp - 0x40); + break :blk control[0..1]; + } + } + if (key.text.len > 0) break :blk key.text; + break :blk switch (key.cp) { + Key.enter => "\r", + Key.backspace => "\x7f", + Key.tab => "\t", + Key.escape => "\x1b", + Key.up => "\x1b[A", + Key.down => "\x1b[B", + Key.right => "\x1b[C", + Key.left => "\x1b[D", + Key.delete => "\x1b[3~", + else => null, + }; + }; + if (bytes) |encoded| + p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(encoded) } }); +} + +pub fn enterTty(p: *Pardes, id: usize) void { + const pane = p.panes[id] orelse return; + const selected = pane.rawPointerText(pane.sel[@intFromEnum(config.select_button)]) != null; + if (comptime enabled) if (!selected and pane.terminal != null and pane.body.cur_pinned and pane.terminal.?.vt.cursorIsAtPrompt()) handoff: { + const screen = pane.terminal.?.vt.screens.active; + const goff: i32 = @intCast(screen.pages.scrollbar().offset); + const vp_row = pane.gridRow(pane.body.cur_row) - goff; + if (vp_row < 0) break :handoff; + + var grid_col: i32 = @max(0, pane.body.cur_col); + if (screen.pages.pin(.{ .viewport = .{ .x = 0, .y = @intCast(vp_row) } })) |row_pin| { + if (row_pin.rowAndCell().row.semantic_prompt != .none) switch (promptCut(row_pin)) { + .cut => |cols| grid_col += @intCast(cols), + .keep, .blank => {}, + }; + } + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intCast(grid_col), .y = @intCast(vp_row) }, + }) orelse break :handoff; + const cursor_pin = screen.cursor.page_pin.*; + var prompts = cursor_pin.promptIterator(.left_up, null); + const prompt_pin = prompts.next() orelse break :handoff; + if (click_pin.before(prompt_pin)) break :handoff; + const moves = screen.promptClickMove(click_pin); + for (0..moves.left) |_| p.emitWrite(id, "\x1b[D"); + for (0..moves.right) |_| p.emitWrite(id, "\x1b[C"); + }; + + pane.body.mode = .tty; + followOutput(pane); // entering raw mode is a request for the live shell + pane.body.msel.active = false; + pane.body.vsel.active = false; + pane.body.nsel = 0; + // The raw renderer follows the live cursor. A captured mouse selection + // keeps its modal endpoint for the next return to editor mode. + pane.body.cur_pinned = selected; + pane.body.select = false; + pane.body.restore_cursor = false; + pane.body.sticky_col = -1; + pane.body.normal.clear(); +} + +// Returned slices remain valid until the next cache rebuild or reset. +pub const RowsCache = struct { + /// whose grid this describes; null = the slot is free + pane: ?*const Pane = null, + /// the rows joined by '\n' — `flatSurface` hands this back verbatim + /// instead of rebuilding the join on every keystroke + text: []const u8 = &.{}, + /// slices INTO `text`, absolute grid rows from 0 + rows: [][]const u8 = &.{}, + /// `text` is a prefix of this: blanking a prompt row shortens the join, + /// and the slack is not worth a second allocation to reclaim + text_alloc: []u8 = &.{}, + stale: bool = false, + + pub fn reset(c: *RowsCache, gpa: std.mem.Allocator) void { + if (c.text_alloc.len > 0) gpa.free(c.text_alloc); + if (c.rows.len > 0) gpa.free(c.rows); + c.* = .{}; + } + + /// Free a stale entry. Called at the top of `update`, and nowhere else. + pub fn sweep(c: *RowsCache, gpa: std.mem.Allocator) void { + if (c.stale) c.reset(gpa); + } + + /// `pane`'s grid moved: the entry no longer describes it. + pub fn markStale(c: *RowsCache, pane: *const Pane) void { + if (c.pane == pane) c.stale = true; + } + + pub fn dropPane(c: *RowsCache, pane: *const Pane) void { + if (c.pane != pane) return; + c.pane = null; + c.stale = true; + } +}; + +const Rows = struct { + text_alloc: []u8, + text: []const u8, + rows: [][]const u8, +}; + +const empty_grid = [1][]const u8{""}; + +/// What LEAVING raw tty mode does to one prompt row, decided from its cells +/// alone. See config.tty_blank for why any of this happens. +const PromptCut = union(enum) { + /// show the row exactly as ghostty dumped it + keep, + /// show nothing at all + blank, + /// drop this many leading COLUMNS — the prompt — and keep the rest, which + /// is what was typed at it + cut: usize, +}; + +fn promptCut(pin: ghostty_vt.Pin) PromptCut { + if (config.tty_blank == .prompt_and_input) return .blank; + const cells = pin.cells(.all); + var cols: usize = 0; + while (cols < cells.len and cells[cols].semantic_content == .prompt) cols += 1; + // Flagged, but with no prompt cells at the FRONT: a right-side prompt, or + // a repaint that has moved on. Nothing here is the prompt, so hide nothing. + if (cols == 0) return .keep; + // ...and all prompt, nothing typed yet: the row is chrome end to end. + if (cols >= cells.len) return .blank; + return .{ .cut = cols }; +} + +pub fn promptPrefixBytes(pane: *Pane, row: i32, raw: []const u8) usize { + if (comptime !enabled) return 0; + if (row < 0) return 0; + const state = pane.terminal orelse return 0; + const pin = state.vt.screens.active.pages.pin(.{ .screen = .{ .x = 0, .y = @intCast(row) } }) orelse return 0; + if (pin.rowAndCell().row.semantic_prompt == .none) return 0; + return switch (promptCut(pin)) { + .keep => 0, + .blank => raw.len, + .cut => |cols| @min(raw.len, File.rawAtDisplay(raw, cols)), + }; +} + +fn promptRow(pin: ghostty_vt.Pin, raw: []const u8) []const u8 { + const cols = switch (promptCut(pin)) { + .keep => return raw, + .blank => return "", + .cut => |n| n, + }; + const cells = pin.cells(.all); + var at: usize = 0; + var col: usize = 0; + while (col < cols and at < raw.len) { + const cell = &cells[col]; + at = @min(raw.len, at + dumpedBytes(pin, cell)); + // the tail cell of a wide glyph spells nothing of its own + col += if (cell.wide == .wide) @as(usize, 2) else 1; + } + return std.mem.trimEnd(u8, raw[at..], " \t"); +} + +fn dumpedBytes(pin: ghostty_vt.Pin, cell: *const ghostty_vt.Cell) usize { + switch (cell.wide) { + .spacer_head, .spacer_tail => return 0, + .narrow, .wide => {}, + } + var n: usize = switch (cell.content_tag) { + .codepoint, .codepoint_grapheme => std.unicode.utf8CodepointSequenceLength( + cell.codepoint(), + ) catch 1, + // A cell carrying only a colour still spells one blank in the dump. + else => 1, + }; + if (cell.content_tag == .codepoint_grapheme) { + if (pin.grapheme(cell)) |extra| for (extra) |cp| { + n += std.unicode.utf8CodepointSequenceLength(cp) catch 1; + }; + } + return n; +} + +pub fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 { + if (comptime !enabled) return &empty_grid; + if (pane.terminal == null) return &empty_grid; + const c = &p.shell_rows; + if (!c.stale and c.pane == pane) return c.rows; + if (c.pane != null) { + c.stale = true; + return (try buildRows(p.scratch.allocator(), p, pane)).rows; + } + const built = try buildRows(p.gpa, p, pane); + c.* = .{ + .pane = pane, + .text = built.text, + .rows = built.rows, + .text_alloc = built.text_alloc, + }; + return c.rows; +} + +/// The full modal motion surface: shell history with the live edit overlay +/// spliced into the rows it covers. +pub fn cursorLines(p: *Pardes, pane: *Pane) ![]const []const u8 { + const rows = try shellRows(p, pane); + if (pane.ovl == null) return rows; + var last = rows.len; + if (pane.ovl) |overlay| + last = @max(last, @as(usize, @intCast(@max(0, overlay.row + overlay.rows)))); + var count = last; + if (pane.ovl) |overlay| { + if (overlay.row >= 0 and @as(usize, @intCast(overlay.row)) < last) + count = count - @min( + @as(usize, @intCast(overlay.rows)), + last - @as(usize, @intCast(overlay.row)), + ) + @max(1, modal.lineCount(overlay.text)); + } + const lines = try p.scratch.allocator().alloc([]const u8, count); + var n: usize = 0; + var grid_row: usize = 0; + while (grid_row < last) : (grid_row += 1) { + if (pane.ovl) |overlay| if (overlay.row >= 0 and grid_row == @as(usize, @intCast(overlay.row))) { + var overlay_lines = std.mem.splitScalar(u8, overlay.text, '\n'); + while (overlay_lines.next()) |line| : (n += 1) lines[n] = line; + grid_row += @intCast(overlay.rows - 1); + continue; + }; + lines[n] = if (grid_row < rows.len) rows[grid_row] else ""; + n += 1; + } + return lines[0..n]; +} + +/// Flatten `cursorLines` without rebuilding the common cached/no-overlay +/// case. Scratch-owned when a join is required. +pub fn flatSurface(p: *Pardes, pane: *Pane, lines: []const []const u8) ![]const u8 { + const cache = &p.shell_rows; + if (!cache.stale and cache.pane == pane and + lines.ptr == cache.rows.ptr and lines.len == cache.rows.len) return cache.text; + var total: usize = if (lines.len > 0) lines.len - 1 else 0; + for (lines) |line| total += line.len; + const text = try p.scratch.allocator().alloc(u8, total); + var at: usize = 0; + for (lines, 0..) |line, i| { + if (i > 0) { + text[at] = '\n'; + at += 1; + } + @memcpy(text[at..][0..line.len], line); + at += line.len; + } + return text; +} + +/// Materialize or extend the terminal edit overlay with one exact allocation. +/// Row slices are scratch-owned/borrowed; only the joined text is installed. +pub fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?EditText { + const want_lo = @max(0, @min(lo, hi)); + const want_hi = @max(want_lo, @max(lo, hi)); + const fresh = pane.ovl == null; + const old: EditBuffer = pane.ovl orelse .{ .row = want_lo, .rows = 1, .text = &.{} }; + const lines: i32 = if (fresh) 1 else @intCast(modal.lineCount(old.text)); + const up = old.row - want_lo; + const down = want_hi - (old.row + lines - 1); + const extending = fresh or up > 0 or down > 0; + var row0 = old.row; + var covered = old.rows; + var text = old.text; + var owned = false; + if (extending) { + const rows = shellRows(p, pane) catch return null; + row0 = old.row - @max(0, up); + covered = old.rows + @max(0, up) + @max(0, down); + const up_len: usize = @intCast(@max(0, up)); + const down_len: usize = @intCast(@max(0, down)); + const parts = p.scratch.allocator().alloc([]const u8, up_len + 1 + down_len) catch return null; + for (parts[0..up_len], 0..) |*part, i| { + const src = @as(usize, @intCast(row0)) + i; + part.* = if (src < rows.len) rows[src] else ""; + } + const middle: usize = @intCast(old.row); + parts[up_len] = if (fresh) + (if (middle < rows.len) rows[middle] else "") + else + old.text; + for (parts[up_len + 1 ..], 0..) |*part, i| { + const src = @as(usize, @intCast(old.row + old.rows)) + i; + part.* = if (src < rows.len) rows[src] else ""; + } + text = std.mem.join(p.gpa, "\n", parts) catch return null; + owned = true; + } + + const row: usize = @intCast(@max(0, want_lo - row0)); + const line_len: i32 = @intCast(modal.lineSlice(text, row).len); + if (col > line_len) { + const spaces = p.scratch.allocator().alloc(u8, @intCast(col - line_len)) catch { + if (owned) p.gpa.free(text); + return null; + }; + @memset(spaces, ' '); + const padded = modal.insertAt(p.gpa, text, .{ .row = row, .col = @intCast(line_len) }, spaces) catch { + if (owned) p.gpa.free(text); + return null; + }; + if (owned) p.gpa.free(text); + text = padded; + owned = true; + } + if (owned) { + if (pane.ovl) |overlay| p.gpa.free(overlay.text); + pane.ovl = .{ .row = row0, .rows = covered, .text = text }; + } + return .{ .text = pane.ovl.?.text, .row0 = pane.ovl.?.row }; +} + +/// Consume a rewritten overlay, freeing the terminal edit text it replaces. +pub fn setEditText(p: *Pardes, pane: *Pane, new: []u8) void { + const overlay = if (pane.ovl) |*value| value else return pardes.edit.retireEditText(p, new); + for (0..pane.sel.len) |button| { + pane.clearPointerSelection(button); + pane.sel[button].state = .none; + } + p.gpa.free(overlay.text); + overlay.text = new; +} + +/// The shell's screen and scrollback (the primary screen, even under a +/// full-screen program) as VT that replays to what was shown: styled +/// text, soft-wrapped lines rejoined so they reflow at another width. +fn screenVt(pane: *const Pane, arena: std.mem.Allocator) ![]const u8 { + const screen = pane.terminal.?.vt.screens.get(.primary) orelse return &.{}; + var out: std.Io.Writer.Allocating = .init(arena); + try ghostty_vt.formatter.ScreenFormatter.init(screen, .{ .emit = .vt, .unwrap = true }).format(&out.writer); + return out.written(); +} + +/// Serialize terminal-only state; the core supplies shared pane metadata. +pub fn dumpPane( + pane: *Pane, + arena: std.mem.Allocator, + tag: []const u8, + body: []const u8, + scroll: usize, +) !dump.Pane { + if (!enabled or pane.terminal == null) { + const text = if (pane.ovl) |overlay| overlay.text else ""; + return .{ + .kind = .terminal, + .tag = tag, + .body = body, + .scroll = scroll, + .cols = pane.cols, + .rows = pane.rows, + .vweight = pane.vweight, + .collapsed = pane.collapsed, + .terminal = .{ + .cwd = try arena.dupe(u8, pane.cwdSlice()), + .shell = try arena.dupe(u8, pane.shell orelse ""), + .command = try arena.dupe(u8, pane.command orelse ""), + .status = pane.command_status, + .stream = try arena.dupe(u8, text), + .stream_b64 = &.{}, + .cursor = .{ .col = 0, .row = 0 }, + }, + }; + } + const full = try pane.terminal.?.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} }); + const extra = if (pane.ovl) |overlay| overlay.text.len else 0; + const stream = try arena.alloc(u8, try std.math.add(usize, full.len, extra)); + var len: usize = 0; + var lines = std.mem.splitAny(u8, full, "\n"); + var prompts = pane.terminal.?.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); + var row: i32 = 0; + var skip: i32 = 0; + while (lines.next()) |raw| : (row += 1) { + // Hidden overlay rows still consume prompt pins to keep them aligned. + const prompt = if (pane.body.mode != .tty) prompts.next() else null; + if (skip > 0) { + skip -= 1; + continue; + } + if (row > 0) { + stream[len] = '\n'; + len += 1; + } + if (pane.body.mode != .tty) if (pane.ovl) |overlay| if (row == overlay.row) { + @memcpy(stream[len..][0..overlay.text.len], overlay.text); + len += overlay.text.len; + skip = overlay.rows - 1; + continue; + }; + const shown = if (prompt) |pin| + if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, raw) else raw + else + raw; + @memcpy(stream[len..][0..shown.len], shown); + len += shown.len; + } + return .{ + .kind = .terminal, + .tag = tag, + .body = body, + .scroll = scroll, + .cols = pane.cols, + .rows = pane.rows, + .vweight = pane.vweight, + .collapsed = pane.collapsed, + .terminal = .{ + .cwd = try arena.dupe(u8, pane.cwdSlice()), + .shell = try arena.dupe(u8, pane.shell orelse ""), + .command = try arena.dupe(u8, pane.command orelse ""), + .status = pane.command_status, + .stream = stream[0..len], + .stream_b64 = try dump.encodeBytes(arena, try screenVt(pane, arena)), + .stream_is_screen = true, + .cursor = .{ + .col = pane.terminal.?.vt.screens.active.cursor.x, + .row = pane.terminal.?.vt.screens.active.cursor.y, + }, + }, + }; +} + +fn buildRows(alloc: std.mem.Allocator, p: *Pardes, pane: *Pane) !Rows { + const full = try pane.terminal.?.vt.screens.active.dumpStringAlloc(p.scratch.allocator(), .{ .screen = .{} }); + var pit = pane.terminal.?.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); + // split yields one more item than delimiters; the extra final slot is the + // cursor row retained below. + const n_rows = std.mem.count(u8, full, "\n") + 2; + const rows = try alloc.alloc([]const u8, n_rows); + errdefer alloc.free(rows); + // Blanking a prompt row only ever SHORTENS it and the retained cursor row + // adds one separator, so the dump's length plus one bounds the join. + const text = try alloc.alloc(u8, full.len + 1); + errdefer alloc.free(text); + var at: usize = 0; + var n: usize = 0; + var it = std.mem.splitScalar(u8, full, '\n'); + while (it.next()) |raw| { + const shown = if (pit.next()) |pin| + if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, raw) else raw + else + raw; + if (n > 0) { + text[at] = '\n'; + at += 1; + } + @memcpy(text[at..][0..shown.len], shown); + rows[n] = text[at..][0..shown.len]; + at += shown.len; + n += 1; + } + text[at] = '\n'; + at += 1; + rows[n] = text[at..][0..0]; + n += 1; + std.debug.assert(n == n_rows); + return .{ .text_alloc = text, .text = text[0..at], .rows = rows }; +} + +pub fn bodyText(arena: std.mem.Allocator, pane: *Pane) ![]const u8 { + const vp: []const []const u8 = if (comptime !enabled) &.{} else vp: { + const state = pane.terminal orelse break :vp &.{}; + const screen = state.vt.screens.active; + var tl = screen.pages.getTopLeft(.viewport); + tl.x = 0; + const br = screen.pages.getBottomRight(.viewport) orelse return error.UnknownPoint; + var rows_out: std.Io.Writer.Allocating = .init(arena); + try screen.dumpString(&rows_out.writer, .{ .tl = tl, .br = br, .unwrap = false }); + const raw = try rows_out.toOwnedSlice(); + var prompts = screen.pages.rowIterator(.right_down, .{ .viewport = .{} }, null); + const vp = try arena.alloc([]const u8, std.mem.count(u8, raw, "\n") + 1); + var lines = std.mem.splitScalar(u8, raw, '\n'); + var n: usize = 0; + while (lines.next()) |ln| { + vp[n] = if (pane.body.mode != .tty) + if (prompts.next()) |pin| + if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, ln) else ln + else + ln + else + ln; + n += 1; + } + std.debug.assert(n == vp.len); + break :vp vp; + }; + + const len = fillBody(null, pane, vp); + const out = try arena.alloc(u8, len); + const filled = fillBody(out, pane, vp); + std.debug.assert(filled == out.len); + return out; +} + +pub const BodyRow = union(enum) { + grid: i32, + edit: struct { line: []const u8, idx: usize }, +}; + +const BodyWalk = struct { + pane: *Pane, + goff: i32, + g: i32, + /// the buffer can start above the viewport: drop the lines scrolled past + skip: usize, + n: usize = 0, + lines: ?std.mem.SplitIterator(u8, .scalar) = null, + covered: i32 = 0, + line_idx: usize = 0, + + fn init(pane: *Pane) BodyWalk { + const off = pane.scroll(); + const goff = gridOffset(pane); + return .{ + .pane = pane, + .goff = goff, + .g = if (pane.body.mode == .tty) goff else pane.gridRow(off), + .skip = if (pane.ovl) |o| @intCast(@max(0, off - pane.surfRow(o.row))) else 0, + }; + } + + fn next(w: *BodyWalk) ?BodyRow { + while (w.n < w.pane.rows) { + if (w.lines) |*it| { + if (it.next()) |line| { + const idx = w.line_idx; + w.line_idx += 1; + // Lines scrolled off the top still count: the index names a + // line of the BUFFER, not of the visible body. + if (w.skip > 0) { + w.skip -= 1; + continue; + } + w.n += 1; + return .{ .edit = .{ .line = line, .idx = idx } }; + } + // The buffer stands in for `rows` shell rows however many lines + // it actually spelled, which is the whole slide. + w.g += w.covered; + w.skip = 0; + w.lines = null; + continue; + } + if (w.pane.body.mode != .tty) if (w.pane.ovl) |o| if (w.g == o.row) { + w.lines = std.mem.splitScalar(u8, o.text, '\n'); + w.covered = o.rows; + w.line_idx = 0; + continue; + }; + const vi = w.g - w.goff; + w.g += 1; + w.n += 1; + return .{ .grid = vi }; + } + return null; + } +}; + +/// Run the terminal body row walk. A null destination counts bytes; a slice +/// fills the exact allocation made from that count. +fn fillBody(dst: ?[]u8, pane: *Pane, viewport: []const []const u8) usize { + var walk: BodyWalk = .init(pane); + var written: usize = 0; + var first = true; + while (walk.next()) |row| { + if (!first) { + if (dst) |out| out[written] = '\n'; + written += 1; + } + first = false; + const bytes = switch (row) { + .edit => |e| e.line, + .grid => |vi| if (vi >= 0 and @as(usize, @intCast(vi)) < viewport.len) + viewport[@intCast(vi)] + else + "", + }; + if (dst) |out| @memcpy(out[written..][0..bytes.len], bytes); + written += bytes.len; + } + return written; +} + +// Match surviving edit-buffer rows to their original terminal styles. +const EditAnchors = struct { + /// the buffer's own text, walked in order: a line the VIEWPORT skipped still + /// consumes the row it came from, so the lines below it stay aligned + text: []const u8 = &.{}, + at: usize = 0, + shell: []const []const u8 = &.{}, + /// the covered span, absolute grid rows, as `[first, end)` + first: usize = 0, + end: usize = 0, + lines: usize = 0, + /// the line `at` names, and the first row still unclaimed + idx: usize = 0, + cursor: usize = 0, + budget: usize = 0, + active: bool = false, + + fn init(p: *Pardes, pane: *Pane, o: EditBuffer) EditAnchors { + if (o.rows <= 0 or o.row < 0) return .{}; + const first: usize = @intCast(o.row); + const screen = pane.terminal.?.vt.screens.active; + const total = screen.pages.scrollbar().total; + if (first >= total) return .{}; + const covered: usize = @intCast(o.rows); + const count = @min(covered, total - first); + const tl = screen.pages.pin(.{ .screen = .{ .y = @intCast(first) } }) orelse return .{}; + const br = screen.pages.pin(.{ .screen = .{ + .x = screen.pages.cols - 1, + .y = @intCast(first + count - 1), + } }) orelse return .{}; + const arena = p.scratch.allocator(); + var output: std.Io.Writer.Allocating = .init(arena); + screen.dumpString(&output.writer, .{ .tl = tl, .br = br, .unwrap = false }) catch return .{}; + const shell = arena.alloc([]const u8, count) catch return .{}; + var raw = std.mem.splitScalar(u8, output.written(), '\n'); + var pins = tl.rowIterator(.right_down, br); + for (shell) |*row| { + const text = raw.next() orelse ""; + row.* = if (pins.next()) |pin| + if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, text) else text + else + text; + } + const lines = std.mem.count(u8, o.text, "\n") + 1; + return .{ + .text = o.text, + .shell = shell, + .first = first, + .end = first + count, + .lines = lines, + .cursor = first, + .budget = covered + 4 * lines, + .active = true, + }; + } + + fn shellRow(a: *EditAnchors, idx: usize) ?Anchor { + if (!a.active or idx >= a.lines) return null; + var found: ?Anchor = null; + while (a.idx <= idx) : (a.idx += 1) found = a.claim(a.nextLine() orelse return null); + return found; + } + + fn nextLine(a: *EditAnchors) ?[]const u8 { + if (a.at > a.text.len) return null; + const rest = a.text[a.at..]; + if (std.mem.indexOfScalar(u8, rest, '\n')) |n| { + a.at += n + 1; + return rest[0..n]; + } + // The last line has no terminator; one past the end ends the walk. + a.at = a.text.len + 1; + return rest; + } + + /// Where this line still stands over the grid, if anywhere. + fn claim(a: *EditAnchors, line: []const u8) ?Anchor { + const end = if (line.len == 0) @min(a.cursor + 1, a.end) else a.end; + var k = a.cursor; + while (k < end) : (k += 1) { + if (a.budget == 0) return null; + a.budget -= 1; + if (!std.mem.eql(u8, line, a.shell[k - a.first])) continue; + a.cursor = k + 1; + return .{ .row = @intCast(k) }; + } + if (a.cursor >= a.end) return null; + const shell = a.shell[a.cursor - a.first]; + var p: usize = 0; + while (p < line.len and p < shell.len and line[p] == shell[p]) p += 1; + var s: usize = 0; + const room = @min(line.len, shell.len) - p; + while (s < room and line[line.len - 1 - s] == shell[shell.len - 1 - s]) s += 1; + if (p + s == 0) return null; + if (line.len != p + s and shell.len - (p + s) > shell.len / 2) return null; + const row = a.cursor; + if (s > 0 or p >= shell.len) a.cursor += 1; + return .{ .row = @intCast(row), .prefix = p, .suffix = s, .shell_len = shell.len }; + } +}; + +const Anchor = struct { + /// the row, absolute while it comes from `EditAnchors`, viewport once + /// `recolorAnsi` has subtracted the walk's offset + row: i32, + prefix: usize = std.math.maxInt(usize), + suffix: usize = 0, + /// the row's own dumped length — what the suffix is measured from on the + /// GRID side, where the edit may have changed the byte count + shell_len: usize = 0, + + fn whole(an: Anchor) bool { + return an.prefix == std.math.maxInt(usize); + } +}; + +pub fn recolorAnsi(p: *Pardes, s: *Surface, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, body: []const u8) void { + // No emulator, no ANSI cells: the whole pass — and the 256-colour theme + // projection behind it — is compiled out. + if (comptime !enabled) return; + if (pane.terminal == null) return; + const body_y = p.bodyTop(pane, r); + var filtered_storage: FilteredColors = undefined; + const filtered: ?*FilteredColors = if (pane.tty_filter) blk: { + const tz_filter = tracy.zone(@src(), "filterInit"); + defer tz_filter.end(); + filtered_storage = FilteredColors.init(p, pane); + break :blk &filtered_storage; + } else null; + // DECSCNM, read once: the filtered palette folds it in itself, the raw + // path needs it per cell. + const scnm = pane.terminal.?.vt.modes.get(.reverse_colors); + const pages = &pane.terminal.?.vt.screens.active.pages; + const vp_rows: i32 = @intCast(scrollbar(pane).len); + // Which buffer lines the user has not actually changed, so a row swallowed + // by a growing buffer keeps the colour it still stands over. + var anchors: ?EditAnchors = null; + var walk: BodyWalk = .init(pane); + var lines = std.mem.splitScalar(u8, body, '\n'); + var vr: u16 = 0; + while (walk.next()) |row| : (vr += 1) { + if (vr >= body_h) break; + // Before any early exit below, or the lines fall out of step with rows. + const text = lines.next() orelse ""; + const anchor: Anchor = switch (row) { + .edit => |e| blk: { + if (anchors == null) anchors = .init(p, pane, pane.ovl.?); + var an = anchors.?.shellRow(e.idx) orelse continue; + an.row -= walk.goff; + break :blk an; + }, + .grid => |v| .{ .row = v }, + }; + const vi = anchor.row; + if (vi < 0 or vi >= vp_rows) continue; + const row_pin = pages.pin(.{ .viewport = .{ .y = @intCast(vi) } }) orelse continue; + const cut: u16 = if (pane.body.mode == .tty) 0 else cut: { + if (row_pin.rowAndCell().row.semantic_prompt == .none) break :cut 0; + break :cut switch (promptCut(row_pin)) { + .keep => 0, + // Blanked end to end: the row shows nothing of the grid, so + // projecting the prompt's own colours onto it would be a lie. + .blank => continue, + .cut => |n| std.math.cast(u16, n) orelse continue, + }; + }; + if (cut > 0 and text.len == 0) continue; + var at: usize = 0; + var sc: u16 = 0; + var gc: u16 = cut; + var sb: usize = 0; + const mine_from = @min(anchor.prefix, text.len); + const mine_to = text.len - @min(anchor.suffix, text.len); + var crossed = false; + while (at < text.len and sc < tw) { + const stop = modal.nextGrapheme(text, at); + if (stop <= at) break; + const span: u16 = if (sc + 1 < tw and s.at(tx + sc + 1, body_y + vr).len == 0) 2 else 1; + if (at >= mine_from and at < mine_to) { + sc += span; + at = stop; + continue; + } + if (at >= mine_to and !crossed) { + crossed = true; + const upto = anchor.shell_len - @min(anchor.suffix, anchor.shell_len); + while (sb < upto) { + const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break; + sb += dumpedBytes(row_pin, ci.cell); + gc = std.math.add(u16, gc, 1) catch break; + } + } + const want = stop - at; + // Consume every cell that contributed to this grapheme. A cluster + // ghostty split across several cells is still ONE printed glyph. + var covered: usize = 0; + var style: ?pardes.CellStyle = null; + while (covered < want) { + const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break; + if (style == null and ci.cell.wide != .spacer_tail and ci.cell.wide != .spacer_head) + style = cellStyle(p, ci, filtered, scnm); + covered += dumpedBytes(row_pin, ci.cell); + gc = std.math.add(u16, gc, 1) catch break; + // A spacer contributes no bytes; without this the loop would + // spin on a row that ends in one. + if (covered == 0 and gc >= pane.cols) break; + } + while (pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } })) |t| { + if (t.cell.wide != .spacer_tail) break; + gc = std.math.add(u16, gc, 1) catch break; + } + if (style) |st| for (0..span) |k| { + const cell = s.at(tx + sc + @as(u16, @intCast(k)), body_y + vr); + if (cell.default and filtered == null) continue; + cell.default = false; + cell.style = st; + }; + sb += covered; + sc += span; + at = stop; + } + var tail: ?pardes.CellStyle = null; + if (anchor.whole() or anchor.suffix > 0) { + while (sc < tw) { + const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break; + gc = std.math.add(u16, gc, 1) catch break; + if (ci.cell.wide == .spacer_tail) continue; + const cell = s.at(tx + sc, body_y + vr); + sc += 1; + const style = cellStyle(p, ci, filtered, scnm); + tail = style; + if (cell.default and filtered == null) continue; + cell.default = false; + cell.style = style; + } + if (tail) |style| while (sc < tw) : (sc += 1) { + const cell = s.at(tx + sc, body_y + vr); + if (cell.default and filtered == null) continue; + cell.default = false; + cell.style = style; + }; + } + } +} + +fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell, filtered: ?*FilteredColors, scnm: bool) pardes.CellStyle { + const style = ci.style(); + var cs: pardes.CellStyle = .{ + .fg = if (filtered) |colors| colors.fg(style) else ghostColor(p, style.fg_color, scnm), + .bg = if (filtered) |colors| colors.bg(style, ci.cell) else ghostColor(p, style.bg_color, !scnm), + .bold = style.flags.bold, + .dim = style.flags.faint, + .italic = style.flags.italic, + .blink = style.flags.blink, + .reverse = style.flags.inverse, + .invisible = style.flags.invisible, + .strikethrough = style.flags.strikethrough, + .ul = switch (style.flags.underline) { + .none => .off, + .single => .single, + .double => .double, + .curly => .curly, + .dotted => .dotted, + .dashed => .dashed, + }, + }; + if (filtered == null) switch (ci.cell.content_tag) { + .bg_color_palette => cs.bg = palColor(p, ci.cell.content.color_palette.data), + .bg_color_rgb => { + const rgb = ci.cell.content.color_rgb; + cs.bg = .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }; + }, + else => {}, + }; + return cs; +} + +const FilteredColors = struct { + source: GColor.Palette, + target: *const GColor.Palette, + theme_bg: GColor.RGB, + theme_fg: GColor.RGB, + dynamic_bg: ?GColor.RGB, + dynamic_fg: ?GColor.RGB, + default_bg: GColor.RGB, + /// What a foreground too near `default_bg` becomes instead. + fallback_fg: GColor.RGB, + default_bg_luminance: f64, + fg_for_palette: [256]pardes.Color = undefined, + bg_for_palette: [256]pardes.Color = undefined, + /// The two answers for a cell that names no colour of its own. + fg_default: pardes.Color = undefined, + bg_default: pardes.Color = undefined, + cache_rgb: [256]GColor.RGB = undefined, + cache_key: [256]u8 = undefined, + cache_valid: [256]bool = @splat(false), + + fn init(p: *Pardes, pane: *const Pane) FilteredColors { + var source = GColor.default; + for (&source, 0..) |*rgb, i| + rgb.* = pane.terminal.?.vt.colorForXterm(.{ .palette = @intCast(i) }) orelse rgb.*; + const theme = p.theme(); + var theme_bg = asGhostRgb(theme.bg orelse theme.tag_bg); + var theme_fg = asGhostRgb(theme.fg orelse theme.tag_fg); + var dynamic_bg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .background }); + var dynamic_fg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .foreground }); + if (pane.terminal.?.vt.modes.get(.reverse_colors)) { + std.mem.swap(GColor.RGB, &theme_bg, &theme_fg); + std.mem.swap(?GColor.RGB, &dynamic_bg, &dynamic_fg); + } + var self: FilteredColors = .{ + .source = source, + .target = p.tty_filter_palette.get(theme), + .theme_bg = theme_bg, + .theme_fg = theme_fg, + .dynamic_bg = dynamic_bg, + .dynamic_fg = dynamic_fg, + .default_bg = theme_bg, + .fallback_fg = theme_fg, + .default_bg_luminance = luminanceOf(theme_bg), + }; + if (dynamic_bg) |rgb| self.default_bg = self.keyedRgb(rgb); + self.default_bg_luminance = luminanceOf(self.default_bg); + if (self.theme_bg.contrast(self.default_bg) > self.theme_fg.contrast(self.default_bg)) + self.fallback_fg = self.theme_bg; + + self.fg_default = asPardesColor(self.legible( + if (self.dynamic_fg) |rgb| self.keyedRgb(rgb) else self.theme_fg, + )); + self.bg_default = asPardesColor(self.default_bg); + for (&self.source, 0..) |current, i| { + const idx: u8 = @intCast(i); + const mapped = self.paletteRgb(idx, current); + self.fg_for_palette[idx] = asPardesColor(self.legible(mapped)); + self.bg_for_palette[idx] = asPardesColor(mapped); + } + return self; + } + + fn fg(self: *FilteredColors, style: ghostty_vt.Style) pardes.Color { + return switch (style.fg_color) { + .none => self.fg_default, + .palette => |idx| self.fg_for_palette[idx], + .rgb => asPardesColor(self.legible(self.keyedRgb(style.fg(.{ + .default = self.dynamic_fg orelse self.theme_fg, + .palette = &self.source, + .bold = null, + })))), + }; + } + + fn bg(self: *FilteredColors, style: ghostty_vt.Style, cell: *const ghostty_vt.Cell) pardes.Color { + switch (cell.content_tag) { + .bg_color_palette => return self.bg_for_palette[cell.content.color_palette.data], + .bg_color_rgb => {}, + else => switch (style.bg_color) { + .none => return self.bg_default, + .palette => |idx| return self.bg_for_palette[idx], + .rgb => {}, + }, + } + // Truecolour, from either the cell or its style. + return asPardesColor(self.keyedRgb(style.bg(cell, &self.source).?)); + } + + fn legible(self: *const FilteredColors, rgb: GColor.RGB) GColor.RGB { + if (contrastOf(luminanceOf(rgb), self.default_bg_luminance) >= + config.tty_filter_min_contrast) return rgb; + return self.fallback_fg; + } + + fn paletteRgb(self: *FilteredColors, idx: u8, current: GColor.RGB) GColor.RGB { + if (current.eql(GColor.default[idx])) return self.target[idx]; + return self.keyedRgb(current); + } + + fn keyedRgb(self: *FilteredColors, rgb: GColor.RGB) GColor.RGB { + return self.target[self.nearestKey(rgb)]; + } + + fn nearestKey(self: *FilteredColors, rgb: GColor.RGB) u8 { + const rgb24 = (@as(u32, rgb.r) << 16) | (@as(u32, rgb.g) << 8) | rgb.b; + const slot: u8 = @truncate((rgb24 *% 0x9e3779b1) >> 24); + if (self.cache_valid[slot] and self.cache_rgb[slot].eql(rgb)) + return self.cache_key[slot]; + + var best: u8 = 0; + var best_distance: u32 = std.math.maxInt(u32); + for (GColor.default, 0..) |candidate, i| { + const distance = colorDistance(rgb, candidate); + // Strict comparison makes duplicate-colour ties stable at the + // lowest canonical xterm key. + if (distance < best_distance) { + best_distance = distance; + best = @intCast(i); + } + } + self.cache_rgb[slot] = rgb; + self.cache_key[slot] = best; + self.cache_valid[slot] = true; + return best; + } +}; + +const channel_luminance: [256]f64 = blk: { + @setEvalBranchQuota(20000); + var table: [256]f64 = undefined; + for (&table, 0..) |*slot, c| { + const normalized: f64 = @as(f64, @floatFromInt(c)) / 255; + slot.* = if (normalized <= 0.03928) + normalized / 12.92 + else + std.math.pow(f64, (normalized + 0.055) / 1.055, 2.4); + } + break :blk table; +}; + +fn luminanceOf(rgb: GColor.RGB) f64 { + return 0.2126 * channel_luminance[rgb.r] + + 0.7152 * channel_luminance[rgb.g] + + 0.0722 * channel_luminance[rgb.b]; +} + +/// ghostty's `RGB.contrast` with both luminances already in hand. +fn contrastOf(a_luminance: f64, b_luminance: f64) f64 { + const lighter = @max(a_luminance, b_luminance); + const darker = @min(a_luminance, b_luminance); + return (lighter + 0.05) / (darker + 0.05); +} + +fn colorDistance(a: GColor.RGB, b: GColor.RGB) u32 { + const dr = @as(i32, a.r) - @as(i32, b.r); + const dg = @as(i32, a.g) - @as(i32, b.g); + const db = @as(i32, a.b) - @as(i32, b.b); + return @intCast(dr * dr + dg * dg + db * db); +} + +test "the luminance table answers exactly what ghostty computes" { + for (0..256) |i| { + const c: u8 = @intCast(i); + const grey: GColor.RGB = .{ .r = c, .g = c, .b = c }; + try std.testing.expectEqual(grey.luminance(), luminanceOf(grey)); + } + // Channel weights are asymmetric, so a grey ramp alone would not catch a + // transposed coefficient. The palette is what the tables enumerate. + for (GColor.default) |candidate| { + try std.testing.expectEqual(candidate.luminance(), luminanceOf(candidate)); + for (GColor.default) |page| { + try std.testing.expectEqual( + candidate.contrast(page), + contrastOf(luminanceOf(candidate), luminanceOf(page)), + ); + } + } +} + +test "terminal Filter keys indexed truecolor OSC and background-only cells through the theme" { + const testing = std.testing; + // Raw index assertions require a theme that inherits the ANSI palette. + const p = try Pardes.init(testing.allocator, .{ .startup_config = "Theme helix\n", .tty_only = true, .cols = 18, .rows = 6 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tag_expanded = false; // a one-row tag keeps this small screen's rows + p.sync(); + try testing.expect(pane.tty_filter); + pane.tty_filter = false; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mA" ++ + "\x1b[38;5;196mB" ++ + "\x1b[38;2;255;0;0mC" ++ + "\x1b[0;48;5;25mD" ++ + "\x1b[0;48;2;0;95;175mE" ++ + "\x1b[0;1;2;3;4;5;7;8;9mF" ++ + "\x1b[0;48;5;25m\x1b[K" ++ + "\r\n\x1b[0;38;5;2m界" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = p.bodyTop(pane, r); + + const raw = try p.render(frame.allocator()); + try testing.expectEqual(pardes.Color{ .index = 1 }, raw.at(tx, body_y).style.fg); + try testing.expectEqual(pardes.Color{ .index = 196 }, raw.at(tx + 1, body_y).style.fg); + try testing.expectEqual(pardes.Color{ .rgb = .{ 255, 0, 0 } }, raw.at(tx + 2, body_y).style.fg); + + pane.tty_filter = true; + _ = frame.reset(.retain_capacity); + const filtered = try p.render(frame.allocator()); + var expected_cache: FilterPalette = .{}; + const expected = expected_cache.get(p.theme()); + try testing.expectEqual(asPardesColor(expected[1]), filtered.at(tx, body_y).style.fg); + try testing.expectEqual(asPardesColor(expected[196]), filtered.at(tx + 1, body_y).style.fg); + try testing.expectEqual(filtered.at(tx + 1, body_y).style.fg, filtered.at(tx + 2, body_y).style.fg); + try testing.expectEqual(asPardesColor(expected[25]), filtered.at(tx + 3, body_y).style.bg); + try testing.expectEqual(filtered.at(tx + 3, body_y).style.bg, filtered.at(tx + 4, body_y).style.bg); + + const attrs = filtered.at(tx + 5, body_y).style; + try testing.expect(attrs.bold); + try testing.expect(attrs.dim); + try testing.expect(attrs.italic); + try testing.expect(attrs.blink); + try testing.expect(attrs.reverse); + try testing.expect(attrs.invisible); + try testing.expect(attrs.strikethrough); + try testing.expectEqual(.single, attrs.ul); + + const erased = pane.terminal.?.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = 6, .y = 0 } }).?; + try testing.expectEqual(.bg_color_palette, erased.cell.content_tag); + try testing.expectEqual(asPardesColor(expected[25]), filtered.at(tx + 6, body_y).style.bg); + try testing.expectEqual(asPardesColor(expected[2]), filtered.at(tx, body_y + 1).style.fg); + try testing.expectEqual(filtered.at(tx, body_y + 1).style, filtered.at(tx + 1, body_y + 1).style); + // A filtered terminal never delegates either colour to a backend palette, + // including cells which were empty/default before the pass. + for (0..r.w - config.GUTTER) |col| { + const cell = filtered.at(tx + @as(u16, @intCast(col)), body_y); + try testing.expect(!cell.default); + switch (cell.style.fg) { + .rgb => {}, + else => return error.FilteredForegroundWasNotRgb, + } + switch (cell.style.bg) { + .rgb => {}, + else => return error.FilteredBackgroundWasNotRgb, + } + } + + // Colors remains the global master gate. The pane remembers Filter while + // ANSI projection is dormant, and resumes it without replaying VT bytes. + p.settings.colors = false; + _ = frame.reset(.retain_capacity); + const plain = try p.render(frame.allocator()); + try testing.expect(pane.tty_filter); + try testing.expectEqual(asPardesColor(asGhostRgb(p.theme().fg.?)), plain.at(tx, body_y).style.fg); + p.settings.colors = true; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]4;1;#ff0000\x1b\\" } }); + const osc_red = pane.terminal.?.vt.colorForXterm(.{ .palette = 1 }).?; + try testing.expect(osc_red.eql(.{ .r = 255, .g = 0, .b = 0 })); + pane.tty_filter = false; + pane.tty_filter = true; + try testing.expect(osc_red.eql(pane.terminal.?.vt.colorForXterm(.{ .palette = 1 }).?)); + _ = frame.reset(.retain_capacity); + const osc_palette = try p.render(frame.allocator()); + try testing.expectEqual(asPardesColor(expected[196]), osc_palette.at(tx, body_y).style.fg); + + // Dynamic default foreground/background colours key every default cell, + // including the otherwise blank end of the row. + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]10;#ff0000\x1b\\" ++ + "\x1b]11;#5f5f5f\x1b\\" } }); + const dyn_fg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .foreground }).?; + const dyn_bg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .background }).?; + try testing.expect(dyn_fg.eql(.{ .r = 255, .g = 0, .b = 0 })); + try testing.expect(dyn_bg.eql(.{ .r = 95, .g = 95, .b = 95 })); + _ = frame.reset(.retain_capacity); + const dynamic = try p.render(frame.allocator()); + const blank = dynamic.at(tx + r.w - config.GUTTER - 1, body_y + 2).style; + try testing.expectEqual(asPardesColor(expected[196]), blank.fg); + try testing.expectEqual(asPardesColor(expected[59]), blank.bg); + + const explicit_before_reverse = dynamic.at(tx, body_y).style.fg; + try testing.expectEqual(asPardesColor(expected[196]), explicit_before_reverse); + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } }); + _ = frame.reset(.retain_capacity); + const reversed = try p.render(frame.allocator()); + const reversed_blank = reversed.at(tx + r.w - config.GUTTER - 1, body_y + 2).style; + try testing.expectEqual(asPardesColor(expected[59]), reversed_blank.fg); + try testing.expectEqual(asPardesColor(expected[196]), reversed_blank.bg); + const reversed_explicit = reversed.at(tx, body_y).style; + try testing.expectEqual(asPardesColor(expected[196]), reversed_explicit.bg); + try testing.expectEqual(pardes.Color{ .rgb = p.theme().bg.? }, reversed_explicit.fg); + try testing.expect(!std.meta.eql(reversed_explicit.fg, reversed_explicit.bg)); + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5l" } }); + _ = frame.reset(.retain_capacity); + const unreversed = try p.render(frame.allocator()); + const unreversed_blank = unreversed.at(tx + r.w - config.GUTTER - 1, body_y + 2).style; + try testing.expectEqual(asPardesColor(expected[196]), unreversed_blank.fg); + try testing.expectEqual(asPardesColor(expected[59]), unreversed_blank.bg); + + // The cache is keyed by values, not a theme name. Replacing a custom + // theme in place immediately recolours already-rendered indexed cells. + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]104;1\x1b\\" } }); + var custom = p.theme().*; + custom.name = try p.gpa.dupe(u8, "same-name"); + custom.palette = null; + custom.kw = .{ 1, 2, 3 }; + p.custom_theme = custom; + _ = frame.reset(.retain_capacity); + const custom_first = try p.render(frame.allocator()); + try testing.expectEqual(pardes.Color{ .rgb = .{ 1, 2, 3 } }, custom_first.at(tx, body_y).style.fg); + if (p.custom_theme) |*theme| theme.kw = .{ 4, 5, 6 }; + _ = frame.reset(.retain_capacity); + const custom_second = try p.render(frame.allocator()); + try testing.expectEqual(pardes.Color{ .rgb = .{ 4, 5, 6 } }, custom_second.at(tx, body_y).style.fg); +} + +test "terminal Filter keeps extended keys dark-to-light on a light theme" { + const p = try Pardes.init(std.testing.allocator, .{ .startup_config = "Theme acme\n", .tty_only = true }); + defer p.deinit(); + try std.testing.expectEqualStrings("acme", p.theme().name); + var cache: FilterPalette = .{}; + const palette = cache.get(p.theme()); + try std.testing.expect(palette[16].eql(asGhostRgb(p.theme().fg.?))); + try std.testing.expect(palette[231].eql(asGhostRgb(p.theme().bg.?))); +} + +test "terminal Filter preserves exact palette-null light theme default roles" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 5 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + + var light = p.theme().*; + light.name = try p.gpa.dupe(u8, "filter-light-defaults"); + light.bg = .{ 0xf8, 0xf8, 0xf8 }; + light.fg = .{ 0x38, 0x38, 0x38 }; + light.palette = null; + p.custom_theme = light; + + const pane = p.panes[0].?; + try testing.expectEqual(@as(?GColor.RGB, null), pane.terminal.?.vt.colorForXterm(.{ .dynamic = .foreground })); + try testing.expectEqual(@as(?GColor.RGB, null), pane.terminal.?.vt.colorForXterm(.{ .dynamic = .background })); + pane.tty_filter = true; + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = p.bodyTop(pane, r); + const ordinary = try p.render(frame.allocator()); + try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, ordinary.at(tx, body_y).style.fg); + try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, ordinary.at(tx, body_y).style.bg); + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } }); + _ = frame.reset(.retain_capacity); + const reversed = try p.render(frame.allocator()); + try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, reversed.at(tx, body_y).style.fg); + try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, reversed.at(tx, body_y).style.bg); +} + +test "terminal Filter maps the default roles before it maps anything else" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + try testing.expect(pane.tty_filter); + + // Native dark/light palettes plus legacy helix/dark/acme, including + // the null-page fallback that only the legacy dark theme exercises. + for (0..9) |t| { + p.settings.theme = @intCast(t); + const stage_one = FilteredColors.init(p, pane); + // `dark` declares no background of its own, which is exactly why the + // resolver reads the tag colours as the fallback rather than `.?`. + const theme = p.theme(); + try testing.expect(stage_one.theme_bg.eql(asGhostRgb(theme.bg orelse theme.tag_bg))); + try testing.expect(stage_one.theme_fg.eql(asGhostRgb(theme.fg orelse theme.tag_fg))); + // With no OSC 11 in play the mapped page IS that anchor, and the + // fallback is the other one: a background never contrasts with itself. + try testing.expect(stage_one.default_bg.eql(stage_one.theme_bg)); + try testing.expect(stage_one.fallback_fg.eql(stage_one.theme_fg)); + } + + p.settings.theme = 0; + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]11;#5f5f5f\x1b\\" } }); + var moved = FilteredColors.init(p, pane); + try testing.expect(!moved.default_bg.eql(moved.theme_bg)); + try testing.expect(moved.default_bg.eql(moved.keyedRgb(.{ .r = 0x5f, .g = 0x5f, .b = 0x5f }))); +} + +test "terminal Filter refuses a foreground that would collapse onto the page" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + try testing.expect(pane.tty_filter); + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[38;2;255;255;255mW" ++ + "\x1b[0;30mB" ++ + "\x1b[0;31mR" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = p.bodyTop(pane, r); + + for (0..9) |t| { + p.settings.theme = @intCast(t); + var fc = FilteredColors.init(p, pane); + const page = asPardesColor(fc.default_bg); + const rescued = asPardesColor(fc.fallback_fg); + _ = frame.reset(.retain_capacity); + const g = try p.render(frame.allocator()); + + // The colour each of the three would have been given with no floor. + const raw_white = fc.keyedRgb(.{ .r = 255, .g = 255, .b = 255 }); + const raw_black = fc.paletteRgb(0, GColor.default[0]); + const raw_red = fc.paletteRgb(1, GColor.default[1]); + + for ([_]struct { at: u16, raw: GColor.RGB }{ + .{ .at = 0, .raw = raw_white }, + .{ .at = 1, .raw = raw_black }, + .{ .at = 2, .raw = raw_red }, + }) |case| { + const cell = g.at(tx + case.at, body_y).style; + try testing.expectEqual(page, cell.bg); + if (case.raw.contrast(fc.default_bg) < config.tty_filter_min_contrast) { + // Refused: the projection's answer is not painted, the anchor is. + try testing.expectEqual(rescued, cell.fg); + try testing.expect(!std.meta.eql(cell.fg, cell.bg)); + } else { + // Cleared the floor, so stage two leaves it exactly alone. + try testing.expectEqual(asPardesColor(case.raw), cell.fg); + } + // Either way a filtered cell delegates neither colour to a backend. + switch (cell.fg) { + .rgb => |ink| try testing.expect(asGhostRgb(ink).contrast(fc.default_bg) >= + config.tty_filter_min_contrast), + else => return error.FilteredForegroundWasNotRgb, + } + } + + // At least one of the three has to have been a real collapse, or this + // theme proved nothing: white on the light theme, black on the dark. + try testing.expect(raw_white.contrast(fc.default_bg) < config.tty_filter_min_contrast or + raw_black.contrast(fc.default_bg) < config.tty_filter_min_contrast); + // A saturated red is never the page on any curated theme. + try testing.expect(raw_red.contrast(fc.default_bg) >= config.tty_filter_min_contrast); + } +} + +test "terminal Filter holds every projected foreground off the page" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + + for (0..9) |t| { + p.settings.theme = @intCast(t); + var fc = FilteredColors.init(p, pane); + var refused: usize = 0; + for (fc.target, 0..) |projected, key| { + const ink = fc.legible(projected); + try testing.expect(ink.contrast(fc.default_bg) >= config.tty_filter_min_contrast); + if (!ink.eql(projected)) { + refused += 1; + try testing.expect(ink.eql(fc.fallback_fg)); + // Only ever refused for being too near the page. + try testing.expect(projected.contrast(fc.default_bg) < config.tty_filter_min_contrast); + } + // The key a background asks for is handed back untouched, including + // the one whose value is the page itself. + try testing.expect(fc.keyedRgb(GColor.default[key]).eql(fc.target[fc.nearestKey(GColor.default[key])])); + } + // Every curated theme owns at least one collapsing key — that is why + // the floor exists — and the floor must not be flattening the palette. + try testing.expect(refused > 0); + try testing.expect(refused < fc.target.len / 8); + } +} + +test "tty ansi colors follow the prompt hug into normal mode" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32mPP\x1b]133;B\x1b\\\x1b[31mR\x1b[34mB\x1b[0m out" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = p.bodyTop(pane, r); + const red: pardes.Color = .{ .rgb = p.theme().palette.?[1] }; + const blue: pardes.Color = .{ .rgb = p.theme().palette.?[4] }; + + // tty mode projects the emulator's ansi colours cell for cell. + pane.body.mode = .tty; + p.shell_rows.stale = true; + const tty = try p.render(frame.allocator()); + try testing.expectEqual(red, tty.at(tx + 2, body_y).style.fg); + try testing.expectEqual(blue, tty.at(tx + 3, body_y).style.fg); + + pane.body.mode = .normal; + p.shell_rows.stale = true; + _ = frame.reset(.retain_capacity); + const norm = try p.render(frame.allocator()); + try testing.expectEqualStrings("R", norm.at(tx, body_y).grapheme()); + try testing.expectEqualStrings("B", norm.at(tx + 1, body_y).grapheme()); + try testing.expectEqual(red, norm.at(tx, body_y).style.fg); + try testing.expectEqual(blue, norm.at(tx + 1, body_y).style.fg); +} + +test "a prompt row hidden end to end paints nothing at all" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 8 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tag_expanded = false; // a 12x8 screen: the body, not the tag + p.sync(); + pane.tty_filter = false; + pane.body.mode = .normal; + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32maaaaaaaaa\x1b]133;B\x1b\\\x1b[41;36m\u{754C}\x1b[0m\r\n" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = p.bodyTop(pane, r); + p.shell_rows.stale = true; + const s = try p.render(frame.allocator()); + + try testing.expectEqualStrings(" ", s.at(tx, body_y).grapheme()); + try testing.expect(!std.meta.eql(pardes.Color{ .rgb = p.theme().palette.?[1] }, s.at(tx, body_y).style.bg)); +} + +pub fn palColor(p: *Pardes, idx: u8) pardes.Color { + if (p.theme().palette) |pal| if (idx < 16) return .{ .rgb = pal[idx] }; + return .{ .index = idx }; +} + +pub fn ghostColor(p: *Pardes, color: ghostty_vt.Style.Color, is_bg: bool) pardes.Color { + return switch (color) { + .none => blk: { + const t = if (is_bg) p.theme().bg else p.theme().fg; + break :blk if (t) |c| .{ .rgb = c } else .default; + }, + .palette => |idx| palColor(p, idx), + .rgb => |rgb| .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }, + }; +} + +/// executing at a prompt with typed text below it: pad the output area +/// with newlines so the command's output doesn't overwrite the buffer +pub fn padOutputBelowEdits(p: *Pardes, id: usize) void { + // Nothing to pad away from: with no emulator there is no prompt and no + // child whose output could land on top of the edit buffer. + if (comptime !enabled) return; + const pane = p.panes[id] orelse return; + const o = pane.ovl orelse return; + if (!pane.isTerminal()) return; + const state = pane.terminal orelse return; + if (!state.vt.cursorIsAtPrompt()) return; + // the buffer's LAST surface row: its lines may outnumber the shell + // rows it covers, and it is the bottom one output must clear + const max_row = o.row + @as(i32, @intCast(modal.lineCount(o.text))) - 1; + const goff: i32 = @intCast(state.vt.screens.active.pages.scrollbar().offset); + const cursor_abs = pane.surfRow(goff + @as(i32, @intCast(state.vt.screens.active.cursor.y))); + const pad = std.math.clamp(max_row - cursor_abs, 0, @as(i32, pane.rows)); + var i: i32 = 0; + while (i < pad) : (i += 1) p.emitWrite(id, "\r"); +} + +pub fn pushUndo(p: *Pardes, pane: *Pane) void { + pane.body.remember(p.gpa, pane.ovl); +} + +pub fn undo(p: *Pardes, pane: *Pane) void { + const back_to = pane.body.step(p.gpa, pane.ovl, true) orelse return; + if (pane.ovl) |o| p.gpa.free(o.text); + pane.ovl = back_to.ovl; + pane.ensureCursorVisible(); +} + +pub fn redo(p: *Pardes, pane: *Pane) void { + const forward_to = pane.body.step(p.gpa, pane.ovl, false) orelse return; + if (pane.ovl) |o| p.gpa.free(o.text); + pane.ovl = forward_to.ovl; + pane.ensureCursorVisible(); +} + +pub fn ptyReport(handler: *ghostty_vt.TerminalStream.Handler, data: [:0]const u8) void { + const state: *State = @alignCast(@fieldParentPtr("vt", handler.terminal)); + const room = state.reply.len - state.reply_len; + const n = @min(room, data.len); + @memcpy(state.reply[state.reply_len..][0..n], data[0..n]); + state.reply_len += @intCast(n); +} + +const DeviceAttrs = @typeInfo(@typeInfo(@typeInfo( + @FieldType(ghostty_vt.TerminalStream.Handler.Effects, "device_attributes"), +).optional.child).pointer.child).@"fn".return_type.?; +pub fn ptyDeviceAttrs(_: *ghostty_vt.TerminalStream.Handler) DeviceAttrs { + return .{}; +} diff --git a/src/tty/tty.zig b/src/tty/tty.zig index f5ae078a..3902271c 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -1915,7 +1915,7 @@ test "TTY queued results reject old PTY generations and LSP request IDs" { shell.gens[0] = 2; _ = shell.apply(.{ .pty_read = .{ .id = 0, .gen = 1, .bytes = try gpa.dupe(u8, "old session\n") } }); _ = shell.apply(.{ .pty_read = .{ .id = 0, .gen = 2, .bytes = try gpa.dupe(u8, "current session\n") } }); - const text = try pardes.panes.Terminal.screenTextAlloc(core.panes[0].?, gpa); + const text = try pardes.panes.terminal.screenTextAlloc(core.panes[0].?, gpa); defer gpa.free(text); try std.testing.expect(std.mem.indexOf(u8, text, "old session") == null); try std.testing.expect(std.mem.indexOf(u8, text, "current session") != null); -- cgit v1.3