summaryrefslogtreecommitdiff
path: root/build.zig
Commit message (Collapse)AuthorAge
* G4: bloom, vignette and grain in the post chainGabriel Schneider39 hours
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Three bundled Shadertoy passes beside Crt, each `Bloom|Vignette|Grain 0..3` (off by default, nothing drawn while off), in shaders/post/: - Bloom: what is brighter than the page (a channel's linear peak over 0.75/0.7/0.6 and over the page's own + 0.15, so a light page does not bloom) glows, 2/3.5/5% (§8.2's budget). The glow is a dual Kawase blur in RGBA16F textures at half size and down, 3/4/5 steps by level (shaders/bloom-down, bloom-up; Post.bloomChain), composited in linear light and dithered. - Vignette: round, from halfway to a corner, 10/18/28% at the far corners, in linear light, dithered. - Grain: a still, triangular-spread grain of 1.5/3/5 of 255 on page-coloured pixels only, a logical pixel big; still, so it asks for no frames. Focus rule: the shell passes every bundled pass the rectangles of the tags with their grips, the column and workspace tags, the notices and the cursor (pardesSpare, at most 32, neighbours on one row merged), and a bundled pass leaves them as they are; and a per-theme ceiling (pardesCap, Post.capsOf) keeps the page's text and the selection's text at min(their contrast, 4.5) under each pass at its strongest. User files get no ceiling and no spared rectangles. Fix found on the way: SDL GPU binds at most 4 KiB of a uniform block, so every Shadertoy colour after iPalette (iBackgroundColor, iForegroundColor, the cursor and selection colours, iPalette[>=4]) read zero. The prefix's block is cut in three (Globals, Palette, Colors) with ghostty's names and order, each pushed as a slice of the one struct; pardes's own block moves to binding 3. Tests: the caps hold over every native theme; each uniform slice fits 4 KiB; the prefix's members in ghostty's order across its blocks. Gates: tty/gui unit-test and core-test pass; snap fails only nested-optout; GUI goldens 01-08 byte-identical (09-18 wait on wkzlsqvy, as in somtrmsz); web builds. Feel review material: .scratch/render/{bloom,vignette,grain}/ (levels on forge, dusk, acme, crops, a levels mp4).
*-. Merge: 9P rounds 7-8 + layout files + loose ends + G3 cursorGabriel Schneider39 hours
|\ \
| * | The board image links again: sources are no longer embedded by default on ↵Gabriel Schneider39 hours
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | esp32p4 uzksuumxruzr turned -Dembed-sources on for esp32p4 (before it, the board was the one build without them). The sources are ~1.8 MB against a 1.5 MiB app partition, so the object built but the firmware did not link: .flash.rodata overflowed by 786392 bytes and .flash.text by 1732312. Default off everywhere; -Dembed-sources=true still embeds them. Without them the image is 1459760 B, 113104 B under 1.5 MiB. Co-Authored-By: Claude Opus 5.5 <[email protected]>
| * | config-test goes: config.zig is the core now, and core-test runs its testsGabriel Schneider39 hours
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | config.zig imports pardes.zig and builtins.zig, so it cannot be compiled "without the editor" any more; the step failed with 40 missing-module errors (zls, uucode, cloud9, mupdf, ...). Its two tests (Space-k unbound, wheel drift guard) already run in core-test, checked with -Dtest-filter. Wiring every core import into it would only rebuild core-test for two tests. Co-Authored-By: Claude Opus 5.5 <[email protected]>
| * | Only kept builds carry the commit, so a jj new rebuilds nothingGabriel Schneider39 hours
| |/ | | | | | | | | | | | | | | | | | | | | The git commit sat in the options module every binary imports, so each HEAD move (jj new, jj commit) recompiled every test and snapshot binary: measured 24.4 s and +0.6 GB of .zig-cache for test-build -Dplatform=tty after one no-op HEAD move. Now -Dstamp-commit decides, defaulting to on for release builds and the bare zig build that installs into ~/.local; every other Debug build prints `pardes <version>` alone. After: 0.21 s, +0 bytes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* / A repeat inside a regexp group gives back to what follows the groupGabriel Schneider39 hours
|/ | | | | | | | | | | | | | On aab, /a+ab/ matched but /(a+)ab/ and /(a*)ab/ missed, and so did (.+)_area and ([a-z_]+)_area, in addr, Edit and look alike. mvzr's hasAlt counts a group's own ) as an alternative, so every group with pattern after it takes matchGroup's alternatives branch, which never tries a repeat's shorter matches. No newer mvzr fixes it (trunk is the pinned commit), so the build patches the fetched source as it does the step budget, anchor-checked: when the rest fails after a group, the group's shorter matches are tried, longest first. docs/mvzr-group-backtrack.md is a report for upstream. Co-Authored-By: Claude Opus 5.5 <[email protected]>
*-. Trial merge: 9P + helix + renderGabriel Schneider39 hours
|\ \
| | * Ripple and Glitch are gone; Crt staysGabriel Schneider39 hours
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The user tried them live and wants them removed entirely: the builtins, their levels, config keys and leader paths (tR, tg), their slots in the built-in chain, their shaders (source and prebuilt), EffectCode paths, and on macOS their scene flags (pardes.h), the Metal kernel's coordinate warps and the Swift pointer mapping that mirrored them. Docs say so (decision 8 in docs/render-pipeline.md); config.md documents Crt's levels, Shader and ShaderAnimation instead. Shared files touched: macos.zig (flags). Not touched: pardes.zig, Messages.zig, mouse.zig, gui.zig, detached/*.
| | * Post chain: Shadertoy passes compiled by glslc; Crt, Ripple and Glitch ↵Gabriel Schneider39 hours
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | rewritten as Shadertoy files Stage 9 of docs/render-pipeline.md. src/gui/Post.zig runs the chain over the finished frame: each pass reads the one before, ping-pong textures between, the last writes the window; an empty chain is the direct path. The prefix (shaders/post/prefix.glsl) is ghostty's uniform block, name for name and offset for offset (tested against a copy of its Uniforms, the selection colours by their GLSL names), at SDL GPU's bindings, y down. A user's file (Shader <path>) is compiled by glslc with compileGlsl's own flags on a thread of its own, taken in at the next loop step; errors name the file's own lines; a failed compile keeps the last good pipeline; glslc missing is said once. The bundled passes compile with the build, through the same prefix. Crt, Ripple and Glitch are Shadertoy files, rewritten: no barrel (input is identity, tested), everything in linear light and dithered, keyed to iTime. scene_effects, crt.zig and crt.frag.glsl are gone; the core keeps no clock for the chain. ShaderAnimation off|on|always drives redraw level A: the chain alone over the retained frame (34 us CPU a redraw, measured). Shared files touched: pardes.zig (nextWake loses the scene line, disableSceneEffects empties the chain, two tests), builtins.zig (one switch arm), ninep/ctl.zig (two switch arms), macos.zig (flags from the chain), gui.zig. Not touched: Messages.zig, mouse.zig, detached/*, host_io, tty.
| | * The GUI draws in tiers from the regions; pane chrome moves with its paneGabriel Schneider39 hours
| |/ |/| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Stage 8 of docs/render-pipeline.md. The frame is drawn in groups: tier 0, one per track in paint order (tiers 2 and 3), tier 4 (notices, then guides and the debug box), tier 5 (bar cursors), each cells, images, decor. Decor (rules, rails, thumbs, grip marks, spines, the workspace and column rules, the bottom band, notice rules, bar cursors) is whole-pixel rects through the new decor.frag over ui.vert, so it carries its track's transition and clip. A closing pane's chrome comes from Surface.previous_regions. Deleted: taglineBaseRgb, topbarPaneBorderHeight, bottomTaglinePresent, frameChromeBg, cellBackgroundIs and the rail inference, paneGripCell's scan, transient_on, PaintPlan. One cover map (coverFrame) is marked from the layers and regions once a frame. New regions column, guide and debug; Surface.chrome is the palette, carried in wire v8 (not shipped yet), so an attached GUI draws the same chrome (test). A per-instance clip replaces the vertical transition's scissor. Goldens: 01-12 byte-identical. 13-17 differ only past the grid: the image pass left its scissor at the grid's size, cutting rule ends, rail feet and the bottom band in the leftover pixels whenever a picture was on screen. 16-debug now shows the debug box, which a context-row layer had hidden. 18-mid-transition is new: virtual clock (PARDES_TEST_CLOCK in the GUI), PanelSlide Newcol with the picture, frame 6 of 12. Also: the GUI sleeps when idle instead of polling every 16 ms, and a minimized or occluded window sleeps through animation. Tracy 'gui frame build' at 200x60: 992/1015 us median before, 989/987 us after. Shared files touched: pardes.zig (one export), detached/client.zig, detached/server.zig, detached/wire.zig (all additive), gui.zig. Not touched: Messages.zig, mouse.zig, tagline.zig, colors.zig, tty.zig, host_io.zig, dump.zig, exec.zig, panes.zig.
| * ]f, mif and the other textobject keys use helix's own queriesGabriel Schneider39 hours
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | helix's textobjects.scm for pardes's grammars are vendored in vendor/queries under MPL-2.0, each with a header naming its source and what it inherits, inlined; a README says what is there and what is not. syntax.objectAt and objectNext run them over the file's kept parse, with their #eq? and #match? predicates, for ]f [f ]t [t ]a [a ]c [c ]T [T ]e [e ]x [x and mi/ma with f t a c T e x. erlang's and zig's queries do not compile against the grammar versions pardes builds and are left out; fortran, markdown and powershell have none in helix. A test replays sixteen Python and JSON cases whose results were taken from the installed hx, and another holds that every vendored query compiles. Co-Authored-By: Claude Opus 5.5 <[email protected]>
| * The helix-golf steps that walk search hits are waived, so hxgolf can passGabriel Schneider39 hours
| | | | | | | | | | | | | | | | reverse_golf_example from ""N on needs *, N and n to walk the search register; in pardes those keys are the acme look ring, by decision. The seven steps are pinned in golf-waivers.jsonl, so a new mismatch stands out. Co-Authored-By: Claude Opus 5.5 <[email protected]>
| * Every helix-golf example is a differential case, one per step of its walkthroughGabriel Schneider39 hours
|/ | | | | | | | | | | The ten examples on helix-golf are imported as 118 cases: each numbered step of an example's walkthrough is the case whose keys are the command up to and including that step, so the first mismatch names the step where pardes and helix part. Six more restart at a step that begins with %, from helix's own text there, so a later step is tested even when an earlier one misses. zig build hxgolf compares all 124 with goldens from hx-harness. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* A regular expression search is bounded by a step budget patched into mvzr, ↵Gabriel Schneider39 hours
| | | | | | | | | | | | | | | | | | not windows and a repeat cap The windows returned wrong matches: a candidate reaching a window's edge was left to the next window, half a window on, which could answer a match starting mid-token rather than the leftmost, and addr then pointed data's next write at the wrong bytes. The repeat cap missed mvzr's own worst case, a chain of a?, and alternation under a repeat, each exponential inside one mvzr call the deadline could not interrupt; and it refused ordinary s/S patterns. build.zig now patches the fetched mvzr at build time with a step counter on its backtracking recursion (matchPattern), so a fresh fetch keeps it and a moved anchor stops the build; regexp.zig gives each compiled pattern a budget, about 300 ms here, and a search that spends it fails as taking too long. Windows, the cap and their special cases are gone, and matches are exact again. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Allocate tree-sitter's external scanners from the same Zig allocator as its ↵Gabriel Schneider39 hours
| | | | | | | | | | | | | | | | | | | | | | | runtime syntax.zig points the tree-sitter runtime at a Zig allocator, but the grammars' external scanners did not follow it. A grammar's tree_sitter/alloc.h maps ts_malloc to the runtime's current allocator only when TREE_SITTER_REUSE_ALLOCATOR is defined, and plain malloc otherwise, so every scanner's state and its arrays came from libc. Five scanners (bash, markdown, markdown_inline, python, typst) also call malloc, calloc, realloc and free by name. build.zig now compiles every grammar with TREE_SITTER_REUSE_ALLOCATOR and forces in src/tree_sitter_heap.h, which includes stdlib.h and then defines the four names as calls through ts_current_malloc and friends. A header forced in from the build rather than a patch to the grammars keeps zig-pkg pristine. No grammar archive refers to libc's allocator any more; only the runtime does, for its defaults. A scanner frees only what it allocated itself, and the syntax tests that create and destroy a parser for every grammar under a leak-checking allocator pass. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Inflate MuPDF's streams with the zlib FreeType linksGabriel Schneider39 hours
| | | | | | | | | | | | | | | | MuPDF compiled its bundled zlib 1.3.1 (ZLIB_SRC) into libmupdf.a, and the freetype package links the zlib package, 1.3.2, for its gzip module: 68 symbols defined twice, with the linker keeping one of each. MuPDF now links that same zlib artifact and stops compiling its own. build.zig asks FreeType's build for zlib with the options FreeType asks with, which the build's dependency cache answers with the very artifact FreeType links, so the tty and the gui each carry exactly one zlib. MuPDF hands zlib its own allocation functions on every stream, so nothing about where the memory comes from changes. Every page of the three PDFs in docs/ still renders bit for bit as before, at 144 and at 300 dpi. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Build MuPDF against the FreeType the gui links, not the slim one it bundlesGabriel Schneider39 hours
| | | | | | | | | | | | | | | | | | | | MuPDF compiled its own FreeType 2.13.3 (FREETYPE_SRC, trimmed by scripts/freetype's slimftmodules.h and slimftoptions.h) into libmupdf.a, and the gui also links the freetype package, 2.14.3 in its full configuration. The two define the same 312 symbols, and a linker handed both keeps one of each: the gui's MuPDF ran the package's FreeType compiled against the slim headers, while the tty ran the slim one. Now there is one. mupdf.zig takes the build's freetype artifact, links it (its headers come with it) and no longer compiles FREETYPE_SRC or puts the slim headers on the include path. The shared artifact is built at c_optimize, as MuPDF is, so a Debug gui's glyph atlas now uses a ReleaseFast FreeType too; one artifact cannot be both. The tty gains the full FreeType's extra modules, which FT_Add_Default_Modules registers whether or not a PDF needs them. MuPDF asks nothing of FreeType the full configuration lacks: every page of docs/design.pdf, docs/9p.pdf and docs/registry.pdf renders bit for bit as before, at 144 and at 300 dpi. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Hand every C library's allocator hook the same C heapGabriel Schneider39 hours
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | C's malloc, calloc, realloc and free over a Zig allocator were written three times: syntax.zig's for tree-sitter, pdf.zig's for MuPDF, and gui.zig's for FreeType and HarfBuzz. They are now one, src/c_heap.zig's Heap. Each hook names the allocator variable its heap reads; gui.zig exports its heap under the ui_malloc names font.c and HarfBuzz call. It is a module of its own because pdf.zig is the MuPDF module, and a file cannot belong to that module and the core's at once. A block starts with a 16-byte header holding its size and the allocator that made it, so it goes back where it came from even after the heap is repointed, as the fonts' copy did. The fonts' copy carried the 16-byte Allocator itself in a 32-byte header; here the header holds the allocator's index in a table of every allocator a heap has used (entries are published once and never change, and a heap remembers where its current one was last found). The difference is measurable: with the 32-byte header, pardes-pdf-bench's filtered page render at 96 dpi took 1.1% longer than before this change, slower in 15 of 16 pinned rounds in two separate runs, and highlighting all of src/pardes.zig (305k tree-sitter blocks) up to 0.6% longer, against an A/A pair within 0.7%. With the 16-byte header both are back within the A/A pair's noise. Two other things change. For tree-sitter and MuPDF, a block made before the allocator was repointed now goes back to the one that made it rather than through the current one. For the fonts, realloc(block, 0) now frees and returns null, as glibc's does and as the other two copies already did; neither FreeType nor HarfBuzz asks for it. The heap's test replaces the tests of the old copies. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Allocate FreeType's and HarfBuzz's memory from the gui's Zig allocatorGabriel Schneider39 hours
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | FreeType and HarfBuzz allocated with libc malloc, out of sight of the Zig allocator the rest of the gui uses, so its Debug leak report and a test's std.testing.allocator never saw them. Now every allocation the font code makes goes through ui_malloc, ui_calloc, ui_realloc and ui_free, exported from gui.zig over `font_allocator`: - HarfBuzz is compiled with hb_malloc_impl and friends pointing at them. - Each UIFont gives its FreeType library its own memory manager (FT_New_Library with an FT_MemoryRec_ over the same functions, then FT_Add_Default_Modules and FT_Set_Default_Properties: exactly what FT_Init_FreeType does over its malloc manager), torn down with FT_Done_Library. - The shim's own UIFont and scratch arrays use them too. C's free and realloc pass no size, so a block starts with a 32-byte header (the caller's pointer stays 16-byte, max_align_t, aligned) holding its size and the allocator that made it, so a block goes back where it came from even after font_allocator is repointed. runNative points font_allocator at the gui's gpa. HarfBuzz makes a few process-wide objects on first use and never frees them (the default Unicode functions, the locale's language, hb-ft's font functions); ui_font_prime makes them from the default heap before that, so a Debug build's leak report stays about fonts. Tests do the same: the shaping tests, and a new test that creates, rasterizes and shapes fonts under std.testing.allocator, report any leak. Only the render thread calls into fonts, and every allocator used is thread-safe. It costs nothing measurable: against the previous change, 10 interleaved rounds with an A/A pair show every scenario as fast or faster with Adwaita Mono and Maple Mono alike, and first paint over 30 launches each matches main. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Shape text with HarfBuzz, so a font's programming ligatures draw across ↵Gabriel Schneider39 hours
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | their cells The SDL shell rasterized every cell by its own codepoint, so a font's `->`, `!=` or `<=` ligature never appeared. Text is now shaped with HarfBuzz 11.0.0, built from source like FreeType: a lazy build.zig.zon dependency (the tarball Ghostty pins) compiled only for the gui shell, its FreeType integration linked against the freetype package, so the binary carries no system HarfBuzz or FreeType and a tty build never compiles it. Shaping turns on only the font's ligature features (liga, calt, clig, rlig, rclt); composition, local forms and fractions stay off, so a cell draws either its nominal glyph, as it always did, or part of a ligature. A glyph no lookup of those features covers cannot change, so its cell is resolved alone, and that resolution is cached by codepoint: in a font without ligatures (Adwaita Mono) no cell is ever shaped, and the pipeline draws pixel for pixel as before. A cell whose glyph a ligature could replace is shaped with its word: the cells between spaces that share a decoration and a role. A word ends either side of the cursor, block or bar, so the cell being edited shows its own character (Ghostty's rule), and inside f|i, f|l and s|t, whose typographic ligatures do not belong on a grid (Ghostty again). Colours never end a word: a ligature spans a syntax colour change or a selection edge. A codepoint the primary font lacks shapes in the fallback face that has it. Taglines are not shaped: they are drawn at two pitches, and a strip would not line up in one. HarfBuzz only chooses glyphs; the grid places them. A substituted glyph whose ink reaches over neighbouring cells that draw nothing of their own (the spacer glyphs of Fira Code-style fonts such as Maple Mono, or the cells a merged ligature cluster swallowed) claims them: it is rasterized once as a strip that many cells wide, and each cell samples its own slice, so selection, the cursor and per-cell colours work unchanged. Anything the grid cannot place (several glyphs in one cell, a positioned mark) keeps its nominal glyphs. A frame must not pay for this. A shaped word's atlas slots are cached by its text (checked against the stored text, not just its hash), so a frame costs one lookup per word and a compare for the rest of the word being walked. The atlas is keyed by (face, glyph, role, decoration, lead, span), packed into 64 bits so a lookup hashes one word; ASCII glyph ids are a table, and a face's HarfBuzz state is made when it is first asked about a glyph. Measured with the latency trace, whose F line now also carries the submit time (CPU = submit - present entry), in 10 interleaved rounds against main with an A/A pair, at 160x50 cells: with Adwaita Mono every scenario (idle, scrolling, typing, terminal output) is as fast or 1-3% faster, and the first frame and time to first paint are unchanged. With Maple Mono, ligatures cost the first frame about 0.8 ms (HarfBuzz setup and shaping the screen) and scrolling new text about 1-2%. With it the codepoint raster path is gone: grips look up their glyph and stay centered, since a grip is not text. ui_font_scale_for_height, an identity kept as a "size token", is removed (px is the size everywhere), ui_font_has_glyph becomes ui_font_glyph, and the space slot is simply the first, cleared cell of the atlas rather than a rasterized space. CellStyle.ul gets an explicit u3 tag so a decoration fits in the packed keys. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Repaint PDF highlights by row, send rasters by shared memory, and animate ↵Gabriel Schneider39 hours
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | messages PDF highlights (hover preview, search, selection) are baked into page rasters, and any change re-rendered the whole page with MuPDF; the TTY then re-sent it as base64 (4.7 MB a page), the GUI as a new texture. Worse, a pointer motion over a PDF invalidated the page even when no preview was shown, so every motion paid that. Now: - A raster whose baked highlight set equals the wanted one is left alone. - A highlighted page keeps its clean rows (before highlights and tint); a change repaints only the rows of quads that differ, running MuPDF's highlight pass (pardes_pdf_paint_highlights) over those clean rows and tinting them: the operations a full render performs, so the pixels are identical. MuPDF band renders are NOT bit-identical to a whole page (edge rows, resampled images), so they are never used to patch; the comment claiming otherwise is corrected. - ImagePlace.patch hands shells the changed rows; the GUI uploads just those rows into the texture it holds. - The TTY probes kitty shared memory (t=s) with an id vaxis never reaches and sends rasters as a /dev/shm name when the terminal reads it; direct base64 otherwise (ssh). - Shells that take row patches (GUI, TTY with shm) repaint a selection while it is dragged instead of only on release. Latency elsewhere: - TTY: an animating frame no longer sleeps 16 ms blind; a tick thread posts into the input queue, so input inside the frame is handled at once. - TTY and GUI: queued pointer motions coalesce to the last. - GUI: a skipped swapchain image re-arms the frame (3 retries); animations still tick while nothing presents. - Editing: the line index is carried across an edit instead of rebuilt from a scan of the whole file per keystroke. Messages fall into their row (ease-in; the GUI slides the band out from under the tagline, a terminal fades it), stay until the next input as before, linger MessageLinger ms (default 800), and dissolve (ease-out). MessageAnimation toggles it; both are settings, in Config and startup files. The snapshot harness pins the old behaviour. The detached server now ticks animations. A restored terminal comes back live: the old screen and scrollback (dumped as clean VT by ghostty's formatter, replayed at the new size; older dumps fall back to their rendered text), a dim "restored history" marker, then a new shell in the directory it was in. Right-click on a line number in a file pane looks at that line (a sticky context header's number included). Measured with an external pty driver (TTY), an in-process fence trace (GUI, PARDES_TEST_LATENCY), and test/pdf_pointer_bench.zig (pixel identity against the baseline and a whole-page oracle); balanced A/A/B rounds, paired per-round statistics. Messages stack: each event gets its own row and its own fall, linger and dissolve; a line keeps its row until it leaves and a new one fills the first free row. Announcements and statuses are replaced in place, not stacked. MessageFall, MessageDissolve and DumpDir are settings Config reports. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Merge the macOS first-class-host workGabriel Schneider39 hours
|\
| * Make the macOS shell a first-class hostGabriel Schneider39 hours
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Pty children are exec'd with their own TERM/COLORTERM/TERM_PROGRAM instead of inheriting a .app launch's empty environment, and ttyTaken finally answers on darwin — libproc walks the tty's foreground process group — so Escape reaches the child and Exec stops believing every pane sits at its prompt. The occupancy suite runs on both platforms now. The workspace tag row moves into the native menu bar as a Builtins menu. -Dworkspace-tag (default off for -Dplatform=macos, on everywhere else) drives it, and Pardes.topBarHeight replaces the TOPBAR_H constant so the core stops reserving the row. The view pins every variable-font axis to the file's own default (Maple Mono came up Thin otherwise), shapes ligatures, carries per-shape pointer cursors, and draws the look-hover affordance as refracted glass. Tag rows fill edge to edge, with the anchor box painted back on top of that fill and its mode glyph centred on the same square. Theme accents re-saturated across the set. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* | Advertise the editor in the posted-9P registryGabriel Schneider39 hours
|/ | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | pardes spoke 9P and could be mounted, but only by naming its socket: $XDG_RUNTIME_DIR/pardes-9p-<name>.sock sits one directory above the registry and nothing could find it. Now a listening editor advertises itself at $XDG_RUNTIME_DIR/9p/pardes/<name>, a symlink to the socket it already binds. One directory for the program, one entry per editor — the layout zmx posts its sessions under — so several editors group rather than crowd the registry root. The socket does not move: adopting the registry only advertises. Only the runtime-directory socket posts, so an instance on the ~/.local/state fallback stays out of the user's registry, the way a private ZMX_DIR does for zmx. Stopping unposts, and only while the entry is still ours, so a name another editor has since claimed is never unlinked. The cloud9 pin moves to 9c4d668c for cloud9.post's path helpers. Serving is the whole of it. Consuming the registry is 9ns's job: it mounts the lot at /mnt/9p and an interactive fish already self-wraps in one, so a pardes started from a terminal reads /mnt/9p/harness/... with the same code that reads any other path. Two drafts that taught `resolve` to dial the registry itself were reverted — one duplicated 9ns for no gain, the other reinterpreted relative dials, which are a feature. `resolve` is byte-identical to what it was, and no dial that worked changes meaning. What pardes still does not do, and why, is in docs/cloud9.md: it binds its own socket rather than posting through cloud9.post, because post claims flat names only — legalName rejects '/', and claimName derives its lock directory by stripping "/9p" — so a name inside a subdirectory cannot go through it. zmx hand-rolls the same symlink for the same reason. Unifying them means teaching post a group, which is a change to adversarially-hardened code rather than a rename. docs/divergences.md records what this bookmark move leaves beside it: the editor line rruwvuzm (~1300 lines, forked at 01104e7c, still on the old cloud9 pin), the other bookmarks, and two failures that are not this change — fs-test's syntax-highlighting assertion, which fails identically on a clean main, and pardes not starting headless, which is why this is covered by 9p-io-test rather than by running the editor. Tests: 11/11 9p-io-test, including a listener that posts on start and unposts on stop; 31/31 unit-test.
* Flatten the 9P control tree and move it out of fs.zigGabriel Schneider39 hours
| | | | | | | | | | | | | | | The served tree loses the self/ level: /index /ctl /new /log /screen /listeners /pane/<n>/... /os, with /src only in -Dembed-sources=true builds (default off, on for esp32p4). ctl speaks the editor's own language with two lowercase verbs, look TEXT and exec TEXT, plus acme's addr verbs; the new/ factory directory becomes one clone file; cons is gone (exec Msg); name and sel are files; stats report real lengths, modes and mtimes; /log streams pane new/del/rename/save events. The tree code lives in src/ninep/ (tree, pane, ctl, addr, pty, events, screen, sources); fs.zig keeps host access, mounts, resolution and find/grep. Same engine and transports. README (fs-help.txt) and docs rewritten; tests updated and extended. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* Reuse bounded source analysis and speed up result context traversalGabriel Schneider39 hours
|
* Release Pardes 0.3 and stamp the macOS package versionGabriel Schneider39 hours
|
* Add Linux Tty9p mounted terminals and forward raw TTY keysGabriel Schneider2026-09-15
|
* 9p: use cloud9 protocol sessions and transportsGabriel Schneider2026-09-15
|
* macos: merge local trackpad gestures with current upstreamGabriel Schneider2026-09-07
|\
* | Refactor panes and filesystem; replace FUSE with 9PGabriel Schneider2026-09-07
|/ | | | | | Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples. Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill.
* hosts: the effects three shells kept a copy of become one, and the mac's own ↵Gabriel Schneider2026-09-02
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | bugs go with them Nine read-only scouts compared every host-side concern across `src/macos.zig`, `src/tty/tty.zig`, `src/gui/gui.zig` and `src/detached/server.zig`. What they found was not a style problem: each duplicated body had drifted, and in every case the drift WAS a bug the users of that shell could see. So the fixes and the deduplication are the same change. **One PATH, adopted before the first fork.** LaunchServices hands a bundle launchd's environment, whose `PATH` is `/usr/bin:/bin:/usr/sbin:/sbin`. Every pty shell, `|` filter and language server the app forked inherited it, so `yazi` in `/opt/homebrew/bin` was absent from a Dock launch and present in the identical binary run from a terminal — the "it worked briefly" window was simply the sessions started from a shell. `shell_bin.adoptSystemPath` composes `/etc/paths` then `/etc/paths.d/*` in the order `path_helper` reads them, deduplicating on first occurrence, and runs once at startup in all four native hosts. It APPENDS: an entry already present keeps its position, so running it over a real session cannot demote a mise shim behind `/usr/bin` and silently change which `node` runs. A `PATH` that was configured is left byte-for-byte alone; only one nobody configured is repaired. `prepareForFork` folds that adoption together with the prompt-rc staging and the `BASH_SILENCE_DEPRECATION_WARNING` setenv the five hand-copied prefork sites had between them — `server.zig` had none of it, which is why every detached pane opened with Apple's zsh banner. **The LSP protocol client never worked on macOS.** It opened its control socket with `libc.SOCK.CLOEXEC`; Zig defines that constant for Linux and Darwin answers `socketpair` with `EPROTONOSUPPORT`, so the call failed before any fork, `ensure` returned `error.NoServer`, and every row in the spec table — rust-analyzer, clangd, gopls — was unreachable in every macOS build. The in-process ZLS backend kept answering, which is what made it read as "only Zig is supported". It is a plain socket plus `fcntl(FD_CLOEXEC)` now, the route `fuse.zig:943` and `nested.zig:95` already took for the same reason. The snapshot suite that covered this path had never run natively on a Mac: the harness targets defaulted to x86_64-linux. **One LSP host worker.** `src/lsp_host.zig` is the snapshot, the worker body and the job lifetime that `tty.zig` and `gui.zig` carried verbatim — `gui.zig` said so in a comment — and that `macos.zig` did not carry at all: `lsp` and `pipe` were absent from its `Host.VTable`, so the core answered its own empty answer, `SPC l i` rendered a blank panel and a `|` filter silently did nothing. All three shells share the module, and the AppKit host implements both effects. Its status sink is now REGISTERED as well as defined, so unsolicited server news reaches the message row instead of nowhere. **The animation clock measures time.** `pardes_animation_tick` advanced one scene frame per callback and published `frame_count / 60`, so scene time was a count of callbacks rather than elapsed seconds — and `AppDelegate` re-armed `asyncAfter(.now() + 0.016)` only after the previous frame's work had finished, making the true period 16 ms plus all of it. Motion ran at about three quarters of wall clock and unevenly. The tick now spends measured monotonic time in whole `frame_ns` steps and banks the remainder, so a late callback advances two frames instead of stretching one; `spendTickTime` is that arithmetic as a pure function with its own tests and no display attached. On macOS 14+ the animating run is one `CADisplayLink` phase-locked to vsync rather than a chain rebuilt after every frame; macOS 13 keeps the old chain. **Three more single definitions.** `panel_animation.paintOrder` is the moving-then-opening-then-closing composite order as a rule the core applies once in `Pardes.render` — `macos.zig` was re-sorting an already-sorted list. `selection_pipe.Tasks` is the bounded in-flight pipe table `tty.zig` and `gui.zig` each declared. `boxContains` was a fourth copy of the half-open cell test and is now an alias of `Box.contains`. **A filtered terminal stops asking libm per cell.** `Filter`'s legibility stage called `RGB.contrast` for every painted cell, and that ends in `std.math.pow` up to six times, re-deriving a ratio against a background that had not moved; the existing memo cache covered the palette reduction beside it and never this. The indexed path's input is a `u8`, so all 256 answers are enumerated once per pass — after the default roles are fixed, before the first cell is read — and what a cell names becomes an array index. Only truecolour still reduces. ReleaseFast, 190x56, Tracy: recolour 3.09 ms -> 0.130 ms, frame 3.37 ms -> 0.299 ms. The comptime luminance table is pinned to `RGB.luminance` and `RGB.contrast` by exact-equality test over every channel value and all 65 536 palette pairs, because the decision is a threshold comparison where one ULP is a different colour. A `filterInit` Tracy zone records the part that is still per-pass: 2.9 us warm against a 117 us pass, which is the measurement that says not to cache it across frames. Released as 0.0.2. `build.zig.zon` carries the version into `pardes --version` and into the `Changelog` pane through `@embedFile`, so the entries above open a `## 0.0.2` section and `## 0.0.1` closes with the tagline work of the parent commit. Two bugs here were mine, caught by review rather than by me: a double free in the macOS pipe drain arm (`Msg.free` already owns the response) that segfaulted the app on the first `|`, and a proposed `getRowAndCell` optimisation that targeted 2 of 43 draw samples while the contrast math beside it took 12 — and would not have compiled. The profile that justified it was a Debug build, which `build.zig:1160` already documents as ~5x slower than release. Native and -Dplatform=macos suites: 0 failures. All targets build with Tracy on and off; the shipped release binary contains no `___tracy_emit_zone_begin`. App reinstalled, signature verified, dmg regenerated, launched with 0 crash reports; installed binaries verified byte-identical to a fresh build.
* lsp: a protocol client for every other language, narrated on the message rowGabriel Schneider2026-09-01
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The seam grows a second backend: src/lsp/lsp_client.zig speaks JSON-RPC to child language servers — rust-analyzer, clangd, gopls, tsserver, pyright are rows in a spec table — while the in-process ZLS analyser keeps .zig. One reader thread per server owns the socket, routes responses to a mailbox under the conn mutex (monotonic condvar), answers server-to-client requests, feeds the diagnostics store, and narrates $/progress and state changes through a status sink both native shells post to the transient message row: "rust-analyzer: cargo check 88% 955/1083" lands where a save narrates, with the same clock. Chatty progress is throttled and deduplicated; settled states always land, which is also what makes the goldens deterministic. Nothing wedges and nothing healthy dies: waits are deadline-bounded, a timeout cancels and returns no rows, three consecutive timeouts restart the server ONLY while it is idle (an indexing server is narrating its own excuse), spawn and handshake failures back off 10s to 2min, a crash shortly after ready counts as a failure, and only a missing binary disables a spec. PARDES_LSP_{RS,C,GO,TS,PY} override binaries; empty disables; the snapshot harness pins RS to test/lspmock.zig and empties the rest. Mutating answers really mutate now: the @put record beside rename @edit carries per-range text, so = applies the formatter (both backends) and a same-file WorkspaceEdit rename applies atomically, one undo step, narrated ("renamed 2 range(s)"); a multi-file rename previews as rows instead of half-applying. Malformed responses fail closed: coordinates validated not clamped, one bad TextEdit poisons the whole edit set, poison frames kill the connection instead of buffering forever, decoded control bytes reject a uri, hierarchy items too deep to reserialize are skipped. Four kinds helix does not have, on SPC l: c/C incoming/outgoing calls (rows are call sites), t/T super/subtypes. Pull diagnostics (3.17) preferred when advertised. Help gains a language-keys footer for the motions no builtin row could carry; lsp.rel and look.grep now share one path-shortening rule. zig build lspprobe drives the seam from the CLI (comma-separated kinds share one server); measured against a 1083-crate workspace warm: gd 26ms, gr 213 rows 165ms, incoming calls 212 sites 197ms, document symbols 670 rows 347ms. docs/lsp.md tells the whole story; lsp-evaluation.md gets an addendum.
* build: pardes builds without the ESP32-P4 toolchain checkout beside itGabriel Schneider2026-08-27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `build.zig.zon` named `../05-zig-p4` as a path dependency, and `build.zig` `@import`ed it inside `if (-Desp32p4-firmware)`. But `@import` in a build script is resolved when the SCRIPT is compiled, not when the branch that needs it is taken -- so naming the package at all meant anyone without that sibling checkout could not build pardes AT ALL. Not the firmware: the terminal shell, the SDL shell, the tests. `zig build` failed with build.zig:1073: error: no module named 'zig_p4' available within module 'root.@build' from a line inside an `if` that was false. Neither escape hatch works for a PATH dependency, and both were tried rather than assumed. `.lazy = true` is about FETCHING; a path dep whose directory is absent is generated as a package with no `build.zig` rather than one marked unavailable, so `b.lazyImport` -- which exists for exactly this and is what the standard library says is to `@import` what `lazyDependency` is to `dependency` -- reaches a `@compileError` instead of returning null. Making it a fetched dependency instead is not available either: the toolchain has no remote. So the duplicate goes. That build tree's firmware block linked an image the toolchain repository already knows how to link -- its own build.zig has `-Dpardes`, `-Dapp=<root>` and `-Dpardes-obj=<path>`, and its comments record having learned this same lesson from the other direction, where nesting pardes's ~30-package graph under it broke every build there. The object is the seam: it crosses by PATH and never by package, and each repository builds what it owns the pieces of. zig build -Dplatform=esp32p4 # here, no toolchain needed zig build -Dpardes # there, the console image zig build -Dpardes -Dapp=<pardes>/src/esp32p4_9p.zig # there, the 9P image For the second and third to work with no module map, `src/board9p.zig` and the 9P firmware root now reach the codec by PATH instead of through a named `ninep` module that only pardes's own build.zig knew to inject -- which is also why the root moved from `src/esp32p4/nine.zig` up to `src/esp32p4_9p.zig`, beside `src/esp32p4.zig`: a path import may not escape its module's own directory. Both files are now self-contained, and `zig test src/board9p.zig` works with no flags. `-Desp32p4-port`, `-Desp32p4-prof` and `-Desp32p4-cpu-mhz` go with the block. An option this build cannot honour is worse than no option, because it accepts the flag and then ignores it; all three are spelled the same way in the toolchain. Verified by moving ../05-zig-p4 out of the way: `zig build`, `zig build -Dplatform=esp32p4` and `zig build unit-test` all pass without it. With it back, the toolchain still links both images -- console 812,720 B, 9P 88,096 B. next-steps.txt gains the six features the 9P chain shipped.
* 9p: serve the acme tree over 9P2000 on a unix socket, beside the mountGabriel Schneider2026-08-27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Step 4 of the 9P chain (docs/9p.typ 12.4, docs/registry.typ 9P-15/16/17/4/5). src/9p.zig is a base 9P2000 codec and a SANS-IO server: it never touches a descriptor, takes no allocator, starts no thread, and builds for wasm32-freestanding and riscv32-freestanding. That is what lets the same code serve a unix socket here and a UART on the board later. Server(comptime fs: type) duck-typed on fs.Req/fs.Reply/fs.Reply.Attr, so it never imports acmefs and acmefs never learns 9P init{ in, out, root } the caller owns the buffers; msize is derived retry/next/reply the three fs_service.Transport ops, by name push/output/wrote/hangup bytes in, bytes out, partial writes supported next() is a PUMP, not one-message-one-request: a 3-element Twalk is three lookups, Topen|OTRUNC is a setattr then an open, Tversion is none at all. Decisions that were open and are now taken, each recorded in the file: * qid.version is ALWAYS 0, which makes Linux set P9L_DIRECT and skip its cache -- the 9P equivalent of the FOPEN_DIRECT_IO fuse.zig relies on. * Every Rread is clamped to the client's count. An over-long one is a hard -EIO in Linux, not a truncation. * Rerror carries Linux's exact strerror text (registry 9P-4 option A), so a mount recovers the errno instead of ESERVERFAULT. Asserted as literals, because a typo there is 'Unknown error 526' on every mount. * `.` and `..` are resolved BY THE SERVER. Under FUSE the kernel does it and acmefs says so; 9P has no kernel, and forwarding `..` as a lookup would break every client that normalises a path. * Topen checks the perm bits itself. Under FUSE the kernel enforced them; over 9P nobody is above the server, and `errors` would have been readable. * Tcreate and Tremove are Rerror: `new/` creates a pane on WALK, so the capability exists and is not spelled Tcreate. THE INTEGRATION BUG, which was not in the protocol: the daemon's push_fs_reply sent every reply to the FUSE mount, whose park table has no 9P tag, so it dropped it -- Tversion worked (no core involved) and Tattach hung forever. That is exactly the 'no routing origin for the 9P descriptor' cell in the layering table of docs/9p.typ. Session.fs_origin now carries the transport that asked. Proved with plan9port against a live daemon serving BOTH transports at once: 9p ls / and /1, read index/ctl/tag, write /1/body, stat, a walk through /1/../index, pane creation through `new/body`, and the two refusals arriving as strings -- 'permission denied' and 'No such file or directory' -- confirmed on the raw wire as Rerror text rather than numbers. A write over 9P reads back through FUSE and a write through FUSE reads back over 9P. msize 8192, 34,072 bytes per connection (Server 9,488 + in 8,192 + out 16,384, out being two msize so that every reply is infallible), four connections. zig build unit-test: 468 tests before, 503 after.
* An edited row keeps its colours, four copies of forkShell become one, and ↵Gabriel Schneider2026-08-27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Esc stops recentring ## A terminal row's ANSI colours survive being edited The loudest colour bug this editor had: one keystroke anywhere in a coloured shell row turned EVERY column of it grey. `EditAnchors` anchored a buffer line only when it was BYTE-IDENTICAL to the shell row it stood over, so a single differing byte dropped the whole row's colour projection. Worst shape is invisible: append past the pane's right edge, where the text is clipped, and the row looks the same and only its colour goes. Anchoring is byte-level now. An edit leaves the row's own bytes at both ends, and being the same bytes they keep the same colours; only what was typed has no cell under it, so only that takes none. Live, on real `fastfetch`: a 32-column blue run split into 6 + 26 around one typed character. Three defects underneath it, all found by machinery rather than by reading: * A JOIN removes a buffer line while the buffer's covered span grows, so `lines == covered` and both aligned guesses — Nth line over the Nth covered row, and the same counted from the bottom — resolved to the SAME wrong row. Every untouched row below a join went plain. Anchoring is now a streaming monotone matching: one shell-row cursor that only ever moves forward, advanced once per buffer line, linear in the buffer where the version before it was quadratic. * An EMPTY line is not evidence. Splitting a row makes one, it equals every blank row in the span, and left free to look ahead it claimed the blank row below the last output and took every coloured row in between out of reach of the lines that owned them. * Reflow under a scrolled viewport. `PageList.getTopLeft(.viewport)` returns the viewport pin verbatim, x and all, while `PageList.pin` forces x to 0 — so after a reflow remapped a tracked pin into the middle of a row, the text pass dumped row 0 from that column while the colour pass paired the fragment with the row's FIRST cells. Row 0 wore its left half's colours until the pane snapped back to live output. `bodyText` dumps from column zero now, which is also what ghostty's own renderer draws. Also here: DECSCNM (reverse video) was silently dropped whenever `tty_filter` was off, because the raw path resolved a `.none` colour by role and never consulted the mode. The test that found the first two is the one worth keeping: random editing against an ABSOLUTE oracle — every row's own text names the colour it must have — because the differential oracle it replaced was blind by construction. It skipped the edited row, which is the row the user is complaining about. ## Esc returns to a pane without moving its view Esc in body normal mode runs `Last`, "the pane you were in before this one", and that went through `focusPaneLine`, which recentred a file on the target line unconditionally. So returning to a buffer repainted the whole screen to show a line that was already on it. `focusPaneLine` takes a landing now: `.center` for the three callers going somewhere you have not been (a look target, a path a pane already holds, `@pN:LINE:COL`), `.keep` for Esc. `.keep` leaves the view alone and lets `ensureCursorVisible` — which already existed and already scrolls by the minimum into the `scroll_off` band — be the only thing that may move anything. Not `line = 0`, which `focusPaneLine` already understands as "focus and touch nothing": a background pane's view can move while you are away, because the wheel scrolls the pane under the POINTER and a resize reveals no cursor, so the recorded cursor plus a minimal nudge is what actually gets you back. Ctrl-o and Ctrl-i keep centring, and the asymmetry is structural rather than arbitrary: `Last` only ever CROSSES panes, so the pane it lands on already holds the view you left it with, while `jumpBy` can land in the SAME pane, where a long in-file jump would arrive on the very top or bottom row with `scroll_off` lines of context on one side. Helix splits the same pair the same way — its jumplist centres, its buffer switch does not. One deliberate consequence: under `.keep` a PDF's page is not restored AT ALL, because a page reveal IS that pane's view and a reveal of the page you are already on still snaps `document_scroll_y` to that page's start, discarding where you had read to. When something moved the pane while you were away — the wheel again — Esc leaves it where the wheel left it, and Ctrl-o is how you reach the recorded page. ## host_io.zig: the machine-local half of a host, once `host.zig` is the seam. The part of the answer that is identical on every host with an operating system under it — fork a pane's shell, put bytes on a disk — was written FOUR times: in tty.zig, gui.zig, macos.zig and detached/server.zig. What those copies had in common says what they were for: all four were missing FD_CLOEXEC on the pty master, so in every shell pardes has shipped, a program in one pane could read another pane's terminal. One copy now, and the wire got smaller for it: `ServerMsg.spawn` is gone. A frontend never asked the server to fork anything — the server has an operating system under it and forks through `host_io` like every other host — and `decodeClient` lost the scratch buffer that message needed.
* One core behind N frontends, the board's own runner moved in, and every ↵Gabriel Schneider2026-08-27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | board cap on one screen ## The wire is the effect stream, not a new protocol `pardes --detach` leaves a core running with no terminal; `pardes --attach` is a frontend that owns a terminal and a socket and nothing else. N frontends on one core all look at the same screen — `screen -x`, not N sessions. The codec (`src/detached/wire.zig`) carries exactly one `Event` or one `Host.VTable` call per message. That is not a coincidence and it is why there is no third vocabulary to keep in step: the core's IO seam was already a struct of function pointers with plain-data arguments, so a socket is a legal implementation of it. `nested.zig`'s socket could not be reused — it carries a builtin command line, and a command line cannot carry a frame. ARCHITECTURE-NEUTRAL on purpose, not as decoration. The frontend on the far end may be riscv32-freestanding on the ESP32-P4 while the core is x86_64 Linux, so every field is an explicit little-endian fixed width and no message is a blit of a native struct. A protocol that only works between two builds of the same compiler would have thrown away the one frontend that motivated it. ## The board comes in; its toolchain stays out `src/p4.zig` becomes `src/esp32p4.zig`, and the pardes half of `../05-zig-p4` — the vaxis-over- serial runner, the UART editor terminal, the keystroke rescue ring, the on-die test suite — moves into `src/esp32p4/`. `build.zig.zon` gains `.zig_p4 = .{ .path = "../05-zig-p4" }`, so `zig build -Dplatform=esp32p4 -Desp32p4-firmware` builds, flashes, monitors and self-tests the board from this repo's `build.zig`. The DIVISION is the point. What moved is what only pardes wants: the runner that drives a pardes core over a serial line. What stayed is everything a second project would also want — the HAL, the register/radio/oracle layers, the linker script, `_start`. `zig_p4` declares no dependencies of its own and its `build()` early-returns when it is not the root package, so this costs the package graph exactly zero packages and the editor's own builds nothing at all. ## limits.zig: nine forgettable places become one budget Nine `platform == .esp32p4` capacity tests lived in nine files. They were never nine decisions — they are ONE decision, how much memory this build may spend, taken nine times where no reader could see the total. `src/limits.zig` puts the whole budget on one screen with every cap named against what it is measured against, derived from two booleans. The payoff is testability on a machine that is not the board: the caps are ordinary comptime values, so a host build can be compiled against the board's numbers and the parking, eviction and clamping paths a 240 KiB core takes get exercised by the normal test suite instead of only over a UART. ## A bare `zig build` `zig build` with no arguments now builds the tty and GUI binaries and installs them into `~/.local/bin`, and says so once on stdout with the flag that overrides it. The old default built one binary into `zig-out` — a path nothing on a `PATH` ever looks at, which made "build it" and "use it" two different commands for no reason.
* Make the chrome fade a build option, and compile it out for the boardGabriel Schneider2026-08-26
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A theme change moves the anchored chrome palette - taglines, boxes, line numbers, scroll bars - from the old colors to the new ones over ten display frames. On a screen that repaints in microseconds that is a short legible transition, and it is why the code exists: a palette that teleports reads as a glitch. On a 115200 serial line it is not a fade. Each of the ten steps recolors every anchored cell, so the diff finds the whole chrome dirty and spends a frame's worth of wire on it, ten times over, with nothing else on screen to look at. Measured on the die, one `NextColor`: fade on 12,593 bytes 1,097 ms of saturated wire fade off 2,425 bytes 215 ms A second of the editor talking to itself about a color, on the one transport where a second is noticeable, for a gradient nobody can watch arrive at 11.5 KB/s. ## Comptime, so the code is not there `ChromeAnimation` now selects between `animation.Transition` and a new `animation.Immediate` - the same interface with the animation taken out, a value that is only ever what it was last set to. That is what makes `ChromeTheme.interpolate` unreachable, and unreachable is what makes it absent: the flashed image drops 2,336 bytes, and the object 13,180. A bool tested at runtime would have kept every one of those bytes and still paid the branch. It also would have needed a second meaning bolted onto `animate_theme_changes`, whose job is the startup window and nothing else; that field is untouched here. The option is `-Dtheme-animation`, defaulting to off for `p4` and on everywhere else, and it is an option rather than a platform test because "is a frame expensive" is a property of the transport: a P4 driven over something faster than a UART would want the fade back, and `-Dtheme-animation=true` gives it to them. ## What was checked `Immediate` is new code with one contract worth pinning, and it is the one a caller could get wrong: it must arrive at the SAME palette a completed fade arrives at. An endpoint that differed by a rounding step would make the option a change of colors rather than a change of how long they take. Tested against a fully advanced `Transition` in `animation.zig`. Full suite: unit-test, snap 95/95, hxdiff 481/0, hxparity 561/0, image-harness, pdf-harness, mupdf-check. Builds: tty, p4, gui, and tty/gui with the fade forced off. On the die the canonical verifier reports the screen IDENTICAL across both arms - the workload contains no theme change, so this is the check that ordinary rendering was not perturbed - and `p4-bench --check` stays 4/4.
* Raise the board's grid to 56x14, and make it a build optionGabriel Schneider2026-08-26
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The 40x12 ceiling was never about the screen. It was about memory, and the comment above `max_cols` said so: "every cell is paid for four times over: vaxis keeps a Screen and an InternalScreen, pardes keeps its own Surface and previous_cells". Two of those four are now dead weight - with `direct_emit` the emitter diffs the Surface against its own shadow and writes the escapes itself, so vaxis's two grids are allocated, never read, and were the largest single claim on a 384 KiB heap. `init` sizes them to ONE CELL. vaxis still does the work only it can do: the alternate screen, the capability queries, and parsing everything that comes back. That removes the memory ceiling entirely - the heap now reports 336 KB free at every geometry tried, including ones that used to fail - and leaves latency as the only limit, which is the honest one: every frame walks the whole grid. ## Measured on the die, 0.87 us per cell geometry cells round trip 40x12 480 3,628 us the old default 56x14 784 3,930 us the new one 56x16 896 3,965 us 60x18 1,080 4,114 us 64x20 1,280 4,281 us 80x24 1,920 4,809 us 100x30 3,000 5,743 us 120x36 4,320 6,923 us 140x42 5,880 8,310 us the largest that runs 160x48 7,680 links, then traps 200x60 12,000 does not link 56x14 is 63% more area and 40% more width than 40x12 and still holds the 4 ms this port was built to. 56x16 was tried first: 3,965 us on the bench instrument but 4,029 on the phase-randomised one, which is over, and the two instruments differ by about 50 us systematically - so the wider grid went and two rows stayed behind. Width is worth more than height for reading code. The two failures at the top are worth naming precisely because they are different failures. 200x60 does not link: `.bss will not fit in region l2mem, overflowed by 76036 bytes`, that `.bss` being the shell's shadow copy of the grid, sized at comptime. 160x48 links and then TRAPS at boot - the same region pressure arriving at runtime as a collision rather than as a diagnostic. Neither is a heap problem any more, which is the interesting part: the heap has 336 KB spare while `.bss` runs out. `-Dp4-cols` / `-Dp4-rows` because none of the above is a constant. 80x24 is one flag away for anyone who would rather have the classic terminal than the millisecond. Verified at the new geometry rather than assumed: the A/B against the reference path - vaxis rendering, full repaint, `shadow_grid` and `direct_emit` both off - is identical in every cell, characters and resolved style. That matters more here than usual because the emitter's column arithmetic has a special case at the last column, and 40 was the only width it had ever been asked about. snap 95/95, hxdiff 481/0, hxparity 561/0, unit-test, both A/B arms, tty/p4/gui, and the board's own `p4-bench --check`.
* Never build the P4 object Debug; the measured mode becomes the defaultGabriel Schneider2026-08-25
| | | | | | | | | | | | | | | | | | | | | | | | `-Doptimize` defaults to Debug, so the naive `zig build -Dplatform=p4` produced an object that CANNOT RUN. Debug wraps every tier in `allocators.zig` in a `DebugAllocator`, whose metadata is page-granular, and one 4 KiB page per size class does not fit in the 384 KiB the board hands the editor: the image links, flashes, and then dies in `Pardes.init`. Nothing said so, because every build in this session happened to pass `-Doptimize=` explicitly. The p4 target now falls back to ReleaseFast, and that mode was measured rather than preferred. On the die, against ReleaseSmall over 5 document lengths x 7 trials: configuration fixed per char at 160 chars ReleaseSmall 16.99 ms 54.3 us 25.56 ms ReleaseFast 14.85 ms 34.7 us 20.30 ms 0.79x 13% off the fixed per-keystroke cost, 36% off the per-character cost, for 35% more flash on a partition that is 39% used. An explicit `-Doptimize=` still wins, so ReleaseSmall stays one flag away when flash matters more than latency - which is why this is a fallback and not a hard override. Following the file's own convention: the web target has pinned ReleaseSmall unconditionally for the same kind of reason (size is its budget) since before this.
* A fourth platform: pardes as ESP32-P4 firmware, bytes in and bytes outGabriel Schneider2026-08-25
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `-Dplatform=p4 -Dtarget=riscv32-freestanding` emits a single freestanding OBJECT exporting a seven-function C ABI, not an executable. The board's toolchain (../05-zig-p4) owns `_start`, the linker script and the UART driver and links this in. The seam is bytes rather than types, so neither side can accidentally depend on the other's internals, and a signature that drifts fails at link time. The serial line is the whole of the I/O. `src/p4.zig` drives vaxis unchanged over it: the renderer is a byte writer and `queryTerminalSend` is a byte writer, so the terminal emulator on the host answers the capability handshake and the firmware sees a real terminal. Measured going out over the wire on attach: alt screen, in-band resize, cursor report, kitty keyboard, kitty graphics, DA1. THREE WORDS EXIST ONLY HERE. `src/board_memory.zig` implements `Peek`, `Poke` and `Hexdump`, gated on `builtin.os.tag == .freestanding and !isWasm()` - derived from the TARGET, because they are a property of running with no OS under you rather than a product option, and because wasm is freestanding too and is exactly what must be excluded: in a browser an address is an offset into the linear memory this editor's own heap lives in. Every access goes through `*allowzero volatile`: a peripheral register is not memory, and address 0 is an ordinary unmapped address on this bus. One 4 KiB cap per command, set by the console rather than the memory - an unbounded dump would wedge the only console the board has for eleven hours. Measured on ESP32-P4 rev v1.3 silicon, driven from a host terminal: Peek 0x501101a4 0x0e63ce71, then 0xaeaa6919 on a second read - the RNG register, so the volatile loads are not folded Poke 0x5011002c 0xdeadbeef LP_STORE0; a later Peek returned 0xdeadbeef Hexdump 0x5011002c 32 16 bytes a row, hex columns and an ASCII gutter Peek 0x50110001 `peek: MisalignedAddress` on the message row That last line is the one that matters. A misaligned 32-bit access traps, and a trap in firmware is a watchdog reset that takes the session with it, so the check that turns it into a message is the reason the file is hand-written rather than a generic reader. BARE METAL BOOTS AN EMPTY OUTPUT BUFFER. Every other boot layout in `init` makes a shell, and on this platform that is not a preference but an impossibility: nothing to fork, no pty to give a terminal pane. Booting one anyway produced precisely what that describes - a pane whose tag ends in `Filter`, no gutter, no buffer, and every keystroke vanishing into the Fallback's silent pty. An output buffer is also what the platform's own words want, since Peek, Poke and Hexdump each fill one. Sized for the board rather than for a desktop: * `allocators.zig` gains a p4 tier that is ALL fallback - every capacity is zero, so each arena spills immediately to the 384 KiB heap the firmware hands over, and no megabyte-shaped static reservation lands in `.bss`. * `source_manifest.zig`'s allowlist is EMPTY on p4. The table is ~0.95 MiB of rodata against a 1.5 MiB flash partition; the firmware's filesystem is the serial host's, through the Host vtable. * The grid is clamped and the clamp is measured, not guessed: every cell is paid for four times (vaxis Screen + InternalScreen, pardes Surface + previous_cells), so 40x12 fits and 80x24 exhausts the heap during `Pardes.init`. * `Vaxis.resize` deinits both screens before allocating replacements, so a failed resize leaves vaxis rendering nothing. The p4 shell keeps the previous geometry on failure instead of leaving a half-applied one. Also here: `output_pane_integration_test.zig` had an exhaustive switch over `Platform` that adding `.p4` left unhandled, which broke `zig build unit-test` outright - the native test binary is the one consumer no platform build compiles. 346 tests pass again.
* tests: a capture is a delta and a click names its word, so a tagline edit ↵Gabriel Schneider2026-08-25
| | | | stops rewriting the suite
* acmefs: pardes --fs serves acme's control filesystem over raw Linux FUSEGabriel Schneider2026-08-25
|
* host: the core owns the event loop; every platform becomes a vtable of ↵Gabriel Schneider2026-08-25
| | | | optional methods
* term_pane + builtins: terminal pane work, builtins/config additions, snapshotsGabriel Schneider2026-08-18
|
* modal + file_pane: rework editing math and pane behavior, config/syntax ↵Gabriel Schneider2026-08-18
| | | | additions, unit tests
* big slow change: prebuilt shaders (SPIR-V/Metal), core gui reflow, docs, web ↵Gabriel Schneider2026-08-18
| | | | + snapshot refresh
* pdf: continuous scroll bench harness and per-frame render pathGabriel Schneider2026-08-15
|
* look: richer path/range parsing, pdf rendering, corner-drag and stepgrain ↵Gabriel Schneider2026-08-15
| | | | snapshots
* shaders: -Dprebuilt-shaders, so a gui build needs no Vulkan SDKGabriel Schneider2026-08-12
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | glslc is the one build input that wants a tool a stock machine does not have, and it is also the input that changes least often: eight GLSL files that have outlived several rewrites of everything around them. Asking every machine that wants to run the SDL shell for shaderc is the wrong trade. The SPIR-V is now COMMITTED, under shaders/prebuilt/, and -Dprebuilt-shaders embeds that copy instead of shelling out. The default stays the honest one -- compile the shaders that are actually in the tree -- because the flag trades a dependency for a freshness problem: with it on, the .glsl sources are not build inputs at all, so editing one changes nothing. `zig build shaders` is the other half, and it is deliberately independent of -Dplatform: it recompiles every shader and writes the result back into the tracked directory, so whoever changes a shader refreshes the cache on a machine that has the compiler and commits the diff. `jj diff shaders/prebuilt` after it is the freshness check -- empty means the cache was already current. The shader list is also spelled once now (gui_shaders): the eight embeds, the eight glslc runs and the refresh step all read it, so adding a shader is a name there plus the @embedFile in gui.zig, not three edits in two places. Verified: -Dplatform=gui -Dprebuilt-shaders builds with glslc absent from PATH, and image-harness passes on that binary -- real SDL GPU pipelines built from the committed SPIR-V, 512 source pixels read back. The default gui build still runs the eight glslc steps; tty runs none. The committed bytes are identical to a fresh glslc run, and `zig build shaders` is idempotent.