summaryrefslogtreecommitdiff
path: root/src/term_pane.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-30 23:35:45 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commit19f7322100062b7c1adcde3376063ce6c1d8c72d (patch)
treeb76347dffe0347c29a38b4cad6031678683dba74 /src/term_pane.zig
parent753d5451ca7d09dbc5ff44f3c2dcf9a47fb87f96 (diff)
downloadpardes-19f7322100062b7c1adcde3376063ce6c1d8c72d.tar.gz
pardes-19f7322100062b7c1adcde3376063ce6c1d8c72d.zip
structure: backends into src/{tty,gui,lsp}, pane kinds and builtins into their own files
The core now lies FLAT at src/ and every subdirectory is one backend, so a file being in no directory at all is what says it is core. Pane-kind bodies leave pardes.zig for term_pane.zig / file_pane.zig / output_pane.zig, leaving it the layout, the event/effect machine and the generic render loop. Builtins are one struct each in builtins.zig, and the enum is folded out of the file's own declaration list at comptime — a zig file IS a struct, so the list of builtins and the builtins themselves are the same text. Adding one is writing a struct. Key paths deliberately stay one table for the config pass. Pure refactor: no golden moved.
Diffstat (limited to 'src/term_pane.zig')
-rw-r--r--src/term_pane.zig222
1 files changed, 222 insertions, 0 deletions
diff --git a/src/term_pane.zig b/src/term_pane.zig
new file mode 100644
index 00000000..1c03afa7
--- /dev/null
+++ b/src/term_pane.zig
@@ -0,0 +1,222 @@
+//! 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");
+
+/// 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. Scratch-arena backed.
+/// 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.
+/// ponytail: O(scrollback) dump+scan per keystroke — window it around the
+/// viewport if a multi-MB scrollback ever makes navigation lag.
+pub fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 {
+ const arena = p.scratch.allocator();
+ const full = try pane.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} });
+ var pit = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null);
+ var ls: std.ArrayList([]const u8) = .empty;
+ var it = std.mem.splitAny(u8, full, "\n");
+ while (it.next()) |raw| {
+ const is_prompt = if (pit.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false;
+ try ls.append(arena, if (is_prompt) "" else raw);
+ }
+ try ls.append(arena, "");
+ return ls.items;
+}
+
+/// 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);
+ var vp: std.ArrayList([]const u8) = .empty;
+ var lines = std.mem.splitScalar(u8, raw, '\n');
+ while (lines.next()) |ln| {
+ const is_prompt = if (pane.mode != .tty)
+ if (prompts.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false
+ else
+ false;
+ try vp.append(arena, if (is_prompt) "" else ln);
+ }
+ 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 out: std.ArrayList(u8) = .empty;
+ 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) try out.append(arena, '\n');
+ try out.appendSlice(arena, ln);
+ n += 1;
+ }
+ skip = 0;
+ g += o.rows;
+ continue;
+ };
+ if (n > 0) try out.append(arena, '\n');
+ const vi = g - goff;
+ if (vi >= 0 and @as(usize, @intCast(vi)) < vp.items.len) try out.appendSlice(arena, vp.items[@intCast(vi)]);
+ n += 1;
+ g += 1;
+ }
+ return out.items;
+}
+
+/// 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);
+ 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, r.y + pardes.BOX_H + 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 .{};
+}