diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-30 23:35:45 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | 19f7322100062b7c1adcde3376063ce6c1d8c72d (patch) | |
| tree | b76347dffe0347c29a38b4cad6031678683dba74 /src/term_pane.zig | |
| parent | 753d5451ca7d09dbc5ff44f3c2dcf9a47fb87f96 (diff) | |
| download | pardes-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.zig | 222 |
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 .{}; +} |
