diff options
Diffstat (limited to 'src/term_pane.zig')
| -rw-r--r-- | src/term_pane.zig | 678 |
1 files changed, 658 insertions, 20 deletions
diff --git a/src/term_pane.zig b/src/term_pane.zig index 1243dfa1..adba1cd6 100644 --- a/src/term_pane.zig +++ b/src/term_pane.zig @@ -1,24 +1,440 @@ //! 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. +//! emulator — constructing and replaying the emulator, reading its grid back +//! out as text (for motions and for the body), translating its cell styles +//! into ours, handling the pty replies it hands back through a callback, and +//! keeping 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. +//! Shared modal edit semantics and the pane-wide screen/grid coordinate +//! 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. 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 Key = pardes.Key; +const EditText = pardes.EditText; 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 + +/// The owned text standing in for `rows` live terminal rows, beginning at +/// absolute surface row `row`. The emulator grid remains untouched underneath. +pub const EditBuffer = struct { + row: i32 = 0, + rows: i32 = 1, + text: []u8 = &.{}, +}; + +/// One whole-state terminal edit boundary. Null means the pane has not yet +/// materialized an edit buffer; non-null snapshots own their text. +pub const Snapshot = struct { + ovl: ?EditBuffer, + cur_row: i32, + cur_col: i32, + vsel: pardes.CharSel, +}; + +/// Command bytes aimed at a shell whose prompt does not exist yet. The host +/// resolves the actual executable after the core has already queued `.spawn`, +/// so `spawn` is deliberately an UNKNOWN phase: shells with prompt integration +/// advance to `input` and wait for OSC 133 B; unadorned/unsupported shells are +/// opened immediately by `shellSpawned` and let the pty buffer input until the +/// child reads it. +/// +/// This is pane state, not a Pardes-wide job table. A reused pane slot can +/// therefore never inherit a command intended for the shell it replaced. +pub const PendingCommand = struct { + bytes: []u8 = &.{}, + wait: enum { none, spawn, input } = .none, +}; + +/// A terminal constructed by `newShell` cannot safely receive a command until +/// 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 { + std.debug.assert(pane.pending_command.bytes.len == 0); + pane.pending_command.wait = .spawn; +} + +/// Own `command` until this pane's new child can accept it. False means the +/// gate is already open and the caller should emit in the ordinary way. +/// Multiple gestures before the prompt appears retain their byte order in one +/// flat allocation; each command gets exactly the CR execute normally emits. +pub fn queuePendingCommand(pane: *Pane, command: []const u8) !bool { + if (pane.pending_command.wait == .none) return false; + const old_len = pane.pending_command.bytes.len; + const new_len = try std.math.add(usize, old_len, try std.math.add(usize, command.len, 1)); + const bytes = if (old_len == 0) + try pane.gpa.alloc(u8, new_len) + else + try pane.gpa.realloc(pane.pending_command.bytes, new_len); + @memcpy(bytes[old_len..][0..command.len], command); + bytes[new_len - 1] = '\r'; + pane.pending_command.bytes = bytes; + // An explicit command replaces the automatic greeting. Otherwise both + // would be released by the same first prompt and `ls` would follow what + // the user actually asked to run. + pane.greet = false; + return true; +} + +/// The host successfully forked `pane`. `prompt_marks` describes the argv it +/// ACTUALLY used, not the configured shell name: a staged-rc failure or an +/// unsupported family is an honest unmarked shell and must not wait forever +/// for an OSC sequence it cannot produce. +pub fn shellSpawned(p: *Pardes, id: usize, prompt_marks: bool) void { + const pane = p.panes[id] orelse return; + if (!pane.isTerminal() or pane.pending_command.wait != .spawn) return; + if (prompt_marks) { + pane.pending_command.wait = .input; + releasePendingCommand(p, id, pane, false); + } else { + // There is no semantic event on which an automatic greeting can be + // safely based. Explicit commands still release below (the pty owns + // their buffering); silently omit the cosmetic `ls` rather than race + // an unknown shell's startup and possibly type into its rc program. + pane.greet = false; + releasePendingCommand(p, id, pane, true); + } +} + +/// Called from the ordinary sync after terminal output has been parsed. The +/// semantic cursor is ghostty-vt's parsed OSC state, so this and the greeting +/// share the exact same definition of "readline owns input". +pub fn releasePendingCommandIfReady(p: *Pardes, id: usize, pane: *Pane) void { + if (pane.pending_command.wait == .input) + releasePendingCommand(p, id, pane, promptInputReady(pane)); +} + +fn releasePendingCommand(p: *Pardes, id: usize, pane: *Pane, ready: bool) void { + if (!ready) return; + const bytes = pane.pending_command.bytes; + pane.pending_command = .{}; + if (bytes.len > 0) { + p.emitWrite(id, bytes); + pane.gpa.free(bytes); + } +} + +pub fn deinitPendingCommand(pane: *Pane) void { + if (pane.pending_command.bytes.len > 0) + pane.gpa.free(pane.pending_command.bytes); + pane.pending_command = .{}; +} + +/// The emulator stores an Io value for optional kitty-image work. The browser +/// 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) + std.Io.failing + else + std.Io.Threaded.global_single_threaded.io(); +} + +/// 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); + pane.* = .{ + .vt = try ghostty_vt.Terminal.init(terminalIo(), gpa, .{ + .cols = cols, + .rows = rows, + .max_scrollback = 16 * 1024 * 1024, + }), + .stream = undefined, + .gpa = gpa, + .cols = cols, + .rows = rows, + }; + pane.stream = pane.vt.vtStream(); + // Answer terminal queries (DSR/DA/kitty) back into the pty, else + // crossterm apps (nushell, helix, fzf) block on the reply forever. + pane.stream.handler.effects.write_pty = ptyReport; + pane.stream.handler.effects.device_attributes = ptyDeviceAttrs; + 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.?; + const bytes = if (terminal.stream_b64.len > 0) + try dump.decodeBytes(p.scratch.allocator(), terminal.stream_b64) + else + &.{}; + 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); + if (src.scroll > 0) + pane.vt.screens.active.scroll(.{ .delta_row = -@as(isize, @intCast(src.scroll)) }); + } + return pane; +} + +/// Feed the emulator and retain the bounded suffix a dump can replay. Live +/// output and restoration share this byte path, then apply different views. +fn ingest(pane: *Pane, bytes: []const u8) void { + if (bytes.len >= pane.tty_stream.len) { + const kept = bytes[bytes.len - pane.tty_stream.len ..]; + @memcpy(pane.tty_stream[0..], kept); + pane.tty_stream_head = 0; + pane.tty_stream_len = pane.tty_stream.len; + } else { + const overflow = bytes.len -| (pane.tty_stream.len - pane.tty_stream_len); + pane.tty_stream_head = (pane.tty_stream_head + overflow) % pane.tty_stream.len; + pane.tty_stream_len -= overflow; + const tail = (pane.tty_stream_head + pane.tty_stream_len) % pane.tty_stream.len; + const first = @min(bytes.len, pane.tty_stream.len - tail); + @memcpy(pane.tty_stream[tail..][0..first], bytes[0..first]); + @memcpy(pane.tty_stream[0 .. bytes.len - first], bytes[first..]); + pane.tty_stream_len += bytes.len; + } + pane.stream.nextSlice(bytes); +} + +/// Return the replay ring in chronological order. Wrapped records are copied +/// into `allocator`; contiguous records remain a borrowed slice of the pane. +fn replayBytes(pane: *const Pane, allocator: std.mem.Allocator) ![]const u8 { + if (pane.tty_stream_len == 0) return &.{}; + if (pane.tty_stream_head + pane.tty_stream_len <= pane.tty_stream.len) + return pane.tty_stream[pane.tty_stream_head..][0..pane.tty_stream_len]; + const out = try allocator.alloc(u8, pane.tty_stream_len); + const first = pane.tty_stream.len - pane.tty_stream_head; + @memcpy(out[0..first], pane.tty_stream[pane.tty_stream_head..]); + @memcpy(out[first..], pane.tty_stream[0 .. pane.tty_stream_len - first]); + return out; +} + +/// 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 { + 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); +} + +/// True only after OSC 133 B ended the prompt and handed the cursor to shell +/// input. `cursorIsAtPrompt` deliberately also accepts OSC A's `.prompt` +/// 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 { + return pane.vt.screens.active_key != .alternate and + pane.vt.screens.active.cursor.semantic_content == .input; +} + +test "fresh-shell greeting waits for OSC 133 B input phase" { + if (pardes.platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); + defer p.deinit(); + const pane = p.panes[0].?; + while (p.nextEffect()) |_| {} // initial spawn + + p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); + while (p.nextEffect()) |effect| switch (effect) { + .write => return error.GreetedBeforeOutput, + else => {}, + }; + p.update(.{ .output = .{ .pane = 0, .bytes = "startup banner\r\n" } }); + while (p.nextEffect()) |effect| switch (effect) { + .write => return error.GreetedBeforePrompt, + else => {}, + }; + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x07prompt$ " } }); + try std.testing.expect(!promptInputReady(pane)); + while (p.nextEffect()) |effect| switch (effect) { + .write => return error.GreetedDuringPrompt, + else => {}, + }; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;B\x07" } }); + try std.testing.expect(promptInputReady(pane)); + var greeted = false; + while (p.nextEffect()) |effect| switch (effect) { + .write => |write| greeted = greeted or std.mem.eql(u8, write.bytes.slice(), "ls\r"), + else => {}, + }; + try std.testing.expect(greeted); + try std.testing.expect(!pane.greet); +} + +test "fresh-shell commands preserve order and wait for OSC 133 B" { + if (pardes.platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} // the host has not acknowledged spawn yet + + try std.testing.expectEqual(@as(?usize, 0), p.execute(0, "echo first")); + try std.testing.expectEqual(@as(?usize, 0), p.execute(0, "echo second")); + while (p.nextEffect()) |effect| switch (effect) { + .write => return error.CommandEscapedBeforeFork, + else => {}, + }; + + p.acknowledgeShell(0, "/bin/bash", true); + while (p.nextEffect()) |effect| switch (effect) { + .write => return error.CommandEscapedBeforePrompt, + else => {}, + }; + p.update(.{ .output = .{ .pane = 0, .bytes = "startup\r\n\x1b]133;A\x07prompt$ " } }); + while (p.nextEffect()) |effect| switch (effect) { + .write => return error.CommandEscapedDuringPrompt, + else => {}, + }; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;B\x07" } }); + var sent: [64]u8 = undefined; + var sent_len: usize = 0; + while (p.nextEffect()) |effect| switch (effect) { + .write => |write| { + const bytes = write.bytes.slice(); + @memcpy(sent[sent_len..][0..bytes.len], bytes); + sent_len += bytes.len; + }, + else => {}, + }; + try std.testing.expectEqualStrings("echo first\recho second\r", sent[0..sent_len]); + try std.testing.expectEqual(.none, p.panes[0].?.pending_command.wait); +} + +test "unmarked fresh shells omit the automatic greeting" { + if (pardes.platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{}); + defer p.deinit(); + const pane = p.panes[0].?; + while (p.nextEffect()) |_| {} + try std.testing.expect(pane.greet); + + p.acknowledgeShell(0, "/bin/sh", false); + try std.testing.expect(!pane.greet); + try std.testing.expectEqual(.none, pane.pending_command.wait); + p.update(.{ .output = .{ .pane = 0, .bytes = "plain prompt$ " } }); + while (p.nextEffect()) |effect| switch (effect) { + .write => return error.UnmarkedGreetingEscaped, + else => {}, + }; +} + +/// Encode one key for the program that owns a raw terminal and queue its pty +/// write. Global chords and mode routing have already been handled by core. +pub fn forwardKey(p: *Pardes, id: usize, key: Key) void { + var control: [1]u8 = undefined; + const bytes: ?[]const u8 = blk: { + if (key.ctrl) { + if (key.cp >= 'a' and key.cp <= 'z') { + control[0] = @intCast(key.cp - 0x60); + break :blk control[0..1]; + } + // ASCII @, A-Z, [, \, ], ^ and _ are one contiguous control range. + if (key.cp >= '@' and key.cp <= '_') { + control[0] = @intCast(key.cp - 0x40); + break :blk control[0..1]; + } + } + if (key.text.len > 0) break :blk key.text; + break :blk switch (key.cp) { + Key.enter => "\r", + Key.backspace => "\x7f", + Key.tab => "\t", + Key.escape => "\x1b", + Key.up => "\x1b[A", + Key.down => "\x1b[B", + Key.right => "\x1b[C", + Key.left => "\x1b[D", + Key.delete => "\x1b[3~", + else => null, + }; + }; + if (bytes) |encoded| + p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(encoded) } }); +} + +/// Enter raw tty, handing a pinned modal cursor back to the shell prompt when +/// OSC 133 marks one. The visible prompt row is left-hugged outside tty mode, +/// so translate its column through the hidden prompt before asking ghostty for +/// 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: { + const goff: i32 = @intCast(screen.pages.scrollbar().offset); + const vp_row = pane.gridRow(pane.cur_row) - goff; + if (vp_row < 0) break :handoff; + + var grid_col: i32 = @max(0, pane.cur_col); + if (screen.pages.pin(.{ .viewport = .{ .x = 0, .y = @intCast(vp_row) } })) |row_pin| { + if (row_pin.rowAndCell().row.semantic_prompt != .none) switch (promptCut(row_pin)) { + .cut => |cols| grid_col += @intCast(cols), + .keep, .blank => {}, + }; + } + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intCast(grid_col), .y = @intCast(vp_row) }, + }) orelse break :handoff; + const cursor_pin = screen.cursor.page_pin.*; + var prompts = cursor_pin.promptIterator(.left_up, null); + const prompt_pin = prompts.next() orelse break :handoff; + if (click_pin.before(prompt_pin)) break :handoff; + 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; + pane.vsel.active = false; + pane.nsel = 0; + // A pinned row scrolls away. Raw mode must follow the program's live + // cursor, and Last must not restore a stale modal spot on the way back. + pane.cur_pinned = false; + pane.select = false; + pane.append_at = null; + pane.sticky_col = -1; + pane.pending = 0; +} + +test "raw terminal keys encode text controls and special sequences" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + + const Case = struct { key: Key, expected: ?[]const u8 }; + const cases = [_]Case{ + .{ .key = .{ .cp = 'é', .text = "é" }, .expected = "é" }, + .{ .key = .{ .cp = 'c', .text = "c", .ctrl = true }, .expected = "\x03" }, + .{ .key = .{ .cp = 'C', .text = "C", .ctrl = true }, .expected = "\x03" }, + .{ .key = .{ .cp = '@', .text = "@", .ctrl = true }, .expected = "\x00" }, + .{ .key = .{ .cp = '_', .text = "_", .ctrl = true }, .expected = "\x1f" }, + .{ .key = .{ .cp = '1', .text = "1", .ctrl = true }, .expected = "1" }, + .{ .key = .{ .cp = Key.up, .alt = true, .shift = true }, .expected = "\x1b[A" }, + .{ .key = .{ .cp = Key.delete }, .expected = "\x1b[3~" }, + .{ .key = .{ .cp = Key.home }, .expected = null }, + }; + for (cases) |case| { + forwardKey(p, 0, case.key); + const expected = case.expected orelse { + try std.testing.expect(p.nextEffect() == null); + continue; + }; + const effect = p.nextEffect() orelse return error.MissingWriteEffect; + switch (effect) { + .write => |write| { + try std.testing.expectEqual(@as(u8, 0), write.pane); + try std.testing.expectEqualStrings(expected, write.bytes.slice()); + }, + else => return error.UnexpectedEffect, + } + try std.testing.expect(p.nextEffect() == null); + } +} /// 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 @@ -81,7 +497,7 @@ const Rows = struct { /// 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) { +const PromptCut = union(enum) { /// show the row exactly as ghostty dumped it keep, /// show nothing at all @@ -96,7 +512,7 @@ pub const PromptCut = union(enum) { /// 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 { +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; @@ -118,7 +534,7 @@ pub fn promptCut(pin: ghostty_vt.Pin) PromptCut { /// 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 { +fn promptRow(pin: ghostty_vt.Pin, raw: []const u8) []const u8 { const cols = switch (promptCut(pin)) { .keep => return raw, .blank => return "", @@ -174,6 +590,188 @@ pub fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 { return c.rows; } +/// The full modal motion surface: shell history with the live edit overlay +/// spliced into the rows it covers. +pub fn cursorLines(p: *Pardes, pane: *Pane) ![]const []const u8 { + const rows = try shellRows(p, pane); + if (pane.ovl == null) return rows; + var last = rows.len; + if (pane.ovl) |overlay| + last = @max(last, @as(usize, @intCast(@max(0, overlay.row + overlay.rows)))); + var count = last; + if (pane.ovl) |overlay| { + if (overlay.row >= 0 and @as(usize, @intCast(overlay.row)) < last) + count = count - @min( + @as(usize, @intCast(overlay.rows)), + last - @as(usize, @intCast(overlay.row)), + ) + @max(1, modal.lineCount(overlay.text)); + } + const lines = try p.scratch.allocator().alloc([]const u8, count); + var n: usize = 0; + var grid_row: usize = 0; + while (grid_row < last) : (grid_row += 1) { + if (pane.ovl) |overlay| if (overlay.row >= 0 and grid_row == @as(usize, @intCast(overlay.row))) { + var overlay_lines = std.mem.splitScalar(u8, overlay.text, '\n'); + while (overlay_lines.next()) |line| : (n += 1) lines[n] = line; + grid_row += @intCast(overlay.rows - 1); + continue; + }; + lines[n] = if (grid_row < rows.len) rows[grid_row] else ""; + n += 1; + } + return lines[0..n]; +} + +/// Flatten `cursorLines` without rebuilding the common cached/no-overlay +/// case. Scratch-owned when a join is required. +pub fn flatSurface(p: *Pardes, pane: *Pane, lines: []const []const u8) ![]const u8 { + const cache = &p.shell_rows; + if (!cache.stale and cache.pane == pane and + lines.ptr == cache.rows.ptr and lines.len == cache.rows.len) return cache.text; + var total: usize = if (lines.len > 0) lines.len - 1 else 0; + for (lines) |line| total += line.len; + const text = try p.scratch.allocator().alloc(u8, total); + var at: usize = 0; + for (lines, 0..) |line, i| { + if (i > 0) { + text[at] = '\n'; + at += 1; + } + @memcpy(text[at..][0..line.len], line); + at += line.len; + } + return text; +} + +/// Materialize or extend the terminal edit overlay with one exact allocation. +/// Row slices are scratch-owned/borrowed; only the joined text is installed. +pub fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?EditText { + const want_lo = @max(0, @min(lo, hi)); + const want_hi = @max(want_lo, @max(lo, hi)); + const fresh = pane.ovl == null; + const old: EditBuffer = pane.ovl orelse .{ .row = want_lo, .rows = 1, .text = &.{} }; + const lines: i32 = if (fresh) 1 else @intCast(modal.lineCount(old.text)); + const up = old.row - want_lo; + const down = want_hi - (old.row + lines - 1); + const extending = fresh or up > 0 or down > 0; + var row0 = old.row; + var covered = old.rows; + var text = old.text; + var owned = false; + if (extending) { + const rows = shellRows(p, pane) catch return null; + row0 = old.row - @max(0, up); + covered = old.rows + @max(0, up) + @max(0, down); + const up_len: usize = @intCast(@max(0, up)); + const down_len: usize = @intCast(@max(0, down)); + const parts = p.scratch.allocator().alloc([]const u8, up_len + 1 + down_len) catch return null; + for (parts[0..up_len], 0..) |*part, i| { + const src = @as(usize, @intCast(row0)) + i; + part.* = if (src < rows.len) rows[src] else ""; + } + const middle: usize = @intCast(old.row); + parts[up_len] = if (fresh) + (if (middle < rows.len) rows[middle] else "") + else + old.text; + for (parts[up_len + 1 ..], 0..) |*part, i| { + const src = @as(usize, @intCast(old.row + old.rows)) + i; + part.* = if (src < rows.len) rows[src] else ""; + } + text = std.mem.join(p.gpa, "\n", parts) catch return null; + owned = true; + } + + const row: usize = @intCast(@max(0, want_lo - row0)); + const line_len: i32 = @intCast(modal.lineSlice(text, row).len); + if (col > line_len) { + const spaces = p.scratch.allocator().alloc(u8, @intCast(col - line_len)) catch { + if (owned) p.gpa.free(text); + return null; + }; + @memset(spaces, ' '); + const padded = modal.insertAt(p.gpa, text, .{ .row = row, .col = @intCast(line_len) }, spaces) catch { + if (owned) p.gpa.free(text); + return null; + }; + if (owned) p.gpa.free(text); + text = padded; + owned = true; + } + if (owned) { + if (pane.ovl) |overlay| p.gpa.free(overlay.text); + pane.ovl = .{ .row = row0, .rows = covered, .text = text }; + } + return .{ .text = pane.ovl.?.text, .row0 = pane.ovl.?.row }; +} + +/// Consume a rewritten overlay, freeing the terminal edit text it replaces. +pub fn setEditText(p: *Pardes, pane: *Pane, new: []u8) void { + const overlay = if (pane.ovl) |*value| value else return p.gpa.free(new); + p.gpa.free(overlay.text); + overlay.text = new; +} + +/// Serialize terminal-only state; the core supplies shared pane metadata. +pub fn dumpPane( + pane: *Pane, + arena: std.mem.Allocator, + tag: []const u8, + body: []const u8, + scroll: usize, +) !dump.Pane { + 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)); + var len: usize = 0; + var lines = std.mem.splitAny(u8, full, "\n"); + var prompts = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); + var row: i32 = 0; + var skip: i32 = 0; + while (lines.next()) |raw| : (row += 1) { + // Hidden overlay rows still consume prompt pins to keep them aligned. + const prompt = if (pane.mode != .tty) prompts.next() else null; + if (skip > 0) { + skip -= 1; + continue; + } + if (row > 0) { + stream[len] = '\n'; + len += 1; + } + if (pane.mode != .tty) if (pane.ovl) |overlay| if (row == overlay.row) { + @memcpy(stream[len..][0..overlay.text.len], overlay.text); + len += overlay.text.len; + skip = overlay.rows - 1; + continue; + }; + const shown = if (prompt) |pin| + if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, raw) else raw + else + raw; + @memcpy(stream[len..][0..shown.len], shown); + len += shown.len; + } + 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 = stream[0..len], + .stream_b64 = try dump.encodeBytes(arena, try replayBytes(pane, arena)), + .cursor = .{ + .col = pane.vt.screens.active.cursor.x, + .row = pane.vt.screens.active.cursor.y, + }, + }, + }; +} + 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); @@ -292,7 +890,7 @@ fn fillBody(dst: ?[]u8, pane: *Pane, viewport: []const []const u8) usize { 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; + const body_y = if (p.settings.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; @@ -377,15 +975,15 @@ pub fn padOutputBelowEdits(p: *Pardes, id: usize) void { } /// 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; +pub fn snap(p: *Pardes, pane: *Pane) ?Snapshot { + var ovl: ?EditBuffer = 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 { +pub fn restoreSnap(p: *Pardes, pane: *Pane, s: Snapshot) void { if (pane.ovl) |o| p.gpa.free(o.text); pane.ovl = s.ovl; pane.cur_row = s.cur_row; @@ -396,12 +994,52 @@ pub fn restoreSnap(p: *Pardes, pane: *Pane, s: EditSnap) void { pane.ensureCursorVisible(); } +fn pushHistory(gpa: std.mem.Allocator, slots: []Snapshot, len: *usize, value: Snapshot) void { + if (len.* == slots.len) { + if (slots[0].ovl) |overlay| gpa.free(overlay.text); + std.mem.copyForwards(Snapshot, slots[0 .. slots.len - 1], slots[1..]); + len.* -= 1; + } + slots[len.*] = value; + len.* += 1; +} + +pub fn pushUndo(p: *Pardes, pane: *Pane) void { + const current = pane.ovl orelse EditBuffer{ .rows = 0 }; + if (pane.ed_undo_len > 0) { + const top = pane.ed_undo[pane.ed_undo_len - 1]; + const same = if (top.ovl) |overlay| pane.ovl != null and overlay.row == current.row and + overlay.rows == current.rows and std.mem.eql(u8, overlay.text, current.text) else pane.ovl == null; + if (same) return; + } + const value = snap(p, pane) orelse return; + pushHistory(p.gpa, &pane.ed_undo, &pane.ed_undo_len, value); + for (pane.ed_redo[0..pane.ed_redo_len]) |item| if (item.ovl) |overlay| p.gpa.free(overlay.text); + pane.ed_redo_len = 0; +} + +pub fn undo(p: *Pardes, pane: *Pane) void { + if (pane.ed_undo_len == 0) return; + const current = snap(p, pane) orelse return; + pushHistory(p.gpa, &pane.ed_redo, &pane.ed_redo_len, current); + pane.ed_undo_len -= 1; + restoreSnap(p, pane, pane.ed_undo[pane.ed_undo_len]); +} + +pub fn redo(p: *Pardes, pane: *Pane) void { + if (pane.ed_redo_len == 0) return; + const current = snap(p, pane) orelse return; + pushHistory(p.gpa, &pane.ed_undo, &pane.ed_undo_len, current); + pane.ed_redo_len -= 1; + restoreSnap(p, pane, pane.ed_redo[pane.ed_redo_len]); +} + // 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 pane: *Pane = @alignCast(@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]); |
