1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
|
//! 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");
const config = @import("config.zig");
/// 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
/// would only double a multi-megabyte buffer for a slot it is about to lose
/// again. gpa-owned rather than scratch-arena: the whole point is to outlive
/// the update that built it.
///
/// LIFETIME, the part that would rot silently: `rows` is handed out to
/// callers, so the buffers are freed in exactly two places — `sweep`, at the
/// TOP of an update before any handler can be holding them, and `reset` when
/// the editor goes away. Everything that notices the entry has gone bad
/// (output arrived, the grid reflowed, the pane died, another pane wants the
/// slot) only marks it `stale`; nothing frees mid-update. That is the same
/// guarantee the scratch arena gave, spelled out.
pub const RowsCache = struct {
/// whose grid this describes; null = the slot is free
pane: ?*const Pane = null,
/// the rows joined by '\n' — `flatSurface` hands this back verbatim
/// instead of rebuilding the join on every keystroke
text: []const u8 = &.{},
/// slices INTO `text`, absolute grid rows from 0
rows: [][]const u8 = &.{},
/// `text` is a prefix of this: blanking a prompt row shortens the join,
/// and the slack is not worth a second allocation to reclaim
text_alloc: []u8 = &.{},
stale: bool = false,
pub fn reset(c: *RowsCache, gpa: std.mem.Allocator) void {
if (c.text_alloc.len > 0) gpa.free(c.text_alloc);
if (c.rows.len > 0) gpa.free(c.rows);
c.* = .{};
}
/// Free a stale entry. Called at the top of `update`, and nowhere else.
pub fn sweep(c: *RowsCache, gpa: std.mem.Allocator) void {
if (c.stale) c.reset(gpa);
}
/// `pane`'s grid moved: the entry no longer describes it.
pub fn markStale(c: *RowsCache, pane: *const Pane) void {
if (c.pane == pane) c.stale = true;
}
/// `pane` is being destroyed. Drop the pointer now — a freed pane's
/// address can come back from the allocator as a different pane, and an
/// entry still naming it would answer for the wrong grid — but leave the
/// buffers to the next sweep, as ever.
pub fn dropPane(c: *RowsCache, pane: *const Pane) void {
if (c.pane != pane) return;
c.pane = null;
c.stale = true;
}
};
const Rows = struct {
text_alloc: []u8,
text: []const u8,
rows: [][]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.
pub const PromptCut = union(enum) {
/// show the row exactly as ghostty dumped it
keep,
/// show nothing at all
blank,
/// drop this many leading COLUMNS — the prompt — and keep the rest, which
/// is what was typed at it
cut: usize,
};
/// The prompt and the command typed at it share a grid row, and OSC 133 marks
/// them apart CELL by cell (`Cell.semantic_content` is output / input /
/// 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 {
if (config.tty_blank == .prompt_and_input) return .blank;
const cells = pin.cells(.all);
var cols: usize = 0;
while (cols < cells.len and cells[cols].semantic_content == .prompt) cols += 1;
// Flagged, but with no prompt cells at the FRONT: a right-side prompt, or
// a repaint that has moved on. Nothing here is the prompt, so hide nothing.
if (cols == 0) return .keep;
// ...and all prompt, nothing typed yet: the row is chrome end to end.
if (cols >= cells.len) return .blank;
return .{ .cut = cols };
}
/// That decision applied to `raw`, the line ghostty dumped for `pin`'s row.
/// Always a slice OF `raw` — dropping the prompt is a left-hug, so the command
/// starts at column 0 with no run of blanks in front of it where the prompt
/// used to be, and there is nothing to allocate or copy anywhere.
///
/// Walking the dump rather than rebuilding the row out of cells keeps ghostty
/// 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 {
const cols = switch (promptCut(pin)) {
.keep => return raw,
.blank => return "",
.cut => |n| n,
};
const cells = pin.cells(.all);
var at: usize = 0;
var col: usize = 0;
while (col < cols and at < raw.len) {
const cell = &cells[col];
var cps: usize = 1;
if (pin.grapheme(cell)) |extra| cps += extra.len;
for (0..cps) |_| at = modal.nextGrapheme(raw, at);
// the tail cell of a wide glyph spells nothing of its own
col += if (cell.wide == .wide) @as(usize, 2) else 1;
}
return std.mem.trimEnd(u8, raw[at..], " \t");
}
/// 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.
/// 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.
///
/// Building it is O(scrollback) — a dump of the whole history — and a
/// keystroke asks for it once or twice, so the result is memoized against the
/// pane until its grid changes. A pane sitting on 16 MiB of agent transcript
/// 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 {
const c = &p.shell_rows;
if (!c.stale and c.pane == pane) return c.rows;
if (c.pane != null) {
// Another pane holds the slot. Take it for the NEXT update (the sweep
// frees what is there) and answer this one from scratch: whoever owns
// the live entry may still be holding the rows it handed out.
c.stale = true;
return (try buildRows(p.scratch.allocator(), p, pane)).rows;
}
const built = try buildRows(p.gpa, p, pane);
c.* = .{
.pane = pane,
.text = built.text,
.rows = built.rows,
.text_alloc = built.text_alloc,
};
return c.rows;
}
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);
// split yields one more item than delimiters; the extra final slot is the
// cursor row retained below.
const n_rows = std.mem.count(u8, full, "\n") + 2;
const rows = try alloc.alloc([]const u8, n_rows);
errdefer alloc.free(rows);
// Blanking a prompt row only ever SHORTENS it and the retained cursor row
// adds one separator, so the dump's length plus one bounds the join.
const text = try alloc.alloc(u8, full.len + 1);
errdefer alloc.free(text);
var at: usize = 0;
var n: usize = 0;
var it = std.mem.splitScalar(u8, full, '\n');
while (it.next()) |raw| {
const shown = if (pit.next()) |pin|
if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, raw) else raw
else
raw;
if (n > 0) {
text[at] = '\n';
at += 1;
}
@memcpy(text[at..][0..shown.len], shown);
rows[n] = text[at..][0..shown.len];
at += shown.len;
n += 1;
}
text[at] = '\n';
at += 1;
rows[n] = text[at..][0..0];
n += 1;
std.debug.assert(n == n_rows);
return .{ .text_alloc = text, .text = text[0..at], .rows = rows };
}
/// 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);
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;
n += 1;
}
std.debug.assert(n == vp.len);
const len = fillBody(null, pane, vp);
const out = try arena.alloc(u8, len);
const filled = fillBody(out, pane, vp);
std.debug.assert(filled == out.len);
return out;
}
/// 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 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 written: usize = 0;
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) {
if (dst) |out| out[written] = '\n';
written += 1;
}
if (dst) |out| @memcpy(out[written..][0..ln.len], ln);
written += ln.len;
n += 1;
}
skip = 0;
g += o.rows;
continue;
};
if (n > 0) {
if (dst) |out| out[written] = '\n';
written += 1;
}
const vi = g - goff;
if (vi >= 0 and @as(usize, @intCast(vi)) < viewport.len) {
const line = viewport[@intCast(vi)];
if (dst) |out| @memcpy(out[written..][0..line.len], line);
written += line.len;
}
n += 1;
g += 1;
}
return written;
}
/// 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);
const body_y = if (p.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;
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, body_y + 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 .{};
}
|