summaryrefslogtreecommitdiff
path: root/src/modal.zig
Commit message (Collapse)AuthorAge
* Edit's x, y and s match whole runes, as addresses doGabriel Schneider43 hours
| | | | | | | | | | | mvzr matches bytes, and only the addr path widened a match to runes, so ,x/./a/|/ on é split it into invalid UTF-8 and ,s/./X/g gave a combining sequence four X's. Edit's matches now go through the same rune alignment (and an empty match steps a rune, not a byte). runeStart no longer walks a stray continuation byte back onto the ASCII before it: an invalid byte is a place of its own. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Addresses snap to runes, not grapheme clustersGabriel Schneider43 hours
| | | | | | | | | | sam and acme address runes; pardes snapped #n, line:col, dot and data's reads to grapheme clusters, so a lone combining mark or a CRLF's \r could not be addressed. #n, line:col, a search's match, dot (both ways) and data now land on rune boundaries (modal.runeStart and friends), and the tty's cell drawing is untouched. The README, fs.md and the skill say so. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* ]f, mif and the other textobject keys use helix's own queriesGabriel Schneider43 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]>
* gw labels the words in view and jumps to the one whose label is typedGabriel Schneider43 hours
| | | | | | | | | | | | helix goto_word: the words of two or more word characters between the view's first and last lines get two-letter labels, nearest the cursor first, one forward and one back in turn (modal.jumpWords). The next two keys choose one and select it, stretching the selection to it in select mode; any other key takes the labels away. The words live on Pane.jump, and renderBody paints their labels over the body's cells in the selection colours, the one change on the render side. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Z arms the view keys until Esc, and zm puts the cursor's column mid-viewGabriel Schneider43 hours
| | | | | | | | | Z is helix's sticky view mode: the z keys stay armed after each until Esc, which leaves the mode and does nothing else (no pane hop). zm is align_view_middle: an unwrapped view scrolls sideways so the cursor's column is in its middle. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* g. goes to where the last edit endedGabriel Schneider43 hours
| | | | | | | | | | helix goto_last_modification: every range moves to the end of the last change (the start of a deletion, the end of an insertion), extending in select mode. The position is kept on the Text in surface rows, set where setEditText hands a body its new text, so a terminal's typed text has one too and moves with its scrollback. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Alt-o, Alt-i and the other tree-sitter keys walk the file's syntax treeGabriel Schneider43 hours
| | | | | | | | | | | | | | helix's expand/shrink, previous/next sibling, all siblings, all children and parent-node end/start (Alt-o/up, Alt-i/down, Alt-p/left, Alt-right, Alt-a, Alt-I, Alt-e, Alt-b; Alt-n stays the new terminal) walk a whole-file parse the file keeps (File.node_tree), made on the first such key after an edit and reused until the next, never per key. syntax.walkNodes follows helix's TreeCursor over every node, anonymous ones included, and Alt-i goes back to what Alt-o grew from while the selection still holds it. The harness runs without grammars, so the cases prove the plain-text no-op; a test replays eleven JSON walks whose results were taken from the installed hx. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* K and Alt-K keep or drop the ranges a regex matches in; Alt-: turns every ↵Gabriel Schneider43 hours
| | | | | | | | | | | range forward K and Alt-K share the s/S prompt, which now carries a mode rather than a split flag: a range stays when a match starts inside it (K) or when none does (Alt-K), the primary becomes the first, and keeping none leaves the selection as it was (helix keep_or_remove_matches). Alt-: is ensure_selections_forward. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Q records keys into a register and q types them again; . repeats the last insertGabriel Schneider43 hours
| | | | | | | | | | | | | Macro.zig keeps keys to be typed again. Q records into @ (or "<reg>) in helix's key notation until the next Q; q replays a register's keys count times through the modal handling, where Esc does not hop panes and Enter, Tab and Space neither look, execute nor open the leader. . replays the normal command that entered the last insert session once and what was typed in it count times (helix repeat_last_insert). a at the end of the text now adds the newline helix's append_mode does, which a terminal's text does not. helix-golf's invert_dictionary passes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* $ keeps the selections a shell command exits 0 on, and line end is glGabriel Schneider43 hours
| | | | | | | | | | | | | $ was a second key for line end, which helix never meant: in helix it is shell_keep_pipe. It now arms the pipe prompt with a $ marker, runs the command once per range with that range's text on stdin, and keeps the ranges it exits 0 on; the primary stays if kept, else the last kept range takes over, and keeping none changes nothing. The runner's answer is all or nothing, so the command runs in a subshell whose status is echoed as its output. Line end stays on helix's own gl and End; the snapshots, tutor and docs that pressed $ for it press gl. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Registers hold a value per range, and "<reg> names oneGabriel Schneider43 hours
| | | | | | | | | | | | | Registers.zig replaces the one yank buffer: every register keeps a value per range, y fills them in document order, and p, P, R and insert Ctrl-r put value i at range i, repeating the last (helix paste_impl). "<reg> names the register for the next command; _ swallows, # numbers the ranges, . is each range's text, % the file's name, / the last s/S pattern, and + and * are the system clipboard through the ClipYank and ClipPaste paths. SPC y now writes + alone, as helix's does. The acme chords and a paste into a terminal take the default register joined by newlines. The msel-yank-paste waiver is gone. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* & aligns the selections into columnsGabriel Schneider43 hours
| | | | | | | | | | | helix-golf's enumerate_and_align ends on %s |\d+<ret>&. align_selections treats the k-th range of each line as column k and puts spaces before each range until its head reaches the widest head of its column, measured in display cells, which File.rawDisplayCol already gives. A range over several lines refuses, as in helix. The example itself still needs more than 64 ranges, which the selection model does not hold. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Alt-( and Alt-) rotate the text of the selectionsGabriel Schneider43 hours
| | | | | | | | | | | helix-golf swaps keys and values with rotate_selection_contents (webS_to_ <ret><A-(>, then per line). Each range's text moves to the next range (the previous one for Alt-(), in one edit; a count rotates by that many ranges, each range comes back over the text it now holds in its own direction, and the primary follows its text. That is the harness's helix; 25.07.1 read the count as a group size and left the primary alone. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Alt-J joins lines and selects the spaces it put inGabriel Schneider43 hours
| | | | | | | | | | helix-golf's text_into_array turns lines into an array with %<A-s>ms"<A-J>i,: join_selections_space joins like J and leaves one cursor on each space it inserted. J and Alt-J now make one edit over every range, as helix does, rather than J being replayed per range; that is what lets Alt-J hand back all the spaces as the new selection. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* 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.
* 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 a keystroke 2.6x cheaper by not asking Unicode about ASCIIGabriel Schneider2026-08-25
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A keystroke on the ESP32-P4 cost 17.0 ms and the goal is 4. Profiling the core in that board's exact configuration - 40x12, tree-sitter disabled, via `zig build perf -Dtree-sitter=disabled -- --cols 40 --rows 12 --only small` - named the cost, and it was Unicode machinery answering questions about the letter `y`. Four changes, each a fast path guarded so that non-ASCII text takes exactly the road it took before. `modal.graphemeStart` was 21.5% of a keystroke, the single largest item. It iterates graphemes FROM THE START of the text with the full UAX #29 break state machine until it passes the offset, and the render path calls it once per visible row with a column offset - so the cost followed the cursor's distance along its line. That is the shape measured on the die, where inserting at column 320 of a fixed 320-character line cost 7.8 ms more than inserting at column 0 of the same line. In UAX #29 every ASCII scalar is its own cluster with ONE exception, GB3 (CR joined to LF); every other rule that could extend a cluster - Extend, ZWJ, SpacingMark, Prepend, Regional_Indicator - is spelled with non-ASCII scalars. So an ASCII byte whose predecessor is also ASCII, and not that CR-LF pair, IS a boundary. O(1), and sound rather than approximate. `Surface.print` then became the largest at 26.2%: per character it took a UTF-8 length, a decode, a FRESHLY CONSTRUCTED grapheme iterator, a slice validation and a width lookup, to conclude that `y` is one cell. Printable ASCII followed by ASCII takes none of that now. Same guard, same reason. `file_pane.graphemeDisplayWidth` was 6.9%, essentially all of it asking `gwidth` about ASCII. Bounded to 0x20..0x7e on purpose: DEL and the C0 controls are not one printable cell and `gwidth` stays the authority on them. `modal.lineSlice` searched for "\n" with the generic substring search where a memchr does; it is called once per visible row per frame. Measured at the P4's geometry and configuration, on the host: render 55 -> 12 us, key-down 483 -> 24 us, key-right 327 -> 13 us, edit-char 205 -> 46 us. On the die, the per-character cost of a keystroke fell from 54.3 to 6.9 us - 7.9x - and a keystroke at a 160-character line from 25.56 ms to 15.36 ms. ## The shadow grid, and why it is static `src/p4.zig`'s `present` copied all 480 cells into vaxis every frame, which measured 6.75 ms on the die - 57% of a keystroke - and was paid whether or not anything changed: a second render with nothing new cost the same as the first. vaxis diffs its own grid, but only after being told every cell, and being told is the expensive part. So `present` now keeps the previous Surface and tells vaxis only what moved. `Cell.visuallyEqual` is the right comparison and already existed. Copy: 6.75 -> 1.45 ms. The grid lives in `.bss`, sized by `max_cols` x `max_rows` at comptime, and that is not a micro-optimisation. The first version allocated it from the editor's heap; on a board whose 384 KiB is nearly spoken for, that is exactly the kind of change that works and then breaks something else three steps away. `shadow_grid` is a comptime A/B switch, kept deliberately. With it false, `present` behaves as it did before - clear and write every cell - which is the reference any measurement should be compared against, and the way to tell a rendering bug from a rendering difference. It earned its keep immediately: the two paths were run against the same 19-step workload on the die - inserts, deletes, motions that move the modified-marker, a line outgrowing the viewport, backspaces that shrink it - and the reconstructed screens are byte-identical. ## Verification `snap` 95/95 scripts, `hxdiff` 481 cases 0 mismatches, `hxparity` 561 cases 0 mismatches, `unit-test`, `image-harness`, `pdf-harness`, `mupdf-check`, and tty / p4 / gui all build. The rendering changes are exactly the sort that pass a latency benchmark while corrupting a screen, so the snapshot parity suite is the one that matters here and it is unchanged. `test/perf.zig` gains `--cols`/`--rows`/`--only`. The screen's shape is one of the things that table exists to hold constant, and 40x12 is not a scaled guess at the board - it is the board. `--only` exists because under `perf record` one 63 ms cell on the largest fixture swamps every sample from the case being asked about. ## Found, not fixed `vx.resize` fails on this board: a runtime geometry change hits its allocation failure path, restores the previous size and returns, so 80 bytes go out where 1,392 should. Verified independent of everything above - it reproduces with `shadow_grid` false. The board therefore has one geometry for the life of a session, which is why the staleness test above compares two firmwares rather than resizing one.
* Bound the edit path's document scans, and find out they were never the problemGabriel Schneider2026-08-25
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The measurement said a keystroke costs 54 us per character already in the line. The source said why: `insertAt` called `lineCount` - `std.mem.count` over every byte - TWICE merely to clamp a row, and then `spliceAlloc` allocated and copied the whole document. Three whole-document passes before one character can be inserted, which fits a linear slope exactly. So it was fixed. `modal.lineSpan` finds a row's byte span in ONE scan that stops at that row, and `insertAt` uses it; a full count is paid only on the rare clamping path where the cursor is past the end. Two tests pin the equivalence, including the edges that make line counting awkward - an empty document, a trailing newline (its own empty last line), and a row past the end. The first version of `lineSpan` disagreed with `lineCount` about an empty document and the test caught it. On the host harness this is a real win, reproduced over three independent runs at matched sample counts: edit-char 1k lines 50k lines 300k lines before 730 us 2996 us 15030 us after 721 us 2414 us 10955 us ratio 0.98x 0.79-0.82x 0.78-0.83x Every operation I did not touch stayed at 1.00x, which is better evidence than any single cell. On the board it changed NOTHING. The slope was 54.3 us/char before and 54.0 after, a ratio of 1.00 over 5 conditions x 7 trials. Not a contradiction - the same fact seen twice. The removed passes are O(document), and this board's document is a few hundred BYTES, so two scans of it cost nothing worth measuring. ## Where the time actually goes `-Dprof` times the two phases on the die with the cycle counter around `pardes_p4_input` and `pardes_p4_render`: chars in line input (parse+edit) render 1 220 us 14804 us 80 212 us 17114 us 240 250 us 24615 us Input is FLAT at ~220 us - 1.5% of a keystroke - and does not grow with the document at all. The ~15 ms floor and every microsecond of the slope are inside `render`. The edit path could be made free and nobody would notice. `soc.flushFlashCache`'s home in soc.zig is what let the profiling build exist at all alongside the responder; `-Dprof` defaults off because it puts a line on the wire per frame, which is the resource being measured. ## The report `experiments/report.typ` gains Experiment 3 and, more importantly, a correction: Experiment 2's mechanism claim was wrong and now says so, with the disproof next to it. The ranked recommendations are reordered - the renderer is now #1 and the change this commit makes is listed unranked, because on this target it buys nothing, which is exactly why it is worth recording. The position table earns its place there too: in a fixed 320-character line, an insert at column 320 costs 33.9 ms and emits 28 bytes, while one at column 0 costs 26.0 ms and emits 81. Output size and latency are not merely uncorrelated on this board, they are inverted - which is the signature of a walk from the start of a line, and the next thing to go looking for. The lesson is the one the instrument exists to enforce. A plausible mechanism, read off the source and consistent with the shape of the data, was wrong about where the time went, and only a measurement inside the firmware could say so.
* 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
* fix tab rendering - waybe wrecklessGabriel Schneider2026-08-10
|
* replace ArrayLists with bounded storageGabriel Schneider2026-08-10
|
* separate modal parsing and reuse actions for PDF navigationGabriel Schneider2026-08-10
|
* motion, scrolling and redraw are flat in file size nowGabriel Schneider2026-08-01
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* helix diff testing and feature parityGabriel Schneider2026-08-01
|
* pardes v2: the rewrite, complete and organized. src/ (core + three shells), ↵Gabriel Schneider2026-08-01
test/ (snapshot parity harness + 18 frozen goldens). One sans-IO core, vaxis tty + SDL3 GPU native + wasm web shells, 18/18 parity with the purged prototype, 7.6k lines vs 12.1k. Fix: gui shell pre-sized the core at init so the greet-releasing resize never fired (blank panes until first interaction); live sessions now init at defaults and get the real grid as a resize event (the shell contract, documented on Options).