summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-25 13:01:13 -0300
committerGabriel Schneider <[email protected]>2026-08-25 17:13:54 -0300
commit939e3a7288d6782139cac36f091ccd1d41cdfc0a (patch)
tree6b7b86c2ba4ade9f34c8f174354425a5d31a150a
parente5f9e172330bc4500995ddd5954ed94f94e07af6 (diff)
downloadpardes-939e3a7288d6782139cac36f091ccd1d41cdfc0a.tar.gz
pardes-939e3a7288d6782139cac36f091ccd1d41cdfc0a.zip
A fourth platform: pardes as ESP32-P4 firmware, bytes in and bytes out
`-Dplatform=p4 -Dtarget=riscv32-freestanding` emits a single freestanding OBJECT exporting a seven-function C ABI, not an executable. The board's toolchain (../05-zig-p4) owns `_start`, the linker script and the UART driver and links this in. The seam is bytes rather than types, so neither side can accidentally depend on the other's internals, and a signature that drifts fails at link time. The serial line is the whole of the I/O. `src/p4.zig` drives vaxis unchanged over it: the renderer is a byte writer and `queryTerminalSend` is a byte writer, so the terminal emulator on the host answers the capability handshake and the firmware sees a real terminal. Measured going out over the wire on attach: alt screen, in-band resize, cursor report, kitty keyboard, kitty graphics, DA1. THREE WORDS EXIST ONLY HERE. `src/board_memory.zig` implements `Peek`, `Poke` and `Hexdump`, gated on `builtin.os.tag == .freestanding and !isWasm()` - derived from the TARGET, because they are a property of running with no OS under you rather than a product option, and because wasm is freestanding too and is exactly what must be excluded: in a browser an address is an offset into the linear memory this editor's own heap lives in. Every access goes through `*allowzero volatile`: a peripheral register is not memory, and address 0 is an ordinary unmapped address on this bus. One 4 KiB cap per command, set by the console rather than the memory - an unbounded dump would wedge the only console the board has for eleven hours. Measured on ESP32-P4 rev v1.3 silicon, driven from a host terminal: Peek 0x501101a4 0x0e63ce71, then 0xaeaa6919 on a second read - the RNG register, so the volatile loads are not folded Poke 0x5011002c 0xdeadbeef LP_STORE0; a later Peek returned 0xdeadbeef Hexdump 0x5011002c 32 16 bytes a row, hex columns and an ASCII gutter Peek 0x50110001 `peek: MisalignedAddress` on the message row That last line is the one that matters. A misaligned 32-bit access traps, and a trap in firmware is a watchdog reset that takes the session with it, so the check that turns it into a message is the reason the file is hand-written rather than a generic reader. BARE METAL BOOTS AN EMPTY OUTPUT BUFFER. Every other boot layout in `init` makes a shell, and on this platform that is not a preference but an impossibility: nothing to fork, no pty to give a terminal pane. Booting one anyway produced precisely what that describes - a pane whose tag ends in `Filter`, no gutter, no buffer, and every keystroke vanishing into the Fallback's silent pty. An output buffer is also what the platform's own words want, since Peek, Poke and Hexdump each fill one. Sized for the board rather than for a desktop: * `allocators.zig` gains a p4 tier that is ALL fallback - every capacity is zero, so each arena spills immediately to the 384 KiB heap the firmware hands over, and no megabyte-shaped static reservation lands in `.bss`. * `source_manifest.zig`'s allowlist is EMPTY on p4. The table is ~0.95 MiB of rodata against a 1.5 MiB flash partition; the firmware's filesystem is the serial host's, through the Host vtable. * The grid is clamped and the clamp is measured, not guessed: every cell is paid for four times (vaxis Screen + InternalScreen, pardes Surface + previous_cells), so 40x12 fits and 80x24 exhausts the heap during `Pardes.init`. * `Vaxis.resize` deinits both screens before allocating replacements, so a failed resize leaves vaxis rendering nothing. The p4 shell keeps the previous geometry on failure instead of leaving a half-applied one. Also here: `output_pane_integration_test.zig` had an exhaustive switch over `Platform` that adding `.p4` left unhandled, which broke `zig build unit-test` outright - the native test binary is the one consumer no platform build compiles. 346 tests pass again.
-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;