//! Terminal panes: everything a Pane does BECAUSE it owns a ghostty-vt //! emulator — reading the grid back out as text (for motions and for the //! body), translating the emulator's own cell styles into ours, the pty //! replies ghostty hands back through a callback, and the edit-buffer undo //! snapshots that only exist because a terminal's "content" is a live grid //! rather than a []u8. //! //! What is NOT here: the edit buffer's SEMANTICS (Pane.ovl, surfRow/gridRow) //! live on Pane in pardes.zig, because the motion, insert and undo machinery //! is shared with file panes verbatim — a terminal in insert mode behaving //! exactly like a text file is the whole design, and splitting the row math //! away from the code that uses it would only hide that. const std = @import("std"); const ghostty_vt = @import("ghostty-vt"); const pardes = @import("pardes.zig"); const Pardes = pardes.Pardes; const Pane = pardes.Pane; const EditSnap = pardes.EditSnap; const Ovl = pardes.Ovl; const modal = @import("modal.zig"); const config = @import("config.zig"); /// The memo behind `shellRows`. ONE entry for the editor, because the motion /// surface is built for the pane the cursor is in and a second pane asking /// would only double a multi-megabyte buffer for a slot it is about to lose /// again. gpa-owned rather than scratch-arena: the whole point is to outlive /// the update that built it. /// /// LIFETIME, the part that would rot silently: `rows` is handed out to /// callers, so the buffers are freed in exactly two places — `sweep`, at the /// TOP of an update before any handler can be holding them, and `reset` when /// the editor goes away. Everything that notices the entry has gone bad /// (output arrived, the grid reflowed, the pane died, another pane wants the /// slot) only marks it `stale`; nothing frees mid-update. That is the same /// guarantee the scratch arena gave, spelled out. 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; } /// `pane` is being destroyed. Drop the pointer now — a freed pane's /// address can come back from the allocator as a different pane, and an /// entry still naming it would answer for the wrong grid — but leave the /// buffers to the next sweep, as ever. 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, }; /// 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. pub 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, }; /// The prompt and the command typed at it share a grid row, and OSC 133 marks /// them apart CELL by cell (`Cell.semantic_content` is output / input / /// prompt). The row flag every caller tests first is only ghostty's "some cell /// in here is a prompt cell" index; taking the row on that flag alone is what /// used to throw the command away with the prompt. pub 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 }; } /// That decision applied to `raw`, the line ghostty dumped for `pin`'s row. /// Always a slice OF `raw` — dropping the prompt is a left-hug, so the command /// starts at column 0 with no run of blanks in front of it where the prompt /// used to be, and there is nothing to allocate or copy anywhere. /// /// Walking the dump rather than rebuilding the row out of cells keeps ghostty /// the single authority on how a cell spells itself — wide glyphs, combining /// marks and all. One non-spacer cell is one dumped grapheme, and that is what /// makes the cell walk and the byte walk stay in step. pub 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]; var cps: usize = 1; if (pin.grapheme(cell)) |extra| cps += extra.len; for (0..cps) |_| at = modal.nextGrapheme(raw, at); // 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"); } /// A terminal's shell rows as the surface sees them: the WHOLE /// history+active grid, prompt rows blanked (OSC 133), absolute grid rows /// from 0. The raw material the motion surface is composed from — the /// edit buffer is NOT applied here, so it is also what seeding the buffer /// reads. /// ghostty's dump trims the grid's trailing blank rows; ONE of them is /// kept back, the row the cursor sits on below the last line of output. /// That row is a file's final newline: without it the surface would have /// one line fewer than the same text in a document, and every motion and /// linewise edit at the bottom would diverge. /// /// Building it is O(scrollback) — a dump of the whole history — and a /// keystroke asks for it once or twice, so the result is memoized against the /// pane until its grid changes. A pane sitting on 16 MiB of agent transcript /// paid that dump per press of `j` before the cache; now it pays it once per /// chunk of output. pub fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 { const c = &p.shell_rows; if (!c.stale and c.pane == pane) return c.rows; if (c.pane != null) { // Another pane holds the slot. Take it for the NEXT update (the sweep // frees what is there) and answer this one from scratch: whoever owns // the live entry may still be holding the rows it handed out. 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; } fn buildRows(alloc: std.mem.Allocator, p: *Pardes, pane: *Pane) !Rows { const full = try pane.vt.screens.active.dumpStringAlloc(p.scratch.allocator(), .{ .screen = .{} }); var pit = pane.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 }; } /// The body a terminal renders: the viewport's shell rows (prompt rows blanked /// outside tty mode) with the edit buffer's lines standing in for the rows it /// covers, so what you see is what the motions move over. pub fn bodyText(arena: std.mem.Allocator, pane: *Pane) ![]const u8 { const raw = try pane.vt.plainString(arena); var prompts = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .viewport = .{} }, null); const vp = try arena.alloc([]const u8, std.mem.count(u8, raw, "\n") + 1); var lines = std.mem.splitScalar(u8, raw, '\n'); var n: usize = 0; while (lines.next()) |ln| { vp[n] = if (pane.mode != .tty) if (prompts.next()) |pin| if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, ln) else ln else ln else ln; n += 1; } std.debug.assert(n == vp.len); 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; } /// 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 { const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); const off = pane.scroll(); var g: i32 = pane.gridRow(off); // the buffer can start above the viewport: drop the lines scrolled past var skip: usize = if (pane.ovl) |o| @intCast(@max(0, off - pane.surfRow(o.row))) else 0; var written: usize = 0; var n: usize = 0; while (n < pane.rows) { if (pane.mode != .tty) if (pane.ovl) |o| if (g == o.row) { var bit = std.mem.splitScalar(u8, o.text, '\n'); var k: usize = 0; while (bit.next()) |ln| : (k += 1) { if (k < skip) continue; if (n >= pane.rows) break; if (n > 0) { if (dst) |out| out[written] = '\n'; written += 1; } if (dst) |out| @memcpy(out[written..][0..ln.len], ln); written += ln.len; n += 1; } skip = 0; g += o.rows; continue; }; if (n > 0) { if (dst) |out| out[written] = '\n'; written += 1; } const vi = g - goff; if (vi >= 0 and @as(usize, @intCast(vi)) < viewport.len) { const line = viewport[@intCast(vi)]; if (dst) |out| @memcpy(out[written..][0..line.len], line); written += line.len; } n += 1; g += 1; } return written; } /// tty colors: recolor each body cell from the emulator's own style so shell /// output keeps its ansi colors (gated on the Colors toggle by the caller). /// Rows the edit buffer stands in for are OURS, not the emulator's: they keep /// the plain body style, and the rows under them are read from the shell row /// the surface actually shows there. pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, off: i32) void { const s = &p.surface; const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); const body_y = if (p.tag_bottom) r.y else r.y + pardes.BOX_H; var vr: u16 = 0; while (vr < body_h and vr < pane.rows) : (vr += 1) { const grid = pane.gridRow(off + @as(i32, vr)) - goff; if (pane.mode != .tty) if (pane.ovl) |o| { if (off + @as(i32, vr) >= o.row and off + @as(i32, vr) < o.row + @as(i32, @intCast(modal.lineCount(o.text)))) continue; }; if (grid < 0) continue; var c: u16 = 0; while (c < tw) : (c += 1) { const ci = pane.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(c), .y = @intCast(grid) } }) orelse continue; if (ci.cell.wide == .spacer_tail) continue; const cell = s.at(tx + c, body_y + vr); if (cell.default) continue; cell.style = cellStyle(p, ci); } } } pub fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell) pardes.CellStyle { const style = ci.style(); var cs: pardes.CellStyle = .{ .fg = ghostColor(p, style.fg_color, false), .bg = ghostColor(p, style.bg_color, true), .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, }, }; 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; } 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 { const pane = p.panes[id] orelse return; const o = pane.ovl orelse return; if (!pane.isTerminal()) return; if (!pane.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(pane.vt.screens.active.pages.scrollbar().offset); const cursor_abs = pane.surfRow(goff + @as(i32, @intCast(pane.vt.screens.active.cursor.y))); const pad = std.math.clamp(max_row - cursor_abs, 0, @as(i32, pane.rows)); var i: i32 = 0; while (i < pad) : (i += 1) p.emitWrite(id, "\r"); } /// the snapshot takes ownership of a COPY of the edit buffer's text pub fn snap(p: *Pardes, pane: *Pane) ?EditSnap { var ovl: ?Ovl = null; if (pane.ovl) |o| ovl = .{ .row = o.row, .rows = o.rows, .text = p.gpa.dupe(u8, o.text) catch return null }; return .{ .ovl = ovl, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .vsel = pane.vsel }; } /// undo/redo restores the selection recorded with the snapshot (helix /// keeps selections in its history transactions) pub fn restoreSnap(p: *Pardes, pane: *Pane, s: EditSnap) void { if (pane.ovl) |o| p.gpa.free(o.text); pane.ovl = s.ovl; pane.cur_row = s.cur_row; pane.cur_col = s.cur_col; pane.cur_pinned = true; pane.vsel = s.vsel; pane.msel.active = false; pane.ensureCursorVisible(); } // ghostty calls this with a reply (cursor-position report, DA, ...) to send // back to the child as if it typed it. The handler's `terminal` is our Pane.vt // field; recover the Pane and stash the bytes — sync() drains them into write // effects (the callback has no path to the effect queue). pub fn ptyReport(handler: *ghostty_vt.TerminalStream.Handler, data: [:0]const u8) void { const pane: *Pane = @fieldParentPtr("vt", handler.terminal); const room = pane.reply.len - pane.reply_len; const n = @min(room, data.len); @memcpy(pane.reply[pane.reply_len..][0..n], data[0..n]); pane.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 .{}; }