summaryrefslogtreecommitdiff
path: root/src/term_pane.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/term_pane.zig')
-rw-r--r--src/term_pane.zig263
1 files changed, 236 insertions, 27 deletions
diff --git a/src/term_pane.zig b/src/term_pane.zig
index 7662292e..cda45394 100644
--- a/src/term_pane.zig
+++ b/src/term_pane.zig
@@ -9,8 +9,17 @@
//! invariants remain on Pane in pardes.zig. Terminal-only projection, history
//! snapshots, and cell styling live here, so the core does not need to know
//! how a live terminal becomes an editable text surface.
+//!
+//! ...and because it does not, this is the only CORE file that ever holds a
+//! ghostty-vt VALUE: pardes.zig no longer imports the emulator at all, and
+//! image.zig's import exists solely to comptime-check a colour table against
+//! it. (src/gui/gui.zig and the test/ snapshot harness import it too — both
+//! are backends, and neither is in the p4 graph.) `pardes.terminal_panes` says
+//! whether a build has an emulator at all; the two Pane slots and the
+//! accessors under "the emulator, as the core is allowed to see it" are the
+//! whole seam, and `!enabled` answers every one of them with the empty grid.
+//! See pardes.terminal_panes for why the P4 firmware has none.
const std = @import("std");
-const ghostty_vt = @import("ghostty-vt");
const pardes = @import("pardes.zig");
const Pardes = pardes.Pardes;
const Pane = pardes.Pane;
@@ -20,7 +29,29 @@ const modal = @import("modal.zig");
const config = @import("config.zig");
const dump = @import("dump.zig");
-pub const history_max = 64; // snapshots copy the whole edit buffer; keep this tighter than files
+/// `pardes.terminal_panes`, re-exported so every gate in this file reads one
+/// local name. When false the import below is a DEAD comptime branch, so
+/// build.zig need not resolve the ghostty dependency at all.
+pub const enabled = pardes.terminal_panes;
+const ghostty_vt = if (enabled) @import("ghostty-vt") else struct {};
+
+/// EDIT-BUFFER BOUNDARIES REMEMBERED PER PANE. Snapshots copy the whole edit
+/// buffer, so keep this tighter than files.
+///
+/// A CAPACITY, not a presence: without an emulator `pane.ovl` is not a typed
+/// overlay on a live grid, it is the pane's ENTIRE content (see `create` and
+/// `restore` below), so undo on it matters more here, not less. But each entry
+/// is a gpa copy of that content, and 64 of them is 2.5 KiB of `Pane` plus 64
+/// heap copies — on a board with a 384 KiB heap the ring would run out of
+/// memory long before it ran out of slots. `pushHistory` evicts and frees the
+/// oldest once full, so the shorter ring loses only the deepest undo steps.
+pub const history_max = if (enabled) 64 else 8;
+
+/// The emulator and its VT parser as PANE FIELDS — the `PdfSlot` pattern from
+/// pardes.zig, zero-sized where there are no terminal panes. Declared here
+/// rather than there so the emulator's type never has to be named by the core.
+pub const VtSlot = if (enabled) ghostty_vt.Terminal else void;
+pub const StreamSlot = if (enabled) ghostty_vt.TerminalStream else void;
const GColor = ghostty_vt.color;
@@ -37,11 +68,16 @@ const FilterPaletteKey = struct {
/// interpolation: its CIELAB cube and greyscale ramp give every xterm key a
/// theme-derived RGB value while retaining the conventional dark-to-light
/// index orientation on light themes (`harmonious = false`).
-pub const FilterPalette = struct {
+///
+/// Zero-sized without an emulator — there are no ANSI cells to reproject, so
+/// `Pardes.tty_filter_palette` costs the core nothing but keeps its `.{}`.
+pub const FilterPalette = if (enabled) LivePalette else struct {};
+
+const LivePalette = struct {
key: ?FilterPaletteKey = null,
colors: GColor.Palette = GColor.default,
- fn get(self: *FilterPalette, theme: *const pardes.Theme) *const GColor.Palette {
+ 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;
@@ -126,6 +162,10 @@ pub const PendingCommand = struct {
/// the native host has at least completed forkpty. Dump-replay terminals do
/// not call this: they are dead grids, not half-spawned children.
pub fn armShellSpawn(pane: *Pane) void {
+ // With no emulator there is no fork to wait on and no OSC 133 that could
+ // ever arrive, so the gate stays open: `queuePendingCommand` declines and
+ // the command leaves as an ordinary write, rather than waiting forever.
+ if (comptime !enabled) return;
std.debug.assert(pane.pending_command.bytes.len == 0);
pane.pending_command.wait = .spawn;
}
@@ -200,17 +240,107 @@ pub fn deinitPendingCommand(pane: *Pane) void {
/// has no host IO and must not instantiate std.Io.Threaded's POSIX backend
/// merely to construct a replay-only terminal.
pub fn terminalIo() std.Io {
- return if (comptime pardes.platform == .web)
+ return if (comptime !pardes.hosted)
std.Io.failing
else
std.Io.Threaded.global_single_threaded.io();
}
+// ---- the emulator, as the core is allowed to see it ----
+//
+// Every question pardes.zig used to answer by walking `pane.vt.screens.active`
+// for itself, named. That is the boundary this file's header always claimed,
+// and naming them is what lets a build with no emulator answer ALL of them at
+// comptime with the empty grid, instead of scattering one platform test
+// through the core's scroll, cursor, mouse, resize and render paths.
+
+/// 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 sb = pane.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 viewport 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 cur = pane.vt.screens.active.cursor;
+ return .{ .x = @intCast(cur.x), .y = @intCast(cur.y) };
+}
+
+/// Move the emulator's viewport by `delta` shell rows (negative scrolls back).
+pub fn scrollGrid(pane: *Pane, delta: i32) void {
+ if (comptime !enabled) return;
+ pane.vt.screens.active.scroll(.{ .delta_row = delta });
+}
+
+/// Snap the viewport back onto live output.
+pub fn followOutput(pane: *Pane) void {
+ if (comptime !enabled) return;
+ pane.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;
+ pane.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;
+ return pane.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 m = &pane.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;
+ return pane.vt.modes.get(.mouse_format_sgr);
+}
+
+/// The whole scrollback as plain text, `gpa`-owned: what `Save` writes out.
+pub fn screenTextAlloc(pane: *Pane, gpa: std.mem.Allocator) ![]const u8 {
+ if (comptime !enabled) return &.{};
+ return pane.vt.screens.active.dumpStringAlloc(gpa, .{ .screen = .{} });
+}
+
+/// Release the emulator's heap. The Pane allocation itself is the core's.
+pub fn deinitEmulator(pane: *Pane, gpa: std.mem.Allocator) void {
+ if (comptime !enabled) return;
+ pane.stream.deinit();
+ pane.vt.deinit(gpa);
+}
+
/// 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 gpa.create(Pane);
errdefer gpa.destroy(pane);
+ if (comptime !enabled) {
+ // No emulator: the pane is a plain text surface whose whole content is
+ // its edit buffer. `tty_filter` stays off — there are no ANSI cells to
+ // reproject and `recolorAnsi` is compiled out entirely.
+ pane.* = .{ .vt = {}, .stream = {}, .gpa = gpa, .cols = cols, .rows = rows };
+ return pane;
+ }
pane.* = .{
.vt = try ghostty_vt.Terminal.init(terminalIo(), gpa, .{
.cols = cols,
@@ -233,10 +363,39 @@ pub fn create(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane {
return pane;
}
+/// A doc pane (file/image/PDF): no pty and no spawn, and a stub 1x1 emulator
+/// only because the shared pane machinery touches its allocator-owned bits.
+/// Slot registration stays with the core, as for `create`.
+pub fn createDoc(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane {
+ const pane = try gpa.create(Pane);
+ errdefer gpa.destroy(pane);
+ pane.* = .{
+ .vt = if (comptime enabled)
+ try ghostty_vt.Terminal.init(terminalIo(), gpa, .{ .cols = 1, .rows = 1 })
+ else {},
+ .stream = undefined,
+ .gpa = gpa,
+ .cols = cols,
+ .rows = rows,
+ };
+ if (comptime enabled) pane.stream = pane.vt.vtStream();
+ return pane;
+}
+
/// Rebuild a dump's dead terminal emulator. 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) !*Pane {
const terminal = src.terminal.?;
+ if (comptime !enabled) {
+ // Nothing to replay the recorded VT bytes INTO. The dump also carries
+ // the rendered text of that grid, so it becomes the pane's edit buffer
+ // — the one content a build with no emulator can show at all.
+ 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;
+ }
const bytes = if (terminal.stream_b64.len > 0)
try dump.decodeBytes(p.scratch.allocator(), terminal.stream_b64)
else
@@ -244,9 +403,9 @@ pub fn restore(p: *Pardes, src: dump.Pane) !*Pane {
const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows));
if (bytes.len > 0) {
ingest(pane, bytes);
- pane.vt.screens.active.scroll(.active);
+ followOutput(pane);
if (src.scroll > 0)
- pane.vt.screens.active.scroll(.{ .delta_row = -@as(isize, @intCast(src.scroll)) });
+ scrollGrid(pane, -@as(i32, @intCast(src.scroll)));
}
return pane;
}
@@ -288,10 +447,12 @@ fn replayBytes(pane: *const Pane, allocator: std.mem.Allocator) ![]const u8 {
/// 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;
ingest(pane, bytes);
p.shell_rows.markStale(pane);
const body_mode = if (pane.tag_edit) pane.tag_mode else pane.mode;
- if (body_mode == .tty) pane.vt.screens.active.scroll(.active);
+ if (body_mode == .tty) followOutput(pane);
}
/// True only after OSC 133 B ended the prompt and handed the cursor to shell
@@ -299,6 +460,7 @@ pub fn feedOutput(p: *Pardes, pane: *Pane, bytes: []const u8) void {
/// phase; that is right for navigation but too early to inject a greeting —
/// readline may not own echo yet and would leave `ls` on an unmarked row.
pub fn promptInputReady(pane: *const Pane) bool {
+ if (comptime !enabled) return false;
return pane.vt.screens.active_key != .alternate and
pane.vt.screens.active.cursor.semantic_content == .input;
}
@@ -435,8 +597,11 @@ pub fn forwardKey(p: *Pardes, id: usize, key: Key) void {
/// the arrow-key movement the child understands.
pub fn enterTty(p: *Pardes, id: usize) void {
const pane = p.panes[id] orelse return;
- const screen = pane.vt.screens.active;
- if (pane.cur_pinned and pane.vt.cursorIsAtPrompt()) handoff: {
+ // Only the PROMPT HANDOFF needs the emulator; the mode switch below is
+ // plain pane state, so a build without one still has a raw mode — it just
+ // has no prompt to translate a pinned cursor back onto.
+ if (comptime enabled) if (pane.cur_pinned and pane.vt.cursorIsAtPrompt()) handoff: {
+ const screen = pane.vt.screens.active;
const goff: i32 = @intCast(screen.pages.scrollbar().offset);
const vp_row = pane.gridRow(pane.cur_row) - goff;
if (vp_row < 0) break :handoff;
@@ -458,7 +623,7 @@ pub fn enterTty(p: *Pardes, id: usize) void {
const moves = screen.promptClickMove(click_pin);
for (0..moves.left) |_| p.emitWrite(id, "\x1b[D");
for (0..moves.right) |_| p.emitWrite(id, "\x1b[C");
- }
+ };
pane.mode = .tty;
pane.msel.active = false;
@@ -567,6 +732,11 @@ const Rows = struct {
rows: [][]const u8,
};
+/// The motion surface of a pane with no emulator behind it: exactly the one
+/// blank row `buildRows` retains from a real grid, so surface row 0 exists and
+/// every motion, edit and undo path measures the same thing it always did.
+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) {
@@ -643,6 +813,12 @@ fn promptRow(pin: ghostty_vt.Pin, raw: []const u8) []const u8 {
/// 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 {
+ // With no emulator there is no history to dump, and no cache to keep it
+ // in: one empty row, which is the same row `buildRows` keeps back from
+ // ghostty's trimmed dump — a file's final newline. Everything above the
+ // grid (the edit overlay, its undo stacks, every motion) works unchanged
+ // over it, so a pane on the board is an ordinary scratch buffer.
+ if (comptime !enabled) return &empty_grid;
const c = &p.shell_rows;
if (!c.stale and c.pane == pane) return c.rows;
if (c.pane != null) {
@@ -792,6 +968,27 @@ pub fn dumpPane(
body: []const u8,
scroll: usize,
) !dump.Pane {
+ if (comptime !enabled) {
+ // A pane with no emulator has no grid to serialize and no VT bytes to
+ // record — its edit buffer IS its content, so that is what the dump
+ // carries, and `restore` reads it straight back into a fresh buffer.
+ 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,
+ .terminal = .{
+ .cwd = try arena.dupe(u8, pane.cwdSlice()),
+ .stream = try arena.dupe(u8, text),
+ .stream_b64 = &.{},
+ .cursor = .{ .col = 0, .row = 0 },
+ },
+ };
+ }
const full = try pane.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));
@@ -885,22 +1082,28 @@ fn buildRows(alloc: std.mem.Allocator, p: *Pardes, pane: *Pane) !Rows {
/// 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
+ // The VIEWPORT half is the emulator's; the row walk below is the edit
+ // buffer's and is shared. With no emulator the viewport is simply empty,
+ // and `fillBody` renders the overlay against blank rows.
+ const vp: []const []const u8 = if (comptime !enabled) &.{} else vp: {
+ 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
- else
- ln;
- n += 1;
- }
- std.debug.assert(n == vp.len);
+ 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);
@@ -912,7 +1115,7 @@ pub fn bodyText(arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
/// 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 goff: i32 = gridOffset(pane);
const off = pane.scroll();
var g: i32 = pane.gridRow(off);
// the buffer can start above the viewport: drop the lines scrolled past
@@ -959,6 +1162,9 @@ fn fillBody(dst: ?[]u8, pane: *Pane, viewport: []const []const u8) usize {
/// it) and reads the live viewport row for row: normal/insert editing shows
/// plain text, so nothing an edit does can move a shell row's colour.
pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16) void {
+ // No emulator, no ANSI cells: the whole pass — and the 256-colour theme
+ // projection behind it — is compiled out.
+ if (comptime !enabled) return;
const s = &p.surface;
const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
var filtered_storage: FilteredColors = undefined;
@@ -1415,6 +1621,9 @@ pub fn ghostColor(p: *Pardes, color: ghostty_vt.Style.Color, is_bg: bool) pardes
/// 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;