| Commit message (Collapse) | Author | Age |
| ... | |
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Adds -Dplatform=macos, a fourth backend beside tty, gui and web. Zig keeps the
core, the ptys, every effect and the worker threads; Swift owns NSApplication,
the window, input translation, and drawing the cell grid with CoreText. They
meet at a hand-written C ABI in src/macos/pardes.h, built as a static library
the app links.
The ABI is src/web.zig's boundary with the wasm removed, because both hosts are
the same animal: someone else owns the clock, feeds events in through flat
functions, and reads one packed cell buffer out. The browser proved the shape.
The one divergence is that the browser has no processes and forwards every
effect to JavaScript, whereas forkpty is right here, so src/macos.zig performs
them — spawn, write, resize_pty, save_file, new_file, write_dump, open_link,
set_clipboard. lsp, pipe and watch are answered with nothing and marked; the
core already tolerates that, since the browser answers none of them either.
This deliberately inverts ghostty's split, which was studied first and is
written up in docs/ghostty-macos-notes.md. Ghostty hands Zig a bare NSView*,
installs its own CALayer and owns the frame clock; Swift never renders. Pardes
does the opposite because its frame is already a cell grid and CoreText draws
one natively — the alternative is a second hand-rolled glyph atlas, which is
what most of gui.zig's 4,300 lines already are. It would also have been written
blind: the Swift half cannot be compiled here.
What makes the scaffold verifiable rather than dead code is that the Zig half is
ordinary POSIX and builds and tests on Linux. Borrowing ghostty's best trick,
build.zig translate-C's the header into the test build and src/macos.zig asserts
every constant, struct layout, and exported function's arity and widths against
it. That guard earned its place immediately: pardes_scroll grew a cell
coordinate after the Swift view had been written against the older form.
Skipped, and named as the upgrade path in docs/macos.md: the Xcode project,
xcframework, lipo and codesigning ghostty needs. All four exist for
distribution; a dev build is a swiftc invocation and a directory with a plist.
The Swift app is a scaffold and says so — every uncertain API spelling carries
an UNVERIFIED marker, and no part of it has been compiled.
tty is unaffected: 75/75 snapshot scripts and both unit suites pass.
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| |
|
|
| |
search/range look steps, goldens updated
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
file:LINE:COL-ENDLINE:ENDCOL, with the two short forms people actually type
reading naturally: file:412:9-21 on one line, file:412-418 whole ones. Ends are
inclusive. A path feature, not a search feature — a ranged path typed in a tag
or middle-clicked out of a shell's output selects just the same; search is only
its first consumer.
The dash is the fussy part. `-` was already a file char, so a ranged word
survives click expansion whole, but a range needs a number on BOTH sides or
my-file:10, build-2 and 2026-07-30 would stop being paths. Table-driven test in
look.zig for exactly that.
Selecting goes through the cellRange/setPaneRange pair the multi-cursor work
left, and hxOff clamps both ends, so a stale range selects what still exists
rather than crashing or reaching past EOF — pinned with an 8:6-400:9 range in a
nine-line file.
Producers: / search, Grep, and five LSP sites through a new spanRow — goto,
references, rename tokens and both symbol lists were throwing away real
protocol ranges at path:line:col. Left alone deliberately: Find rows are bare
paths with nothing to span, a jump is a spot not a span, and the diagnostic and
format paths only ever have a point, where half a range would be worse than
none.
One knock-on worth knowing: n now leaves an EXPLICIT selection, so a topbar
execute chords it. grep.snap's no-match step was silently becoming
`Grep TARGET`; it runs from the leader path now, which never chords, and the
dedicated chord steps stayed where they were.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
It never did — the effect arm was `.watch => {}` with a comment saying a shell
that never delivers the event simply never reloads. That was written as a
deliberate scope cut and it is the whole bug: the user runs pardes-gui.
The tty watcher was fine, and I proved all four suspicions false against a real
binary in a real pty with the write done by a stranger process and NO input
delivered afterwards: the loop does wake (postEvent signals an empty queue),
zig fmt's rename-over does fire MOVED_TO and is caught, three panes across two
directories all reload and closing one leaves its neighbour still following,
and the self-write hash guard does not swallow a real change. The live session
even had its inotify mark on src with the right mask and re-read config.zig
when I touched that directory.
Duplicated rather than shared with tty.zig, the way the two shells already each
own LspJob, forkShell and their pty readers. The headless PARDES_TEST_GRID path
deliberately passes -1: its contract is one frame per scripted input, and a
reload on its own clock would put an unasked-for frame in the stream.
filewatch.snap proved less than it looked. Its real blind spots were the
rename-over shape — the old script only truncated, so it saw CLOSE_WRITE and
never MOVED_TO — multiple directories, and the one that mattered: the suite
only ever runs the TTY binary, so it structurally cannot see a gui-only
regression. It now uses a new `run` directive whose writer is a child of the
RUNNER, so nothing it does reaches an app pty.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
228 in the ring: 3 pardes ships plus all 214 helix runtime themes and all 11
zed variants across its three families. No exclusions.
inherits is what this cost. 76 themes need it, so the reader is two passes now,
and the merge is WHOLE-KEY rather than field-wise because helix merges the
theme body at depth 1 — the three *_transparent themes clear their parent's
background with an empty table, and a field-wise merge would leave it painted.
I had that wrong until I read helix-view/src/theme.rs.
At 214 files the generator meets themes that are SPARSE rather than broken, and
the old fatal-on-anything-missing would have rejected them. Every fallback is
helix's own rule: no background at all means wear the terminal's, so bg and fg
go null (13 themes, correctly); the twelve RGB fields that cannot be null fall
back to ghostty's default palette entries 7 and 0, which is exactly what a
pardes terminal already paints an unstyled ANSI index as. fatal is kept for
what genuinely cannot be read.
Three real bugs surfaced only at scale: #ccc shorthand doubles the nibble
(helix's rule, 58 colours), a quoted inline-table key silently dropped a
selection colour, and four themes name a cursor background EQUAL to the page
because they are about to reverse that cell — taking it painted an invisible
move box. 0 degenerate themes out of 225 now: no fg==bg, no syntax colour on
its own background, no invisible box.
Collisions get an unconditional rule rather than a clever one: a zed variant is
always <name>_zed, because both projects ship gruvbox, ayu and one, and a
conditional tag would move a name when the other source changes. A comptime
assert holds it.
Cost: no-op build 0.257 -> 0.276s, a pardes.zig change 46.8 -> 47.5s, binary
+0.98%. The generator does 217 sources in 54ms.
NextColor stays and is no longer a way to REACH a theme — but the browse got
better, not worse: the generated half sorts by name, so its neighbours are that
theme's own family. theme.golden's fourth click moved from ayu_dark to acid,
which sorting 225 names does; themesel gained a tail capture proving row 228
renders.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
zig build perf drives the core directly — event, effects, one frame, no pty —
over four generated fixtures: 1k lines, 50k, 300k, and 400 lines of 8000
columns, because a file that is long and a file that is wide fail differently.
Every sample seeks somewhere else in the file first, since measuring at line 3
of a 300k-line file hides exactly the bug.
perf record said half the run was scanning for newlines from byte 0. So File
carries a line index, built on demand and invalidated in exactly ONE place —
setContent, the funnel every content swap already goes through. That killed the
scrollbar's per-frame line count (12.6% of the whole run by itself), scrollBy,
ensureCursorVisible, lastNavRow, the syntax window bounds and two O(scroll)
walks. normalKey computed max_line as a const at the top: two full passes over
the buffer on every keystroke of every kind, for three g/G branches. It is lazy
now. The modal primitives each walked the text twice for the same line.
And the visible window was re-parsed on every scrolled row — a third of a
megabyte per keypress on the wide fixture. The highlighted range is remembered,
a scroll inside it is free, and only a re-parse that FOLLOWS a scroll takes
slack: doing it unconditionally made typing 2.1x slower, since every character
paid for a band it could never amortise.
One j on a 19 MB file: 37.8ms -> 266us. Render: 4.1ms -> 77us. Open costs 1.25x
more for the one extra pass, which buys 54x on every frame after, and 8 bytes
per line of memory.
Left standing, measured and named: edit-char is 14ms on 19MB because content is
immutable and every keystroke copies the buffer. A third of that is the index
rebuild, which could be a shift if setContent knew the edit offset; the rest
wants a rope. bodyText's double copy and Surface.print's per-cell decode never
rose above 2% of the profile afterwards, so they were left alone.
No golden moved.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The primary cursor stays exactly where it was — cur_row/cur_col plus vsel — and
sels[] holds helix's OTHER ranges. That split is why nothing moved at one
cursor: with nsel == 0 not one line of the existing motion, operator, render or
mouse code takes a different branch, which is what protects 800 differential
cases and 67 goldens.
paneRanges/setPaneRanges are the whole list; setPaneRanges IS helix's
Selection::new (min width 1, sorted, overlaps merged, primary follows its range
through a merge). An ordinary key runs the single-selection handler once per
range, visited last-first so an edit never disturbs a range still waiting, and
each finished pass is remembered as a distance from the END of the text, which
an earlier edit cannot move — helix's change mapping without a change map.
pushUndo fires once per keystroke, yanks accumulate, and a builtin acts from
the primary and stops the replay, which also closes the use-after-free window
if it frees the pane.
s and S reuse the / prompt wholesale rather than growing a second one: the
pattern is typed into the tag tail, and every keystroke re-runs the match from
the selection the prompt opened on, so the preview is live and Esc is just the
empty pattern. mvzr does runtime patterns — a bytecode VM in a fixed-size
struct with no allocator — with 64 ops and 8 char classes per pattern, no
case-insensitive flag (helix's smart case is done by folding a scratch copy),
no captures, no multi-line anchors. The last two are the two waivers.
Ctrl-c is a whole-list key and not a per-cursor replay, because helix decides
comment-vs-uncomment ONCE for the whole selection; replaying it would take that
decision n times. Comment tokens are a table in config.zig keyed on the same
extension syntax.zig picks grammars by.
Found and fixed a pre-existing single-cursor bug on the way: la<bs><esc> left
the cursor one cell before where the append began. helix's restore_cursor can
never walk past the origin; ours backed up unconditionally. hxdiff was green
before AND after — the old one-selection contract could not see it.
hxdiff 360 -> 481 cases, hxparity 440 -> 561, all goldens from real helix; the
harness contract now reports every range and its primary, omitted when there is
one, so 359 of the 360 old goldens are byte-identical. The one that moved is
o-count: helix's 2o really does leave two cursors and could not say so before.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
An external update is pushed onto the undo stack exactly like an edit the user
typed, so unsaved work is one `u` away and pardes never has to merge anything.
That is the design, not an implementation detail: the whole feature is
pushUndo() then setContent().
One inotify instance in the tty shell, blocking in readVec through std.Io on a
concurrent task started beside the pty readers — after loop.start(), so the
forkpty ordering is untouched. It watches the containing DIRECTORY, because an
editor rewrites by rename-over and a watch on the file would follow the dead
inode, and it listens for CLOSE_WRITE rather than MODIFY, which is one event per
finished writer and most of the debounce for free.
Our own Save does not reach the undo stack: each watch keeps a hash of the
bytes last seen on disk and save_file restamps it. A hash rather than mtime and
size because the reload has to read the file anyway.
The core stays sans-IO — one watch effect out, one file_changed event in, and a
shell that cannot watch simply never sends the event, which is what the gui and
the web platform do. Linux only; fanotify is what the build system uses and is
rejected in a comment: it exists for thousands of directories across mounts,
and sixteen panes of inotify is a third of the code with no kernel floor.
New golden filewatch: edit without saving, overwrite from a shell in another
column, watch it reload, undo, get the unsaved edit back. None moved.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
queryTerminal(2ms) blocks on a futex until DA1 comes back, and the number has
to beat one terminal round trip: local answers in microseconds, ssh localhost
under a millisecond, any real link never. Measured against sshd with the
replies delayed to model the wire, 2ms already loses at 5ms RTT.
Losing it is worse than never probing, because vaxis splits detect from enable
and only detect respects the deadline. The flag flips the moment the futex
times out, so the two replies gated on it — explicit width and scaled text,
both spelled as a cursor-position report — stop being read as probe replies and
arrive at the app as shift-F3 and alt-F3 keypresses, while the ungated ones
keep mutating caps from the reader thread long after enable already declined to
switch those modes on. Over ssh the terminal sat in its default modes while
caps claimed otherwise: kitty keyboard was never actually pushed, ever.
So send the probes and resolve them on the loop. DA1 is last and terminals
answer in order, so when the reader flips the flag every earlier reply is
applied — no window to miss at any latency. Verified over real ssh at 5 through
500ms RTT: 7/7 caps and kitty keyboard actually enabled at every one, where
before it was 5/7 and never. Startup is 2ms faster, no golden moves (nothing
answers in the harness, and with no caps enable writes no bytes).
Honest scope: I could not reproduce the reported stale characters, only the
handshake bug behind them. The width half of the theory is inert — vaxis's
Cell.width defaults to 1 and pardes writes one codepoint per cell with an
explicit spacer, so gwidth, the only consumer of caps.unicode, is never called.
That is written down so nobody re-derives it. If the dirty screen survives,
the next suspect is vaxis's own carry-over for an escape sequence split across
a read boundary (Loop.zig:174-190, wrong length and an off-by-one): four wheel
events sent whole scroll four notches, the same four split at a `;` with a
60ms gap scroll zero. Network framing is exactly what makes those gaps. It is
an input bug in a vendored dep and wants its own change.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The pad faithfully turns a two-finger scroll's sideways drift into wheel_left
and wheel_right, so a plain scroll slid the view sideways underneath you. Every
vertical tick now re-arms a guard and every horizontal tick spends one instead
of scrolling, so horizontal has to EARN its way back by landing three ticks in
a row with no vertical among them.
Clock-free on purpose: the core is a state machine with no timestamp on a mouse
event, and faking one by counting renders would be worse than the counter. The
guard is only ever armed BY vertical scrolling, so a horizontal swipe from a
still view still moves on its first tick — only horizontal that interrupts
vertical has anything to prove.
A tilt wheel gets the same treatment, where recent-vertical is a much weaker
signal of accident. Deliberate: the only honest fix is a per-device flag out of
the shell, and that layer costs more than the three clicks it would save.
The rule is one pure function next to the number it reads, with an inline test
written to hold for any tuning of that number. unit-test grew a fourth binary
over the core module hxdiff already links. New golden wheeldrift; none moved.
|