diff options
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 193 |
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); } + |
