summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--build.zig102
-rw-r--r--src/acmefs.zig5
-rw-r--r--src/allocators.zig33
-rw-r--r--src/board_memory.zig323
-rw-r--r--src/builtins.zig101
-rw-r--r--src/config.zig14
-rw-r--r--src/dump.zig11
-rw-r--r--src/effect_sources.zig4
-rw-r--r--src/file_pane.zig12
-rw-r--r--src/image.zig50
-rw-r--r--src/look.zig5
-rw-r--r--src/main.zig9
-rw-r--r--src/output_pane_integration_test.zig4
-rw-r--r--src/p4.zig578
-rw-r--r--src/pardes.zig193
-rw-r--r--src/runtime_config.zig26
-rw-r--r--src/source_manifest.zig13
-rw-r--r--src/term_pane.zig263
18 files changed, 1617 insertions, 129 deletions
diff --git a/build.zig b/build.zig
index 061d1f2c..01fed2b6 100644
--- a/build.zig
+++ b/build.zig
@@ -4,7 +4,10 @@ const mupdf_build = @import("mupdf.zig");
const snap_build = @import("build/snap.zig");
const grammar_manifest = @import("src/grammar_manifest.zig");
-pub const Platform = enum { tty, gui, web, macos };
+/// `p4` is not a shell in this package at all: it is a MODULE (`pardes_p4`)
+/// compiled for riscv32-freestanding, which the zig-p4 firmware package
+/// imports and gives a serial host. See the p4 branch below.
+pub const Platform = enum { tty, gui, web, macos, p4 };
/// The oldest macOS pardes.app claims to run on, spelled ONCE. Three things
/// have to agree about it or the bundle is a lie: the target this build gives
@@ -39,7 +42,7 @@ const gui_shaders = [_][]const u8{
/// committed SPIR-V, so the builtin cannot print code other than what produced
/// the bytecode that build executes.
pub fn build(b: *std.Build) void {
- const platform = b.option(Platform, "platform", "which shell to build (tty, gui, web, macos)") orelse .tty;
+ const platform = b.option(Platform, "platform", "which shell to build (tty, gui, web, macos, p4)") orelse .tty;
// Default target is the Steam Deck (deckcap's trick): x86_64 linux-gnu
// with the glibc version pinned low, so a binary built on a rolling-
// release host runs on SteamOS — a native build references the host's
@@ -60,12 +63,30 @@ pub fn build(b: *std.Build) void {
// Anywhere else it stays plain native, which is the whole point of the
// Linux dev loop: `zig build unit-test -Dplatform=macos` has to produce a
// binary that machine can actually execute.
- const target = b.standardTargetOptions(.{ .default_target = switch (platform) {
+ const p4_target: std.Target.Query = .{
+ .cpu_arch = .riscv32,
+ .os_tag = .freestanding,
+ .abi = .none,
+ .cpu_model = .{ .explicit = &std.Target.riscv.cpu.generic_rv32 },
+ .cpu_features_add = riscvFeatures(&.{ .m, .a, .f, .c, .zicsr, .zifencei }),
+ };
+ const requested_target = b.standardTargetOptions(.{ .default_target = switch (platform) {
.macos => if (builtin.os.tag.isDarwin()) .{
.cpu_arch = builtin.target.cpu.arch,
.os_tag = .macos,
.os_version_min = .{ .semver = macos_min_version },
} else .{},
+ // The P4 firmware target, spelled out here so `-Dplatform=p4` alone is a
+ // working command line. The CPU FEATURES are part of that spelling and
+ // are not optional: the object this build emits is linked into an image
+ // whose other halves are compiled `generic_rv32+m+a+f+c+zicsr+zifencei`,
+ // and `f` decides the float ABI. Leaving the model implicit produced a
+ // soft-float object and `ld.lld: cannot link object files with different
+ // floating-point ABI` — at LINK time in the other repo, far from here.
+ // Espressif's GCC adds the vendor extensions xesploop/xespv2p1 on top;
+ // upstream LLVM has neither and ordinary code never emits them, so this
+ // matches the base ISA the firmware uses exactly.
+ .p4 => p4_target,
.tty, .gui, .web => .{
.cpu_arch = .x86_64,
.os_tag = .linux,
@@ -73,6 +94,14 @@ pub fn build(b: *std.Build) void {
.glibc_version = .{ .major = 2, .minor = 38, .patch = 0 },
},
} });
+ // `standardTargetOptions` honours `default_target` ONLY when `-Dtarget` is absent, so the
+ // documented `-Dplatform=p4 -Dtarget=riscv32-freestanding` discarded the CPU features above and
+ // silently produced a soft-float object. The features are not a preference here - `f` decides
+ // the float ABI, and the object is linked into an image whose other halves have it - so p4 takes
+ // the pinned query whatever was asked for. `-Dtarget` stays accepted, and the check further down
+ // still rejects anything that is not riscv32-freestanding, so a wrong `-Dtarget` is an error
+ // rather than something quietly ignored.
+ const target = if (platform == .p4) b.resolveTargetQuery(p4_target) else requested_target;
const requested_optimize = b.standardOptimizeOption(.{});
const static = b.option(bool, "static", "statically link") orelse false;
const dump_path = b.option([]const u8, "dump", "dump .zon embedded into the web shell (-Dplatform=web)");
@@ -83,7 +112,13 @@ pub fn build(b: *std.Build) void {
else
&.{};
const is_web = platform == .web;
- const enable_mupdf = b.option(bool, "mupdf", "native PDF rendering with MuPDF (AGPL/commercial; native default on, web off; -Dmupdf=false disables)") orelse !is_web;
+ const is_p4 = platform == .p4;
+ // Platforms with no host libc: the browser and the P4 firmware. Every
+ // dependency below that exists only because a target links libc — the
+ // image decoder, ZLS, ghostty's C++ simd, MuPDF — is off for both, and the
+ // reason is freestanding-ness rather than the browser.
+ const freestanding_core = is_web or is_p4;
+ const enable_mupdf = b.option(bool, "mupdf", "native PDF rendering with MuPDF (AGPL/commercial; native default on, web/p4 off; -Dmupdf=false disables)") orelse !freestanding_core;
// JPEG 2000, and with it scanned PDFs: a scan is one /JPXDecode image per
// page, so without this MuPDF decodes nothing and every page comes back
// blank. On by default — a viewer that cannot open scans is the more
@@ -92,6 +127,7 @@ pub fn build(b: *std.Build) void {
// mupdf.zig.
const enable_jpx = b.option(bool, "jpx", "JPEG 2000 in PDFs, for scanned documents (default on; -Djpx=false drops openjpeg)") orelse true;
const is_web_target = target.result.cpu.arch == .wasm32 and target.result.os.tag == .freestanding;
+ const is_p4_target = target.result.cpu.arch == .riscv32 and target.result.os.tag == .freestanding;
// wasm: size is the budget
const optimize = if (is_web) .ReleaseSmall else requested_optimize;
// The vendored C is never what we are debugging, and at -O0 it dominates
@@ -104,8 +140,9 @@ pub fn build(b: *std.Build) void {
// The browser keeps its useful default grammar without acquiring a host
// libc contract: Tree-sitter and the generated Zig parser are linked into
// the freestanding module against src/web/libc's tiny in-module shim.
- const default_grammars: TreeSitterGrammars = if (is_web) .zig else .full;
- const tree_sitter_grammars = b.option(TreeSitterGrammars, "tree-sitter", "tree-sitter grammar set: disabled, zig, minimal (c/c++/zig), full") orelse default_grammars;
+ const default_grammars: TreeSitterGrammars = if (is_web) .zig else if (is_p4) .disabled else .full;
+ const requested_grammars = b.option(TreeSitterGrammars, "tree-sitter", "tree-sitter grammar set: disabled, zig, minimal (c/c++/zig), full");
+ const tree_sitter_grammars = requested_grammars orelse default_grammars;
const tracy = b.option([]const u8, "tracy", "enable Tracy profiling; supply the path to a Tracy source checkout");
// Who signs pardes.app. Ad-hoc ("-") is what makes a bundle launchable on
// the machine that built it and needs no keychain; a Developer ID here is
@@ -166,6 +203,9 @@ pub fn build(b: *std.Build) void {
if (!is_web and (is_web_target or target.result.os.tag == .emscripten)) return failBuild(b, web_step, "wasm browser targets require -Dplatform=web");
if (platform == .web and dump_path == null) return failBuild(b, web_step, "-Dplatform=web requires -Ddump=<dump.zon> (the browser has no ptys; state replays from an embedded dump)");
if (enable_mupdf and is_web) return failBuild(b, web_step, "-Dmupdf=true is supported only by the native tty/Kitty and gui/SDL backends");
+ if (is_p4 and !is_p4_target) return failBuild(b, web_step, "-Dplatform=p4 requires -Dtarget=riscv32-freestanding (ESP32-P4 firmware)");
+ if (enable_mupdf and is_p4) return failBuild(b, web_step, "-Dmupdf=true is supported only by the native tty/Kitty and gui/SDL backends");
+ if (is_p4 and requested_grammars != null and requested_grammars.? != .disabled) return failBuild(b, web_step, "-Dplatform=p4 has no tree-sitter: the grammars' parse tables are megabytes and the flash partition is 1.5 MiB (-Dtree-sitter=disabled)");
// build-time IO: slurps the grammars' highlights.scm queries, and reads
// vendor/themes to find the theme sources
@@ -181,9 +221,13 @@ pub fn build(b: *std.Build) void {
.root_source_file = b.path(switch (platform) {
.web => "src/web.zig",
.macos => "src/macos.zig",
+ // p4 roots at its own flat C ABI too, for the same reason web and
+ // macOS do: the firmware's `_start`, its linker script and its UART
+ // live in the zig-p4 package, which LINKS the object this emits.
+ .p4 => "src/p4.zig",
.tty, .gui => "src/main.zig",
}),
- .link_libc = !is_web,
+ .link_libc = !freestanding_core,
});
// helix differential harness (test/hxdiff.zig): a second compilation of
// the core, driven headlessly. Mirrors root_mod's wiring for
@@ -276,7 +320,7 @@ pub fn build(b: *std.Build) void {
// the web shell does not, because it has no threads and no-ops the
// lsp effect anyway, so paying to compile an analyser it can never call
// would be pure wasm.
- const zls_backend = !is_web;
+ const zls_backend = !freestanding_core;
const opts = b.addOptions();
opts.addOption(Platform, "platform", platform);
@@ -417,7 +461,7 @@ pub fn build(b: *std.Build) void {
// zstbi (C stb_image): image pane decode. The freestanding core has no C
// allocator ABI, so its module is a compatible no-decode shim; browser IO
// can grow native image decoding independently of the core.
- const zstbi_dep = if (!is_web) b.dependency("zstbi", .{ .target = target, .optimize = c_optimize }) else null;
+ const zstbi_dep = if (!freestanding_core) b.dependency("zstbi", .{ .target = target, .optimize = c_optimize }) else null;
const zstbi_mod = if (zstbi_dep) |dep| dep.module("root") else b.createModule(.{
.target = target,
.optimize = optimize,
@@ -599,7 +643,7 @@ pub fn build(b: *std.Build) void {
// without it); gate that block on `b.graph.host.result.os.tag == .linux`.
// Ghostty's pinned translate-c tarball also 404s now (codeberg dropped
// the archive endpoint) — seed zig-pkg/ from a machine that has it.
- const ghostty_simd = !is_web and (!target.result.os.tag.isDarwin() or b.graph.host.result.os.tag.isDarwin());
+ const ghostty_simd = !freestanding_core and (!target.result.os.tag.isDarwin() or b.graph.host.result.os.tag.isDarwin());
// app-runtime none + emit-xcframework off: only the ghostty-vt module is
// consumed, and the defaults otherwise drag ghostty's app graph into the
// build — gtk4 header translation via host pkg-config for linux targets,
@@ -614,7 +658,13 @@ pub fn build(b: *std.Build) void {
// own safety checks come from OUR optimize mode and are unaffected, since
// ghostty-vt is a module compiled into this binary. See reflow.snap.
const ghostty_optimize: std.builtin.OptimizeMode = if (optimize == .Debug) .ReleaseSafe else optimize;
- const ghostty_dep = b.lazyDependency("ghostty", .{ .target = target, .optimize = ghostty_optimize, .simd = ghostty_simd, .@"app-runtime" = .none, .@"emit-xcframework" = false });
+ // The P4 firmware has no terminal panes at all (see `terminal_panes` in
+ // src/pardes.zig), so src/term_pane.zig's `@import("ghostty-vt")` sits in a
+ // dead comptime branch and `wireCore` below is handed a null. The
+ // dependency is therefore not merely unused, it is never REQUESTED: this is
+ // a lazyDependency, so a p4 build does not need the ghostty package (nor
+ // its translate-c tarball, nor its simd C++) present at all.
+ const ghostty_dep = if (is_p4) null else b.lazyDependency("ghostty", .{ .target = target, .optimize = ghostty_optimize, .simd = ghostty_simd, .@"app-runtime" = .none, .@"emit-xcframework" = false });
if (ghostty_dep) |dep| {
const ghostty_vt = dep.module("ghostty-vt");
ghostty_vt_for_snap = ghostty_vt;
@@ -794,6 +844,28 @@ pub fn build(b: *std.Build) void {
run_web_harness.step.dependOn(web_step);
b.step("web-harness", "run the dependency-free JS/WASM DOM harness").dependOn(&run_web_harness.step);
b.getInstallStep().dependOn(web_step);
+ } else if (is_p4) {
+ // ONE freestanding object, exporting the C ABI in src/p4.zig. Not an executable, because
+ // the firmware's `_start`, its generated linker script and its UART driver all live in the
+ // zig-p4 package; not a library, because `addLibrary` bundles compiler_rt and the firmware
+ // already has its own; and not a MODULE exposed through build.zig.zon, which is what this
+ // was first and is the interesting part.
+ //
+ // A path dependency was tried and reverted. Nesting this package's ~30-package graph under
+ // zig-p4's broke every build in that repo, not just the firmware one: `std/Build.zig:2091`
+ // exceeded its 1000-branch comptime quota through ghostty's `SharedDeps.zig:874`
+ // `lazyImport`, seven cached tree-sitter versions failed to compile because their build.zig
+ // uses APIs removed in 0.16, and the fetch materialised 2.6 GB across 42,736 files into a
+ // repo whose entire claim is that Zig is its only dependency. An object has none of that,
+ // and the seam it leaves is bytes rather than types, which is the right seam for a serial
+ // line anyway.
+ //
+ // The object is also the compile probe: rooted at src/p4.zig it drags the whole core through
+ // the riscv32 backend by actually calling it, so `llvm-size` on the result is a real number
+ // to hold against the board's 1.5 MiB factory partition.
+ const obj = b.addObject(.{ .name = "pardes-p4", .root_module = root_mod });
+ b.getInstallStep().dependOn(&b.addInstallFile(obj.getEmittedBin(), "pardes-p4.o").step);
+ web_step.dependOn(&b.addFail("web needs -Dplatform=web -Dtarget=wasm32-freestanding -Ddump=<dump.zon>").step);
} else if (platform == .macos) {
// The native macOS shell is a static library plus a Swift app: Zig
// keeps the core, the ptys and every effect; Swift owns AppKit and
@@ -1435,3 +1507,11 @@ fn wireCore(m: *std.Build.Module, d: struct {
if (d.vaxis) |x| m.addImport("vaxis", x);
if (d.uucode) |x| m.addImport("uucode", x);
}
+
+/// The CPU feature set for the ESP32-P4 firmware target, as a `Cpu.Feature.Set`. Spelled as a
+/// helper because `cpu_features_add` wants a set and there is no literal syntax for one.
+fn riscvFeatures(comptime features: []const std.Target.riscv.Feature) std.Target.Cpu.Feature.Set {
+ var set = std.Target.Cpu.Feature.Set.empty;
+ for (features) |f| set.addFeature(@intFromEnum(f));
+ return set;
+}
diff --git a/src/acmefs.zig b/src/acmefs.zig
index db610f67..35523358 100644
--- a/src/acmefs.zig
+++ b/src/acmefs.zig
@@ -45,6 +45,9 @@ const config = @import("config.zig");
const modal = @import("modal.zig");
const file_pane = @import("file_pane.zig");
const output_pane = @import("output_pane.zig");
+/// only for the scrollback read below: a terminal's `body` is a grid, and
+/// term_pane owns how a grid becomes bytes (and whether there is one at all).
+const term_pane = @import("term_pane.zig");
/// only for `look.readFile`, which is what a `get` verb IS — the same
/// synchronous path-backed read `file_pane.open` does, and the one place this
/// module touches a disk.
@@ -1236,7 +1239,7 @@ fn readBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
// is a terminal emulator, and its "body" is the scrollback — which only
// becomes bytes when somebody renders the pages into lines. So it is
// produced, staged, and paid for per read. `win`'s transcript, read side.
- const text = pane.vt.screens.active.dumpStringAlloc(p.gpa, .{ .screen = .{} }) catch
+ const text = term_pane.screenTextAlloc(pane, p.gpa) catch
return Reply.fail(req.tag, E.NOMEM);
defer p.gpa.free(text);
const out = p.fs.stage(p.gpa);
diff --git a/src/allocators.zig b/src/allocators.zig
index 7a8433e8..cf024a0b 100644
--- a/src/allocators.zig
+++ b/src/allocators.zig
@@ -4,16 +4,39 @@ const config = @import("pardes_config");
const Allocator = std.mem.Allocator;
const debug_enabled = builtin.mode == .Debug;
+/// Three tiers, because the address space differs by four orders of magnitude.
+/// `reduced_target` is the browser: a wasm linear memory it grows on demand, so
+/// the static reservations are megabytes rather than tens.
+///
+/// `p4` is ESP32-P4 firmware, and its tier is deliberately ALL FALLBACK. Every
+/// capacity here is a `StackFallbackAllocator`'s buffer, which is a static and
+/// therefore lands in `.bss` — and on the P4 `.bss`, `.data` and the stack all
+/// share ONE 240 KiB chunk of L2MEM at 0x4FF03000, while the heap the fallback
+/// allocator hands out is the separate 384 KiB chunk at 0x4FF40000 - the 128 KiB
+/// above that measured as L2 cache rather than memory. A
+/// megabyte-shaped reservation here would not fit, and every byte that did fit
+/// would be taken from the stack's neighbourhood to duplicate memory the heap
+/// already has. So the buffers exist only because the type requires one: 4 KiB
+/// absorbs the small churn, and everything else spills to the real heap on the
+/// first allocation.
+const p4 = config.platform == .p4;
const reduced_target = config.platform == .web or builtin.os.tag == .freestanding;
const KiB = 1024;
const MiB = 1024 * KiB;
const capacities = struct {
- const pardes = if (reduced_target) 8 * MiB else 32 * MiB;
- const frame = if (reduced_target) 4 * MiB else 16 * MiB;
- const tree_sitter = if (reduced_target) 4 * MiB else 16 * MiB;
- const image = if (reduced_target) 64 * KiB else 32 * MiB;
- const pdf = if (reduced_target or !config.mupdf) 64 * KiB else 64 * MiB;
+ const pardes = if (p4) 4 * KiB else if (reduced_target) 8 * MiB else 32 * MiB;
+ const frame = if (p4) 4 * KiB else if (reduced_target) 4 * MiB else 16 * MiB;
+ // Zero is legal and always spills, which is exactly what an arena for a
+ // compiled-out subsystem should do. `StackFallbackAllocator(0).buffer` is
+ // `[0]u8`; `get()` inits the FixedBufferAllocator over an empty slice, so
+ // `FixedBufferAllocator.alloc` fails every nonzero request and `alloc`
+ // falls through to `self.fallback_allocator.rawAlloc`, while `ownsPtr` over
+ // an empty range is false for every pointer so `resize`/`remap`/`free`
+ // route to the fallback too. See lib/std/heap.zig, StackFallbackAllocator.
+ const tree_sitter = if (p4) 0 else if (reduced_target) 4 * MiB else 16 * MiB;
+ const image = if (p4) 0 else if (reduced_target) 64 * KiB else 32 * MiB;
+ const pdf = if (p4) 0 else if (reduced_target or !config.mupdf) 64 * KiB else 64 * MiB;
};
pub const Allocators = struct {
diff --git a/src/board_memory.zig b/src/board_memory.zig
new file mode 100644
index 00000000..853ae5da
--- /dev/null
+++ b/src/board_memory.zig
@@ -0,0 +1,323 @@
+//! The board's own address space, as text: the Peek, Poke and Hexdump
+//! builtins' whole implementation.
+//!
+//! BARE METAL ONLY (`enabled` below), and the reason is not caution but
+//! honesty: with no OS there is no MMU, no supervisor and no process — the
+//! editor IS the system software — so every one of the 2^32 addresses is
+//! legitimately this program's to read and write, and a word that could name
+//! only some of them would be lying about where it is running. Under an OS the
+//! same three words would be either a segfault or a syscall stub, so they are
+//! absent from those builds entirely rather than present and refusing.
+//!
+//! Everything here goes through `*allowzero volatile` pointers. A peripheral
+//! register is not memory: reading UART_STATUS twice is two reads and must not
+//! be folded into one, a write to a write-only command register has no
+//! observable value for the optimizer to keep, and address 0 is an ordinary
+//! (unmapped) address on this bus rather than the null Zig assumes it is.
+//!
+//! The formatting side is a plain renderer over `Pardes.gpa`, so it lands in
+//! an output buffer the same way Jumplist and Config do: an output buffer is a
+//! file pane, so every motion, chord and Look works on a dump for free — you
+//! can right-click an address in a hexdump row and Peek it.
+const std = @import("std");
+const builtin = @import("builtin");
+const pardes = @import("pardes.zig");
+const Pardes = pardes.Pardes;
+const output_pane = @import("output_pane.zig");
+
+/// The one gate, and it is derived from the TARGET rather than from
+/// `pardes.platform`: these three words are not a product configuration, they
+/// are a property of running with no operating system under you, and a
+/// predicate spelled out of `builtin` cannot drift from that the way a
+/// hand-maintained platform enum can. Same idiom as allocators.zig's tiers.
+///
+/// Wasm is `freestanding` too — that is the `web` platform — and it is exactly
+/// what this must exclude: inside the browser's sandbox an address is an offset
+/// into a linear memory the engine owns, so a "peek" there would read a number
+/// that means nothing about any machine and a "poke" would corrupt the heap
+/// this same editor is running out of. Bare metal is the freestanding target
+/// whose addresses are the bus's.
+pub const enabled = builtin.os.tag == .freestanding and !builtin.target.cpu.arch.isWasm();
+
+// `pardes.platform` is not the gate, but it IS an independent witness, so each
+// of the three interesting builds proves its own half of the predicate rather
+// than leaving "wasm is freestanding" as a comment nobody re-checks. The one
+// that matters is the middle line: without the `isWasm` term above, the web
+// build would silently hand a browser tab a Poke that writes into the linear
+// memory this editor's own heap lives in.
+comptime {
+ if (pardes.hosted and enabled) @compileError("an OS is not bare metal");
+ if (pardes.platform == .web and enabled) @compileError("wasm is not bare metal");
+ if (pardes.platform == .p4 and !enabled) @compileError("the P4 firmware is bare metal");
+}
+
+/// How much of the address space ONE command may render.
+///
+/// The number is set by the console, not by the memory: UART0 runs at 115200
+/// baud and measures ~11.9 KB/s on the wire, and a hexdump row is 76 bytes of
+/// text per 16 bytes of memory. 4 KiB is therefore 256 rows and ~19.5 KiB of
+/// text — under two seconds to paint the whole buffer, and ~4% of the 512 KiB
+/// heap the firmware hands over. `Hexdump 0x0 0xffffffff` would otherwise wedge
+/// the only console the board has for eleven hours, with no way to interrupt
+/// it, which makes an unbounded dump not a slow command but a lost session.
+///
+/// Peek's cap is the same 4 KiB window expressed in words, so `Peek a 1024`
+/// and `Hexdump a 4096` cover exactly the same bytes.
+pub const max_bytes: u32 = 4096;
+pub const max_words: u32 = max_bytes / 4;
+
+/// One address past the last: the reads below are bounded by this rather than
+/// wrapping, because `Hexdump 0xfffffff0 256` wrapping to 0 would silently
+/// show you the bottom of the space labelled with top-of-space addresses.
+const space: u64 = 1 << 32;
+
+pub const Error = error{
+ MissingAddress,
+ BadAddress,
+ BadCount,
+ MissingValue,
+ BadValue,
+ /// the ONE fault this file exists to prevent by hand: the RISC-V core
+ /// traps an unaligned 32-bit access, and a trap in firmware with no
+ /// handler is a watchdog reset that takes the session with it. Reported on
+ /// the message row instead.
+ MisalignedAddress,
+ ExtraArgument,
+};
+
+/// hex (`0x4ff40000`), decimal (`1341718528`), and — for free, from base 0 —
+/// binary and octal. A bare `4ff40000` is deliberately NOT hex: it is a
+/// legal-looking decimal number, so guessing the base would make one typo
+/// silently address somewhere else entirely.
+fn parseAddr(tok: []const u8) Error!u32 {
+ return std.fmt.parseInt(u32, tok, 0) catch return Error.BadAddress;
+}
+
+fn parseCount(tok: []const u8) Error!u64 {
+ return std.fmt.parseInt(u64, tok, 0) catch return Error.BadCount;
+}
+
+fn parseValue(tok: []const u8) Error!u32 {
+ return std.fmt.parseInt(u32, tok, 0) catch return Error.BadValue;
+}
+
+/// A 32-bit peripheral or RAM read that the compiler may neither elide,
+/// duplicate, reorder past another access, nor narrow.
+fn readWord(addr: u32) u32 {
+ const cell: *allowzero const volatile u32 = @ptrFromInt(@as(usize, addr));
+ return cell.*;
+}
+
+fn writeWord(addr: u32, value: u32) void {
+ const cell: *allowzero volatile u32 = @ptrFromInt(@as(usize, addr));
+ cell.* = value;
+}
+
+fn readByte(addr: u32) u8 {
+ const cell: *allowzero const volatile u8 = @ptrFromInt(@as(usize, addr));
+ return cell.*;
+}
+
+const Limit = enum {
+ /// the 4 KiB console cap above
+ console,
+ /// the end of the 32-bit address space
+ space,
+};
+
+/// How many units this command will actually show, and WHY that is fewer than
+/// you asked for when it is. Never silent: the note below becomes the buffer's
+/// FIRST line, which is the one place a clamp cannot be missed — a trailing
+/// note on a 256-row dump is a note you scroll past.
+const Extent = struct {
+ count: u32,
+ /// the tighter of the two bounds, or null when neither applied
+ limit: ?Limit,
+};
+
+fn extent(addr: u32, requested: u64, unit: u32, cap: u32) Extent {
+ var count = requested;
+ var limit: ?Limit = null;
+ if (count > cap) {
+ count = cap;
+ limit = .console;
+ }
+ const fits = (space - addr) / unit;
+ if (count > fits) {
+ count = fits;
+ limit = .space;
+ }
+ return .{ .count = @intCast(count), .limit = limit };
+}
+
+fn writeNote(w: *std.Io.Writer, e: Extent, requested: u64, unit_name: []const u8) !void {
+ switch (e.limit orelse return) {
+ .console => try w.print(
+ "clamped: {d} {s} requested, {d} shown ({d}-byte cap, one 115200-baud console)\n",
+ .{ requested, unit_name, e.count, max_bytes },
+ ),
+ .space => try w.print(
+ "clamped: {d} {s} requested, {d} shown (the 32-bit address space ends at 0x100000000)\n",
+ .{ requested, unit_name, e.count },
+ ),
+ }
+}
+
+// The two bounds and their reporting, on the one part of this file that is
+// pure arithmetic and therefore testable on any target — the accesses
+// themselves are only meaningful on the board.
+test "the clamp reports the tighter bound and never wraps the address space" {
+ const eq = std.testing.expectEqual;
+ // neither bound applied: what you asked for, and nothing to report
+ try eq(Extent{ .count = 3, .limit = null }, extent(0x4ff40000, 3, 4, max_words));
+ // the console cap, in words and in bytes
+ try eq(Extent{ .count = max_words, .limit = .console }, extent(0x4ff40000, 99_999, 4, max_words));
+ try eq(Extent{ .count = max_bytes, .limit = .console }, extent(0, 100_000, 1, max_bytes));
+ // sixteen bytes left above 0xfffffff0 — the whole point, because wrapping
+ // would show the BOTTOM of the space under top-of-space addresses
+ try eq(Extent{ .count = 16, .limit = .space }, extent(0xfffffff0, 64, 1, max_bytes));
+ try eq(Extent{ .count = 4, .limit = .space }, extent(0xfffffff0, 64, 4, max_words));
+ // ...including the row that has no whole word left in it
+ try eq(Extent{ .count = 0, .limit = .space }, extent(0xffffffff, 1, 4, max_words));
+ // both bounds at once: the tighter one is the one reported
+ try eq(Extent{ .count = max_bytes, .limit = .console }, extent(0xffff0000, 1 << 20, 1, max_bytes));
+}
+
+test "a clamp note is written exactly when something was clamped" {
+ var buf: [256]u8 = undefined;
+ var w: std.Io.Writer = .fixed(&buf);
+
+ try writeNote(&w, extent(0x4ff40000, 3, 4, max_words), 3, "words");
+ try std.testing.expectEqualStrings("", w.buffered());
+
+ try writeNote(&w, extent(0x4ff40000, 99_999, 4, max_words), 99_999, "words");
+ try std.testing.expectEqualStrings(
+ "clamped: 99999 words requested, 1024 shown (4096-byte cap, one 115200-baud console)\n",
+ w.buffered(),
+ );
+
+ w = .fixed(&buf);
+ try writeNote(&w, extent(0xfffffff0, 64, 1, max_bytes), 64, "bytes");
+ try std.testing.expectEqualStrings(
+ "clamped: 64 bytes requested, 16 shown (the 32-bit address space ends at 0x100000000)\n",
+ w.buffered(),
+ );
+}
+
+test "an address is hex or decimal, and a bare hex-looking token is decimal" {
+ try std.testing.expectEqual(0x4ff40000, parseAddr("0x4ff40000"));
+ try std.testing.expectEqual(0x4ff40000, parseAddr("1341390848"));
+ // a bare hex-looking token is a decimal number, never a guess
+ try std.testing.expectError(Error.BadAddress, parseAddr("4ff40000"));
+ try std.testing.expectError(Error.BadAddress, parseAddr("0x100000000"));
+ try std.testing.expectError(Error.BadCount, parseCount("-1"));
+ try std.testing.expectError(Error.BadValue, parseValue("0x1_0000_0000"));
+}
+
+/// `Peek <addr> [count]` — count 32-bit words at addr, one `addr: value` row
+/// each. One word per row rather than four so that every row carries its own
+/// address: the rows are then ordinary Look targets, and `Peek` or `Poke`
+/// chorded onto one re-reads or writes exactly that word.
+pub fn peek(p: *Pardes, id: usize, argument: []const u8) !void {
+ var it = std.mem.tokenizeAny(u8, argument, " \t\r\n");
+ const addr = try parseAddr(it.next() orelse return Error.MissingAddress);
+ const requested = if (it.next()) |tok| try parseCount(tok) else 1;
+ if (it.next() != null) return Error.ExtraArgument;
+ if (addr % 4 != 0) return Error.MisalignedAddress;
+
+ const e = extent(addr, requested, 4, max_words);
+ var out: std.Io.Writer.Allocating = .init(p.gpa);
+ errdefer out.deinit();
+ try writeNote(&out.writer, e, requested, "words");
+ for (0..e.count) |i| {
+ const at = addr + @as(u32, @intCast(i * 4));
+ try out.writer.print("0x{x:0>8}: 0x{x:0>8}\n", .{ at, readWord(at) });
+ }
+ const content = try out.toOwnedSlice();
+ try fill(p, id, .{ .cmd = .Peek }, content);
+}
+
+/// `Poke <addr> <value>` — one 32-bit store, then one load back, both reported
+/// on the message row.
+///
+/// The READ-BACK is the whole point of the word and not a confirmation: on RAM
+/// it always equals what you wrote and tells you nothing, and on MMIO it
+/// almost never does — a write-only command register reads as 0, a W1C status
+/// bit reads back cleared, a reserved field reads back masked, and a register
+/// behind a gated clock reads back whatever the bus returns for nothing at
+/// all. Printing only the value written would show you your own argument.
+pub fn poke(p: *Pardes, id: usize, argument: []const u8) !void {
+ var it = std.mem.tokenizeAny(u8, argument, " \t\r\n");
+ const addr = try parseAddr(it.next() orelse return Error.MissingAddress);
+ const value = try parseValue(it.next() orelse return Error.MissingValue);
+ if (it.next() != null) return Error.ExtraArgument;
+ if (addr % 4 != 0) return Error.MisalignedAddress;
+
+ writeWord(addr, value);
+ const back = readWord(addr);
+ var buf: [96]u8 = undefined;
+ p.setMessage(id, std.fmt.bufPrint(
+ &buf,
+ "0x{x:0>8}: wrote 0x{x:0>8}, reads 0x{x:0>8}",
+ .{ addr, value, back },
+ ) catch unreachable);
+}
+
+/// `Hexdump <addr> [len]` — len bytes, 16 to a row, hex columns and an ASCII
+/// gutter, in `hexdump -C`'s layout because that is the one everyone can
+/// already read. BYTE reads, so a partial row at the end of the space is a
+/// short row rather than a refusal, and no alignment is required: this is the
+/// word you reach for when you do not yet know what is there.
+pub fn hexdump(p: *Pardes, id: usize, argument: []const u8) !void {
+ var it = std.mem.tokenizeAny(u8, argument, " \t\r\n");
+ const addr = try parseAddr(it.next() orelse return Error.MissingAddress);
+ const requested = if (it.next()) |tok| try parseCount(tok) else 256;
+ if (it.next() != null) return Error.ExtraArgument;
+
+ const e = extent(addr, requested, 1, max_bytes);
+ var out: std.Io.Writer.Allocating = .init(p.gpa);
+ errdefer out.deinit();
+ try writeNote(&out.writer, e, requested, "bytes");
+ var row: u32 = 0;
+ while (row < e.count) : (row += 16) {
+ const n = @min(@as(u32, 16), e.count - row);
+ var bytes: [16]u8 = undefined;
+ for (0..n) |i| bytes[i] = readByte(addr + row + @as(u32, @intCast(i)));
+ try out.writer.print("0x{x:0>8} ", .{addr + row});
+ for (0..16) |i| {
+ // hexdump -C's gap after the eighth column: the eye counts to
+ // eight, not to sixteen
+ if (i == 8) try out.writer.writeByte(' ');
+ if (i < n)
+ try out.writer.print(" {x:0>2}", .{bytes[i]})
+ else
+ try out.writer.writeAll(" ");
+ }
+ try out.writer.writeAll(" |");
+ for (0..n) |i| try out.writer.writeByte(
+ if (bytes[i] >= 0x20 and bytes[i] < 0x7f) bytes[i] else '.',
+ );
+ try out.writer.writeAll("|\n");
+ }
+ const content = try out.toOwnedSlice();
+ try fill(p, id, .{ .cmd = .Hexdump }, content);
+}
+
+/// The shared tail. `fillResults` is the one public entry that REFILLS the
+/// buffer a command already opened instead of stacking a twin beside it, which
+/// is what a dump wants: peeking twenty addresses in a row is twenty renders
+/// of one window on memory, not twenty panes. The empty argument is what makes
+/// it one window — a dump is identified by the command, never by the address,
+/// so a second Peek replaces the first rather than opening a buffer per
+/// address and exhausting the pane slots.
+///
+/// Neither buffer `steps`, so nothing is armed on n/N and focus stays in the
+/// pane you typed the command in. `content` is gpa-owned and adopted there.
+fn fill(p: *Pardes, id: usize, from: output_pane.Origin, content: []u8) !void {
+ const pane = p.panes[id] orelse {
+ p.gpa.free(content);
+ return error.MissingPane;
+ };
+ const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice();
+ try output_pane.fillResults(p, id, dir, from, "", content, null);
+}
diff --git a/src/builtins.zig b/src/builtins.zig
index 5a6b6ea9..0c9993a7 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -28,15 +28,21 @@ const output_pane = @import("output_pane.zig");
const image_pane = @import("image_pane.zig");
const config = @import("config.zig");
const runtime_config = @import("runtime_config.zig");
+const board_memory = @import("board_memory.zig");
/// The platform's runtime-setting facilities, stated once as plain data.
/// Registry generation, leader paths, Config, and EffectCode all consume this
/// exact value rather than rebuilding equivalent-looking boolean expressions.
pub const capabilities: runtime_config.Capabilities = .{
.font_picker = pardes.font_picker,
- .panel_transitions = pardes.platform != .web,
+ // A transition is composited by the shell, and EffectCode has to be able
+ // to show WHICH compositor: only the three hosted shells are in this
+ // package, so the hostless platforms have no honest source to print.
+ .panel_transitions = pardes.hosted,
.scene_shaders = pardes.platform == .gui or pardes.platform == .macos,
- .tagline_font_size = pardes.platform != .tty,
+ // The tty's font belongs to its emulator, and the P4 firmware's belongs to
+ // whatever terminal is on the other end of the serial line.
+ .tagline_font_size = pardes.platform != .tty and pardes.platform != .p4,
};
/// What a builtin gets to act on. One bundle rather than five parameters
@@ -150,7 +156,12 @@ pub const OutputTraits = struct {
/// on the builtin enum being constructed.
pub const registry = struct {
pub fn Builtin() type {
- @setEvalBranchQuota(20_000);
+ // The duplicate-name check below is O(n^2) string comparisons over every manual builtin AND
+ // every generated setting, so this quota grows quadratically with the builtin count. 20,000
+ // was enough until three more (Peek/Poke/Hexdump) tipped `-Dplatform=gui` over with
+ // "evaluation exceeded 20000 backwards branches". Raised with room rather than to the next
+ // value that happens to pass, so the next builtin does not have to rediscover this.
+ @setEvalBranchQuota(200_000);
const manual = manualBuiltinList();
const generated = settingList();
const count = manual.len + generated.len;
@@ -198,6 +209,18 @@ test "capabilities exactly gate setting and effect-source builtins" {
try std.testing.expectEqual(EffectCode.enabled, effect_code_registered);
}
+// The three memory words, checked the same way but at COMPTIME rather than in
+// a test, because the property is about builds this test binary is not: the
+// tty suite can only ever observe its own platform, and what matters is that
+// `-Dplatform=web -Dtarget=wasm32-freestanding` does not quietly hand a
+// browser tab a Poke. Every build of every platform now proves its own half.
+comptime {
+ for ([_][]const u8{ "Peek", "Poke", "Hexdump" }) |name|
+ if (@hasField(registry.Builtin(), name) != board_memory.enabled) @compileError(
+ "bare-metal memory word gating leaked: " ++ name,
+ );
+}
+
// ---- the two acme verbs ----
// Look and Execute are the verbs the whole environment is built on, and they
@@ -336,7 +359,7 @@ pub const ThemeSel = struct {
/// core owns parsing and keeps the last valid value across a bad live edit.
pub const ThemeFile = struct {
pub const takes_arg = true;
- pub const enabled = pardes.platform != .web;
+ pub const enabled = pardes.hosted;
pub fn run(c: Ctx) void {
if (comptime enabled)
c.p.requestThemeFile(c.id, c.arg orelse return)
@@ -349,7 +372,7 @@ pub const ThemeFile = struct {
/// `<config>/themes/builtin`. Filesystem work remains a host effect, just like
/// Dump and Save; the build-time ring itself is the sole source of the data.
pub const DumpThemes = struct {
- pub const enabled = pardes.platform != .web;
+ pub const enabled = pardes.hosted;
pub fn run(c: Ctx) void {
if (comptime enabled) {
if (c.p.opts.config_dir == null) {
@@ -894,3 +917,71 @@ pub const Lspwhy = struct {
c.p.lspRequest(c.id, .explain, "");
}
};
+
+// ---- the machine's address space (bare metal only) ----
+//
+// Three words gated by `board_memory.enabled`, which is a fact about the
+// TARGET (freestanding, and not wasm) rather than about `pardes.platform` —
+// see the reasoning there. Today that is exactly `-Dplatform=p4`; what makes
+// it the right predicate is that a second bare-metal port gets them without
+// anyone remembering to add an enum arm, and the browser never does.
+// Elsewhere they are absent from the command enum, the help index, the leader
+// table and the dispatcher, which is the gate ThemeFile and DumpThemes
+// already use.
+//
+// They are not a debugger and not a privilege: with no OS there is no MMU, no
+// supervisor and no process, so pardes IS the system software and all 2^32
+// addresses are already its own. RAM, the peripheral registers behind the
+// console it is talking to you over, and its own .text are one flat space, and
+// a word that could reach only part of it would be pretending to be an
+// application. What you actually reach for these for is the case a hosted
+// editor never has: the display did not come up, and the question is whether
+// the register you thought you wrote holds what you thought you wrote.
+//
+// Implementation, parsing, the volatile accesses and the clamp are all in
+// board_memory.zig, the way the PDF words live in pdf_pane.zig — these three
+// structs are the words, their argument contract, and where the answer goes.
+
+/// `Peek <addr> [count]` — count 32-bit words (default 1) as `addr: value`
+/// rows, hex or decimal address, refused rather than trapped when unaligned.
+pub const Peek = struct {
+ pub const takes_arg = true;
+ pub const enabled = board_memory.enabled;
+ pub const output: OutputTraits = .{ .name = config.peek_buffer };
+ pub fn run(c: Ctx) void {
+ if (comptime enabled) apply(c) else unreachable;
+ }
+ fn apply(c: Ctx) void {
+ board_memory.peek(c.p, c.id, c.arg orelse "") catch |err|
+ c.p.reportError(c.id, "peek", err);
+ }
+};
+
+/// `Poke <addr> <value>` — one 32-bit store, answered on the message row with
+/// the value written AND the value that reads back, which on MMIO is the
+/// interesting half (see board_memory.poke).
+pub const Poke = struct {
+ pub const takes_arg = true;
+ pub const enabled = board_memory.enabled;
+ pub fn run(c: Ctx) void {
+ if (comptime enabled) apply(c) else unreachable;
+ }
+ fn apply(c: Ctx) void {
+ board_memory.poke(c.p, c.id, c.arg orelse "") catch |err|
+ c.p.reportError(c.id, "poke", err);
+ }
+};
+
+/// `Hexdump <addr> [len]` — len bytes (default 256) in `hexdump -C`'s layout.
+pub const Hexdump = struct {
+ pub const takes_arg = true;
+ pub const enabled = board_memory.enabled;
+ pub const output: OutputTraits = .{ .name = config.hexdump_buffer };
+ pub fn run(c: Ctx) void {
+ if (comptime enabled) apply(c) else unreachable;
+ }
+ fn apply(c: Ctx) void {
+ board_memory.hexdump(c.p, c.id, c.arg orelse "") catch |err|
+ c.p.reportError(c.id, "hexdump", err);
+ }
+};
diff --git a/src/config.zig b/src/config.zig
index 79fd7fc9..c9d414ca 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -224,10 +224,18 @@ pub const leader_path = paths: {
// Native-only filesystem theme commands. ThemeFile needs an operand and
// DumpThemes is intentionally occasional, so both stay word-executed
// rather than spending leader chords.
- if (pardes.platform != .web) {
+ if (pardes.hosted) {
table.set(.ThemeFile, null);
table.set(.DumpThemes, null);
}
+ // The bare-metal memory words. Peek, Poke and Hexdump all take an ADDRESS,
+ // so none of them can have a leader path for the reason Theme and Msg have
+ // none: a key path names a builtin and can never carry an operand.
+ if (builtins.Peek.enabled) {
+ table.set(.Peek, null);
+ table.set(.Poke, null);
+ table.set(.Hexdump, null);
+ }
// The pane-local PDF commands exist only in MuPDF builds through their
// explicit registry availability, so name their paths inside the same
// comptime branch. PdfTint/PdfFit retain their display slots and
@@ -787,6 +795,10 @@ pub const pdf_sections_buffer = "+PdfSections";
pub const hover_buffer = "+Hover";
pub const lsp_buffer = "+Lsp";
pub const changelog_buffer = "+Changelog";
+/// The two memory windows a bare-metal build's Peek and Hexdump render. Absent
+/// from every hosted build along with the builtins that name them.
+pub const peek_buffer = "+Peek";
+pub const hexdump_buffer = "+Hexdump";
/// The empty buffer New and Newcol open: no file behind it yet, so Save asks
/// for a path (prefilled with the inherited directory).
pub const scratch_buffer = "+New";
diff --git a/src/dump.zig b/src/dump.zig
index 566cf03c..37ba83c0 100644
--- a/src/dump.zig
+++ b/src/dump.zig
@@ -42,7 +42,16 @@ pub const max_panes: usize = 16;
pub const max_cols: usize = 6;
/// Bounds the only user-editable, schema-owned tag fragment. Keep this beside
/// the dump limits so readers can reject data before copying it into a pane.
-pub const max_tag_tail: usize = 4096;
+/// It IS the storage bound: `Pane.tag_tail` is `[max_tag_tail]u8`, and every
+/// writer (appendTag, tagInsert, restoreDumpTail, the acmefs `tag` file)
+/// refuses input that does not fit rather than truncating it, so the schema
+/// limit and the buffer can never disagree.
+///
+/// 512 on the P4 firmware. A tag is ONE line — a pane's path plus its command
+/// words — and 4 KiB of it is 4 KiB per pane out of a 384 KiB heap. A serial
+/// console is 80 columns; 512 is six of those.
+pub const max_tag_tail: usize =
+ if (@import("pardes_config").platform == .p4) 512 else 4096;
/// Output arguments are typed in the same bounded one-line tag storage. Keep
/// the schema limit named independently so a dump reader can validate it
/// without importing the output-pane implementation.
diff --git a/src/effect_sources.zig b/src/effect_sources.zig
index 6f1c6bb1..a01f1177 100644
--- a/src/effect_sources.zig
+++ b/src/effect_sources.zig
@@ -175,12 +175,12 @@ pub fn forSetting(setting: runtime_config.Setting) ?[]const Segment {
.tty => &tty_panel,
.gui => &gui_panel,
.macos => &mac_panel,
- .web => null,
+ .web, .p4 => null,
},
.scene => switch (backend) {
.gui => &gui_scene,
.macos => &mac_scene_segments,
- .tty, .web => null,
+ .tty, .web, .p4 => null,
},
else => null,
};
diff --git a/src/file_pane.zig b/src/file_pane.zig
index be3ff201..8fc0fa0d 100644
--- a/src/file_pane.zig
+++ b/src/file_pane.zig
@@ -14,10 +14,16 @@ const look = @import("look.zig");
const output_pane = @import("output_pane.zig");
const syntax = @import("syntax.zig");
const tracy = @import("tracy.zig");
+const term_pane = @import("term_pane.zig");
const dump = @import("dump.zig");
const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2;
-const undo_max = 256;
+/// EDIT BOUNDARIES REMEMBERED PER FILE PANE. Every entry owns a gpa copy of
+/// the WHOLE file, so this number multiplies heap, not just the pane: 256 of
+/// them is not a bound a 384 KiB board could ever reach anyway. `pushHistory`
+/// evicts and frees the oldest once full, so the smaller ring loses the
+/// deepest undo steps and nothing else — no truncation, no dropped edit.
+const undo_max = if (@import("pardes_config").platform == .p4) 16 else 256;
/// Content and primary selection at one file edit boundary. Keeping only the
/// primary avoids putting pardes.MAX_SELS ranges in every history entry.
@@ -723,8 +729,8 @@ pub fn drawGutter(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, bod
const s = &p.surface;
const ch = p.chromeTheme();
const goff = pane.scroll();
- const gcur = pane.vt.screens.active.cursor;
- const gcrow = if (pane.cur_pinned) pane.cur_row else @as(i32, @intCast(gcur.y)) + goff;
+ const gcur = term_pane.gridCursor(pane);
+ const gcrow = if (pane.cur_pinned) pane.cur_row else @as(i32, gcur.y) + goff;
// the cursor's LINE, not its row: wrapped, one line owns a run of rows and
// the number sits on the first of them, so the whole run lights up — the
// gutter is naming the line you are on, and that is still one line
diff --git a/src/image.zig b/src/image.zig
index a9f0e227..50e9649c 100644
--- a/src/image.zig
+++ b/src/image.zig
@@ -4,20 +4,56 @@
//! a pixel attachment the shell transmits/places (TTY: Kitty; SDL: GPU texture).
const std = @import("std");
const zstbi = @import("zstbi");
-const ghostty_vt = @import("ghostty-vt");
+/// The 16-colour ANSI table below is the only thing this file ever wanted from
+/// the emulator, and a build without one still renders images — see
+/// `pardes.terminal_panes`.
+const terminal_panes = @import("pardes.zig").terminal_panes;
+const ghostty_vt = if (terminal_panes) @import("ghostty-vt") else struct {};
const pdf_enabled = @import("pardes_config").mupdf;
pub const petscii = @import("petscii.zig");
/// the terminal's own 16 ANSI colors — what the `terminal` palette mode
/// scores against; cells then paint as indexed colors so the real terminal
/// resolves the RGB
-pub fn ansiPalette() [16][3]u8 {
- var p: [16][3]u8 = undefined;
- for (0..16) |i| {
+///
+/// These are GHOSTTY's sixteen, transcribed, because on a platform that has an
+/// emulator the scoring used to read them straight out of it and a build
+/// without one has to score against the identical table or render different
+/// glyph art for the same photo. Note they are the base16 Tomorrow Night set,
+/// NOT the xterm defaults: zig-pkg/ghostty-1.3.2-dev-5UdBCzeJ.../src/terminal/
+/// color.zig:389-405 (`Name.default`), which color.zig:8-15 copies into the
+/// first sixteen entries of `color.default`. The comptime block below makes
+/// the emulator prove that, so the transcription cannot rot in silence.
+const ansi_default = [16][3]u8{
+ .{ 0x1D, 0x1F, 0x21 }, // black
+ .{ 0xCC, 0x66, 0x66 }, // red
+ .{ 0xB5, 0xBD, 0x68 }, // green
+ .{ 0xF0, 0xC6, 0x74 }, // yellow
+ .{ 0x81, 0xA2, 0xBE }, // blue
+ .{ 0xB2, 0x94, 0xBB }, // magenta
+ .{ 0x8A, 0xBE, 0xB7 }, // cyan
+ .{ 0xC5, 0xC8, 0xC6 }, // white
+ .{ 0x66, 0x66, 0x66 }, // bright black
+ .{ 0xD5, 0x4E, 0x53 }, // bright red
+ .{ 0xB9, 0xCA, 0x4A }, // bright green
+ .{ 0xE7, 0xC5, 0x47 }, // bright yellow
+ .{ 0x7A, 0xA6, 0xDA }, // bright blue
+ .{ 0xC3, 0x97, 0xD8 }, // bright magenta
+ .{ 0x70, 0xC0, 0xB1 }, // bright cyan
+ .{ 0xEA, 0xEA, 0xEA }, // bright white
+};
+
+comptime {
+ if (terminal_panes) for (ansi_default, 0..) |rgb, i| {
const c = ghostty_vt.color.default[i];
- p[i] = .{ c.r, c.g, c.b };
- }
- return p;
+ if (rgb[0] != c.r or rgb[1] != c.g or rgb[2] != c.b) @compileError(
+ "src/image.zig ansi_default has drifted from ghostty's color.default",
+ );
+ };
+}
+
+pub fn ansiPalette() [16][3]u8 {
+ return ansi_default;
}
/// cap the longest side before keeping/transmitting: a pane is at most a
diff --git a/src/look.zig b/src/look.zig
index fbcc80a9..df1a15ae 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -563,7 +563,10 @@ fn findEmbeddedSource(path: []const u8, allow_root_suffix: bool) ?embedded_sourc
/// embedded source.
const platform_has_fs = !pardes.isolated and switch (pardes.platform) {
.tty, .gui, .macos => true,
- .web => false,
+ // The browser's filesystem is the embedded source archive; the P4
+ // firmware's is whatever the serial host answers for, through the Host
+ // vtable — never a path this process opens.
+ .web, .p4 => false,
};
// Find's safety rails. The core is SYNCHRONOUS — a Find at `/` runs inside the
diff --git a/src/main.zig b/src/main.zig
index 69cc1715..c60fb888 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -231,10 +231,11 @@ fn nativeMain(init: std.process.Init) !void {
switch (pardes.platform) {
.tty => try @import("tty/tty.zig").run(init, opts),
.gui => try @import("gui/gui.zig").run(init, opts),
- // Both library shells are entered by their host through a flat C ABI,
- // and never link this file at all: the browser through src/web.zig,
- // the macOS app through src/macos.zig.
- .web, .macos => unreachable,
+ // Every other shell is entered by its host and never links this file
+ // at all: the browser through src/web.zig, the macOS app through
+ // src/macos.zig, and the ESP32-P4 firmware through its own app root in
+ // the zig-p4 package, which imports this package's `pardes_p4` module.
+ .web, .macos, .p4 => unreachable,
}
}
diff --git a/src/output_pane_integration_test.zig b/src/output_pane_integration_test.zig
index 13d41506..ab6daa15 100644
--- a/src/output_pane_integration_test.zig
+++ b/src/output_pane_integration_test.zig
@@ -70,7 +70,9 @@ test "EffectCode opens the embedded implementation used by this backend" {
else
"shaders/ui.vert.glsl",
.macos => "src/macos/Sources/ScenePostprocessor.swift",
- .web => unreachable,
+ // Neither backend builds this native test binary: the browser shell is wasm and the P4
+ // firmware is a freestanding object, so no `unit-test` run can ever land here.
+ .web, .p4 => unreachable,
};
try std.testing.expect(std.mem.indexOf(u8, out.content, backend_source) != null);
}
diff --git a/src/p4.zig b/src/p4.zig
new file mode 100644
index 00000000..7356dc73
--- /dev/null
+++ b/src/p4.zig
@@ -0,0 +1,578 @@
+//! The ESP32-P4 firmware shell: pardes as one freestanding object, bytes in and bytes out.
+//!
+//! This is the fourth platform, and the only one that is not an executable. `zig build
+//! -Dplatform=p4 -Dtarget=riscv32-freestanding` emits this file as a single object exporting the C
+//! ABI below; the `zig-p4` package links it beside its own `_start`, its generated linker script,
+//! and its UART driver. Nothing here knows what a UART is.
+//!
+//! **Why an object and not a module.** The obvious arrangement was for zig-p4 to declare this
+//! package in its `build.zig.zon` and import `pardes_p4`. That was built, and it broke every build
+//! in that repo: nesting this package's ~30-package graph under one whose own claim is "host
+//! dependencies: Zig, that is the whole list" made `std/Build.zig:2091` exceed its 1000-branch
+//! comptime quota (through ghostty's `SharedDeps.zig:874` `lazyImport`), dragged in seven cached
+//! tree-sitter versions whose `build.zig` uses APIs removed in 0.16, and materialised 2.6 GB across
+//! 42,736 files into that repo's working copy. A linked object has none of those properties and one
+//! extra virtue: the seam is bytes, so neither side can accidentally depend on the other's types.
+//!
+//! **Where the terminal is.** On the host. The board writes ANSI and reads ANSI; the terminal
+//! emulator at the far end of the serial line does the font rendering, and answers this program's
+//! own capability queries. That is why `vaxis` works here unmodified: `Vaxis.render`,
+//! `queryTerminalSend` and `enableDetectedFeatures` all take a bare `*std.Io.Writer`
+//! (`Vaxis.zig:375,278,329`), so the transport is a parameter. `vaxis.Tty` and `vaxis.Loop` are
+//! termios/ioctl/SIGWINCH bound and are not used.
+//!
+//! **Where the memory is.** Not here either. The firmware measured its own RAM (240 KiB low,
+//! 384 KiB high, and a 128 KiB region that turned out to be L2 cache) and owns the allocator; this
+//! file receives four function pointers and rebuilds a `std.mem.Allocator` from them. Everything
+//! the editor allocates comes from there.
+//!
+//! **Window size** arrives as DEC mode 2048 in-band resize reports, parsed by `vaxis.Parser` like
+//! any other input. Firmware has no `TIOCGWINSZ`, so the host-side bridge synthesises the first one.
+
+const std = @import("std");
+const pardes = @import("pardes.zig");
+const vaxis = @import("vaxis");
+
+// ------------------------------------------------------------------ what a freestanding root owes
+//
+// These are ROOT-module declarations: std reads them off whichever file is the compilation root, and
+// as of the build change that emits this file as the object, that is this file. They are not
+// ceremony - each one was discovered by the build failing without it.
+
+/// The board has no MMU and no pages, but std derives allocator alignment from these two. 4 KiB is
+/// the ESP32-P4's cache and DMA granularity. Without them: "riscv32-freestanding has unknown
+/// page_size_min" from std/heap.zig:48.
+///
+/// `logFn` is the load-bearing one. std's DEFAULT log implementation reaches `std.debug_io`, which
+/// instantiates `std.Io.Threaded` - a thread pool, `getrandom`, `IOV_MAX`, `mremap` - none of which
+/// exist on this target, and ONE `log.warn` anywhere in the core or in vaxis is enough to drag the
+/// whole thing in and fail the build with "no member named 'getrandom'".
+pub const std_options: std.Options = .{
+ .page_size_min = 4096,
+ .page_size_max = 4096,
+ .logFn = logFn,
+};
+
+/// Logs go out the same byte sink as the frames, which is the only sink there is. Truncated rather
+/// than allocated: a log line is never worth an allocation on a 384 KiB heap, and a logger that can
+/// fail on OOM is a logger that disappears exactly when it is needed.
+fn logFn(
+ comptime level: std.log.Level,
+ comptime scope: @EnumLiteral(),
+ comptime fmt: []const u8,
+ args: anytype,
+) void {
+ if (out_ctx == null and @intFromPtr(out_write) == 0) return;
+ var buf: [256]u8 = undefined;
+ const line = std.fmt.bufPrint(
+ &buf,
+ "\r\n[" ++ level.asText() ++ "/" ++ @tagName(scope) ++ "] " ++ fmt ++ "\r\n",
+ args,
+ ) catch "\r\n[log truncated]\r\n";
+ out_write(out_ctx, line.ptr, line.len);
+}
+
+pub const panic = std.debug.FullPanic(panicImpl);
+
+/// A panic here cannot unwind and has nowhere to go, so it reports through the write callback and
+/// stops. `@trap` and not a spin: the firmware's own panic handler prints through the mask ROM,
+/// which shares nothing with this path but the FIFO, so a trap leaves that diagnostic route intact.
+fn panicImpl(msg: []const u8, _: ?usize) noreturn {
+ const prefix = "\r\nMARK PARDES_CORE_PANIC ";
+ out_write(out_ctx, prefix.ptr, prefix.len);
+ out_write(out_ctx, msg.ptr, msg.len);
+ out_write(out_ctx, "\r\n", 2);
+ @trap();
+}
+
+// ---------------------------------------------------------------------------------- the C ABI
+//
+// Deliberately tiny, and versioned. Linkers do not type-check C symbols, so a signature that drifts
+// on one side of this seam links cleanly and then corrupts the stack. `pardes_p4_abi_version` is the
+// cheapest possible defence: the firmware calls it first and refuses to continue on a mismatch.
+
+/// Bumped whenever any signature below changes, including a type.
+const abi_version: u32 = 1;
+
+export fn pardes_p4_abi_version() callconv(.c) u32 {
+ return abi_version;
+}
+
+/// The firmware's allocator, as C function pointers. `alignment` is a log2 value, matching
+/// `std.mem.Alignment`'s own representation, so no translation table is needed.
+///
+/// `remap` is absent on purpose: this allocator cannot move a block without copying it, so
+/// `std.mem.Allocator`'s remap is implemented locally as "resize in place, or fail" and the caller's
+/// own alloc/copy/free path handles the rest.
+pub const Allocator = extern struct {
+ ctx: ?*anyopaque,
+ alloc: *const fn (ctx: ?*anyopaque, len: usize, log2_align: u8) callconv(.c) ?[*]u8,
+ resize: *const fn (ctx: ?*anyopaque, ptr: [*]u8, len: usize, log2_align: u8, new_len: usize) callconv(.c) bool,
+ free: *const fn (ctx: ?*anyopaque, ptr: [*]u8, len: usize, log2_align: u8) callconv(.c) void,
+};
+
+/// How finished runs of ANSI leave this object.
+pub const WriteFn = *const fn (ctx: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void;
+
+// ------------------------------------------------------------------- the allocator, rebuilt
+// One `std.mem.Allocator` whose vtable forwards to the four pointers above. The indirection is the
+// price of the seam and it is paid once per allocation, which on a first-fit heap is already the
+// cheap part (measured on the die: 8,229 cycles for one allocation across 257 free blocks).
+
+var host_alloc: Allocator = undefined;
+
+fn hostAlloc(_: *anyopaque, len: usize, alignment: std.mem.Alignment, _: usize) ?[*]u8 {
+ return host_alloc.alloc(host_alloc.ctx, len, @intFromEnum(alignment));
+}
+
+fn hostResize(_: *anyopaque, mem: []u8, alignment: std.mem.Alignment, new_len: usize, _: usize) bool {
+ return host_alloc.resize(host_alloc.ctx, mem.ptr, mem.len, @intFromEnum(alignment), new_len);
+}
+
+fn hostRemap(_: *anyopaque, mem: []u8, alignment: std.mem.Alignment, new_len: usize, _: usize) ?[*]u8 {
+ return if (host_alloc.resize(host_alloc.ctx, mem.ptr, mem.len, @intFromEnum(alignment), new_len)) mem.ptr else null;
+}
+
+fn hostFree(_: *anyopaque, mem: []u8, alignment: std.mem.Alignment, _: usize) void {
+ host_alloc.free(host_alloc.ctx, mem.ptr, mem.len, @intFromEnum(alignment));
+}
+
+const host_vtable: std.mem.Allocator.VTable = .{
+ .alloc = hostAlloc,
+ .resize = hostResize,
+ .remap = hostRemap,
+ .free = hostFree,
+};
+
+/// `ptr` is never dereferenced - the four forwarders read the file-scope `host_alloc` - but
+/// `std.mem.Allocator` requires a non-null context, so it points at the record itself.
+fn gpa() std.mem.Allocator {
+ return .{ .ptr = @ptrCast(&host_alloc), .vtable = &host_vtable };
+}
+
+// ------------------------------------------------------------------------------- the ANSI sink
+// A `std.Io.Writer` over the firmware's write callback. Buffered, because vaxis emits a frame as a
+// long run of small writes - cursor move, SGR run, grapheme, repeat - and an unbuffered writer would
+// make a C call per fragment.
+
+var out_write: WriteFn = undefined;
+var out_ctx: ?*anyopaque = null;
+var out_buf: [8192]u8 = undefined;
+var out: std.Io.Writer = undefined;
+
+fn drain(w: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize {
+ // The shape std documents at Io/Writer.zig:46-63: buffer first, then every slice of `data`, with
+ // the LAST slice repeated `splat` times, and the count returned excluding the buffered bytes.
+ if (w.end > 0) {
+ out_write(out_ctx, w.buffer.ptr, w.end);
+ w.end = 0;
+ }
+ const head = data[0 .. data.len - 1];
+ const pattern = data[head.len];
+ var written: usize = 0;
+ for (head) |bytes| {
+ if (bytes.len > 0) out_write(out_ctx, bytes.ptr, bytes.len);
+ written += bytes.len;
+ }
+ var i: usize = 0;
+ while (i < splat) : (i += 1) {
+ if (pattern.len > 0) out_write(out_ctx, pattern.ptr, pattern.len);
+ }
+ return written + pattern.len * splat;
+}
+
+// ------------------------------------------------------------------------------------ the state
+
+var core: ?*pardes.Pardes = null;
+var vx: vaxis.Vaxis = undefined;
+var parser: vaxis.Parser = .{};
+
+/// vaxis wants an environment map. There is no environment; an empty one is the honest answer and
+/// the only thing vaxis reads it for is TERM-derived heuristics, which the capability queries
+/// supersede.
+var env_map: std.process.Environ.Map = undefined;
+
+/// Input that arrived mid-sequence. An escape sequence can be split across UART reads, and the
+/// parser reports "incomplete" by consuming nothing, so the tail has to survive until more arrives.
+var in_buf: [1024]u8 = undefined;
+var in_len: usize = 0;
+
+/// Bracketed paste: between the markers, keys are DATA and never commands.
+var paste_buf: std.ArrayListUnmanaged(u8) = .empty;
+var in_paste: bool = false;
+
+/// Set by anything that could change the screen; cleared by a render. The firmware asks before
+/// rendering, because on a 115200-baud link an unconditional repaint per loop saturates the wire and
+/// starves input.
+var dirty: bool = true;
+
+/// The largest grid this board can render, and the reason it is not the host's terminal size.
+///
+/// Every cell is paid for four times over: vaxis keeps a `Screen` and an `InternalScreen`, pardes
+/// keeps its own `Surface` and `previous_cells`. Against a 384 KiB heap that puts a hard ceiling on
+/// the geometry, and it was measured rather than guessed - 40x12 initialises with room to spare,
+/// 80x24 exhausts the heap and `Pardes.init` returns OutOfMemory with 9,128 bytes left.
+///
+/// Raising these is what PSRAM would buy: this board has 32 MB fitted and untrained.
+pub const max_cols: u16 = 40;
+pub const max_rows: u16 = 12;
+
+var cur_winsize: vaxis.Winsize = .{ .rows = max_rows, .cols = max_cols, .x_pixel = 0, .y_pixel = 0 };
+
+
+// -------------------------------------------------------------------------------------- exports
+
+/// Hand over the allocator and the output sink, state the initial window size, and bring the editor
+/// up. Returns 0, or a small non-zero code the firmware can only report.
+export fn pardes_p4_init(
+ alloc: *const Allocator,
+ write: WriteFn,
+ ctx: ?*anyopaque,
+ cols: u16,
+ rows: u16,
+) callconv(.c) u32 {
+ host_alloc = alloc.*;
+ out_write = write;
+ out_ctx = ctx;
+ out = .{ .vtable = &.{ .drain = drain }, .buffer = &out_buf };
+
+ const a = gpa();
+ env_map = .{ .array_hash_map = .empty, .allocator = a };
+ // Clamped, so a firmware asking for more than the heap affords still starts. See `max_cols`.
+ cur_winsize = .{
+ .rows = @min(rows, max_rows),
+ .cols = @min(cols, max_cols),
+ .x_pixel = 0,
+ .y_pixel = 0,
+ };
+
+ const allocs = pardes.allocators.init(a);
+ // `std.Io.failing` and not a real Io: every path in the core that would perform I/O is behind
+ // the Host vtable, and the ones that are not are the ones this platform does not have.
+ pardes.image.start(std.Io.failing, allocs.image);
+ pardes.syntax.start(allocs.tree_sitter);
+
+ vx = vaxis.init(std.Io.failing, a, &env_map, .{}) catch |err| return errCode(err);
+ vx.resize(a, &out, cur_winsize) catch |err| return errCode(err);
+
+ // Ask the terminal what it is. Both halves are pure byte writers, which is the whole reason this
+ // works over a serial line: the replies arrive as ordinary input and are parsed like any key.
+ vx.enterAltScreen(&out) catch |err| return errCode(err);
+ vx.queryTerminalSend(&out) catch |err| return errCode(err);
+ out.flush() catch |err| return errCode(err);
+
+ // The CLAMPED geometry, because the core and vaxis must agree on the grid and vaxis was just
+ // sized to `cur_winsize`.
+ core = pardes.Pardes.init(allocs.pardes, .{
+ .cols = cur_winsize.cols,
+ .rows = cur_winsize.rows,
+ .frame_allocator = allocs.frame,
+ .image_allocator = allocs.image,
+ .tree_sitter_allocator = allocs.tree_sitter,
+ }) catch |err| return errCode(err);
+
+ dirty = true;
+ return 0;
+}
+
+/// Raw bytes off the wire: keystrokes, capability replies, and in-band resize reports. All three are
+/// the same kind of thing to `vaxis.Parser`, and this function does not distinguish them.
+export fn pardes_p4_input(ptr: [*]const u8, len: usize) callconv(.c) void {
+ const c = core orelse return;
+
+ // Append, dropping the oldest on overflow: a full buffer means the parser is stuck on a
+ // malformed sequence, and keeping the tail is what lets it resynchronise.
+ const room = in_buf.len - in_len;
+ const take = @min(room, len);
+ if (take < len) {
+ in_len = 0;
+ @memcpy(in_buf[0..@min(len, in_buf.len)], ptr[0..@min(len, in_buf.len)]);
+ in_len = @min(len, in_buf.len);
+ } else {
+ @memcpy(in_buf[in_len..][0..take], ptr[0..take]);
+ in_len += take;
+ }
+
+ var off: usize = 0;
+ while (off < in_len) {
+ const res = parser.parse(in_buf[off..in_len], gpa()) catch break;
+ if (res.n == 0) break; // incomplete: wait for more bytes
+ off += res.n;
+ if (res.event) |ev| apply(c, ev);
+ }
+ // Keep whatever was not consumed: the tail of a split escape sequence.
+ if (off > 0) {
+ std.mem.copyForwards(u8, in_buf[0 .. in_len - off], in_buf[off..in_len]);
+ in_len -= off;
+ }
+}
+
+/// One parsed vaxis event applied to the core. Mirrors the tty shell's `apply`
+/// (`src/tty/tty.zig:926-985`), minus everything that needs an OS.
+fn apply(c: *pardes.Pardes, ev: vaxis.Event) void {
+ switch (ev) {
+ .key_press => |key| if (in_paste) {
+ // Between the brackets a key is DATA, never a command. vaxis gives control bytes no
+ // text at all, so a line break inside a paste arrives as a bare CR (Key.enter) or, from
+ // a terminal that does not translate them, as ctrl+j.
+ const text = key.text orelse "";
+ const cp = mapKey(effCp(key));
+ const bytes: []const u8 = if (text.len > 0)
+ text
+ else if (cp == pardes.Key.tab)
+ "\t"
+ else if (cp == pardes.Key.enter or (key.mods.ctrl and cp == 'j'))
+ "\n"
+ else
+ "";
+ if (bytes.len > 0) paste_buf.appendSlice(gpa(), bytes) catch {};
+ } else {
+ c.update(.{ .key = .{
+ .cp = mapKey(effCp(key)),
+ .text = key.text orelse "",
+ .ctrl = key.mods.ctrl,
+ .alt = key.mods.alt,
+ .shift = key.mods.shift,
+ } });
+ dirty = true;
+ },
+ .paste_start => {
+ paste_buf.clearRetainingCapacity();
+ in_paste = true;
+ },
+ .paste_end => {
+ in_paste = false;
+ if (paste_buf.items.len > 0) {
+ c.update(.{ .paste = paste_buf.items });
+ dirty = true;
+ }
+ paste_buf.clearRetainingCapacity();
+ },
+ // OSC 52. The bytes are the parser's, allocated from our own allocator, so they are freed
+ // here rather than leaked - the core copies whatever it keeps.
+ .paste => |text| {
+ c.update(.{ .paste = text });
+ gpa().free(text);
+ dirty = true;
+ },
+ .mouse => |m| {
+ const button: ?pardes.Mouse.Button = switch (m.button) {
+ .left => .left,
+ .middle => .middle,
+ .right => .right,
+ .wheel_up => .wheel_up,
+ .wheel_down => .wheel_down,
+ .wheel_left => .wheel_left,
+ .wheel_right => .wheel_right,
+ .none => .none,
+ else => null,
+ };
+ if (button) |b| {
+ c.update(.{ .mouse = .{
+ .button = b,
+ .kind = switch (m.type) {
+ .press => .press,
+ .release => .release,
+ .motion => .motion,
+ .drag => .drag,
+ },
+ .col = @intCast(m.col),
+ .row = @intCast(m.row),
+ .ctrl = m.mods.ctrl,
+ } });
+ dirty = true;
+ }
+ },
+ // The only way this platform learns its size, and the one place a 384 KiB heap shows through
+ // to the user. Two things happen here that the tty shell does not need.
+ //
+ // CLAMPED, because the grids do not fit an arbitrary terminal: vaxis keeps a `Screen` and an
+ // `InternalScreen`, pardes keeps its own `Surface` and `previous_cells`, so every cell is
+ // paid for four times. Measured on the die - 40x12 initialises with room to spare, 80x24
+ // exhausts the heap and `Pardes.init` returns OutOfMemory with 9,128 bytes left. The host's
+ // terminal is normally larger than the board can render, so the editor takes a corner of it
+ // instead of refusing to start.
+ //
+ // ATOMIC, because `Vaxis.resize` deinits both screens BEFORE allocating the replacements
+ // (Vaxis.zig:194-206), so a failed resize leaves vaxis with freed screens and renders
+ // nothing at all. That is exactly how this was found: the host bridge injects a size report
+ // on attach, the 80x24 it reported could not be allocated, and an editor that had just drawn
+ // its interface went silent. A failure now puts the previous geometry back.
+ .winsize => |ws| {
+ const want: vaxis.Winsize = .{
+ .rows = @min(ws.rows, max_rows),
+ .cols = @min(ws.cols, max_cols),
+ .x_pixel = ws.x_pixel,
+ .y_pixel = ws.y_pixel,
+ };
+ if (want.cols == cur_winsize.cols and want.rows == cur_winsize.rows) return;
+ const previous = cur_winsize;
+ vx.resize(gpa(), &out, want) catch {
+ vx.resize(gpa(), &out, previous) catch {};
+ return;
+ };
+ cur_winsize = want;
+ c.update(.{ .resize = .{ .cols = want.cols, .rows = want.rows } });
+ dirty = true;
+ },
+ // A TTY cannot report a pointer leaving its grid, so losing focus is the only reliable
+ // pointer-leave signal there is.
+ .focus_out => {
+ c.update(.pointer_leave);
+ dirty = true;
+ },
+ .focus_in, .mouse_leave => {},
+ // Capability replies. vaxis's own Loop sets these fields directly (`Loop.zig:377-403`);
+ // with no Loop, this is where they land. DA1 is the terminator: every terminal answers it
+ // last, so it is the signal that the whole handshake is in and the detected features can be
+ // switched on.
+ .cap_kitty_keyboard => vx.caps.kitty_keyboard = true,
+ .cap_kitty_graphics => vx.caps.kitty_graphics = true,
+ .cap_rgb => vx.caps.rgb = true,
+ .cap_unicode => {
+ vx.caps.unicode = .unicode;
+ vx.screen.width_method = .unicode;
+ },
+ .cap_sgr_pixels => vx.caps.sgr_pixels = true,
+ .cap_color_scheme_updates => vx.caps.color_scheme_updates = true,
+ .cap_multi_cursor => vx.caps.multi_cursor = true,
+ .cap_da1 => {
+ vx.enableDetectedFeatures(&out) catch {};
+ out.flush() catch {};
+ dirty = true;
+ },
+ .color_report, .color_scheme => {},
+ .key_release => {},
+ }
+}
+
+/// The effective codepoint the way vaxis's own `Key.matches` sees it: a single-character `text`
+/// wins, because the terminal has already resolved shift; otherwise the shifted codepoint.
+fn effCp(key: vaxis.Key) u21 {
+ if (key.text) |t| {
+ const view = std.unicode.Utf8View.init(t) catch return key.codepoint;
+ var it = view.iterator();
+ if (it.nextCodepoint()) |cp| {
+ if (it.nextCodepoint() == null) return cp;
+ }
+ }
+ return key.shifted_codepoint orelse key.codepoint;
+}
+
+/// vaxis functional-key codepoints -> core constants. The ASCII ones already coincide, so
+/// enter/tab/escape/backspace pass straight through.
+fn mapKey(cp: u21) u21 {
+ return switch (cp) {
+ vaxis.Key.up => pardes.Key.up,
+ vaxis.Key.down => pardes.Key.down,
+ vaxis.Key.left => pardes.Key.left,
+ vaxis.Key.right => pardes.Key.right,
+ vaxis.Key.home => pardes.Key.home,
+ vaxis.Key.end => pardes.Key.end,
+ vaxis.Key.page_up => pardes.Key.page_up,
+ vaxis.Key.page_down => pardes.Key.page_down,
+ vaxis.Key.delete => pardes.Key.delete,
+ else => cp,
+ };
+}
+
+export fn pardes_p4_tick(now_ms: u64) callconv(.c) void {
+ const c = core orelse return;
+ _ = now_ms;
+ if (c.animationActive()) {
+ c.update(.tick);
+ dirty = true;
+ }
+}
+
+export fn pardes_p4_wants_frame() callconv(.c) bool {
+ const c = core orelse return false;
+ return dirty or c.animationActive();
+}
+
+export fn pardes_p4_render() callconv(.c) u32 {
+ const c = core orelse return 0;
+ c.pump(.{ .ctx = null, .vtable = &pardes_host }) catch |err| return errCode(err);
+ dirty = false;
+ return 0;
+}
+
+export fn pardes_p4_quit() callconv(.c) bool {
+ const c = core orelse return true;
+ return c.quit;
+}
+
+// ------------------------------------------------------------------------------------ the host
+
+const pardes_host: pardes.Host.VTable = .{ .push_present = present };
+
+/// The canonical surface -> vaxis, cell for cell, then one render. Same shape as the tty shell's
+/// (`src/tty/tty.zig:1096`) minus the panel compositor and the kitty image path: neither has a
+/// reason to exist on a board with no pixels.
+fn present(_: ?*anyopaque, surface: *const pardes.Surface) void {
+ const win = vx.window();
+ win.clear();
+ var y: u16 = 0;
+ while (y < surface.rows) : (y += 1) {
+ var x: u16 = 0;
+ while (x < surface.cols) : (x += 1) {
+ // `at` takes a mutable Surface but only reads; the tty shell does the same const-cast
+ // for the same reason (src/tty/tty.zig:1105).
+ const cell = @constCast(surface).at(x, y);
+ if (cell.default) continue;
+ win.writeCell(x, y, .{
+ .char = .{ .grapheme = cell.grapheme() },
+ .style = vaxisStyle(cell.style),
+ });
+ }
+ }
+ if (surface.cursor) |cur| {
+ win.showCursor(cur.x, cur.y);
+ } else win.hideCursor();
+
+ // vaxis diffs against its own shadow grid, so this writes only what changed - which is what
+ // makes an editor usable at 11.9 KB/s.
+ vx.render(&out) catch return;
+ out.flush() catch return;
+}
+
+fn vaxisStyle(s: pardes.CellStyle) vaxis.Style {
+ return .{
+ .fg = vaxisColor(s.fg),
+ .bg = vaxisColor(s.bg),
+ .bold = s.bold,
+ .dim = s.dim,
+ .italic = s.italic,
+ .blink = s.blink,
+ .reverse = s.reverse,
+ .invisible = s.invisible,
+ .strikethrough = s.strikethrough,
+ .ul_style = switch (s.ul) {
+ .off => .off,
+ .single => .single,
+ .double => .double,
+ .curly => .curly,
+ .dotted => .dotted,
+ .dashed => .dashed,
+ },
+ };
+}
+
+fn vaxisColor(c: pardes.Color) vaxis.Color {
+ return switch (c) {
+ .default => .default,
+ .index => |i| .{ .index = i },
+ .rgb => |rgb| .{ .rgb = rgb },
+ };
+}
+
+/// Errors cross the ABI as small non-zero integers. `@intFromError` is not stable across builds, so
+/// it is not used: the firmware only reports the number, and a stable-looking value that silently
+/// changed meaning would be worse than an opaque one.
+fn errCode(err: anyerror) u32 {
+ return switch (err) {
+ error.OutOfMemory => 1,
+ error.WriteFailed => 2,
+ else => 255,
+ };
+}
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);
}
+
diff --git a/src/runtime_config.zig b/src/runtime_config.zig
index 19964a48..bd1ba00b 100644
--- a/src/runtime_config.zig
+++ b/src/runtime_config.zig
@@ -5,6 +5,22 @@
const std = @import("std");
const panel_animation = @import("panel_animation.zig");
+/// HOW LONG A HOST-SUPPLIED ABSOLUTE PATH MAY BE, and the only reason this
+/// record was ever kilobytes: the shell a native host resolved, the font file
+/// a native picker returned, and (in pardes.zig) the one watched theme file.
+/// All three name something on a FILESYSTEM, and all three are retained
+/// inline because the core has no allocator at the point they arrive.
+///
+/// Fixed at 4095 wherever a filesystem exists — deliberately NOT derived from
+/// std.fs PATH_MAX, which web has no answer for, and 4095 rather than 4096 so
+/// the macOS C bridge's NUL fits without a second, subtly different limit at
+/// that boundary. Zero on the P4 firmware, which has no filesystem, no
+/// processes to spawn a shell for and no font picker: `Text(0)` is a
+/// zero-sized field whose `set` refuses every non-empty path, so the three
+/// producers report failure instead of storing 12 KiB nothing can fill.
+pub const host_path_cap: usize =
+ if (@import("pardes_config").platform == .p4) 0 else 4095;
+
/// Tagline glyphs retain body-cell geometry, so allowing a face larger than
/// the body would clip into neighbouring cells. Zero would make the role
/// invisible. Keep the runtime command on the same 1...100 contract as the
@@ -47,18 +63,16 @@ pub const State = struct {
/// pending until the next terminal spawn acknowledges it.
shell: struct {
requested: Text(255) = .{},
- // Fixed across native and freestanding builds: runtime State is one
- // data schema, and web has no std.fs PATH_MAX to derive this from.
- effective: Text(4095) = .{},
+ // One data schema across native, browser and freestanding builds; only
+ // its one absolute-path field follows `host_path_cap`.
+ effective: Text(host_path_cap) = .{},
pending: bool = true,
} = .{},
/// The shell acknowledges a font before `effective` changes. A rejected
/// request therefore remains queryable without claiming it is on screen.
font: struct {
- // 4095 leaves room for the NUL in the macOS C bridge without a
- // second, subtly different limit at that boundary.
- requested_path: Text(4095) = .{},
+ requested_path: Text(host_path_cap) = .{},
requested_name: Text(255) = .{},
effective_name: Text(255) = .{},
pending: bool = false,
diff --git a/src/source_manifest.zig b/src/source_manifest.zig
index 5de560ac..e2cc312c 100644
--- a/src/source_manifest.zig
+++ b/src/source_manifest.zig
@@ -15,9 +15,20 @@
//! Paths are as a user would type them, repo-root-relative, which is what
//! `look` resolves a click against.
+//! ON THE P4 the allowlist is EMPTY, and that is the whole difference: the
+//! table is ~0.95 MiB of rodata against a 1.5 MiB flash partition, and the
+//! firmware's filesystem is the serial host's, reached through the Host
+//! vtable. The API is unchanged — `all` is a zero-length array and `find`
+//! answers null — so every caller compiles identically and simply finds
+//! nothing embedded.
+
pub const Source = struct { path: []const u8, contents: []const u8 };
-pub const all = [_]Source{
+/// A slice, not an array: the P4 table is empty and every consumer only ever
+/// iterates or takes `.len`.
+pub const all: []const Source = if (@import("pardes_config").platform == .p4) &.{} else &allowlist;
+
+const allowlist = [_]Source{
.{ .path = "build.zig", .contents = @embedFile("root-build.zig") },
.{ .path = "build.zig.zon", .contents = @embedFile("root-build.zig.zon") },
.{ .path = "src/pardes.zig", .contents = @embedFile("pardes.zig") },
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;