| Commit message (Collapse) | Author | Age |
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Pure move, no behaviour change: the Message stamp helpers, LoggedMessage,
setStatus, setMessage, showMessage, dismissMessage, dismissLine,
advanceMessages, advanceLine, messagesAnimating, MessageMotion,
messageFrames, messageMotion, noticeText, noticeLife, blendRgb,
logMessage, messageLog, reportError, the notice painters (leaderText,
noticeCols, Printed, printRight), collectNotices, and the six message
tests go verbatim to Messages.zig.
The methods become free functions taking `p: *Pardes`. setStatus,
setMessage and reportError are called from ~170 places as `p.setMessage(..)`,
so Pardes keeps three declaration aliases (`pub const setMessage =
Messages.setMessage;`) and those call sites stay as they are; every other
call changes from `p.x(..)` to `Messages.x(p, ..)` (46 of them). The five
shells' `pardes.Pardes.Message` become `pardes.Messages.Message`. The
message ring's fields stay on Pardes for now; moving them into Messages is
a separate change.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Pure move, no behaviour change: submitPipe, pipeRequest, pipeFailed,
pipeCut, pipeOutput and pipeResponse, the PendingPipe they share,
pipeMarker, and the eight pipe tests with their nextPipeEffect helper go
verbatim to the end of selection_pipe.zig, so the whole of `|` (runner,
boundary values, prompt, request and atomic edit) is one file. Inside that
file the `selection_pipe.` prefix drops; the file doc now says it holds both
halves.
The methods become free functions taking `p: *Pardes`: the four shells'
`core.pipeRequest(id)` become `selection_pipe.pipeRequest(core, id)`, and
pardes.zig's two calls change the same way. pushUndo becomes pub because
the pipe's edit calls it; PendingPipe.deinit becomes pub for Pardes.deinit.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Any new glyph re-uploaded the whole 2048x2048 atlas: 4 MB copied into the
transfer buffer and then to the texture, about half a millisecond of CPU on
the frame, and the GPU's copy on top. Glyphs fill the atlas pen-style, a band
of rows at a time, so a frame's new glyphs sit in a band a cell or two tall.
The atlas now keeps the rows drawn into since the last upload (cachedGlyph
widens them by each raster's rows: plain glyphs, ligature strips, tagline and
grip glyphs alike), and uploadAtlas copies and uploads just those rows, at the
same offset in the transfer buffer as in the stage; the texture keeps the
rest. A reset (a font, size or tagline change) marks every row, as
the texture's first upload does, so those still send the whole atlas. A test
replays uploads over glyphs of every kind and a reset, and checks that no row
outside the dirty rows ever differs from what the texture holds.
Hidden captures of 27 layouts, including new glyphs typed and printed, font
and size switches, and Ligatures off and on, are byte-identical to before.
Over 10 interleaved rounds (160x50 cells), a frame that draws new glyphs
spends 12-50 us uploading instead of about 0.5 ms: a terminal printing twelve
new glyphs a line goes from 1.04 to 0.55 ms of renderFrame CPU on such frames
(-34% over the scenario's frames, 9 of 10 rounds faster, -20% to the GPU
fence), a typed glyph's frame from 0.89 to 0.38 ms, and opening a file of 600
new glyphs from 9.1 to 8.7 ms. The first frame and a font size change still
upload everything and cost what they did; other frames are unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
for each cell
renderFrame skips the grid cells a body or tag layer paints itself, and it
asked which those are cell by cell: for every cell, twice a frame (counting
instances, then emitting them), bodyLayerAt and tagLayerIn walked all 16 body
layers and all 119 tag layers. In a ReleaseFast profile of a full screen of
src/pardes.zig those walks were about a third of the gui's CPU samples.
coverLayers now fills each live layer's rectangle into a per-cell map once a
frame, and the loops read one byte. The loops only ever asked whether some
layer covers a cell, never which, so overlap order does not enter into it; the
map marks exactly the cells the walks found: a tag layer only its viewport's
first row, and a viewport past the grid only the grid's part of it. The loops
over the layers a panel transition leaves get a second map, built only while
one runs.
A test holds the map to the old walk over a core's layouts (columns, stacked
and collapsed panes, column tags, Tagbottom, message and leader chips) and
3000 random layer sets, some past the grid's edges or at the top of u16.
Hidden captures of 27 layouts (message and prompt chips, a column grip rail,
a PDF, an image, font and tagline changes), with and without leftover edge
pixels, are byte-identical to before.
Over 10 interleaved rounds (160x50 cells), renderFrame's CPU a frame falls
from about 2.0 ms to 0.32-0.37 ms idle, scrolling, typing, under terminal
spew and over a PDF (-82 to -84%, every round), and cell emission from about
1.07 ms to 0.27 ms. The first frame's CPU goes from 6.2 to 4.6 ms.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The SDL shell shapes words with HarfBuzz, so a font's `->` and `!=` draw as
ligatures, and there was no way to have the plain glyphs back short of
changing font. `Ligatures` is a toggle, on by default; off, no cell goes to
the shaper and every cell draws its own glyph, exactly as a font without
ligatures does.
It exists only where it means something. A new `ligatures` capability, true
for the gui shell alone, gates it like Font and WindowOpacity are gated: it is
not a builtin elsewhere, has no leader path, and Config does not list it,
rather than print a row the TTY could never change. macOS draws CoreText
ligatures of its own, but nothing there reads the setting, so it stays off
there. The table check that kept every toggle unconditional now lets the
ligatures toggle, and only it, carry a capability, and requires that it
carry `ligatures`; every other setting's rule is as it was.
The gui keeps the setting beside its text caches, which were resolved under
it: when the core's value changes, the per-codepoint cells (which record
whether a cell is shaped) and the shaped words are dropped, and the frame the
toggle asked for draws every cell again. The atlas keeps its glyphs: plain
ones draw either way, and a ligature's strip is reused when it comes back.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
| |
The GUI's ligature tests shape `->` and `==` with Maple Mono NF, the only
ligature font in assets/, but the font was ignored, so a checkout without a
private copy skipped the tests or ran them without their Maple half, and
passed. The font is SIL OFL 1.1, whose licence is already tracked beside it,
so it is committed and the tests now fail when it is missing instead of
quietly passing.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
the way out
SDL allocated with libc malloc. runNative now installs the shared C heap over
the gui's gpa with SDL_SetMemoryFunctions before SDL's first call: SDL frees
with whichever functions are current, so none of its memory may predate
them. SDL allocates from its own threads too, and the gpa is thread-safe in
every build (a DebugAllocator in Debug, libc's malloc in release).
That put SDL in a Debug build's leak report, and the report filled up: 1769
blocks at exit, because runNative never released its GPU objects, its window
or SDL itself. The process was about to exit, but a report that long hides
any leak that matters. runNative now releases every pipeline, texture,
sampler and buffer the Gui holds, closes the gamepad, destroys the GPU device
and the window, and calls SDL_Quit, all through defers. Eight 32-byte blocks
are left, and they are SDL's: 3.4.4's VULKAN_INTERNAL_DestroyCommandPool
never frees two arrays per command buffer (buffersUsedInPendingTransfers and
texturesUsedInPendingTransfers), which SDL's main branch now frees. They go
away with the next SDL; a comment at the SDL_Quit says so.
The frame-cost harness and a software-present run (PARDES_SOFT_PRESENT) both
run and exit cleanly in Debug.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
first
A message fell in on t*t, an ease-in: it hung near its start for the first
frames and was still moving at its fastest when it hit the row, a sixth of a
row in its last frame. It now decelerates into place with an ease-out cubic,
slide = -(1-t)^3: a quarter of the way in the first frame, and the last
frames only settle it. There is no overshoot. At a row's height and the
dozen frames of the 180 ms fall, a follow-through is a pixel or two, which
reads as a jitter rather than a bounce. MessageFall keeps its 180 ms: the
ease-out puts the chip where it is going within about 100 ms.
The colour leads the motion: alpha = 1 - (1 - min(1, 2t))^2, whole by half
way, so what lands is already legible. The SDL shell used to show the chip
solid while it slid and fade it only on the way out; it now fades it up with
the same alpha, and the rule under a chip carries the chip's fade (a new
TagLayer.fade) instead of standing solid while the chip comes or goes.
The dissolve was an ease-out, (1-t)^2: it lost a tenth of its colour in the
first frame and spent its last frames nearly invisible. An exit eases in
instead, alpha = 1 - t^2 over the same 300 ms: it lets go gently and leaves
faster and faster. t is now counted to the last frame, so that frame shows
it gone rather than it blinking out from 0.3%. 1 - t^3 was the other
candidate; in captures it held the chip nearly untouched for half the
dissolve after the linger had already held it, then dropped a sixth of its
colour a frame, which reads as a pop. It stays in place: the lines stacked
around it do not move, and a drift would read as it leaving its row.
The test pins the shapes: a fast first step into the row, never past it,
solid by half way; a dissolve whose first drop is smaller than its last and
which ends at exactly nothing.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Del takes a side: `Del k` gives the closed pane's rows to the nearest
expanded pane above it, `Del j` to the one below, each falling back to the
other side when it has none. DelAbove and DelBelow are those two lines, with
no path of their own under SPC.
A bare Del started from a key -- SPC d, Enter on the tag word, a row run from
an output buffer -- on a pane with expanded panes both above and below asks
instead of guessing. The question is a prompt like Save's or a search's
(Pane.Prompt.del_side), so it is painted on the pane's notice band by the
same path, and the next key answers it before any mode sees it: k or Up,
j or Down, anything else keeps the pane, as does a click. Only a key press
sets Pardes.can_ask, so a click, a 9P ctl or event write, a startup line, a
restore and a shell exiting all close the pane at once, the rows going
where layout.absorbVWeight has always sent them. A collapsed pane is not
asked about (it has only a tag row to give), and collapsed neighbours are
passed over (layout.expandedNeighbor, which Collapse now uses too).
removePane and absorbVWeight take the recipient; every other caller passes
null. Three scripts that closed a middle pane with SPC d answer k, which
is where the rows went before, and their goldens are unchanged. delask.snap
covers the question, Esc, j, a clicked DelBelow and a clicked Del.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A notice chip -- Msg output, an older message, the leader chord, a prompt --
is a piece of tagline hung over the body's top rows, but in the SDL shell it
ended in a bare edge while the tagline above it ends in a rule. It now has
the tagline's rule along its bottom: the chrome border colour, one pixel,
running on to the window edge when the chip does. A chip still falling into
place brings its rule with it, cut at its row's top like the chip.
The macOS shell already rules every non-workspace tag layer, notices
included; a terminal draws no rule under its tagline, so its chips get none
either.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
shows it
The column grip worked in the GUI but showed nothing anyone could see.
Driving the real SDL window with pixel mouse events, press, drag and
release all reach the core and reorder or resize the column; the feedback
is what failed. The grip kept its muted color while held, and a reorder's
insertion rail was drawn in the gutter cell at the destination edge. The
GUI paints that cell as the column's scroll rail, and at the window's left
edge, the common case of moving the right column first, the dashes were
lost in it. From the leftmost column, a drag short of a neighbour's middle
shows no rail, rightly, since nothing would move, but nothing said the
grip was even held.
A held grip now lights in column_box, the accent its muted color is mixed
from, and the rail takes that color. The rail runs in the seam cell left of
where the column's rule will land, where a border drag's rail runs, for a
reorder and a left-edge move alike; at the window's left edge, which has no
seam, it runs just past the scroll rail, leaving column 0's grip and pane
boxes whole. A carried column's pointer aims at a place, not a word, so the
column tags no longer light the word under it, and a release snaps the
layout as a border drag's does rather than animating it. The preview and the release both
ask layout.columnDrop where the column goes, so the rail stands where the
release puts the edge, and the drag no longer carries its unused column
index.
The GUI test feed takes ESC]777;mouse;<down|up|motion>;<button>;<x>;<y> in
window pixels and dispatches it as an SDL event. test/column_grip.py uses
it to drive the grip through the same pixel-to-cell and tag hit path as a
hand on the mouse, and checks the grip, the rail and the result against
GPU captures and the 9P grid.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
stb_truetype sources
Bold in the SDL shell was a smear of the finished glyph: every pixel took
the larger of itself and three quarters of its left neighbour. That added a
grey fringe on the right of each stem instead of a thicker stem, so bold read
as blurred regular text, and at large sizes it barely read as bold at all.
The rasterizer now does what Ghostty's FreeType face does: it loads the
hinted glyph, thickens the outline with FT_Outline_Embolden, and renders
that. The stems widen as one shape with hinted edges, and the advance is
untouched, so the cell grid, caret and selection do not move. The strength
is Ghostty's heuristic, 1/32 of the line height rounded up (one pixel at a
32 px line). A bitmap strike has no outline; it is widened by one whole
pixel with FT_Bitmap_Embolden, the classic overstrike. Underline and
strikethrough are still drawn into the cached cell mask (decorateLine).
vendor/stb (stb_truetype.h and its implementation unit) was left over from
the rasterizer FreeType replaced; nothing built or referenced it.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
resize columns from their grip
Every tag band (workspace, column, pane) is centred in its row, so text sits
at one baseline offset. Anchors are inset from the column rule by that same
margin, so they are square and the band frames them on the left, top and bottom;
the pane mark moves to stay centred. When the window is not a whole number of
cells, the bands, rules, spines and scroll thumbs at the right and bottom edges
run on through the leftover pixels.
A column grip dropped short of another column's place now moves the column's
left edge, with a dashed rail preview, sharing setColumnPairWidth with the
border drag. A release still on the grip changes nothing, and a pair too
narrow for two MINW columns is left alone.
PARDES_TEST_PAD adds leftover pixels to a test-mode window and capture.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| | |
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The editor's loop was the only thing that could answer a 9P request, which
made the editor's own syscalls through a mount of its own tree -- a Look at
/mnt/9p/pardes/<me>/anything under a `9ns --mntgen` view, a Save into it --
requests only the blocked loop could serve. The name-based refusal that
followed (ownMountSuffix) and the in-process routing of a mount of oneself
(Client.sameSession) were patches over that, and both are gone, with the
mailbox that shipped every request to the editor's thread.
One rule replaces them, `pardes.turn`: the core is single-threaded, the
editor's thread has the turn by default and gives it up in two kinds of gap
-- while it waits for input and while a step of it is out in a host syscall
-- and a cloud9 connection task takes it in those gaps to answer. `out`
counts the steps that are out, from any thread: while one is, the core reads
consistently but that step still holds pointers into it, so a request that
would change a pane (a write, a truncation, an rmdir) is parked in the
engine and retried when the turn is next given up with nothing out, and the
editor's own wake waits for the count to reach zero. It is never a write of
its own that a step waits on out there -- writes come from a shell
performing a save between steps -- so a parked request is never the
syscall's own, and making a pane or rendering a screen need not park:
every yield sits before its step's mutation, so the layout and the surface
are whole under it. A changing request that queued effects is answered
once the editor has performed them (`echo Save > exec` returns with the
file written, as acme's `put` does), and it settles the way a step does,
because without that a /log reader waited for the user's next keystroke.
Every host syscall on a user path has to give the turn up, not fs.zig's
alone: the first end-to-end run hung in `inotify_add_watch` performing the
new pane's watch effect. PDFs and images are read whole at open, so no
draw goes out into the host. The core's allocator takes its fixed buffer
through the lock-free interface, since a connection task allocates while
the editor's thread is out in a syscall that allocates too. A Restore puts
the replacement in first and releases every task waiting on the old core.
cloud9 (pinned at eb1a104) parks an open, a truncating wstat, a clunk and a
remove on `again`, not only reads and writes, and answers a parked job
whose fid was clunked without asking the backend.
Verified: test/selfmount.py runs the editor under `9ns --mntgen` and
Looks at, reads and Saves its own tree through the mount; a unit test pins
that a change parks while the editor is out mid-step and lands when it
rests, while a read is answered in the window. 9P over the Unix socket
against a tty session, same machine, Debug builds: a read of /index 278us
-> 61us, a truncating body write 1184us -> 609us, exec Save 718us -> 583us;
the gesture benchmark is unchanged (geometric mean 0.997 over 53 cells).
Also from the reviews: a notice chip over an image or PDF pane was painted
out by the picture drawn after the cells, so pictures give up the rows; in
the GUI a tree-sitter context band painted over the chip, so body layers
are emitted first; a message is one row of printable text, its 256-byte
cut never leaves half a glyph, and one wider than its pane keeps its tail
(the file name, the reason) rather than its head.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A notice is an overlay now, which is the one place it is NOT like the
tree-sitter context: it takes no row from the body and moves no text. Each one
is a chip as wide as its own message plus a blank cell either side, anchored
to the pane right edge on the body top rows. The rest of each of those rows is
ordinary body text that still reads and still answers a click -- Notices.left
records where each chip starts, and the hit test refuses only the cells it
covers rather than the whole row.
The width is counted in GRID columns rather than scaled into the tagline face
the chip is drawn in, because the canonical grid is what a terminal client
draws and a chip narrower than its own text there would clip it; the narrower
tagline face simply leaves a little more room inside the chip. The tag layer
takes the geometry the grid pass already chose, so the two cannot disagree and
the GUI skipping the cells a tag layer covers leaves no stub behind.
Pane.body_offset is gone with the reservation it existed for, and bodyTop
takes only a rect. The body layer keeps every row it had, the scrollbar runs
the full body again, and the GUI smooth-scroll extents go back to the pane.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A message, a leader chord and a prompt used to share one row of body text at
the bottom of a pane, wearing the tagline font and nothing else about a
tagline. Now each one is a TagLayer of its own, emitted through the same
renderHeaderLayer the pane and column tags go through, so it gets the tagline
height, the small-font metrics, the band offset and the border for free --
none of which a body-grid row can have by changing its font role. The text is
right aligned. The prompt stays on the canonical grid because it owns a
cursor, and a cursor has to sit on a real cell.
The body starts BELOW the bands rather than under them, the way tree-sitter
context rows already worked. Pane.body_offset is how many rows they took and
Pardes.bodyTop(pane, rect) is the one place that answers "where does the body
begin" -- replacing fifteen copies of `if (tag_bottom) r.y else r.y + BOX_H`
spread across the paint, hit-test, scroll, PDF and image paths, which is what
let the bands and the text under them come adrift. Every notice is painted on
the grid as well, because the grid is what a terminal client draws and a band
it cannot see is a message it never gets; the GUI skips grid cells a tag layer
covers, so nothing is drawn twice.
Three bugs the bands exposed, fixed here:
- a prompt band flush with the right edge put its caret one column past the
pane, which the detached wire refuses -- so every frame was dropped for as
long as the prompt was open. The band now reserves that column.
- a click on a band mapped to Sel row 0, which is the TAG row: clicking
chrome expanded a word out of the tagline and ran it as a builtin.
- a watched file reloading under the editor changed the core without going
through update, so needs_frame was never set and the reload was never
drawn. Pardes.invalidate() is the name for that, and the file and theme
reloads call it.
A session can now drive its own 9P namespace instead of being refused one:
ownMountSuffix answers what a path names inside this editors own tree and
resolve, readLimit and write serve it from memory rather than making the
syscall that never returns. The match is anchored to whole components under
the registrys 9p/pardes/<name>, because a bare /pardes/<name> anywhere in a
string would claim ~/src/pardes/<name>/README -- and, before write learned the
same trick, write the trees bytes over the real file. readFileLimit and
writeFile refuse instead, having no core to answer from.
A toggle setting SETS when given `on` or `off` and only flips when it is bare,
so the report LocationsConfig prints can be fed back as configuration and mean
what it says.
Snapshots: 97/98, from 0/98. The goldens were several commits stale and 17
scripts had stopped running; `config <line>` is a new script command that
appends to the per-script startup config, so a script that clicks body
coordinates pins `Verbose off` instead of counting the rows an announcement
moves. nested-optout is left failing on purpose: two levels of nesting prepend
vaxis F3 codepoints to typed lines, which is a real bug and is written down in
docs/divergences.md with a repro.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
| |
The hand-written poll loop for Unix/TCP listeners is replaced by
cloud9.serve.Runner; requests are queued to the editor thread, which answers
them under the connection lock on each frame and retries parked reads as
before. QUIC keeps the poll path (its adapter is fd based). The detached
server no longer loses a wake that lands between frames. The firmware path
keeps driving the engine with push/step. cloud9 re-pinned.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| |
|
|
| |
Empty marker on the last pre-Reload change. Keep the Reload experiment on reload (3801914), its first change on reload-start (200a1fc), and the unfinished performance investigation on reload-perf-wip.
|
| | |
|
| |
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A message row is cleared by the next keystroke, so anything reported while you
were looking at another pane was gone before you could read it — a save that
failed, a watcher's reload, a builtin's complaint. `setMessage` now records
into a fixed ring first: no allocation and no failure path, because it sits
underneath `reportError`, which is reached from sites that are reporting an
allocation failure. `Messages` (`SPC h m`) reads it back oldest-first.
Three things an adversarial pass found, each of which defeated the feature:
PROGRESS IS NOT A MESSAGE. A language server emits `Indexing 47%` several
times a second, and every tick is a distinct string BY CONSTRUCTION, so no
de-duplication can collapse it: at the client's one-per-150ms throttle it
takes about nineteen seconds to push every real message out of the ring. A log
that one indexing run empties is not a log. That path is `setStatus` now —
the row, and nothing else.
THE CLOCK MADE EVERY HOST MESSAGE UNIQUE. `message.stamp` prefixes `HH:MM:SS`,
so `saved /x.zig` at 14:32:07 and at :09 compared unequal and the ring filled
with rows that look identical and each say (x1) — exactly the case the
de-duplication exists for. It compares `message.body` now, the row without its
clock, and the newest wording wins so the row carries the last time it
happened rather than the first. It also keys on the PANE (one pane's failure
must not be recorded as another's) and compares the truncated form, so two
identical messages over 256 bytes stop being two rows.
AND THE CAPACITY BELONGS IN limits.zig. 128 entries is 32.75 KiB that is
allocated whether or not anybody reads it — 8.5% of the ESP32-P4's whole
384 KiB heap, about the size of its effect ring. The board takes sixteen.
The builtins/leader goldens move because the listing gains a row, and
builtins.snap middle-clicks a SCREEN COORDINATE that Tutor moved out of; both
updated selectively and verified against a fresh run.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Two things made the selection pipe feel like it had never worked. It runs —
test/snapshots/pipe.snap drives the real binary through a pty and filters
`alpha beta` to `ALPHA BETA` — but it had no way to tell you when it did not,
and one of its failure conditions was not a failure at all.
IT NOW SAYS WHY. `runOne` read the command's stderr into memory and freed it
two lines later, unread; every caller answered a failed filter with a bare
`return`; `pipeResponse` had eight more silent exits under that. So `| trr`
(a typo), `| grep nomatch` (exit 1), `| jq .` on bad JSON — all did nothing,
said nothing, and left the text alone with no way to find out why. The runner
carries a `Failure` home instead: which selection, what became of the command,
and its own stderr. The core turns that into an `+Errors` buffer — acme's name
for output that came from the program rather than from a word anybody clicked:
| trr
exit status 127
sh: line 1: trr: command not found
An output buffer rather than the message row because the useful half of a
shell failure is the text the shell wrote, and a 256-byte row would keep the
label and throw away the reason. Focus stays with the file: `openRead` moves
`p.active` to what it opens, which is right for a Grep you asked to read and
wrong for a report you did not — you want to fix the command and press `|`
again. A host with no `pull_pipe` at all (the detached daemon, the browser,
the board) now says that too, instead of answering failure into the void.
`| head -1` NOW WORKS. `writer_context.ok` was part of the success condition,
so a command that stopped reading its stdin failed the filter even though it
had done exactly its job: `head` takes the line it wants and closes the pipe,
the write gets EPIPE, and a selection bigger than the 64 KiB pipe buffer was
enough to trigger it. helix joins its input task and ignores the result for
this reason; the exit status is the whole verdict. Also reported rather than
swallowed: the ten-second timeout, the output ceilings, and a file edited
while the filter ran — one keystroke during a slow command used to discard the
result in a way indistinguishable from the filter doing nothing.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A review of what this program does when the environment says no. The finding
that reframes it: there were almost NO panics on ordinary paths — the rule
already held — but there was a great deal of silence, and one case worse than
any panic.
SILENT DATA LOSS ON SAVE. `saveFile` marked the pane saved the moment it
QUEUED the effect, before any host had tried; `host_io.writeFd` returned void,
so a short or failed write was indistinguishable from a complete one; and
`writeFileBytes` returned true regardless. A save to a read-only file, or into
a directory removed under the pane, therefore cleared the tag's ` *` and posted
nothing — and `Del` makes no dirty check, so the next click threw the edits
away with the screen saying they were safe. On a full disk it was worse: the
file is already `O_TRUNC`'d when `write` fails, so the message row said `saved`
over a file that had just been emptied.
Now: `writeFd` reports, `writeFileBytes` returns WHY (`PermissionDenied`,
`NoSpaceLeft`, `ReadOnlyFilesystem`, …) including a failed `close`, which is
where write-back filesystems report at all; the core marks the pane saved
around `perform` rather than at emit, which is also where the bytes are read;
and a host that could not write calls `Pardes.saveFailed`, which puts the
reason on the message row and takes the clean mark back. That is a CALL and
not a return value because host.zig enforces, at comptime, that a `push_`
method reaching every host in a fan-out cannot have one answer — the first
attempt at this changed the signature and the compiler was right to refuse it.
TWO PANICS ON AN ORDINARY KEYSTROKE, in look.zig's number scans. `v = v * 10 +
d` over caller-supplied digits, reached from `parsePathLine` and the `@pN` scan
— which every Look, every right-click and every n/N motion runs on whatever
word is under the pointer. A hash in a log, a CSV column, any output shaped
`foo:99999999999999999999`, and the editor died with "integer overflow". Both
saturate now, the same way acmefs.zig's address parser already did; a saturated
line is refused by `file_pane.open`'s `line <= total` and a saturated pane id
by `focusPaneLine`'s `id < MAX_PANES`, so nothing addressable changes.
A BOOT FILE THAT WILL NOT OPEN joins the missing-name case in the `+Errors`
pane instead of taking the launch down: `pardes /root` resolves as a `.file`,
could not be read, and left `error: PermissionDenied` and a return trace.
`look.readFile` now says which errno it was, so the pane can say "permission
denied" rather than a word from the source code.
The tag-marker test drained no effects and passed anyway, which is exactly the
defect; it drains now.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
effects that compile
Three things this shell had its own copy of, and in each case the fix is that
it stops having one.
**The tagline band.** A pane tag draws at `gui_tagline_font_percent` of the
body face and the band it sits on shrinks with it, while the grid row stays
body-sized — so something has to decide where the shorter band sits in the
taller row. This shell decided by centring, always, which is precisely the case
`config.gui_topbar_pane_border_px` exists to prevent: the topbar's unused
half-band meets the first pane tag's unused half-band and the window background
shows through the seam. The strip is as wide as the bands are short — on a
20-pixel cell, 4 physical pixels at the default 82%, 10 at 50%, 14 at 30% — so
it grew as the tagline face shrank and read as "the tagline is wrong on the mac"
rather than as one missing rule. The rule is `pardes.taglineBandOffset` in the
core now and both pixel hosts call it: row zero bottom-aligned, the first
pane-tag row top-aligned, the two joined by `gui_topbar_pane_border_px` in the
theme's scrollbar-track colour, every row between centred, and a `Tagbottom`
band on the final row flush with the window edge — with the sub-cell strip
beneath it painted in that band's own colour, because the core grid holds only
whole cells and a window is any height it likes. `pardes_tagline_band_offset`,
`pardes_topbar_pane_border_px` and `pardes_topbar_pane_border_rgb` carry it over
the C ABI as PHYSICAL pixels: the host multiplies its points by the backing
scale going in and divides coming out, which is the snapping `Metrics` already
does for the cell, and is what keeps a one-pixel rule one pixel instead of a
two-pixel smear.
**The watch.** `file_watch.zig` was one mark/reconcile transaction over
`inotify`, so the tty shell, the SDL window and the detached daemon all watched
nothing off Linux: an edit made outside pardes never reached the pane, and a PDF
replaced on disk kept rendering the old inode. It is the same transaction over
two kernels now — `init`, `wait`, `stop`, `drain`, `markDir` and `unmarkDir` are
still the whole of it, and the hosts wait on a kqueue and poll it exactly as
they did the old descriptor. A macOS mark is TWO filters, because a kqueue
directory filter reports its entries changing and never a write to a file
already inside it: the parent mark follows rename-over saves, `markFile` catches
in-place writes, and `remarkFile` re-arms the file filter once a rename has moved
the inode. That is the same pair the AppKit host's DispatchSources already used
for the same reason. Directory marks are deduplicated here by device and inode,
because each `EVFILT_VNODE` filter needs a descriptor of its own and inotify did
that deduplication itself; `stop` and `drain` wake through the one `EVFILT_USER`
filter, since a kqueue cannot simply be read the way an inotify descriptor can.
**The effects.** The three `crt.ci.metal` entry points are
`extern "C" [[stitchable]]`. `CIKernel.kernels(withMetalString:)` compiles that
source at runtime, looks for stitchable functions, and rejects the WHOLE source
with "cannot find a valid stitchable Metal function in the source" when it finds
none — so `ScenePostprocessor.init?` returned nil and every scene effect and
panel transition silently degraded to the plain CoreText draw. The
`effect_sources.zig` test pins the exact spelling of all three, and
`draw-effect` in the e2e suite catches the degradation rather than the spelling.
Beside them, the offscreen harness owes the core a PRESENTATION. Its window is
borderless and never ordered front, so AppKit runs no display cycle and
`pardes_frame_presented` — whose only caller is `draw(_:)` — never fired. The
core holds pointer gestures inert while a layout mutation has not reached a
backend, which for an unpresenting harness is the rest of the script: the first
pane a script opened silently killed every later click, drag and Look. So
`readFrame` presents what it just rendered, into a bitmap nobody reads.
`PARDES_CHROME` also looks under `/Applications`, where a browser's executable
lives inside an application bundle and never on `PATH`. The macOS goldens are
regenerated; docs/macos.md, config.md, detached.md, web.md and the design PDF
follow.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| | |
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`Shell.host()` hands the headless `PARDES_TEST_GRID` harness `grid_vtable`, which
inherited `push_set_clipboard` and `pull_read_clipboard` from the windowed
vtable. Both of those functions opened with `if (s.gui == null) return;` — and
in grid mode `s.gui` is null by definition, so the pair were methods that could
never answer. host.zig says what a NULL method means in as many words: "Null
answers immediately from the in-process clipboard instead, so a request never
goes unanswered." A method present and mute is the one case that contract does
not cover: `SPC y` went nowhere and `SPC p` waited for a reply nobody was going
to send, so paste was dead in the only mode of this shell a test can drive —
which is also why the SDL shell's clipboard had no coverage at all.
Null them, and the grid harness runs on the same in-process clipboard
`pardes-isolate` does. The two SDL functions then have no reachable
`gui == null` path left, so the dead guards go with them.
The regression is the round trip through the real seam: build the Shell the
harness builds, take the host off `Shell.host()`, assert it picked
`grid_vtable`, then `SPC y` and `SPC p` and check the bytes came back. It fails
on the old vtable at `clip_pending == null` — the paste that never arrived.
Verified end to end as well: `PARDES_TEST_GRID` with `x`, `SPC y`, `SPC p`
duplicates the line and marks the buffer dirty; the shipped binary does
nothing. The windowed shell is unchanged and still reads the desktop
clipboard (checked on wayland and x11, keys injected at evdev level, against
a real focused window).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Step 3 of the 9P chain (docs/9p.typ 12.3, docs/registry.typ 9P-8). Nothing here
is about 9P: it lands in the FUSE-served tree and any later transport inherits it.
A script could write into a terminal that already existed and read its rendered
scrollback. It could not START one, RESIZE one or SIGNAL one. Two of those were
already effects the core emits, so `exec` and `winsize` are existing
capabilities acquiring a name; only `sig` is new, and it brings the one new
host method, `push_pty_signal`.
pty/ctl winsize <cols> <rows> | sig INT|TERM|HUP|QUIT|KILL | exec
one verb per line, validate-all then apply-all, EINVAL applies
nothing -- `writeCtl`'s shape and `writeCtl`'s reason
pty/status cols, rows, tty-taken as three %11d fields
pty/data write is input to the process; read is the RAW output stream,
gated on a reader count so a pane nobody reads costs one branch
A pane that is not a terminal has no pty/ at all: the lookup is ENOENT and
readdir does not list it.
`PaneFile` is an enum(u4) and this takes it from 11 values to 15. ONE REMAINS.
That is also why pty/ is a DIRECTORY and not three more flat names -- a
subdirectory costs one value and buys its own namespace, so `ctl` and `data`
did not have to be renamed.
Two things the core does not know, and which are therefore not invented: a
child's EXIT STATUS (a shell's death is `Event.eof`, which removes the pane,
so there is no directory left to read it in) and RAW/COOKED (the core never
sets a termios; the mode belongs to the program on the far side).
Verified live against a daemon: pty/ appears only on the terminal pane; a
`winsize 0 24` and a `sig SIGINT` are refused; a bad verb beside a good one
applies neither; `echo pty-works` written to pty/data runs in the shell and its
output reaches the body; and a blocking read of pty/data returns the raw stream,
OSC 133 marks and all. fs-bench unchanged and still zero allocations.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Step 2 of the 9P chain (docs/9p.typ 12.2, docs/registry.typ 9P-2).
`drain` and `step` never asked a `*fuse.Fs` for anything but `retry()`,
`next()` and `reply()`, so the concrete pointer was a coupling that bought
nothing and forbade a second answer. `Transport` names the three; `Fs.transport()`
is the first implementor and the thunks are the entire cost.
No behaviour change. The order contract -- retry() to null, then next() to null --
moves into `drain`'s doc comment, where it belongs: it is the caller's rule and
every implementor inherits it, rather than a fact about FUSE.
`start` and `wake` keep their `*fuse.Fs`: they are about a MOUNT, which is a
FUSE thing, and a 9P listener will bring its own.
Measured unchanged against zig build fs-bench -Doptimize=ReleaseFast: getattr 19 ns,
lookup 40, read body 4K/1M 25/25, read ctl 385, read index 633, readdir 38,
read event (empty) 22, all at zero allocations.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The mouse did not work. Chasing that found something much larger: NO escape sequence
worked on this transport, and had not since the port began.
`vaxis.Parser` resolves a buffer containing nothing but 0x1b as the Escape KEY. That is
deliberate and correct for a terminal, where the kernel hands over a whole escape sequence
in a single read, so a solitary ESC really does mean somebody pressed Escape. A 115200
serial line hands over ONE BYTE AT A TIME - 87 us apart, an eternity to a loop running at
360 MHz - so the first byte of every sequence arrived alone and was resolved as Escape,
and the remaining bytes arrived as ordinary keys.
A mouse click therefore came through as TEN key presses: Escape, `[`, `<`, `0`, `;`, `1`,
`8`, `;`, `3`, `M`. The `0` among them is "go to column zero" in normal mode, which is
exactly where the cursor kept landing, and why the first attempt at this looked like a
coordinate bug. Arrow keys, function keys, and the host bridge's in-band resize reports
were all being taken apart the same way.
Longer partial sequences were never affected: the CSI scanner returns `n == 0` for "no
final byte yet" and the shell already keeps those bytes. Only the one-byte case needed an
answer, because it is the only one the parser answers WRONGLY instead of declining. So the
shell holds a buffer that is exactly one ESC and lets `pardes_p4_tick` release it after
10 ms - two orders of magnitude longer than the 87 us until the next byte of a real
sequence, and imperceptible to a person pressing Escape. The same trade every terminal
editor makes, for the same reason.
Finding it took instrumenting the ABI: printing `@tagName` of every event the shell
applied. Ten `key_press` where one `mouse` belonged is not a thing any amount of reading
the coordinate arithmetic would have shown, and I had already read it twice.
## Mouse reporting, and the 1003 that is not requested
With the sequences intact, `apply` already handled `.mouse` - it mirrors the tty shell - so
enabling reporting was the only missing piece. Spelled out here rather than taken from
`vx.setMouseMode`, which asks for `1002;1003;1004;1006`: 1003 is ANY-MOTION tracking, a
report per cell the pointer crosses with no button held. On a 115200 line that is dozens of
15-byte reports for one sweep, arriving as input the editor must parse while it paints, and
arriving whether or not anyone wants it - moving the mouse over the window would starve
typing. 1002 reports presses, releases and motion while a button is held, which is exactly
what a click and a drag-select need.
Verified on the die: a click at column 12 puts the cursor at column 12 and one at column 22
puts it at column 22, a drag paints a selection, and the wheel scrolls. A press alone paints
the new position and then reverts - the caret does not move until the gesture ends - so the
release is what commits it, which cost an hour of believing a working click was broken.
Screen byte-identical to the vaxis reference, round trip median 3682 us against 3682, snap
95/95, hxdiff 481/0, hxparity 561/0, unit-test, tty/p4/gui all build.
|
| | |
|
| |
|
|
| |
optional methods
|