diff options
Diffstat (limited to 'src/term_pane.zig')
| -rw-r--r-- | src/term_pane.zig | 263 |
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; |
