summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig193
1 files changed, 140 insertions, 53 deletions
diff --git a/src/pardes.zig b/src/pardes.zig
index c5eda6aa..91811cc9 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -22,7 +22,6 @@
const std = @import("std");
pub const animation = @import("animation.zig");
pub const panel_animation = @import("panel_animation.zig");
-const ghostty_vt = @import("ghostty-vt");
const uucode = @import("uucode");
const vaxis = @import("vaxis");
const mvzr = @import("mvzr");
@@ -63,7 +62,10 @@ pub const fallback_dump_path = host_mod.fallback_dump_path;
/// unless -Dtracy names a Tracy checkout.
pub const frameMark = tracy.frameMark;
-pub const Platform = enum { tty, gui, web, macos };
+/// `p4` is ESP32-P4 firmware: a riscv32-freestanding core whose whole host is
+/// a serial line. It joins `web` in having no filesystem, no ptys and no
+/// config directory, which is what `hosted` below is for.
+pub const Platform = enum { tty, gui, web, macos, p4 };
pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config").platform));
/// A build with no host but its display: the embedded source filesystem, the
@@ -78,6 +80,29 @@ pub const isolated = @import("pardes_isolation").isolated;
/// disabled so much as meaningless — see builtins.zig.
pub const font_picker = platform == .gui or platform == .macos;
+/// Platforms whose host is a real operating system: a filesystem to open, a
+/// pty to fork, a config directory to watch. The browser and the P4 firmware
+/// have none of the three, and every gate that used to read `platform != .web`
+/// reads this instead so a third such platform cannot forget one of them.
+pub const hosted = platform == .tty or platform == .gui or platform == .macos;
+
+/// Builds that HAVE terminal panes: a pane whose content is a live ghostty-vt
+/// emulator being fed pty bytes. The P4 firmware has no processes, no ptys and
+/// nothing that could produce a VT byte, so there the emulator is ~400 KiB of
+/// flash and a PageList of RAM spent parsing input that cannot arrive — and it
+/// drags a pile of freestanding root hooks in behind it (os.PATH_MAX,
+/// os.heap.page_allocator, a cwd handle), none of which the core itself wants.
+/// False means ghostty-vt is not in the module graph at all: build.zig never
+/// even asks for the dependency.
+///
+/// A PLATFORM gate and deliberately NOT one derived from the target: `web` is
+/// freestanding too and KEEPS the emulator, because the browser shell renders
+/// terminal panes back out of a replayed dump. Every gate in the core keys off
+/// this one name, and src/term_pane.zig re-exports it as `enabled` and owns
+/// the whole seam — the two Pane slots included — so ghostty-vt ends up
+/// imported by exactly one file.
+pub const terminal_panes = platform != .p4;
+
/// ...and the one fact about that face the core keeps: the name `Font` last
/// resolved, which the Debug overlay prints. Behind the same comptime shim
/// builtins.zig and macos.zig import this file with, so a tty or web binary
@@ -104,7 +129,9 @@ pub const pdf_raster_policy: PdfRasterPolicy = switch (platform) {
// Both pixel shells rasterize for a real display and can afford it; the
// wire-bandwidth argument that shapes the Kitty policy does not apply.
.gui, .macos => sdl_pdf_raster_policy,
- .web => kitty_pdf_raster_policy,
+ // Neither hostless platform rasterizes a PDF at all (mupdf is compiled
+ // out), so the cheaper policy is the honest placeholder.
+ .web, .p4 => kitty_pdf_raster_policy,
};
// The capacities and the two heights that are STRUCTURE, not taste: the
@@ -2245,7 +2272,37 @@ fn mix(a: [3]u8, b: [3]u8) [3]u8 {
const TAG_TAIL_CAP = dump.max_tag_tail; // one editable command line; extra input is refused
const TTY_REPLAY_CAP = 1024 * 1024; // oldest bytes are evicted from the dump/replay record
-const EFFECT_CAP = 4096; // one update may queue 256 KiB of ordered 64-byte writes
+
+/// How many effects the ring holds. SHRUNK, not moved to the heap, on the
+/// board: `pump` drains this to empty on every iteration with an
+/// unconditional `while (nextEffect())` — including effects `perform` itself
+/// queues — so no capacity can deadlock the drain, and the only question a
+/// capacity answers is how big a single-pump BURST may be before `emit`
+/// refuses the overflow. The one producer that can burst is `emitWrite`,
+/// which chunks arbitrary bytes into 64-byte `.write` effects for a pty, and
+/// a build with `terminal_panes == false` has no pty to write to. Everything
+/// else queues O(1) effects per event, and `in_q` holds at most 64 events per
+/// pump, so 128 leaves two effects per queued event.
+///
+/// A 1.0625 MiB inline ring cannot live in the board's 384 KiB heap at all;
+/// 128 entries is 34 KiB. NOTE THE BEHAVIOUR CHANGE: `emit` has always
+/// refused (not evicted) once full, so on p4 a burst larger than 128 effects
+/// now drops its tail where 4096 would have held it — reachable only through
+/// `emitWrite`, i.e. only if a pty ever appears on this platform.
+const EFFECT_CAP = if (platform == .p4) 128 else 4096;
+
+/// Rows the per-pane soft-wrap map covers. `wrapWidth` refuses to wrap a pane
+/// taller than this (it reads the array's own length), so shrinking it cannot
+/// truncate a map — a taller pane renders unwrapped, exactly as documented on
+/// `Pane.wrap_line`. A serial console is not 128 rows tall.
+const WRAP_ROWS = if (platform == .p4) 128 else 256;
+
+/// A shell's reported working directory, owned inline by the pane. Zero-sized
+/// where there are no processes to report one: the `PdfSlot` rule, applied to
+/// a capacity whose sole producer (`Pardes.setCwd`, fed by a pty's prompt
+/// report) does not exist without terminal panes. `setOwnedCwd` clamps, so a
+/// zero cap reads as "no directory known" — which is the truth here.
+const CWD_BUF_CAP = if (terminal_panes) 1024 else 0;
/// Everything a theme repaints. `bg`/`fg` null = leave the host terminal's own
/// default cell showing (the native-dark shape); `palette` null = let a child's
@@ -3635,6 +3692,19 @@ pub const SelRange = struct {
const PdfSlot = if (pdf_enabled) ?pdf_pane.State else void;
+/// The emulator's raw-byte dump/replay ring, and NOTHING where there is no pty
+/// to read bytes from — same shape as `PdfSlot`, for a much harder reason. It
+/// is a MEGABYTE inline in every Pane: on the P4 the whole heap is 384 KiB, so
+/// carrying it would make `gpa.create(Pane)` fail before anything could ask
+/// for a grid, and `Pardes.init` — which creates a pane unconditionally —
+/// could not return.
+const ReplaySlot = if (terminal_panes) [TTY_REPLAY_CAP]u8 else void;
+
+/// The staging buffer for the query replies ghostty computes. Its only writer
+/// is term_pane's `ptyReport` callback, which does not exist without an
+/// emulator, so `reply_len` there is permanently 0 and `sync` never reads it.
+const ReplySlot = if (terminal_panes) [256]u8 else void;
+
fn hasPdf(pane: *const Pane) bool {
return if (comptime pdf_enabled) pane.pdf != null else false;
}
@@ -3658,8 +3728,8 @@ pub const Pane = struct {
/// A pane's working directory. `.inherited` is a live `*Pane` link kept
/// valid by deferred teardown (see PaneAllocator) + reapPanes' fixup.
pub const Cwd = union(enum) { none, inherited: *Pane, owned: []const u8 };
- vt: ghostty_vt.Terminal,
- stream: ghostty_vt.TerminalStream,
+ vt: term_pane.VtSlot,
+ stream: term_pane.StreamSlot,
/// The same allocator Pardes holds. A pane already owns heap (its content,
/// its emulator, its undo stacks) and Pardes frees all of it; this is here
/// so the pane methods that need the file's LINE INDEX — scrollBy and
@@ -3767,7 +3837,7 @@ pub const Pane = struct {
/// link to the pane it was opened from (.inherited), or unknown (.none).
/// The inherited pointer is kept valid by deferred pane teardown + fixup.
cwd: Cwd = .none,
- cwd_buf: [1024]u8 = undefined,
+ cwd_buf: [CWD_BUF_CAP]u8 = undefined,
/// modal cursor, at ABSOLUTE body rows of the pane's SURFACE (file lines,
/// or the terminal's shell rows with its edit buffer standing in). Tracks
/// the shell cursor until pinned by a click or a key.
@@ -3795,20 +3865,22 @@ pub const Pane = struct {
/// renderPane call. Nothing else may write it; a second writer is a second
/// truth, and the first click on a stale row is how you find out.
///
- /// ponytail: a fixed 256 rows. A pane taller than that does not wrap at
- /// all — bodyText leaves wrap_n at 0 and clips the way it always did —
- /// rather than half-recording a mapping every site here would then have to
- /// distrust. Grow the arrays the day a 256-row window turns up.
- wrap_line: [256]i32 = undefined,
- wrap_col: [256]i32 = undefined,
+ /// ponytail: a fixed `WRAP_ROWS` rows (256; 128 on the board). A pane
+ /// taller than that does not wrap at all — bodyText leaves wrap_n at 0 and
+ /// clips the way it always did — rather than half-recording a mapping
+ /// every site here would then have to distrust. `wrapWidth` derives that
+ /// refusal from `wrap_line.len` itself, so the bound follows the array.
+ /// Grow the arrays the day a taller window turns up.
+ wrap_line: [WRAP_ROWS]i32 = undefined,
+ wrap_col: [WRAP_ROWS]i32 = undefined,
wrap_n: u16 = 0,
sel: [3]Sel = @splat(.{}),
/// terminals only: the typed-text buffer standing in for shell rows
ovl: ?term_pane.EditBuffer = null,
/// every raw pty byte, in order — a bounded dump/replay ring. Once full,
/// new output evicts the oldest bytes while the live terminal still sees
- /// every byte.
- tty_stream: [TTY_REPLAY_CAP]u8 = undefined,
+ /// every byte. A megabyte, inline: see `ReplaySlot`.
+ tty_stream: ReplaySlot = if (terminal_panes) undefined else {},
tty_stream_head: usize = 0,
tty_stream_len: usize = 0,
/// Terminal-only, pane-local presentation mode. Ghostty remains the owner
@@ -3818,7 +3890,7 @@ pub const Pane = struct {
/// query replies ghostty computed (DSR, DA, kitty); the stream handler has
/// no path to the effect queue, so they land here and sync() drains them
/// into write effects. Bounded: replies are tiny escape sequences.
- reply: [256]u8 = undefined,
+ reply: ReplySlot = if (terminal_panes) undefined else {},
reply_len: u16 = 0,
/// THE TRANSIENT MESSAGE: what just happened to this pane, drawn on its
/// LAST row until the next key or mouse event wipes it (see update). Fixed
@@ -3948,7 +4020,7 @@ pub const Pane = struct {
pub fn scroll(pane: *Pane) i32 {
if (pane.file) |f| return @intCast(f.scroll);
if (comptime pdf_enabled) if (pane.pdf) |pv| return @intCast(pv.text_scroll);
- return pane.surfRow(@intCast(pane.vt.screens.active.pages.scrollbar().offset));
+ return pane.surfRow(term_pane.gridOffset(pane));
}
/// The document position a BODY ROW begins at — `vr` 0 is the first row
@@ -4012,8 +4084,8 @@ pub const Pane = struct {
}
} else {
// the vt scrolls in SHELL rows; convert through the edit buffer
- const off: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset);
- pane.vt.screens.active.scroll(.{ .delta_row = pane.gridRow(pane.surfRow(off) + delta) - off });
+ const off = term_pane.gridOffset(pane);
+ term_pane.scrollGrid(pane, pane.gridRow(pane.surfRow(off) + delta) - off);
}
}
@@ -4088,9 +4160,9 @@ pub const Pane = struct {
pane.cur_row = pane.scroll();
pane.cur_col = 0;
} else {
- const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset);
- pane.cur_row = pane.surfRow(@as(i32, @intCast(pane.vt.screens.active.cursor.y)) + goff);
- pane.cur_col = @intCast(pane.vt.screens.active.cursor.x);
+ const cur = term_pane.gridCursor(pane);
+ pane.cur_row = pane.surfRow(@as(i32, cur.y) + term_pane.gridOffset(pane));
+ pane.cur_col = @intCast(cur.x);
}
pane.cur_pinned = true;
}
@@ -5442,7 +5514,11 @@ pub const Pardes = struct {
/// request stale as soon as another ThemeFile/Theme/NextColor command wins.
custom_theme: ?Theme = null,
custom_theme_active: bool = false,
- theme_file_path: runtime_cfg.Text(4095) = .{},
+ /// Sized by `runtime_cfg.host_path_cap`, which is 0 where the platform has
+ /// no filesystem to hold a theme file: `set` then refuses every non-empty
+ /// path and `themeFileRequest` answers `PathTooLong`, which is the honest
+ /// answer on a board whose only IO is a UART.
+ theme_file_path: runtime_cfg.Text(runtime_cfg.host_path_cap) = .{},
theme_file_generation: u32 = 0,
theme_file_pane: u8 = 0,
chrome_animation: ChromeAnimation = ChromeAnimation.init(initial_chrome),
@@ -5630,6 +5706,27 @@ pub const Pardes = struct {
p.ncol = 1;
p.col_n[0] = 1;
p.col_terms[0][0] = 0;
+ } else if (comptime platform == .p4) {
+ // BARE METAL BOOTS AN EMPTY OUTPUT BUFFER, and a shell is not a layout preference
+ // here but an impossibility: there is no operating system under this, so there is
+ // nothing to fork and no pty to give a terminal pane. Booting one anyway produced
+ // exactly what that describes - a pane whose tag ends in `Filter`, whose pty is the
+ // Fallback's silent one, with no gutter, no buffer, and no key that reaches anything.
+ // Measured on an ESP32-P4 over the serial line: every keystroke vanished.
+ //
+ // An output buffer is the right default rather than a file pane, and not only because
+ // `opts.file` cannot work here (the P4's embedded allowlist is empty by design - see
+ // source_manifest.zig - so `look.readFile` has nothing to resolve a path against). It
+ // is what the platform's own words WANT: `Peek`, `Poke` and `Hexdump` each fill an
+ // output buffer, so booting into one means the first dump lands in the same kind of
+ // pane the boot pane already is. It is editable text with no file behind it, which is
+ // the honest description of a buffer on a board with no filesystem.
+ const content = try p.gpa.dupe(u8, "");
+ errdefer p.gpa.free(content);
+ _ = try output_pane.open(p, 0, "", .{ .cmd = .New }, "", content);
+ p.ncol = 1;
+ p.col_n[0] = 1;
+ p.col_terms[0][0] = 0;
} else if (opts.tty_only) {
_ = try p.newShell(0, "");
p.panes[0].?.mode = .tty;
@@ -5760,8 +5857,7 @@ pub const Pardes = struct {
if (pane.ovl) |o| p.gpa.free(o.text);
for (pane.ed_undo[0..pane.ed_undo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.text);
for (pane.ed_redo[0..pane.ed_redo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.text);
- pane.stream.deinit();
- pane.vt.deinit(p.gpa);
+ term_pane.deinitEmulator(pane, p.gpa);
p.gpa.destroy(pane);
}
@@ -5814,20 +5910,11 @@ pub const Pardes = struct {
return pane;
}
- /// a doc pane (file/image): no pty, no spawn; a stub 1x1 emulator only
- /// because the shared machinery touches its allocator-owned bits.
+ /// a doc pane (file/image/PDF): no pty and no spawn. Whatever emulator half
+ /// it still needs is term_pane's business — see `createDoc` there.
pub fn newDocPane(p: *Pardes, id: usize) !*Pane {
std.debug.assert(p.panes[id] == null);
- const pane = try p.gpa.create(Pane);
- errdefer p.gpa.destroy(pane);
- pane.* = .{
- .vt = try ghostty_vt.Terminal.init(term_pane.terminalIo(), p.gpa, .{ .cols = 1, .rows = 1 }),
- .stream = undefined,
- .gpa = p.gpa,
- .cols = p.screen_w,
- .rows = p.screen_h,
- };
- pane.stream = pane.vt.vtStream();
+ const pane = try term_pane.createDoc(p.gpa, p.screen_w, p.screen_h);
p.installPane(id, pane);
return pane;
}
@@ -6052,7 +6139,7 @@ pub const Pardes = struct {
/// core. Relative paths belong to the per-user config directory; absolute
/// paths remain useful for trying a file elsewhere.
pub fn requestThemeFile(p: *Pardes, id: usize, argument: []const u8) void {
- if (comptime platform == .web) return;
+ if (comptime !hosted) return;
const input = std.mem.trim(u8, argument, " \t\r\n");
if (input.len == 0 or !std.mem.endsWith(u8, input, ".zon")) {
p.reportError(id, "theme file", error.InvalidThemePath);
@@ -6329,7 +6416,7 @@ pub const Pardes = struct {
if (pane.serial != st.serial) return; // a recycled slot: not ours
if (pane.file) |f| return p.hostWriteFile(st.pane, st.path.slice(), f.content);
if (!pane.isTerminal()) return;
- const text = pane.vt.screens.active.dumpStringAlloc(p.gpa, .{ .screen = .{} }) catch return;
+ const text = term_pane.screenTextAlloc(pane, p.gpa) catch return;
defer p.gpa.free(text);
p.hostWriteFile(st.pane, st.path.slice(), text);
},
@@ -6655,7 +6742,7 @@ pub const Pardes = struct {
/// multi-line paste would run every line but the last.
pub fn typeToTty(p: *Pardes, id: usize, pane: *const Pane, text: []const u8) void {
if (text.len == 0) return;
- if (pane.vt.modes.get(.bracketed_paste)) {
+ if (term_pane.bracketedPaste(pane)) {
p.emitWrite(id, "\x1b[200~");
p.emitWrite(id, text);
p.emitWrite(id, "\x1b[201~");
@@ -12660,15 +12747,14 @@ pub const Pardes = struct {
if (cut) return;
const y = p.yank orelse return;
if (y.len == 0) return;
- const m = &pane.vt.modes;
- if (m.get(.mouse_event_normal) or m.get(.mouse_event_button) or m.get(.mouse_event_any)) {
+ if (term_pane.reportsMouse(pane)) {
// dragUpdate keeps sel[sel_slot] tracking the held select button, so
// the click lands where the mouse is at this tap; 1-based,
// body-relative (the tag row is ours, not the app's)
const col: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].c1 + 1, 1, 9999));
const row: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].r1 - @as(i32, BOX_H) + 1, 1, 9999));
var mb: [32]u8 = undefined;
- if (m.get(.mouse_format_sgr)) {
+ if (term_pane.mouseFormatSgr(pane)) {
p.emitWrite(s.id, std.fmt.bufPrint(&mb, "\x1b[<0;{d};{d}M\x1b[<0;{d};{d}m", .{ col, row, col, row }) catch return);
} else {
// ponytail: legacy X10 bytes; add utf8/urxvt formats if an app ever wants them
@@ -12926,7 +13012,7 @@ pub const Pardes = struct {
const src = p.panes[src_id] orelse return;
const src_h = p.rects[src_id].h;
const body: u16 = if (src_h > BOX_H) src_h - BOX_H else 1;
- const cur: u16 = if (!src.isTerminal()) body / 2 else @as(u16, @intCast(src.vt.screens.active.cursor.y)) + 1;
+ const cur: u16 = if (!src.isTerminal()) body / 2 else term_pane.gridCursor(src).y + 1;
// cap keep so a content-full source still leaves the new pane a tag +
// a few body rows (an Alt-n from a full shell was born 0 rows tall)
const keep = std.math.clamp(cur, 1, @max(1, body -| (BOX_H + 3)));
@@ -12968,8 +13054,8 @@ pub const Pardes = struct {
const tt = p.panes[tty_id] orelse return;
// No typing, cursor on the first prompt line, no scrollback: this is
// the throwaway boot placeholder a document may replace.
- if (tt.ovl != null or tt.vt.screens.active.cursor.y != 0 or
- tt.vt.screens.active.pages.scrollbar().total > tt.rows) return;
+ if (tt.ovl != null or term_pane.gridCursor(tt).y != 0 or
+ term_pane.scrollbar(tt).total > tt.rows) return;
p.computeGeom(); // a just-stacked doc has no rect yet; absorb snaps to rows
p.absorbVWeight(tty_id);
p.layoutRemove(tty_id);
@@ -14013,7 +14099,7 @@ pub const Pardes = struct {
p.trackJump();
for (&p.panes, 0..) |*slot, id| {
const pane = slot.* orelse continue;
- if (pane.reply_len > 0) {
+ if (comptime terminal_panes) if (pane.reply_len > 0) {
var off: u16 = 0;
while (off < pane.reply_len) {
const n = @min(pane.reply_len - off, 64);
@@ -14021,7 +14107,7 @@ pub const Pardes = struct {
off += n;
}
pane.reply_len = 0;
- }
+ };
const r = p.rects[id];
const cols = @max(1, r.w -| config.GUTTER);
const rows = @max(1, r.h -| BOX_H); // the tag steals the top row
@@ -14032,7 +14118,7 @@ pub const Pardes = struct {
// doc panes have no pty/emulator grid to reflow; just record
// the size so bodyText renders the right number of rows
if (pane.isTerminal()) {
- pane.vt.resize(p.gpa, .{ .cols = cols, .rows = rows }) catch {};
+ term_pane.resizeGrid(pane, p.gpa, cols, rows);
p.shell_rows.markStale(pane); // reflow moved every row
p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } });
}
@@ -14659,7 +14745,7 @@ pub const Pardes = struct {
sb_off = page;
sb_total = if (comptime pdf_enabled) at.pdf.?.page_count else 0;
} else {
- const sb = at.vt.screens.active.pages.scrollbar();
+ const sb = term_pane.scrollbar(at);
sb_off = sb.offset;
sb_total = sb.total;
}
@@ -15294,9 +15380,9 @@ pub const Pardes = struct {
// cursor: tracks the shell cursor until pinned by a click or a key
// (the tag cursor above wins while the tag is focused)
if (active and !pane.tag_edit) {
- const cur = pane.vt.screens.active.cursor;
+ const cur = term_pane.gridCursor(pane);
if (pane.mode != .tty) {
- const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset);
+ const goff = term_pane.gridOffset(pane);
const crow = if (pane.cur_pinned) pane.cur_row else pane.surfRow(@as(i32, @intCast(cur.y)) + goff);
const ccol = if (pane.cur_pinned) pane.cur_col else @as(i32, @intCast(cur.x));
// which ROW of a wrapped line the cursor is on, and which byte
@@ -15354,7 +15440,7 @@ pub const Pardes = struct {
.offset = page,
.len = 1,
} else blk: {
- const gsb = pane.vt.screens.active.pages.scrollbar();
+ const gsb = term_pane.scrollbar(pane);
break :blk .{ .total = gsb.total, .offset = gsb.offset, .len = gsb.len };
};
const track_h: usize = r.h - BOX_H;
@@ -15830,3 +15916,4 @@ test "entering tty walks the shell cursor to the column clicked past the prompt"
try std.testing.expectEqual(@as(usize, 0), rights);
try std.testing.expectEqual(@as(usize, 7), lefts);
}
+