diff options
| -rw-r--r-- | build.zig | 102 | ||||
| -rw-r--r-- | src/acmefs.zig | 5 | ||||
| -rw-r--r-- | src/allocators.zig | 33 | ||||
| -rw-r--r-- | src/board_memory.zig | 323 | ||||
| -rw-r--r-- | src/builtins.zig | 101 | ||||
| -rw-r--r-- | src/config.zig | 14 | ||||
| -rw-r--r-- | src/dump.zig | 11 | ||||
| -rw-r--r-- | src/effect_sources.zig | 4 | ||||
| -rw-r--r-- | src/file_pane.zig | 12 | ||||
| -rw-r--r-- | src/image.zig | 50 | ||||
| -rw-r--r-- | src/look.zig | 5 | ||||
| -rw-r--r-- | src/main.zig | 9 | ||||
| -rw-r--r-- | src/output_pane_integration_test.zig | 4 | ||||
| -rw-r--r-- | src/p4.zig | 578 | ||||
| -rw-r--r-- | src/pardes.zig | 193 | ||||
| -rw-r--r-- | src/runtime_config.zig | 26 | ||||
| -rw-r--r-- | src/source_manifest.zig | 13 | ||||
| -rw-r--r-- | src/term_pane.zig | 263 |
18 files changed, 1617 insertions, 129 deletions
@@ -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; |
